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. |
| 4 | import { execFileSync } from 'node:child_process' |
| 5 | import { existsSync, readdirSync, readFileSync } from 'node:fs' |
| 6 | import { homedir } from 'node:os' |
| 7 | import { join } from 'node:path' |
| 8 | import { |
| 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' |
| 21 | import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' |
| 22 | import { availableCards, buildAction, CARD_ACTION, CARD_IDS, OPERATOR_ABI, type CardId, type OperatorDeployment } from '../operator/cards' |
| 23 | import { cloudConfig, handleInfoRequest, handleProposeRequest, readCapped } from '../operator/cloud' |
| 24 | import { DEPLOYMENT } from '../operator/deployment.generated' |
| 25 | import { |
| 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' |
| 44 | import { findBlocked, NAMES_NOTE } from './scan' |
| 45 | |
| 46 | let fails = 0 |
| 47 | function ok(cond: unknown, msg: string) { |
| 48 | console.log(`${cond ? 'ok ' : 'FAIL'} ${msg}`) |
| 49 | if (!cond) fails++ |
| 50 | } |
| 51 | const WEB = process.cwd() |
| 52 | const REPO = join(WEB, '..') |
| 53 | |
| 54 | // every network call must go through an injected fake; the real fetch is a trap |
| 55 | let trapped = 0 |
| 56 | globalThis.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 ---------- |
| 62 | const shipped = JSON.parse(readFileSync(join(WEB, 'src', 'chain-data-sepolia.json'), 'utf8')) |
| 63 | ok(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 | } |
| 83 | const 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 | }) |
| 91 | const 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 ---------- |
| 128 | ok(constantTimeEqual('a'.repeat(64), 'a'.repeat(64)) && constantTimeEqual('', ''), 'constant-time compare: equal strings') |
| 129 | ok(!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 | } |
| 135 | ok(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 | } |
| 182 | ok(parseCard('{"card":"v2"}') === 'v2' && parseCard('{"card":"evil-drain","target":"0x01"}') === 'evil-drain', 'body: the card id is read, any other field is ignored') |
| 183 | ok( |
| 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 | ) |
| 189 | ok(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 ---------- |
| 192 | const OWNER_KEY = generatePrivateKey() |
| 193 | const OWNER = privateKeyToAccount(OWNER_KEY).address |
| 194 | const OTHER_KEY = generatePrivateKey() |
| 195 | const TOKEN = toHex(crypto.getRandomValues(new Uint8Array(32))).slice(2) |
| 196 | const DEP: OperatorDeployment = { ...DEPLOYMENT, pending: false, owner: OWNER } |
| 197 | const [RPC_A, RPC_B] = ['https://rpc-a.invalid', 'https://rpc-b.invalid'] |
| 198 | const ZERO32 = `0x${'0'.repeat(64)}` as Hex |
| 199 | |
| 200 | interface 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 | } |
| 216 | const 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 | }) |
| 233 | function 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 | } |
| 273 | const 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 | }) |
| 282 | const auth = `Bearer ${TOKEN}` |
| 283 | const body = (card: string) => JSON.stringify({ card }) |
| 284 | const SECRETS = [OWNER_KEY.slice(2).toLowerCase(), OTHER_KEY.slice(2).toLowerCase(), TOKEN.toLowerCase()] |
| 285 | const seen: string[] = [] |
| 286 | const 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 | } |
| 291 | const is = (r: ApiResult, status: number, error?: string) => r.status === status && (error === undefined || r.body.error === error) |
| 292 | |
| 293 | async 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 | |
| 490 | main() |
| 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 | }) |