aegis/lib/eip712.js

182 lines
8.3 KiB
JavaScript
Raw Normal View History

// 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;
};
// Strict: a character that is not hex used to become a zero byte, so the
// overlay showed one value and the signature covered another.
const hex2bytes = (h) => {
const s = String(h).replace(/^0x/i, "");
if (s.length % 2) throw new Error("hex: odd length");
if (!/^[0-9a-fA-F]*$/.test(s)) throw new Error("hex: not a hex string");
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];
// A non-array used to sign as an empty array while the overlay
// showed the value itself.
if (!Array.isArray(value)) throw new Error(`${type} expects an array`);
if (arr[2] !== "" && value.length !== Number(arr[2])) throw new Error(`${type} expects ${arr[2]} items, got ${value.length}`);
const items = 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") {
// Validate before decoding: the hex decoder turns non-hex characters
// into zero bytes, so a malformed address would sign as a different one.
const s = String(value ?? "").replace(/^0x/i, "");
if (!/^[0-9a-fA-F]{40}$/.test(s)) throw new Error("address must be 20 bytes of hex");
const h = hex2bytes(s);
const out = new Uint8Array(32);
out.set(h, 12);
return out;
}
if (type === "bool") {
// Truthiness signed "false" (a string) as true while the overlay
// showed false. Only values that say what they mean are accepted.
let b;
if (value === true || value === 1 || value === "true" || value === "1") b = 1;
else if (value === false || value === 0 || value === "false" || value === "0") b = 0;
else throw new Error("bool expects true or false");
const out = new Uint8Array(32);
out[31] = b;
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.
// Range-checked against the declared width: 2^256 + 5 used to sign as
// 5, and a uint8 of 300 as a word no contract would decode.
const intOf = (v) => {
if (typeof v === "bigint") return v;
if (typeof v === "number") { if (!Number.isSafeInteger(v)) throw new Error("integer is not exact"); return BigInt(v); }
const t = String(v ?? "").trim();
if (!/^-?(0x[0-9a-fA-F]+|\d+)$/.test(t)) throw new Error(`not an integer: ${t.slice(0, 40)}`);
return t.startsWith("-") ? -BigInt(t.slice(1)) : BigInt(t);
};
const uintM = /^uint(\d*)$/.exec(type);
if (uintM) {
const bits = Number(uintM[1] || 256);
if (bits < 8 || bits > 256 || bits % 8) throw new Error("bad integer width " + type);
const v = intOf(value);
if (v < 0n || v >= (1n << BigInt(bits))) throw new Error(`${type} out of range`);
return bigToBe32(v, false);
}
const intM = /^int(\d*)$/.exec(type);
if (intM) {
const bits = Number(intM[1] || 256);
if (bits < 8 || bits > 256 || bits % 8) throw new Error("bad integer width " + type);
const v = intOf(value);
const lim = 1n << BigInt(bits - 1);
if (v < -lim || v >= lim) throw new Error(`${type} out of range`);
return bigToBe32(v, 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 };
};