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.
155 lines
7.1 KiB
JavaScript
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");
|
|
});
|
|
});
|