cre/veto/chain.ts61 lines · 3321 bytes
| 1 | // Batched chain reads under CRE's per-run limits (`cre workflow limits export`): |
| 2 | // - ChainRead.CallLimit 15: every EVM capability read (callContract and headerByNumber) counts as one call; |
| 3 | // - ChainRead.PayloadSizeLimit 5 kb. |
| 4 | // Several eth_calls at the same block go through one Multicall3.aggregate3 call, split into chunks whose request |
| 5 | // and response both stay under READ_PAYLOAD_BYTES. The local simulator enforces the call limit but not the payload |
| 6 | // limit (a 64 KB call and a 64 KB reply both pass there), so the chunking is what keeps a deployed workflow inside it. |
| 7 | import { type Address, decodeFunctionResult, encodeFunctionData, type Hex } from 'viem' |
| 8 | import { MULTICALL3_ABI } from './abi' |
| 9 | |
| 10 | /** One eth_call: target, calldata and the size of its return data in bytes (for the payload budget). */ |
| 11 | export type Read = { to: Address; data: Hex; ret: number } |
| 12 | |
| 13 | /** The CRE per-run limits this workflow plans against (HTTP sizes in bytes, 1 kb = 1000 bytes to stay on the safe side). */ |
| 14 | export const LIMITS = { chainReads: 15, httpCalls: 15, httpRequestBytes: 120_000, httpResponseBytes: 250_000 } as const |
| 15 | |
| 16 | /** Thrown when a run would exceed a per-run CRE call limit; the handlers turn it into a logged deferral. */ |
| 17 | export class BudgetError extends Error {} |
| 18 | /** Upper bound for one chain read's request and its response (below CRE's 5 kb ChainRead payload limit). */ |
| 19 | export const READ_PAYLOAD_BYTES = 4096 |
| 20 | |
| 21 | const pad32 = (n: number) => Math.ceil(n / 32) * 32 |
| 22 | const hexLen = (h: Hex) => (h.length - 2) / 2 |
| 23 | |
| 24 | /** aggregate3 calldata: selector, array offset, length, then per call an offset, (target, allowFailure, offset), length, data. */ |
| 25 | export const aggregate3RequestBytes = (calls: Read[]) => 4 + 64 + calls.reduce((n, c) => n + 32 + 96 + 32 + pad32(hexLen(c.data)), 0) |
| 26 | /** aggregate3 return data: array offset, length, then per result an offset, (success, offset), length, data. */ |
| 27 | export const aggregate3ResponseBytes = (calls: Read[]) => 64 + calls.reduce((n, c) => n + 32 + 64 + 32 + pad32(c.ret), 0) |
| 28 | |
| 29 | /** Greedy split: a chunk grows while its aggregate3 request and response both fit in `limit`. */ |
| 30 | export function chunkReads(calls: Read[], limit = READ_PAYLOAD_BYTES): Read[][] { |
| 31 | const chunks: Read[][] = [] |
| 32 | let cur: Read[] = [] |
| 33 | for (const c of calls) { |
| 34 | const next = [...cur, c] |
| 35 | if (cur.length && (aggregate3RequestBytes(next) > limit || aggregate3ResponseBytes(next) > limit)) { |
| 36 | chunks.push(cur) |
| 37 | cur = [c] |
| 38 | } else cur = next |
| 39 | } |
| 40 | if (cur.length) chunks.push(cur) |
| 41 | return chunks |
| 42 | } |
| 43 | |
| 44 | /** How many ChainRead calls `calls` cost (a chunk of one call is sent directly, without Multicall3). */ |
| 45 | export const readCost = (calls: Read[]) => chunkReads(calls).length |
| 46 | |
| 47 | export const encodeAggregate3 = (calls: Read[]): Hex => |
| 48 | encodeFunctionData({ |
| 49 | abi: MULTICALL3_ABI, |
| 50 | functionName: 'aggregate3', |
| 51 | args: [calls.map((c) => ({ target: c.to, allowFailure: false, callData: c.data }))], |
| 52 | }) |
| 53 | |
| 54 | export function decodeAggregate3(data: Hex, n: number): Hex[] { |
| 55 | const res = decodeFunctionResult({ abi: MULTICALL3_ABI, functionName: 'aggregate3', data }) |
| 56 | if (res.length !== n) throw new Error(`multicall returned ${res.length} results, expected ${n}`) |
| 57 | return res.map((r, i) => { |
| 58 | if (!r.success) throw new Error(`multicall call ${i} failed`) |
| 59 | return r.returnData |
| 60 | }) |
| 61 | } |