@crewhaus/tool-chaincall 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +264 -0
- package/dist/fixtures.d.ts +120 -0
- package/dist/fixtures.js +205 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +1231 -0
- package/dist/lib/batch.d.ts +13 -0
- package/dist/lib/batch.js +55 -0
- package/dist/lib/codec.d.ts +49 -0
- package/dist/lib/codec.js +113 -0
- package/dist/lib/fees.d.ts +61 -0
- package/dist/lib/fees.js +113 -0
- package/dist/lib/hex.d.ts +56 -0
- package/dist/lib/hex.js +118 -0
- package/dist/lib/proxy.d.ts +99 -0
- package/dist/lib/proxy.js +152 -0
- package/dist/lib/rpc.d.ts +110 -0
- package/dist/lib/rpc.js +190 -0
- package/dist/lib/simulate.d.ts +68 -0
- package/dist/lib/simulate.js +154 -0
- package/package.json +47 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @crewhaus/tool-chaincall — ask a contract what it knows, and what a call
|
|
3
|
+
* would cost.
|
|
4
|
+
*
|
|
5
|
+
* Four read-only questions that each take more than one round trip to answer
|
|
6
|
+
* properly, and that a model gets plausibly wrong when it answers them by
|
|
7
|
+
* reasoning instead of by reading:
|
|
8
|
+
*
|
|
9
|
+
* - **EvmMulticall** — a hundred view calls at ONE block height, so the
|
|
10
|
+
* answers are consistent with each other.
|
|
11
|
+
* - **ContractInspect** — is there code here, is it a proxy, and what does
|
|
12
|
+
* it claim to implement.
|
|
13
|
+
* - **EvmSimulateBundle** — what would this ordered sequence of calls do,
|
|
14
|
+
* and honest degradation when the node cannot answer that question.
|
|
15
|
+
* - **GasMarketRead** — what the fee market is doing, and which fee market
|
|
16
|
+
* this chain actually runs.
|
|
17
|
+
*
|
|
18
|
+
* **Nothing here signs or sends.** No schema has a field to pass a private
|
|
19
|
+
* key, a mnemonic or a signed transaction to; every JSON-RPC method goes
|
|
20
|
+
* through `chain-adapter-base`'s read-only allowlist at the single dispatch
|
|
21
|
+
* point in `./lib/rpc`; and `index.test.ts` asserts both, by walking the
|
|
22
|
+
* schemas and by recording every method the tools dispatch. Simulating a
|
|
23
|
+
* transaction is a read — the node evaluates it and throws the result away.
|
|
24
|
+
* Submitting one is not, and there is no path to it from here.
|
|
25
|
+
*
|
|
26
|
+
* Two rules run through all four:
|
|
27
|
+
*
|
|
28
|
+
* 1. **A wrong answer is worse than no answer.** A batch result the tool
|
|
29
|
+
* cannot line up with the calls that produced it is refused, not
|
|
30
|
+
* zipped. A proxy with two implementations is unresolved, not
|
|
31
|
+
* arbitrated. A bundle simulated without state chaining omits its
|
|
32
|
+
* effects entirely rather than reporting the computable half.
|
|
33
|
+
* 2. **No chain quantity is ever a JS number.** Wei, balances, gas and
|
|
34
|
+
* block numbers are `bigint` in memory and decimal strings on the way
|
|
35
|
+
* out. A uint256 is 78 digits; a double carries 15, and loses the
|
|
36
|
+
* difference silently.
|
|
37
|
+
*/
|
|
38
|
+
import { buildTool } from "@crewhaus/tool-builder";
|
|
39
|
+
import { MULTICALL3_ADDRESS, decodeAggregate3, decodeRevertData, encodeAggregate3, } from "@crewhaus/tool-onchain";
|
|
40
|
+
import { z } from "zod";
|
|
41
|
+
import { chunk, runAggregate3 } from "./lib/batch";
|
|
42
|
+
import { SELECTORS, balanceOfData, checkAddress, decodeValues, encodeCallData, getEthBalanceData, supportsInterfaceData, } from "./lib/codec";
|
|
43
|
+
import { changeBps, nextBaseFee, summarisePercentile } from "./lib/fees";
|
|
44
|
+
import { ZERO_ADDRESS, data as asData, blockParam, byteLength, isZeroWord, optionalQuantity, quantity, toQuantity, wordAt, wordToAddress, wordToBigint, wordToBool, } from "./lib/hex";
|
|
45
|
+
import { DIAMOND_LOUPE_ID, EIP1822_SLOT, EIP1967_ADMIN_SLOT, EIP1967_BEACON_SLOT, EIP1967_IMPLEMENTATION_SLOT, ERC165_INVALID_ID, KNOWN_INTERFACES, minimalProxyTarget, reconcileProxy, } from "./lib/proxy";
|
|
46
|
+
import { ChainCallError, isAbort, isMethodUnsupported, resolveRpc, rpcError, rpcErrorText, rpcRead, startDeadline, } from "./lib/rpc";
|
|
47
|
+
import { FALLBACK_LIMITATIONS, parseSimulateV1, revertFromCallError, toWireCall, unchainedLimitation, } from "./lib/simulate";
|
|
48
|
+
export { ChainCallError, _setRpc, chainRpcFromAdapter, setChainRpcResolver, } from "./lib/rpc";
|
|
49
|
+
export { MULTICALL3_ADDRESS } from "@crewhaus/tool-onchain";
|
|
50
|
+
export { EIP1822_SLOT, EIP1967_ADMIN_SLOT, EIP1967_BEACON_SLOT, EIP1967_IMPLEMENTATION_SLOT, ERC165_INVALID_ID, KNOWN_INTERFACES, } from "./lib/proxy";
|
|
51
|
+
const json = (value) => JSON.stringify(value);
|
|
52
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
53
|
+
/**
|
|
54
|
+
* Ceilings. Each exists because passing it turns a read into something else:
|
|
55
|
+
* a crawl, a request body a provider drops, or a batch past a node's gas cap.
|
|
56
|
+
*/
|
|
57
|
+
const LIMITS = {
|
|
58
|
+
calls: 512,
|
|
59
|
+
batchSize: 64,
|
|
60
|
+
maxBatchSize: 256,
|
|
61
|
+
bundleCalls: 64,
|
|
62
|
+
interfaceProbes: 32,
|
|
63
|
+
feeBlocks: 100,
|
|
64
|
+
trackedAccounts: 16,
|
|
65
|
+
trackedTokens: 8,
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* Pillar 3 sink-side: these cross a network boundary, so they say so. The
|
|
69
|
+
* destination is whatever the operator wired into `spec.chains[]` — no
|
|
70
|
+
* caller supplies a URL anywhere in this package — but "it is the operator's
|
|
71
|
+
* endpoint" is not a reason to hide that bytes leave the process.
|
|
72
|
+
*/
|
|
73
|
+
const NETWORK_TOOL = {
|
|
74
|
+
readOnly: true,
|
|
75
|
+
concurrencySafe: true,
|
|
76
|
+
scope: "external",
|
|
77
|
+
ioCapability: "network",
|
|
78
|
+
};
|
|
79
|
+
const chainIdField = z.string().min(1).describe("id of the chain, from spec.chains[]");
|
|
80
|
+
const blockNumberField = z
|
|
81
|
+
.string()
|
|
82
|
+
.regex(/^\d+$/)
|
|
83
|
+
.optional()
|
|
84
|
+
.describe("an exact block number as a DECIMAL string; overrides blockTag");
|
|
85
|
+
const blockTagField = z
|
|
86
|
+
.enum(["latest", "safe", "finalized", "pending", "earliest"])
|
|
87
|
+
.optional()
|
|
88
|
+
.describe("default latest");
|
|
89
|
+
const timeoutField = z
|
|
90
|
+
.number()
|
|
91
|
+
.int()
|
|
92
|
+
.min(1_000)
|
|
93
|
+
.max(120_000)
|
|
94
|
+
.optional()
|
|
95
|
+
.describe(`deadline for the whole call in ms; default ${DEFAULT_TIMEOUT_MS}`);
|
|
96
|
+
const multicallAddressField = z
|
|
97
|
+
.string()
|
|
98
|
+
.regex(/^0x[0-9a-fA-F]{40}$/)
|
|
99
|
+
.optional()
|
|
100
|
+
.describe(`Multicall3 on this chain; default ${MULTICALL3_ADDRESS}`);
|
|
101
|
+
/** The block parameter, and whether the caller pinned it themselves. */
|
|
102
|
+
function blockOf(input) {
|
|
103
|
+
return {
|
|
104
|
+
param: blockParam(input.blockNumber, input.blockTag),
|
|
105
|
+
pinnedByCaller: input.blockNumber !== undefined,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/** A 32-byte word from a storage read, normalised: nodes pad differently. */
|
|
109
|
+
function storageWord(raw, what) {
|
|
110
|
+
const value = quantity(raw, what);
|
|
111
|
+
return `0x${value.toString(16).padStart(64, "0")}`;
|
|
112
|
+
}
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// EvmMulticall
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
const multicallCall = z
|
|
117
|
+
.object({
|
|
118
|
+
target: z.string().min(1).describe("the contract to call"),
|
|
119
|
+
data: z.string().optional().describe("pre-encoded calldata, 0x hex (from AbiEncodeCall)"),
|
|
120
|
+
signature: z.string().optional().describe("e.g. balanceOf(address) — encoded here instead"),
|
|
121
|
+
args: z.array(z.unknown()).max(64).optional().describe("arguments for `signature`"),
|
|
122
|
+
outputs: z
|
|
123
|
+
.array(z.string())
|
|
124
|
+
.max(32)
|
|
125
|
+
.optional()
|
|
126
|
+
.describe('ABI types to decode the result with, e.g. ["uint256"]'),
|
|
127
|
+
allowFailure: z
|
|
128
|
+
.boolean()
|
|
129
|
+
.optional()
|
|
130
|
+
.describe("default true: this call reverting is a row, not a batch failure"),
|
|
131
|
+
label: z.string().max(80).optional().describe("carried into the result row"),
|
|
132
|
+
})
|
|
133
|
+
.strict();
|
|
134
|
+
/** Build one sub-call's calldata, refusing the two ambiguous input shapes. */
|
|
135
|
+
async function calldataFor(call, index) {
|
|
136
|
+
const hasData = call.data !== undefined;
|
|
137
|
+
const hasSignature = call.signature !== undefined;
|
|
138
|
+
if (hasData && hasSignature) {
|
|
139
|
+
throw new ChainCallError(`calls[${index}] gives both data and signature — they would encode to different calldata and there is no way to tell which one was meant`);
|
|
140
|
+
}
|
|
141
|
+
if (hasData)
|
|
142
|
+
return asData(call.data, `calls[${index}].data`);
|
|
143
|
+
if (hasSignature) {
|
|
144
|
+
return encodeCallData(call.signature, call.args ?? [], `calls[${index}] (${call.signature})`);
|
|
145
|
+
}
|
|
146
|
+
throw new ChainCallError(`calls[${index}] has neither data nor signature — give one of them (data for calldata you already have, signature+args to encode it here)`);
|
|
147
|
+
}
|
|
148
|
+
export const evmMulticall = buildTool({
|
|
149
|
+
name: "EvmMulticall",
|
|
150
|
+
description: "Run many view calls against a chain in one request through Multicall3, returning one decoded row per call at a SINGLE block height. Use it for any question that reads more than one contract — a hundred balances, a token's whole metadata, a pool's reserves plus its fee — because a hundred separate reads land at a hundred different block heights and the answers do not add up. A sub-call that reverts is a row with its revert reason, not a failed request, so one bad token does not lose the other ninety-nine. When a batch is too large for one request it is split, and the block is PINNED from the first batch so every row still answers at the same height. A call that succeeds with empty return data is flagged rather than decoded: in the EVM, calling an address with no code succeeds and returns nothing, which is how a missing contract becomes a zero balance. It reads only, and nothing here signs or submits anything.",
|
|
151
|
+
inputSchema: z
|
|
152
|
+
.object({
|
|
153
|
+
chainId: chainIdField,
|
|
154
|
+
calls: z.array(multicallCall).min(1).max(LIMITS.calls),
|
|
155
|
+
blockNumber: blockNumberField,
|
|
156
|
+
blockTag: blockTagField,
|
|
157
|
+
multicall3Address: multicallAddressField,
|
|
158
|
+
batchSize: z
|
|
159
|
+
.number()
|
|
160
|
+
.int()
|
|
161
|
+
.min(1)
|
|
162
|
+
.max(LIMITS.maxBatchSize)
|
|
163
|
+
.optional()
|
|
164
|
+
.describe(`calls per eth_call; default ${LIMITS.batchSize}`),
|
|
165
|
+
timeoutMs: timeoutField,
|
|
166
|
+
})
|
|
167
|
+
.strict(),
|
|
168
|
+
...NETWORK_TOOL,
|
|
169
|
+
execute: async (input, ctx) => {
|
|
170
|
+
const deadline = startDeadline(input.timeoutMs ?? DEFAULT_TIMEOUT_MS, ctx?.signal);
|
|
171
|
+
try {
|
|
172
|
+
const rpc = resolveRpc(input.chainId, "EvmMulticall");
|
|
173
|
+
const multicall3 = input.multicall3Address ?? MULTICALL3_ADDRESS;
|
|
174
|
+
const { param, pinnedByCaller } = blockOf(input);
|
|
175
|
+
const calls = [];
|
|
176
|
+
for (const [index, call] of input.calls.entries()) {
|
|
177
|
+
calls.push({
|
|
178
|
+
target: call.target,
|
|
179
|
+
callData: await calldataFor(call, index),
|
|
180
|
+
allowFailure: call.allowFailure ?? true,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
const size = input.batchSize ?? LIMITS.batchSize;
|
|
184
|
+
const batches = chunk(calls, size);
|
|
185
|
+
const first = batches[0];
|
|
186
|
+
// The block probe: Multicall3 calling ITSELF for `getBlockNumber()`,
|
|
187
|
+
// riding in the first batch. One extra slot instead of an extra
|
|
188
|
+
// round trip, and — unlike asking eth_blockNumber first — the number
|
|
189
|
+
// it returns is the block this batch actually executed at, so there
|
|
190
|
+
// is no window in which the head moves between the two reads.
|
|
191
|
+
const probing = !pinnedByCaller;
|
|
192
|
+
const firstCalls = probing
|
|
193
|
+
? [...first, { target: multicall3, callData: SELECTORS.getBlockNumber, allowFailure: true }]
|
|
194
|
+
: first;
|
|
195
|
+
const rows = await runAggregate3(rpc, input.chainId, firstCalls, multicall3, param, deadline.signal, "EvmMulticall");
|
|
196
|
+
let pinned;
|
|
197
|
+
let resultRows = [...rows];
|
|
198
|
+
if (probing) {
|
|
199
|
+
const probe = resultRows.pop();
|
|
200
|
+
const word = probe?.success === true ? wordAt(probe.returnData, 0) : undefined;
|
|
201
|
+
const value = word === undefined ? undefined : wordToBigint(word);
|
|
202
|
+
// A block number is a uint64 on every live chain. Anything larger
|
|
203
|
+
// means whatever is at that address is not Multicall3, and pinning to
|
|
204
|
+
// it would put a fabricated height in the answer.
|
|
205
|
+
if (value !== undefined && value > 0n && value < 1n << 64n)
|
|
206
|
+
pinned = value;
|
|
207
|
+
}
|
|
208
|
+
if (batches.length > 1 && !pinnedByCaller && pinned === undefined) {
|
|
209
|
+
// Splitting without a pinned block is the silent failure this tool
|
|
210
|
+
// exists to avoid: each batch would resolve "latest" separately and
|
|
211
|
+
// the rows would come from different heights while still arriving as
|
|
212
|
+
// one table.
|
|
213
|
+
throw new ChainCallError(`EvmMulticall: this batch needs ${batches.length} requests and the block could not be pinned — Multicall3 at ${multicall3} did not answer getBlockNumber(), so the remaining batches would each resolve "${param}" separately and the rows would not share a block. Pass an explicit blockNumber, or raise batchSize so it fits in one request.`);
|
|
214
|
+
}
|
|
215
|
+
const pinnedParam = pinned === undefined ? param : toQuantity(pinned);
|
|
216
|
+
for (const batch of batches.slice(1)) {
|
|
217
|
+
const more = await runAggregate3(rpc, input.chainId, batch, multicall3, pinnedParam, deadline.signal, "EvmMulticall");
|
|
218
|
+
resultRows = [...resultRows, ...more];
|
|
219
|
+
}
|
|
220
|
+
let failed = 0;
|
|
221
|
+
let emptyReturns = 0;
|
|
222
|
+
const results = [];
|
|
223
|
+
for (const [index, row] of resultRows.entries()) {
|
|
224
|
+
const call = input.calls[index];
|
|
225
|
+
if (!row.success) {
|
|
226
|
+
failed++;
|
|
227
|
+
results.push({
|
|
228
|
+
index,
|
|
229
|
+
...(call.label !== undefined ? { label: call.label } : {}),
|
|
230
|
+
target: call.target,
|
|
231
|
+
success: false,
|
|
232
|
+
revert: row.revert ?? decodeRevertData(row.returnData),
|
|
233
|
+
});
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const empty = row.returnData === "0x";
|
|
237
|
+
if (empty)
|
|
238
|
+
emptyReturns++;
|
|
239
|
+
let decoded;
|
|
240
|
+
let decodeError;
|
|
241
|
+
if (call.outputs !== undefined && !empty) {
|
|
242
|
+
try {
|
|
243
|
+
decoded = await decodeValues(call.outputs, row.returnData, `calls[${index}]`);
|
|
244
|
+
}
|
|
245
|
+
catch (err) {
|
|
246
|
+
// One contract answering in a shape the declared types cannot
|
|
247
|
+
// read is a fact about that contract, not a reason to lose the
|
|
248
|
+
// rest of the table.
|
|
249
|
+
decodeError = err.message;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
results.push({
|
|
253
|
+
index,
|
|
254
|
+
...(call.label !== undefined ? { label: call.label } : {}),
|
|
255
|
+
target: call.target,
|
|
256
|
+
success: true,
|
|
257
|
+
returnData: row.returnData,
|
|
258
|
+
...(empty ? { emptyReturn: true } : {}),
|
|
259
|
+
...(decoded !== undefined ? { decoded } : {}),
|
|
260
|
+
...(decodeError !== undefined ? { decodeError } : {}),
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
const caveats = [];
|
|
264
|
+
if (emptyReturns > 0) {
|
|
265
|
+
caveats.push(`${emptyReturns} call(s) SUCCEEDED with empty return data. In the EVM a call to an address with no code succeeds and returns nothing, so this is usually a wrong address, a contract not deployed on this chain, or a function the contract does not have — it is not a zero value, and it has deliberately not been decoded as one.`);
|
|
266
|
+
}
|
|
267
|
+
if (pinned === undefined && !pinnedByCaller) {
|
|
268
|
+
caveats.push(`the block was not pinned: these rows were read at "${param}", which the node resolved at request time.`);
|
|
269
|
+
}
|
|
270
|
+
return json({
|
|
271
|
+
chainId: input.chainId,
|
|
272
|
+
multicall3,
|
|
273
|
+
block: {
|
|
274
|
+
requested: param,
|
|
275
|
+
number: pinned === undefined ? null : pinned.toString(),
|
|
276
|
+
pinned: pinned !== undefined || pinnedByCaller,
|
|
277
|
+
source: pinnedByCaller
|
|
278
|
+
? "caller"
|
|
279
|
+
: pinned === undefined
|
|
280
|
+
? "none"
|
|
281
|
+
: "Multicall3 getBlockNumber() in the first batch",
|
|
282
|
+
},
|
|
283
|
+
requests: batches.length,
|
|
284
|
+
callCount: input.calls.length,
|
|
285
|
+
succeeded: input.calls.length - failed,
|
|
286
|
+
failed,
|
|
287
|
+
emptyReturns,
|
|
288
|
+
results,
|
|
289
|
+
...(caveats.length > 0 ? { caveats } : {}),
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
finally {
|
|
293
|
+
deadline.cancel();
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
/**
|
|
298
|
+
* Run the introspection view calls, preferring one batched request.
|
|
299
|
+
*
|
|
300
|
+
* Multicall3 is not deployed everywhere — a local fork, an L2 that predates
|
|
301
|
+
* the deterministic deploy, a chain with its own address. When the batch
|
|
302
|
+
* itself fails the reads are made one at a time instead, and which path ran
|
|
303
|
+
* is reported, because ten sequential round trips against a rate-limited
|
|
304
|
+
* endpoint is a different thing from one.
|
|
305
|
+
*/
|
|
306
|
+
function looksLikeRevert(err) {
|
|
307
|
+
if (revertFromCallError(err) !== undefined)
|
|
308
|
+
return true;
|
|
309
|
+
const { code, message } = rpcError(err);
|
|
310
|
+
// Geth answers a reverted eth_call with code 3. Nodes that do not attach
|
|
311
|
+
// revert data still say so in the message.
|
|
312
|
+
return code === 3 || /revert|out of gas|invalid opcode/i.test(message ?? "");
|
|
313
|
+
}
|
|
314
|
+
async function runViews(rpc, chainId, views, multicall3, block, signal) {
|
|
315
|
+
try {
|
|
316
|
+
const rows = await runAggregate3(rpc, chainId, views.map((v) => ({ target: v.target, callData: v.callData, allowFailure: true })), multicall3, block, signal, "ContractInspect");
|
|
317
|
+
return {
|
|
318
|
+
mode: "multicall3",
|
|
319
|
+
rows: rows.map((r) => ({ success: r.success, returnData: r.returnData })),
|
|
320
|
+
unreadable: 0,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
catch (err) {
|
|
324
|
+
if (isAbort(err, signal))
|
|
325
|
+
throw err;
|
|
326
|
+
const batchError = err.message;
|
|
327
|
+
const rows = [];
|
|
328
|
+
let unreadable = 0;
|
|
329
|
+
let unreadableReason;
|
|
330
|
+
for (const view of views) {
|
|
331
|
+
try {
|
|
332
|
+
const raw = await rpcRead(rpc, chainId, "eth_call", [{ to: view.target, data: view.callData }, block], signal);
|
|
333
|
+
rows.push({ success: true, returnData: asData(raw, "an eth_call result") });
|
|
334
|
+
}
|
|
335
|
+
catch (callErr) {
|
|
336
|
+
if (isAbort(callErr, signal))
|
|
337
|
+
throw callErr;
|
|
338
|
+
// A view that REVERTS is the answer for most of these probes: a
|
|
339
|
+
// contract that is not ERC-165 reverts on supportsInterface, and one
|
|
340
|
+
// that is not a proxy reverts on implementation(). A probe that
|
|
341
|
+
// failed for any OTHER reason — the node is down, rate-limiting, the
|
|
342
|
+
// block is pruned — taught us nothing, and must not be counted as a
|
|
343
|
+
// revert, because "it reverted" reads as "it does not support that".
|
|
344
|
+
const silent = !looksLikeRevert(callErr);
|
|
345
|
+
if (silent) {
|
|
346
|
+
unreadable++;
|
|
347
|
+
unreadableReason ??= rpcErrorText(callErr);
|
|
348
|
+
}
|
|
349
|
+
rows.push({ success: false, returnData: "0x", ...(silent ? { unreadable: true } : {}) });
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return {
|
|
353
|
+
mode: "sequential",
|
|
354
|
+
rows,
|
|
355
|
+
batchError,
|
|
356
|
+
unreadable,
|
|
357
|
+
...(unreadableReason === undefined ? {} : { unreadableReason }),
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
/** A single-word bool return, or undefined when the call gave no word. */
|
|
362
|
+
function boolResult(row) {
|
|
363
|
+
if (row === undefined || !row.success)
|
|
364
|
+
return undefined;
|
|
365
|
+
const word = wordAt(row.returnData, 0);
|
|
366
|
+
return word === undefined ? undefined : wordToBool(word);
|
|
367
|
+
}
|
|
368
|
+
/** A single-word address return, or undefined when it was not one. */
|
|
369
|
+
function addressResult(row, what) {
|
|
370
|
+
if (row === undefined || !row.success)
|
|
371
|
+
return undefined;
|
|
372
|
+
const word = wordAt(row.returnData, 0);
|
|
373
|
+
if (word === undefined)
|
|
374
|
+
return undefined;
|
|
375
|
+
try {
|
|
376
|
+
const address = wordToAddress(word, what);
|
|
377
|
+
return address === ZERO_ADDRESS ? undefined : address;
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
return undefined;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
export const contractInspect = buildTool({
|
|
384
|
+
name: "ContractInspect",
|
|
385
|
+
description: "Ask an address what it is before calling it: whether there is code there at all, how big it is, whether it is a proxy and what it delegates to, and which ERC-165 interfaces it claims. Use it whenever an address arrives from somewhere you did not write — a proxy's ABI is the implementation's, not the proxy's, and calling the wrong ABI produces calldata a node accepts and a contract misreads. The answer is split into what was VERIFIED and what the contract CLAIMS, because those are different kinds of fact: a proxy's implementation slot is storage, read with eth_getStorageAt, while supportsInterface is the contract answering a question about itself and a contract can lie. Contracts that claim to support the reserved 0xffffffff interface id are reported as non-compliant and their claims are dropped entirely rather than listed. A proxy whose mechanisms disagree, and a diamond that has no single implementation, come back unresolved with the reason instead of a guessed address. Read-only: it never signs, sends or deploys.",
|
|
386
|
+
inputSchema: z
|
|
387
|
+
.object({
|
|
388
|
+
chainId: chainIdField,
|
|
389
|
+
address: z.string().min(1),
|
|
390
|
+
interfaceIds: z
|
|
391
|
+
.array(z.string().regex(/^0x[0-9a-fA-F]{8}$/))
|
|
392
|
+
.max(LIMITS.interfaceProbes)
|
|
393
|
+
.optional()
|
|
394
|
+
.describe("extra ERC-165 interface ids to probe, on top of the well-known set"),
|
|
395
|
+
probeInterfaces: z
|
|
396
|
+
.boolean()
|
|
397
|
+
.optional()
|
|
398
|
+
.describe("default true; false skips ERC-165 entirely"),
|
|
399
|
+
multicall3Address: multicallAddressField,
|
|
400
|
+
blockNumber: blockNumberField,
|
|
401
|
+
blockTag: blockTagField,
|
|
402
|
+
timeoutMs: timeoutField,
|
|
403
|
+
})
|
|
404
|
+
.strict(),
|
|
405
|
+
...NETWORK_TOOL,
|
|
406
|
+
execute: async (input, ctx) => {
|
|
407
|
+
const deadline = startDeadline(input.timeoutMs ?? DEFAULT_TIMEOUT_MS, ctx?.signal);
|
|
408
|
+
try {
|
|
409
|
+
const rpc = resolveRpc(input.chainId, "ContractInspect");
|
|
410
|
+
const target = await checkAddress(input.address, "address");
|
|
411
|
+
const { param } = blockOf(input);
|
|
412
|
+
const multicall3 = input.multicall3Address ?? MULTICALL3_ADDRESS;
|
|
413
|
+
const caveats = [];
|
|
414
|
+
const code = asData(await rpcRead(rpc, input.chainId, "eth_getCode", [target.address, param], deadline.signal), "eth_getCode");
|
|
415
|
+
const codeSize = byteLength(code);
|
|
416
|
+
if (codeSize === 0) {
|
|
417
|
+
return json({
|
|
418
|
+
chainId: input.chainId,
|
|
419
|
+
address: target.address,
|
|
420
|
+
checksumVerified: target.checksumVerified,
|
|
421
|
+
block: { requested: param },
|
|
422
|
+
verified: { isContract: false, codeSize: 0 },
|
|
423
|
+
claimed: {},
|
|
424
|
+
caveats: [
|
|
425
|
+
"there is no code at this address at this block. It is an externally owned account, a contract that has not been deployed yet, or one that has self-destructed — an address with no code TODAY can hold a contract tomorrow, because CREATE2 lets the same address be deployed to again.",
|
|
426
|
+
...(target.checksumVerified
|
|
427
|
+
? []
|
|
428
|
+
: [
|
|
429
|
+
"this address was given in lowercase, so its EIP-55 checksum could not be verified and a typo would not have been caught.",
|
|
430
|
+
]),
|
|
431
|
+
],
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
const [implWord, adminWord, beaconWord, uupsWord] = await Promise.all([EIP1967_IMPLEMENTATION_SLOT, EIP1967_ADMIN_SLOT, EIP1967_BEACON_SLOT, EIP1822_SLOT].map(async (slot) => storageWord(await rpcRead(rpc, input.chainId, "eth_getStorageAt", [target.address, slot, param], deadline.signal), `the storage slot ${slot}`)));
|
|
435
|
+
const signals = [];
|
|
436
|
+
const clone = minimalProxyTarget(code);
|
|
437
|
+
if (clone !== null) {
|
|
438
|
+
signals.push({
|
|
439
|
+
kind: "eip1167-minimal",
|
|
440
|
+
implementation: clone,
|
|
441
|
+
source: "the EIP-1167 minimal-proxy pattern in the runtime bytecode",
|
|
442
|
+
evidence: "bytecode",
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
const readSlotAddress = (word, slot, label) => {
|
|
446
|
+
if (isZeroWord(word))
|
|
447
|
+
return undefined;
|
|
448
|
+
try {
|
|
449
|
+
return wordToAddress(word, `${label} (slot ${slot})`);
|
|
450
|
+
}
|
|
451
|
+
catch {
|
|
452
|
+
// A non-zero slot whose top bytes are dirty is not an address. On
|
|
453
|
+
// an EIP-1822 implementation contract this slot holds the proxiable
|
|
454
|
+
// UUID rather than a pointer, and masking it down to 20 bytes would
|
|
455
|
+
// report a fragment of a hash as a contract.
|
|
456
|
+
caveats.push(`${label} (slot ${slot}) is set to ${word}, which is not an address — it is reported as raw storage and not used to resolve an implementation.`);
|
|
457
|
+
return undefined;
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
const slotImplementation = readSlotAddress(implWord, EIP1967_IMPLEMENTATION_SLOT, "the EIP-1967 implementation slot");
|
|
461
|
+
if (slotImplementation !== undefined) {
|
|
462
|
+
signals.push({
|
|
463
|
+
kind: "eip1967",
|
|
464
|
+
implementation: slotImplementation,
|
|
465
|
+
source: `the EIP-1967 implementation slot (${EIP1967_IMPLEMENTATION_SLOT})`,
|
|
466
|
+
evidence: "storage",
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
const slot1822 = readSlotAddress(uupsWord, EIP1822_SLOT, "the EIP-1822 slot");
|
|
470
|
+
if (slot1822 !== undefined) {
|
|
471
|
+
signals.push({
|
|
472
|
+
kind: "eip1822",
|
|
473
|
+
implementation: slot1822,
|
|
474
|
+
source: `the EIP-1822 slot keccak256("PROXIABLE") (${EIP1822_SLOT})`,
|
|
475
|
+
evidence: "storage",
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
const beacon = readSlotAddress(beaconWord, EIP1967_BEACON_SLOT, "the EIP-1967 beacon slot");
|
|
479
|
+
const admin = readSlotAddress(adminWord, EIP1967_ADMIN_SLOT, "the EIP-1967 admin slot");
|
|
480
|
+
const wantInterfaces = input.probeInterfaces !== false;
|
|
481
|
+
const probeIds = wantInterfaces
|
|
482
|
+
? [
|
|
483
|
+
...KNOWN_INTERFACES.map((i) => i.id),
|
|
484
|
+
...(input.interfaceIds ?? []).filter((id) => !KNOWN_INTERFACES.some((i) => i.id.toLowerCase() === id.toLowerCase())),
|
|
485
|
+
]
|
|
486
|
+
: [];
|
|
487
|
+
const views = [
|
|
488
|
+
// The compliance sentinel goes out only when its answer will be used.
|
|
489
|
+
// Probing it and discarding the result would cost a call to learn
|
|
490
|
+
// something nothing downstream reads.
|
|
491
|
+
...(wantInterfaces
|
|
492
|
+
? [{ target: target.address, callData: supportsInterfaceData(ERC165_INVALID_ID) }]
|
|
493
|
+
: []),
|
|
494
|
+
...probeIds.map((id) => ({ target: target.address, callData: supportsInterfaceData(id) })),
|
|
495
|
+
{ target: target.address, callData: SELECTORS.implementation },
|
|
496
|
+
{ target: target.address, callData: SELECTORS.proxiableUUID },
|
|
497
|
+
...(beacon !== undefined ? [{ target: beacon, callData: SELECTORS.implementation }] : []),
|
|
498
|
+
];
|
|
499
|
+
const { mode, rows, batchError, unreadable, unreadableReason } = await runViews(rpc, input.chainId, views, multicall3, param, deadline.signal);
|
|
500
|
+
if (batchError !== undefined) {
|
|
501
|
+
caveats.push(`the batched read failed and the probes were made one at a time instead (${batchError}).`);
|
|
502
|
+
}
|
|
503
|
+
if (unreadable > 0 && unreadable === views.length) {
|
|
504
|
+
// Every probe failed for a reason that was not a revert. A report
|
|
505
|
+
// built on that would say "not a proxy, not ERC-165" about a
|
|
506
|
+
// contract nobody actually managed to ask.
|
|
507
|
+
throw new ChainCallError(`ContractInspect: neither the batched read nor any of the ${views.length} individual probes could be answered by this node (${unreadableReason ?? "no reason given"}). Nothing was learned about this contract's interfaces or its proxy views, so no report is given — one would read as "not a proxy, not ERC-165", which is a different claim from "could not ask".`);
|
|
508
|
+
}
|
|
509
|
+
if (unreadable > 0) {
|
|
510
|
+
caveats.push(`${unreadable} of ${views.length} probes could not be answered by the node at all (${unreadableReason ?? "no reason given"}) — those are reported as absent, which is not the same as the contract having said no.`);
|
|
511
|
+
}
|
|
512
|
+
let cursor = 0;
|
|
513
|
+
const sentinelRow = wantInterfaces ? rows[cursor++] : undefined;
|
|
514
|
+
const sentinel = wantInterfaces ? boolResult(sentinelRow) : undefined;
|
|
515
|
+
const claims = probeIds.map((id) => ({ id, claimed: boolResult(rows[cursor++]) }));
|
|
516
|
+
const implementationView = addressResult(rows[cursor++], "implementation()");
|
|
517
|
+
const proxiableUuid = rows[cursor++];
|
|
518
|
+
const beaconImplementation = beacon === undefined
|
|
519
|
+
? undefined
|
|
520
|
+
: addressResult(rows[cursor++], "the beacon's implementation()");
|
|
521
|
+
if (beacon !== undefined) {
|
|
522
|
+
signals.push({
|
|
523
|
+
kind: "eip1967-beacon",
|
|
524
|
+
implementation: beaconImplementation ?? null,
|
|
525
|
+
source: `the EIP-1967 beacon slot (${EIP1967_BEACON_SLOT}) names beacon ${beacon}, whose implementation() was then called`,
|
|
526
|
+
evidence: "call",
|
|
527
|
+
});
|
|
528
|
+
if (beaconImplementation === undefined) {
|
|
529
|
+
caveats.push(`the beacon at ${beacon} did not answer implementation(), so the implementation behind this beacon proxy is unknown.`);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
// A contract that answers `true` to the reserved 0xffffffff id answers
|
|
533
|
+
// true to everything, which makes every other claim it makes worthless.
|
|
534
|
+
// Reporting the eleven interfaces it "supports" would be eleven wrong
|
|
535
|
+
// facts a caller has no way to spot, so they are dropped.
|
|
536
|
+
const erc165Compliant = sentinel === false;
|
|
537
|
+
const supported = erc165Compliant
|
|
538
|
+
? claims.filter((c) => c.claimed === true).map((c) => c.id)
|
|
539
|
+
: [];
|
|
540
|
+
if (erc165Compliant && supported.some((id) => id.toLowerCase() === DIAMOND_LOUPE_ID)) {
|
|
541
|
+
signals.push({
|
|
542
|
+
kind: "diamond",
|
|
543
|
+
implementation: null,
|
|
544
|
+
source: "an ERC-165 claim of the EIP-2535 DiamondLoupe interface",
|
|
545
|
+
evidence: "call",
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
const proxy = reconcileProxy(signals);
|
|
549
|
+
if (proxy.isProxy && proxy.signals.some((s) => s.evidence === "call")) {
|
|
550
|
+
caveats.push("part of this proxy verdict rests on a view CALL rather than on storage or bytecode, so it is what the contract says about itself at this block rather than a fact read out of its state.");
|
|
551
|
+
}
|
|
552
|
+
if (!target.checksumVerified) {
|
|
553
|
+
caveats.push("this address was given in lowercase, so its EIP-55 checksum could not be verified and a typo would not have been caught.");
|
|
554
|
+
}
|
|
555
|
+
const named = (id) => KNOWN_INTERFACES.find((i) => i.id.toLowerCase() === id.toLowerCase())?.name;
|
|
556
|
+
return json({
|
|
557
|
+
chainId: input.chainId,
|
|
558
|
+
address: target.address,
|
|
559
|
+
checksumVerified: target.checksumVerified,
|
|
560
|
+
block: { requested: param },
|
|
561
|
+
verified: {
|
|
562
|
+
isContract: true,
|
|
563
|
+
codeSize,
|
|
564
|
+
storage: {
|
|
565
|
+
eip1967Implementation: implWord,
|
|
566
|
+
eip1967Admin: adminWord,
|
|
567
|
+
eip1967Beacon: beaconWord,
|
|
568
|
+
eip1822: uupsWord,
|
|
569
|
+
adminAddress: admin ?? null,
|
|
570
|
+
beaconAddress: beacon ?? null,
|
|
571
|
+
},
|
|
572
|
+
proxy: {
|
|
573
|
+
isProxy: proxy.isProxy,
|
|
574
|
+
resolved: proxy.resolved,
|
|
575
|
+
kind: proxy.kind,
|
|
576
|
+
implementation: proxy.implementation,
|
|
577
|
+
signals: proxy.signals,
|
|
578
|
+
...(proxy.unresolvedReason !== undefined
|
|
579
|
+
? { unresolvedReason: proxy.unresolvedReason }
|
|
580
|
+
: {}),
|
|
581
|
+
},
|
|
582
|
+
reads: mode,
|
|
583
|
+
},
|
|
584
|
+
claimed: {
|
|
585
|
+
...(!wantInterfaces
|
|
586
|
+
? {}
|
|
587
|
+
: {
|
|
588
|
+
erc165: erc165Compliant
|
|
589
|
+
? {
|
|
590
|
+
compliant: true,
|
|
591
|
+
supported: supported.map((id) => ({
|
|
592
|
+
id,
|
|
593
|
+
...(named(id) !== undefined ? { name: named(id) } : {}),
|
|
594
|
+
})),
|
|
595
|
+
probed: probeIds.length,
|
|
596
|
+
caveat: "supportsInterface is the contract's own answer about itself. It is a claim, not a verification: a contract can claim an interface it does not implement, and implement one it does not claim.",
|
|
597
|
+
}
|
|
598
|
+
: {
|
|
599
|
+
compliant: false,
|
|
600
|
+
established: sentinelRow?.unreadable !== true,
|
|
601
|
+
reason:
|
|
602
|
+
// A sentinel the node never answered establishes
|
|
603
|
+
// nothing. Saying "this contract does not implement
|
|
604
|
+
// ERC-165" there is a claim about a contract nobody
|
|
605
|
+
// managed to ask — the same mistake the all-probes-
|
|
606
|
+
// failed refusal exists to prevent, one probe at a time.
|
|
607
|
+
sentinelRow?.unreadable === true
|
|
608
|
+
? `supportsInterface(0xffffffff) could not be answered by the node at all (${unreadableReason ?? "no reason given"}), so whether this contract implements ERC-165 was never established. Its claims are dropped because none of them can be trusted without the sentinel — NOT because the contract said no.`
|
|
609
|
+
: sentinel === undefined
|
|
610
|
+
? "supportsInterface(0xffffffff) did not return a value — this contract does not implement ERC-165, so no interface claim could be read from it"
|
|
611
|
+
: "this contract answered TRUE for the reserved 0xffffffff interface id, which ERC-165 requires to be false. It answers true to everything, so its claims carry no information and none are listed.",
|
|
612
|
+
},
|
|
613
|
+
}),
|
|
614
|
+
implementationView: implementationView ?? null,
|
|
615
|
+
uupsProxiable: proxiableUuid?.success === true && proxiableUuid.returnData !== "0x",
|
|
616
|
+
note: "implementationView and uupsProxiable are view CALLS. On a proxy they are answered by the implementation through the delegate, so they describe the code reached at this address rather than the address itself.",
|
|
617
|
+
},
|
|
618
|
+
...(caveats.length > 0 ? { caveats } : {}),
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
finally {
|
|
622
|
+
deadline.cancel();
|
|
623
|
+
}
|
|
624
|
+
},
|
|
625
|
+
});
|
|
626
|
+
// ---------------------------------------------------------------------------
|
|
627
|
+
// EvmSimulateBundle
|
|
628
|
+
// ---------------------------------------------------------------------------
|
|
629
|
+
const simCallSchema = z
|
|
630
|
+
.object({
|
|
631
|
+
from: z
|
|
632
|
+
.string()
|
|
633
|
+
.regex(/^0x[0-9a-fA-F]{40}$/)
|
|
634
|
+
.optional(),
|
|
635
|
+
to: z.string().regex(/^0x[0-9a-fA-F]{40}$/),
|
|
636
|
+
data: z.string().optional().describe("calldata, 0x hex"),
|
|
637
|
+
value: z.string().regex(/^\d+$/).optional().describe("native value in wei, decimal string"),
|
|
638
|
+
gas: z.string().regex(/^\d+$/).optional().describe("gas cap for this call, decimal string"),
|
|
639
|
+
})
|
|
640
|
+
.strict();
|
|
641
|
+
/** Build the bracketing balance reads: native first, then each token. */
|
|
642
|
+
function balanceProbes(accounts, tokens, multicall3) {
|
|
643
|
+
const probes = [];
|
|
644
|
+
for (const account of accounts) {
|
|
645
|
+
probes.push({ account, asset: "native", callData: getEthBalanceData(account) });
|
|
646
|
+
}
|
|
647
|
+
for (const token of tokens) {
|
|
648
|
+
for (const account of accounts) {
|
|
649
|
+
probes.push({ account, asset: token, callData: balanceOfData(account) });
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
const calls = probes.map((p) => ({
|
|
653
|
+
target: p.asset === "native" ? multicall3 : p.asset,
|
|
654
|
+
callData: p.callData,
|
|
655
|
+
allowFailure: true,
|
|
656
|
+
}));
|
|
657
|
+
return { probes, calls };
|
|
658
|
+
}
|
|
659
|
+
/** Decode one bracket's aggregate3 blob into a balance per probe. */
|
|
660
|
+
function readProbeBlob(returnData, count) {
|
|
661
|
+
const rows = decodeAggregate3(returnData, count);
|
|
662
|
+
return rows.map((row) => {
|
|
663
|
+
if (!row.success)
|
|
664
|
+
return null;
|
|
665
|
+
const word = wordAt(row.returnData, 0);
|
|
666
|
+
return word === undefined ? null : wordToBigint(word);
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
export const evmSimulateBundle = buildTool({
|
|
670
|
+
name: "EvmSimulateBundle",
|
|
671
|
+
description: "Simulate an ordered sequence of calls against a block and report what each one would do — status, gas, return data, revert reason, logs — without submitting anything. Use it to check a plan before approving it: an approve followed by a swap, a multi-step position change, a governance execution. The point is that state CHAINS, so the second call sees what the first one did. That needs eth_simulateV1, which many endpoints do not implement; when one does not, this falls back to independent eth_calls and says so in `mode`, sets `chained: false`, and OMITS logs and balance deltas entirely rather than reporting the half that survives — a partially-true effects summary is precisely what a policy gate would trust and be wrong about. A timeout or a rejected parameter is never degraded into a fallback, because neither says the node cannot answer the real question. Optional balance tracking brackets the bundle with Multicall3 balance reads, so native and ERC-20 deltas come from the chain's own accounting rather than from summing Transfer logs, which miss fee-on-transfer and rebasing tokens. It simulates only: no key is accepted, nothing is signed, and nothing is broadcast.",
|
|
672
|
+
inputSchema: z
|
|
673
|
+
.object({
|
|
674
|
+
chainId: chainIdField,
|
|
675
|
+
calls: z.array(simCallSchema).min(1).max(LIMITS.bundleCalls),
|
|
676
|
+
blockNumber: blockNumberField,
|
|
677
|
+
blockTag: blockTagField,
|
|
678
|
+
stateOverrides: z
|
|
679
|
+
.record(z.record(z.unknown()))
|
|
680
|
+
.optional()
|
|
681
|
+
.describe("per-address overrides (balance, nonce, code, state/stateDiff)"),
|
|
682
|
+
blockOverrides: z
|
|
683
|
+
.record(z.unknown())
|
|
684
|
+
.optional()
|
|
685
|
+
.describe("per-block overrides (time, number, baseFeePerGas, gasLimit)"),
|
|
686
|
+
validation: z
|
|
687
|
+
.boolean()
|
|
688
|
+
.optional()
|
|
689
|
+
.describe("default false; true makes the node check nonces, balances and fees"),
|
|
690
|
+
traceTransfers: z
|
|
691
|
+
.boolean()
|
|
692
|
+
.optional()
|
|
693
|
+
.describe("default true; surfaces native value moves as synthetic Transfer logs"),
|
|
694
|
+
allowFallback: z
|
|
695
|
+
.boolean()
|
|
696
|
+
.optional()
|
|
697
|
+
.describe("default true; false refuses rather than answering the unchained question"),
|
|
698
|
+
trackBalances: z
|
|
699
|
+
.object({
|
|
700
|
+
accounts: z
|
|
701
|
+
.array(z.string().regex(/^0x[0-9a-fA-F]{40}$/))
|
|
702
|
+
.min(1)
|
|
703
|
+
.max(LIMITS.trackedAccounts),
|
|
704
|
+
tokens: z
|
|
705
|
+
.array(z.string().regex(/^0x[0-9a-fA-F]{40}$/))
|
|
706
|
+
.max(LIMITS.trackedTokens)
|
|
707
|
+
.optional(),
|
|
708
|
+
})
|
|
709
|
+
.strict()
|
|
710
|
+
.optional()
|
|
711
|
+
.describe("read these balances before and after the bundle, inside the simulation"),
|
|
712
|
+
multicall3Address: multicallAddressField,
|
|
713
|
+
timeoutMs: timeoutField,
|
|
714
|
+
})
|
|
715
|
+
.strict(),
|
|
716
|
+
...NETWORK_TOOL,
|
|
717
|
+
execute: async (input, ctx) => {
|
|
718
|
+
const deadline = startDeadline(input.timeoutMs ?? DEFAULT_TIMEOUT_MS, ctx?.signal);
|
|
719
|
+
try {
|
|
720
|
+
const rpc = resolveRpc(input.chainId, "EvmSimulateBundle");
|
|
721
|
+
const { param } = blockOf(input);
|
|
722
|
+
const multicall3 = input.multicall3Address ?? MULTICALL3_ADDRESS;
|
|
723
|
+
const traceTransfers = input.traceTransfers ?? true;
|
|
724
|
+
const userCalls = input.calls.map((call, index) => toWireCall(call, index));
|
|
725
|
+
let probes = [];
|
|
726
|
+
let bracketed = userCalls;
|
|
727
|
+
if (input.trackBalances !== undefined) {
|
|
728
|
+
const built = balanceProbes(input.trackBalances.accounts, input.trackBalances.tokens ?? [], multicall3);
|
|
729
|
+
probes = built.probes;
|
|
730
|
+
// The probes ride INSIDE the bundle so they see the same chained
|
|
731
|
+
// state the calls do. Reading balances with a separate eth_call
|
|
732
|
+
// afterwards would read the real chain, where none of this happened.
|
|
733
|
+
const packed = encodeAggregate3(built.calls, multicall3);
|
|
734
|
+
const bracket = { to: packed.to, input: packed.data };
|
|
735
|
+
bracketed = [bracket, ...userCalls, bracket];
|
|
736
|
+
}
|
|
737
|
+
const payload = {
|
|
738
|
+
blockStateCalls: [
|
|
739
|
+
{
|
|
740
|
+
calls: bracketed,
|
|
741
|
+
...(input.stateOverrides !== undefined ? { stateOverrides: input.stateOverrides } : {}),
|
|
742
|
+
...(input.blockOverrides !== undefined ? { blockOverrides: input.blockOverrides } : {}),
|
|
743
|
+
},
|
|
744
|
+
],
|
|
745
|
+
validation: input.validation ?? false,
|
|
746
|
+
traceTransfers,
|
|
747
|
+
returnFullTransactions: false,
|
|
748
|
+
};
|
|
749
|
+
let raw;
|
|
750
|
+
try {
|
|
751
|
+
raw = await rpcRead(rpc, input.chainId, "eth_simulateV1", [payload, param], deadline.signal);
|
|
752
|
+
}
|
|
753
|
+
catch (err) {
|
|
754
|
+
if (isAbort(err, deadline.signal)) {
|
|
755
|
+
// A failure a cancellation could also produce must name its
|
|
756
|
+
// reason. A deadline or a cancelled run says nothing about whether
|
|
757
|
+
// the node implements eth_simulateV1, so degrading here would turn
|
|
758
|
+
// a slow endpoint into a permanently weaker answer that still
|
|
759
|
+
// reads like an answer.
|
|
760
|
+
throw new ChainCallError("EvmSimulateBundle: the call was cancelled — the deadline elapsed, or the run was cancelled — before the node answered eth_simulateV1. This is NOT treated as an unimplemented method, so no eth_call fallback was attempted; raise timeoutMs or retry.", err);
|
|
761
|
+
}
|
|
762
|
+
if (!isMethodUnsupported(err, deadline.signal)) {
|
|
763
|
+
throw new ChainCallError(`EvmSimulateBundle: the node refused this bundle — ${rpcErrorText(err)}. The node implements eth_simulateV1, so this is an answer about the bundle and not a reason to fall back to eth_call.`, err);
|
|
764
|
+
}
|
|
765
|
+
if (input.allowFallback === false) {
|
|
766
|
+
throw new ChainCallError(`EvmSimulateBundle: this endpoint does not implement eth_simulateV1 (${rpcErrorText(err)}) and allowFallback is false. The eth_call fallback cannot chain state between calls, so it answers a different question; re-run with allowFallback, or point at an endpoint that implements eth_simulateV1.`, err);
|
|
767
|
+
}
|
|
768
|
+
if (input.trackBalances !== undefined) {
|
|
769
|
+
throw new ChainCallError(`EvmSimulateBundle: this endpoint does not implement eth_simulateV1 (${rpcErrorText(err)}), and trackBalances needs the chained after-state that only a real simulation has. There is no honest delta to compute from independent eth_calls, so none is reported — re-run without trackBalances for per-call outcomes, or use an endpoint that implements eth_simulateV1.`, err);
|
|
770
|
+
}
|
|
771
|
+
return json(await fallbackBundle(rpc, input.chainId, input.calls, param, deadline.signal));
|
|
772
|
+
}
|
|
773
|
+
const block = parseSimulateV1(raw, bracketed.length);
|
|
774
|
+
const tracking = input.trackBalances !== undefined;
|
|
775
|
+
const inner = tracking ? block.calls.slice(1, -1) : block.calls;
|
|
776
|
+
const calls = inner.map((call, index) => ({ ...call, index }));
|
|
777
|
+
const limitations = [];
|
|
778
|
+
if (traceTransfers) {
|
|
779
|
+
limitations.push("traceTransfers is on, so native value movements appear as synthetic ERC-20-shaped Transfer logs emitted by the zero address. They are not events any contract actually emitted.");
|
|
780
|
+
}
|
|
781
|
+
// The same absence-is-not-emptiness rule the fallback keeps, applied to
|
|
782
|
+
// the path that usually runs. A node can implement eth_simulateV1 and
|
|
783
|
+
// still return no log list; the rows then carry no `logs` key, and a
|
|
784
|
+
// reader who is not told that would take the missing key for silence.
|
|
785
|
+
const withoutLogs = calls.filter((call) => call.logs === undefined).length;
|
|
786
|
+
if (withoutLogs > 0) {
|
|
787
|
+
limitations.push(`${withoutLogs} of ${calls.length} call(s) came back from this node with NO log list, so no \`logs\` key is written on them. That is absence, not "this call emitted no events" — a call the node did report logs for carries the key, empty when it emitted nothing.`);
|
|
788
|
+
}
|
|
789
|
+
const withoutGas = calls.filter((call) => call.gasUsed === undefined).length;
|
|
790
|
+
if (withoutGas > 0) {
|
|
791
|
+
limitations.push(`${withoutGas} of ${calls.length} call(s) came back with no gasUsed figure, so no \`gasUsed\` key is written on them rather than a zero.`);
|
|
792
|
+
}
|
|
793
|
+
const balanceChanges = tracking
|
|
794
|
+
? readBalanceChanges(block.calls, probes, multicall3)
|
|
795
|
+
: undefined;
|
|
796
|
+
return json({
|
|
797
|
+
chainId: input.chainId,
|
|
798
|
+
mode: "eth_simulateV1",
|
|
799
|
+
chained: true,
|
|
800
|
+
blockNumber: block.blockNumber,
|
|
801
|
+
callCount: calls.length,
|
|
802
|
+
reverted: calls.filter((c) => c.status === "reverted").length,
|
|
803
|
+
calls,
|
|
804
|
+
...(balanceChanges !== undefined ? { balanceChanges } : {}),
|
|
805
|
+
...(limitations.length > 0 ? { limitations } : {}),
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
finally {
|
|
809
|
+
deadline.cancel();
|
|
810
|
+
}
|
|
811
|
+
},
|
|
812
|
+
});
|
|
813
|
+
/**
|
|
814
|
+
* Difference the two bracketing balance reads, or say why there is nothing
|
|
815
|
+
* to difference.
|
|
816
|
+
*
|
|
817
|
+
* Three ways this declines, and none of them produces a partial delta: the
|
|
818
|
+
* bracket did not execute (no Multicall3 at that address), it executed but
|
|
819
|
+
* did not answer in Multicall3's shape, or one probe inside it failed — the
|
|
820
|
+
* last is per-entry, because a token that has no code is a fact about that
|
|
821
|
+
* token and not about the other rows.
|
|
822
|
+
*/
|
|
823
|
+
function readBalanceChanges(calls, probes, multicall3) {
|
|
824
|
+
const before = calls[0];
|
|
825
|
+
const after = calls[calls.length - 1];
|
|
826
|
+
if (before.status !== "success" || after.status !== "success") {
|
|
827
|
+
return {
|
|
828
|
+
ok: false,
|
|
829
|
+
reason: `the bracketing balance read did not execute — Multicall3 at ${multicall3} is probably not deployed on this chain. No deltas are reported rather than partial ones.`,
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
let start;
|
|
833
|
+
let end;
|
|
834
|
+
try {
|
|
835
|
+
start = readProbeBlob(before.returnData, probes.length);
|
|
836
|
+
end = readProbeBlob(after.returnData, probes.length);
|
|
837
|
+
}
|
|
838
|
+
catch (err) {
|
|
839
|
+
return {
|
|
840
|
+
ok: false,
|
|
841
|
+
reason: `the bracketing balance read at ${multicall3} did not answer in Multicall3's shape, so nothing could be differenced from it — ${err.message}`,
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
return {
|
|
845
|
+
ok: true,
|
|
846
|
+
entries: probes.map((probe, i) => {
|
|
847
|
+
const from = start[i];
|
|
848
|
+
const to = end[i];
|
|
849
|
+
if (from === null || from === undefined || to === null || to === undefined) {
|
|
850
|
+
return {
|
|
851
|
+
account: probe.account,
|
|
852
|
+
asset: probe.asset,
|
|
853
|
+
ok: false,
|
|
854
|
+
reason: "this balance could not be read inside the simulation — the token has no code at this address, or it does not implement balanceOf(address)",
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
return {
|
|
858
|
+
account: probe.account,
|
|
859
|
+
asset: probe.asset,
|
|
860
|
+
beforeWei: from.toString(),
|
|
861
|
+
afterWei: to.toString(),
|
|
862
|
+
deltaWei: (to - from).toString(),
|
|
863
|
+
};
|
|
864
|
+
}),
|
|
865
|
+
note: "these come from the chain's own balance accounting inside the simulated state, not from summing Transfer logs, so fee-on-transfer and rebasing tokens are counted correctly. The native figures include gas paid by the simulated caller.",
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* The lesser answer, stated as one.
|
|
870
|
+
*
|
|
871
|
+
* Every call here runs against the SAME unmodified block, so this is not the
|
|
872
|
+
* bundle the caller described — it is each of its calls asked separately. No
|
|
873
|
+
* `logs` key is written on any row and no `balanceChanges` key on the
|
|
874
|
+
* result: an empty list would be read as "nothing happened", and the
|
|
875
|
+
* difference between that and "this could not be observed" is the difference
|
|
876
|
+
* between a gate passing and a gate being fooled.
|
|
877
|
+
*/
|
|
878
|
+
async function fallbackBundle(rpc, chainId, calls, block, signal) {
|
|
879
|
+
// One resolution, then one height for every call. A tag sent N times is
|
|
880
|
+
// resolved N times, and the head moving mid-loop puts rows from two blocks
|
|
881
|
+
// into one table while the limitation below claims they share one — the
|
|
882
|
+
// exact inconsistency EvmMulticall refuses to produce when it has to split.
|
|
883
|
+
const pinned = await resolveFallbackBlock(rpc, chainId, block, signal);
|
|
884
|
+
const at = pinned === null ? block : toQuantity(pinned);
|
|
885
|
+
const rows = [];
|
|
886
|
+
let reverted = 0;
|
|
887
|
+
for (const [index, call] of calls.entries()) {
|
|
888
|
+
const wire = toWireCall(call, index);
|
|
889
|
+
try {
|
|
890
|
+
const raw = await rpcRead(rpc, chainId, "eth_call", [wire, at], signal);
|
|
891
|
+
rows.push({
|
|
892
|
+
index,
|
|
893
|
+
status: "success",
|
|
894
|
+
returnData: asData(raw, `the eth_call result for call ${index}`),
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
catch (err) {
|
|
898
|
+
if (isAbort(err, signal))
|
|
899
|
+
throw err;
|
|
900
|
+
const revert = revertFromCallError(err);
|
|
901
|
+
reverted++;
|
|
902
|
+
rows.push({
|
|
903
|
+
index,
|
|
904
|
+
status: "reverted",
|
|
905
|
+
returnData: revert?.data ?? "0x",
|
|
906
|
+
...(revert !== undefined ? { revert } : {}),
|
|
907
|
+
error: rpcErrorText(err),
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
return {
|
|
912
|
+
chainId,
|
|
913
|
+
mode: "eth_call-fallback",
|
|
914
|
+
chained: false,
|
|
915
|
+
blockNumber: pinned === null ? null : pinned.toString(),
|
|
916
|
+
callCount: rows.length,
|
|
917
|
+
reverted,
|
|
918
|
+
calls: rows,
|
|
919
|
+
limitations: [
|
|
920
|
+
unchainedLimitation(pinned === null ? null : pinned.toString()),
|
|
921
|
+
...FALLBACK_LIMITATIONS,
|
|
922
|
+
],
|
|
923
|
+
note: "this endpoint does not implement eth_simulateV1, so the bundle was NOT simulated as a sequence. Each call below was evaluated independently; this answers a different question from the one asked.",
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
/**
|
|
927
|
+
* Resolve a block tag to the height the fallback will hold every call at, or
|
|
928
|
+
* null when the node will not say.
|
|
929
|
+
*
|
|
930
|
+
* A caller-supplied number needs no round trip — it is already a height. A
|
|
931
|
+
* tag costs one `eth_getBlockByNumber`, and a tag that cannot be resolved is
|
|
932
|
+
* reported as unpinned rather than silently sent once per call: an extra read
|
|
933
|
+
* is cheap, and a table whose rows came from two heights is not.
|
|
934
|
+
*/
|
|
935
|
+
async function resolveFallbackBlock(rpc, chainId, block, signal) {
|
|
936
|
+
if (block.startsWith("0x"))
|
|
937
|
+
return quantity(block, "the pinned block");
|
|
938
|
+
try {
|
|
939
|
+
const raw = await rpcRead(rpc, chainId, "eth_getBlockByNumber", [block, false], signal);
|
|
940
|
+
if (raw === null || typeof raw !== "object")
|
|
941
|
+
return null;
|
|
942
|
+
const number = raw["number"];
|
|
943
|
+
return number === undefined || number === null ? null : quantity(number, "the block's number");
|
|
944
|
+
}
|
|
945
|
+
catch (err) {
|
|
946
|
+
// A cancellation is still a cancellation, and a method the allowlist
|
|
947
|
+
// refused is a defect in this file — neither may be swallowed into
|
|
948
|
+
// "unpinned", which is a sentence about the chain rather than about us.
|
|
949
|
+
if (isAbort(err, signal) || err?.name === "ChainAdapterError")
|
|
950
|
+
throw err;
|
|
951
|
+
return null;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
// ---------------------------------------------------------------------------
|
|
955
|
+
// GasMarketRead
|
|
956
|
+
// ---------------------------------------------------------------------------
|
|
957
|
+
/** The OP-stack GasPriceOracle predeploy, identical across every OP chain. */
|
|
958
|
+
const OP_GAS_PRICE_ORACLE = "0x420000000000000000000000000000000000000F";
|
|
959
|
+
export const gasMarketRead = buildTool({
|
|
960
|
+
name: "GasMarketRead",
|
|
961
|
+
description: "Read a chain's fee market: the current base fee, priority-fee percentiles from eth_feeHistory over a window of blocks, and the next block's base fee computed by the EIP-1559 rule. Use it to decide when to send and what to pay, instead of a model recalling a gwei figure. It reports WHICH mechanism the chain is running rather than assuming one — a chain with no baseFeePerGas has no 1559 market and is reported as legacy, a node without eth_feeHistory is reported as such instead of being filled in, and a base fee that never moves across the window is flagged rather than trended. The projection is computed from the block's integer gasUsed and gasLimit, never from feeHistory's floating-point gasUsedRatio, and is compared against the node's own next-block figure so a chain with a non-standard elasticity shows up as a disagreement rather than as a confident wrong number. It does not price rollup L1 data fees, and says so — on an OP-stack chain it checks whether the GasPriceOracle predeploy is there and warns that the cost it does not report is often the larger one.",
|
|
962
|
+
inputSchema: z
|
|
963
|
+
.object({
|
|
964
|
+
chainId: chainIdField,
|
|
965
|
+
blockCount: z
|
|
966
|
+
.number()
|
|
967
|
+
.int()
|
|
968
|
+
.min(1)
|
|
969
|
+
.max(LIMITS.feeBlocks)
|
|
970
|
+
.optional()
|
|
971
|
+
.describe("blocks of fee history to sample; default 20"),
|
|
972
|
+
percentiles: z
|
|
973
|
+
.array(z.number().min(0).max(100))
|
|
974
|
+
.min(1)
|
|
975
|
+
.max(8)
|
|
976
|
+
.optional()
|
|
977
|
+
.describe("priority-fee percentiles, ascending; default [10, 50, 90]"),
|
|
978
|
+
blockNumber: blockNumberField,
|
|
979
|
+
blockTag: blockTagField,
|
|
980
|
+
gasLimit: z
|
|
981
|
+
.string()
|
|
982
|
+
.regex(/^\d+$/)
|
|
983
|
+
.optional()
|
|
984
|
+
.describe("gas for a planned transaction, decimal; prices it at atPercentile"),
|
|
985
|
+
atPercentile: z
|
|
986
|
+
.number()
|
|
987
|
+
.min(0)
|
|
988
|
+
.max(100)
|
|
989
|
+
.optional()
|
|
990
|
+
.describe("which percentile to price the planned transaction at; default 50"),
|
|
991
|
+
checkRollupOracle: z
|
|
992
|
+
.boolean()
|
|
993
|
+
.optional()
|
|
994
|
+
.describe("default true; one eth_getCode to see if an OP-stack L1 fee applies"),
|
|
995
|
+
timeoutMs: timeoutField,
|
|
996
|
+
})
|
|
997
|
+
.strict(),
|
|
998
|
+
...NETWORK_TOOL,
|
|
999
|
+
execute: async (input, ctx) => {
|
|
1000
|
+
const deadline = startDeadline(input.timeoutMs ?? DEFAULT_TIMEOUT_MS, ctx?.signal);
|
|
1001
|
+
try {
|
|
1002
|
+
const rpc = resolveRpc(input.chainId, "GasMarketRead");
|
|
1003
|
+
const { param } = blockOf(input);
|
|
1004
|
+
const blockCount = input.blockCount ?? 20;
|
|
1005
|
+
const percentiles = input.percentiles ?? [10, 50, 90];
|
|
1006
|
+
const caveats = [];
|
|
1007
|
+
for (let i = 1; i < percentiles.length; i++) {
|
|
1008
|
+
if (percentiles[i] <= percentiles[i - 1]) {
|
|
1009
|
+
throw new ChainCallError(`GasMarketRead: eth_feeHistory requires strictly ascending percentiles; [${percentiles.join(", ")}] is not`);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
const rawBlock = await rpcRead(rpc, input.chainId, "eth_getBlockByNumber", [param, false], deadline.signal);
|
|
1013
|
+
if (rawBlock === null || typeof rawBlock !== "object") {
|
|
1014
|
+
throw new ChainCallError(`GasMarketRead: the node has no block at "${param}" — it may be pruned, or the chain may not have reached it`);
|
|
1015
|
+
}
|
|
1016
|
+
const block = rawBlock;
|
|
1017
|
+
const baseFee = optionalQuantity(block["baseFeePerGas"], "the block's baseFeePerGas");
|
|
1018
|
+
const gasUsed = quantity(block["gasUsed"], "the block's gasUsed");
|
|
1019
|
+
const gasLimit = quantity(block["gasLimit"], "the block's gasLimit");
|
|
1020
|
+
const blockNumber = quantity(block["number"], "the block's number");
|
|
1021
|
+
let history;
|
|
1022
|
+
try {
|
|
1023
|
+
const raw = await rpcRead(rpc, input.chainId, "eth_feeHistory", [toQuantity(BigInt(blockCount)), param, percentiles], deadline.signal);
|
|
1024
|
+
if (raw !== null && typeof raw === "object")
|
|
1025
|
+
history = raw;
|
|
1026
|
+
}
|
|
1027
|
+
catch (err) {
|
|
1028
|
+
if (isAbort(err, deadline.signal))
|
|
1029
|
+
throw err;
|
|
1030
|
+
if (!isMethodUnsupported(err, deadline.signal))
|
|
1031
|
+
throw err;
|
|
1032
|
+
caveats.push(`this node does not implement eth_feeHistory (${rpcErrorText(err)}), so there are no priority-fee percentiles and no sampled window — only the head block's own figures.`);
|
|
1033
|
+
}
|
|
1034
|
+
let gasPrice;
|
|
1035
|
+
try {
|
|
1036
|
+
gasPrice = quantity(await rpcRead(rpc, input.chainId, "eth_gasPrice", [], deadline.signal), "eth_gasPrice");
|
|
1037
|
+
}
|
|
1038
|
+
catch (err) {
|
|
1039
|
+
if (isAbort(err, deadline.signal))
|
|
1040
|
+
throw err;
|
|
1041
|
+
caveats.push(`eth_gasPrice was not answered (${rpcErrorText(err)}).`);
|
|
1042
|
+
}
|
|
1043
|
+
const historyBaseFees = Array.isArray(history?.["baseFeePerGas"])
|
|
1044
|
+
? history["baseFeePerGas"].map((v, i) => quantity(v, `feeHistory.baseFeePerGas[${i}]`))
|
|
1045
|
+
: [];
|
|
1046
|
+
const rewards = Array.isArray(history?.["reward"]) ? history["reward"] : [];
|
|
1047
|
+
const mechanism = baseFee === undefined ? "legacy" : "eip1559";
|
|
1048
|
+
let projection;
|
|
1049
|
+
if (baseFee !== undefined) {
|
|
1050
|
+
// gasUsedRatio in feeHistory is a JSON float and has already lost the
|
|
1051
|
+
// low bits of gasUsed. The rule is integer arithmetic on wei, so it
|
|
1052
|
+
// reads the block's own gasUsed and gasLimit instead.
|
|
1053
|
+
const computed = nextBaseFee(baseFee, gasUsed, gasLimit);
|
|
1054
|
+
const nodeNext = historyBaseFees[historyBaseFees.length - 1];
|
|
1055
|
+
const agrees = nodeNext === undefined ? null : nodeNext === computed.next;
|
|
1056
|
+
if (agrees === false) {
|
|
1057
|
+
caveats.push(`the next base fee this tool computed (${computed.next}) differs from the one the node reported (${nodeNext}). This chain does not follow the vanilla EIP-1559 rule — a different elasticity, a different change denominator, or a floor — so trust the node's figure and treat the computed one as a cross-check that failed.`);
|
|
1058
|
+
}
|
|
1059
|
+
projection = {
|
|
1060
|
+
computedWei: computed.next.toString(),
|
|
1061
|
+
nodeReportedWei: nodeNext === undefined ? null : nodeNext.toString(),
|
|
1062
|
+
agrees,
|
|
1063
|
+
direction: computed.direction,
|
|
1064
|
+
gasTarget: computed.target.toString(),
|
|
1065
|
+
rule: "EIP-1559: the base fee moves by at most 1/8 per block toward gasLimit/2",
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
const summaries = percentiles
|
|
1069
|
+
.map((percentile, column) => {
|
|
1070
|
+
const values = [];
|
|
1071
|
+
for (const [row, entry] of rewards.entries()) {
|
|
1072
|
+
if (!Array.isArray(entry))
|
|
1073
|
+
continue;
|
|
1074
|
+
const value = entry[column];
|
|
1075
|
+
if (value === undefined || value === null)
|
|
1076
|
+
continue;
|
|
1077
|
+
values.push(quantity(value, `feeHistory.reward[${row}][${column}]`));
|
|
1078
|
+
}
|
|
1079
|
+
return summarisePercentile(percentile, values);
|
|
1080
|
+
})
|
|
1081
|
+
.filter((s) => s !== undefined);
|
|
1082
|
+
const windowFees = historyBaseFees.slice(0, -1);
|
|
1083
|
+
const first = windowFees[0];
|
|
1084
|
+
const last = windowFees[windowFees.length - 1];
|
|
1085
|
+
const constant = windowFees.length > 1 && windowFees.every((f) => f === windowFees[0]);
|
|
1086
|
+
if (constant) {
|
|
1087
|
+
caveats.push("the base fee is identical in every sampled block. Several chains hold it at a floor and price congestion elsewhere (Arbitrum) or have no dynamic 1559 market at all, so do not read this as a quiet market.");
|
|
1088
|
+
}
|
|
1089
|
+
if (baseFee !== undefined &&
|
|
1090
|
+
windowFees.length > 0 &&
|
|
1091
|
+
windowFees[windowFees.length - 1] !== baseFee) {
|
|
1092
|
+
caveats.push("the fee-history window does not end at the block that was read — the head moved between the two requests, so the window and the head block are one block apart.");
|
|
1093
|
+
}
|
|
1094
|
+
let rollup;
|
|
1095
|
+
if (input.checkRollupOracle !== false) {
|
|
1096
|
+
try {
|
|
1097
|
+
const code = asData(await rpcRead(rpc, input.chainId, "eth_getCode", [OP_GAS_PRICE_ORACLE, param], deadline.signal), "eth_getCode for the GasPriceOracle predeploy");
|
|
1098
|
+
rollup = { opStackGasPriceOracle: byteLength(code) > 0, address: OP_GAS_PRICE_ORACLE };
|
|
1099
|
+
if (byteLength(code) > 0) {
|
|
1100
|
+
caveats.push(`there IS an OP-stack GasPriceOracle at ${OP_GAS_PRICE_ORACLE}, so this chain charges an L1 data fee on top of everything below — frequently the larger share of a transaction's cost. This tool does not read that oracle or compute the post-Ecotone blob-scalar formula, so every figure here is execution gas only.`);
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
catch {
|
|
1104
|
+
// Not knowing whether a predeploy is there is not a reason to fail
|
|
1105
|
+
// a fee read; the general caveat below still applies.
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
caveats.push("these are execution-gas figures for this chain alone. On any rollup the L1 data fee is charged separately and is not included here.");
|
|
1109
|
+
let planned;
|
|
1110
|
+
if (input.gasLimit !== undefined) {
|
|
1111
|
+
const gas = BigInt(input.gasLimit);
|
|
1112
|
+
const at = input.atPercentile ?? 50;
|
|
1113
|
+
const chosen = summaries.find((s) => s.percentile === at) ?? summaries[0];
|
|
1114
|
+
if (chosen !== undefined && chosen.percentile !== at) {
|
|
1115
|
+
// Quietly pricing at another percentile is how a caller who asked
|
|
1116
|
+
// for the 90th under-tips at the 10th and never learns why.
|
|
1117
|
+
caveats.push(`the ${at}th priority-fee percentile was not among the ones this node answered, so the planned transaction below is priced at the ${chosen.percentile}th instead. Ask for it in \`percentiles\` if you need it.`);
|
|
1118
|
+
}
|
|
1119
|
+
if (baseFee === undefined) {
|
|
1120
|
+
planned =
|
|
1121
|
+
gasPrice === undefined
|
|
1122
|
+
? {
|
|
1123
|
+
ok: false,
|
|
1124
|
+
reason: "this chain has no 1559 market and the node did not answer eth_gasPrice, so there is nothing to price against",
|
|
1125
|
+
}
|
|
1126
|
+
: {
|
|
1127
|
+
ok: true,
|
|
1128
|
+
mechanism: "legacy",
|
|
1129
|
+
gasLimit: gas.toString(),
|
|
1130
|
+
gasPriceWei: gasPrice.toString(),
|
|
1131
|
+
estimatedCostWei: (gas * gasPrice).toString(),
|
|
1132
|
+
};
|
|
1133
|
+
}
|
|
1134
|
+
else {
|
|
1135
|
+
// The node's own next-block figure wins over ours: when the two
|
|
1136
|
+
// disagree it is this chain's rule that differs, not the node's
|
|
1137
|
+
// arithmetic, and the caveat above already says so.
|
|
1138
|
+
const nodeNext = projection?.["nodeReportedWei"];
|
|
1139
|
+
const computedNext = projection?.["computedWei"];
|
|
1140
|
+
const next = BigInt(nodeNext ?? computedNext ?? baseFee.toString());
|
|
1141
|
+
const tip = chosen === undefined ? 0n : BigInt(chosen.medianWei);
|
|
1142
|
+
// Two base fees of headroom is the common wallet default: the base
|
|
1143
|
+
// fee can rise by 1/8 per block, so 2x covers roughly six blocks of
|
|
1144
|
+
// sustained full blocks. Unspent headroom is refunded.
|
|
1145
|
+
const maxFee = next * 2n + tip;
|
|
1146
|
+
planned = {
|
|
1147
|
+
ok: true,
|
|
1148
|
+
mechanism: "eip1559",
|
|
1149
|
+
gasLimit: gas.toString(),
|
|
1150
|
+
requestedPercentile: at,
|
|
1151
|
+
atPercentile: chosen?.percentile ?? null,
|
|
1152
|
+
maxPriorityFeePerGasWei: tip.toString(),
|
|
1153
|
+
suggestedMaxFeePerGasWei: maxFee.toString(),
|
|
1154
|
+
expectedCostWei: (gas * (next + tip)).toString(),
|
|
1155
|
+
worstCaseCostWei: (gas * maxFee).toString(),
|
|
1156
|
+
...(chosen === undefined
|
|
1157
|
+
? {
|
|
1158
|
+
note: "no percentile data was available, so the tip is 0 — that is an absence, not a market reading",
|
|
1159
|
+
}
|
|
1160
|
+
: {}),
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
return json({
|
|
1165
|
+
chainId: input.chainId,
|
|
1166
|
+
mechanism,
|
|
1167
|
+
block: {
|
|
1168
|
+
number: blockNumber.toString(),
|
|
1169
|
+
requested: param,
|
|
1170
|
+
baseFeePerGasWei: baseFee === undefined ? null : baseFee.toString(),
|
|
1171
|
+
gasUsed: gasUsed.toString(),
|
|
1172
|
+
gasLimit: gasLimit.toString(),
|
|
1173
|
+
},
|
|
1174
|
+
gasPriceWei: gasPrice === undefined ? null : gasPrice.toString(),
|
|
1175
|
+
...(projection !== undefined ? { nextBaseFee: projection } : {}),
|
|
1176
|
+
feeHistory: history === undefined
|
|
1177
|
+
? { available: false }
|
|
1178
|
+
: {
|
|
1179
|
+
available: true,
|
|
1180
|
+
blocks: windowFees.length,
|
|
1181
|
+
oldestBlock: optionalQuantity(history["oldestBlock"], "feeHistory.oldestBlock")?.toString() ??
|
|
1182
|
+
null,
|
|
1183
|
+
baseFeeFirstWei: first === undefined ? null : first.toString(),
|
|
1184
|
+
baseFeeLastWei: last === undefined ? null : last.toString(),
|
|
1185
|
+
baseFeeChangeBps: first === undefined || last === undefined
|
|
1186
|
+
? null
|
|
1187
|
+
: (changeBps(first, last) ?? null),
|
|
1188
|
+
baseFeeConstant: constant,
|
|
1189
|
+
priorityFees: summaries,
|
|
1190
|
+
},
|
|
1191
|
+
...(blobFees(history) ?? {}),
|
|
1192
|
+
...(rollup !== undefined ? { rollup } : {}),
|
|
1193
|
+
...(planned !== undefined ? { planned } : {}),
|
|
1194
|
+
caveats,
|
|
1195
|
+
});
|
|
1196
|
+
}
|
|
1197
|
+
finally {
|
|
1198
|
+
deadline.cancel();
|
|
1199
|
+
}
|
|
1200
|
+
},
|
|
1201
|
+
});
|
|
1202
|
+
/**
|
|
1203
|
+
* The blob market, when the chain has one.
|
|
1204
|
+
*
|
|
1205
|
+
* The next blob base fee is taken from the node's own tail entry rather than
|
|
1206
|
+
* recomputed: EIP-4844 prices blobs with an exponential, not the 1559 linear
|
|
1207
|
+
* rule, and approximating it with the wrong formula would produce a number
|
|
1208
|
+
* that looks like the others and is not.
|
|
1209
|
+
*/
|
|
1210
|
+
function blobFees(history) {
|
|
1211
|
+
const raw = history?.["baseFeePerBlobGas"];
|
|
1212
|
+
if (!Array.isArray(raw) || raw.length < 2)
|
|
1213
|
+
return undefined;
|
|
1214
|
+
const values = raw.map((v, i) => quantity(v, `feeHistory.baseFeePerBlobGas[${i}]`));
|
|
1215
|
+
const current = values[values.length - 2];
|
|
1216
|
+
const next = values[values.length - 1];
|
|
1217
|
+
return {
|
|
1218
|
+
blobs: {
|
|
1219
|
+
baseFeePerBlobGasWei: current.toString(),
|
|
1220
|
+
nextBaseFeePerBlobGasWei: next.toString(),
|
|
1221
|
+
source: "the node's own feeHistory — EIP-4844 prices blob gas with an exponential rule, not the 1559 linear one, so this is not recomputed here",
|
|
1222
|
+
},
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
/** Every tool this package registers, in the order a catalog should list them. */
|
|
1226
|
+
export const CHAINCALL_TOOLS = Object.freeze([
|
|
1227
|
+
contractInspect,
|
|
1228
|
+
evmMulticall,
|
|
1229
|
+
evmSimulateBundle,
|
|
1230
|
+
gasMarketRead,
|
|
1231
|
+
]);
|