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.
335 lines
16 KiB
JavaScript
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");
|
|
});
|
|
});
|