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 / operator / operator.ts
web/operator/operator.ts353 lines · 17033 bytes
1// Operator backend: launches one of the seven fixed attack cards (operator/cards.ts) on Ethereum Sepolia as the
2// AirlockAdmin owner, for a live demo. Runtime-neutral (viem and Web APIs only, no node: imports): the Vite dev server
3// (vite.config.ts, loopback only) and the Cloudflare Pages Functions (functions/api/operator/) both call it.
4//
5// GET /api/operator 200 {enabled, auth: 'none' | 'token', cards, cooldownBlocks}
6// POST /api/operator/propose body {"card": "<id>"}; header Authorization: Bearer <token> when auth is 'token'
7// 200 {ok: true, card, txHash} the proposal is signed and sent; it is not mined yet
8// 400 {ok: false, error: 'unknown card'}
9// 401 {ok: false, error: 'unauthorized'}
10// 409 {ok: false, error: 'cooldown', retryAfterBlocks} the newest proposal is under 3 blocks old, or the
11// owner still has a transaction in flight
12// 502 {ok: false, error: 'rpc'} no Sepolia node answered usefully (or fees were out of bounds)
13// and three that only a misconfiguration or the chain itself can cause:
14// 403 {ok: false, enabled: false, error: 'disabled'} the operator is off here
15// 422 {ok: false, error: 'reverted'} the node's dry run of the proposal reverted, so nothing was sent
16// 500 {ok: false, error: 'misconfigured'} the key does not belong to the deployment's owner
17//
18// The private key and the token are handed in by the caller at request time and stay inside this module: no response,
19// no error string and no log line carries them, nor any text a node sent back. The client only ever names a card id;
20// the address and the calldata come from the deployment data bundled at build time.
21import { decodeFunctionResult, encodeFunctionData, keccak256, numberToHex, sha256, stringToBytes, type Address, type Hex } from 'viem'
22import { privateKeyToAccount, type PrivateKeyAccount } from 'viem/accounts'
23import { availableCards, buildAction, deploymentUsable, isCardId, OPERATOR_ABI, type CardId, type OperatorDeployment } from './cards.ts'
24import { DEPLOYMENT } from './deployment.generated.ts'
25
26export const SEPOLIA_CHAIN_ID = 11155111
27/** Tried in this order; a read that fails on one is asked of the next */
28export const DEFAULT_RPCS: readonly string[] = ['https://ethereum-sepolia-rpc.publicnode.com', 'https://sepolia.drpc.org']
29/** A new proposal waits until the newest one is at least this many blocks old */
30export const COOLDOWN_BLOCKS = 3
31/** proposeUpgrade used 594,008 gas on Sepolia (each one so far); an explicit limit means no estimate call */
32export const GAS_LIMIT = 1_000_000n
33/** Never sign a fee above this (a node answering nonsense must not drain the owner): 500 gwei */
34export const MAX_FEE_CAP = 500_000_000_000n
35/** Tip floor so a stage demo is mined in the next block or two: 1.5 gwei */
36export const MIN_PRIORITY_FEE = 1_500_000_000n
37/** A request body over this many bytes is refused unread */
38export const MAX_BODY_BYTES = 1024
39/** The configured token must be at least this long (set-operator-secrets.sh makes 64 hex characters) */
40export const MIN_TOKEN_LENGTH = 32
41/** After a send, this isolate refuses another until the owner's nonce moves on, for at most this long */
42const RECENT_SEND_MS = 90_000
43
44export interface OperatorInfo {
45 enabled: boolean
46 auth: 'none' | 'token'
47 cards: string[]
48 cooldownBlocks: number
49}
50
51/** What every runtime answers when the operator is not configured (a static deploy answers 404 instead) */
52export const disabledInfo = (): OperatorInfo => ({ enabled: false, auth: 'token', cards: [], cooldownBlocks: 0 })
53
54export interface ApiResult {
55 status: number
56 body: Record<string, unknown>
57}
58
59export interface OperatorConfig {
60 /** 'none': the local dev server (loopback only); 'token': a Bearer token is required */
61 auth: 'none' | 'token'
62 /** The expected token when auth is 'token' */
63 token?: string
64 /** Returns the owner's private key (hex, 0x optional) at request time; undefined when there is none */
65 ownerKey: () => string | undefined
66 /** Sepolia JSON-RPC endpoints (default DEFAULT_RPCS) */
67 rpcUrls?: readonly string[]
68 /** Default: the Sepolia deployment bundled at build time (operator/deployment.generated.ts) */
69 deployment?: OperatorDeployment
70 cooldownBlocks?: number
71 /** Tests inject a fake Sepolia here */
72 fetchFn?: typeof fetch
73 rpcTimeoutMs?: number
74 now?: () => number
75}
76
77/** The JSON headers every operator response carries: never cached, never sniffed, never referred */
78export const JSON_HEADERS: Readonly<Record<string, string>> = {
79 'content-type': 'application/json; charset=utf-8',
80 'cache-control': 'no-store',
81 'x-content-type-options': 'nosniff',
82 'referrer-policy': 'no-referrer',
83}
84
85const result = (status: number, body: Record<string, unknown>): ApiResult => ({ status, body })
86const fail = (status: number, error: string, extra: Record<string, unknown> = {}): ApiResult => result(status, { ok: false, error, ...extra })
87
88// ---------------------------------------------------------------- secrets
89
90/** A 32-byte hex private key (whitespace and a 0x prefix allowed), or null */
91export function parseKey(raw: string | undefined): Hex | null {
92 const k = (raw ?? '').replace(/\s+/g, '')
93 if (!/^(0x)?[0-9a-fA-F]{64}$/.test(k)) return null
94 return (k.startsWith('0x') ? k : `0x${k}`) as Hex
95}
96
97function accountOf(raw: string | undefined): PrivateKeyAccount | null {
98 const k = parseKey(raw)
99 if (!k) return null
100 try {
101 return privateKeyToAccount(k) // throws on 0 or a value over the curve order
102 } catch {
103 return null
104 }
105}
106
107/** Compares two secrets in time that depends on neither: both are hashed to 32 bytes first, so even their lengths
108 * never show, and every byte of the digests is compared */
109export function constantTimeEqual(a: string, b: string): boolean {
110 const x = sha256(stringToBytes(a), 'bytes')
111 const y = sha256(stringToBytes(b), 'bytes')
112 let diff = 0
113 for (let i = 0; i < 32; i++) diff |= x[i] ^ y[i]
114 return diff === 0
115}
116
117/** The token of an "Authorization: Bearer <token>" header, or null */
118export function bearer(header: string | null | undefined): string | null {
119 const m = /^\s*Bearer\s+(\S+)\s*$/i.exec(header ?? '')
120 return m ? m[1] : null
121}
122
123// ---------------------------------------------------------------- local dev server guard
124
125/** True for a loopback peer address as Node reports it (IPv4 127/8, IPv6 ::1, IPv4-mapped 127/8) */
126export function isLoopbackAddress(a: string | null | undefined): boolean {
127 return /^(127\.\d{1,3}\.\d{1,3}\.\d{1,3}|::1|::ffff:127\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.test(a ?? '')
128}
129
130/** True when a dev server bound to this `server.host` value listens on loopback only (unset = vite's localhost) */
131export function loopbackBind(host: string | boolean | undefined): boolean {
132 if (host === undefined) return true
133 if (typeof host !== 'string') return false // true = every interface
134 const h = host.trim().toLowerCase().replace(/^\[|\]$/g, '')
135 return h === 'localhost' || isLoopbackAddress(h)
136}
137
138/** The dev server's guard. The connecting peer (the socket's remote address, which a request cannot fake) must be
139 * loopback, so another device on the same network is refused even when it sends Host 127.0.0.1. Host must be
140 * 127.0.0.1:<port> or localhost:<port> (a page that rebinds its own name to 127.0.0.1 still sends its own name);
141 * Origin, whenever the browser sends one, must be that same origin; Sec-Fetch-Site, when present, same-origin or
142 * none (typed into the address bar). */
143export function localRequestAllowed(
144 h: { remote: string | null | undefined; host?: string | null; origin?: string | null; secFetchSite?: string | null },
145 port: number,
146): boolean {
147 if (!isLoopbackAddress(h.remote)) return false
148 const host = (h.host ?? '').toLowerCase()
149 if (!port || (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`)) return false
150 if (h.origin != null && h.origin.toLowerCase() !== `http://${host}`) return false
151 if (h.secFetchSite != null && h.secFetchSite !== 'same-origin' && h.secFetchSite !== 'none') return false
152 return true
153}
154
155// ---------------------------------------------------------------- GET /api/operator
156
157const tokenOk = (cfg: OperatorConfig) => cfg.auth === 'none' || (typeof cfg.token === 'string' && cfg.token.trim().length >= MIN_TOKEN_LENGTH)
158
159/** Enabled only with a usable deployment, a well-formed key and (auth 'token') a long enough token */
160export function operatorInfo(cfg: OperatorConfig | null): OperatorInfo {
161 if (!cfg) return disabledInfo()
162 const d = cfg.deployment ?? DEPLOYMENT
163 if (!deploymentUsable(d) || !tokenOk(cfg) || !parseKey(cfg.ownerKey())) return disabledInfo()
164 return { enabled: true, auth: cfg.auth, cards: availableCards(d), cooldownBlocks: cfg.cooldownBlocks ?? COOLDOWN_BLOCKS }
165}
166
167// ---------------------------------------------------------------- JSON-RPC with fallback
168
169/** No endpoint gave a usable answer */
170class RpcDown extends Error {}
171/** A node answered with a JSON-RPC error the caller treats as final (a revert, an already known transaction) */
172class NodeRefused extends Error {}
173
174interface RpcError {
175 code?: number
176 message: string
177}
178type Rpc = (method: string, params: unknown[], isFinal?: (e: RpcError) => boolean) => Promise<unknown>
179
180function makeRpc(urls: readonly string[], fetchFn: typeof fetch, timeoutMs: number): Rpc {
181 let start = 0
182 return async (method, params, isFinal) => {
183 for (let i = 0; i < urls.length; i++) {
184 const idx = (start + i) % urls.length
185 const ctl = new AbortController()
186 const timer = setTimeout(() => ctl.abort(), timeoutMs)
187 let j: unknown
188 try {
189 const res = await fetchFn(urls[idx], {
190 method: 'POST',
191 headers: { 'content-type': 'application/json' },
192 body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
193 signal: ctl.signal,
194 })
195 if (!res.ok) continue
196 j = await res.json()
197 } catch {
198 continue
199 } finally {
200 clearTimeout(timer)
201 }
202 if (!j || typeof j !== 'object') continue
203 const o = j as { result?: unknown; error?: { code?: unknown; message?: unknown } }
204 if (o.error) {
205 const e: RpcError = { code: typeof o.error.code === 'number' ? o.error.code : undefined, message: String(o.error.message ?? '') }
206 if (isFinal?.(e)) throw new NodeRefused()
207 continue
208 }
209 if (!('result' in o)) continue
210 start = idx
211 return o.result
212 }
213 throw new RpcDown()
214 }
215}
216
217const isRevert = (e: RpcError) => e.code === 3 || /revert/i.test(e.message)
218const isAlreadyKnown = (e: RpcError) => /already known|known transaction|already imported/i.test(e.message)
219
220function qty(v: unknown): bigint {
221 if (typeof v !== 'string' || !/^0x[0-9a-fA-F]{1,64}$/.test(v)) throw new RpcDown()
222 return BigInt(v)
223}
224function hexData(v: unknown): Hex {
225 if (typeof v !== 'string' || !/^0x([0-9a-fA-F]{2})*$/.test(v)) throw new RpcDown()
226 return v as Hex
227}
228
229// ---------------------------------------------------------------- POST /api/operator/propose
230
231let inFlight = false
232let lastSent: { nonce: bigint; at: number } | null = null
233
234/** Tests only: forget the in-flight flag and the last send */
235export function resetOperatorState(): void {
236 inFlight = false
237 lastSent = null
238}
239
240/** The card id of a request body ({"card": "<id>"}), or null */
241export function parseCard(body: string | null): CardId | null {
242 if (body == null || body.length > MAX_BODY_BYTES) return null
243 let j: unknown
244 try {
245 j = JSON.parse(body)
246 } catch {
247 return null
248 }
249 if (!j || typeof j !== 'object' || Array.isArray(j)) return null
250 const card = (j as { card?: unknown }).card
251 return isCardId(card) ? card : null
252}
253
254/** Blocks still to wait: the newest proposal must be at least `cooldown` blocks old (0 = go) */
255export function cooldownLeft(latestBlock: bigint, newestProposedBlock: bigint | null, cooldown: number): number {
256 if (newestProposedBlock === null) return 0
257 const since = latestBlock - newestProposedBlock
258 const left = BigInt(cooldown) - (since < 0n ? 0n : since)
259 return left > 0n ? Number(left) : 0
260}
261
262/** Checks, signs and sends one card's proposal. `body` is the raw request body (null when it was too large). */
263export async function propose(req: { authorization?: string | null; body: string | null }, cfg: OperatorConfig | null): Promise<ApiResult> {
264 if (!cfg || !operatorInfo(cfg).enabled) return fail(403, 'disabled', { enabled: false })
265 if (cfg.auth === 'token') {
266 const got = bearer(req.authorization)
267 if (got === null || !constantTimeEqual(got, (cfg.token ?? '').trim())) return fail(401, 'unauthorized')
268 }
269 const d = cfg.deployment ?? DEPLOYMENT
270 const card = parseCard(req.body)
271 const action = card ? buildAction(card, d) : null
272 if (!card || !action) return fail(400, 'unknown card')
273 // checked before any network call: a key that is not the owner's never reaches a node
274 const account = accountOf(cfg.ownerKey())
275 if (!account || account.address.toLowerCase() !== d.owner.toLowerCase()) return fail(500, 'misconfigured')
276 const cooldown = cfg.cooldownBlocks ?? COOLDOWN_BLOCKS
277 if (inFlight) return fail(409, 'cooldown', { retryAfterBlocks: cooldown + 1 })
278 inFlight = true
279 try {
280 return await send(card, action, account, cfg, cooldown)
281 } catch (e) {
282 return e instanceof NodeRefused ? fail(422, 'reverted') : fail(502, 'rpc')
283 } finally {
284 inFlight = false
285 }
286}
287
288async function send(card: CardId, action: { to: Address; data: Hex }, account: PrivateKeyAccount, cfg: OperatorConfig, cooldown: number): Promise<ApiResult> {
289 const fetchFn = cfg.fetchFn ?? ((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init))
290 const rpc = makeRpc(cfg.rpcUrls?.length ? cfg.rpcUrls : DEFAULT_RPCS, fetchFn, cfg.rpcTimeoutMs ?? 8000)
291 const now = cfg.now ?? Date.now
292 const owner = account.address
293 const admin = action.to
294
295 const [chainId, block, nextIdRaw, nonceLatest, noncePending, tip] = await Promise.all([
296 rpc('eth_chainId', []),
297 rpc('eth_getBlockByNumber', ['latest', false]),
298 rpc('eth_call', [{ to: admin, data: encodeFunctionData({ abi: OPERATOR_ABI, functionName: 'nextId' }) }, 'latest']),
299 rpc('eth_getTransactionCount', [owner, 'latest']),
300 rpc('eth_getTransactionCount', [owner, 'pending']),
301 rpc('eth_maxPriorityFeePerGas', []).catch(() => null),
302 ])
303 if (qty(chainId) !== BigInt(SEPOLIA_CHAIN_ID)) throw new RpcDown()
304 const blk = block as { number?: unknown; baseFeePerGas?: unknown } | null
305 if (!blk || typeof blk !== 'object') throw new RpcDown()
306 const latest = qty(blk.number)
307 const baseFee = qty(blk.baseFeePerGas)
308
309 // 1. cooldown: the newest proposal (nextId - 1) must be at least `cooldown` blocks old
310 const nextId = decodeFunctionResult({ abi: OPERATOR_ABI, functionName: 'nextId', data: hexData(nextIdRaw) })
311 let newestBlock: bigint | null = null
312 if (nextId > 1n) {
313 const raw = await rpc('eth_call', [{ to: admin, data: encodeFunctionData({ abi: OPERATOR_ABI, functionName: 'getProposal', args: [nextId - 1n] }) }, 'latest'])
314 newestBlock = decodeFunctionResult({ abi: OPERATOR_ABI, functionName: 'getProposal', data: hexData(raw) }).proposedBlock
315 }
316 const left = cooldownLeft(latest, newestBlock, cooldown)
317 if (left > 0) return fail(409, 'cooldown', { retryAfterBlocks: left })
318
319 // 2. nothing of the owner's in flight: no pending transaction, and not our own last send still unmined
320 const nonce = qty(nonceLatest)
321 const recent = lastSent !== null && now() - lastSent.at < RECENT_SEND_MS && nonce <= lastSent.nonce
322 if (qty(noncePending) > nonce || recent) return fail(409, 'cooldown', { retryAfterBlocks: cooldown + 1 })
323
324 // 3. dry run as the owner: a proposal the contract would refuse is never sent (NodeRefused -> 422)
325 await rpc('eth_call', [{ from: owner, to: admin, data: action.data, gas: numberToHex(GAS_LIMIT) }, 'latest'], isRevert)
326
327 // 4. EIP-1559 fees from the node, with a tip floor and a hard cap
328 const tipWei = tip === null ? MIN_PRIORITY_FEE : qty(tip)
329 const maxPriorityFeePerGas = tipWei > MIN_PRIORITY_FEE ? tipWei : MIN_PRIORITY_FEE
330 const maxFeePerGas = baseFee * 2n + maxPriorityFeePerGas
331 if (maxFeePerGas > MAX_FEE_CAP) throw new RpcDown()
332
333 // 5. sign and send; the same signed bytes go to the next node if one fails, so a retry can never double-send
334 const raw = await account.signTransaction({
335 type: 'eip1559',
336 chainId: SEPOLIA_CHAIN_ID,
337 nonce: Number(nonce),
338 to: admin,
339 data: action.data,
340 value: 0n,
341 gas: GAS_LIMIT,
342 maxFeePerGas,
343 maxPriorityFeePerGas,
344 })
345 const txHash = keccak256(raw)
346 try {
347 await rpc('eth_sendRawTransaction', [raw], isAlreadyKnown)
348 } catch (e) {
349 if (!(e instanceof NodeRefused)) throw e // NodeRefused here: the node already has this exact transaction
350 }
351 lastSent = { nonce, at: now() }
352 return result(200, { ok: true, card, txHash })
353}