@crewhaus/tool-chaincall 0.7.0 → 0.8.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/LICENSE ADDED
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
package/NOTICE ADDED
@@ -0,0 +1,9 @@
1
+ CrewHaus Factory
2
+ Copyright 2026 Max Meier
3
+
4
+ This product includes software developed by Max Meier
5
+ (operating as StudioMax — https://studiomax.io) and contributors.
6
+
7
+ The CrewHaus name and logo are trademarks of Max Meier. See
8
+ https://github.com/crewhaus/factory/blob/main/TRADEMARK.md for the
9
+ use-of-marks policy.
package/README.md CHANGED
@@ -46,6 +46,32 @@ SUCCEEDS in the EVM and returns nothing. Decode that as `uint256` and you get
46
46
  zero, which reads as a balance. Every such row is marked `emptyReturn` and
47
47
  left undecoded, and the result says how many there were.
48
48
 
49
+ ## Who answers a batch
50
+
51
+ A batch is one `eth_call` to Multicall3, and every row in it — each call's
52
+ return data, whether it succeeded, the block number that pins it — is what
53
+ that contract returns. Any contract can implement `aggregate3` and make rows
54
+ up, so which one answers is the operator's decision, not a call's: batches go
55
+ to the canonical deployment (`0xcA11bde05977b3631167028862bE2a173976CA11`)
56
+ unless `tool_config.chaincall.multicall3` names another for the chain. A
57
+ call's `multicall3Address` is accepted only when it is that same address, and
58
+ anything else is refused before any read. `EvmMulticall`, `ContractInspect`
59
+ (when it batched) and a balance-tracking `EvmSimulateBundle` say who answered:
60
+ `aggregator: { address, source }`, with `source` either `canonical` or
61
+ `config`.
62
+
63
+ ```yaml
64
+ tool_config:
65
+ chaincall:
66
+ multicall3: # only where the canonical deployment is absent
67
+ "324": "0xF9cda624FBC7e059355ce98a31693d299FACd963"
68
+ ```
69
+
70
+ `EvmSimulateBundle` runs under `stateOverrides` the caller chose, so its
71
+ results describe the chain as overridden; it says so in `limitations`, and
72
+ names the aggregator when an override rewrote the one that read the tracked
73
+ balances.
74
+
49
75
  ## Verified, and claimed
50
76
 
51
77
  `ContractInspect` splits its answer in two, because they are different kinds
@@ -97,10 +123,14 @@ is not the same thing as one.
97
123
  A probe that **reverts** is an answer: a contract that is not ERC-165 reverts
98
124
  on `supportsInterface`, one that is not a proxy reverts on `implementation()`.
99
125
  A probe the node could not answer at all — it is down, it is rate-limiting, it
100
- pruned the block — is not, and the two are counted separately. When *every*
101
- probe fails that way the whole read is **refused**, because the report it would
102
- otherwise produce ("not a proxy, not ERC-165") is a set of claims about a
103
- contract nobody managed to ask.
126
+ pruned the block — is not, and the two are counted separately. With several
127
+ nodes in the chain's `rpcUrls`, a probe counts as reverted when every node that
128
+ answered said so; a node that could not be reached counts neither way. A
129
+ `quorum` of two or more nodes stops waiting once no answer can win, so there a
130
+ probe that fails on every node counts as unanswered. When *every* probe fails
131
+ that way the whole read is **refused**, because the report it would otherwise
132
+ produce ("not a proxy, not ERC-165") is a set of claims about a contract nobody
133
+ managed to ask.
104
134
 
105
135
  The same line is held one probe at a time. When it is the `0xffffffff`
106
136
  sentinel that the node would not answer, the claims are still dropped — but
@@ -138,7 +168,10 @@ providers use instead of it mean the method is unimplemented. A **timeout**,
138
168
  a **rejected parameter**, a **rate limit** and a **revert** do not — degrading on
139
169
  any of those would turn a transient fault into a permanently weaker answer
140
170
  that still reads like an answer. A timed-out simulation is a refusal that says
141
- so, and no `eth_call` is attempted after it.
171
+ so, and no `eth_call` is attempted after it. With several nodes, every one
172
+ must say it lacks the method: one that rejected the bundle, could not be
173
+ reached or was not heard may have it. A `quorum` of two or more nodes stops
174
+ waiting once no answer can win, so it never falls back.
142
175
 
143
176
  `allowFallback: false` refuses instead of answering the lesser question.
144
177
 
@@ -174,7 +207,8 @@ one:
174
207
  - a block with no `baseFeePerGas` is a **legacy** chain, priced from
175
208
  `eth_gasPrice`, with no 1559 projection at all;
176
209
  - a node without `eth_feeHistory` is reported as such — no percentiles, rather
177
- than percentiles from somewhere else;
210
+ than percentiles from somewhere else. With several nodes, every one must lack
211
+ it, as for the simulation fallback above;
178
212
  - a base fee that is **identical in every sampled block** is flagged. Several
179
213
  chains hold it at a floor and price congestion elsewhere; that is not a
180
214
  quiet market;
@@ -220,10 +254,32 @@ A tool that cannot say no is not finished. These say no, with the reason:
220
254
  - **balance tracking without a chained simulation**;
221
255
  - **fee percentiles that are not strictly ascending**, which `eth_feeHistory`
222
256
  requires, and a **block the node does not have**;
257
+ - **a `multicall3Address` other than the canonical deployment or the one the
258
+ operator configured** for the chain, before anything is dialled;
259
+ - **a node that answers with any redirect**, even to its own address: a chain
260
+ read never follows one, and the error says where it pointed. Under
261
+ `fallback`, the next node named is asked instead. Point `rpcUrls` at the
262
+ address that answers directly;
223
263
  - **an address that is not 20 hex bytes**, before anything is dialled. A
224
264
  lowercase address is answered, with `checksumVerified: false` attached,
225
265
  because EIP-55 can only verify a checksum that is there.
226
266
 
267
+ ## Permission rules
268
+
269
+ A rule's argument for these tools is `<chainId>/<address>`, the chain first
270
+ and written as your spec's `chains[].id` names it:
271
+ `EvmMulticall(8453/0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913)` is one contract
272
+ on a chain declared `id: "8453"`, `EvmMulticall(base-mainnet/0x8335…)` the same
273
+ contract on one declared `id: base-mainnet`, and `ContractInspect(**0x8335…)`
274
+ that address on any chain.
275
+
276
+ An allow must be written that way: `*` does not cross the `/`, so
277
+ `alwaysAllow ContractInspect(*)` or `ContractInspect(0x8335…)` grants nothing
278
+ (write `**` for "any"). A deny or ask also fires on the address alone and on
279
+ the chain alone, so `alwaysDeny ContractInspect(0x8335…)` denies that contract
280
+ on every chain. A deny or ask ignores the letter case of the hex: an EIP-55
281
+ address and its lower-case spelling are one account.
282
+
227
283
  ## Numbers
228
284
 
229
285
  No chain quantity is ever a JS number. Wei, balances, gas and block numbers
@@ -238,10 +294,22 @@ an integer nor the value that was computed.
238
294
 
239
295
  ## Wiring
240
296
 
241
- The transport is bound once at boot, per chain, from the spec's `chains[]`
242
- block — the same shape `@crewhaus/tool-evm` uses. No caller supplies a URL
297
+ The transport is bound once at boot, per chain, from the spec's `chains`
298
+ block — the same block `@crewhaus/tool-evm` reads. A compiled bundle,
299
+ `crewhaus run` and `crewhaus eval` call `bindChainCallChains` with it; without
300
+ one, every call refuses and names the block to write. No caller supplies a URL
243
301
  anywhere in this package, and there is no endpoint list inside it.
244
302
 
303
+ ```yaml
304
+ chains:
305
+ - id: "8453"
306
+ kind: evm
307
+ rpcUrls: [$BASE_RPC_URL] # read from the environment, never compiled in
308
+ finality: { kind: finalized }
309
+ ```
310
+
311
+ A host that builds its own adapters binds them directly:
312
+
245
313
  ```ts
246
314
  import { setChainRpcResolver, chainRpcFromAdapter } from "@crewhaus/tool-chaincall";
247
315
 
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { RegisteredTool } from "@crewhaus/tool-catalog";
2
- export { ChainCallError, type ChainRpc, type ChainRpcResolver, _setRpc, chainRpcFromAdapter, setChainRpcResolver, } from "./lib/rpc";
2
+ export { ChainCallError, type ChainRpc, type ChainRpcResolver, _setRpc, bindChainCallChains, chainRpcFromAdapter, setChainRpcResolver, } from "./lib/rpc";
3
3
  export { MULTICALL3_ADDRESS } from "@crewhaus/tool-onchain";
4
+ export { type Aggregator, type ChaincallConfigInput, registerChaincallConfig, } from "./lib/aggregator";
4
5
  export { EIP1822_SLOT, EIP1967_ADMIN_SLOT, EIP1967_BEACON_SLOT, EIP1967_IMPLEMENTATION_SLOT, ERC165_INVALID_ID, KNOWN_INTERFACES, } from "./lib/proxy";
5
6
  export declare const evmMulticall: RegisteredTool;
6
7
  export declare const contractInspect: RegisteredTool;
package/dist/index.js CHANGED
@@ -38,15 +38,17 @@
38
38
  import { buildTool } from "@crewhaus/tool-builder";
39
39
  import { MULTICALL3_ADDRESS, decodeAggregate3, decodeRevertData, encodeAggregate3, } from "@crewhaus/tool-onchain";
40
40
  import { z } from "zod";
41
+ import { resolveAggregator } from "./lib/aggregator";
41
42
  import { chunk, runAggregate3 } from "./lib/batch";
42
43
  import { SELECTORS, balanceOfData, checkAddress, decodeValues, encodeCallData, getEthBalanceData, supportsInterfaceData, } from "./lib/codec";
43
44
  import { changeBps, nextBaseFee, summarisePercentile } from "./lib/fees";
44
45
  import { ZERO_ADDRESS, data as asData, blockParam, byteLength, isZeroWord, optionalQuantity, quantity, toQuantity, wordAt, wordToAddress, wordToBigint, wordToBool, } from "./lib/hex";
45
46
  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 { ChainCallError, firstRefusal, isAbort, isMethodUnsupported, nodeAnswers, resolveRpc, rpcErrorText, rpcRead, startDeadline, } from "./lib/rpc";
47
48
  import { FALLBACK_LIMITATIONS, parseSimulateV1, revertFromCallError, toWireCall, unchainedLimitation, } from "./lib/simulate";
48
- export { ChainCallError, _setRpc, chainRpcFromAdapter, setChainRpcResolver, } from "./lib/rpc";
49
+ export { ChainCallError, _setRpc, bindChainCallChains, chainRpcFromAdapter, setChainRpcResolver, } from "./lib/rpc";
49
50
  export { MULTICALL3_ADDRESS } from "@crewhaus/tool-onchain";
51
+ export { registerChaincallConfig, } from "./lib/aggregator";
50
52
  export { EIP1822_SLOT, EIP1967_ADMIN_SLOT, EIP1967_BEACON_SLOT, EIP1967_IMPLEMENTATION_SLOT, ERC165_INVALID_ID, KNOWN_INTERFACES, } from "./lib/proxy";
51
53
  const json = (value) => JSON.stringify(value);
52
54
  const DEFAULT_TIMEOUT_MS = 30_000;
@@ -97,7 +99,7 @@ const multicallAddressField = z
97
99
  .string()
98
100
  .regex(/^0x[0-9a-fA-F]{40}$/)
99
101
  .optional()
100
- .describe(`Multicall3 on this chain; default ${MULTICALL3_ADDRESS}`);
102
+ .describe(`leave it out: batches go to the canonical Multicall3 (${MULTICALL3_ADDRESS}) or the one the operator configured for the chain, and any other address is refused`);
101
103
  /** The block parameter, and whether the caller pinned it themselves. */
102
104
  function blockOf(input) {
103
105
  return {
@@ -147,6 +149,7 @@ async function calldataFor(call, index) {
147
149
  }
148
150
  export const evmMulticall = buildTool({
149
151
  name: "EvmMulticall",
152
+ operativeArgs: [{ field: "calls.target", kind: "id", within: "chainId" }],
150
153
  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
154
  inputSchema: z
152
155
  .object({
@@ -170,7 +173,10 @@ export const evmMulticall = buildTool({
170
173
  const deadline = startDeadline(input.timeoutMs ?? DEFAULT_TIMEOUT_MS, ctx?.signal);
171
174
  try {
172
175
  const rpc = resolveRpc(input.chainId, "EvmMulticall");
173
- const multicall3 = input.multicall3Address ?? MULTICALL3_ADDRESS;
176
+ // Resolved before any read: an aggregator the operator did not name
177
+ // is refused, not asked.
178
+ const aggregator = resolveAggregator(input.chainId, input.multicall3Address, ctx?.toolConfig, "EvmMulticall");
179
+ const multicall3 = aggregator.address;
174
180
  const { param, pinnedByCaller } = blockOf(input);
175
181
  const calls = [];
176
182
  for (const [index, call] of input.calls.entries()) {
@@ -270,6 +276,8 @@ export const evmMulticall = buildTool({
270
276
  return json({
271
277
  chainId: input.chainId,
272
278
  multicall3,
279
+ // Who answered every row, since each one is its word.
280
+ aggregator,
273
281
  block: {
274
282
  requested: param,
275
283
  number: pinned === undefined ? null : pinned.toString(),
@@ -306,10 +314,14 @@ export const evmMulticall = buildTool({
306
314
  function looksLikeRevert(err) {
307
315
  if (revertFromCallError(err) !== undefined)
308
316
  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 ?? "");
317
+ // Every node that answered must have said it reverted, and one must have
318
+ // answered: a node that could not be reached taught nothing either way, and
319
+ // one that answered anything else contradicts the revert. Geth answers a
320
+ // reverted eth_call with code 3; nodes that do not attach revert data still
321
+ // say so in the message.
322
+ const answers = nodeAnswers(err).flatMap(({ answer }) => (answer === undefined ? [] : [answer]));
323
+ return (answers.length > 0 &&
324
+ answers.every(({ code, message }) => code === 3 || /revert|out of gas|invalid opcode/i.test(message ?? "")));
313
325
  }
314
326
  async function runViews(rpc, chainId, views, multicall3, block, signal) {
315
327
  try {
@@ -382,7 +394,8 @@ function addressResult(row, what) {
382
394
  }
383
395
  export const contractInspect = buildTool({
384
396
  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.",
397
+ operativeArgs: [{ field: "address", kind: "id", within: "chainId" }],
398
+ description: "Ask an address what it is before calling it: whether there is code there, 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, and calling the wrong ABI produces calldata a node accepts and a contract misreads. The answer separates what was VERIFIED from what the contract CLAIMS: a proxy's implementation slot is storage, read with eth_getStorageAt, while supportsInterface is the contract answering about itself, and a contract can lie. A contract claiming the reserved 0xffffffff interface id is reported as non-compliant and its claims are dropped. A proxy whose mechanisms disagree, or a diamond with no single implementation, comes back unresolved with the reason instead of a guessed address. Read-only: it never signs, sends or deploys.",
386
399
  inputSchema: z
387
400
  .object({
388
401
  chainId: chainIdField,
@@ -407,9 +420,10 @@ export const contractInspect = buildTool({
407
420
  const deadline = startDeadline(input.timeoutMs ?? DEFAULT_TIMEOUT_MS, ctx?.signal);
408
421
  try {
409
422
  const rpc = resolveRpc(input.chainId, "ContractInspect");
423
+ const aggregator = resolveAggregator(input.chainId, input.multicall3Address, ctx?.toolConfig, "ContractInspect");
410
424
  const target = await checkAddress(input.address, "address");
411
425
  const { param } = blockOf(input);
412
- const multicall3 = input.multicall3Address ?? MULTICALL3_ADDRESS;
426
+ const multicall3 = aggregator.address;
413
427
  const caveats = [];
414
428
  const code = asData(await rpcRead(rpc, input.chainId, "eth_getCode", [target.address, param], deadline.signal), "eth_getCode");
415
429
  const codeSize = byteLength(code);
@@ -558,6 +572,8 @@ export const contractInspect = buildTool({
558
572
  address: target.address,
559
573
  checksumVerified: target.checksumVerified,
560
574
  block: { requested: param },
575
+ // Who answered the batched view calls, when they were batched.
576
+ aggregator: mode === "multicall3" ? aggregator : null,
561
577
  verified: {
562
578
  isContract: true,
563
579
  codeSize,
@@ -668,7 +684,8 @@ function readProbeBlob(returnData, count) {
668
684
  }
669
685
  export const evmSimulateBundle = buildTool({
670
686
  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.",
687
+ operativeArgs: [{ field: "calls.to", kind: "id", within: "chainId" }],
688
+ description: "Simulate an ordered sequence of calls against a block and report what each would do (status, gas, return data, revert reason, logs) without submitting anything. Use it to check a plan before approving it: an approve then a swap, a multi-step position change, a governance execution. State CHAINS, so each call sees what the one before it did, and that needs eth_simulateV1. When the endpoint lacks it, this falls back to independent eth_calls, says so in `mode`, sets `chained: false`, and OMITS logs and balance deltas rather than report a half-true effects summary a policy gate would trust. A timeout or a rejected parameter is never degraded into that fallback. 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. It simulates only: no key is accepted, nothing is signed, and nothing is broadcast.",
672
689
  inputSchema: z
673
690
  .object({
674
691
  chainId: chainIdField,
@@ -719,7 +736,12 @@ export const evmSimulateBundle = buildTool({
719
736
  try {
720
737
  const rpc = resolveRpc(input.chainId, "EvmSimulateBundle");
721
738
  const { param } = blockOf(input);
722
- const multicall3 = input.multicall3Address ?? MULTICALL3_ADDRESS;
739
+ // Multicall3 reads the tracked balances, and nothing else here; without
740
+ // trackBalances a multicall3Address is ignored, as it always was.
741
+ const aggregator = input.trackBalances === undefined
742
+ ? undefined
743
+ : resolveAggregator(input.chainId, input.multicall3Address, ctx?.toolConfig, "EvmSimulateBundle");
744
+ const multicall3 = aggregator?.address ?? MULTICALL3_ADDRESS;
723
745
  const traceTransfers = input.traceTransfers ?? true;
724
746
  const userCalls = input.calls.map((call, index) => toWireCall(call, index));
725
747
  let probes = [];
@@ -760,7 +782,11 @@ export const evmSimulateBundle = buildTool({
760
782
  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
783
  }
762
784
  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);
785
+ const refusal = firstRefusal(err);
786
+ const one = nodeAnswers(err).length === 1;
787
+ throw new ChainCallError(refusal !== undefined
788
+ ? `EvmSimulateBundle: ${one ? "the node" : "a node"} refused this bundle — ${refusal}. ${one ? "The node" : "That node"} implements eth_simulateV1, so this is an answer about the bundle and not a reason to fall back to eth_call.`
789
+ : `EvmSimulateBundle: no node simulated this bundle — ${rpcErrorText(err)}. The eth_call fallback is used only when every node says it lacks eth_simulateV1, and not every node did, so it was not used. Retry, or check the nodes in the chains block.`, err);
764
790
  }
765
791
  if (input.allowFallback === false) {
766
792
  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);
@@ -775,6 +801,14 @@ export const evmSimulateBundle = buildTool({
775
801
  const inner = tracking ? block.calls.slice(1, -1) : block.calls;
776
802
  const calls = inner.map((call, index) => ({ ...call, index }));
777
803
  const limitations = [];
804
+ const overridden = Object.keys(input.stateOverrides ?? {});
805
+ if (overridden.length > 0) {
806
+ const aggregatorOverridden = aggregator !== undefined &&
807
+ overridden.some((a) => a.toLowerCase() === aggregator.address.toLowerCase());
808
+ limitations.push(`stateOverrides rewrote ${overridden.length} address(es) for this simulation, so every result describes the chain as overridden, not as it is${aggregatorOverridden
809
+ ? ` — including the Multicall3 (${aggregator.address}) that read the tracked balances, so balanceChanges are whatever the override's code returns`
810
+ : ""}.`);
811
+ }
778
812
  if (traceTransfers) {
779
813
  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
814
  }
@@ -801,7 +835,7 @@ export const evmSimulateBundle = buildTool({
801
835
  callCount: calls.length,
802
836
  reverted: calls.filter((c) => c.status === "reverted").length,
803
837
  calls,
804
- ...(balanceChanges !== undefined ? { balanceChanges } : {}),
838
+ ...(balanceChanges !== undefined ? { balanceChanges, aggregator } : {}),
805
839
  ...(limitations.length > 0 ? { limitations } : {}),
806
840
  });
807
841
  }
@@ -958,7 +992,8 @@ async function resolveFallbackBlock(rpc, chainId, block, signal) {
958
992
  const OP_GAS_PRICE_ORACLE = "0x420000000000000000000000000000000000000F";
959
993
  export const gasMarketRead = buildTool({
960
994
  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.",
995
+ operativeArgs: [{ field: "chainId", kind: "id" }],
996
+ 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 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 runs rather than assuming one: a chain with no baseFeePerGas is reported as legacy, a node without eth_feeHistory is reported as such rather than filled in, and a base fee that never moves is flagged rather than trended. The projection uses the block's integer gasUsed and gasLimit, never feeHistory's floating-point ratio, and is compared with the node's own next-block figure, so a non-standard elasticity shows as a disagreement. It does not price rollup L1 data fees, and says so: on an OP-stack chain it checks for the GasPriceOracle predeploy and warns that the unreported cost is often the larger one.",
962
997
  inputSchema: z
963
998
  .object({
964
999
  chainId: chainIdField,
@@ -0,0 +1,24 @@
1
+ /** Who served a batch, as every batched answer reports it. */
2
+ export type Aggregator = {
3
+ /** EIP-55. */
4
+ readonly address: string;
5
+ /** `canonical`: the deterministic deploy. `config`: the operator's tool_config. */
6
+ readonly source: "canonical" | "config";
7
+ };
8
+ /** The spec's `tool_config.chaincall` block. */
9
+ export type ChaincallConfigInput = {
10
+ /** chain id → the Multicall3 deployment batches on that chain go to. */
11
+ readonly multicall3?: Readonly<Record<string, string>>;
12
+ };
13
+ /**
14
+ * Apply `tool_config.chaincall` at boot: the Multicall3 deployments in
15
+ * `multicall3`, each held to the address check (shape and EIP-55 checksum).
16
+ * A block without it leaves every chain on the canonical deployment.
17
+ */
18
+ export declare function registerChaincallConfig(input: ChaincallConfigInput): void;
19
+ /**
20
+ * The aggregator a batch on `chainId` goes to, and where that came from. A
21
+ * `requested` address other than that one is refused: which contract answers
22
+ * every row is not a per-call choice.
23
+ */
24
+ export declare function resolveAggregator(chainId: string, requested: string | undefined, toolConfig: unknown, toolName: string): Aggregator;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Which Multicall3 answers a batch — decided by the operator, never by a call.
3
+ *
4
+ * A batch is ONE `eth_call` to Multicall3, and every row in it — each view
5
+ * call's return data, whether it succeeded, the block number the batch pins —
6
+ * is whatever that contract returns. Any contract can implement `aggregate3`
7
+ * and return rows it made up. So a model-supplied `multicall3Address` was a
8
+ * way to make EvmMulticall report a balance nobody holds as `success: true`,
9
+ * ContractInspect report interfaces a contract never claimed, and
10
+ * EvmSimulateBundle's balance tracking report deltas that never happened.
11
+ *
12
+ * The aggregator is the canonical deployment unless the operator names
13
+ * another for that chain in `tool_config.chaincall.multicall3` (a chain whose
14
+ * Multicall3 is elsewhere, such as zkSync Era). A call may still pass
15
+ * `multicall3Address`, which older callers do, but only the address that
16
+ * would be used anyway is accepted; anything else is refused before a read.
17
+ * This is `@crewhaus/tool-token`'s rule, applied to this package's batches.
18
+ */
19
+ import { MULTICALL3_ADDRESS, parseMulticallMap } from "@crewhaus/tool-onchain";
20
+ import { ChainCallError } from "./rpc";
21
+ /**
22
+ * The operator's Multicall3 deployments by chain id, from
23
+ * `tool_config.chaincall.multicall3`. Only {@link registerChaincallConfig}
24
+ * writes it.
25
+ */
26
+ let configured = new Map();
27
+ function parseChaincallMulticall(input) {
28
+ try {
29
+ return parseMulticallMap(input, "tool_config.chaincall.multicall3");
30
+ }
31
+ catch (err) {
32
+ throw new ChainCallError(err.message);
33
+ }
34
+ }
35
+ /**
36
+ * Apply `tool_config.chaincall` at boot: the Multicall3 deployments in
37
+ * `multicall3`, each held to the address check (shape and EIP-55 checksum).
38
+ * A block without it leaves every chain on the canonical deployment.
39
+ */
40
+ export function registerChaincallConfig(input) {
41
+ const block = (input ?? {});
42
+ configured = parseChaincallMulticall(block["multicall3"]);
43
+ }
44
+ /**
45
+ * The map one call runs under: the serving candidate's own `tool_config`
46
+ * block when it has one (it replaces the boot registration, as every
47
+ * tool_config block does), else the boot registration.
48
+ */
49
+ function mapFor(toolConfig) {
50
+ if (typeof toolConfig === "object" && toolConfig !== null && !Array.isArray(toolConfig)) {
51
+ return parseChaincallMulticall(toolConfig["multicall3"]);
52
+ }
53
+ return configured;
54
+ }
55
+ /**
56
+ * The aggregator a batch on `chainId` goes to, and where that came from. A
57
+ * `requested` address other than that one is refused: which contract answers
58
+ * every row is not a per-call choice.
59
+ */
60
+ export function resolveAggregator(chainId, requested, toolConfig, toolName) {
61
+ const override = mapFor(toolConfig).get(chainId);
62
+ const aggregator = override === undefined
63
+ ? { address: MULTICALL3_ADDRESS, source: "canonical" }
64
+ : { address: override, source: "config" };
65
+ if (requested !== undefined &&
66
+ requested.trim().toLowerCase() !== aggregator.address.toLowerCase()) {
67
+ throw new ChainCallError(`${toolName}: multicall3Address ${JSON.stringify(requested.slice(0, 64))} is not the Multicall3 chain "${chainId}" reads through (${aggregator.address}). The aggregator answers every row of a batch — return data, success, the block number — so which contract that is belongs to the operator: name it in tool_config.chaincall.multicall3 for chain "${chainId}", and leave multicall3Address out.`);
68
+ }
69
+ return aggregator;
70
+ }