Source mirror · commit 369512f · 2026-10-26 · MIT · canonical repo: gitlab.com/jiapunk/upgrade-airlock
git clone https://upgrade-airlock.pages.dev/source/upgrade-airlock.git
upgrade-airlock / web / test / operator-selftest.ts
web/test/operator-selftest.ts498 lines · 28980 bytes
1// Operator backend self-test (npm run selftest): the shared module in operator/ and the Cloudflare handlers, driven
2// with real Request objects against a fake Sepolia JSON-RPC. No network: the global fetch is a trap, and every RPC
3// goes through the injected fake. No real key: the owner is a fresh random key made here.
4import { execFileSync } from 'node:child_process'
5import { existsSync, readdirSync, readFileSync } from 'node:fs'
6import { homedir } from 'node:os'
7import { join } from 'node:path'
8import {
9 decodeFunctionData,
10 encodeAbiParameters,
11 encodeFunctionResult,
12 keccak256,
13 parseTransaction,
14 recoverTransactionAddress,
15 toFunctionSelector,
16 toHex,
17 type Address,
18 type Hex,
19 type TransactionSerializedEIP1559,
20} from 'viem'
21import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'
22import { availableCards, buildAction, CARD_ACTION, CARD_IDS, OPERATOR_ABI, type CardId, type OperatorDeployment } from '../operator/cards'
23import { cloudConfig, handleInfoRequest, handleProposeRequest, readCapped } from '../operator/cloud'
24import { DEPLOYMENT } from '../operator/deployment.generated'
25import {
26 bearer,
27 constantTimeEqual,
28 COOLDOWN_BLOCKS,
29 cooldownLeft,
30 DEFAULT_RPCS,
31 disabledInfo,
32 GAS_LIMIT,
33 localRequestAllowed,
34 loopbackBind,
35 MAX_BODY_BYTES,
36 MIN_PRIORITY_FEE,
37 operatorInfo,
38 parseCard,
39 propose,
40 resetOperatorState,
41 type ApiResult,
42 type OperatorConfig,
43} from '../operator/operator'
44import { findBlocked, NAMES_NOTE } from './scan'
45
46let fails = 0
47function ok(cond: unknown, msg: string) {
48 console.log(`${cond ? 'ok ' : 'FAIL'} ${msg}`)
49 if (!cond) fails++
50}
51const WEB = process.cwd()
52const REPO = join(WEB, '..')
53
54// every network call must go through an injected fake; the real fetch is a trap
55let trapped = 0
56globalThis.fetch = (async () => {
57 trapped++
58 throw new TypeError('network trapped by the self-test')
59}) as typeof fetch
60
61// ---------- 1. the seven cards and their exact calldata ----------
62const shipped = JSON.parse(readFileSync(join(WEB, 'src', 'chain-data-sepolia.json'), 'utf8'))
63ok(CARD_IDS.join() === 'evil-mint,evil-owner,evil-fee,evil-basefee,evil-drain,remove-receiver,v2', 'seven cards, in picker order')
64{
65 const g = DEPLOYMENT
66 const same =
67 g.pending === shipped.pending &&
68 g.chainId === shipped.chainId &&
69 g.airlockAdmin === shipped.airlockAdmin &&
70 g.owner === shipped.owner &&
71 g.receiver === shipped.receiver &&
72 (['v2', 'evilMint', 'evilOwner', 'evilFee', 'evilBaseFee', 'evilDrain'] as const).every((k) => g.impls[k] === shipped.impls?.[k])
73 ok(same, 'operator/deployment.generated.ts matches src/chain-data-sepolia.json (npm run sync:sepolia regenerates it)')
74}
75// propose.sh's case table: card -> deployment key
76{
77 const sh = readFileSync(join(REPO, 'scripts', 'sepolia', 'propose.sh'), 'utf8')
78 const table = new Map<string, string>()
79 for (const m of sh.matchAll(/^\s+([a-z0-9-]+)(?: \| [A-Za-z0-9]+)?\) KEY=([A-Za-z0-9]+) EXPECT=/gm)) table.set(m[1], m[2])
80 const mine = CARD_IDS.map((c) => [c, CARD_ACTION[c].kind === 'remove-receiver' ? 'receiver' : (CARD_ACTION[c] as { impl: string }).impl])
81 ok(table.size === 7 && mine.every(([c, k]) => table.get(c) === k), `every card proposes what scripts/sepolia/propose.sh proposes (${table.size} rows)`)
82}
83const castBin = [join(homedir(), '.foundry', 'bin', 'cast'), 'cast'].find((p) => {
84 try {
85 execFileSync(p, ['--version'], { stdio: 'pipe' })
86 return true
87 } catch {
88 return false
89 }
90})
91const castCalldata = (sig: string, ...args: string[]) => execFileSync(castBin!, ['calldata', sig, ...args], { encoding: 'utf8' }).trim()
92{
93 const upSel = toFunctionSelector('proposeUpgrade(address,bytes)')
94 const gSel = toFunctionSelector('proposeSetGuardian(address,bool)')
95 let exact = 0
96 let castSame = 0
97 for (const card of CARD_IDS) {
98 const a = buildAction(card, DEPLOYMENT)
99 const act = CARD_ACTION[card]
100 const target = (act.kind === 'remove-receiver' ? DEPLOYMENT.receiver : DEPLOYMENT.impls[act.impl]) as Address
101 const want =
102 act.kind === 'remove-receiver'
103 ? ((gSel + encodeAbiParameters([{ type: 'address' }, { type: 'bool' }], [target, false]).slice(2)) as Hex)
104 : ((upSel + encodeAbiParameters([{ type: 'address' }, { type: 'bytes' }], [target, '0x']).slice(2)) as Hex)
105 if (a && a.to === DEPLOYMENT.airlockAdmin && a.data === want) exact++
106 if (castBin && a) {
107 const c = act.kind === 'remove-receiver' ? castCalldata('proposeSetGuardian(address,bool)', target, 'false') : castCalldata('proposeUpgrade(address,bytes)', target, '0x')
108 if (c === a.data) castSame++
109 }
110 }
111 ok(exact === 7, `every card's calldata is selector + abi.encode(target, 0x | false), sent to AirlockAdmin (${exact}/7)`)
112 if (castBin) ok(castSame === 7, `cast calldata gives the same bytes for all seven cards (${castSame}/7)`)
113 else console.log('note: cast not found, calldata compared with viem encoding only')
114 const art = join(REPO, 'out', 'AirlockAdmin.sol', 'AirlockAdmin.json')
115 if (existsSync(art)) {
116 const ids = JSON.parse(readFileSync(art, 'utf8')).methodIdentifiers as Record<string, string>
117 const need = ['proposeUpgrade(address,bytes)', 'proposeSetGuardian(address,bool)', 'nextId()', 'getProposal(uint256)']
118 ok(need.every((s) => ids[s] && `0x${ids[s]}` === toFunctionSelector(OPERATOR_ABI.find((f) => f.type === 'function' && s.startsWith(f.name + '('))!)), 'operator ABI selectors match the forge artifact of AirlockAdmin')
119 }
120 ok(availableCards(DEPLOYMENT).length === (DEPLOYMENT.pending ? 0 : 7), `the shipped deployment can launch ${availableCards(DEPLOYMENT).length} cards`)
121 const { evilDrain: _d, ...older } = DEPLOYMENT.impls
122 const old: OperatorDeployment = { ...DEPLOYMENT, impls: older }
123 ok(buildAction('evil-drain', old) === null && !availableCards(old).includes('evil-drain') && availableCards(old).length === 6, 'a deployment without a card\'s implementation does not offer that card')
124 ok(availableCards({ ...DEPLOYMENT, pending: true }).length === 0 && buildAction('v2', { ...DEPLOYMENT, pending: true }) === null, 'the placeholder deployment offers nothing')
125}
126
127// ---------- 2. secrets, auth and the local guard ----------
128ok(constantTimeEqual('a'.repeat(64), 'a'.repeat(64)) && constantTimeEqual('', ''), 'constant-time compare: equal strings')
129ok(!constantTimeEqual('a'.repeat(64), 'a'.repeat(63) + 'b') && !constantTimeEqual('a'.repeat(64), 'a'.repeat(63)) && !constantTimeEqual('abc', 'abcd') && !constantTimeEqual('x', ''), 'constant-time compare: one byte off, a prefix, different lengths')
130{
131 // it hashes both sides first, so the loop is 32 bytes whatever the inputs; check that long inputs still compare
132 const long = 'f'.repeat(10_000)
133 ok(constantTimeEqual(long, long) && !constantTimeEqual(long, long + 'f'), 'constant-time compare: long inputs (hashed to 32 bytes first)')
134}
135ok(bearer('Bearer abc') === 'abc' && bearer('bearer abc ') === 'abc' && bearer('Basic abc') === null && bearer('Bearer') === null && bearer(null) === null && bearer('Bearer a b') === null, 'Authorization: Bearer parsing')
136{
137 const P = 5173
138 const yes = [
139 { host: '127.0.0.1:5173' },
140 { host: 'localhost:5173' },
141 { host: '127.0.0.1:5173', origin: 'http://127.0.0.1:5173' },
142 { host: 'localhost:5173', origin: 'http://localhost:5173', secFetchSite: 'same-origin' },
143 ]
144 const no = [
145 { host: 'attacker.example:5173' }, // DNS rebinding: the browser sends the attacker's name
146 { host: 'attacker.example:5173', origin: 'http://attacker.example:5173' },
147 { host: '127.0.0.1:5174' },
148 { host: '192.168.1.20:5173' },
149 { host: '' },
150 { host: '127.0.0.1:5173', origin: 'http://evil.example' },
151 { host: '127.0.0.1:5173', origin: 'null' },
152 { host: '127.0.0.1:5173', origin: 'http://localhost:5173' },
153 { host: '127.0.0.1:5173', secFetchSite: 'cross-site' },
154 { host: '127.0.0.1:5173', secFetchSite: 'same-site' },
155 ]
156 const lo = (h: { host?: string; origin?: string; secFetchSite?: string }) => ({ remote: '127.0.0.1', ...h })
157 ok(yes.every((h) => localRequestAllowed(lo(h), P)), 'local guard: loopback Host with no Origin or a matching one is allowed')
158 ok(no.every((h) => !localRequestAllowed(lo(h), P)) && !localRequestAllowed(lo({ host: '127.0.0.1:0' }), 0), 'local guard: foreign Host (DNS rebinding), other port, foreign or null Origin, cross-site fetch are refused')
159 // the peer address cannot be faked by a header: a device on the LAN that sends Host 127.0.0.1 is still refused
160 ok(
161 !localRequestAllowed({ remote: '192.168.1.20', host: '127.0.0.1:5173' }, P) &&
162 !localRequestAllowed({ remote: '::ffff:192.168.1.20', host: '127.0.0.1:5173' }, P) &&
163 !localRequestAllowed({ remote: '10.0.0.5', host: 'localhost:5173', origin: 'http://localhost:5173' }, P) &&
164 !localRequestAllowed({ remote: 'fe80::1', host: '127.0.0.1:5173' }, P) &&
165 !localRequestAllowed({ remote: undefined, host: '127.0.0.1:5173' }, P) &&
166 !localRequestAllowed({ remote: '', host: '127.0.0.1:5173' }, P) &&
167 !localRequestAllowed({ remote: '127.0.0.1.evil', host: '127.0.0.1:5173' }, P),
168 'local guard: a non-loopback peer (LAN IPv4, mapped LAN, link-local, unknown) is refused even with Host 127.0.0.1:5173',
169 )
170 ok(
171 localRequestAllowed({ remote: '::ffff:127.0.0.1', host: '127.0.0.1:5173' }, P) &&
172 localRequestAllowed({ remote: '::1', host: 'localhost:5173' }, P) &&
173 localRequestAllowed({ remote: '127.0.0.2', host: '127.0.0.1:5173' }, P),
174 'local guard: loopback peers (127/8, ::1, ::ffff:127.0.0.1) are allowed',
175 )
176 ok(
177 loopbackBind(undefined) && loopbackBind('127.0.0.1') && loopbackBind('localhost') && loopbackBind('::1') && loopbackBind('[::1]') &&
178 !loopbackBind(true) && !loopbackBind('0.0.0.0') && !loopbackBind('::') && !loopbackBind('192.168.1.20') && !loopbackBind(''),
179 'dev server bind: the operator API runs only on a loopback bind (vite --host / 0.0.0.0 / a LAN address keep it off)',
180 )
181}
182ok(parseCard('{"card":"v2"}') === 'v2' && parseCard('{"card":"evil-drain","target":"0x01"}') === 'evil-drain', 'body: the card id is read, any other field is ignored')
183ok(
184 [null, '', 'v2', '"v2"', '[]', '["v2"]', '{"card":2}', '{"card":"V2"}', '{"card":"evilMint"}', '{"card":"0x4D959f981C01E3B81ac6C9A20E84816Fe5f6Bce3"}', '{"card":"remove-receiver "}', `{"card":"v2","x":"${'a'.repeat(MAX_BODY_BYTES)}"}`].every(
185 (b) => parseCard(b) === null,
186 ),
187 'body: anything but one of the seven ids (an address, another spelling, an oversized body) is refused',
188)
189ok(cooldownLeft(100n, null, 3) === 0 && cooldownLeft(100n, 100n, 3) === 3 && cooldownLeft(100n, 99n, 3) === 2 && cooldownLeft(100n, 98n, 3) === 1 && cooldownLeft(100n, 97n, 3) === 0 && cooldownLeft(100n, 101n, 3) === 3, 'cooldown math: 3, 2, 1 blocks left, then go; a node behind the proposal counts as 0 blocks')
190
191// ---------- 3. a fake Sepolia ----------
192const OWNER_KEY = generatePrivateKey()
193const OWNER = privateKeyToAccount(OWNER_KEY).address
194const OTHER_KEY = generatePrivateKey()
195const TOKEN = toHex(crypto.getRandomValues(new Uint8Array(32))).slice(2)
196const DEP: OperatorDeployment = { ...DEPLOYMENT, pending: false, owner: OWNER }
197const [RPC_A, RPC_B] = ['https://rpc-a.invalid', 'https://rpc-b.invalid']
198const ZERO32 = `0x${'0'.repeat(64)}` as Hex
199
200interface Fake {
201 chainId: number
202 block: bigint
203 baseFee: bigint
204 tip: bigint | null
205 nextId: bigint
206 newest: bigint
207 nonce: bigint
208 pending: bigint
209 revert: boolean
210 sendError: string | null
211 down: Set<string>
212 calls: { url: string; method: string }[]
213 dryRuns: { from: string; data: Hex }[]
214 sent: Hex[]
215}
216const fake = (o: Partial<Fake> = {}): Fake => ({
217 chainId: 11155111,
218 block: 12_000_000n,
219 baseFee: 1_000_000_000n,
220 tip: 2_000_000_000n,
221 nextId: 9n,
222 newest: 11_999_000n,
223 nonce: 40n,
224 pending: 40n,
225 revert: false,
226 sendError: null,
227 down: new Set(),
228 calls: [],
229 dryRuns: [],
230 sent: [],
231 ...o,
232})
233function fakeFetch(f: Fake): typeof fetch {
234 return (async (input: RequestInfo | URL, init?: RequestInit) => {
235 const url = String(input)
236 const req = JSON.parse(String(init?.body)) as { id: number; method: string; params: any[] }
237 f.calls.push({ url, method: req.method })
238 if (f.down.has(url)) throw new TypeError('fetch failed')
239 const res = (o: object) => new Response(JSON.stringify({ jsonrpc: '2.0', id: req.id, ...o }), { status: 200, headers: { 'content-type': 'application/json' } })
240 const okr = (result: unknown) => res({ result })
241 const err = (code: number, message: string) => res({ error: { code, message } })
242 switch (req.method) {
243 case 'eth_chainId':
244 return okr(toHex(f.chainId))
245 case 'eth_getBlockByNumber':
246 return okr({ number: toHex(f.block), baseFeePerGas: toHex(f.baseFee) })
247 case 'eth_getTransactionCount':
248 return okr(toHex(req.params[1] === 'pending' ? f.pending : f.nonce))
249 case 'eth_maxPriorityFeePerGas':
250 return f.tip === null ? err(-32601, 'the method eth_maxPriorityFeePerGas does not exist') : okr(toHex(f.tip))
251 case 'eth_call': {
252 const call = req.params[0] as { from?: string; to: string; data: Hex }
253 const d = decodeFunctionData({ abi: OPERATOR_ABI, data: call.data })
254 if (d.functionName === 'nextId') return okr(encodeFunctionResult({ abi: OPERATOR_ABI, functionName: 'nextId', result: f.nextId }))
255 if (d.functionName === 'getProposal')
256 return okr(
257 encodeFunctionResult({
258 abi: OPERATOR_ABI,
259 functionName: 'getProposal',
260 result: { kind: 0, status: 1, proposer: OWNER, target: OWNER, value: 0n, codehash: ZERO32, dataHash: ZERO32, proposedBlock: f.newest, readyBlock: f.newest + 25n, vetoCount: 0n },
261 }),
262 )
263 f.dryRuns.push({ from: String(call.from), data: call.data })
264 return f.revert ? err(3, 'execution reverted: NotOwner') : okr(encodeFunctionResult({ abi: OPERATOR_ABI, functionName: 'proposeUpgrade', result: f.nextId }))
265 }
266 case 'eth_sendRawTransaction':
267 f.sent.push(req.params[0])
268 return f.sendError ? err(-32000, f.sendError) : okr(keccak256(req.params[0]))
269 }
270 return err(-32601, 'not in the fake')
271 }) as typeof fetch
272}
273const cfg = (f: Fake, o: Partial<OperatorConfig> = {}): OperatorConfig => ({
274 auth: 'token',
275 token: TOKEN,
276 ownerKey: () => OWNER_KEY,
277 rpcUrls: [RPC_A, RPC_B],
278 deployment: DEP,
279 fetchFn: fakeFetch(f),
280 ...o,
281})
282const auth = `Bearer ${TOKEN}`
283const body = (card: string) => JSON.stringify({ card })
284const SECRETS = [OWNER_KEY.slice(2).toLowerCase(), OTHER_KEY.slice(2).toLowerCase(), TOKEN.toLowerCase()]
285const seen: string[] = []
286const run = async (f: Fake, card: string, o: Partial<OperatorConfig> = {}, authorization: string | null = auth) => {
287 const r = await propose({ authorization, body: body(card) }, cfg(f, o))
288 seen.push(JSON.stringify(r))
289 return r
290}
291const is = (r: ApiResult, status: number, error?: string) => r.status === status && (error === undefined || r.body.error === error)
292
293async function main() {
294 // ---------- 4. GET /api/operator ----------
295 ok(JSON.stringify(operatorInfo(null)) === JSON.stringify({ enabled: false, auth: 'token', cards: [], cooldownBlocks: 0 }) && JSON.stringify(disabledInfo()) === JSON.stringify(operatorInfo(null)), 'not configured: {enabled:false, auth:"token", cards:[], cooldownBlocks:0}')
296 const on = operatorInfo(cfg(fake()))
297 ok(on.enabled && on.auth === 'token' && on.cards.join() === CARD_IDS.join() && on.cooldownBlocks === COOLDOWN_BLOCKS && COOLDOWN_BLOCKS === 3, 'configured: enabled, token auth, the seven cards, a 3-block cooldown')
298 ok(operatorInfo(cfg(fake(), { auth: 'none', token: undefined })).auth === 'none' && operatorInfo(cfg(fake(), { auth: 'none', token: undefined })).enabled, 'local dev server: enabled with auth "none"')
299 ok(
300 !operatorInfo(cfg(fake(), { token: 'short' })).enabled &&
301 !operatorInfo(cfg(fake(), { token: undefined })).enabled &&
302 !operatorInfo(cfg(fake(), { ownerKey: () => undefined })).enabled &&
303 !operatorInfo(cfg(fake(), { ownerKey: () => '0x1234' })).enabled &&
304 !operatorInfo(cfg(fake(), { deployment: { ...DEP, pending: true } })).enabled,
305 'off without a 32+ character token, without a well-formed key, or on the placeholder deployment',
306 )
307
308 // ---------- 5. POST /api/operator/propose: refusals that never touch the network ----------
309 {
310 const f = fake()
311 const r401 = [await run(f, 'v2', {}, null), await run(f, 'v2', {}, `Bearer ${TOKEN.slice(0, -1)}${TOKEN.endsWith('0') ? '1' : '0'}`), await run(f, 'v2', {}, `Bearer ${TOKEN}x`), await run(f, 'v2', {}, TOKEN), await run(f, 'v2', {}, `Basic ${TOKEN}`)]
312 ok(r401.every((r) => is(r, 401, 'unauthorized') && r.body.ok === false), '401 unauthorized: no header, a wrong token, a longer one, no "Bearer"')
313 const r400 = [await run(f, 'evilMint'), await run(f, '0x4D959f981C01E3B81ac6C9A20E84816Fe5f6Bce3'), await run(f, ''), await propose({ authorization: auth, body: null }, cfg(f)), await propose({ authorization: auth, body: '{bad json' }, cfg(f))]
314 ok(r400.every((r) => is(r, 400, 'unknown card')), '400 unknown card: another spelling, an address, empty, an oversized or broken body')
315 const r401first = await run(f, 'not-a-card', {}, null)
316 ok(is(r401first, 401), 'the token is checked before the card')
317 ok(is(await run(f, 'evil-drain', { deployment: { ...DEP, impls: { ...DEP.impls, evilDrain: undefined } } }), 400, 'unknown card'), '400 for a card this deployment has no implementation for')
318 const off = await run(f, 'v2', { ownerKey: () => undefined })
319 ok(is(off, 403, 'disabled') && off.body.enabled === false, '403 disabled when the operator is off')
320 const wrong = await run(f, 'v2', { ownerKey: () => OTHER_KEY })
321 ok(is(wrong, 500, 'misconfigured'), '500 misconfigured: a key that is not the owner\'s')
322 ok(f.calls.length === 0, `none of these reached the network (${f.calls.length} RPC calls)`)
323 }
324
325 // ---------- 6. checks against the chain ----------
326 for (const [since, left] of [
327 [0n, 3],
328 [1n, 2],
329 [2n, 1],
330 ] as const) {
331 resetOperatorState()
332 const f = fake({ newest: 12_000_000n - since })
333 const r = await run(f, 'evil-mint')
334 ok(is(r, 409, 'cooldown') && r.body.retryAfterBlocks === left && f.sent.length === 0, `409 cooldown: newest proposal ${since} block(s) old -> retry after ${left}`)
335 }
336 {
337 resetOperatorState()
338 const f = fake({ newest: 12_000_000n - 3n })
339 ok(is(await run(f, 'evil-mint'), 200) && f.sent.length === 1, 'newest proposal 3 blocks old: sent')
340 resetOperatorState()
341 const f1 = fake({ nextId: 1n })
342 ok(is(await run(f1, 'v2'), 200) && f1.calls.filter((c) => c.method === 'eth_call').length === 2, 'no proposal yet (nextId 1): no cooldown read, sent')
343 }
344 {
345 resetOperatorState()
346 const f = fake({ pending: 41n })
347 const r = await run(f, 'evil-fee')
348 ok(is(r, 409, 'cooldown') && r.body.retryAfterBlocks === COOLDOWN_BLOCKS + 1 && f.sent.length === 0, '409 cooldown while the owner has a pending transaction')
349 }
350 {
351 resetOperatorState()
352 const f = fake({ chainId: 1 })
353 ok(is(await run(f, 'v2'), 502, 'rpc') && f.sent.length === 0, '502 rpc: a node that is not Sepolia; nothing sent')
354 resetOperatorState()
355 const g = fake({ down: new Set([RPC_A, RPC_B]) })
356 ok(is(await run(g, 'v2'), 502, 'rpc') && g.sent.length === 0, '502 rpc: every node down')
357 resetOperatorState()
358 const h = fake({ baseFee: 400_000_000_000n })
359 ok(is(await run(h, 'v2'), 502, 'rpc') && h.sent.length === 0, '502 rpc: fees above the 500 gwei cap are never signed')
360 resetOperatorState()
361 const rv = fake({ revert: true })
362 ok(is(await run(rv, 'remove-receiver'), 422, 'reverted') && rv.sent.length === 0 && rv.dryRuns.length === 1, '422 reverted: the dry run reverts, nothing is sent')
363 }
364
365 // ---------- 7. a send, for every card ----------
366 for (const card of CARD_IDS) {
367 resetOperatorState()
368 const f = fake()
369 const r = await run(f, card)
370 const raw = f.sent[0] as TransactionSerializedEIP1559 | undefined
371 const tx = raw ? parseTransaction(raw) : null
372 const want = buildAction(card as CardId, DEP)!
373 const signer = raw ? await recoverTransactionAddress({ serializedTransaction: raw }) : null
374 ok(
375 is(r, 200) &&
376 r.body.ok === true &&
377 r.body.card === card &&
378 f.sent.length === 1 &&
379 r.body.txHash === keccak256(raw!) &&
380 tx?.type === 'eip1559' &&
381 tx.chainId === 11155111 &&
382 tx.to?.toLowerCase() === DEP.airlockAdmin.toLowerCase() &&
383 tx.data === want.data &&
384 (tx.value ?? 0n) === 0n &&
385 tx.gas === GAS_LIMIT &&
386 tx.nonce === 40 &&
387 tx.maxPriorityFeePerGas === 2_000_000_000n &&
388 tx.maxFeePerGas === 2n * 1_000_000_000n + 2_000_000_000n &&
389 signer === OWNER &&
390 f.dryRuns.length === 1 &&
391 f.dryRuns[0].from.toLowerCase() === OWNER.toLowerCase() &&
392 f.dryRuns[0].data === want.data,
393 `${card}: one EIP-1559 tx from the owner to AirlockAdmin, exact calldata, gas 1,000,000, nonce from the node, fee 2*base + tip, dry run first; txHash returned`,
394 )
395 }
396 {
397 resetOperatorState()
398 const f = fake({ down: new Set([RPC_A]) })
399 ok(is(await run(f, 'v2'), 200) && f.calls.some((c) => c.url === RPC_B && c.method === 'eth_sendRawTransaction'), 'publicnode down: the fallback RPC is used')
400 resetOperatorState()
401 const t = fake({ tip: null })
402 await run(t, 'v2')
403 ok(parseTransaction(t.sent[0] as TransactionSerializedEIP1559).maxPriorityFeePerGas === MIN_PRIORITY_FEE, 'a node without eth_maxPriorityFeePerGas: the 1.5 gwei tip floor')
404 resetOperatorState()
405 const lo = fake({ tip: 1n })
406 await run(lo, 'v2')
407 ok(parseTransaction(lo.sent[0] as TransactionSerializedEIP1559).maxPriorityFeePerGas === MIN_PRIORITY_FEE, 'a tiny tip from the node is raised to the floor')
408 resetOperatorState()
409 const known = fake({ sendError: 'already known' })
410 const rk = await run(known, 'v2')
411 ok(is(rk, 200) && rk.body.txHash === keccak256(known.sent[0]), '"already known" from the node: the same signed tx is there, 200 with its hash')
412 resetOperatorState()
413 const funds = fake({ sendError: 'insufficient funds for gas * price + value' })
414 ok(is(await run(funds, 'v2'), 502, 'rpc') && funds.sent.length === 2, 'a node that refuses the tx: the same bytes are tried on the fallback, then 502 rpc')
415 }
416 {
417 // after a send, this isolate waits for the owner's nonce to move on
418 resetOperatorState()
419 const f = fake()
420 ok(is(await run(f, 'evil-mint'), 200), 'first launch sent')
421 const again = await run(f, 'evil-owner')
422 ok(is(again, 409, 'cooldown') && f.sent.length === 1, 'a second launch before the first is mined: 409 cooldown (even when the node does not show it pending)')
423 f.nonce = 41n
424 f.pending = 41n
425 f.newest = f.block
426 f.block += 3n
427 ok(is(await run(f, 'evil-owner'), 200) && f.sent.length === 2, 'once it is mined and 3 blocks old, the next launch goes')
428 // two at once: the second sees the first in flight
429 resetOperatorState()
430 const g = fake()
431 const [a, b] = await Promise.all([run(g, 'v2'), run(g, 'evil-mint')])
432 ok([a, b].filter((r) => r.status === 200).length === 1 && [a, b].filter((r) => is(r, 409, 'cooldown')).length === 1 && g.sent.length === 1, 'two launches at the same moment: one is sent, the other gets 409')
433 }
434
435 // ---------- 8. the Cloudflare handlers ----------
436 {
437 const url = 'https://airlock.example/api/operator'
438 const info = await handleInfoRequest(new Request(url), {})
439 const j = await info.json()
440 ok(info.status === 200 && JSON.stringify(j) === JSON.stringify(disabledInfo()), 'Function GET without secrets: 200 {enabled:false, ...}')
441 ok(info.headers.get('content-type')?.startsWith('application/json') && info.headers.get('cache-control') === 'no-store' && info.headers.get('x-content-type-options') === 'nosniff', 'Function responses: JSON, no-store, nosniff')
442 ok(!(await (await handleInfoRequest(new Request(url), { OWNER_KEY: OWNER_KEY })).json()).enabled && !(await (await handleInfoRequest(new Request(url), { OPERATOR_TOKEN: TOKEN })).json()).enabled, 'Function GET with only one of the two secrets: off')
443 const env = { OWNER_KEY: OTHER_KEY.slice(2), OPERATOR_TOKEN: TOKEN }
444 const jon = await (await handleInfoRequest(new Request(url), env)).json()
445 ok(jon.enabled === true && jon.auth === 'token' && jon.cards.length === availableCards(DEPLOYMENT).length && jon.cooldownBlocks === 3, 'Function GET with both secrets (key without 0x): enabled, token auth')
446 const put = await handleInfoRequest(new Request(url, { method: 'PUT' }), env)
447 ok(put.status === 405 && put.headers.get('allow') === 'GET', 'Function GET route: 405 for PUT, Allow: GET')
448 const purl = url + '/propose'
449 const pget = await handleProposeRequest(new Request(purl), env)
450 ok(pget.status === 405 && pget.headers.get('allow') === 'POST', 'Function propose route: 405 for GET, Allow: POST')
451 const post = (h: Record<string, string>, b: string) => handleProposeRequest(new Request(purl, { method: 'POST', headers: h, body: b }), env)
452 const noAuth = await post({ 'content-type': 'application/json' }, body('v2'))
453 ok(noAuth.status === 401 && (await noAuth.json()).error === 'unauthorized', 'Function POST without the token: 401')
454 const foreign = await post({ authorization: auth, origin: 'https://evil.example' }, body('v2'))
455 ok(foreign.status === 403, 'Function POST from a foreign Origin: 403')
456 const big = await post({ authorization: auth, origin: 'https://airlock.example' }, `{"card":"v2","pad":"${'x'.repeat(2 * MAX_BODY_BYTES)}"}`)
457 ok(big.status === 400, 'Function POST with an oversized body: 400 (not read past the cap)')
458 const bad = await post({ authorization: auth }, body('drain-everything'))
459 ok(bad.status === 400 && (await bad.json()).error === 'unknown card', 'Function POST with an unknown card: 400')
460 const dummy = await post({ authorization: auth }, body('v2'))
461 ok(dummy.status === 500 && (await dummy.json()).error === 'misconfigured' && trapped === 0, 'Function POST with a key that is not the owner\'s: 500 misconfigured, before any network call')
462 ok(cloudConfig({ OWNER_KEY: 'k' }) === null && cloudConfig({ OPERATOR_TOKEN: 't' }) === null && cloudConfig({ OWNER_KEY: 'k', OPERATOR_TOKEN: 't' })?.auth === 'token', 'cloudConfig needs both secrets')
463 const stream = new ReadableStream<Uint8Array>({
464 start(c) {
465 for (let i = 0; i < 8; i++) c.enqueue(new Uint8Array(400).fill(65))
466 c.close()
467 },
468 })
469 const capped = await readCapped(new Request(purl, { method: 'POST', body: stream, duplex: 'half' } as RequestInit))
470 ok(capped === null, 'a streamed body over the cap is cut off (no content-length needed)')
471 }
472
473 // ---------- 9. nothing secret, nothing local ----------
474 {
475 const all = seen.join('\n').toLowerCase()
476 ok(SECRETS.every((s) => !all.includes(s)), `no response carries the key or the token (${seen.length} responses)`)
477 ok(!/execution reverted|fetch failed|insufficient|already known|notowner|does not exist|trapped/i.test(seen.join('\n')), 'no response carries a node\'s or the runtime\'s error text')
478 const errors = new Set(seen.map((s) => JSON.parse(s).body.error).filter(Boolean))
479 ok([...errors].every((e) => ['unauthorized', 'unknown card', 'cooldown', 'rpc', 'disabled', 'reverted', 'misconfigured'].includes(e)), `error strings are fixed words only: ${[...errors].join(', ')}`)
480 ok(DEFAULT_RPCS.join() === 'https://ethereum-sepolia-rpc.publicnode.com,https://sepolia.drpc.org', 'default RPCs: publicnode, then drpc')
481 const files = [...['cards.ts', 'operator.ts', 'cloud.ts', 'deployment.generated.ts'].map((f) => join(WEB, 'operator', f)), join(WEB, 'functions', 'api', 'operator', 'index.ts'), join(WEB, 'functions', 'api', 'operator', 'propose.ts'), join(WEB, 'vite.config.ts')]
482 const hits = files.flatMap((f) => findBlocked(readFileSync(f, 'utf8')).map((h) => `${f.slice(WEB.length + 1)}: ${h}`))
483 ok(hits.length === 0, `operator sources are clean of blocked names and local paths (${NAMES_NOTE})${hits.length ? ': ' + hits.join('; ') : ''}`)
484 ok(readdirSync(join(WEB, 'functions', 'api', 'operator')).sort().join() === 'index.ts,propose.ts', 'functions/api/operator holds exactly the two routes')
485 ok(files.slice(0, 6).every((f) => !/from ['"]node:/.test(readFileSync(f, 'utf8'))), 'operator/ and functions/ import no node: modules (they run on Workers)')
486 ok(trapped === 0, `the real fetch was never called (${trapped})`)
487 }
488}
489
490main()
491 .catch((e) => {
492 console.log(`FAIL crashed: ${e instanceof Error ? e.message : String(e)}`)
493 fails++
494 })
495 .finally(() => {
496 console.log(fails ? `\n${fails} FAILED` : '\nall passed')
497 process.exit(fails ? 1 : 0)
498 })