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. |
| 21 | import { decodeFunctionResult, encodeFunctionData, keccak256, numberToHex, sha256, stringToBytes, type Address, type Hex } from 'viem' |
| 22 | import { privateKeyToAccount, type PrivateKeyAccount } from 'viem/accounts' |
| 23 | import { availableCards, buildAction, deploymentUsable, isCardId, OPERATOR_ABI, type CardId, type OperatorDeployment } from './cards.ts' |
| 24 | import { DEPLOYMENT } from './deployment.generated.ts' |
| 25 | |
| 26 | export const SEPOLIA_CHAIN_ID = 11155111 |
| 27 | /** Tried in this order; a read that fails on one is asked of the next */ |
| 28 | export 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 */ |
| 30 | export const COOLDOWN_BLOCKS = 3 |
| 31 | /** proposeUpgrade used 594,008 gas on Sepolia (each one so far); an explicit limit means no estimate call */ |
| 32 | export const GAS_LIMIT = 1_000_000n |
| 33 | /** Never sign a fee above this (a node answering nonsense must not drain the owner): 500 gwei */ |
| 34 | export 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 */ |
| 36 | export const MIN_PRIORITY_FEE = 1_500_000_000n |
| 37 | /** A request body over this many bytes is refused unread */ |
| 38 | export const MAX_BODY_BYTES = 1024 |
| 39 | /** The configured token must be at least this long (set-operator-secrets.sh makes 64 hex characters) */ |
| 40 | export 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 */ |
| 42 | const RECENT_SEND_MS = 90_000 |
| 43 | |
| 44 | export 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) */ |
| 52 | export const disabledInfo = (): OperatorInfo => ({ enabled: false, auth: 'token', cards: [], cooldownBlocks: 0 }) |
| 53 | |
| 54 | export interface ApiResult { |
| 55 | status: number |
| 56 | body: Record<string, unknown> |
| 57 | } |
| 58 | |
| 59 | export 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 */ |
| 78 | export 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 | |
| 85 | const result = (status: number, body: Record<string, unknown>): ApiResult => ({ status, body }) |
| 86 | const 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 */ |
| 91 | export 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 | |
| 97 | function 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 */ |
| 109 | export 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 */ |
| 118 | export 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) */ |
| 126 | export 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) */ |
| 131 | export 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). */ |
| 143 | export 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 | |
| 157 | const 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 */ |
| 160 | export 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 */ |
| 170 | class RpcDown extends Error {} |
| 171 | /** A node answered with a JSON-RPC error the caller treats as final (a revert, an already known transaction) */ |
| 172 | class NodeRefused extends Error {} |
| 173 | |
| 174 | interface RpcError { |
| 175 | code?: number |
| 176 | message: string |
| 177 | } |
| 178 | type Rpc = (method: string, params: unknown[], isFinal?: (e: RpcError) => boolean) => Promise<unknown> |
| 179 | |
| 180 | function 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 | |
| 217 | const isRevert = (e: RpcError) => e.code === 3 || /revert/i.test(e.message) |
| 218 | const isAlreadyKnown = (e: RpcError) => /already known|known transaction|already imported/i.test(e.message) |
| 219 | |
| 220 | function 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 | } |
| 224 | function 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 | |
| 231 | let inFlight = false |
| 232 | let lastSent: { nonce: bigint; at: number } | null = null |
| 233 | |
| 234 | /** Tests only: forget the in-flight flag and the last send */ |
| 235 | export function resetOperatorState(): void { |
| 236 | inFlight = false |
| 237 | lastSent = null |
| 238 | } |
| 239 | |
| 240 | /** The card id of a request body ({"card": "<id>"}), or null */ |
| 241 | export 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) */ |
| 255 | export 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). */ |
| 263 | export 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 | |
| 288 | async 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 | } |