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