aere-contracts/test/aere402-facilitator-invariant-property.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

749 lines
39 KiB
JavaScript

// =============================================================================
// 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);
});
});