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 / cre / veto / chain.ts
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.
7import { type Address, decodeFunctionResult, encodeFunctionData, type Hex } from 'viem'
8import { MULTICALL3_ABI } from './abi'
9
10/** One eth_call: target, calldata and the size of its return data in bytes (for the payload budget). */
11export 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). */
14export 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. */
17export class BudgetError extends Error {}
18/** Upper bound for one chain read's request and its response (below CRE's 5 kb ChainRead payload limit). */
19export const READ_PAYLOAD_BYTES = 4096
20
21const pad32 = (n: number) => Math.ceil(n / 32) * 32
22const 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. */
25export 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. */
27export 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`. */
30export 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). */
45export const readCost = (calls: Read[]) => chunkReads(calls).length
46
47export 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
54export 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}