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.
749 lines
39 KiB
JavaScript
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);
|
|
});
|
|
});
|