// ============================================================================= // SEEDED RANDOMIZED PROPERTY / STATEFUL-INVARIANT fuzzing for the LIVE agentic // settlement facilitator: AERE402FacilitatorV2 (canonical, cross-wired to // AereAgentV2). Matched to the live addresses in sdk-js/src/addresses.ts: // AereAgentV2 0x3FAcb997eb4252341052e7c84a86fCa0513c4490 (CANONICAL registry) // AERE402FacilitatorV2 cross-wired settlement facilitator (25bps -> Sink) // (the V1 pair 0xbA6e...4E56 / 0xE963...3536 is DEPRECATED for finding F13 and is // NOT under test here.) // // Source under test: // contracts/agentic/AERE402FacilitatorV2.sol (settle + fee split + replay) // contracts/agentic/AereAgentV2.sol (prepaid registry, debit gate) // // TOOLCHAIN: hardhat-based randomized invariant fuzzing (NOT Foundry - `forge` // is not installed; the repo builds through hardhat with OZ 4.9.6 + viaIR). Every // random choice comes from mulberry32(seed); the base seed is printed at suite // start and pinnable via FUZZ_SEED. On any break, the failing // (campaign, step, actor, action, values) is embedded in the assertion message // for a minimal repro. // // SCOPE: run this file in ISOLATION (the full suite OOM/segfaults on the // unrelated ML-DSA PQC KAT tests): // npx hardhat test test/aere402-facilitator-invariant-property.test.js // No deploy to any live node, no contract-logic change, one new test file only. // Reuses existing mocks: MockERC20Lending (settlement token), MockSinkSimple // (fee-accepting sink). One minimal in-file reverting-sink mock for the // liveness/residual path. // // INVARIANTS FUZZED (exactly the AERE402Facilitator brief): // * CONSERVATION: for every settlement, the agent's registry debit == payee // credit + protocol fee, with fee == floor(amount * 25 / 10000) routed to the // Sink EXACTLY. No wei is created or lost (payeeDelta + sinkDelta == amount; // facilitator residual == 0 when the sink accepts). // * REGISTRY SOLVENCY: the registry's real token balance always equals the sum // of its internal prepaid balances[agentId][token] (no phantom credit). // * NO REPLAY: an EIP-712 authorization cannot be replayed - re-submitting the // same (agentId, payee, nonce) reverts NonceAlreadyConsumed, even re-signed // with a fresh deadline; a passed deadline reverts DeadlinePassed. // * NO CROSS-CONTRACT / CROSS-CHAIN REPLAY: a signature bound to a different // verifyingContract or a different chainId reverts InvalidSignature (domain // separation). // * NO SELF-DEAL: payee == the agent's signer key reverts SelfDealForbidden, so // a leaked signer key cannot pay itself to drain the prepaid balance. // * ONLY AUTHORIZED FUNDS MOVE: a wrong signer (InvalidSignature), a tampered // field (InvalidSignature), or a mismatched submitter (PayeeMismatch) all // revert and move zero funds; global conservation is unchanged after a burst // of such rejected attempts. // * NO OVERFLOW/UNDERFLOW: fee/split math is exact across dust..1e24 amounts; // dust amounts whose fee floors to 0 route the whole amount to the payee. // * LIVENESS: a reverting sink never blocks a user settlement - the payee is // still paid in full-minus-fee and the fee is retained as a sweepable // residual (debit == payee + residual), so no wei is lost. // // No em-dashes anywhere in this file. // ============================================================================= const { expect } = require("chai"); const { ethers, network } = require("hardhat"); const { time } = require("@nomicfoundation/hardhat-network-helpers"); /* ------------------------------- seeded PRNG ------------------------------ */ function mulberry32(a) { return function () { a |= 0; a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } const BASE_SEED = process.env.FUZZ_SEED ? Number(process.env.FUZZ_SEED) : 0x402ABCE1; function rngFor(tag, campaign) { let h = BASE_SEED ^ (campaign * 0x9e3779b1); for (let i = 0; i < tag.length; i++) h = (Math.imul(h, 31) + tag.charCodeAt(i)) | 0; return mulberry32(h >>> 0); } const ri = (rng, min, max) => min + Math.floor(rng() * (max - min + 1)); // inclusive const pick = (rng, arr) => arr[Math.floor(rng() * arr.length)]; const chance = (rng, p) => rng() < p; /* ------------------------------- misc helpers ----------------------------- */ const ONE = 10n ** 18n; const FEE_BPS = 25n; const BPS_DEN = 10_000n; const feeOf = (amount) => (amount * FEE_BPS) / BPS_DEN; const PAYMENT_AUTH_TYPES = { PaymentAuth: [ { name: "agentId", type: "uint256" }, { name: "token", type: "address" }, { name: "payee", type: "address" }, { name: "amount", type: "uint256" }, { name: "resourceId", type: "bytes32" }, { name: "nonce", type: "uint256" }, { name: "deadline", type: "uint256" }, ], }; async function domainFor(facAddr, chainId) { return { name: "AERE402Facilitator", version: "1", chainId, verifyingContract: facAddr, }; } async function setBal(addr, amountWei) { await network.provider.send("hardhat_setBalance", [addr, "0x" + amountWei.toString(16)]); } async function nowTs() { return BigInt((await ethers.provider.getBlock("latest")).timestamp); } // A random settlement amount: mix of fee-flooring dust (< 400 wei -> fee==0), // small, and large fractional values so the split math is stressed hard. function rndAmount(rng, cap) { const k = rng(); let a; if (k < 0.18) a = BigInt(ri(rng, 1, 399)); // dust: fee floors to 0 else if (k < 0.45) a = BigInt(ri(rng, 400, 100_000)); // tiny non-zero fee else if (k < 0.75) a = ONE * BigInt(ri(rng, 1, 500)) / BigInt(ri(rng, 1, 997)); // fractional else a = ONE * BigInt(ri(rng, 1, 1_000_000)); // large if (a < 1n) a = 1n; if (cap !== undefined && a > cap) a = cap; // never exceed available prepaid balance return a < 1n ? 1n : a; } // Iteration counts (env-overridable so a quick smoke can shrink them). const N = { CAMPAIGNS: Number(process.env.F402_CAMPAIGNS || 5), STEPS: Number(process.env.F402_STEPS || 60), SELFDEAL_TRIALS: Number(process.env.F402_SELFDEAL || 24), EDGE_TRIALS: Number(process.env.F402_EDGE || 40), }; // ============================================================================= describe("INVARIANT: AERE402FacilitatorV2 agentic settlement (25bps -> Sink)", function () { this.timeout(0); let signers, chainId, TokenF, SinkF, AgentF, FacF; before(async function () { signers = await ethers.getSigners(); chainId = (await ethers.provider.getNetwork()).chainId; TokenF = await ethers.getContractFactory("MockERC20Lending"); SinkF = await ethers.getContractFactory("MockSinkSimple"); AgentF = await ethers.getContractFactory("AereAgentV2"); FacF = await ethers.getContractFactory("AERE402FacilitatorV2"); console.log( ` [402] base seed 0x${BASE_SEED.toString(16)} | chainId=${chainId} | ` + `${N.CAMPAIGNS}x${N.STEPS} main + ${N.SELFDEAL_TRIALS} self-deal + ${N.EDGE_TRIALS} edge` ); }); // Deploy a fresh, cross-wired stack: token + sink + AereAgentV2 registry + // AERE402FacilitatorV2, mirroring the production companion-redeploy wiring // (registry.AERE402_FACILITATOR == facilitator, facilitator.AGENT_REGISTRY == // registry). Returns handles + addresses. async function deployStack(deployer, sinkAddrOverride) { const token = await TokenF.connect(deployer).deploy("SettleToken", "STL", 18); await token.waitForDeployment(); // Default sink is MockSinkSimple (accepts flush by pulling the fee). A test // may override with a NON-ACCEPTING sink address (a contract lacking a // flush(address,uint256) selector), which makes the facilitator's low-level // sink call return false - the "sink does not accept" branch that leaves the // fee as a sweepable residual. let sink, sinkAddr; if (sinkAddrOverride) { sink = null; sinkAddr = sinkAddrOverride; } else { sink = await SinkF.connect(deployer).deploy(); await sink.waitForDeployment(); sinkAddr = await sink.getAddress(); } // Pre-compute the facilitator's future address so the registry can pin it. const nonce = await ethers.provider.getTransactionCount(deployer.address); const futureFac = ethers.getCreateAddress({ from: deployer.address, nonce: nonce + 1 }); const agent = await AgentF.connect(deployer).deploy(futureFac); await agent.waitForDeployment(); const fac = await FacF.connect(deployer).deploy(await agent.getAddress(), sinkAddr); await fac.waitForDeployment(); expect((await fac.getAddress()).toLowerCase(), "facilitator address prediction drifted") .to.equal(futureFac.toLowerCase()); expect(await agent.AERE402_FACILITATOR(), "cross-wire agent->fac").to.equal(await fac.getAddress()); expect(await fac.AGENT_REGISTRY(), "cross-wire fac->agent").to.equal(await agent.getAddress()); return { token, sink, agent, fac, tokenAddr: await token.getAddress(), sinkAddr, agentAddr: await agent.getAddress(), facAddr: await fac.getAddress(), }; } // Register an agent whose signer is a fresh random secp256k1 wallet (the SDK // key that signs payment auths). Unlimited caps so the conservation loop is // never masked by a registry rate-limit revert. Funds the prepaid balance. async function registerAndFund(stack, operator, topUpAmount) { const signerWallet = ethers.Wallet.createRandom(); const tx = await stack.agent .connect(operator) .registerAgent(signerWallet.address, ethers.ZeroHash, ethers.ZeroHash, 0, 0, 0, ""); const agentId = (await tx.wait()).logs.find((l) => l.fragment?.name === "AgentRegistered").args.agentId; await topUp(stack, operator, agentId, topUpAmount); return { signerWallet, agentId, operator }; } async function topUp(stack, operator, agentId, amount) { await (await stack.token.mint(operator.address, amount)).wait(); await (await stack.token.connect(operator).approve(stack.agentAddr, amount)).wait(); await (await stack.agent.connect(operator).topUp(agentId, stack.tokenAddr, amount)).wait(); } async function signAuth(signerWallet, facAddr, cid, v) { const domain = await domainFor(facAddr, cid); return signerWallet.signTypedData(domain, PAYMENT_AUTH_TYPES, v); } // The global conservation battery, asserted after EVERY step. async function assertGlobal(ctx, stack, agentIds, state) { // ---- REGISTRY SOLVENCY: registry token balance == sum of internal prepaid. let sumInternal = 0n; for (const id of agentIds) sumInternal += await stack.agent.balances(id, stack.tokenAddr); const regBal = await stack.token.balanceOf(stack.agentAddr); expect(regBal, `[402 SOLVENCY] registry balance != sum(internal prepaid) ${ctx}`).to.equal(sumInternal); // ---- CONSERVATION (cumulative): everything debited landed at a payee or the // sink; nothing created or lost, and the facilitator never accretes a // residual when the sink accepts (MockSinkSimple always accepts). expect( state.toPayees + state.toSink, `[402 CONSERVE] payees(${state.toPayees})+sink(${state.toSink}) != debited(${state.debited}) ${ctx}` ).to.equal(state.debited); expect(state.toSink, `[402 FEE-SUM] cumulative sink != cumulative fee ${ctx}`).to.equal(state.fees); const facResid = await stack.token.balanceOf(stack.facAddr); expect(facResid, `[402 NO-RESIDUAL] facilitator retained dust with a live sink ${ctx}`).to.equal(0n); // ---- NO VALUE CREATION: nothing moved out beyond what was ever funded in. expect(state.debited, `[402 NO-CREATE] debited(${state.debited}) > funded(${state.funded}) ${ctx}`) .to.be.lte(state.funded); // ---- TOKEN CONSERVATION: total supply only ever grew via explicit mints. expect(await stack.token.totalSupply(), `[402 SUPPLY] token supply drifted ${ctx}`).to.equal(state.minted); } // ------------------------------------------------------------------------- it("main interleaving: conservation, exact 25bps fee, replay + auth rejection, registry solvency", async function () { let steps = 0, settled = 0, replays = 0, expired = 0, wrongPayee = 0, badSig = 0, tampered = 0, topups = 0, regs = 0, rotations = 0, pauses = 0, advances = 0, dustSettles = 0; const deployer = signers[0]; const operators = signers.slice(1, 6); // 5 operators const payees = signers.slice(10, 18); // 8 distinct payee/submitter accounts for (let c = 0; c < N.CAMPAIGNS; c++) { const rng = rngFor("main", c); const stack = await deployStack(deployer); const state = { debited: 0n, toPayees: 0n, toSink: 0n, fees: 0n, funded: 0n, minted: 0n }; const agentIds = []; const agentInfo = {}; // agentId -> { signerWallet, operator, paused } const usedTuples = []; // { agentId, payee, nonce } const nonceCursor = {}; // `${agentId}:${payee}` -> next fresh nonce // Seed 2 funded agents. for (let i = 0; i < 2; i++) { const op = pick(rng, operators); const amt = ONE * BigInt(ri(rng, 100, 5000)); state.minted += amt; state.funded += amt; const info = await registerAndFund(stack, op, amt); agentIds.push(info.agentId); agentInfo[info.agentId] = { ...info, paused: false }; regs++; } await assertGlobal(`c${c} init`, stack, agentIds, state); for (let s = 0; s < N.STEPS; s++) { const action = pick(rng, [ "settle", "settle", "settle", "settle", "settle", "replay", "expired", "wrongPayee", "badSig", "tampered", "topup", "register", "rotate", "pause", "advance", ]); const ctx = `c${c} s${s} action=${action}`; try { if (action === "settle") { const liveAgents = agentIds.filter((id) => !agentInfo[id].paused); if (liveAgents.length === 0) { steps++; continue; } const agentId = pick(rng, liveAgents); const info = agentInfo[agentId]; const bal = await stack.agent.balances(agentId, stack.tokenAddr); if (bal === 0n) { steps++; continue; } const amount = rndAmount(rng, bal); // Pick a payee distinct from the agent's signer (self-deal is a // separate negative case). let payee = pick(rng, payees); if (payee.address === info.signerWallet.address) { steps++; continue; } const key = `${agentId}:${payee.address}`; const nonce = BigInt(nonceCursor[key] || 0); nonceCursor[key] = Number(nonce) + 1; const resourceId = ethers.id(`res:${ctx}:${nonce}`); const deadline = (await nowTs()) + 3600n; const sig = await signAuth(info.signerWallet, stack.facAddr, chainId, { agentId, token: stack.tokenAddr, payee: payee.address, amount, resourceId, nonce, deadline, }); // ---- per-settle measured deltas ---- const fee = feeOf(amount); const toPayee = amount - fee; const regIntBefore = await stack.agent.balances(agentId, stack.tokenAddr); const regTokBefore = await stack.token.balanceOf(stack.agentAddr); const payeeBefore = await stack.token.balanceOf(payee.address); const sinkBefore = await stack.token.balanceOf(stack.sinkAddr); const facBefore = await stack.token.balanceOf(stack.facAddr); await (await stack.fac .connect(payee) .settle(agentId, stack.tokenAddr, payee.address, amount, resourceId, nonce, deadline, sig)).wait(); const regIntAfter = await stack.agent.balances(agentId, stack.tokenAddr); const regTokAfter = await stack.token.balanceOf(stack.agentAddr); const payeeAfter = await stack.token.balanceOf(payee.address); const sinkAfter = await stack.token.balanceOf(stack.sinkAddr); const facAfter = await stack.token.balanceOf(stack.facAddr); // debit exactly amount, from both internal accounting and real balance. expect(regIntBefore - regIntAfter, `[402 DEBIT-INT] internal debit != amount ${ctx} amt=${amount}`).to.equal(amount); expect(regTokBefore - regTokAfter, `[402 DEBIT-TOK] registry token debit != amount ${ctx} amt=${amount}`).to.equal(amount); // payee credited exactly amount - fee. expect(payeeAfter - payeeBefore, `[402 CREDIT] payee credit != amount-fee ${ctx} amt=${amount} fee=${fee}`).to.equal(toPayee); // sink credited EXACTLY the 25bps fee. expect(sinkAfter - sinkBefore, `[402 FEE] sink credit != 25bps fee ${ctx} amt=${amount} fee=${fee}`).to.equal(fee); // fee is exactly floor(amount*25/10000). expect(fee, `[402 FEE-MATH] fee != floor(amount*25/10000) ${ctx}`).to.equal((amount * 25n) / 10_000n); // no wei created or lost: payee + sink == amount; facilitator net 0. expect((payeeAfter - payeeBefore) + (sinkAfter - sinkBefore), `[402 NO-LOSS] payee+sink != amount ${ctx}`).to.equal(amount); expect(facAfter - facBefore, `[402 FAC-NET] facilitator retained wei ${ctx}`).to.equal(0n); // nonce is now consumed in the per-payee namespace. expect(await stack.fac.consumed(agentId, payee.address, nonce), `[402 CONSUMED] nonce not marked consumed ${ctx}`).to.equal(true); state.debited += amount; state.toPayees += toPayee; state.toSink += fee; state.fees += fee; usedTuples.push({ agentId, payee, nonce }); settled++; if (fee === 0n) dustSettles++; } else if (action === "replay") { if (usedTuples.length === 0) { steps++; continue; } const t = pick(rng, usedTuples); const info = agentInfo[t.agentId]; // Re-sign the SAME (agentId, payee, nonce) with a FRESH deadline + // amount so only the nonce collision can be the cause. Must still // revert NonceAlreadyConsumed (true replay is always blocked). const bal = await stack.agent.balances(t.agentId, stack.tokenAddr); const amount = bal > 0n ? (bal > ONE ? ONE : bal) : 1n; const deadline = (await nowTs()) + 3600n; const resourceId = ethers.id(`replay:${ctx}`); const sig = await signAuth(info.signerWallet, stack.facAddr, chainId, { agentId: t.agentId, token: stack.tokenAddr, payee: t.payee.address, amount, resourceId, nonce: t.nonce, deadline, }); await expect( stack.fac.connect(t.payee).settle(t.agentId, stack.tokenAddr, t.payee.address, amount, resourceId, t.nonce, deadline, sig), `[402 REPLAY] true replay not rejected ${ctx}` ).to.be.revertedWithCustomError(stack.fac, "NonceAlreadyConsumed"); replays++; } else if (action === "expired") { const agentId = pick(rng, agentIds); const info = agentInfo[agentId]; const payee = pick(rng, payees); if (payee.address === info.signerWallet.address) { steps++; continue; } const key = `${agentId}:${payee.address}`; const nonce = BigInt(nonceCursor[key] || 0); // fresh, so DeadlinePassed fires first const deadline = 1n; // long past const resourceId = ethers.id(`exp:${ctx}`); const sig = await signAuth(info.signerWallet, stack.facAddr, chainId, { agentId, token: stack.tokenAddr, payee: payee.address, amount: ONE, resourceId, nonce, deadline, }); await expect( stack.fac.connect(payee).settle(agentId, stack.tokenAddr, payee.address, ONE, resourceId, nonce, deadline, sig), `[402 EXPIRED] expired auth not rejected ${ctx}` ).to.be.revertedWithCustomError(stack.fac, "DeadlinePassed"); expired++; } else if (action === "wrongPayee") { const agentId = pick(rng, agentIds); const info = agentInfo[agentId]; const payee = pick(rng, payees); let submitter = pick(rng, payees); if (submitter.address === payee.address) { steps++; continue; } if (payee.address === info.signerWallet.address) { steps++; continue; } const key = `${agentId}:${payee.address}`; const nonce = BigInt(nonceCursor[key] || 0); const deadline = (await nowTs()) + 3600n; const resourceId = ethers.id(`wp:${ctx}`); const sig = await signAuth(info.signerWallet, stack.facAddr, chainId, { agentId, token: stack.tokenAddr, payee: payee.address, amount: ONE, resourceId, nonce, deadline, }); // A third party submits an auth intended for `payee`. await expect( stack.fac.connect(submitter).settle(agentId, stack.tokenAddr, payee.address, ONE, resourceId, nonce, deadline, sig), `[402 WRONGPAYEE] submitter != payee not rejected ${ctx}` ).to.be.revertedWithCustomError(stack.fac, "PayeeMismatch"); wrongPayee++; } else if (action === "badSig") { const agentId = pick(rng, agentIds); const info = agentInfo[agentId]; const payee = pick(rng, payees); if (payee.address === info.signerWallet.address) { steps++; continue; } const key = `${agentId}:${payee.address}`; const nonce = BigInt(nonceCursor[key] || 0); // fresh -> not blocked by nonce const deadline = (await nowTs()) + 3600n; const resourceId = ethers.id(`bs:${ctx}`); // Signed by an UNREGISTERED random key, not the agent's signer. const rogue = ethers.Wallet.createRandom(); const sig = await signAuth(rogue, stack.facAddr, chainId, { agentId, token: stack.tokenAddr, payee: payee.address, amount: ONE, resourceId, nonce, deadline, }); await expect( stack.fac.connect(payee).settle(agentId, stack.tokenAddr, payee.address, ONE, resourceId, nonce, deadline, sig), `[402 BADSIG] unauthorized signer moved funds ${ctx}` ).to.be.revertedWithCustomError(stack.fac, "InvalidSignature"); badSig++; } else if (action === "tampered") { const liveAgents = agentIds.filter((id) => !agentInfo[id].paused); if (liveAgents.length === 0) { steps++; continue; } const agentId = pick(rng, liveAgents); const info = agentInfo[agentId]; const payee = pick(rng, payees); if (payee.address === info.signerWallet.address) { steps++; continue; } const key = `${agentId}:${payee.address}`; const nonce = BigInt(nonceCursor[key] || 0); const deadline = (await nowTs()) + 3600n; const resourceId = ethers.id(`tmp:${ctx}`); // Sign for `amount`, then submit with a mutated amount -> digest // mismatch -> recovered signer != expected -> InvalidSignature. const signedAmount = ONE; const submittedAmount = ONE + 1n; const sig = await signAuth(info.signerWallet, stack.facAddr, chainId, { agentId, token: stack.tokenAddr, payee: payee.address, amount: signedAmount, resourceId, nonce, deadline, }); await expect( stack.fac.connect(payee).settle(agentId, stack.tokenAddr, payee.address, submittedAmount, resourceId, nonce, deadline, sig), `[402 TAMPER] tampered field accepted ${ctx}` ).to.be.revertedWithCustomError(stack.fac, "InvalidSignature"); tampered++; } else if (action === "topup") { const agentId = pick(rng, agentIds); const op = agentInfo[agentId].operator; const amt = ONE * BigInt(ri(rng, 10, 2000)); state.minted += amt; state.funded += amt; await topUp(stack, op, agentId, amt); topups++; } else if (action === "register") { if (agentIds.length >= 6) { steps++; continue; } const op = pick(rng, operators); const amt = ONE * BigInt(ri(rng, 50, 3000)); state.minted += amt; state.funded += amt; const info = await registerAndFund(stack, op, amt); agentIds.push(info.agentId); agentInfo[info.agentId] = { ...info, paused: false }; regs++; } else if (action === "rotate") { // Rotating the signer invalidates any previously-signed-but-unsubmitted // auth. We assert the OLD signer can no longer authorize a settlement. const agentId = pick(rng, agentIds.filter((id) => !agentInfo[id].paused)); if (agentId === undefined) { steps++; continue; } const info = agentInfo[agentId]; const oldSigner = info.signerWallet; const newSigner = ethers.Wallet.createRandom(); await (await stack.agent.connect(info.operator).setSigner(agentId, newSigner.address)).wait(); agentInfo[agentId].signerWallet = newSigner; // Old signer's fresh, correctly-formed auth must now be rejected. const payee = pick(rng, payees); if (payee.address !== newSigner.address && payee.address !== oldSigner.address) { const key = `${agentId}:${payee.address}`; const nonce = BigInt(nonceCursor[key] || 0); const deadline = (await nowTs()) + 3600n; const resourceId = ethers.id(`rot:${ctx}`); const sig = await signAuth(oldSigner, stack.facAddr, chainId, { agentId, token: stack.tokenAddr, payee: payee.address, amount: ONE, resourceId, nonce, deadline, }); await expect( stack.fac.connect(payee).settle(agentId, stack.tokenAddr, payee.address, ONE, resourceId, nonce, deadline, sig), `[402 ROTATE] rotated-out signer still authorizes ${ctx}` ).to.be.revertedWithCustomError(stack.fac, "InvalidSignature"); } rotations++; } else if (action === "pause") { const agentId = pick(rng, agentIds); const info = agentInfo[agentId]; const newPaused = !info.paused; await (await stack.agent.connect(info.operator).setPaused(agentId, newPaused)).wait(); agentInfo[agentId].paused = newPaused; if (newPaused) { // A settle against a paused agent must revert (registry gate); no funds move. const payee = pick(rng, payees); if (payee.address !== info.signerWallet.address) { const key = `${agentId}:${payee.address}`; const nonce = BigInt(nonceCursor[key] || 0); const deadline = (await nowTs()) + 3600n; const resourceId = ethers.id(`pz:${ctx}`); const sig = await signAuth(info.signerWallet, stack.facAddr, chainId, { agentId, token: stack.tokenAddr, payee: payee.address, amount: ONE, resourceId, nonce, deadline, }); await expect( stack.fac.connect(payee).settle(agentId, stack.tokenAddr, payee.address, ONE, resourceId, nonce, deadline, sig), `[402 PAUSED] paused agent still settled ${ctx}` ).to.be.revertedWithCustomError(stack.agent, "AgentPausedErr"); } } pauses++; } else if (action === "advance") { await time.increase(ri(rng, 1, 2 * 24 * 60 * 60)); await network.provider.send("evm_mine"); advances++; } } catch (e) { // A genuine invariant break must always propagate. Registry-side // reverts on edge amounts (InsufficientBalance etc.) are legal and // leave state unchanged, so the global battery below still holds. if (/402 [A-Z]|INVARIANT/.test(String(e.message))) throw e; if (!/InsufficientBalance|reverted/i.test(String(e.message))) throw e; } await assertGlobal(ctx, stack, agentIds, state); steps++; } } console.log( ` [402 main] steps=${steps} settled=${settled} (dust=${dustSettles}) replay=${replays} ` + `expired=${expired} wrongPayee=${wrongPayee} badSig=${badSig} tamper=${tampered} ` + `topup=${topups} reg=${regs} rotate=${rotations} pause=${pauses} adv=${advances}` ); expect(steps).to.be.greaterThan(200); expect(settled, "not enough successful settlements to be meaningful").to.be.greaterThan(40); expect(dustSettles, "never hit the fee-floors-to-0 dust edge").to.be.greaterThan(0); expect(replays + expired + wrongPayee + badSig + tampered, "not enough rejection coverage").to.be.greaterThan(20); }); // ------------------------------------------------------------------------- // NO SELF-DEAL: a leaked signer key cannot pay ITSELF to drain the prepaid // balance. payee == expected signer must always revert SelfDealForbidden and // move zero funds, across randomized amounts. it("self-deal: payee == signer always reverts SelfDealForbidden and moves nothing", async function () { const deployer = signers[0]; const operator = signers[1]; const stack = await deployStack(deployer); let trials = 0; const rng = rngFor("selfdeal", 0); for (let i = 0; i < N.SELFDEAL_TRIALS; i++) { // The signer key must be able to SEND the settle tx (msg.sender == payee == // signer), so fund the random wallet with ETH and connect it as a sender. const signerWallet = ethers.Wallet.createRandom().connect(ethers.provider); await setBal(signerWallet.address, ONE * 10n); const topAmt = ONE * BigInt(ri(rng, 10, 1000)); const tx = await stack.agent .connect(operator) .registerAgent(signerWallet.address, ethers.ZeroHash, ethers.ZeroHash, 0, 0, 0, ""); const agentId = (await tx.wait()).logs.find((l) => l.fragment?.name === "AgentRegistered").args.agentId; await topUp(stack, operator, agentId, topAmt); const amount = rndAmount(rng, topAmt); const deadline = (await nowTs()) + 3600n; const resourceId = ethers.id(`selfdeal:${i}`); const nonce = 0n; const sig = await signAuth(signerWallet, stack.facAddr, chainId, { agentId, token: stack.tokenAddr, payee: signerWallet.address, amount, resourceId, nonce, deadline, }); const regIntBefore = await stack.agent.balances(agentId, stack.tokenAddr); const signerBalBefore = await stack.token.balanceOf(signerWallet.address); await expect( stack.fac .connect(signerWallet) .settle(agentId, stack.tokenAddr, signerWallet.address, amount, resourceId, nonce, deadline, sig), `[402 SELFDEAL] i${i} signer paid itself amt=${amount}` ).to.be.revertedWithCustomError(stack.fac, "SelfDealForbidden"); // Nothing moved: prepaid balance and signer token balance unchanged; nonce // NOT consumed (the revert rolled everything back). expect(await stack.agent.balances(agentId, stack.tokenAddr), `[402 SELFDEAL-BAL] i${i}`).to.equal(regIntBefore); expect(await stack.token.balanceOf(signerWallet.address), `[402 SELFDEAL-TOK] i${i}`).to.equal(signerBalBefore); expect(await stack.fac.consumed(agentId, signerWallet.address, nonce), `[402 SELFDEAL-NONCE] i${i}`).to.equal(false); trials++; } console.log(` [402 self-deal] trials=${trials} all reverted SelfDealForbidden`); expect(trials).to.equal(N.SELFDEAL_TRIALS); }); // ------------------------------------------------------------------------- // NO CROSS-CONTRACT / CROSS-CHAIN REPLAY: the EIP-712 domain binds each auth // to a specific verifyingContract AND chainId, so a signature minted for a // different facilitator or a different chain cannot be replayed here. it("domain separation: cross-contract and cross-chain signatures both revert InvalidSignature", async function () { const deployer = signers[0]; const operator = signers[1]; const payee = signers[10]; // Two independent, identically-configured facilitators, each with its OWN // registry, sharing the SAME agent signer key so ONLY the verifyingContract // differs between the two domains. const stackA = await deployStack(deployer); const stackB = await deployStack(deployer); const signerWallet = ethers.Wallet.createRandom(); const amt = ONE * 10n; // Register the same signer on both registries and fund A. const txA = await stackA.agent.connect(operator).registerAgent(signerWallet.address, ethers.ZeroHash, ethers.ZeroHash, 0, 0, 0, ""); const agentIdA = (await txA.wait()).logs.find((l) => l.fragment?.name === "AgentRegistered").args.agentId; await topUp(stackA, operator, agentIdA, ONE * 100n); const txB = await stackB.agent.connect(operator).registerAgent(signerWallet.address, ethers.ZeroHash, ethers.ZeroHash, 0, 0, 0, ""); const agentIdB = (await txB.wait()).logs.find((l) => l.fragment?.name === "AgentRegistered").args.agentId; // agentIds happen to match (both 0), which makes the cross-contract replay // maximally dangerous if the domain did NOT bind the verifyingContract. expect(agentIdA).to.equal(agentIdB); const deadline = (await nowTs()) + 3600n; const resourceId = ethers.id("cross:res"); const value = { agentId: agentIdA, token: stackA.tokenAddr, payee: payee.address, amount: amt, resourceId, nonce: 7n, deadline }; // (1) CROSS-CONTRACT: signed for facilitator B, submitted to facilitator A. const sigForB = await signAuth(signerWallet, stackB.facAddr, chainId, value); await expect( stackA.fac.connect(payee).settle(agentIdA, stackA.tokenAddr, payee.address, amt, resourceId, 7n, deadline, sigForB), "[402 XCONTRACT] a signature for facilitator B settled on facilitator A" ).to.be.revertedWithCustomError(stackA.fac, "InvalidSignature"); // (2) CROSS-CHAIN: signed with a foreign chainId, submitted here. const sigForOtherChain = await signAuth(signerWallet, stackA.facAddr, chainId + 1n, value); await expect( stackA.fac.connect(payee).settle(agentIdA, stackA.tokenAddr, payee.address, amt, resourceId, 7n, deadline, sigForOtherChain), "[402 XCHAIN] a foreign-chain signature settled here" ).to.be.revertedWithCustomError(stackA.fac, "InvalidSignature"); // (3) CONTROL: the correctly-domained signature DOES settle, proving only the // domain (not some other field) was the reason (1)+(2) failed. const sigForA = await signAuth(signerWallet, stackA.facAddr, chainId, value); const payeeBefore = await stackA.token.balanceOf(payee.address); await (await stackA.fac.connect(payee).settle(agentIdA, stackA.tokenAddr, payee.address, amt, resourceId, 7n, deadline, sigForA)).wait(); expect(await stackA.token.balanceOf(payee.address) - payeeBefore).to.equal(amt - feeOf(amt)); console.log(" [402 domain] cross-contract + cross-chain rejected; same-domain control settled"); }); // ------------------------------------------------------------------------- // LIVENESS + RESIDUAL CONSERVATION: a sink whose flush() reverts must NEVER // block a user settlement. The payee is still paid amount-fee, and the fee is // retained in the facilitator as a sweepable residual, so debit == payee + // residual (no wei lost). Randomized amounts. it("sink-failure liveness: non-accepting sink never blocks settlement; fee retained as exact residual", async function () { const deployer = signers[0]; const operator = signers[1]; const payees = signers.slice(10, 16); // A NON-ACCEPTING sink: a plain token contract with no flush(address,uint256) // selector, so the facilitator's low-level SINK.call(flush(...)) returns false // (identical outcome to a sink whose flush() reverts). The best-effort call is // swallowed and the fee is left as a facilitator residual. const deadSink = await TokenF.connect(deployer).deploy("DeadSink", "DEAD", 18); await deadSink.waitForDeployment(); const stack = await deployStack(deployer, await deadSink.getAddress()); const signerWallet = ethers.Wallet.createRandom(); const tx = await stack.agent.connect(operator).registerAgent(signerWallet.address, ethers.ZeroHash, ethers.ZeroHash, 0, 0, 0, ""); const agentId = (await tx.wait()).logs.find((l) => l.fragment?.name === "AgentRegistered").args.agentId; await topUp(stack, operator, agentId, ONE * 100_000n); const rng = rngFor("sinkfail", 0); let trials = 0, feeAccrued = 0n, debited = 0n, paid = 0n, dust = 0; for (let i = 0; i < N.EDGE_TRIALS; i++) { const bal = await stack.agent.balances(agentId, stack.tokenAddr); if (bal === 0n) break; const amount = rndAmount(rng, bal > ONE * 1000n ? ONE * 1000n : bal); const payee = pick(rng, payees); if (payee.address === signerWallet.address) continue; const key = `sf:${payee.address}`; // simple per-payee monotonic nonce const nonce = BigInt(i); const deadline = (await nowTs()) + 3600n; const resourceId = ethers.id(`sinkfail:${i}`); const sig = await signAuth(signerWallet, stack.facAddr, chainId, { agentId, token: stack.tokenAddr, payee: payee.address, amount, resourceId, nonce, deadline, }); const fee = feeOf(amount); const payeeBefore = await stack.token.balanceOf(payee.address); const facBefore = await stack.token.balanceOf(stack.facAddr); const regIntBefore = await stack.agent.balances(agentId, stack.tokenAddr); // Despite the sink reverting inside settle, the tx SUCCEEDS (best-effort // sink call is swallowed). await (await stack.fac.connect(payee).settle(agentId, stack.tokenAddr, payee.address, amount, resourceId, nonce, deadline, sig)).wait(); const payeeAfter = await stack.token.balanceOf(payee.address); const facAfter = await stack.token.balanceOf(stack.facAddr); const regIntAfter = await stack.agent.balances(agentId, stack.tokenAddr); expect(regIntBefore - regIntAfter, `[402 SF-DEBIT] i${i}`).to.equal(amount); expect(payeeAfter - payeeBefore, `[402 SF-PAY] payee not paid amount-fee i${i}`).to.equal(amount - fee); // fee could not reach the sink, so it stays as facilitator residual. expect(facAfter - facBefore, `[402 SF-RESIDUAL] residual != fee i${i} fee=${fee}`).to.equal(fee); // CONSERVATION under sink failure: debit == payee credit + retained residual. expect((payeeAfter - payeeBefore) + (facAfter - facBefore), `[402 SF-CONSERVE] i${i}`).to.equal(amount); debited += amount; paid += amount - fee; feeAccrued += fee; if (fee === 0n) dust++; trials++; } // Total residual sitting in the facilitator equals every un-flushed fee. expect(await stack.token.balanceOf(stack.facAddr), "[402 SF-TOTAL] residual != sum of fees").to.equal(feeAccrued); expect(paid + feeAccrued, "[402 SF-SUM] paid+residual != debited").to.equal(debited); console.log( ` [402 sink-fail] trials=${trials} (dust=${dust}) debited=${ethers.formatEther(debited)} ` + `paid=${ethers.formatEther(paid)} residual=${ethers.formatEther(feeAccrued)} (all settlements succeeded)` ); expect(trials).to.be.greaterThan(10); }); });