// shutter-crypto.js // // REAL threshold-BLS (BLS12-381) helpers for AereShutterMempoolV2, the // application-level Shutter-style anti-MEV encrypted mempool PoC on AERE // chain 2800. Uses @noble/curves (already in the repo node_modules). // // Scheme (Boldyreva threshold BLS + hashed-ElGamal encryption): // - Committee master secret s, Shamir-shared (t,N) over the scalar field Fr. // - Group public key PK = s * g2 (G2, the "encryption pubkey"). // - Keyper pubkeys P_i = s_i * g2 (G2, published VSS commitments). // - Epoch identity H1 = hashToCurve(epoch tag) in G1. // - Epoch decrypt key DK = s * H1 (G1, threshold signature). // - Keyper share sig_i = s_i * H1 (G1). // - DK reconstruction DK = sum_{i in S} lambda_i * sig_i (Lagrange at 0). // // On-chain the pairing precompile (EIP-2537, addr 0x0f, VERIFIED live on 2800) // checks: // share valid : e(sig_i, g2) == e(H1, P_i) <=> e(sig_i,g2)*e(-H1,P_i)==1 // DK valid : e(DK, g2) == e(H1, PK) <=> e(DK, g2)*e(-H1,PK )==1 // // Encryption (hashed-ElGamal to the epoch), decryptable only with DK: // gt = e(H1, PK) in GT ; pick random r ; U = r*g2 ; // mask = keccak(gt^r) ; C = plaintext XOR mask ; ciphertext = (U, C). // Decrypt: gt^r = e(DK, U) ; mask = keccak(e(DK,U)) ; plaintext = C XOR mask. // (The GT->bytes masking is off-chain; on-chain we bind the plaintext to a // keccak commitment. DK correctness itself is pairing-verified on-chain.) const { bls12_381 } = require("@noble/curves/bls12-381"); const { keccak_256 } = require("@noble/hashes/sha3"); const { randomBytes } = require("crypto"); const G1 = bls12_381.G1.ProjectivePoint; const G2 = bls12_381.G2.ProjectivePoint; const Fr = bls12_381.fields.Fr; const Fp2 = bls12_381.fields.Fp2; const Fp12 = bls12_381.fields.Fp12; const R = Fr.ORDER; const DST = "AERE-SHUTTER-V2-BLS12381G1-XMD:KECCAK-256_SSWU_RO_"; // documented epoch-identity DST /* ----------------------------- serialization ----------------------------- */ function fpTo64Hex(x) { // 48-byte big-endian field element, left-padded to 64 bytes (EIP-2537). const h = x.toString(16).padStart(96, "0"); return "00".repeat(16) + h; } function g1Hex(P) { const a = P.toAffine(); return "0x" + fpTo64Hex(a.x) + fpTo64Hex(a.y); // 128 bytes } function g2Hex(P) { const a = P.toAffine(); // EIP-2537 Fp2 ordering: (c0, c1) for x then y. return ( "0x" + fpTo64Hex(a.x.c0) + fpTo64Hex(a.x.c1) + fpTo64Hex(a.y.c0) + fpTo64Hex(a.y.c1) ); // 256 bytes } // EIP-2537 -> noble point parsers (64-byte fields, last 48 bytes are the value) function chunkVal(chunk) { return BigInt("0x" + chunk.slice(32)); } function eip2537ToG1(h) { h = h.startsWith("0x") ? h.slice(2) : h; return G1.fromAffine({ x: chunkVal(h.slice(0, 128)), y: chunkVal(h.slice(128, 256)) }); } function eip2537ToG2(h) { h = h.startsWith("0x") ? h.slice(2) : h; const xc0 = chunkVal(h.slice(0, 128)), xc1 = chunkVal(h.slice(128, 256)); const yc0 = chunkVal(h.slice(256, 384)), yc1 = chunkVal(h.slice(384, 512)); return G2.fromAffine({ x: Fp2.fromBigTuple([xc0, xc1]), y: Fp2.fromBigTuple([yc0, yc1]) }); } function randScalar() { // uniform-ish nonzero scalar in [1, R-1] let x = 0n; while (x === 0n) x = BigInt("0x" + randomBytes(48).toString("hex")) % R; return x; } /* --------------------------- polynomial / shamir -------------------------- */ function evalPoly(coeffs, x) { // Horner in Fr let acc = 0n; for (let i = coeffs.length - 1; i >= 0; i--) acc = Fr.add(Fr.mul(acc, x), coeffs[i]); return acc; } // Lagrange coefficient lambda_i for interpolation AT 0 over index set S (1-based x = index). function lagrangeAtZero(indices, i) { let num = 1n, den = 1n; const xi = BigInt(i); for (const j of indices) { if (j === i) continue; const xj = BigInt(j); num = Fr.mul(num, Fr.sub(0n, xj)); // (0 - xj) den = Fr.mul(den, Fr.sub(xi, xj)); // (xi - xj) } return Fr.mul(num, Fr.inv(den)); } /* ------------------------------ committee setup --------------------------- */ // Trusted-dealer Shamir setup for the PoC. Returns committee material. function setupCommittee(t, N) { const coeffs = [randScalar()]; // a0 = master secret s for (let k = 1; k < t; k++) coeffs.push(randScalar()); const s = coeffs[0]; const shares = []; // {index, secret} const pubkeys = []; // P_i hex (G2), index-aligned to keypers[i] (i=0..N-1 -> x=i+1) for (let i = 1; i <= N; i++) { const si = evalPoly(coeffs, BigInt(i)); shares.push({ index: i, secret: si }); pubkeys.push(g2Hex(G2.BASE.multiply(si))); } const PKpoint = G2.BASE.multiply(s); const PK = g2Hex(PKpoint); // group pubkey (encryption key) return { t, N, s, shares, pubkeys, PK, PKpoint, g2Gen: g2Hex(G2.BASE) }; } /* ------------------------------- epoch identity --------------------------- */ function epochIdentity(epochId) { const tag = Buffer.from(`AERE-SHUTTER-EPOCH:${epochId}`, "utf8"); const H1 = bls12_381.G1.hashToCurve(tag, { DST }); // RFC9380 hash-to-curve, cofactor-cleared const P = G1.fromHex(H1.toHex(true)); // normalize to ProjectivePoint return { H1: g1Hex(P), H1neg: g1Hex(P.negate()), point: P }; } /* ------------------------------- shares / DK ------------------------------ */ function makeShare(secret_si, epochPoint) { return g1Hex(epochPoint.multiply(secret_si)); // sig_i = s_i * H1 (G1) } function reconstructDK(epochPoint, committee, subsetIndices) { // subsetIndices are 1-based keyper x-coords (length >= t) let acc = G1.ZERO; for (const i of subsetIndices) { const share = committee.shares.find((s) => s.index === i); const lam = lagrangeAtZero(subsetIndices, i); const sig_i = epochPoint.multiply(share.secret); // = s_i*H1 acc = acc.add(sig_i.multiply(lam)); } return { DK: g1Hex(acc), point: acc }; // should equal s*H1 } /* ------------------------------- encryption ------------------------------- */ function fp12Bytes(z) { // deterministic flatten of an Fp12 element to bytes (12 * 48-byte coords). const flat = [ z.c0.c0.c0, z.c0.c0.c1, z.c0.c1.c0, z.c0.c1.c1, z.c0.c2.c0, z.c0.c2.c1, z.c1.c0.c0, z.c1.c0.c1, z.c1.c1.c0, z.c1.c1.c1, z.c1.c2.c0, z.c1.c2.c1, ]; return Buffer.concat(flat.map((x) => Buffer.from(x.toString(16).padStart(96, "0"), "hex"))); } function maskFromGT(gt, len) { // expand keccak(gt-bytes || counter) to len bytes const base = fp12Bytes(gt); const out = Buffer.alloc(len); let off = 0, ctr = 0; while (off < len) { const blk = Buffer.from(keccak_256(Buffer.concat([base, Buffer.from([ctr & 0xff])]))); const n = Math.min(32, len - off); blk.copy(out, off, 0, n); off += n; ctr++; } return out; } // Encrypt plaintext (Buffer) to the epoch. Returns ciphertext bytes = U(256) || C(len). function encrypt(committee, epochId, plaintext) { const { point: H1 } = epochIdentity(epochId); const PKpt = committee.PKpoint || eip2537ToG2(committee.PK); const r = randScalar(); const U = G2.BASE.multiply(r); // ephemeral const gt = bls12_381.pairing(H1, PKpt); // e(H1, PK) const gtr = Fp12.pow(gt, r); // gt^r const mask = maskFromGT(gtr, plaintext.length); const C = Buffer.from(plaintext.map((b, i) => b ^ mask[i])); const Ubuf = Buffer.from(g2Hex(U).slice(2), "hex"); return { ciphertext: "0x" + Buffer.concat([Ubuf, C]).toString("hex"), U: g2Hex(U) }; } // Decrypt with reconstructed DK point. ciphertext hex -> plaintext Buffer. function decrypt(dkPoint, ciphertextHex) { const buf = Buffer.from(ciphertextHex.slice(2), "hex"); const Ubuf = buf.subarray(0, 256); const C = buf.subarray(256); const U = eip2537ToG2(Ubuf.toString("hex")); const gtr = bls12_381.pairing(dkPoint, U); // e(DK, U) == gt^r const mask = maskFromGT(gtr, C.length); return Buffer.from(C.map((b, i) => b ^ mask[i])); } /* ----------------------------- commit / merkle ---------------------------- */ const { AbiCoder, keccak256, solidityPacked } = require("ethers"); const abi = AbiCoder.defaultAbiCoder(); function commitmentOf(plaintextHex, openingHex) { return keccak256(abi.encode(["bytes", "bytes32"], [plaintextHex, openingHex])); } // Merkle over ordered positions. leaf = keccak(bytes.concat(keccak(abi.encode(epochId,pos,commitment)))). function leafOf(epochId, pos, commitment) { const inner = keccak256(abi.encode(["uint64", "uint256", "bytes32"], [epochId, pos, commitment])); return keccak256(solidityPacked(["bytes32"], [inner])); } function hashPair(a, b) { const [x, y] = a.toLowerCase() <= b.toLowerCase() ? [a, b] : [b, a]; // OZ sorted pairs return keccak256(solidityPacked(["bytes32", "bytes32"], [x, y])); } // Build an OZ-compatible sorted-pair Merkle tree; returns {root, proofs[]}. function buildMerkle(leaves) { if (leaves.length === 1) return { root: leaves[0], proofs: [[]] }; let layer = leaves.slice(); const layers = [layer]; while (layer.length > 1) { const next = []; for (let i = 0; i < layer.length; i += 2) { if (i + 1 === layer.length) next.push(layer[i]); else next.push(hashPair(layer[i], layer[i + 1])); } layer = next; layers.push(layer); } const root = layers[layers.length - 1][0]; const proofs = leaves.map((_, idx) => { const proof = []; let index = idx; for (let l = 0; l < layers.length - 1; l++) { const cur = layers[l]; const pair = index ^ 1; if (pair < cur.length) proof.push(cur[pair]); index = Math.floor(index / 2); } return proof; }); return { root, proofs }; } module.exports = { G1, G2, Fr, DST, fpTo64Hex, g1Hex, g2Hex, randScalar, eip2537ToG1, eip2537ToG2, setupCommittee, epochIdentity, makeShare, reconstructDK, encrypt, decrypt, commitmentOf, leafOf, buildMerkle, };