// EIP-712 typed-data hashing (personal_sign's structured cousin). Produces // the 32-byte digest that eth_signTypedData_v4 signs with the wallet's // secp256k1 key. // // Reference: https://eips.ethereum.org/EIPS/eip-712 // Digest = keccak256(0x19 || 0x01 || domainSeparator || hashStruct(primaryType, message)) // - domainSeparator = hashStruct("EIP712Domain", typedData.domain) // - hashStruct(type, data) = keccak256(typeHash(type) || encodeData(type, data)) // - typeHash(type) = keccak256(encodeType(type)) // - encodeType is the canonical string form; sub-types are appended in // alphabetical order once, without recursion into themselves twice. // // This is enough for every mainstream EIP-712 payload — Permit / EIP-2612, // OpenSea order signatures, WalletConnect handshakes, Snapshot votes. Not // implemented: fixed-size arrays of atomic types wider than a byte (rare // enough that no shipping dapp we care about uses them). module.exports = function makeEip712({ keccak_256 }) { const enc = new TextEncoder(); const concat = (...ps) => { const n = ps.reduce((a, p) => a + p.length, 0); const out = new Uint8Array(n); let k = 0; for (const p of ps) { out.set(p, k); k += p.length; } return out; }; const hex2bytes = (h) => { const s = String(h).replace(/^0x/i, ""); if (s.length % 2) throw new Error("hex: odd length"); const out = new Uint8Array(s.length / 2); for (let i = 0; i < out.length; i++) out[i] = parseInt(s.slice(i * 2, i * 2 + 2), 16); return out; }; const bytesToBig = (b) => { let v = 0n; for (const x of b) v = (v << 8n) | BigInt(x); return v; }; const bigToBe32 = (v, signed) => { let n = BigInt(v); if (n < 0n) { if (!signed) throw new Error("negative value for unsigned type"); // two's complement to 256 bits n = (1n << 256n) + n; } const out = new Uint8Array(32); for (let i = 31; i >= 0; i--) { out[i] = Number(n & 0xffn); n >>= 8n; } return out; }; // encodeType walker — resolves the primary type + every struct it // transitively references, then emits "Primary(...)Sub1(...)Sub2(...)" // with sub-types in alphabetical order per the spec. function findDependencies(primaryType, types, found = new Set()) { if (found.has(primaryType) || !types[primaryType]) return found; found.add(primaryType); for (const f of types[primaryType]) { const base = f.type.replace(/\[.*\]$/, ""); if (types[base]) findDependencies(base, types, found); } return found; } function encodeType(primaryType, types) { const deps = [...findDependencies(primaryType, types)].filter((t) => t !== primaryType).sort(); const all = [primaryType, ...deps]; return all.map((t) => `${t}(${types[t].map((f) => `${f.type} ${f.name}`).join(",")})`).join(""); } function typeHash(primaryType, types) { return keccak_256(enc.encode(encodeType(primaryType, types))); } // Encode one field value per its declared type. Struct + array types // hash themselves to 32 bytes; atomics land in a 32-byte slot each. function encodeValue(type, value, types) { // Array types: `Type[]` (dynamic) or `Type[N]` (fixed) — both encode // as keccak256(concat(encodeValue(baseType, element)...)) per EIP-712. const arr = /^(.+)\[(\d*)\]$/.exec(type); if (arr) { const baseType = arr[1]; const items = Array.isArray(value) ? value : []; const encoded = items.map((v) => encodeValue(baseType, v, types)); return keccak_256(concat(...encoded)); } // Struct types: hashStruct recursion. if (types[type]) return hashStruct(type, value, types); // Atomic types. if (type === "string") return keccak_256(enc.encode(String(value ?? ""))); if (type === "bytes") { const b = typeof value === "string" ? hex2bytes(value) : Uint8Array.from(value || []); return keccak_256(b); } if (type === "address") { const h = hex2bytes(String(value || "0x0").replace(/^0x/, "")); if (h.length !== 20) throw new Error("address must be 20 bytes"); const out = new Uint8Array(32); out.set(h, 12); return out; } if (type === "bool") { const out = new Uint8Array(32); out[31] = value ? 1 : 0; return out; } // bytesN (fixed): left-aligned in a 32-byte word. const bytesN = /^bytes(\d+)$/.exec(type); if (bytesN) { const n = Number(bytesN[1]); if (n < 1 || n > 32) throw new Error("bytesN out of range"); const b = typeof value === "string" ? hex2bytes(value) : Uint8Array.from(value || []); if (b.length !== n) throw new Error(`${type} expects ${n} bytes, got ${b.length}`); const out = new Uint8Array(32); out.set(b, 0); return out; } // uint* / int*: encode as 32-byte big-endian. const uintM = /^uint(\d*)$/.exec(type); if (uintM) return bigToBe32(value, false); const intM = /^int(\d*)$/.exec(type); if (intM) return bigToBe32(value, true); throw new Error("unsupported EIP-712 type: " + type); } function encodeData(primaryType, data, types) { const fields = types[primaryType]; if (!fields) throw new Error("unknown type: " + primaryType); const encoded = fields.map((f) => encodeValue(f.type, data ? data[f.name] : undefined, types)); return concat(...encoded); } function hashStruct(primaryType, data, types) { return keccak_256(concat(typeHash(primaryType, types), encodeData(primaryType, data, types))); } // Full EIP-712 digest, ready for secp256k1.sign(digest, key). function digest(typedData) { const td = typedData && typeof typedData === "object" ? typedData : {}; const types = td.types || {}; if (!types.EIP712Domain) throw new Error("typedData.types.EIP712Domain missing"); const primary = String(td.primaryType || ""); if (!primary || !types[primary]) throw new Error(`typedData.primaryType "${primary}" not in types`); const domainSeparator = hashStruct("EIP712Domain", td.domain || {}, types); const messageHash = hashStruct(primary, td.message || {}, types); return keccak_256(concat(Uint8Array.from([0x19, 0x01]), domainSeparator, messageHash)); } return { digest, encodeType, typeHash, hashStruct }; };