aere-contracts/test/AerePQCTxAccount.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

421 lines
21 KiB
JavaScript

// AerePQCTxAccount — a post-quantum-authorized ERC-4337 v0.7 smart account (deploy-today reference
// path for AIP-PQ-TX). The account's sole owner is a NIST PQC public key, and every fund-moving
// authorization (validateUserOp / executeWithPQC) is decided by the LIVE native PQC precompiles on
// chain 2800: Falcon-512 0x0AE1, Falcon-1024 0x0AE2, ML-DSA-44 0x0AE3, SLH-DSA-128s 0x0AE4. There is
// no ECDSA fallback; a userOp may be relayed by any bundler/EOA and only the PQC key holder can
// authorize it.
//
// Two verification backends are exercised at 0x0AE1..0x0AE4 (the exact pattern used by
// AerePQCSocialRecoveryModule.test.js and AereThresholdAccount.test.js):
// 1. MockPQCPrecompile — the repo's faithful mock (accept iff commitment == keccak256(pk||msg)),
// parsing the input at the precompiles' SPEC offsets, used for the account LOGIC across all four
// schemes (accept + reject branches, domain separation, nonce/replay).
// 2. RealFalcon512PrecompileAdapter — routes the precompile staticcall into the NIST-KAT-proven
// AereFalcon512Verifier, so a subset of tests drive GENUINE Falcon-512 signatures (produced by
// @noble/post-quantum, byte-compatible with AERE's live precompile) through validateUserOp.
//
// Run: npx hardhat test test/AerePQCTxAccount.test.js
const { expect } = require("chai");
const { ethers, network } = require("hardhat");
const path = require("path");
const { pathToFileURL } = require("url");
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, name: "Falcon-512" },
2: { pkLen: 1793, pkHeader: 0x0a, esigHeader: 0x2a, falcon: true, name: "Falcon-1024" },
3: { pkLen: 1312, sigLen: 2420, falcon: false, name: "ML-DSA-44" },
4: { pkLen: 32, sigLen: 7856, falcon: false, name: "SLH-DSA-128s" },
};
const ZERO = ethers.ZeroHash;
// Domain separators — MUST match AerePQCTxAccount exactly.
const USEROP_DOMAIN = ethers.id("AerePQCTxAccount.validateUserOp.v1");
const EXEC_DOMAIN = ethers.id("AerePQCTxAccount.executeWithPQC.v1");
// Fund-moving paths verify over abi.encodePacked(domain, hash) = 64 bytes; EIP-1271 over raw 32 bytes.
const domained = (domain, hash) => ethers.concat([domain, hash]);
// ---- deterministic seeded RNG (keccak hash-chain) --------------------------
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]));
}
// A genuine MOCK envelope binding to `message` under `pubKey` (the mock at 0x0AE1..4 accepts it).
// Envelope shape matches the LIVE precompiles: Falcon = nonce(40)||esig, fixed = raw sig.
function genuineMock(scheme, pubKey, message, rng) {
const m = META[scheme];
const commitment = commitmentFor(pubKey, message);
if (m.falcon) {
const nonce = rng.bytes(40);
const esig = ethers.concat([new Uint8Array([m.esigHeader]), commitment]);
return ethers.hexlify(ethers.concat([nonce, esig]));
}
const sig = new Uint8Array(m.sigLen);
sig.set(ethers.getBytes(commitment), 0);
return ethers.hexlify(sig);
}
// Flip one byte in the commitment region -> a forgery (the mock rejects it).
function tamperMock(scheme, sigHex) {
const b = ethers.getBytes(sigHex);
b[META[scheme].falcon ? 41 : 0] ^= 0x01;
return ethers.hexlify(b);
}
// PackedUserOperation (v0.7) tuple with `sig` in the signature slot; only `signature` matters here.
function userOp(sender, sig) {
return [sender, 0, "0x", "0x", ZERO, 0, ZERO, "0x", sig];
}
describe("AerePQCTxAccount (post-quantum-authorized ERC-4337 account)", function () {
let deployer, ep, other, relayer, stranger;
let mockCode;
before(async function () {
[deployer, ep, other, relayer, stranger] = await ethers.getSigners();
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());
}
});
async function installMocks() {
for (const s of [1, 2, 3, 4]) {
await network.provider.send("hardhat_setCode", [PRECOMPILE[s], mockCode[s]]);
}
}
async function deployAccount(scheme, rng, fund = true) {
await installMocks();
const pk = makePubKey(scheme, rng);
const F = await ethers.getContractFactory("AerePQCTxAccount");
const account = await F.deploy(ep.address, scheme, pk);
await account.waitForDeployment();
if (fund) {
await (await deployer.sendTransaction({ to: await account.getAddress(), value: ethers.parseEther("1") })).wait();
}
return { account, pk, F };
}
// =========================================================================
// Construction / config
// =========================================================================
describe("construction", function () {
it("wires entryPoint, scheme, pubkey and the right precompile for each scheme", async function () {
const rng = makeRng("ctor-wire");
for (const scheme of [1, 2, 3, 4]) {
const { account, pk } = await deployAccount(scheme, rng, false);
expect(await account.entryPoint()).to.equal(ep.address);
expect(await account.pqcScheme()).to.equal(scheme);
expect((await account.pqcPubKey()).toLowerCase()).to.equal(pk.toLowerCase());
expect(await account.precompileAddress()).to.equal(PRECOMPILE[scheme]);
expect(await account.execNonce()).to.equal(0n);
}
});
it("rejects a zero EntryPoint", async function () {
const rng = makeRng("ctor-zeroep");
const F = await ethers.getContractFactory("AerePQCTxAccount");
await expect(F.deploy(ethers.ZeroAddress, 1, makePubKey(1, rng)))
.to.be.revertedWithCustomError(F, "InvalidEntryPoint");
});
it("rejects a malformed public key (wrong length or wrong Falcon header)", async function () {
const rng = makeRng("ctor-badpk");
const F = await ethers.getContractFactory("AerePQCTxAccount");
// Falcon-512 wrong length
await expect(F.deploy(ep.address, 1, "0x09" + "ab".repeat(10)))
.to.be.revertedWithCustomError(F, "InvalidPqcPubKey");
// Falcon-512 wrong header (0x08)
await expect(F.deploy(ep.address, 1, "0x08" + "ab".repeat(896)))
.to.be.revertedWithCustomError(F, "InvalidPqcPubKey");
// ML-DSA-44 wrong length
await expect(F.deploy(ep.address, 3, "0x" + "ab".repeat(100)))
.to.be.revertedWithCustomError(F, "InvalidPqcPubKey");
});
it("rejects an unknown scheme", async function () {
const F = await ethers.getContractFactory("AerePQCTxAccount");
await expect(F.deploy(ep.address, 5, "0x" + "ab".repeat(32)))
.to.be.revertedWithCustomError(F, "InvalidScheme");
});
});
// =========================================================================
// validateUserOp — mock verifier, ALL FOUR schemes (accept + reject)
// =========================================================================
describe("validateUserOp (mock verifier, all schemes)", function () {
for (const scheme of [1, 2, 3, 4]) {
it(`ACCEPT/REJECT for ${META[scheme].name} (scheme ${scheme}) via 0x0AE${scheme}`, async function () {
const rng = makeRng(`vuo-${scheme}`);
const { account, pk } = await deployAccount(scheme, rng, false);
const userOpHash = ethers.id(`userop-${scheme}`);
const message = domained(USEROP_DOMAIN, userOpHash);
// Valid PQC signature over USEROP_DOMAIN||userOpHash -> validationData 0.
const good = genuineMock(scheme, pk, message, rng);
const okVal = await account
.connect(ep)
.validateUserOp.staticCall(userOp(await account.getAddress(), good), userOpHash, 0);
expect(okVal).to.equal(0n);
// Tampered signature -> SIG_VALIDATION_FAILED (1), NOT a revert.
const bad = tamperMock(scheme, good);
const badVal = await account
.connect(ep)
.validateUserOp.staticCall(userOp(await account.getAddress(), bad), userOpHash, 0);
expect(badVal).to.equal(1n);
});
}
it("REJECT: a signature over a DIFFERENT userOpHash does not validate (binds the op)", async function () {
const rng = makeRng("vuo-wronghash");
const { account, pk } = await deployAccount(1, rng, false);
const signed = genuineMock(1, pk, domained(USEROP_DOMAIN, ethers.id("op-A")), rng);
// Submit it against op-B's hash.
const val = await account
.connect(ep)
.validateUserOp.staticCall(userOp(await account.getAddress(), signed), ethers.id("op-B"), 0);
expect(val).to.equal(1n);
});
it("validateUserOp reverts if not called by the EntryPoint", async function () {
const rng = makeRng("vuo-auth");
const { account, pk } = await deployAccount(1, rng, false);
const h = ethers.id("op");
const sig = genuineMock(1, pk, domained(USEROP_DOMAIN, h), rng);
await expect(
account.connect(other).validateUserOp(userOp(await account.getAddress(), sig), h, 0)
).to.be.revertedWithCustomError(account, "Unauthorized");
});
it("validateUserOp forwards missingAccountFunds to the EntryPoint as prefund", async function () {
const rng = makeRng("vuo-prefund");
const { account, pk } = await deployAccount(1, rng, true); // account funded with 1 ETH
const h = ethers.id("op-prefund");
const sig = genuineMock(1, pk, domained(USEROP_DOMAIN, h), rng);
const prefund = ethers.parseEther("0.2");
const before = await ethers.provider.getBalance(ep.address);
// Real (state-changing) call from the EntryPoint: returns 0 AND sends the prefund.
const rc = await (
await account.connect(ep).validateUserOp(userOp(await account.getAddress(), sig), h, prefund)
).wait();
const after = await ethers.provider.getBalance(ep.address);
const gasCost = rc.gasUsed * rc.gasPrice;
// ep received `prefund` minus the gas it paid to send the tx.
expect(after - before + gasCost).to.equal(prefund);
});
});
// =========================================================================
// executeWithPQC (self-relay) + EIP-1271 + domain separation
// =========================================================================
describe("executeWithPQC / EIP-1271 / domain separation (mock verifier)", function () {
it("executeWithPQC moves value on a valid signature and bumps execNonce", async function () {
const rng = makeRng("exec-happy");
const { account, pk } = await deployAccount(1, rng, true);
const target = other.address;
const value = ethers.parseEther("0.25");
const data = "0x";
const n = await account.execNonce();
const challenge = await account.getExecuteChallenge(target, value, data, n);
const sig = genuineMock(1, pk, domained(EXEC_DOMAIN, challenge), rng);
const before = await ethers.provider.getBalance(target);
await (await account.connect(relayer).executeWithPQC(sig, target, value, data)).wait();
expect((await ethers.provider.getBalance(target)) - before).to.equal(value);
expect(await account.execNonce()).to.equal(n + 1n);
});
it("executeWithPQC reverts on an invalid signature; no value moves, nonce unchanged", async function () {
const rng = makeRng("exec-bad");
const { account, pk } = await deployAccount(3, rng, true); // ML-DSA-44 owner
const target = other.address;
const value = ethers.parseEther("0.1");
const n = await account.execNonce();
const challenge = await account.getExecuteChallenge(target, value, "0x", n);
const bad = tamperMock(3, genuineMock(3, pk, domained(EXEC_DOMAIN, challenge), rng));
await expect(account.executeWithPQC(bad, target, value, "0x"))
.to.be.revertedWithCustomError(account, "InvalidSignature");
expect(await account.execNonce()).to.equal(n);
});
it("executeWithPQC legs are single-use: replaying the same signature fails (nonce advanced)", async function () {
const rng = makeRng("exec-replay");
const { account, pk } = await deployAccount(1, rng, true);
const target = other.address;
const value = ethers.parseEther("0.05");
const n = await account.execNonce();
const challenge = await account.getExecuteChallenge(target, value, "0x", n);
const sig = genuineMock(1, pk, domained(EXEC_DOMAIN, challenge), rng);
await (await account.executeWithPQC(sig, target, value, "0x")).wait();
// execNonce is now n+1, so the same signature (bound to nonce n) no longer authorizes.
await expect(account.executeWithPQC(sig, target, value, "0x"))
.to.be.revertedWithCustomError(account, "InvalidSignature");
});
it("EIP-1271 returns the magic value for a valid raw-hash signature, 0 for a tampered one", async function () {
const rng = makeRng("1271");
const { account, pk } = await deployAccount(1, rng, false);
const hash = ethers.id("post-quantum login to dapp.example");
const sig = genuineMock(1, pk, hash, rng); // raw 32-byte message
expect(await account.isValidSignature(hash, sig)).to.equal("0x1626ba7e");
expect(await account.isValidSignature(hash, tamperMock(1, sig))).to.equal("0x00000000");
});
it("DOMAIN SEPARATION: an EIP-1271 login signature cannot be replayed as an executeWithPQC spend", async function () {
const rng = makeRng("domain-sep");
const { account, pk } = await deployAccount(1, rng, true);
const target = other.address;
const value = ethers.parseEther("0.5");
const data = "0x";
const n = await account.execNonce();
// Attacker computes the exact execute challenge (all inputs public; execNonce is public).
const challenge = await account.getExecuteChallenge(target, value, data, n);
// User is phished into signing `challenge` as a RAW login hash (the EIP-1271 surface).
const loginSig = genuineMock(1, pk, challenge, rng); // commitment over the RAW 32-byte challenge
// That login signature IS accepted by the raw-hash isValidSignature surface...
expect(await account.isValidSignature(challenge, loginSig)).to.equal("0x1626ba7e");
// ...but must NOT authorize a spend: executeWithPQC verifies over (EXEC_DOMAIN || challenge) = 64 bytes.
await expect(account.executeWithPQC(loginSig, target, value, data))
.to.be.revertedWithCustomError(account, "InvalidSignature");
expect(await account.execNonce()).to.equal(n); // nothing moved
});
});
// =========================================================================
// EntryPoint-gated dispatch
// =========================================================================
describe("executeFromEntryPoint / executeBatchFromEntryPoint", function () {
it("executeFromEntryPoint is EntryPoint-gated and dispatches when the EntryPoint calls", async function () {
const rng = makeRng("efe");
const { account } = await deployAccount(1, rng, true);
await expect(account.connect(other).executeFromEntryPoint(other.address, 0, "0x"))
.to.be.revertedWithCustomError(account, "Unauthorized");
const value = ethers.parseEther("0.3");
const before = await ethers.provider.getBalance(other.address);
await (await account.connect(ep).executeFromEntryPoint(other.address, value, "0x")).wait();
expect((await ethers.provider.getBalance(other.address)) - before).to.equal(value);
});
it("executeBatchFromEntryPoint dispatches every call and is EntryPoint-gated", async function () {
const rng = makeRng("ebe");
const { account } = await deployAccount(1, rng, true);
const calls = [
[stranger.address, ethers.parseEther("0.1"), "0x"],
[other.address, ethers.parseEther("0.2"), "0x"],
];
await expect(account.connect(other).executeBatchFromEntryPoint(calls))
.to.be.revertedWithCustomError(account, "Unauthorized");
const b1 = await ethers.provider.getBalance(stranger.address);
const b2 = await ethers.provider.getBalance(other.address);
await (await account.connect(ep).executeBatchFromEntryPoint(calls)).wait();
expect((await ethers.provider.getBalance(stranger.address)) - b1).to.equal(ethers.parseEther("0.1"));
expect((await ethers.provider.getBalance(other.address)) - b2).to.equal(ethers.parseEther("0.2"));
});
});
// =========================================================================
// REAL Falcon-512 crypto: genuine signatures via @noble, verified by the
// NIST-KAT-proven AereFalcon512Verifier behind a precompile-format adapter.
// =========================================================================
describe("real Falcon-512 authorization (KAT-proven verifier, no mock)", function () {
let falcon512, adapterCode;
// Pure-Solidity Falcon-512 verify (stand-in for the native precompile) is ~10.7M gas; give the
// heavy nested-staticcall paths ample headroom (the hardhat network permits a large block gas
// limit). TEST-HARNESS accommodation for the expensive stand-in, not a contract concern.
const HEAVY = { gasLimit: 400_000_000 };
before(async function () {
const nobleFalcon = path.resolve(__dirname, "../../sdk-js/node_modules/@noble/post-quantum/falcon.js");
({ falcon512 } = await import(pathToFileURL(nobleFalcon).href));
const V = await ethers.getContractFactory("AereFalcon512Verifier");
const verifier = await V.deploy();
await verifier.waitForDeployment();
const A = await ethers.getContractFactory("RealFalcon512PrecompileAdapter");
const adapter = await A.deploy(await verifier.getAddress());
await adapter.waitForDeployment();
adapterCode = await ethers.provider.getCode(await adapter.getAddress());
});
// noble detached (0x39 || nonce(40) || compressed) -> AERE envelope (nonce(40) || 0x29 || compressed).
function realEnvelope(detached) {
const nonce = detached.slice(1, 41);
const compressed = detached.slice(41);
const esig = new Uint8Array(1 + compressed.length);
esig[0] = 0x29;
esig.set(compressed, 1);
return ethers.hexlify(ethers.concat([nonce, esig]));
}
async function installRealFalconAt0AE1() {
await network.provider.send("hardhat_setCode", [PRECOMPILE[1], adapterCode]);
}
async function deployRealAccount(seedFill) {
await installRealFalconAt0AE1();
const kp = falcon512.keygen(new Uint8Array(48).fill(seedFill));
const pk = ethers.hexlify(kp.publicKey);
const F = await ethers.getContractFactory("AerePQCTxAccount");
const account = await F.deploy(ep.address, 1, pk);
await account.waitForDeployment();
return { account, kp };
}
it("validateUserOp returns 0 for a GENUINE Falcon-512 signature over the domained userOpHash", async function () {
const { account, kp } = await deployRealAccount(0x11);
const userOpHash = ethers.id("real-userop-accept");
const message = ethers.getBytes(domained(USEROP_DOMAIN, userOpHash)); // 64 bytes
// Falcon signing is randomized; re-sign until the full account->adapter->verifier path accepts.
let accepted = false;
for (let attempt = 0; attempt < 8 && !accepted; attempt++) {
const env = realEnvelope(falcon512.sign(message, kp.secretKey));
const val = await account
.connect(ep)
.validateUserOp.staticCall(userOp(await account.getAddress(), env), userOpHash, 0, HEAVY);
if (val === 0n) accepted = true;
}
expect(accepted, "a genuine Falcon-512 signature should validate").to.equal(true);
});
it("validateUserOp returns 1 (SIG_VALIDATION_FAILED) for a tampered Falcon-512 signature", async function () {
const { account, kp } = await deployRealAccount(0x22);
const userOpHash = ethers.id("real-userop-reject");
const message = ethers.getBytes(domained(USEROP_DOMAIN, userOpHash));
const env = ethers.getBytes(realEnvelope(falcon512.sign(message, kp.secretKey)));
env[50] ^= 0x01; // flip a byte inside the compressed signature body -> forgery
const val = await account
.connect(ep)
.validateUserOp.staticCall(userOp(await account.getAddress(), ethers.hexlify(env)), userOpHash, 0, HEAVY);
expect(val).to.equal(1n);
});
});
});