aere-contracts/test/erc8004-adapters.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

604 lines
29 KiB
JavaScript

// ERC-8004 + x402/AP2 conformance adapters over Aere's LIVE, already-deployed agent stack.
//
// The three adapters wrap (never redeploy) the live primitives:
// - AereIdentityRegistry8004 over AereAgentDID (agent identity / DID)
// - AereReputationRegistry8004 over AereAIReputation (attest / read feedback)
// - AereValidationRegistry8004 over AerePQCKeyRegistry (POST-QUANTUM request/respond proof)
// plus AereAP2MandateVerifier: a Falcon-512-signed x402/AP2 spend mandate.
//
// Aere has no PQC precompile in the local Hardhat EVM, so we install the repo's MockPQCPrecompile
// at 0x0AE1 (Falcon-512) and 0x0AE3 (ML-DSA-44) via hardhat_setCode, exactly as the fault-harness
// AereRecoveryRegistry test does. The mock parses the input at the LIVE precompile's spec offsets
// and accepts iff commitment == keccak256(pk || message), so a positive result here proves the
// adapter routed the correct input to the correct precompile and enforces fail-closed semantics.
//
// [MEASURE] The REAL precompile path (that on-chain 0x0AE1 / 0x0AE3 accept a genuine Falcon-512 /
// ML-DSA-44 signature) is exercised separately against LIVE mainnet 2800 via eth_call (the same way
// AerePQCKeyRegistry / AerePQCAttestation are proven) and is NOT re-proven inside this unit test.
// The adapters stay hard-wired to the live registry / precompiles for production.
//
// Run: npx hardhat test test/erc8004-adapters.test.js
const { expect } = require("chai");
const { ethers, network } = require("hardhat");
const PRECOMPILE = {
1: ethers.getAddress("0x0000000000000000000000000000000000000ae1"), // Falcon-512
3: ethers.getAddress("0x0000000000000000000000000000000000000ae3"), // ML-DSA-44
};
const META = {
1: { pkLen: 897, pkHeader: 0x09, esigHeader: 0x29, falcon: true }, // Falcon-512
3: { pkLen: 1312, sigLen: 2420, falcon: false }, // ML-DSA-44
};
const REG_STATUS_ACTIVE = 1;
// ---- deterministic seeded RNG (keccak hash-chain) so the test is fully reproducible ----
function makeRng(seedText) {
let seed = ethers.keccak256(ethers.toUtf8Bytes(seedText));
return {
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;
},
};
}
function makePubKey(scheme, rng) {
const m = META[scheme];
const raw = rng.bytes(m.pkLen);
if (m.falcon) raw[0] = m.pkHeader; // Falcon requires the header byte
return ethers.hexlify(raw);
}
function commitmentFor(pubKey, message) {
return ethers.keccak256(ethers.concat([pubKey, message]));
}
// A genuine PQC signature envelope binding `message` to `pubKey` under the mock's semantics.
function genuine(scheme, pubKey, message, rng) {
const m = META[scheme];
const commitment = commitmentFor(pubKey, message);
if (m.falcon) {
// Falcon: envelope = nonce(40) || esig ; esig = esigHeader || commitment(32) => 73 bytes.
const nonce = rng.bytes(40);
const esig = ethers.concat([new Uint8Array([m.esigHeader]), commitment]);
return ethers.hexlify(ethers.concat([nonce, esig]));
}
// ML-DSA / fixed: sig = commitment(32) || filler ; total sigLen bytes.
const filler = rng.bytes(m.sigLen - 32);
return ethers.hexlify(ethers.concat([commitment, filler]));
}
// Flip one byte inside the commitment region (a genuine forgery the precompile must reject).
function tamper(scheme, sigHex) {
const b = ethers.getBytes(sigHex);
const off = META[scheme].falcon ? 41 : 0; // Falcon commitment starts at 41; ML-DSA at 0.
b[off] ^= 0x01;
return ethers.hexlify(b);
}
async function futureTs(secs) {
const b = await ethers.provider.getBlock("latest");
return b.timestamp + secs;
}
describe("ERC-8004 + x402/AP2 adapters over Aere's live agent stack", function () {
let owner, other, foundation;
let reg, did, identity, validation, mandate;
let mockCode;
before(async function () {
[owner, other, foundation] = await ethers.getSigners();
mockCode = {};
const F = await ethers.getContractFactory("MockPQCPrecompile");
for (const scheme of [1, 3]) {
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, 3]) {
await network.provider.send("hardhat_setCode", [PRECOMPILE[scheme], mockCode[scheme]]);
}
}
// Register a PQC key under `signer` in the live registry (proof-of-possession via the precompile).
async function registerKey(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 { keyId: Number(log.args.keyId), pk, scheme };
}
// A Falcon root -> DID agent -> registered ERC-8004 identity, all over the live primitives.
async function makeAgent(seed) {
const rng = makeRng(seed);
const root = await registerKey(1, owner, rng);
await did.connect(owner).createAgent(root.keyId);
const domain = `agent-${seed}.aere.network`;
const card = `https://${domain}/.well-known/agent-card.json`;
await identity.connect(owner).register(root.keyId, domain, card);
return { rng, agentId: root.keyId, rootPk: root.pk, domain, card };
}
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(),
ethers.ZeroAddress, // agentBond cross-link (informational; unused by the adapters)
ethers.ZeroAddress // aiReputation cross-link (informational)
);
await did.waitForDeployment();
identity = await (await ethers.getContractFactory("AereIdentityRegistry8004")).deploy(await did.getAddress());
await identity.waitForDeployment();
validation = await (await ethers.getContractFactory("AereValidationRegistry8004")).deploy(
await reg.getAddress(),
await did.getAddress()
);
await validation.waitForDeployment();
// Mandate verifier with an OPEN accounting path (SETTLEMENT_EXECUTOR = 0) for verification.
mandate = await (await ethers.getContractFactory("AereAP2MandateVerifier")).deploy(
await reg.getAddress(),
ethers.ZeroAddress
);
await mandate.waitForDeployment();
});
// =========================================================================
// 1. Identity adapter over AereAgentDID
// =========================================================================
describe("AereIdentityRegistry8004 (identity: register / resolve)", function () {
it("wires the live DID and reads the registry from it", async function () {
expect(await identity.DID()).to.equal(await did.getAddress());
expect(await identity.KEY_REGISTRY()).to.equal(await reg.getAddress());
});
it("registers an existing DID agent with a DID string + AgentCard URI, and resolves it", async function () {
const a = await makeAgent("id-1");
const info = await identity.getAgent(a.agentId);
expect(info.agentId).to.equal(BigInt(a.agentId));
expect(info.did).to.equal(`did:aere:31337:${a.agentId}`); // chainId 31337 in Hardhat
expect(info.agentDomain).to.equal(a.domain);
expect(info.agentCardURI).to.equal(a.card);
expect(info.agentAddress).to.equal(owner.address); // current controller, read live from the registry
expect(info.registered).to.equal(true);
// Resolve by domain and by controller address.
const byDomain = await identity.resolveByDomain(a.domain);
expect(byDomain.agentId).to.equal(BigInt(a.agentId));
const byAddr = await identity.resolveByAddress(owner.address);
expect(byAddr.agentId).to.equal(BigInt(a.agentId));
expect(await identity.isRegistered(a.agentId)).to.equal(true);
expect(await identity.agentCount()).to.equal(1n);
});
it("only the agent's controller (Falcon-root owner) can register / update", async function () {
const rng = makeRng("id-ctrl");
const root = await registerKey(1, owner, rng);
await did.connect(owner).createAgent(root.keyId);
// A stranger cannot bind metadata to someone else's agent.
await expect(
identity.connect(other).register(root.keyId, "evil.aere.network", "ipfs://x")
).to.be.revertedWithCustomError(identity, "NotController");
// The controller can, then update.
await identity.connect(owner).register(root.keyId, "ok.aere.network", "ipfs://card1");
await expect(
identity.connect(other).updateAgent(root.keyId, "ok2.aere.network", "ipfs://card2")
).to.be.revertedWithCustomError(identity, "NotController");
await identity.connect(owner).updateAgent(root.keyId, "ok2.aere.network", "ipfs://card2");
const info = await identity.getAgent(root.keyId);
expect(info.agentDomain).to.equal("ok2.aere.network");
expect(info.agentCardURI).to.equal("ipfs://card2");
});
it("reverts for an unknown DID agent and rejects duplicate domains", async function () {
await expect(identity.connect(owner).register(999, "d.aere.network", "u")).to.be.revertedWithCustomError(
identity,
"UnknownAgent"
);
const a = await makeAgent("id-dup");
const rng = makeRng("id-dup2");
const root = await registerKey(1, owner, rng);
await did.connect(owner).createAgent(root.keyId);
await expect(identity.connect(owner).register(root.keyId, a.domain, "u")).to.be.revertedWithCustomError(
identity,
"DomainTaken"
);
});
});
// =========================================================================
// 2. Reputation adapter over AereAIReputation (attest / read)
// =========================================================================
describe("AereReputationRegistry8004 (reputation: attest / read feedback)", function () {
let token, sink, bond, rep, repAdapter;
async function deployRep(attestorAdapter) {
token = await (await ethers.getContractFactory("MockERC20Lending")).deploy("AERE", "AERE", 18);
await token.waitForDeployment();
sink = await (await ethers.getContractFactory("MockSinkSimple")).deploy();
await sink.waitForDeployment();
bond = await (await ethers.getContractFactory("AereAgentBond")).deploy(
await token.getAddress(),
await sink.getAddress(),
[foundation.address]
);
await bond.waitForDeployment();
if (attestorAdapter) {
// The live AereAIReputation attestor set is fixed at deploy with no add path, so to let the
// adapter WRITE feedback through it, the reputation instance must list the adapter as an
// attestor. Predict the adapter address so it can be authorized at reputation deploy.
const nonce = await owner.getNonce();
const predicted = ethers.getCreateAddress({ from: owner.address, nonce: nonce + 1 });
rep = await (await ethers.getContractFactory("AereAIReputation")).deploy(await bond.getAddress(), [predicted]);
await rep.waitForDeployment();
repAdapter = await (await ethers.getContractFactory("AereReputationRegistry8004")).deploy(
await rep.getAddress(),
await identity.getAddress()
);
await repAdapter.waitForDeployment();
expect(await repAdapter.getAddress()).to.equal(predicted);
// M1: giveFeedback is gated to an owner-curated author allowlist. Authorize `other` so the
// existing feedback flows below (which submit as `other`) exercise the write path.
await repAdapter.connect(owner).addFeedbackAuthor(other.address);
} else {
// Reputation that does NOT authorize the adapter (mirrors the immutable mainnet Foundation set).
rep = await (await ethers.getContractFactory("AereAIReputation")).deploy(await bond.getAddress(), [
foundation.address,
]);
await rep.waitForDeployment();
repAdapter = await (await ethers.getContractFactory("AereReputationRegistry8004")).deploy(
await rep.getAddress(),
await identity.getAddress()
);
await repAdapter.waitForDeployment();
// M1: authorize `other` as a feedback author (owner-curated allowlist).
await repAdapter.connect(owner).addFeedbackAuthor(other.address);
}
}
it("forwards ERC-8004 feedback into the live reputation and reads the score back", async function () {
const a = await makeAgent("rep-1");
await deployRep(true);
// Maps the ERC-8004 agentId to exactly one live (operator, agentKey) slot.
const [operator, agentKey] = await repAdapter.liveKeyOf(a.agentId);
expect(operator).to.equal(owner.address); // the agent's controller
expect(agentKey).to.equal(ethers.zeroPadValue(ethers.toBeHex(a.agentId), 32));
expect(await repAdapter.canWriteThrough()).to.equal(true);
// Attest a positive interaction through the adapter; it forwards to the live attest.
const ev1 = ethers.keccak256(ethers.toUtf8Bytes("evidence-1"));
await expect(repAdapter.connect(other).giveFeedback(a.agentId, 1, ev1, "ipfs://ev1")).to.emit(
repAdapter,
"NewFeedback"
);
// Read the canonical live score straight from AereAIReputation via the adapter.
// scoreOf = positiveCount*10 (no slash, no dispute) => 1 positive => 10.
expect(await repAdapter.getScore(a.agentId)).to.equal(10n);
const stats = await repAdapter.getStats(a.agentId);
expect(stats.positiveCount).to.equal(1n);
expect(stats.disputeCount).to.equal(0n);
// A dispute (-1) lowers the score; two positives raise it.
const ev2 = ethers.keccak256(ethers.toUtf8Bytes("evidence-2"));
await repAdapter.connect(other).giveFeedback(a.agentId, 1, ev2, "");
const ev3 = ethers.keccak256(ethers.toUtf8Bytes("evidence-3"));
await repAdapter.connect(other).giveFeedback(a.agentId, -1, ev3, "");
// positive=2 (=>20), dispute=1 (=>-20) => 0.
expect(await repAdapter.getScore(a.agentId)).to.equal(0n);
const stats2 = await repAdapter.getStats(a.agentId);
expect(stats2.positiveCount).to.equal(2n);
expect(stats2.disputeCount).to.equal(1n);
});
it("rejects feedback for an unregistered agent and an out-of-range delta", async function () {
await deployRep(true); // `other` is authorized as a feedback author in deployRep
await expect(repAdapter.connect(other).giveFeedback(12345, 1, ethers.id("x"), "")).to.be.revertedWithCustomError(
repAdapter,
"AgentNotRegistered"
);
const a = await makeAgent("rep-bad");
await expect(repAdapter.connect(other).giveFeedback(a.agentId, 2, ethers.id("x"), "")).to.be.revertedWithCustomError(
repAdapter,
"InvalidDelta"
);
});
// M1 (MEDIUM) fix: giveFeedback is gated to an owner-curated allowlist so the adapter cannot be an
// open reputation firehose once it is a live attestor. Proves: an unauthorized caller reverts, an
// authorized caller succeeds, revocation re-blocks, and only the owner may manage the allowlist.
it("gates giveFeedback to owner-authorized feedback authors (M1)", async function () {
const a = await makeAgent("rep-gate");
await deployRep(true); // adapter is a live attestor; deployRep authorizes `other`, not `foundation`
expect(await repAdapter.canWriteThrough()).to.equal(true);
// An UNAUTHORIZED caller cannot write, even though the adapter is a live attestor.
expect(await repAdapter.isAuthorizedFeedbackAuthor(foundation.address)).to.equal(false);
const ev = ethers.keccak256(ethers.toUtf8Bytes("evidence-gate"));
await expect(
repAdapter.connect(foundation).giveFeedback(a.agentId, 1, ev, "")
).to.be.revertedWithCustomError(repAdapter, "NotAuthorizedFeedbackAuthor");
expect(await repAdapter.getScore(a.agentId)).to.equal(0n); // nothing written
// Owner authorizes `foundation`; the SAME call now succeeds and moves the live score.
await expect(repAdapter.connect(owner).addFeedbackAuthor(foundation.address))
.to.emit(repAdapter, "FeedbackAuthorSet")
.withArgs(foundation.address, true);
await expect(repAdapter.connect(foundation).giveFeedback(a.agentId, 1, ev, "")).to.emit(repAdapter, "NewFeedback");
expect(await repAdapter.getScore(a.agentId)).to.equal(10n);
// Owner revokes; the caller is fail-closed blocked again.
await repAdapter.connect(owner).removeFeedbackAuthor(foundation.address);
const ev2 = ethers.keccak256(ethers.toUtf8Bytes("evidence-gate-2"));
await expect(
repAdapter.connect(foundation).giveFeedback(a.agentId, 1, ev2, "")
).to.be.revertedWithCustomError(repAdapter, "NotAuthorizedFeedbackAuthor");
// The author allowlist is owner-only (OZ v4 Ownable: "Ownable: caller is not the owner").
await expect(repAdapter.connect(other).addFeedbackAuthor(other.address)).to.be.reverted;
});
it("is honest about the immutable attestor set: write-through reverts when the adapter is not authorized", async function () {
const a = await makeAgent("rep-noauth");
await deployRep(false); // reputation lists Foundation only, not the adapter (the mainnet case)
expect(await repAdapter.canWriteThrough()).to.equal(false);
// `other` is an authorized feedback author (deployRep), so it clears the caller gate and reaches
// the fail-closed attestor check on the live reputation.
await expect(
repAdapter.connect(other).giveFeedback(a.agentId, 1, ethers.id("ev"), "")
).to.be.revertedWithCustomError(repAdapter, "AdapterNotAttestor");
// The READ path still adapts the live contract with no authorization: score starts at 0.
expect(await repAdapter.getScore(a.agentId)).to.equal(0n);
});
});
// =========================================================================
// 3. Validation adapter over the live PQC precompiles (the differentiator)
// =========================================================================
describe("AereValidationRegistry8004 (validation: request/respond with a PQC proof)", function () {
it("accepts a genuine Falcon-512 (0x0AE1) validation response and records it", async function () {
const a = await makeAgent("val-falcon");
const rng = makeRng("val-falcon-key");
const v = await registerKey(1, other, rng); // a Falcon-512 validator key
expect(await reg.statusOf(v.keyId)).to.equal(REG_STATUS_ACTIVE);
const dataHash = ethers.id("model-output-commitment-A");
const tx = await validation.requestValidation(v.keyId, a.agentId, dataHash);
const rc = await tx.wait();
const reqLog = rc.logs.map((l) => validation.interface.parseLog(l)).find((p) => p && p.name === "ValidationRequest");
const requestId = reqLog.args.requestId;
const response = 92; // ERC-8004 score 0..100
const challenge = await validation.responseChallenge(requestId, response);
const proof = genuine(1, v.pk, challenge, rng);
await expect(validation.respondValidation(requestId, response, proof))
.to.emit(validation, "ValidationResponse")
.withArgs(requestId, v.keyId, a.agentId, response, 1);
expect(await validation.isValidated(requestId)).to.equal(true);
const rec = await validation.getValidation(requestId);
expect(rec.status).to.equal(2); // Completed
expect(rec.response).to.equal(92n);
expect(rec.pqcScheme).to.equal(1n);
expect(rec.serverAgentId).to.equal(BigInt(a.agentId));
});
it("accepts a genuine ML-DSA-44 (0x0AE3) validation response too", async function () {
const a = await makeAgent("val-mldsa");
const rng = makeRng("val-mldsa-key");
const v = await registerKey(3, other, rng); // an ML-DSA-44 validator key
const dataHash = ethers.id("model-output-commitment-B");
const requestId = await validation.requestValidation.staticCall(v.keyId, a.agentId, dataHash);
await validation.requestValidation(v.keyId, a.agentId, dataHash);
const response = 77;
const challenge = await validation.responseChallenge(requestId, response);
const proof = genuine(3, v.pk, challenge, rng);
await expect(validation.respondValidation(requestId, response, proof))
.to.emit(validation, "ValidationResponse")
.withArgs(requestId, v.keyId, a.agentId, response, 3);
expect(await validation.isValidated(requestId)).to.equal(true);
});
it("REJECTS a tampered proof fail-closed (nothing recorded)", async function () {
const a = await makeAgent("val-tamper");
const rng = makeRng("val-tamper-key");
const v = await registerKey(1, other, rng);
const dataHash = ethers.id("model-output-commitment-C");
const requestId = await validation.requestValidation.staticCall(v.keyId, a.agentId, dataHash);
await validation.requestValidation(v.keyId, a.agentId, dataHash);
const response = 88;
const challenge = await validation.responseChallenge(requestId, response);
const good = genuine(1, v.pk, challenge, rng);
const bad = tamper(1, good);
await expect(validation.respondValidation(requestId, response, bad)).to.be.revertedWithCustomError(
validation,
"PQCValidationFailed"
);
// Fail-closed: request stays Pending, nothing recorded.
expect(await validation.isValidated(requestId)).to.equal(false);
const rec = await validation.getValidation(requestId);
expect(rec.status).to.equal(1); // Pending
});
it("REJECTS a proof bound to a DIFFERENT response score (binding), and treats a stale precompile as invalid", async function () {
const a = await makeAgent("val-bind");
const rng = makeRng("val-bind-key");
const v = await registerKey(1, other, rng);
const dataHash = ethers.id("model-output-commitment-D");
const requestId = await validation.requestValidation.staticCall(v.keyId, a.agentId, dataHash);
await validation.requestValidation(v.keyId, a.agentId, dataHash);
// Sign the challenge for response=50, then try to submit response=51 with it.
const challenge50 = await validation.responseChallenge(requestId, 50);
const proof50 = genuine(1, v.pk, challenge50, rng);
await expect(validation.respondValidation(requestId, 51, proof50)).to.be.revertedWithCustomError(
validation,
"PQCValidationFailed"
);
// Wipe the precompile (pre-fork / stale chain): an empty account returns empty data => invalid.
await network.provider.send("hardhat_setCode", [PRECOMPILE[1], "0x"]);
await expect(validation.respondValidation(requestId, 50, proof50)).to.be.revertedWithCustomError(
validation,
"PQCValidationFailed"
);
expect(await validation.isValidated(requestId)).to.equal(false);
});
it("rejects requests with a non-PQC / inactive validator key or an unknown server agent", async function () {
const a = await makeAgent("val-guard");
const rng = makeRng("val-guard-key");
const v = await registerKey(1, other, rng);
// Unknown server agent.
await expect(validation.requestValidation(v.keyId, 424242, ethers.id("d"))).to.be.revertedWithCustomError(
validation,
"ServerAgentUnknown"
);
// Revoked validator key is no longer ACTIVE.
await reg.connect(other).revokeKey(v.keyId);
await expect(validation.requestValidation(v.keyId, a.agentId, ethers.id("d"))).to.be.revertedWithCustomError(
validation,
"ValidatorKeyNotActive"
);
});
});
// =========================================================================
// 4. x402 / AP2 post-quantum mandate verifier
// =========================================================================
describe("AereAP2MandateVerifier (Falcon-512-signed x402/AP2 spend mandate)", function () {
async function makeMandate(overrides = {}) {
const rng = makeRng(overrides.seed || "mandate-key");
const agent = await registerKey(1, owner, rng); // the paying agent's Falcon-512 key
const start = overrides.periodStart ?? (await futureTs(-10));
const end = overrides.periodEnd ?? (await futureTs(3600));
const m = {
agentKeyId: agent.keyId,
token: overrides.token ?? "0x7e84d7d66d5da4cfE46Da67CDEeB05B323e1f5e8", // live WAERE address (label only)
maxAmount: overrides.maxAmount ?? ethers.parseUnits("10", 18),
periodStart: start,
periodEnd: end,
purpose: overrides.purpose ?? ethers.id("inference:anthropic"),
mandateNonce: overrides.mandateNonce ?? 0,
};
return { rng, agent, m };
}
it("verifies a genuine Falcon mandate signature (pure view) and authorizes spend up to the cap", async function () {
const { rng, agent, m } = await makeMandate();
const mh = await mandate.mandateHash(m);
const sig = genuine(1, agent.pk, mh, rng);
expect(await mandate.verifyMandate(m, sig)).to.equal(true);
// Authorize two spends within the 10-unit cap.
await expect(mandate.authorizeSpend(m, ethers.parseUnits("4", 18), sig)).to.emit(mandate, "MandateAuthorized");
await mandate.authorizeSpend(m, ethers.parseUnits("3", 18), sig);
expect(await mandate.spentUnder(mh)).to.equal(ethers.parseUnits("7", 18));
expect(await mandate.remainingUnder(m)).to.equal(ethers.parseUnits("3", 18));
});
it("rejects spend over the cumulative cap", async function () {
const { rng, agent, m } = await makeMandate({ maxAmount: ethers.parseUnits("5", 18) });
const sig = genuine(1, agent.pk, await mandate.mandateHash(m), rng);
await mandate.authorizeSpend(m, ethers.parseUnits("4", 18), sig);
await expect(mandate.authorizeSpend(m, ethers.parseUnits("2", 18), sig)).to.be.revertedWithCustomError(
mandate,
"MandateCapExceeded"
);
});
it("rejects an expired mandate window", async function () {
const { rng, agent, m } = await makeMandate({ periodStart: await futureTs(-100), periodEnd: await futureTs(-10) });
const sig = genuine(1, agent.pk, await mandate.mandateHash(m), rng);
await expect(mandate.authorizeSpend(m, ethers.parseUnits("1", 18), sig)).to.be.revertedWithCustomError(
mandate,
"MandateExpired"
);
});
it("REJECTS a tampered mandate signature fail-closed (verifyMandate false, authorize reverts)", async function () {
const { rng, agent, m } = await makeMandate();
const good = genuine(1, agent.pk, await mandate.mandateHash(m), rng);
const bad = tamper(1, good);
expect(await mandate.verifyMandate(m, bad)).to.equal(false);
await expect(mandate.authorizeSpend(m, ethers.parseUnits("1", 18), bad)).to.be.revertedWithCustomError(
mandate,
"MandateSignatureInvalid"
);
expect(await mandate.spentUnder(await mandate.mandateHash(m))).to.equal(0n);
});
it("halts the mandate the instant the Falcon key is revoked (root lifecycle)", async function () {
const { rng, agent, m } = await makeMandate({ seed: "mandate-revoke" });
const sig = genuine(1, agent.pk, await mandate.mandateHash(m), rng);
await mandate.authorizeSpend(m, ethers.parseUnits("1", 18), sig); // works before revoke
await reg.connect(owner).revokeKey(agent.keyId);
expect(await mandate.verifyMandate(m, sig)).to.equal(false);
await expect(mandate.authorizeSpend(m, ethers.parseUnits("1", 18), sig)).to.be.revertedWithCustomError(
mandate,
"AgentKeyNotActiveFalcon"
);
});
it("enforces the settlement-executor gate when one is configured", async function () {
// A verifier wired to a specific executor rejects authorizeSpend from anyone else.
const gated = await (await ethers.getContractFactory("AereAP2MandateVerifier")).deploy(
await reg.getAddress(),
other.address // SETTLEMENT_EXECUTOR
);
await gated.waitForDeployment();
const { rng, agent, m } = await makeMandate({ seed: "mandate-gated" });
const sig = genuine(1, agent.pk, await gated.mandateHash(m), rng);
await expect(gated.connect(owner).authorizeSpend(m, ethers.parseUnits("1", 18), sig)).to.be.revertedWithCustomError(
gated,
"NotSettlementExecutor"
);
// The configured executor can.
await expect(gated.connect(other).authorizeSpend(m, ethers.parseUnits("1", 18), sig)).to.emit(
gated,
"MandateAuthorized"
);
});
});
});