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.
421 lines
21 KiB
JavaScript
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);
|
|
});
|
|
});
|
|
});
|