Distributed Systems & CryptographyOctober 22, 2026

Building an EIP 712 Permit2 Offchain Quorum Generator for DeFi Receipts

C

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:

  1. 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.
  2. 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 authorizes
  • spender: who can spend
  • token: ERC-20 being approved
  • amount: allowance amount
  • nonce: replay protection
  • deadline: expiry time
  • chainId: network identifier
  • verifyingContract: the Permit2 contract address
  • signature: optional for the generator demo

The receipt generator focuses on producing:

  • typedDataDigest: the canonical EIP-712 hash
  • buildProofs: deterministic digests from multiple independent serializations

Working code: receipt generator in TypeScript

Below is a self-contained Node.js script that:

  1. Creates EIP-712 typed data for a Permit2-like approval.
  2. Computes the digest using @metamask/eth-sig-util.
  3. Rebuilds typed data in an independently ordered way and recomputes the digest to simulate an offchain quorum check.
  4. 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:

  • typedDataDigest becomes your anchor
  • message and domain provide full reproducibility
  • buildProofs provide 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.