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