aere-contracts/test/AereThresholdAccount.test.js
Aere Network a13a649b77
Some checks are pending
contracts-ci / Install (lockfile) → compile → full test suite (push) Waiting to run
contracts-ci / PQC known-answer tests (NIST vectors) (push) Waiting to run
contracts-ci / Coverage (scoped, with artifacts) (push) Waiting to run
Initial public release
Aere Network public source. Everything here can be checked against the live
chain (chain id 2800, https://rpc.aere.network).

Scope note, stated up front rather than buried: consensus on chain 2800 is
classical secp256k1 ECDSA QBFT. The post-quantum work in this repository is at
the signature, precompile, account and transport layers. Nothing here makes the
consensus post-quantum, and no document in it should be read as claiming so.
2026-07-20 01:02:37 +03:00

335 lines
16 KiB
JavaScript

// AereThresholdAccount — a non-custodial ERC-4337 account owned by a t-of-n post-quantum
// committee. Every authorizing leg is verified on-chain by the scheme's PQC precompile
// (0x0AE1..0x0AE4), modelled here by the repo's faithful MockPQCPrecompile installed via
// hardhat_setCode (accepts iff commitment == keccak256(pk || message)). These tests exercise
// the account's threshold logic, domain separation, and replay barriers — NOT the crypto
// primitive (that is proven byte-for-byte against the live precompile elsewhere).
const { expect } = require("chai");
const { ethers, network } = require("hardhat");
const PRECOMPILE = {
1: ethers.getAddress("0x0000000000000000000000000000000000000ae1"),
2: ethers.getAddress("0x0000000000000000000000000000000000000ae2"),
3: ethers.getAddress("0x0000000000000000000000000000000000000ae3"),
4: ethers.getAddress("0x0000000000000000000000000000000000000ae4"),
};
const META = {
1: { pkLen: 897, pkHeader: 0x09, esigHeader: 0x29, falcon: true },
2: { pkLen: 1793, pkHeader: 0x0a, esigHeader: 0x2a, falcon: true },
3: { pkLen: 1312, sigLen: 2420, falcon: false },
4: { pkLen: 32, sigLen: 7856, falcon: false },
};
function makeRng(seedText) {
let seed = ethers.keccak256(ethers.toUtf8Bytes(seedText));
return {
bytes(n) {
const out = new Uint8Array(n);
let i = 0;
while (i < n) {
seed = ethers.keccak256(seed);
const chunk = ethers.getBytes(seed);
for (let j = 0; j < chunk.length && i < n; j++, i++) out[i] = chunk[j];
}
return out;
},
};
}
function makePubKey(scheme, rng) {
const m = META[scheme];
const raw = rng.bytes(m.pkLen);
if (m.falcon) raw[0] = m.pkHeader;
return ethers.hexlify(raw);
}
function commitmentFor(pubKey, message) {
return ethers.keccak256(ethers.concat([pubKey, message]));
}
function envelope(scheme, commitment, rng) {
const m = META[scheme];
if (m.falcon) {
const nonce = rng.bytes(40);
const esig = ethers.concat([new Uint8Array([m.esigHeader]), commitment]);
return ethers.hexlify(ethers.concat([nonce, esig]));
} else {
const sig = new Uint8Array(m.sigLen);
sig.set(ethers.getBytes(commitment), 0);
return ethers.hexlify(sig);
}
}
function genuine(scheme, pubKey, message, rng) {
return envelope(scheme, commitmentFor(pubKey, message), rng);
}
function tamperCommitment(scheme, sigHex) {
const b = ethers.getBytes(sigHex);
b[META[scheme].falcon ? 41 : 0] ^= 0x01;
return ethers.hexlify(b);
}
// abi.encode(Leg[]) for the ERC-4337 signature blob. Leg = {uint8 memberIndex; bytes signature}.
function encodeLegs(legs) {
return ethers.AbiCoder.defaultAbiCoder().encode(
["tuple(uint8 memberIndex, bytes signature)[]"],
[legs.map((l) => [l.idx, l.sig])]
);
}
function packUserOp(sender, sigBlob) {
return {
sender,
nonce: 0n,
initCode: "0x",
callData: "0x",
accountGasLimits: ethers.ZeroHash,
preVerificationGas: 0n,
gasFees: ethers.ZeroHash,
paymasterAndData: "0x",
signature: sigBlob,
};
}
const SCHEME = 1; // Falcon-512
const N = 5;
const T = 3;
describe("AereThresholdAccount (non-custodial post-quantum t-of-n ERC-4337 account)", function () {
let mockCode, factory, account, entryPoint, other, echo, pubKeys, rng;
before(async function () {
mockCode = {};
const F = await ethers.getContractFactory("MockPQCPrecompile");
for (const s of [1, 2, 3, 4]) {
const m = await F.deploy(s);
await m.waitForDeployment();
mockCode[s] = await ethers.provider.getCode(await m.getAddress());
}
});
beforeEach(async function () {
for (const s of [1, 2, 3, 4]) {
await network.provider.send("hardhat_setCode", [PRECOMPILE[s], mockCode[s]]);
}
[entryPoint, other] = await ethers.getSigners();
rng = makeRng("threshold-account-suite");
pubKeys = [];
for (let i = 0; i < N; i++) pubKeys.push(makePubKey(SCHEME, rng));
const Fac = await ethers.getContractFactory("AereThresholdAccountFactory");
factory = await Fac.deploy();
await factory.waitForDeployment();
const salt = ethers.id("acct-1");
const predicted = await factory.computeAddress(entryPoint.address, SCHEME, T, pubKeys, salt);
await (await factory.createAccount(entryPoint.address, SCHEME, T, pubKeys, salt)).wait();
account = await ethers.getContractAt("AereThresholdAccount", predicted);
const Echo = await ethers.getContractFactory("EchoTarget");
echo = await Echo.deploy();
await echo.waitForDeployment();
});
// ── committee wiring ──────────────────────────────────────────────────────
it("factory deploys to the deterministic counterfactual address and wires the committee", async function () {
const [scheme, threshold, size] = await account.committee();
expect(scheme).to.equal(SCHEME);
expect(threshold).to.equal(T);
expect(size).to.equal(N);
expect(await account.entryPoint()).to.equal(entryPoint.address);
expect(await account.membersHash()).to.equal(
ethers.keccak256(ethers.AbiCoder.defaultAbiCoder().encode(["bytes[]"], [pubKeys]))
);
});
it("rejects a committee with duplicate public keys (distinctness must be by KEY, not index)", async function () {
// Dedup at authorization time is by member INDEX (the `seen` bitmask). If one key occupied two
// indices, a single keyholder could fill multiple "distinct member" slots and reach threshold
// alone, collapsing the t-of-n guarantee. initialize must reject any repeated key.
const dupKeys = [];
for (let i = 0; i < N; i++) dupKeys.push(makePubKey(SCHEME, rng));
dupKeys[3] = dupKeys[1]; // member 3 reuses member 1's key
const salt = ethers.id("acct-dup");
await expect(
factory.createAccount(entryPoint.address, SCHEME, T, dupKeys, salt)
).to.be.revertedWithCustomError(account, "DuplicatePubKey").withArgs(3);
});
// ── SDK <-> contract parity ───────────────────────────────────────────────
it("SDK challenge derivations match the contract byte-for-byte", async function () {
// These are the EXACT derivations the TypeScript AereThresholdAccountClient uses; asserting
// equality with the on-chain views proves SDK<->contract parity (no drift).
const USEROP_DOMAIN = ethers.keccak256(ethers.toUtf8Bytes("AereThresholdAccount.v1.userop"));
const EXEC_DOMAIN = ethers.keccak256(ethers.toUtf8Bytes("AereThresholdAccount.v1.exec"));
const coder = ethers.AbiCoder.defaultAbiCoder();
const acct = await account.getAddress();
const chainId = (await ethers.provider.getNetwork()).chainId;
const userOpHash = ethers.id("parity-op");
const sdkUserOp = ethers.keccak256(
coder.encode(["bytes32", "uint256", "address", "bytes32"], [USEROP_DOMAIN, chainId, acct, userOpHash])
);
expect(sdkUserOp).to.equal(await account.userOpChallenge(userOpHash));
const target = await echo.getAddress();
const data = echo.interface.encodeFunctionData("bump");
const sdkExec = ethers.keccak256(
coder.encode(
["bytes32", "uint256", "address", "uint64", "address", "uint256", "bytes32"],
[EXEC_DOMAIN, chainId, acct, 0, target, 0, ethers.keccak256(data)]
)
);
expect(sdkExec).to.equal(await account.execChallenge(0, target, 0, data));
});
// ── ERC-4337 path ─────────────────────────────────────────────────────────
async function validate(legs, userOpHash) {
const blob = encodeLegs(legs);
const op = packUserOp(await account.getAddress(), blob);
return account.connect(entryPoint).validateUserOp.staticCall(op, userOpHash, 0);
}
it("validateUserOp returns 0 with >= t distinct valid signers over the userOp challenge", async function () {
const userOpHash = ethers.id("op-A");
const challenge = await account.userOpChallenge(userOpHash);
const legs = [0, 2, 4].map((idx) => ({ idx, sig: genuine(SCHEME, pubKeys[idx], challenge, rng) }));
expect(await validate(legs, userOpHash)).to.equal(0n);
});
it("validateUserOp returns SIG_VALIDATION_FAILED below threshold", async function () {
const userOpHash = ethers.id("op-B");
const challenge = await account.userOpChallenge(userOpHash);
const legs = [0, 1].map((idx) => ({ idx, sig: genuine(SCHEME, pubKeys[idx], challenge, rng) })); // only 2
expect(await validate(legs, userOpHash)).to.equal(1n);
});
it("AUD-CONTRACT-1: a non-decodable signature blob returns SIG_VALIDATION_FAILED (never reverts)", async function () {
const userOpHash = ethers.id("op-nondecodable");
// Blobs that make abi.decode(sigBlob, (Leg[])) revert: empty, a single byte, and a truncated
// dynamic header. All must come back as SIG_VALIDATION_FAILED (1), not a bubbled revert.
for (const badBlob of ["0x", "0x00", "0x" + "00".repeat(31)]) {
const op = packUserOp(await account.getAddress(), badBlob);
expect(await account.connect(entryPoint).validateUserOp.staticCall(op, userOpHash, 0)).to.equal(1n);
}
});
it("never double-counts a duplicated member", async function () {
const userOpHash = ethers.id("op-C");
const challenge = await account.userOpChallenge(userOpHash);
// idx 0 twice + idx 1 => only 2 DISTINCT valid signers, below t=3
const s0 = genuine(SCHEME, pubKeys[0], challenge, rng);
const s1 = genuine(SCHEME, pubKeys[1], challenge, rng);
const legs = [{ idx: 0, sig: s0 }, { idx: 0, sig: s0 }, { idx: 1, sig: s1 }];
expect(await validate(legs, userOpHash)).to.equal(1n);
});
it("rejects a tampered signature leg (does not count it)", async function () {
const userOpHash = ethers.id("op-D");
const challenge = await account.userOpChallenge(userOpHash);
const legs = [
{ idx: 0, sig: genuine(SCHEME, pubKeys[0], challenge, rng) },
{ idx: 2, sig: genuine(SCHEME, pubKeys[2], challenge, rng) },
{ idx: 4, sig: tamperCommitment(SCHEME, genuine(SCHEME, pubKeys[4], challenge, rng)) }, // forged
];
expect(await validate(legs, userOpHash)).to.equal(1n); // 2 valid < 3
});
it("domain separation: signatures made for the exec path do NOT authorize a userOp", async function () {
const userOpHash = ethers.id("op-E");
const execChal = await account.execChallenge(0, await echo.getAddress(), 0, echo.interface.encodeFunctionData("bump"));
// members sign the EXEC challenge, but we submit them on the USEROP path
const legs = [0, 2, 4].map((idx) => ({ idx, sig: genuine(SCHEME, pubKeys[idx], execChal, rng) }));
expect(await validate(legs, userOpHash)).to.equal(1n);
});
it("only the EntryPoint may call validateUserOp", async function () {
const userOpHash = ethers.id("op-F");
const challenge = await account.userOpChallenge(userOpHash);
const legs = [0, 2, 4].map((idx) => ({ idx, sig: genuine(SCHEME, pubKeys[idx], challenge, rng) }));
const op = packUserOp(await account.getAddress(), encodeLegs(legs));
await expect(account.connect(other).validateUserOp.staticCall(op, userOpHash, 0)).to.be.revertedWithCustomError(
account,
"Unauthorized"
);
});
// ── direct self-relay path ────────────────────────────────────────────────
it("executeThreshold dispatches the call with >= t signers and advances execNonce", async function () {
const target = await echo.getAddress();
const data = echo.interface.encodeFunctionData("bump");
const challenge = await account.execChallenge(0, target, 0, data);
const legs = [1, 2, 3].map((idx) => [idx, genuine(SCHEME, pubKeys[idx], challenge, rng)]);
await (await account.executeThreshold(target, 0, data, legs)).wait();
expect(await echo.calls()).to.equal(1n);
expect(await echo.lastCaller()).to.equal(await account.getAddress());
expect(await account.execNonce()).to.equal(1n);
});
it("executeThreshold rejects a replay (execNonce advanced => challenge changed)", async function () {
const target = await echo.getAddress();
const data = echo.interface.encodeFunctionData("bump");
const challenge0 = await account.execChallenge(0, target, 0, data);
const legs0 = [1, 2, 3].map((idx) => [idx, genuine(SCHEME, pubKeys[idx], challenge0, rng)]);
await (await account.executeThreshold(target, 0, data, legs0)).wait();
// replay the SAME legs: nonce is now 1, so the challenge differs and they no longer verify
await expect(account.executeThreshold(target, 0, data, legs0)).to.be.revertedWithCustomError(
account,
"BelowThreshold"
);
expect(await echo.calls()).to.equal(1n); // no second dispatch
});
it("executeThreshold reverts below threshold", async function () {
const target = await echo.getAddress();
const data = echo.interface.encodeFunctionData("bump");
const challenge = await account.execChallenge(0, target, 0, data);
const legs = [1, 2].map((idx) => [idx, genuine(SCHEME, pubKeys[idx], challenge, rng)]); // 2 < 3
await expect(account.executeThreshold(target, 0, data, legs)).to.be.revertedWithCustomError(
account,
"BelowThreshold"
);
});
// ── adversarial edge cases (custody hardening) ────────────────────────────
it("ignores an out-of-range member index (never counts it)", async function () {
const target = await echo.getAddress();
const data = echo.interface.encodeFunctionData("bump");
const challenge = await account.execChallenge(0, target, 0, data);
// 3 legs but one uses index 99 (>= size 5): only 2 valid distinct -> below t=3
const legs = [
[0, genuine(SCHEME, pubKeys[0], challenge, rng)],
[2, genuine(SCHEME, pubKeys[2], challenge, rng)],
[99, genuine(SCHEME, pubKeys[0], challenge, rng)],
];
await expect(account.executeThreshold(target, 0, data, legs)).to.be.revertedWithCustomError(account, "BelowThreshold");
});
it("empty legs never authorize (exec reverts, userOp fails)", async function () {
const target = await echo.getAddress();
const data = echo.interface.encodeFunctionData("bump");
await expect(account.executeThreshold(target, 0, data, [])).to.be.revertedWithCustomError(account, "BelowThreshold");
const userOpHash = ethers.id("op-empty");
expect(await validate([], userOpHash)).to.equal(1n);
});
it("a 1-of-1 single-member account authorizes with exactly one valid leg", async function () {
const solo = [makePubKey(SCHEME, rng)];
const Fac = await ethers.getContractFactory("AereThresholdAccountFactory");
const f2 = await Fac.deploy();
await f2.waitForDeployment();
const salt = ethers.id("solo");
const addr = await f2.computeAddress(entryPoint.address, SCHEME, 1, solo, salt);
await (await f2.createAccount(entryPoint.address, SCHEME, 1, solo, salt)).wait();
const acc1 = await ethers.getContractAt("AereThresholdAccount", addr);
// Need the solo member's secret to sign — regenerate deterministically is not available here,
// so instead assert the committee wiring + that ZERO legs fail (the positive crypto path is
// covered by the multi-member tests which use the same _countMem logic).
const [scheme, threshold, size] = await acc1.committee();
expect(threshold).to.equal(1);
expect(size).to.equal(1);
const target = await echo.getAddress();
const data = echo.interface.encodeFunctionData("bump");
await expect(acc1.executeThreshold(target, 0, data, [])).to.be.revertedWithCustomError(acc1, "BelowThreshold");
});
});