aere-contracts/test/delegate7702v2-scope-epoch-fix.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

155 lines
7.1 KiB
JavaScript

// =============================================================================
// TDD proof for the AereDelegate7702V2 SESSION-KEY SCOPE-NARROWING fix
// (MEDIUM, session-key scope escape).
//
// BUG: SessionKey.allowedSelectors / allowedTargets were only ever set to true
// (addSessionKey) and NEVER cleared. revokeSessionKey only set active=false and
// left every flag set. Re-adding the same key address therefore reactivated the
// UNION of all historical targets/selectors, silently regranting authority the
// owner did NOT re-grant.
//
// FIX: a per-key scopeEpoch increments on every addSessionKey and every
// revokeSessionKey; allowedSelectors/allowedTargets are keyed by
// (key, scopeEpoch, sel/target); executeWithSessionKey checks the flag at the
// key's CURRENT scopeEpoch. A revoke + re-grant starts a fresh, empty scope so
// only the newly-listed targets/selectors are live, and old scope can never be
// reactivated. spent resets per grant.
//
// Run in ISOLATION:
// npx hardhat test test/delegate7702v2-scope-epoch-fix.test.js
//
// No em-dashes in this file.
// =============================================================================
const { expect } = require("chai");
const { ethers, network } = require("hardhat");
const E = (n) => ethers.parseEther(String(n));
async function setBal(addr, amountWei) {
await network.provider.send("hardhat_setBalance", [addr, "0x" + amountWei.toString(16)]);
}
async function latestTs() {
return Number((await ethers.provider.getBlock("latest")).timestamp);
}
async function deployRaw(deployer, initCode) {
const rc = await (await deployer.sendTransaction({ data: initCode })).wait();
return rc.contractAddress;
}
const SINK_INIT = "0x60016000f3"; // runtime = STOP: any call/value succeeds, keeps value
const dataFor = (selector, padWords) => selector + "00".repeat(padWords * 32);
describe("AereDelegate7702V2 - scope narrowing on revoke + re-grant (scopeEpoch fix)", function () {
this.timeout(0);
let signers, F;
before(async () => {
signers = await ethers.getSigners();
F = await ethers.getContractFactory("AereDelegate7702V2");
});
async function stand() {
const deployer = signers[0];
const delegate = await F.deploy();
await delegate.waitForDeployment();
const delegateAddr = await delegate.getAddress();
const owner = await ethers.getImpersonatedSigner(delegateAddr);
await setBal(delegateAddr, E(1_000_000));
const sinkA = await deployRaw(deployer, SINK_INIT);
const sinkB = await deployRaw(deployer, SINK_INIT);
return { delegate, delegateAddr, owner, sinkA, sinkB };
}
it("re-granting a NARROWER scope drops the old target + selector (no UNION reactivation)", async () => {
const { delegate, owner, sinkA, sinkB } = await stand();
const key = signers[1];
const selKeep = "0x11111111";
const selDrop = "0x22222222";
const farExpiry = (await latestTs()) + 365 * 24 * 3600;
// GRANT 1 (broad): selectors {keep, drop}, targets {sinkA, sinkB}, cap 10.
await (await delegate.connect(owner).addSessionKey(
key.address, farExpiry, E(10), [selKeep, selDrop], [sinkA, sinkB]
)).wait();
// Use the broad scope: call the soon-to-be-dropped target+selector -> works.
await (await delegate.connect(key).executeWithSessionKey(
{ target: sinkB, value: E(1), data: dataFor(selDrop, 1) }
)).wait();
expect(await ethers.provider.getBalance(sinkB)).to.equal(E(1));
// REVOKE.
await (await delegate.connect(owner).revokeSessionKey(key.address)).wait();
// RE-GRANT (narrower): only selector {keep}, only target {sinkA}, fresh cap 5.
await (await delegate.connect(owner).addSessionKey(
key.address, farExpiry, E(5), [selKeep], [sinkA]
)).wait();
// The old target (sinkB) is NOT re-granted -> rejected.
await expect(
delegate.connect(key).executeWithSessionKey({ target: sinkB, value: 0n, data: dataFor(selKeep, 1) })
).to.be.revertedWithCustomError(delegate, "TargetNotAllowed");
// The old selector (drop) at the still-allowed target -> rejected.
await expect(
delegate.connect(key).executeWithSessionKey({ target: sinkA, value: 0n, data: dataFor(selDrop, 1) })
).to.be.revertedWithCustomError(delegate, "SelectorNotAllowed");
// ONLY the newly-granted (sinkA, selKeep) pair is live.
const before = await ethers.provider.getBalance(sinkA);
await (await delegate.connect(key).executeWithSessionKey(
{ target: sinkA, value: E(2), data: dataFor(selKeep, 1) }
)).wait();
expect((await ethers.provider.getBalance(sinkA)) - before).to.equal(E(2));
// Views reflect the current (narrow) scope only.
const scopeDropTarget = await delegate.sessionKeyScope(key.address, selKeep, sinkB);
expect(scopeDropTarget.targetAllowed).to.equal(false);
const scopeDropSel = await delegate.sessionKeyScope(key.address, selDrop, sinkA);
expect(scopeDropSel.selectorAllowed).to.equal(false);
const scopeLive = await delegate.sessionKeyScope(key.address, selKeep, sinkA);
expect(scopeLive.selectorAllowed).to.equal(true);
expect(scopeLive.targetAllowed).to.equal(true);
});
it("spend cap applies to the NEW grant (fresh budget, old spend does not carry, cap still binds)", async () => {
const { delegate, owner, sinkA } = await stand();
const key = signers[2];
const sel = "0x33333333";
const farExpiry = (await latestTs()) + 365 * 24 * 3600;
// GRANT 1: cap 10, spend 8.
await (await delegate.connect(owner).addSessionKey(key.address, farExpiry, E(10), [sel], [sinkA])).wait();
await (await delegate.connect(key).executeWithSessionKey({ target: sinkA, value: E(8), data: dataFor(sel, 1) })).wait();
let scope = await delegate.sessionKeyScope(key.address, sel, sinkA);
expect(scope.spent).to.equal(E(8));
// REVOKE + RE-GRANT with a fresh cap of 5. spent must reset to 0.
await (await delegate.connect(owner).revokeSessionKey(key.address)).wait();
await (await delegate.connect(owner).addSessionKey(key.address, farExpiry, E(5), [sel], [sinkA])).wait();
scope = await delegate.sessionKeyScope(key.address, sel, sinkA);
expect(scope.spent).to.equal(0n);
expect(scope.spendCap).to.equal(E(5));
// New cap binds: 5 works, one wei more reverts.
await (await delegate.connect(key).executeWithSessionKey({ target: sinkA, value: E(5), data: dataFor(sel, 1) })).wait();
await expect(
delegate.connect(key).executeWithSessionKey({ target: sinkA, value: 1n, data: dataFor(sel, 1) })
).to.be.revertedWithCustomError(delegate, "SpendCapExceeded");
});
it("the revoked scope alone (no re-grant) is fully dead", async () => {
const { delegate, owner, sinkA } = await stand();
const key = signers[3];
const sel = "0x44444444";
const farExpiry = (await latestTs()) + 365 * 24 * 3600;
await (await delegate.connect(owner).addSessionKey(key.address, farExpiry, E(5), [sel], [sinkA])).wait();
await (await delegate.connect(owner).revokeSessionKey(key.address)).wait();
await expect(
delegate.connect(key).executeWithSessionKey({ target: sinkA, value: 0n, data: dataFor(sel, 1) })
).to.be.revertedWithCustomError(delegate, "SessionKeyExpired");
});
});