// AERE402FacilitatorPQC — HTTP 402 agentic settlement rooted in a post-quantum identity. // // The payer is an AereAgentDID: an agent whose ROOT authority is a Falcon key in // AerePQCKeyRegistry, and whose day-to-day work is signed by cheap secp256k1 SESSION // keys. A payment is authorized by the DID SESSION signature; the facilitator delegates // ALL session/root authorization to AereAgentDID.authorize (root lifecycle + scope + // spend cap + secp256k1 session sig + anti-replay), then debits a prepaid vault keyed by // the Falcon root and splits amount -> payee + 25-bps sink fee. // // We install MockPQCPrecompile at 0x0AE1..0x0AE4 (hardhat_setCode) so the registry's // Falcon verification runs; the real precompile encoding is proven separately against the // LIVE mainnet precompile via eth_call. Session ISSUANCE is Falcon-PoP gated; the payment // hot path is a raw secp256k1 signature over the DID action digest (ecrecover). // // Run: npx hardhat test test/aere402-facilitator-pqc.test.js const { expect } = require("chai"); const { ethers, network } = require("hardhat"); const ONE = 10n ** 18n; 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 }, }; // ---- deterministic Falcon key/envelope helpers (mirror AereAgentDID.test.js) --------- function makeRng(seedText) { let seed = ethers.keccak256(ethers.toUtf8Bytes(seedText)); function 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; } return { bytes }; } function makePubKey(scheme, rng) { const m = META[scheme]; const raw = rng.bytes(m.pkLen); 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]; const nonce = rng.bytes(40); const esig = ethers.concat([new Uint8Array([m.esigHeader]), commitment]); return ethers.hexlify(ethers.concat([nonce, esig])); } function genuine(scheme, pubKey, message, rng) { return envelope(scheme, commitmentFor(pubKey, message), rng); } // secp256k1 signature (r||s||v, low-s, v in {27,28}) by `wallet` over the raw 32-byte digest. function ecdsaSign(wallet, digest) { return wallet.signingKey.sign(digest).serialized; } const AGENT_BOND = ethers.getAddress("0x32E0015F622a8719d1C380A87CE1a09bcd0cB86A"); const AI_REPUTATION = ethers.getAddress("0x781ef746c08760aa854cDa4621d54db6734bfeBF"); describe("AERE402FacilitatorPQC (Falcon-root agent payments via DID sessions)", function () { let owner, payee, other, funder; let reg, did, sink, fac, token; let mockCode; before(async function () { [owner, payee, other, funder] = await ethers.getSigners(); mockCode = {}; const F = await ethers.getContractFactory("MockPQCPrecompile"); for (const scheme of [1, 2, 3, 4]) { const mock = await F.deploy(scheme); await mock.waitForDeployment(); mockCode[scheme] = await ethers.provider.getCode(await mock.getAddress()); } }); async function installMocks() { for (const scheme of [1, 2, 3, 4]) { await network.provider.send("hardhat_setCode", [PRECOMPILE[scheme], mockCode[scheme]]); } } beforeEach(async function () { await installMocks(); reg = await (await ethers.getContractFactory("AerePQCKeyRegistry")).deploy(); await reg.waitForDeployment(); did = await (await ethers.getContractFactory("AereAgentDID")).deploy( await reg.getAddress(), AGENT_BOND, AI_REPUTATION ); await did.waitForDeployment(); // Minimal sink that pulls the fee via transferFrom(facilitator). sink = await (await ethers.getContractFactory("MockSinkSimple")).deploy(); await sink.waitForDeployment(); fac = await (await ethers.getContractFactory("AERE402FacilitatorPQC")).deploy( await did.getAddress(), await sink.getAddress() ); await fac.waitForDeployment(); // Settlement token. token = await (await ethers.getContractFactory("MockERC20Lending")).deploy("USD Coin (e)", "USDC.e", 18); await token.waitForDeployment(); await token.mint(funder.address, 1_000_000n * ONE); }); // Register a Falcon root key under `signer`, return { rootKeyId, rootPk, scheme }. async function registerFalconRoot(scheme, signer, rng) { const pk = makePubKey(scheme, rng); const nonce = await reg.identityNonce(signer.address); const ch = await reg.popChallenge(signer.address, scheme, pk, nonce); const tx = await reg.connect(signer).registerKey(scheme, pk, genuine(scheme, pk, ch, rng)); const rc = await tx.wait(); const log = rc.logs.map((l) => reg.interface.parseLog(l)).find((p) => p && p.name === "KeyRegistered"); return { rootKeyId: Number(log.args.keyId), rootPk: pk, scheme }; } async function issueSession(root, { sessionAddr, scopeHash, spendCap, expiry, rng, signer = owner }) { const nonce = (await did.getAgent(root.rootKeyId)).sessionNonce; const challenge = await did.sessionChallenge(root.rootKeyId, nonce, sessionAddr, scopeHash, spendCap, expiry); const pop = genuine(root.scheme, root.rootPk, challenge, rng); const tx = await did.connect(signer).issueSession(root.rootKeyId, sessionAddr, scopeHash, spendCap, expiry, pop); const rc = await tx.wait(); const log = rc.logs.map((l) => did.interface.parseLog(l)).find((p) => p && p.name === "SessionIssued"); return Number(log.args.sessionId); } async function futureTs(secs = 3600) { const b = await ethers.provider.getBlock("latest"); return b.timestamp + secs; } // Full end-to-end setup: Falcon root -> DID agent -> funded vault -> a live session. async function setupAgentSession(opts = {}) { const rng = makeRng(opts.seed || "pqc402"); const scheme = opts.scheme || 1; const root = await registerFalconRoot(scheme, owner, rng); await did.connect(owner).createAgent(root.rootKeyId); // Fund the agent vault. const fund = opts.fund ?? 100n * ONE; await token.connect(funder).approve(await fac.getAddress(), fund); await fac.connect(funder).topUp(root.rootKeyId, await token.getAddress(), fund); const session = ethers.Wallet.createRandom(); const scopeHash = opts.scopeHash ?? ethers.keccak256(ethers.toUtf8Bytes("inference:anthropic")); const spendCap = opts.spendCap ?? 10n * ONE; const expiry = opts.expiry ?? (await futureTs(opts.sessionSecs ?? 3600)); const sid = await issueSession(root, { sessionAddr: session.address, scopeHash, spendCap, expiry, rng }); return { rng, root, session, scopeHash, spendCap, expiry, sid }; } // Sign a payment with the session key over the DID action digest, then submit settle. async function settle(ctx, opts = {}) { const tokenAddr = await token.getAddress(); const _payee = opts.payee ?? payee; const payeeAddr = opts.payeeAddr ?? _payee.address; const amount = opts.amount ?? 4n * ONE; const resourceId = opts.resourceId ?? ethers.id("GET /v1/inference/claude"); const deadline = opts.deadline ?? (await futureTs(1800)); const scope = opts.scope ?? ctx.scopeHash; // actionHash binds the payment terms to THIS facilitator; the session key signs the // DID action digest that commits to (sessionId, scope, actionHash, amount, actionNonce). const actionHash = await fac.paymentActionHash(tokenAddr, payeeAddr, resourceId, deadline); const actionNonce = opts.actionNonce ?? (await did.getSession(ctx.sid)).actionNonce; const digest = await did.actionDigest(ctx.sid, scope, actionHash, amount, actionNonce); const signer = opts.signWallet ?? ctx.session; const sig = opts.sig ?? ecdsaSign(signer, digest); return fac .connect(opts.caller ?? _payee) .settle(ctx.sid, scope, tokenAddr, payeeAddr, amount, resourceId, deadline, sig); } // ----------------------------------------------------------------------- // deployment / wiring // ----------------------------------------------------------------------- describe("deployment", function () { it("wires the DID, reads the registry from it, pins the sink and the fee", async function () { expect(await fac.DID()).to.equal(await did.getAddress()); expect(await fac.KEY_REGISTRY()).to.equal(await reg.getAddress()); expect(await fac.SINK()).to.equal(await sink.getAddress()); expect(Number(await fac.PROTOCOL_FEE_BPS())).to.equal(25); }); it("reverts on a zero DID or zero sink", async function () { const F = await ethers.getContractFactory("AERE402FacilitatorPQC"); await expect(F.deploy(ethers.ZeroAddress, await sink.getAddress())).to.be.revertedWithCustomError(F, "ZeroAddress"); await expect(F.deploy(await did.getAddress(), ethers.ZeroAddress)).to.be.revertedWithCustomError(F, "ZeroAddress"); }); }); // ----------------------------------------------------------------------- // funding vault: topUp / withdraw / controller authority // ----------------------------------------------------------------------- describe("prepaid vault", function () { it("permissionless topUp credits the agent vault; withdraw is controller-only", async function () { const rng = makeRng("vault"); const root = await registerFalconRoot(1, owner, rng); await did.connect(owner).createAgent(root.rootKeyId); const tokenAddr = await token.getAddress(); await token.connect(funder).approve(await fac.getAddress(), 50n * ONE); await expect(fac.connect(funder).topUp(root.rootKeyId, tokenAddr, 50n * ONE)).to.emit(fac, "TopUp"); expect(await fac.balances(root.rootKeyId, tokenAddr)).to.equal(50n * ONE); // Non-controller cannot withdraw. await expect( fac.connect(other).withdraw(root.rootKeyId, tokenAddr, 1n * ONE, other.address) ).to.be.revertedWithCustomError(fac, "NotController"); // Controller (registry owner of the root key) can. const before = await token.balanceOf(other.address); await expect(fac.connect(owner).withdraw(root.rootKeyId, tokenAddr, 20n * ONE, other.address)).to.emit( fac, "Withdrawn" ); expect((await token.balanceOf(other.address)) - before).to.equal(20n * ONE); expect(await fac.balances(root.rootKeyId, tokenAddr)).to.equal(30n * ONE); }); it("topUp reverts for an unknown agent", async function () { await token.connect(funder).approve(await fac.getAddress(), 1n * ONE); await expect(fac.connect(funder).topUp(999, await token.getAddress(), 1n * ONE)).to.be.revertedWithCustomError( fac, "UnknownAgent" ); }); it("controllerOf / isController track the registry owner", async function () { const rng = makeRng("ctrl"); const root = await registerFalconRoot(1, owner, rng); await did.connect(owner).createAgent(root.rootKeyId); expect(await fac.controllerOf(root.rootKeyId)).to.equal(owner.address); expect(await fac.isController(root.rootKeyId, owner.address)).to.equal(true); expect(await fac.isController(root.rootKeyId, other.address)).to.equal(false); expect(await fac.controllerOf(999)).to.equal(ethers.ZeroAddress); }); }); // ----------------------------------------------------------------------- // settle: happy path // ----------------------------------------------------------------------- describe("settle (happy path)", function () { it("settles a session-authorized payment: split payee/sink, spend recorded, vault debited", async function () { const ctx = await setupAgentSession(); const tokenAddr = await token.getAddress(); const amount = 4n * ONE; const fee = (amount * 25n) / 10_000n; const toPayee = amount - fee; const payeeBefore = await token.balanceOf(payee.address); const sinkBefore = await token.balanceOf(await sink.getAddress()); await expect(settle(ctx, { amount })).to.emit(fac, "SettledPQC"); expect((await token.balanceOf(payee.address)) - payeeBefore).to.equal(toPayee); expect((await token.balanceOf(await sink.getAddress())) - sinkBefore).to.equal(fee); // Vault debited by the full gross amount. expect(await fac.balances(ctx.root.rootKeyId, tokenAddr)).to.equal(100n * ONE - amount); // Session spend recorded against the cap; action nonce advanced. const s = await did.getSession(ctx.sid); expect(s.spent).to.equal(amount); expect(Number(s.actionNonce)).to.equal(1); expect(await did.remainingSpend(ctx.sid)).to.equal(ctx.spendCap - amount); }); it("supports multiple sequential payments up to the session cap", async function () { const ctx = await setupAgentSession({ spendCap: 6n * ONE, fund: 100n * ONE }); await settle(ctx, { amount: 2n * ONE, resourceId: ethers.id("r1") }); await settle(ctx, { amount: 3n * ONE, resourceId: ethers.id("r2") }); const s = await did.getSession(ctx.sid); expect(s.spent).to.equal(5n * ONE); expect(Number(s.actionNonce)).to.equal(2); // The remaining 1 AERE is spendable; 2 more is over the cap. await settle(ctx, { amount: 1n * ONE, resourceId: ethers.id("r3") }); expect((await did.getSession(ctx.sid)).spent).to.equal(6n * ONE); }); }); // ----------------------------------------------------------------------- // settle: ROOT LIFECYCLE — the headline guarantee // ----------------------------------------------------------------------- describe("settle (Falcon root lifecycle halts payments)", function () { it("REVOKING the Falcon root instantly halts all downstream payments", async function () { const ctx = await setupAgentSession(); // A payment works before revocation. await settle(ctx, { amount: 1n * ONE, resourceId: ethers.id("pre") }); // Revoke the quantum-durable root key. await reg.connect(owner).revokeKey(ctx.root.rootKeyId); expect(await did.isSessionValid(ctx.sid)).to.equal(false); expect(await fac.isSessionValid(ctx.sid)).to.equal(false); // Every subsequent payment reverts at the DID authorization gate. await expect(settle(ctx, { amount: 1n * ONE, resourceId: ethers.id("post") })).to.be.revertedWithCustomError( did, "SessionExpiredOrInvalid" ); }); it("ROTATING the Falcon root (root no longer ACTIVE) halts downstream payments", async function () { const ctx = await setupAgentSession(); await settle(ctx, { amount: 1n * ONE, resourceId: ethers.id("pre") }); // Rotate the root to a fresh Falcon successor; the old root is now ROTATED (not ACTIVE). const np = makePubKey(1, ctx.rng); const n = await reg.identityNonce(owner.address); const ch = await reg.popChallenge(owner.address, 1, np, n); await reg.connect(owner).rotateKey(ctx.root.rootKeyId, 1, np, genuine(1, np, ch, ctx.rng)); expect(await fac.isSessionValid(ctx.sid)).to.equal(false); await expect(settle(ctx, { amount: 1n * ONE, resourceId: ethers.id("post") })).to.be.revertedWithCustomError( did, "SessionExpiredOrInvalid" ); }); it("funds are NOT stranded after rotation: the controller can still withdraw", async function () { const ctx = await setupAgentSession(); const tokenAddr = await token.getAddress(); const np = makePubKey(1, ctx.rng); const n = await reg.identityNonce(owner.address); const ch = await reg.popChallenge(owner.address, 1, np, n); await reg.connect(owner).rotateKey(ctx.root.rootKeyId, 1, np, genuine(1, np, ch, ctx.rng)); // Spending halts, but the operator still controls the prepaid vault. expect(await fac.controllerOf(ctx.root.rootKeyId)).to.equal(owner.address); const before = await token.balanceOf(owner.address); await fac.connect(owner).withdraw(ctx.root.rootKeyId, tokenAddr, 100n * ONE, owner.address); expect((await token.balanceOf(owner.address)) - before).to.equal(100n * ONE); }); }); // ----------------------------------------------------------------------- // settle: scope / cap / expiry / replay / signature guards // ----------------------------------------------------------------------- describe("settle (scope / cap / expiry / auth guards)", function () { it("over-cap payment reverts (SessionSpendExceeded)", async function () { const ctx = await setupAgentSession({ spendCap: 5n * ONE, fund: 100n * ONE }); await expect(settle(ctx, { amount: 6n * ONE })).to.be.revertedWithCustomError(did, "SessionSpendExceeded"); }); it("out-of-scope payment reverts (SessionScopeViolation)", async function () { const ctx = await setupAgentSession(); const wrongScope = ethers.keccak256(ethers.toUtf8Bytes("withdraw:funds")); await expect(settle(ctx, { amount: 1n * ONE, scope: wrongScope })).to.be.revertedWithCustomError( did, "SessionScopeViolation" ); }); it("expired session reverts (SessionExpiredOrInvalid) even with a future payment deadline", async function () { const ctx = await setupAgentSession({ sessionSecs: 100 }); const tokenAddr = await token.getAddress(); const amount = 1n * ONE; const resourceId = ethers.id("late"); const deadline = await futureTs(100000); // payment deadline far in the future const actionHash = await fac.paymentActionHash(tokenAddr, payee.address, resourceId, deadline); const nonce = (await did.getSession(ctx.sid)).actionNonce; const digest = await did.actionDigest(ctx.sid, ctx.scopeHash, actionHash, amount, nonce); const sig = ecdsaSign(ctx.session, digest); // Advance past the session expiry but not the payment deadline. await network.provider.send("evm_increaseTime", [200]); await network.provider.send("evm_mine", []); await expect( fac.connect(payee).settle(ctx.sid, ctx.scopeHash, tokenAddr, payee.address, amount, resourceId, deadline, sig) ).to.be.revertedWithCustomError(did, "SessionExpiredOrInvalid"); }); it("a signature by a non-session key reverts (SessionSigInvalid)", async function () { const ctx = await setupAgentSession(); const stranger = ethers.Wallet.createRandom(); await expect(settle(ctx, { amount: 1n * ONE, signWallet: stranger })).to.be.revertedWithCustomError( did, "SessionSigInvalid" ); }); it("is anti-replay: re-submitting a consumed session signature reverts", async function () { const ctx = await setupAgentSession(); const tokenAddr = await token.getAddress(); const amount = 1n * ONE; const resourceId = ethers.id("once"); const deadline = await futureTs(1800); const actionHash = await fac.paymentActionHash(tokenAddr, payee.address, resourceId, deadline); const digest = await did.actionDigest(ctx.sid, ctx.scopeHash, actionHash, amount, 0); const sig = ecdsaSign(ctx.session, digest); await fac.connect(payee).settle(ctx.sid, ctx.scopeHash, tokenAddr, payee.address, amount, resourceId, deadline, sig); // The action nonce advanced to 1; the same signature now recovers a stale digest. await expect( fac.connect(payee).settle(ctx.sid, ctx.scopeHash, tokenAddr, payee.address, amount, resourceId, deadline, sig) ).to.be.revertedWithCustomError(did, "SessionSigInvalid"); }); it("tampered terms break the signature (amount changed after signing)", async function () { const ctx = await setupAgentSession(); const tokenAddr = await token.getAddress(); const resourceId = ethers.id("terms"); const deadline = await futureTs(1800); const actionHash = await fac.paymentActionHash(tokenAddr, payee.address, resourceId, deadline); const digest = await did.actionDigest(ctx.sid, ctx.scopeHash, actionHash, 1n * ONE, 0); const sig = ecdsaSign(ctx.session, digest); // signed for 1 AERE // Provider tries to settle 2 AERE with the 1-AERE signature. await expect( fac.connect(payee).settle(ctx.sid, ctx.scopeHash, tokenAddr, payee.address, 2n * ONE, resourceId, deadline, sig) ).to.be.revertedWithCustomError(did, "SessionSigInvalid"); }); }); // ----------------------------------------------------------------------- // settle: facilitator-layer guards (deadline / payee / self-deal / pause / balance) // ----------------------------------------------------------------------- describe("settle (facilitator guards)", function () { it("rejects an expired payment deadline (DeadlinePassed)", async function () { const ctx = await setupAgentSession(); await expect(settle(ctx, { amount: 1n * ONE, deadline: 1 })).to.be.revertedWithCustomError(fac, "DeadlinePassed"); }); it("rejects a caller that is not the payee (PayeeMismatch)", async function () { const ctx = await setupAgentSession(); await expect(settle(ctx, { amount: 1n * ONE, caller: other })).to.be.revertedWithCustomError( fac, "PayeeMismatch" ); }); it("rejects a self-deal: payee == the session key (SelfDealForbidden)", async function () { const ctx = await setupAgentSession(); const tokenAddr = await token.getAddress(); const amount = 1n * ONE; const resourceId = ethers.id("self"); const deadline = await futureTs(1800); const sessionAddr = ctx.session.address; const actionHash = await fac.paymentActionHash(tokenAddr, sessionAddr, resourceId, deadline); const digest = await did.actionDigest(ctx.sid, ctx.scopeHash, actionHash, amount, 0); const sig = ecdsaSign(ctx.session, digest); // The session key must submit its own settle (msg.sender == payee guard), and pays itself. const sessionSigner = await ethers.getImpersonatedSigner(sessionAddr); await owner.sendTransaction({ to: sessionAddr, value: ONE }); await expect( fac.connect(sessionSigner).settle(ctx.sid, ctx.scopeHash, tokenAddr, sessionAddr, amount, resourceId, deadline, sig) ).to.be.revertedWithCustomError(fac, "SelfDealForbidden"); }); it("pause halts settlement; only the controller can pause/unpause", async function () { const ctx = await setupAgentSession(); await expect(fac.connect(other).setPaused(ctx.root.rootKeyId, true)).to.be.revertedWithCustomError( fac, "NotController" ); await expect(fac.connect(owner).setPaused(ctx.root.rootKeyId, true)).to.emit(fac, "PausedSet"); await expect(settle(ctx, { amount: 1n * ONE })).to.be.revertedWithCustomError(fac, "AgentPaused"); // Unpause resumes. await fac.connect(owner).setPaused(ctx.root.rootKeyId, false); await expect(settle(ctx, { amount: 1n * ONE })).to.emit(fac, "SettledPQC"); }); it("reverts when the vault is underfunded (InsufficientBalance)", async function () { // Cap allows the amount, but the vault only holds 1 AERE. const ctx = await setupAgentSession({ fund: 1n * ONE, spendCap: 100n * ONE }); await expect(settle(ctx, { amount: 2n * ONE })).to.be.revertedWithCustomError(fac, "InsufficientBalance"); }); it("reverts on an unknown session (UnknownSession from the DID)", async function () { const ctx = await setupAgentSession(); const tokenAddr = await token.getAddress(); const deadline = await futureTs(1800); await expect( fac .connect(payee) .settle(999, ctx.scopeHash, tokenAddr, payee.address, 1n * ONE, ethers.id("x"), deadline, "0x" + "00".repeat(65)) ).to.be.revertedWithCustomError(did, "UnknownSession"); }); it("rejects a zero amount (ZeroAmount)", async function () { const ctx = await setupAgentSession(); await expect(settle(ctx, { amount: 0n })).to.be.revertedWithCustomError(fac, "ZeroAmount"); }); }); // ----------------------------------------------------------------------- // seeded invariant fuzz: scope + cap + root-lifecycle + vault accounting // ----------------------------------------------------------------------- describe("seeded invariant fuzz", function () { it("vault accounting, session cap and root-lifecycle halt hold across random payments", async function () { const ctx = await setupAgentSession({ seed: "fuzz-402pqc", scheme: 2, fund: 1000n * ONE, spendCap: 1000n * ONE }); const tokenAddr = await token.getAddress(); const feeBps = 25n; let vault = 1000n * ONE; let spent = 0n; let sinkTotal = 0n; const rng = makeRng("fuzz-402pqc-amounts"); function randAmount(maxUnits) { const b = rng.bytes(2); const r = ((b[0] << 8) | b[1]) % maxUnits; return BigInt(1 + r) * (ONE / 100n); // multiples of 0.01 AERE } for (let i = 0; i < 20; i++) { const amount = randAmount(50); // up to 0.50 AERE const remaining = ctx.spendCap - spent; const resourceId = ethers.id("fuzz-" + i); const deadline = await futureTs(3600); const actionHash = await fac.paymentActionHash(tokenAddr, payee.address, resourceId, deadline); const nonce = (await did.getSession(ctx.sid)).actionNonce; const digest = await did.actionDigest(ctx.sid, ctx.scopeHash, actionHash, amount, nonce); const sig = ecdsaSign(ctx.session, digest); if (amount > remaining || amount > vault) { // Over cap or over vault: must revert, state unchanged. await expect( fac.connect(payee).settle(ctx.sid, ctx.scopeHash, tokenAddr, payee.address, amount, resourceId, deadline, sig) ).to.be.reverted; continue; } const payeeBefore = await token.balanceOf(payee.address); await fac.connect(payee).settle(ctx.sid, ctx.scopeHash, tokenAddr, payee.address, amount, resourceId, deadline, sig); const fee = (amount * feeBps) / 10_000n; vault -= amount; spent += amount; sinkTotal += fee; expect((await token.balanceOf(payee.address)) - payeeBefore).to.equal(amount - fee); expect(await fac.balances(ctx.root.rootKeyId, tokenAddr)).to.equal(vault); expect((await did.getSession(ctx.sid)).spent).to.equal(spent); } expect(await token.balanceOf(await sink.getAddress())).to.equal(sinkTotal); // Now revoke the Falcon root: every further payment halts regardless of vault/cap. await reg.connect(owner).revokeKey(ctx.root.rootKeyId); const resourceId = ethers.id("after-revoke"); const deadline = await futureTs(3600); const actionHash = await fac.paymentActionHash(tokenAddr, payee.address, resourceId, deadline); const nonce = (await did.getSession(ctx.sid)).actionNonce; const digest = await did.actionDigest(ctx.sid, ctx.scopeHash, actionHash, ONE / 100n, nonce); const sig = ecdsaSign(ctx.session, digest); await expect( fac.connect(payee).settle(ctx.sid, ctx.scopeHash, tokenAddr, payee.address, ONE / 100n, resourceId, deadline, sig) ).to.be.revertedWithCustomError(did, "SessionExpiredOrInvalid"); }); }); // ----------------------------------------------------------------------- // residual flush FUND-SAFETY (regression: unbounded flushResidual drain) // // BUG (pre-fix): flushResidual(token, amount) was external, unauthenticated and took an // ARBITRARY amount, then called SINK.flush -> transferFrom(facilitator, sink, amount) // WITHOUT any accounting. Because this facilitator CUSTODIES prepaid topUp deposits, the // contract's real token balance == the sum of every agent's vault, so anyone could call // flushResidual(token, contractBalance) and shove all vault funds to the SINK; balances[] // still showed the deposits but the tokens were gone -> withdraw()/settle() revert forever. // // FIX: settle() accrues a per-token `residual` ONLY when its best-effort fee flush fails, // and flushResidual is HARD-BOUNDED to residual[token]. It may only ever move genuine // failed-flush residual, never prepaid vault balances. Invariant preserved at all times: // tokenBalance(this) >= sum(balances[*][token]) + residual[token]. // ----------------------------------------------------------------------- describe("residual flush fund-safety (drain closed)", function () { // The historical drain PoC, now expected to REVERT and move zero vault funds. it("(a) old drain PoC REVERTS: flushResidual cannot touch prepaid vault balances", async function () { const tokenAddr = await token.getAddress(); // Two independent agents each prepay a vault on the SAME facilitator. const a = await setupAgentSession({ seed: "drain-A", fund: 100n * ONE }); const b = await setupAgentSession({ seed: "drain-B", fund: 250n * ONE }); const facAddr = await fac.getAddress(); const contractBal = await token.balanceOf(facAddr); // The contract's real token balance is exactly the sum of the two vaults. expect(contractBal).to.equal(350n * ONE); expect(await fac.residual(tokenAddr)).to.equal(0n); const sinkBefore = await token.balanceOf(await sink.getAddress()); // The exact PoC: an unrelated attacker tries to sweep the whole contract balance // (= every agent's vault) to the sink. Pre-fix this SUCCEEDED and stranded all funds. await expect( fac.connect(other).flushResidual(tokenAddr, contractBal) ).to.be.revertedWithCustomError(fac, "InsufficientResidual"); // Even 1 wei is unmovable while there is no genuine residual. await expect( fac.connect(other).flushResidual(tokenAddr, 1n) ).to.be.revertedWithCustomError(fac, "InsufficientResidual"); // Nothing left for the sink; the vaults are untouched. expect(await token.balanceOf(await sink.getAddress())).to.equal(sinkBefore); expect(await token.balanceOf(facAddr)).to.equal(contractBal); expect(await fac.balances(a.root.rootKeyId, tokenAddr)).to.equal(100n * ONE); expect(await fac.balances(b.root.rootKeyId, tokenAddr)).to.equal(250n * ONE); // The headline guarantee still holds: each controller can withdraw its full idle balance. await fac.connect(owner).withdraw(a.root.rootKeyId, tokenAddr, 100n * ONE, owner.address); await fac.connect(owner).withdraw(b.root.rootKeyId, tokenAddr, 250n * ONE, owner.address); expect(await token.balanceOf(facAddr)).to.equal(0n); }); // Vault-solvency invariant holds across a series of settle + (attempted) flush cycles // when the sink flush SUCCEEDS on the hot path (the live configuration). it("(b) invariant holds after settle+flush cycles (successful sink path)", async function () { const tokenAddr = await token.getAddress(); const ctx = await setupAgentSession({ seed: "inv-ok", fund: 100n * ONE, spendCap: 100n * ONE }); const facAddr = await fac.getAddress(); async function assertSolvent() { const bal = await token.balanceOf(facAddr); const vault = await fac.balances(ctx.root.rootKeyId, tokenAddr); const res = await fac.residual(tokenAddr); // Exact accounting (no external donations): balance == sum(vaults) + residual. expect(bal).to.equal(vault + res); // And the strict safety inequality that guarantees every vault withdraw succeeds. expect(bal >= vault).to.equal(true); } await assertSolvent(); for (let i = 0; i < 5; i++) { await settle(ctx, { amount: 3n * ONE, resourceId: ethers.id("cycle-" + i) }); // Fees flushed successfully -> no residual accrues. expect(await fac.residual(tokenAddr)).to.equal(0n); // A residual flush is a no-op-that-reverts while residual is 0. await expect(fac.connect(other).flushResidual(tokenAddr, 1n)).to.be.revertedWithCustomError( fac, "InsufficientResidual" ); await assertSolvent(); } // Vault debited by the full gross of 5 * 3 AERE; contract holds exactly the remainder. expect(await fac.balances(ctx.root.rootKeyId, tokenAddr)).to.equal(100n * ONE - 15n * ONE); expect(await token.balanceOf(facAddr)).to.equal(85n * ONE); // Controller can still recover the entire idle balance. await fac.connect(owner).withdraw(ctx.root.rootKeyId, tokenAddr, 85n * ONE, owner.address); expect(await token.balanceOf(facAddr)).to.equal(0n); }); // The failed-flush path: residual accrues EXACTLY the fee, flushResidual is bounded to it, // a genuine residual flush moves ONLY the residual, and vault funds stay whole throughout. it("(c) failed sink flush accrues bounded residual; flushResidual moves ONLY residual", async function () { const tokenAddr = await token.getAddress(); // A facilitator wired to a sink we can knock offline. const toggle = await (await ethers.getContractFactory("MockSinkToggle")).deploy(); await toggle.waitForDeployment(); const facT = await (await ethers.getContractFactory("AERE402FacilitatorPQC")).deploy( await did.getAddress(), await toggle.getAddress() ); await facT.waitForDeployment(); const facAddr = await facT.getAddress(); // Register a Falcon root, create the agent, fund the vault on facT, issue a session. const rng = makeRng("residual-accrual"); const root = await registerFalconRoot(1, owner, rng); await did.connect(owner).createAgent(root.rootKeyId); await token.connect(funder).approve(facAddr, 100n * ONE); await facT.connect(funder).topUp(root.rootKeyId, tokenAddr, 100n * ONE); const session = ethers.Wallet.createRandom(); const scopeHash = ethers.keccak256(ethers.toUtf8Bytes("inference:anthropic")); const expiry = await futureTs(3600); const sid = await issueSession(root, { sessionAddr: session.address, scopeHash, spendCap: 50n * ONE, expiry, rng, }); const ctx = { rng, root, session, scopeHash, sid }; // Settle helper targeting facT. async function settleT(amount, resTag) { const resourceId = ethers.id(resTag); const deadline = await futureTs(1800); const actionHash = await facT.paymentActionHash(tokenAddr, payee.address, resourceId, deadline); const nonce = (await did.getSession(ctx.sid)).actionNonce; const digest = await did.actionDigest(ctx.sid, ctx.scopeHash, actionHash, amount, nonce); const sig = ecdsaSign(ctx.session, digest); return facT.connect(payee).settle(ctx.sid, ctx.scopeHash, tokenAddr, payee.address, amount, resourceId, deadline, sig); } function feeOf(amount) { return (amount * 25n) / 10_000n; } function solvent() { // balance == vault + residual, and balance >= vault (withdraw always possible). return Promise.all([ token.balanceOf(facAddr), facT.balances(ctx.root.rootKeyId, tokenAddr), facT.residual(tokenAddr), ]).then(([bal, vault, res]) => { expect(bal).to.equal(vault + res); expect(bal >= vault).to.equal(true); }); } // Knock the sink offline: the fee flush now FAILS, so the fee stays as tracked residual. await toggle.setFail(true); const amt1 = 4n * ONE; await expect(settleT(amt1, "fail-1")).to.emit(facT, "ResidualAccrued"); let residual = feeOf(amt1); expect(await facT.residual(tokenAddr)).to.equal(residual); // Vault debited by the full gross; payee still paid; fee sits idle in the contract. expect(await facT.balances(ctx.root.rootKeyId, tokenAddr)).to.equal(100n * ONE - amt1); await solvent(); // A second failed settle accumulates more residual. const amt2 = 6n * ONE; await settleT(amt2, "fail-2"); residual += feeOf(amt2); expect(await facT.residual(tokenAddr)).to.equal(residual); await solvent(); // flushResidual is HARD-BOUNDED: cannot exceed the tracked residual, so it can never // reach into the (100 - amt1 - amt2) of vault funds the contract still custodies. await expect( facT.connect(other).flushResidual(tokenAddr, residual + 1n) ).to.be.revertedWithCustomError(facT, "InsufficientResidual"); // While the sink is still down, a residual flush attempt reverts and PRESERVES residual. await expect(facT.connect(other).flushResidual(tokenAddr, residual)).to.be.revertedWithCustomError( facT, "TransferFailed" ); expect(await facT.residual(tokenAddr)).to.equal(residual); await solvent(); // Recover the sink; anyone may now flush the genuine residual — and ONLY the residual. await toggle.setFail(false); const sinkBefore = await token.balanceOf(await toggle.getAddress()); const vaultBefore = await facT.balances(ctx.root.rootKeyId, tokenAddr); await expect(facT.connect(other).flushResidual(tokenAddr, residual)).to.emit(facT, "ResidualFlushed"); // Residual fully cleared; the sink received exactly the residual; vault UNCHANGED. expect(await facT.residual(tokenAddr)).to.equal(0n); expect((await token.balanceOf(await toggle.getAddress())) - sinkBefore).to.equal(residual); expect(await facT.balances(ctx.root.rootKeyId, tokenAddr)).to.equal(vaultBefore); await solvent(); // Post-flush, contract holds exactly the vault; controller withdraws it in full. expect(await token.balanceOf(facAddr)).to.equal(vaultBefore); await facT.connect(owner).withdraw(ctx.root.rootKeyId, tokenAddr, vaultBefore, owner.address); expect(await token.balanceOf(facAddr)).to.equal(0n); }); }); });