Building an EIP 712 Permit2 Offchain Quorum Generator for DeFi Receipts
Written by
Cipher Stone
I got obsessed with one very specific problem after watching an on-chain approval flow go sideways: how can a DeFi app prove, cryptographically and with redundancy, which exact user intents were signed—without trusting a single RPC node, UI session, or local cache?
What I ended up building is a tiny “receipt generator” that takes EIP-712 typed data (the human-readable structured data that wallets sign) used in Permit2-style approvals and produces an offchain quorum bundle: multiple independently fetched typed-data digests plus a deterministic signature package. The result is a provenance artifact I can later compare against what actually got executed on-chain.
This sits in a niche corner of DeFi trust modeling: digital provenance for approvals, not just swaps—focused on receipt integrity.
The threat model I cared about
There are two “quiet” failure modes that are hard to debug:
- Typed-data mismatch: a UI or backend constructs the Permit2 payload slightly differently than what the wallet signed (wrong nonce, chainId, spender, etc.). The signature is valid, but not for what you think it approves.
- Single-source inconsistency: one backend instance or RPC endpoint constructs or decodes data differently. You end up with receipts that are unreproducible.
So I built a deterministic generator that:
- Builds the EIP-712 typed-data object for Permit2-like approvals.
- Computes the EIP-712 digest locally.
- Computes the same digest again from independently serialized typed-data.
- Packs everything into a receipt JSON that can be cross-checked.
What “EIP-712 digest” means (briefly)
EIP-712 is a standard way to sign structured data. For a given typed-data payload, wallets derive a message digest using a canonical encoding scheme.
The important part for receipts: if two systems produce the same payload (same fields, same types), they must derive the same digest. That gives us a strong integrity anchor.
The niche scope: Permit2-style approvals receipt (offchain quorum)
I modelled a Permit2-like request with these fields:
owner: who authorizesspender: who can spendtoken: ERC-20 being approvedamount: allowance amountnonce: replay protectiondeadline: expiry timechainId: network identifierverifyingContract: the Permit2 contract addresssignature: optional for the generator demo
The receipt generator focuses on producing:
typedDataDigest: the canonical EIP-712 hashbuildProofs: deterministic digests from multiple independent serializations
Working code: receipt generator in TypeScript
Below is a self-contained Node.js script that:
- Creates EIP-712 typed data for a Permit2-like approval.
- Computes the digest using
@metamask/eth-sig-util. - Rebuilds typed data in an independently ordered way and recomputes the digest to simulate an offchain quorum check.
- Outputs a JSON receipt.
Install dependencies
npm init -y npm i @metamask/eth-sig-util ethereumjs-util
The generator
// permit2_receipt_generator.ts import { TypedDataUtils } from "@metamask/eth-sig-util"; import { bufferToHex } from "ethereumjs-util"; type Domain = { name: string; version: string; chainId: number; verifyingContract: string; }; type PermitLikeMessage = { owner: string; spender: string; token: string; amount: string; // use string to preserve bigints in JSON nonce: string; // string for same reason deadline: string; // string for same reason }; type TypedData = { domain: Domain; types: Record<string, Array<{ name: string; type: string }>>; primaryType: string; message: PermitLikeMessage; }; function buildPermitLikeTypedData( domain: Domain, message: PermitLikeMessage ): TypedData { // This is the key: the "types" definition must exactly match what wallets sign. return { domain, primaryType: "PermitLike", types: { EIP712Domain: [ { name: "name", type: "string" }, { name: "version", type: "string" }, { name: "chainId", type: "uint256" }, { name: "verifyingContract", type: "address" }, ], PermitLike: [ { name: "owner", type: "address" }, { name: "spender", type: "address" }, { name: "token", type: "address" }, { name: "amount", type: "uint256" }, { name: "nonce", type: "uint256" }, { name: "deadline", type: "uint256" }, ], }, message, }; } function buildReceipt( domain: Domain, message: PermitLikeMessage ) { // Build typed data in one canonical way const typedA = buildPermitLikeTypedData(domain, message); // Rebuild typed data with the same logical fields, but created in a different order. // In a real system this could correspond to different backend instances or serializers. const shuffledMessage: PermitLikeMessage = { deadline: message.deadline, nonce: message.nonce, token: message.token, amount: message.amount, spender: message.spender, owner: message.owner, }; const typedB = buildPermitLikeTypedData(domain, shuffledMessage); // Compute EIP-712 digests. TypedDataUtils.eip712Hash returns the digest used for signing. const digestA = TypedDataUtils.eip712Hash(typedA).toString("hex"); const digestB = TypedDataUtils.eip712Hash(typedB).toString("hex"); return { meta: { generator: "permit2-style offchain quorum receipt", algorithm: "EIP712Hash", }, domain, message, quorum: { buildProofs: [ { source: "typedA", typedDataDigest: bufferToHex(Buffer.from(digestA, "hex")) }, { source: "typedB", typedDataDigest: bufferToHex(Buffer.from(digestB, "hex")) }, ], // If these match, we gained confidence that the digest is deterministic for the payload. // If they differ, something is inconsistent in typing, domain, or field encoding. quorumSatisfied: digestA === digestB, }, typedDataDigest: bufferToHex(Buffer.from(digestA, "hex")), primaryType: typedA.primaryType, }; } async function main() { // Example inputs (use real addresses in your app) const domain: Domain = { name: "Permit2", version: "1", chainId: 1, verifyingContract: "0x000000000022D473030F116dDEE9F6B43aC78BA3", // Uniswap Permit2 mainnet address (common example) }; const message: PermitLikeMessage = { owner: "0x1111111111111111111111111111111111111111", spender: "0x2222222222222222222222222222222222222222", token: "0x3333333333333333333333333333333333333333", amount: "1000000000000000000", nonce: "7", deadline: "1735689600", // example unix timestamp }; const receipt = buildReceipt(domain, message); console.log(JSON.stringify(receipt, null, 2)); } main().catch((e) => { console.error(e); process.exit(1); });
Run it:
npx ts-node permit2_receipt_generator.ts
What to look for in output
The critical field is:
quorum.quorumSatisfied
It will be true if both digest computations match. If you accidentally change a field type (like making amount a string but typing it as int256) or modify domain values (chainId/verifyingContract/name/version), you’ll get false and the digests will differ.
That mismatch is exactly the class of “quiet” failure I wanted receipts to catch early.
Simulating a real-world mismatch (why this matters)
Here’s what happens when you make a typical error: changing domain.version from "1" to "2" in one builder instance but not the other.
- The signature would still be a valid EIP-712 signature for the other payload.
- Your receipt would reveal the digest mismatch deterministically.
- You can now trace exactly which component diverged, without guessing.
In code, change:
version: "1"
to:
version: "2"
and quorumSatisfied flips to false immediately.
Turning this into a provenance artifact
The receipt JSON is designed to be stored alongside an execution record:
typedDataDigestbecomes your anchormessageanddomainprovide full reproducibilitybuildProofsprovide offchain quorum evidence of determinism
In a DeFi provenance pipeline, I treat this like an “approval receipt” comparable to transaction hashes—but specific to the intent that led to the on-chain state transition.
Conclusion
I built an offchain quorum generator that produces deterministic EIP-712 digests for Permit2-style approvals and packs them into a verifiable provenance receipt. The key takeaway from the weekend of tinkering: by treating typed-data digests as first-class artifacts, I can detect typed-data mismatch and single-source inconsistency before they turn into confusing execution bugs.