cre/veto/airlock.ts389 lines · 18068 bytes
| 1 | // What the workflow does with a proposal, independent of the CRE SDK: the SDK calls are behind `Ports`, so the |
| 2 | // same code runs in the DON (workflow.ts wires the EVM, HTTP and secrets capabilities) and in Bun unit tests |
| 3 | // (an in-memory chain). |
| 4 | // |
| 5 | // Both handlers end in the same place: every judged proposal gets an on-chain report. |
| 6 | // pass -> Clear (AirlockAdmin.clear: the proposal may execute after its cooldown) |
| 7 | // veto -> Veto (AirlockAdmin.veto + attachReason: it never executes) |
| 8 | // A proposal nobody has judged has no clearance and cannot execute, so a missed log or a failed run delays an |
| 9 | // upgrade instead of letting it through. The sweeper (cron) catches up on those. |
| 10 | // |
| 11 | // "Judged" means judged by this workflow: AirlockAdmin.oracleJudged(id), set when the receiver (the clearance |
| 12 | // oracle) clears or vetoes. A manual clearance by the approver does not set it, so the workflow still judges a |
| 13 | // proposal the approver cleared, and its Veto still wins over that clearance. |
| 14 | import { type Address, decodeFunctionResult, encodeFunctionData, type Hex } from 'viem' |
| 15 | import { ACTION, AIRLOCK_ADMIN_ABI, EMPTY_DATA_HASH, KIND, STATUS, TOKEN_ABI, ZERO_HASH } from './abi' |
| 16 | import { BudgetError, type Read, readCost } from './chain' |
| 17 | import type { Config } from './config' |
| 18 | import { consensusValue, judgeConfig, staticUpgradeCheck, type UpgradeContext, type Verdict } from './judge' |
| 19 | import { actionHash, upgradeDataKey } from './keys' |
| 20 | import { actionOf, buildSignedReport, canonicalEvidence, evidenceHashOf, type ProposalFields, proposalDigest } from './report' |
| 21 | import type { RuleId } from './rules' |
| 22 | import { proposalActors } from './simulate' |
| 23 | |
| 24 | export type Proposal = ProposalFields & { status: number; vetoCount: bigint; clearance: Hex; oracleJudged: boolean } |
| 25 | |
| 26 | export type WriteResult = { ok: boolean; tx?: Hex; error?: string } |
| 27 | |
| 28 | export interface Chain { |
| 29 | /** ChainRead calls still available in this run. */ |
| 30 | readsLeft(): number |
| 31 | /** Latest block number (one read). */ |
| 32 | latestBlock(): bigint |
| 33 | /** Hash of block `n` (one read: header by number). */ |
| 34 | blockHash(n: bigint): Hex |
| 35 | /** eth_calls at one block, batched (readCost(calls) reads). Results in call order. */ |
| 36 | read(calls: Read[], at: 'latest' | bigint): Hex[] |
| 37 | /** Signed report payload -> forwarder -> receiver.onReport. */ |
| 38 | write(payload: Hex): WriteResult |
| 39 | } |
| 40 | |
| 41 | export interface Ports { |
| 42 | chain: Chain |
| 43 | /** The attack simulation on every provider (CRE node mode) and the DON's identical-consensus verdict. */ |
| 44 | simulate(ctx: UpgradeContext, tag: string): Verdict |
| 45 | /** The EIP-712 attester key (a CRE secret). */ |
| 46 | attesterKey(): Hex |
| 47 | log(msg: string): void |
| 48 | } |
| 49 | |
| 50 | export type Outcome = { |
| 51 | id: string |
| 52 | kind: number |
| 53 | action: 'cleared' | 'vetoed' | 'skipped' | 'deferred' | 'unconfirmed' | 'failed' |
| 54 | reason: string |
| 55 | verdict?: 'pass' | 'veto' |
| 56 | rule?: RuleId | null |
| 57 | evidenceHash?: Hex |
| 58 | validUntilBlock?: string |
| 59 | tx?: Hex |
| 60 | } |
| 61 | |
| 62 | export type SweepSummary = { |
| 63 | block: string |
| 64 | window: [string, string] | null |
| 65 | candidates: string[] |
| 66 | outcomes: Outcome[] |
| 67 | readsLeft: number |
| 68 | /** Set when the run stopped early (a per-run budget ran out); the next run picks up from the chain state. */ |
| 69 | error?: string |
| 70 | } |
| 71 | |
| 72 | /** Reads after a report: one batched [getProposal, clearanceOf, oracleJudged] to confirm what the receiver did. */ |
| 73 | const CONFIRM_READS = 1 |
| 74 | /** HTTP calls one attack simulation needs per provider: eth_getCode, then up to `simRequests` eth_simulateV1. */ |
| 75 | export const httpCallsPerProvider = (cfg: Pick<Config, 'simRequests'>) => 1 + cfg.simRequests |
| 76 | |
| 77 | const STATUS_NAME: Record<number, string> = { 0: 'None', 1: 'Pending', 2: 'Vetoed', 3: 'Executed' } |
| 78 | const lc = (a: string) => a.toLowerCase() as Address |
| 79 | |
| 80 | type AdminFn = 'getProposal' | 'clearanceOf' | 'oracleJudged' | 'nextId' | 'delayBlocks' | 'isApprovedCodehash' |
| 81 | const adminRead = (cfg: Pick<Config, 'airlockAdmin'>, functionName: AdminFn, args: readonly unknown[], ret: number): Read => ({ |
| 82 | to: lc(cfg.airlockAdmin), |
| 83 | data: encodeFunctionData({ abi: AIRLOCK_ADMIN_ABI, functionName, args } as never), |
| 84 | ret, |
| 85 | }) |
| 86 | const decodeAdmin = <T>(functionName: string, data: Hex) => decodeFunctionResult({ abi: AIRLOCK_ADMIN_ABI, functionName, data } as never) as T |
| 87 | |
| 88 | /** The reads of one proposal's state: getProposal, clearanceOf, oracleJudged. */ |
| 89 | const proposalReads = (cfg: Pick<Config, 'airlockAdmin'>, id: bigint): Read[] => [ |
| 90 | adminRead(cfg, 'getProposal', [id], 320), |
| 91 | adminRead(cfg, 'clearanceOf', [id], 32), |
| 92 | adminRead(cfg, 'oracleJudged', [id], 32), |
| 93 | ] |
| 94 | const PER_PROPOSAL = 3 |
| 95 | |
| 96 | /** ChainRead calls to read the state of `n` proposals (the sweeper's scan). */ |
| 97 | export const scanCost = (cfg: Pick<Config, 'airlockAdmin'>, n: number): number => |
| 98 | readCost(Array.from({ length: n }, (_, i) => proposalReads(cfg, BigInt(i + 1))).flat()) |
| 99 | |
| 100 | /** getProposal + clearanceOf + oracleJudged for every id, at the latest block. */ |
| 101 | export function readProposals(chain: Chain, cfg: Config, ids: bigint[]): Proposal[] { |
| 102 | const res = chain.read( |
| 103 | ids.flatMap((id) => proposalReads(cfg, id)), |
| 104 | 'latest', |
| 105 | ) |
| 106 | return ids.map((id, i) => { |
| 107 | const g = decodeAdmin<{ |
| 108 | kind: number |
| 109 | status: number |
| 110 | proposer: Address |
| 111 | target: Address |
| 112 | value: bigint |
| 113 | codehash: Hex |
| 114 | dataHash: Hex |
| 115 | proposedBlock: bigint |
| 116 | readyBlock: bigint |
| 117 | vetoCount: bigint |
| 118 | }>('getProposal', res[PER_PROPOSAL * i]) |
| 119 | return { |
| 120 | id, |
| 121 | kind: Number(g.kind), |
| 122 | status: Number(g.status), |
| 123 | proposer: lc(g.proposer), |
| 124 | target: lc(g.target), |
| 125 | value: g.value, |
| 126 | codehash: g.codehash.toLowerCase() as Hex, |
| 127 | dataHash: g.dataHash.toLowerCase() as Hex, |
| 128 | proposedBlock: BigInt(g.proposedBlock), |
| 129 | readyBlock: BigInt(g.readyBlock), |
| 130 | vetoCount: g.vetoCount, |
| 131 | clearance: decodeAdmin<Hex>('clearanceOf', res[PER_PROPOSAL * i + 1]).toLowerCase() as Hex, |
| 132 | oracleJudged: decodeAdmin<boolean>('oracleJudged', res[PER_PROPOSAL * i + 2]), |
| 133 | } |
| 134 | }) |
| 135 | } |
| 136 | |
| 137 | /** |
| 138 | * The sweeper's work list: Pending proposals no verdict of this workflow has reached, oldest first (closest to their |
| 139 | * readyBlock). A clearance by the approver does not take a proposal off the list. |
| 140 | */ |
| 141 | export const sweepCandidates = (ps: Proposal[]): Proposal[] => |
| 142 | ps.filter((p) => p.status === STATUS.Pending && !p.oracleJudged).sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) |
| 143 | |
| 144 | /** The newest `window` ids below nextId. */ |
| 145 | export const sweepWindow = (nextId: bigint, window: number): bigint[] => { |
| 146 | const first = nextId - BigInt(window) > 1n ? nextId - BigInt(window) : 1n |
| 147 | const ids: bigint[] = [] |
| 148 | for (let id = first; id < nextId; id++) ids.push(id) |
| 149 | return ids |
| 150 | } |
| 151 | |
| 152 | const configReads = (cfg: Config, p: Proposal): Read[] => [ |
| 153 | adminRead(cfg, 'isApprovedCodehash', [actionHash(p.kind, p.target, p.value)], 32), |
| 154 | ...(p.kind === KIND.SetDelay ? [adminRead(cfg, 'delayBlocks', [], 32)] : []), |
| 155 | ] |
| 156 | |
| 157 | const upgradeReads = (cfg: Config, p: Proposal): Read[] => [ |
| 158 | ...cfg.holders.map((h) => ({ to: lc(cfg.proxy), data: encodeFunctionData({ abi: TOKEN_ABI, functionName: 'balanceOf', args: [h as Address] }), ret: 32 })), |
| 159 | adminRead(cfg, 'isApprovedCodehash', [p.codehash], 32), |
| 160 | ...(p.dataHash !== EMPTY_DATA_HASH ? [adminRead(cfg, 'isApprovedCodehash', [upgradeDataKey(p.codehash, p.dataHash)], 32)] : []), |
| 161 | ] |
| 162 | |
| 163 | /** Worst-case ChainRead calls to judge and report one proposal (judging reads, block hash, confirmation). */ |
| 164 | export const judgeCost = (cfg: Config, p: Proposal): number => |
| 165 | p.kind === KIND.Upgrade ? readCost(upgradeReads(cfg, p)) + 1 + CONFIRM_READS : readCost(configReads(cfg, p)) + CONFIRM_READS |
| 166 | |
| 167 | type Decision = Verdict & { reason: string } |
| 168 | |
| 169 | /** Reads at the proposal block, then the verdict. `sims.left` caps attack simulations per run. */ |
| 170 | export function judgeProposal(ports: Ports, cfg: Config, p: Proposal, sims: { left: number }): Decision | { deferred: string } { |
| 171 | const { chain } = ports |
| 172 | if (p.kind !== KIND.Upgrade) { |
| 173 | const r = chain.read(configReads(cfg, p), p.proposedBlock) |
| 174 | const approved = decodeAdmin<boolean>('isApprovedCodehash', r[0]) |
| 175 | const delay = p.kind === KIND.SetDelay ? BigInt(decodeAdmin<bigint>('delayBlocks', r[1])) : null |
| 176 | const j = judgeConfig(p, delay, approved) |
| 177 | return { ...consensusValue(j), reason: j.reason } |
| 178 | } |
| 179 | |
| 180 | const r = chain.read(upgradeReads(cfg, p), p.proposedBlock) |
| 181 | const n = cfg.holders.length |
| 182 | const balances = r.slice(0, n).map((d) => decodeFunctionResult({ abi: TOKEN_ABI, functionName: 'balanceOf', data: d })) |
| 183 | const k = balances.findIndex((b) => b > 0n) |
| 184 | const holder = k >= 0 ? lc(cfg.holders[k]) : null |
| 185 | const balance = k >= 0 ? balances[k] : 0n |
| 186 | const amount = balance / 100n > 0n ? balance / 100n : 1n |
| 187 | // A second funded holder (different address) funds the stranger in the simulation, so the test holder's R3 |
| 188 | // accounting stays clean; fund with the test amount, capped at the funder's balance. |
| 189 | const fk = balances.findIndex((b, i) => b > 0n && i !== k && lc(cfg.holders[i]) !== holder) |
| 190 | const funder = fk >= 0 ? lc(cfg.holders[fk]) : null |
| 191 | const fundAmount = fk >= 0 ? (balances[fk] < amount ? balances[fk] : amount) : 0n |
| 192 | const codehashApproved = decodeAdmin<boolean>('isApprovedCodehash', r[n]) |
| 193 | const hasInitData = p.dataHash !== EMPTY_DATA_HASH |
| 194 | const dataApproved = hasInitData ? decodeAdmin<boolean>('isApprovedCodehash', r[n + 1]) : false |
| 195 | const s = staticUpgradeCheck({ strict: cfg.strict, codehash: p.codehash, codehashApproved, hasInitData, dataApproved, holder }) |
| 196 | if (s) return { ...consensusValue(s), reason: s.reason } |
| 197 | if (sims.left < 1) return { deferred: 'one attack simulation per run, already used' } |
| 198 | |
| 199 | const blockHash = chain.blockHash(p.proposedBlock) |
| 200 | const { stranger, receiver } = proposalActors(BigInt(cfg.chainId), lc(cfg.airlockAdmin), p.id, blockHash) |
| 201 | const ctx: UpgradeContext = { |
| 202 | proxy: lc(cfg.proxy), |
| 203 | newImpl: p.target, |
| 204 | codehash: p.codehash, |
| 205 | hasInitData, |
| 206 | block: p.proposedBlock, |
| 207 | holder, |
| 208 | amount, |
| 209 | funder, |
| 210 | fundAmount, |
| 211 | holders: cfg.holders.map(lc), |
| 212 | watch: cfg.watch.map(lc), |
| 213 | stranger, |
| 214 | receiver, |
| 215 | strict: cfg.strict, |
| 216 | codehashApproved, |
| 217 | dataApproved, |
| 218 | callGas: cfg.callGas, |
| 219 | readGas: cfg.readGas, |
| 220 | maxCalls: cfg.maxCalls, |
| 221 | simRequests: cfg.simRequests, |
| 222 | allowedOpcodes: cfg.allowedOpcodes, |
| 223 | } |
| 224 | sims.left -= 1 |
| 225 | const v = ports.simulate(ctx, `#${p.id}`) |
| 226 | return { ...consensusValue(v), reason: `attack simulation, quorum of ${cfg.minAgree} (provider detail in the node logs)` } |
| 227 | } |
| 228 | |
| 229 | /** Evidence, attestation, report, confirmation. */ |
| 230 | export function deliverVerdict(ports: Ports, cfg: Config, p: Proposal, v: Verdict, currentBlock: bigint): Outcome { |
| 231 | const chainId = BigInt(cfg.chainId) |
| 232 | const admin = lc(cfg.airlockAdmin) |
| 233 | const digest = proposalDigest(p) |
| 234 | const verdict = consensusValue(v) |
| 235 | const evidence = canonicalEvidence({ chainId, airlockAdmin: admin, proposal: p, proposalDigest: digest, verdict }) |
| 236 | const evidenceHash = evidenceHashOf(evidence) |
| 237 | // Valid until the later of readyBlock and now, plus a grace: deterministic on every DON node, no clock involved. |
| 238 | const validUntilBlock = (p.readyBlock > currentBlock ? p.readyBlock : currentBlock) + BigInt(cfg.reportGraceBlocks) |
| 239 | const { action, ruleHash } = actionOf(verdict) |
| 240 | const signed = buildSignedReport(ports.attesterKey(), chainId, lc(cfg.receiver), admin, { |
| 241 | action, |
| 242 | proposalId: p.id, |
| 243 | proposalDigest: digest, |
| 244 | ruleHash, |
| 245 | evidenceHash, |
| 246 | validUntilBlock, |
| 247 | }) |
| 248 | const name = action === ACTION.Clear ? 'Clear' : 'Veto' |
| 249 | ports.log(`#${p.id} evidence ${evidence}`) |
| 250 | ports.log(`#${p.id} report ${name} evidenceHash ${evidenceHash} validUntilBlock ${validUntilBlock} digest ${signed.digest}`) |
| 251 | const base: Outcome = { |
| 252 | id: p.id.toString(), |
| 253 | kind: p.kind, |
| 254 | action: 'failed', |
| 255 | reason: '', |
| 256 | verdict: verdict.verdict, |
| 257 | rule: verdict.rule, |
| 258 | evidenceHash, |
| 259 | validUntilBlock: validUntilBlock.toString(), |
| 260 | } |
| 261 | const w = ports.chain.write(signed.payload) |
| 262 | if (w.tx) base.tx = w.tx |
| 263 | if (!w.ok) { |
| 264 | ports.log(`#${p.id} ${name} report not written: ${w.error ?? 'unknown error'}`) |
| 265 | return { ...base, reason: `writeReport failed: ${w.error ?? 'unknown error'}` } |
| 266 | } |
| 267 | // The forwarder swallows a reverting onReport: a successful transaction is not proof, the proposal state is. |
| 268 | // oracleJudged is the receiver's own mark (an approver clearance already makes clearanceOf non-zero). A read that |
| 269 | // does not show the verdict cannot tell a rejected report from a node that has not caught up yet, so it is |
| 270 | // 'unconfirmed', not a failure: the sweeper re-judges any proposal whose oracleJudged is still false. |
| 271 | const [after] = readProposals(ports.chain, cfg, [p.id]) |
| 272 | const accepted = after.oracleJudged && (action === ACTION.Veto ? after.status === STATUS.Vetoed : after.status !== STATUS.Vetoed) |
| 273 | const out: Outcome = { |
| 274 | ...base, |
| 275 | action: accepted ? (action === ACTION.Veto ? 'vetoed' : 'cleared') : 'unconfirmed', |
| 276 | reason: accepted |
| 277 | ? `${name} accepted${action === ACTION.Clear && after.clearance !== evidenceHash ? ` (clearanceOf keeps the earlier clearance ${after.clearance})` : ''}` |
| 278 | : `${name} sent; the read shows ${STATUS_NAME[after.status]}, oracleJudged ${after.oracleJudged} (rejected, or the node is behind; the sweeper re-checks)`, |
| 279 | } |
| 280 | ports.log(`#${p.id} ${name} tx ${w.tx ?? '(none)'} -> ${out.action} (status ${STATUS_NAME[after.status]})`) |
| 281 | return out |
| 282 | } |
| 283 | |
| 284 | function judgeAndDeliver(ports: Ports, cfg: Config, p: Proposal, currentBlock: bigint, sims: { left: number }): Outcome { |
| 285 | const id = p.id.toString() |
| 286 | const need = judgeCost(cfg, p) |
| 287 | if (ports.chain.readsLeft() < need) { |
| 288 | return { id, kind: p.kind, action: 'deferred', reason: `needs ${need} chain reads, ${ports.chain.readsLeft()} left in this run` } |
| 289 | } |
| 290 | const d = judgeProposal(ports, cfg, p, sims) |
| 291 | if ('deferred' in d) { |
| 292 | ports.log(`#${id} deferred: ${d.deferred}`) |
| 293 | return { id, kind: p.kind, action: 'deferred', reason: d.deferred } |
| 294 | } |
| 295 | ports.log(`#${id} verdict ${d.verdict}${d.rule ? ` ${d.rule}` : ''} (${d.reason})`) |
| 296 | return deliverVerdict(ports, cfg, p, d, currentBlock) |
| 297 | } |
| 298 | |
| 299 | const errMsg = (e: unknown) => ((e as Error)?.message ?? String(e)).slice(0, 300) |
| 300 | |
| 301 | /** |
| 302 | * Handler 0: a Proposed log. Judges the proposal while it is Pending and this workflow has not judged it yet (a |
| 303 | * guardian veto still wins over a clearance; an approver clearance does not count as a verdict). |
| 304 | */ |
| 305 | export function handleProposed(ports: Ports, cfg: Config, id: bigint): Outcome { |
| 306 | try { |
| 307 | const currentBlock = ports.chain.latestBlock() |
| 308 | const [p] = readProposals(ports.chain, cfg, [id]) |
| 309 | if (p.status !== STATUS.Pending || p.oracleJudged) { |
| 310 | const reason = |
| 311 | p.status === STATUS.None ? 'unknown proposal (log from a reorged block?)' : p.status !== STATUS.Pending ? `proposal is ${STATUS_NAME[p.status]}` : 'already judged by this workflow' |
| 312 | ports.log(`#${id} skipped: ${reason}`) |
| 313 | return { id: id.toString(), kind: p.kind, action: 'skipped', reason } |
| 314 | } |
| 315 | return judgeAndDeliver(ports, cfg, p, currentBlock, { left: 1 }) |
| 316 | } catch (e) { |
| 317 | if (!(e instanceof BudgetError)) throw e |
| 318 | ports.log(`#${id} deferred: ${errMsg(e)} (the sweeper judges it later)`) |
| 319 | return { id: id.toString(), kind: -1, action: 'deferred', reason: errMsg(e) } |
| 320 | } |
| 321 | } |
| 322 | |
| 323 | /** |
| 324 | * Handler 1: the sweeper. Looks at the newest `sweepWindow` ids and judges every Pending proposal this workflow has |
| 325 | * not judged (approver-cleared ones included), oldest first, within this run's budgets: at most one attack |
| 326 | * simulation, and only while the chain reads a judgement needs are left. A receiver verdict always sets |
| 327 | * oracleJudged, so whatever is left over is picked up by the next run. A budget that runs out anyway ends the run |
| 328 | * with a log line and the summary, never with an error. |
| 329 | */ |
| 330 | export function handleSweep(ports: Ports, cfg: Config): SweepSummary { |
| 331 | const { chain } = ports |
| 332 | let currentBlock = 0n |
| 333 | let ids: bigint[] = [] |
| 334 | let todo: Proposal[] |
| 335 | try { |
| 336 | currentBlock = chain.latestBlock() |
| 337 | const nextId = BigInt(decodeAdmin<bigint>('nextId', chain.read([adminRead(cfg, 'nextId', [], 32)], 'latest')[0])) |
| 338 | ids = sweepWindow(nextId, cfg.sweepWindow) |
| 339 | if (!ids.length) { |
| 340 | ports.log('sweep: no proposals yet') |
| 341 | return { block: currentBlock.toString(), window: null, candidates: [], outcomes: [], readsLeft: chain.readsLeft() } |
| 342 | } |
| 343 | todo = sweepCandidates(readProposals(chain, cfg, ids)) |
| 344 | } catch (e) { |
| 345 | if (!(e instanceof BudgetError)) throw e |
| 346 | ports.log(`sweep stopped before judging anything: ${errMsg(e)}`) |
| 347 | return { |
| 348 | block: currentBlock.toString(), |
| 349 | window: ids.length ? [ids[0].toString(), ids[ids.length - 1].toString()] : null, |
| 350 | candidates: [], |
| 351 | outcomes: [], |
| 352 | readsLeft: chain.readsLeft(), |
| 353 | error: errMsg(e), |
| 354 | } |
| 355 | } |
| 356 | const shown = todo.map((p) => `#${p.id}${p.clearance !== ZERO_HASH ? '(approver-cleared)' : ''}`).join(' ') |
| 357 | ports.log(`sweep at block ${currentBlock}: ids ${ids[0]}-${ids[ids.length - 1]}, pending without a workflow verdict: ${shown || 'none'}`) |
| 358 | const sims = { left: 1 } |
| 359 | const outcomes: Outcome[] = [] |
| 360 | let exhausted: string | undefined |
| 361 | for (const p of todo) { |
| 362 | const id = p.id.toString() |
| 363 | if (exhausted) { |
| 364 | outcomes.push({ id, kind: p.kind, action: 'deferred', reason: exhausted }) |
| 365 | continue |
| 366 | } |
| 367 | try { |
| 368 | outcomes.push(judgeAndDeliver(ports, cfg, p, currentBlock, sims)) |
| 369 | } catch (e) { |
| 370 | const reason = errMsg(e) |
| 371 | if (e instanceof BudgetError) { |
| 372 | exhausted = reason |
| 373 | ports.log(`#${id} deferred, run budget exhausted: ${reason}`) |
| 374 | outcomes.push({ id, kind: p.kind, action: 'deferred', reason }) |
| 375 | } else { |
| 376 | ports.log(`#${id} failed: ${reason}`) |
| 377 | outcomes.push({ id, kind: p.kind, action: 'failed', reason }) |
| 378 | } |
| 379 | } |
| 380 | } |
| 381 | return { |
| 382 | block: currentBlock.toString(), |
| 383 | window: [ids[0].toString(), ids[ids.length - 1].toString()], |
| 384 | candidates: todo.map((p) => p.id.toString()), |
| 385 | outcomes, |
| 386 | readsLeft: chain.readsLeft(), |
| 387 | ...(exhausted ? { error: exhausted } : {}), |
| 388 | } |
| 389 | } |