Skip to content

Commit 0308a04

Browse files
grypezclaude
andcommitted
test(evm-wallet-experiment): add delegation twin e2e script
Script invoked by the docker e2e suite to test local cumulativeSpend enforcement and chain-side rejection of an expired timestamp caveat. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 1a22d75 commit 0308a04

1 file changed

Lines changed: 228 additions & 0 deletions

File tree

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
/* eslint-disable no-plusplus, n/no-process-exit, import-x/no-unresolved */
2+
/**
3+
* Delegation-twin E2E test — runs **inside** the away container.
4+
*
5+
* Exercises the delegation twin as a live exo capability by connecting
6+
* directly to the kernel daemon sockets via daemon-client.mjs. Both the
7+
* home and away sockets are accessible through the shared `ocap-run` volume
8+
* at /run/ocap/<service>-ready.json.
9+
*
10+
* ── What it tests ─────────────────────────────────────────────────────────
11+
*
12+
* 1. Home creates a transfer grant (max spend = 5 units, fake token).
13+
* 2. Away provisions the twin and calls transfer(3) → succeeds on-chain.
14+
* 3. Away calls transfer(3) again → twin rejects LOCALLY ("Insufficient
15+
* budget") before any network call is made.
16+
* 4. Away provisions a call twin with a valueLte(100) caveat and calls
17+
* with value=200 → twin passes through, bundler simulation rejects.
18+
* Demonstrates chain enforcement of a caveat the twin doesn't check.
19+
*
20+
* ── Usage ─────────────────────────────────────────────────────────────────
21+
*
22+
* Invoked by docker-e2e.test.ts via dockerExec:
23+
*
24+
* node --conditions development run-delegation-twin-e2e.mjs \
25+
* <mode> <homeKref> <awayKref> <delegateAddress>
26+
*
27+
* mode bundler-7702 | bundler-hybrid | peer-relay
28+
* homeKref coordinator kref on the home kernel (e.g. ko4)
29+
* awayKref coordinator kref on the away kernel
30+
* delegateAddress on-chain delegate address for the delegation
31+
*/
32+
33+
import '@metamask/kernel-shims/endoify-node';
34+
35+
import { randomBytes } from 'node:crypto';
36+
import { readFile } from 'node:fs/promises';
37+
38+
import { makeDaemonClient } from './helpers/daemon-client.mjs';
39+
40+
const [, , mode, homeKref, awayKref, delegateAddress] = process.argv;
41+
42+
if (!mode || !homeKref || !awayKref || !delegateAddress) {
43+
console.error(
44+
'Usage: run-delegation-twin-e2e.mjs <mode> <homeKref> <awayKref> <delegateAddress>',
45+
);
46+
process.exit(1);
47+
}
48+
49+
const SERVICE_PAIRS = {
50+
'bundler-7702': {
51+
home: 'kernel-home-bundler-7702',
52+
away: 'kernel-away-bundler-7702',
53+
},
54+
'bundler-hybrid': {
55+
home: 'kernel-home-bundler-hybrid',
56+
away: 'kernel-away-bundler-hybrid',
57+
},
58+
'peer-relay': {
59+
home: 'kernel-home-peer-relay',
60+
away: 'kernel-away-peer-relay',
61+
},
62+
};
63+
64+
const pair = SERVICE_PAIRS[mode];
65+
if (!pair) {
66+
console.error(`Unknown mode: ${mode}`);
67+
process.exit(1);
68+
}
69+
70+
const homeReady = JSON.parse(
71+
await readFile(`/run/ocap/${pair.home}-ready.json`, 'utf8'),
72+
);
73+
const awayReady = JSON.parse(
74+
await readFile(`/run/ocap/${pair.away}-ready.json`, 'utf8'),
75+
);
76+
77+
const homeClient = makeDaemonClient(homeReady.socketPath);
78+
const awayClient = makeDaemonClient(awayReady.socketPath);
79+
80+
// Zero-code address on Anvil — EVM calls to it succeed with empty return.
81+
// The erc20TransferAmount enforcer only inspects calldata amount, not the
82+
// token contract itself, so this works as a stand-in token.
83+
const FAKE_TOKEN = '0x000000000000000000000000000000000000dEaD';
84+
const BURN_ADDRESS = '0x000000000000000000000000000000000000dEaD';
85+
const CHAIN_ID = 31337;
86+
87+
let passed = 0;
88+
let failed = 0;
89+
90+
function assert(condition, label) {
91+
if (condition) {
92+
passed++;
93+
console.log(` ✓ ${label}`);
94+
} else {
95+
failed++;
96+
console.error(` ✗ ${label}`);
97+
}
98+
}
99+
100+
console.log(`\n=== Delegation Twin E2E (${mode}) ===\n`);
101+
102+
// Per-run entropy so each test run produces unique delegation hashes even when
103+
// the coordinator vat is freshly instantiated (counter reset to 0).
104+
const entropy = `0x${randomBytes(32).toString('hex')}`;
105+
106+
// ── Test 1: Twin enforces cumulative spend locally ─────────────────────────
107+
108+
console.log('--- Transfer twin: spend tracking ---');
109+
110+
const transferGrant = await homeClient.callVat(
111+
homeKref,
112+
'makeDelegationGrant',
113+
[
114+
'transfer',
115+
{
116+
delegate: delegateAddress,
117+
token: FAKE_TOKEN,
118+
// Passed as a string because the daemon JSON-RPC protocol carries plain
119+
// JSON; coordinator-vat coerces it to BigInt before buildDelegationGrant.
120+
max: '5',
121+
chainId: CHAIN_ID,
122+
entropy,
123+
},
124+
],
125+
);
126+
127+
assert(
128+
transferGrant !== null && typeof transferGrant === 'object',
129+
'home created transfer grant',
130+
);
131+
132+
const twinStandin = await awayClient.callVat(awayKref, 'provisionTwin', [
133+
transferGrant,
134+
]);
135+
const twinKref = twinStandin.getKref();
136+
137+
assert(
138+
typeof twinKref === 'string' && twinKref.length > 0,
139+
`twin kref: ${twinKref}`,
140+
);
141+
142+
// First spend: 3 ≤ 5 remaining → should reach the chain and succeed.
143+
console.log(' Calling transfer(3) — should hit chain...');
144+
const txHash = await awayClient.callVat(twinKref, 'transfer', [
145+
BURN_ADDRESS,
146+
'3',
147+
]);
148+
149+
// For bundler-hybrid, wait for on-chain UserOp inclusion.
150+
if (mode === 'bundler-hybrid') {
151+
console.log(' Waiting for UserOp receipt (hybrid mode)...');
152+
await awayClient.callVat(awayKref, 'waitForUserOpReceipt', [
153+
{ userOpHash: txHash, pollIntervalMs: 500, timeoutMs: 120_000 },
154+
]);
155+
}
156+
157+
assert(
158+
typeof txHash === 'string' && /^0x[\da-f]{64}$/iu.test(txHash),
159+
`first spend (3 units) → tx hash: ${String(txHash).slice(0, 20)}...`,
160+
);
161+
162+
// Second spend: 3 + 3 = 6 > 5 → should be rejected LOCALLY by the
163+
// SpendTracker without making any network call.
164+
console.log(' Calling transfer(3) again — should fail locally...');
165+
const secondBody = await awayClient.callVatExpectError(twinKref, 'transfer', [
166+
BURN_ADDRESS,
167+
'3',
168+
]);
169+
170+
assert(
171+
typeof secondBody === 'string' && secondBody.includes('Insufficient budget'),
172+
`second spend (3 units) rejected locally: ${String(secondBody).slice(0, 80)}`,
173+
);
174+
175+
// ── Test 2 (comparison): expired delegation — twin is blind, chain rejects ──
176+
//
177+
// The twin has no local check for blockWindow / TimestampEnforcer. It passes
178+
// the call straight to redeemFn; the chain rejects because validUntil is in
179+
// the past. This is the canonical example of a caveat the twin doesn't track.
180+
181+
console.log('\n--- Expired delegation: chain enforcement ---');
182+
183+
// validUntil 60 s in the past — delegation is already expired.
184+
const expiredAt = Math.floor(Date.now() / 1000) - 60;
185+
const expiredGrant = await homeClient.callVat(homeKref, 'makeDelegationGrant', [
186+
'call',
187+
{
188+
delegate: delegateAddress,
189+
targets: [BURN_ADDRESS],
190+
chainId: CHAIN_ID,
191+
validUntil: expiredAt,
192+
entropy,
193+
},
194+
]);
195+
196+
assert(
197+
expiredGrant !== null && typeof expiredGrant === 'object',
198+
'home created expired call grant',
199+
);
200+
201+
const expiredTwinStandin = await awayClient.callVat(awayKref, 'provisionTwin', [
202+
expiredGrant,
203+
]);
204+
const expiredTwinKref = expiredTwinStandin.getKref();
205+
206+
// The twin has no blockWindow check — it calls redeemFn, which reaches the
207+
// chain/bundler, which rejects with a TimestampEnforcer revert.
208+
console.log(
209+
' Calling with expired delegation — twin should pass, chain should reject...',
210+
);
211+
const expiredError = await awayClient.callVatExpectError(
212+
expiredTwinKref,
213+
'call',
214+
[BURN_ADDRESS, 0, '0x'],
215+
);
216+
217+
assert(
218+
typeof expiredError === 'string' && expiredError.length > 0,
219+
`expired delegation rejected by chain (not twin): ${String(expiredError).slice(0, 80)}`,
220+
);
221+
222+
// ── Results ────────────────────────────────────────────────────────────────
223+
224+
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===\n`);
225+
if (failed === 0) {
226+
console.log('All delegation twin tests passed');
227+
}
228+
process.exit(failed > 0 ? 1 : 0);

0 commit comments

Comments
 (0)