@crewhaus/tool-token 0.7.0 → 0.8.1

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
@@ -116,6 +116,16 @@ own `getEthBalance` to stay in that snapshot. `batch: false` reads them
116
116
  one at a time for chains with no Multicall3 deployment, and says in the answer
117
117
  that the result is not a snapshot.
118
118
 
119
+ **Which Multicall3 is the operator's choice.** A batch is one `eth_call`, and
120
+ every answer in it — decimals, balances, the block number — is whatever that
121
+ contract returns, so a contract that fakes `aggregate3` can make a decimals
122
+ mismatch read as verified. Batches go to the canonical deployment
123
+ (`0xcA11bde05977b3631167028862bE2a173976CA11`) unless `tool_config.token.multicall3`
124
+ names another for the chain; a call's `multicall3Address` is accepted only when
125
+ it is that same address, and anything else is refused before any read. Every
126
+ batched answer says who served it: `aggregator: { address, source }`, with
127
+ `source` either `canonical` or `config`.
128
+
119
129
  A sub-call inside that batch is a contract call like any other, and one that
120
130
  does not answer is **not a zero**. A `getEthBalance` that came back empty —
121
131
  an aggregator without the helper, a sub-call out of gas — is reported as
@@ -136,7 +146,34 @@ not promised by this paragraph.
136
146
  ## No dialling code at all
137
147
 
138
148
  There is no RPC client and no HTTP client in this package. Both boundaries are
139
- injected:
149
+ injected, and a compiled bundle, `crewhaus run` and `crewhaus eval` bind them at
150
+ boot from the spec:
151
+
152
+ ```yaml
153
+ chains: # the chain reader
154
+ - id: "1"
155
+ kind: evm
156
+ rpcUrls: [$ETH_RPC_URL] # read from the environment, never compiled in
157
+ finality: { kind: finalized }
158
+
159
+ tool_config:
160
+ token:
161
+ metadata_origins: [https://ipfs.io] # the only origins a tokenURI is read from
162
+ multicall3: # only where the canonical deployment is absent
163
+ "324": "0xF9cda624FBC7e059355ce98a31693d299FACd963"
164
+ ```
165
+
166
+ Without a `chains` block, a chain read refuses and names the block to write.
167
+ These tools name a chain by its EIP-155 id, so declare it under that id in
168
+ decimal (`id: "1"`, `id: "8453"`); a chain declared as `mainnet` is refused
169
+ with the ids the spec does declare.
170
+ Without `metadata_origins`, metadata is not fetched, and the answer says why.
171
+ The metadata fetch goes through `@crewhaus/tool-http`'s gate: https only, the
172
+ SSRF refusal, no redirects, a byte cap. The caller's own `allowedHosts` and
173
+ `ipfsGateway` still decide what is attempted; this list decides what can be
174
+ dialled, and a model cannot widen it.
175
+
176
+ A host that builds its own wiring can still call the seams directly:
140
177
 
141
178
  ```ts
142
179
  import { _setChainReader, chainReaderFromAdapters, _setMetadataFetch } from "@crewhaus/tool-token";
@@ -168,5 +205,6 @@ A tool that cannot say no is not finished. These say no, with the reason:
168
205
  - an `ipfs://` URI that resolves outside the gateway it was given
169
206
  - an `https://` metadata host nobody allow-listed, and any `http://` URI
170
207
  - a metadata document over the byte cap — refused rather than parsed as a prefix
208
+ - a `multicall3Address` other than the canonical deployment or the one the operator configured
171
209
  - a Multicall3 address that answers with something other than results
172
210
  - a block tag that is not one, and a token id that is not a `uint256`
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { RegisteredTool } from "@crewhaus/tool-catalog";
2
2
  export { type ChainRead, type ChainReadMethod, type ChainReader, READ_METHODS, TokenError, _setChainReader, chainReaderFromAdapters, hasChainReader, } from "./lib/chain";
3
+ export { type Aggregator, type TokenConfigInput, bindTokenChains, registerTokenConfig, } from "./lib/boot";
3
4
  export { type MetadataFetch, type MetadataResponse, type UriPlan, DEFAULT_MAX_METADATA_BYTES, _setMetadataFetch, planUri, } from "./lib/uri";
4
5
  export { type Candidate, type TokenList, type TokenListEntry, confusableSkeleton, findCandidates, textConcerns, } from "./lib/lists";
5
6
  export declare const tokenResolve: RegisteredTool;
package/dist/index.js CHANGED
@@ -30,20 +30,25 @@
30
30
  *
31
31
  * There is no RPC client and no HTTP client in this package. Chain reads
32
32
  * leave through `_setChainReader` and metadata fetches through
33
- * `_setMetadataFetch`; the runtime binds both at boot and every test drives
33
+ * `_setMetadataFetch`. A bundle binds them at boot from the spec —
34
+ * `bindTokenChains` from its `chains` block, `registerTokenConfig` from
35
+ * `tool_config.token.metadata_origins` (`lib/boot.ts`) — and every test drives
34
36
  * them from recorded answers, so the suite cannot reach the network even by
35
37
  * accident.
36
38
  */
37
39
  import { createHash } from "node:crypto";
40
+ import { CHAINS_BLOCK_EXAMPLE } from "@crewhaus/chain-adapter-base";
38
41
  import { buildTool } from "@crewhaus/tool-builder";
39
42
  import { MULTICALL3_ADDRESS } from "@crewhaus/tool-onchain";
40
43
  import { z } from "zod";
44
+ import { resolveAggregator } from "./lib/boot";
41
45
  import { TokenError, hasChainReader, nativeBalanceCall, readCalls, readHasCode, readNativeBalance, } from "./lib/chain";
42
46
  import { INTERFACE_ID, SELECTOR, addressWord, bytes4Word, callData, decodeAddressValue, decodeBool, decodeStringish, decodeUint, padTokenIdHex, uintWord, } from "./lib/erc";
43
47
  import { ADDRESS_SHAPE, confusableSkeleton, findCandidates, fingerprintLists, textConcerns, } from "./lib/lists";
44
48
  import { MAX_FORMATTABLE_DECIMALS, checkAddress, formatUnits } from "./lib/onchain";
45
49
  import { DEFAULT_MAX_METADATA_BYTES, decodeDataUri, fetchDocument, planUri, referencedUrls, } from "./lib/uri";
46
50
  export { READ_METHODS, TokenError, _setChainReader, chainReaderFromAdapters, hasChainReader, } from "./lib/chain";
51
+ export { bindTokenChains, registerTokenConfig, } from "./lib/boot";
47
52
  export { DEFAULT_MAX_METADATA_BYTES, _setMetadataFetch, planUri, } from "./lib/uri";
48
53
  export { confusableSkeleton, findCandidates, textConcerns, } from "./lib/lists";
49
54
  const json = (value) => JSON.stringify(value);
@@ -69,7 +74,7 @@ const batchField = z
69
74
  const multicallField = z
70
75
  .string()
71
76
  .optional()
72
- .describe(`Multicall3's address on this chain; default ${MULTICALL3_ADDRESS}`);
77
+ .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`);
73
78
  // ---------------------------------------------------------------------------
74
79
  // shared helpers
75
80
  // ---------------------------------------------------------------------------
@@ -298,6 +303,7 @@ const CONCERNING_FLAGS = new Set([
298
303
  ]);
299
304
  export const tokenResolve = buildTool({
300
305
  name: "TokenResolve",
306
+ operativeArgs: [{ field: "query", kind: "text", within: "chainId" }],
301
307
  description: "Turn a token symbol, name or address into ONE checksummed address, confirmed against the contract itself — or refuse and show you why. Use it before any transfer, balance read or approval, because a symbol is not an identifier: nothing stops a second contract calling itself USDC with six decimals and a convincing name, and reputable token lists carry different addresses for the same ticker. When a query could mean more than one address this returns EVERY candidate with resolved:false rather than the first hit or a most-likely, because picking between them is a decision with a wrong answer that costs money. Symbols that differ only by a Cyrillic lookalike or a zero-width space are pulled into the same candidate set on purpose, so an impostor a literal match would miss becomes an ambiguity you have to look at. The contract's own decimals and symbol are compared against what the lists claim, and a decimals mismatch is a refusal rather than a footnote.",
302
308
  inputSchema: z
303
309
  .object({
@@ -342,7 +348,7 @@ export const tokenResolve = buildTool({
342
348
  // through whatever the runtime bound to the seam.
343
349
  scope: "external",
344
350
  ioCapability: "network",
345
- execute: async (input) => {
351
+ execute: async (input, ctx) => {
346
352
  const query = input.query.trim();
347
353
  const lists = (input.lists ?? []);
348
354
  const policy = input.policy ?? "listed-only";
@@ -413,12 +419,17 @@ export const tokenResolve = buildTool({
413
419
  const wantsOnchain = input.confirmOnchain ?? true;
414
420
  if (wantsOnchain && !hasChainReader()) {
415
421
  if (input.confirmOnchain === true) {
416
- throw new TokenError("confirmOnchain was asked for but no chain reader is bound; bind one with _setChainReader() or pass confirmOnchain:false to answer from the lists alone");
422
+ throw new TokenError(`confirmOnchain was asked for but no chain is configured. Declare one in the spec — ${CHAINS_BLOCK_EXAMPLE} — or pass confirmOnchain:false to answer from the lists alone`);
417
423
  }
418
424
  }
419
425
  const confirm = wantsOnchain && hasChainReader();
420
426
  const facts = new Map();
421
427
  let blockNumber = null;
428
+ // Resolved before any read: an aggregator the operator did not name is
429
+ // refused, not asked.
430
+ const aggregator = confirm && (input.batch ?? true)
431
+ ? resolveAggregator(input.chainId, input.multicall3Address, ctx?.toolConfig)
432
+ : null;
422
433
  if (confirm) {
423
434
  const calls = [];
424
435
  for (const [i, candidate] of candidates.entries()) {
@@ -428,10 +439,8 @@ export const tokenResolve = buildTool({
428
439
  chainId: input.chainId,
429
440
  calls,
430
441
  blockTag,
431
- batch: input.batch ?? true,
432
- ...(input.multicall3Address !== undefined
433
- ? { multicall3Address: input.multicall3Address }
434
- : {}),
442
+ batch: aggregator !== null,
443
+ ...(aggregator !== null ? { multicall3Address: aggregator.address } : {}),
435
444
  });
436
445
  blockNumber = read.blockNumber;
437
446
  for (const [i, candidate] of candidates.entries()) {
@@ -453,7 +462,14 @@ export const tokenResolve = buildTool({
453
462
  flags: flagsFor(candidate, onchain),
454
463
  });
455
464
  }
456
- const shared = { ...base, blockNumber, confirmedOnchain: confirm, candidates: flagged };
465
+ const shared = {
466
+ ...base,
467
+ blockNumber,
468
+ confirmedOnchain: confirm,
469
+ // Who answered the batched reads, since every one of them is its word.
470
+ aggregator,
471
+ candidates: flagged,
472
+ };
457
473
  if (flagged.length > 1) {
458
474
  return json({
459
475
  ...shared,
@@ -521,6 +537,7 @@ export const tokenResolve = buildTool({
521
537
  // ---------------------------------------------------------------------------
522
538
  export const erc20Balance = buildTool({
523
539
  name: "Erc20Balance",
540
+ operativeArgs: [{ field: "token", kind: "id", within: "chainId" }],
524
541
  description: "Read ERC-20 balances, and optionally an allowance, for one or many accounts at one block — with the token's decimals taken from the CONTRACT, never from a list and never defaulted. Use it instead of a raw eth_call: every amount comes back both as exact base units and as a decimal string, so a uint256 never becomes a JS number and eighteen digits of precision never quietly become fifteen. A token whose decimals() reverts is reported as unknown and its balances stay in base units rather than being scaled by an assumed 18, which is how a six-decimal transfer becomes a trillion-fold one. Tokens that answer symbol() with a bytes32 instead of a string — MKR and other 2017-era contracts — are decoded rather than crashed on, and an address with no code at it is refused instead of reporting everyone's balance as zero. Pass \"native\" as the token to read the chain's own currency in the same snapshot. It takes a contract ADDRESS, not a symbol: use TokenResolve first.",
525
542
  inputSchema: z
526
543
  .object({
@@ -551,10 +568,13 @@ export const erc20Balance = buildTool({
551
568
  concurrencySafe: true,
552
569
  scope: "external",
553
570
  ioCapability: "network",
554
- execute: async (input) => {
571
+ execute: async (input, ctx) => {
555
572
  const blockTag = input.blockTag ?? "latest";
556
573
  const batch = input.batch ?? true;
557
- const multicall = input.multicall3Address ?? MULTICALL3_ADDRESS;
574
+ const aggregator = batch
575
+ ? resolveAggregator(input.chainId, input.multicall3Address, ctx?.toolConfig)
576
+ : null;
577
+ const multicall = aggregator?.address ?? MULTICALL3_ADDRESS;
558
578
  const isNative = input.token.trim().toLowerCase() === "native";
559
579
  if (!isNative && !ADDRESS_SHAPE.test(input.token.trim())) {
560
580
  throw new TokenError(`Erc20Balance takes a token contract address, not "${input.token}". A symbol is not an identifier — several contracts can claim the same one — so resolve it with TokenResolve first, which returns every candidate when a symbol is ambiguous rather than picking one.`);
@@ -627,6 +647,7 @@ export const erc20Balance = buildTool({
627
647
  blockTag,
628
648
  blockNumber,
629
649
  batched: batch,
650
+ aggregator,
630
651
  token: { kind: "native", address: null, decimals },
631
652
  balances,
632
653
  warnings,
@@ -701,6 +722,7 @@ export const erc20Balance = buildTool({
701
722
  blockTag,
702
723
  blockNumber: read.blockNumber,
703
724
  batched: read.batched,
725
+ aggregator,
704
726
  token: {
705
727
  kind: "erc20",
706
728
  address: token,
@@ -728,6 +750,10 @@ const INTERFACE_KEYS = {
728
750
  };
729
751
  export const erc721TokenInfo = buildTool({
730
752
  name: "Erc721TokenInfo",
753
+ operativeArgs: [
754
+ { field: "contract", kind: "id", within: "chainId" },
755
+ { field: "ipfsGateway", kind: "url" },
756
+ ],
731
757
  description: "Read one NFT: which standard the contract actually implements, the collection's name and symbol, who owns the token (or an ERC-1155 holder's balance of it), and its tokenURI. Use it to check what an NFT is before buying, listing or transferring it. Metadata is read ONLY when it costs no trust: a data: URI is decoded in-process, an ipfs: URI is fetched through a gateway YOU named, and an https: URI only from a host YOU allow-listed — a URL that came out of contract data is not a reason to dial it, and following one is a server-side request forgery with extra steps. The answer lists every URI it read and every one it skipped, with the reason. ERC-1155's {id} placeholder is substituted with the 64-hex-digit zero-padded form the spec requires and almost everyone gets wrong, ERC-1155 has no ownerOf so ownership is answered as a balance or not at all rather than as a misleading null, and a contract that claims to support the invalid interface id is reported as one whose supportsInterface answers mean nothing.",
732
758
  inputSchema: z
733
759
  .object({
@@ -792,6 +818,9 @@ export const erc721TokenInfo = buildTool({
792
818
  catch {
793
819
  throw new TokenError(`"${input.tokenId}" is not a token id; give a decimal or 0x hex uint256 as a string`);
794
820
  }
821
+ const aggregator = (input.batch ?? true)
822
+ ? resolveAggregator(input.chainId, input.multicall3Address, ctx?.toolConfig)
823
+ : null;
795
824
  const code = (input.checkCode ?? true) ? await readHasCode(input.chainId, contract, blockTag) : null;
796
825
  if (code !== null && !code.hasCode) {
797
826
  throw new TokenError(`there is no contract at ${contract} at block ${blockTag} — every call to it answers 0x, so an NFT read against it would report no owner and no URI rather than saying this is not a contract`);
@@ -826,10 +855,8 @@ export const erc721TokenInfo = buildTool({
826
855
  chainId: input.chainId,
827
856
  calls,
828
857
  blockTag,
829
- batch: input.batch ?? true,
830
- ...(input.multicall3Address !== undefined
831
- ? { multicall3Address: input.multicall3Address }
832
- : {}),
858
+ batch: aggregator !== null,
859
+ ...(aggregator !== null ? { multicall3Address: aggregator.address } : {}),
833
860
  });
834
861
  const says = (key) => {
835
862
  const outcome = read.outcomes.get(key);
@@ -1013,6 +1040,7 @@ export const erc721TokenInfo = buildTool({
1013
1040
  blockTag,
1014
1041
  blockNumber: read.blockNumber,
1015
1042
  batched: read.batched,
1043
+ aggregator,
1016
1044
  standard,
1017
1045
  standardSource,
1018
1046
  interfaces,
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The two boot bindings this package's seams get in a compiled bundle,
3
+ * `crewhaus run` and `crewhaus eval` — each from a spec block, each failing
4
+ * closed without one.
5
+ *
6
+ * - {@link bindTokenChains}: the chain reader, from the spec's `chains`
7
+ * block, through the same adapters `tool-evm` reads with.
8
+ * - {@link registerTokenConfig}: the metadata fetcher, from
9
+ * `tool_config.token.metadata_origins`, the operator's list of origins a
10
+ * contract's `tokenURI` may be read from. The caller's own `allowedHosts`
11
+ * and `ipfsGateway` still decide what is attempted; this list decides
12
+ * what can be dialled, and a model cannot widen it. And the Multicall3
13
+ * deployments in `tool_config.token.multicall3`, below.
14
+ *
15
+ * Still no dialling code here: the fetch goes through `@crewhaus/tool-http`'s
16
+ * gate (allow-list, SSRF, no redirects, byte cap), and the chain read through
17
+ * the adapter.
18
+ */
19
+ import type { ChainAdapterConfig } from "@crewhaus/chain-adapter-base";
20
+ /** Bind the chain reader from the spec's `chains` block. */
21
+ export declare function bindTokenChains(config: {
22
+ readonly chains: ReadonlyArray<ChainAdapterConfig>;
23
+ }): void;
24
+ /** The spec's `tool_config.token` block. */
25
+ export type TokenConfigInput = {
26
+ readonly metadata_origins?: ReadonlyArray<string>;
27
+ readonly metadataOrigins?: ReadonlyArray<string>;
28
+ /** chain id → the Multicall3 deployment batches on that chain go to. */
29
+ readonly multicall3?: Readonly<Record<string, string>>;
30
+ };
31
+ /**
32
+ * Apply `tool_config.token`: the Multicall3 deployments in `multicall3`, and
33
+ * the metadata fetcher from `metadata_origins`. Only https origins are
34
+ * accepted: a metadata document read over plain http is whatever the network
35
+ * says it is. A block without the list binds no fetcher.
36
+ */
37
+ export declare function registerTokenConfig(input: TokenConfigInput): void;
38
+ /**
39
+ * Which contract answers a batched read — decided by the operator, never by
40
+ * a call.
41
+ *
42
+ * A batch is ONE `eth_call` to Multicall3, and every sub-result — decimals,
43
+ * symbol, balances, allowances, `ownerOf`, the block number — is whatever
44
+ * that contract returns. Any contract can implement `aggregate3` and return
45
+ * rows it made up. So a model-supplied aggregator address was a way to make
46
+ * TokenResolve report a decimals mismatch as `verified: true`, and
47
+ * Erc20Balance report balances nobody holds.
48
+ *
49
+ * The aggregator is the canonical deployment unless the operator names
50
+ * another for that chain in `tool_config.token.multicall3` (a chain whose
51
+ * Multicall3 is elsewhere, such as zkSync Era). A call may still pass
52
+ * `multicall3Address`, which older callers do, but only the address that
53
+ * would be used anyway is accepted; anything else is refused before a read.
54
+ */
55
+ /** Who served a batch, as every batched answer reports it. */
56
+ export type Aggregator = {
57
+ /** EIP-55. */
58
+ readonly address: string;
59
+ /** `canonical`: the deterministic deploy. `config`: the operator's tool_config. */
60
+ readonly source: "canonical" | "config";
61
+ };
62
+ /**
63
+ * The aggregator a batch on `chainId` goes to, and where that came from. A
64
+ * `requested` address other than that one is refused: which contract answers
65
+ * every read is not a per-call choice.
66
+ */
67
+ export declare function resolveAggregator(chainId: number, requested: string | undefined, toolConfig: unknown): Aggregator;
@@ -0,0 +1,97 @@
1
+ import { createEvmAdapters } from "@crewhaus/chain-adapter-evm";
2
+ import { guardedGet } from "@crewhaus/tool-http";
3
+ import { MULTICALL3_ADDRESS, parseMulticallMap } from "@crewhaus/tool-onchain";
4
+ import { TokenError, _setChainReader, chainReaderFromAdapters } from "./chain";
5
+ import { _setMetadataFetch } from "./uri";
6
+ /**
7
+ * The operator's Multicall3 deployments by chain id, from
8
+ * `tool_config.token.multicall3`. Only {@link registerTokenConfig} writes it.
9
+ */
10
+ let configured = new Map();
11
+ /** A metadata document is small; a slow host should not hold a call open for long. */
12
+ const METADATA_TIMEOUT_MS = 10_000;
13
+ /** Bind the chain reader from the spec's `chains` block. */
14
+ export function bindTokenChains(config) {
15
+ const adapters = createEvmAdapters(config.chains);
16
+ _setChainReader(chainReaderFromAdapters((chainId) => adapters.get(chainId), [...adapters.keys()]));
17
+ }
18
+ /**
19
+ * Apply `tool_config.token`: the Multicall3 deployments in `multicall3`, and
20
+ * the metadata fetcher from `metadata_origins`. Only https origins are
21
+ * accepted: a metadata document read over plain http is whatever the network
22
+ * says it is. A block without the list binds no fetcher.
23
+ */
24
+ export function registerTokenConfig(input) {
25
+ const block = (input ?? {});
26
+ configured = parseTokenMulticall(block["multicall3"]);
27
+ if (Object.hasOwn(block, "metadata_origins") && Object.hasOwn(block, "metadataOrigins")) {
28
+ throw new TokenError("tool_config.token sets both metadata_origins and metadataOrigins. Write the list once, as metadata_origins.");
29
+ }
30
+ const raw = block["metadata_origins"] ?? block["metadataOrigins"];
31
+ if (raw === undefined)
32
+ return;
33
+ if (!Array.isArray(raw) || raw.some((o) => typeof o !== "string")) {
34
+ throw new TokenError('tool_config.token.metadata_origins must be a list of https origins, for example ["https://ipfs.io"].');
35
+ }
36
+ const origins = [];
37
+ for (const origin of raw) {
38
+ let url;
39
+ try {
40
+ url = new URL(origin);
41
+ }
42
+ catch {
43
+ throw new TokenError(`tool_config.token.metadata_origins has "${origin}", which is not an origin. Write it as https://host.`);
44
+ }
45
+ if (url.protocol !== "https:") {
46
+ throw new TokenError(`tool_config.token.metadata_origins has "${url.protocol}//${url.host}", which is not https. Metadata is read over https only.`);
47
+ }
48
+ origins.push(url.origin);
49
+ }
50
+ _setMetadataFetch(async ({ url, maxBytes, signal }) => {
51
+ // One byte over the cap, so a document that fills it exactly is told
52
+ // apart from one that was cut — `fetchDocument` refuses the latter.
53
+ const got = await guardedGet(url, {
54
+ allowedOrigins: origins,
55
+ maxBytes: maxBytes + 1,
56
+ timeoutMs: METADATA_TIMEOUT_MS,
57
+ ...(signal !== undefined ? { signal } : {}),
58
+ });
59
+ return { status: got.status, contentType: got.contentType, bytes: got.bytes };
60
+ });
61
+ }
62
+ /** Parse `tool_config.token.multicall3`, refusing a malformed entry by its key. */
63
+ function parseTokenMulticall(input) {
64
+ try {
65
+ return parseMulticallMap(input, "tool_config.token.multicall3");
66
+ }
67
+ catch (err) {
68
+ throw new TokenError(err.message);
69
+ }
70
+ }
71
+ /**
72
+ * The map one call runs under: the serving candidate's own `tool_config`
73
+ * block when it has one (it replaces the boot registration, as every
74
+ * tool_config block does), else the boot registration.
75
+ */
76
+ function mapFor(toolConfig) {
77
+ if (typeof toolConfig === "object" && toolConfig !== null && !Array.isArray(toolConfig)) {
78
+ return parseTokenMulticall(toolConfig["multicall3"]);
79
+ }
80
+ return configured;
81
+ }
82
+ /**
83
+ * The aggregator a batch on `chainId` goes to, and where that came from. A
84
+ * `requested` address other than that one is refused: which contract answers
85
+ * every read is not a per-call choice.
86
+ */
87
+ export function resolveAggregator(chainId, requested, toolConfig) {
88
+ const override = mapFor(toolConfig).get(String(chainId));
89
+ const aggregator = override === undefined
90
+ ? { address: MULTICALL3_ADDRESS, source: "canonical" }
91
+ : { address: override, source: "config" };
92
+ if (requested !== undefined &&
93
+ requested.trim().toLowerCase() !== aggregator.address.toLowerCase()) {
94
+ throw new TokenError(`multicall3Address ${JSON.stringify(requested.slice(0, 64))} is not the Multicall3 chain ${chainId} reads through (${aggregator.address}). The aggregator answers every read in a batch — decimals, balances, the block number — so which contract that is belongs to the operator: name it in tool_config.token.multicall3 for chain ${chainId}. Or pass batch:false to read each call directly.`);
95
+ }
96
+ return aggregator;
97
+ }
@@ -22,7 +22,7 @@
22
22
  * blocks. `getBlockNumber()` rides along in the same batch so the answer says
23
23
  * which block it is a snapshot of.
24
24
  */
25
- import type { ChainAdapter } from "@crewhaus/chain-adapter-base";
25
+ import { type ChainAdapter } from "@crewhaus/chain-adapter-base";
26
26
  import { CrewhausError } from "@crewhaus/errors";
27
27
  import { type RevertReason } from "@crewhaus/tool-onchain";
28
28
  /** A refusal this package makes: a bad input, a missing seam, an unreadable answer. */
@@ -59,7 +59,7 @@ export declare function assertReadMethod(method: string): asserts method is Chai
59
59
  * bundle has. The adapter applies its own read-only allowlist underneath, so
60
60
  * a write method would be refused twice.
61
61
  */
62
- export declare function chainReaderFromAdapters(resolve: (chainId: string) => ChainAdapter | undefined): ChainReader;
62
+ export declare function chainReaderFromAdapters(resolve: (chainId: string) => ChainAdapter | undefined, declared?: ReadonlyArray<string>): ChainReader;
63
63
  /** One call in a read, keyed so results are never matched up by position here. */
64
64
  export type BatchCall = {
65
65
  /** The caller's own label. Results come back in a map under it. */
package/dist/lib/chain.js CHANGED
@@ -1,3 +1,28 @@
1
+ /**
2
+ * The one way this package learns anything from a chain — and the reason it
3
+ * is the only way.
4
+ *
5
+ * **There is no dialling code in `@crewhaus/tool-token`.** No URL, no fetch,
6
+ * no socket. Every chain read leaves through `_setChainReader`, which the
7
+ * runtime binds at boot and every test in this package drives from recorded
8
+ * answers. Two things follow, and both are the point:
9
+ *
10
+ * 1. A test cannot reach a public RPC endpoint even by accident. There is
11
+ * nothing under the seam to reach it with. A suite that dialled a real
12
+ * node would fail on a CI runner with no egress and flake on somebody
13
+ * else's rate limit.
14
+ * 2. The set of JSON-RPC methods this package can emit is three, they are
15
+ * listed below, and {@link assertReadMethod} refuses anything else
16
+ * before it reaches the reader. `eth_sendTransaction` and
17
+ * `eth_sendRawTransaction` are not reachable from any code path here;
18
+ * `index.test.ts` asserts it.
19
+ *
20
+ * Reads are batched through Multicall3 by default, because a hundred balances
21
+ * read one at a time are a hundred balances from up to a hundred different
22
+ * blocks. `getBlockNumber()` rides along in the same batch so the answer says
23
+ * which block it is a snapshot of.
24
+ */
25
+ import { CHAINS_BLOCK_EXAMPLE } from "@crewhaus/chain-adapter-base";
1
26
  import { CrewhausError } from "@crewhaus/errors";
2
27
  import { MULTICALL3_ADDRESS, decodeAggregate3, encodeAggregate3, } from "@crewhaus/tool-onchain";
3
28
  import { SELECTOR, addressWord, callData, decodeUint } from "./erc";
@@ -15,7 +40,7 @@ export class TokenError extends CrewhausError {
15
40
  */
16
41
  export const READ_METHODS = Object.freeze(["eth_call", "eth_getBalance", "eth_getCode"]);
17
42
  const unbound = async (read) => {
18
- throw new TokenError(`no chain reader is bound, so chain ${read.chainId} cannot be read. This package contains no RPC client of its own: the runtime binds one with _setChainReader(), and chainReaderFromAdapters() wires it from the spec's chains[] block in one line.`);
43
+ throw new TokenError(`no chain is configured, so chain ${read.chainId} cannot be read. Declare it in the spec — ${CHAINS_BLOCK_EXAMPLE}.`);
19
44
  };
20
45
  let reader = unbound;
21
46
  /**
@@ -41,12 +66,18 @@ export function assertReadMethod(method) {
41
66
  * bundle has. The adapter applies its own read-only allowlist underneath, so
42
67
  * a write method would be refused twice.
43
68
  */
44
- export function chainReaderFromAdapters(resolve) {
69
+ export function chainReaderFromAdapters(resolve, declared) {
45
70
  return async (read) => {
46
71
  assertReadMethod(read.method);
47
72
  const adapter = resolve(String(read.chainId));
48
73
  if (adapter === undefined) {
49
- throw new TokenError(`no chain adapter is registered for chain ${read.chainId}; declare it in the spec's chains[] block`);
74
+ // These tools name a chain by its EIP-155 id, and look it up as that
75
+ // number written in decimal. A chain declared as "mainnet" is there,
76
+ // just not under the id asked for — so say which ids ARE declared.
77
+ const known = declared === undefined || declared.length === 0
78
+ ? ""
79
+ : ` The spec declares ${declared.map((id) => `"${id}"`).join(", ")}; the token tools look a chain up by its EIP-155 chain id in decimal, so declare it as id: "${read.chainId}".`;
80
+ throw new TokenError(`no chain is declared with id "${read.chainId}", so chain ${read.chainId} cannot be read.${known === "" ? " Declare it in the spec's chains[] block." : known}`);
50
81
  }
51
82
  return adapter.rpcRead(read.method, read.params);
52
83
  };
@@ -143,7 +174,7 @@ export async function readCalls(opts) {
143
174
  rows = decodeAggregate3(asHex(answer, "the Multicall3 batch"), request.callCount);
144
175
  }
145
176
  catch (err) {
146
- throw new TokenError(`the Multicall3 batch at ${request.to} did not answer with results: ${err.message}. If this chain has no Multicall3 deployment at that address, pass multicall3Address, or batch:false to read the calls one at a time.`);
177
+ throw new TokenError(`the Multicall3 batch at ${request.to} did not answer with results: ${err.message}. If this chain's Multicall3 is deployed elsewhere, the operator names it in tool_config.token.multicall3; or pass batch:false to read the calls one at a time.`);
147
178
  }
148
179
  for (const [i, call] of packed.entries()) {
149
180
  const row = rows[i];
package/dist/lib/uri.js CHANGED
@@ -27,7 +27,7 @@
27
27
  */
28
28
  import { Buffer } from "node:buffer";
29
29
  const unbound = async (req) => {
30
- throw new Error(`no metadata fetcher is bound, so ${req.url} was not read. This package contains no HTTP client of its own: the runtime binds one with _setMetadataFetch(), which is also where redirect, SSRF and timeout policy belongs.`);
30
+ throw new Error(`no metadata origin is allowed, so ${req.url} was not read. Allow the origin in the spec — tool_config.token: { metadata_origins: [https://ipfs.io] }.`);
31
31
  };
32
32
  let metadataFetch = unbound;
33
33
  /** Bind the metadata fetcher. `undefined` restores the unbound state. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/tool-token",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "type": "module",
5
5
  "description": "Deterministic token and address resolution: ERC-20 and ERC-721 reads with impostor flagging",
6
6
  "main": "dist/index.js",
@@ -8,19 +8,22 @@
8
8
  "exports": {
9
9
  ".": {
10
10
  "types": "./dist/index.d.ts",
11
- "import": "./dist/index.js"
11
+ "import": "./dist/index.js",
12
+ "default": "./dist/index.js"
12
13
  }
13
14
  },
14
15
  "scripts": {
15
16
  "test": "bun test src"
16
17
  },
17
18
  "dependencies": {
18
- "@crewhaus/chain-adapter-base": "0.7.0",
19
- "@crewhaus/errors": "0.7.0",
20
- "@crewhaus/tool-builder": "0.7.0",
21
- "@crewhaus/tool-catalog": "0.7.0",
22
- "@crewhaus/tool-onchain": "0.7.0",
23
- "@crewhaus/tool-text": "0.7.0",
19
+ "@crewhaus/chain-adapter-base": "0.8.1",
20
+ "@crewhaus/chain-adapter-evm": "0.8.1",
21
+ "@crewhaus/errors": "0.8.1",
22
+ "@crewhaus/tool-builder": "0.8.1",
23
+ "@crewhaus/tool-catalog": "0.8.1",
24
+ "@crewhaus/tool-http": "0.8.1",
25
+ "@crewhaus/tool-onchain": "0.8.1",
26
+ "@crewhaus/tool-text": "0.8.1",
24
27
  "zod": "^3.23.8"
25
28
  },
26
29
  "devDependencies": {
@@ -44,5 +47,8 @@
44
47
  "publishConfig": {
45
48
  "access": "public"
46
49
  },
47
- "files": ["dist", "README.md", "LICENSE", "NOTICE"]
50
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"],
51
+ "engines": {
52
+ "bun": ">=1.3.14"
53
+ }
48
54
  }
@@ -1,117 +0,0 @@
1
- import type { ChainRead, ChainReader } from "./lib/chain";
2
- /** A 32-byte word from an unsigned integer. */
3
- export declare const word: (value: bigint) => string;
4
- export declare const encodeUint: (value: bigint) => string;
5
- export declare const encodeAddress: (address: string) => string;
6
- export declare const encodeBool: (value: boolean) => string;
7
- /** A dynamic `string` return: offset, length, then the bytes, padded. */
8
- export declare function encodeString(text: string): string;
9
- /** The pre-standard `bytes32` symbol: the text, left-aligned, NUL-padded. */
10
- export declare function encodeBytes32String(text: string): string;
11
- /** `Error(string)` revert data — the selector is the one Solidity emits. */
12
- export declare function revertWith(message: string): Promise<string>;
13
- /** What one contract answers for one call. A revert is an answer, not a throw. */
14
- export type CallAnswer = {
15
- readonly ok: boolean;
16
- readonly data: string;
17
- };
18
- export declare const answers: (data: string) => CallAnswer;
19
- export declare const reverts: (revertData: string) => CallAnswer;
20
- /** What a contract without that function returns: nothing at all. */
21
- export declare const NOTHING: CallAnswer;
22
- export type ContractHandler = (selector: string, args: string) => CallAnswer;
23
- export declare const USDC = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
24
- export declare const IMPOSTOR_USDC = "0x1111111111111111111111111111111111111111";
25
- export declare const CYRILLIC_USDC = "0x2222222222222222222222222222222222222222";
26
- export declare const MKR = "0x9f8f72aa9304c8b593d555f12ef6589cc3a579a2";
27
- export declare const BROKEN = "0x3333333333333333333333333333333333333333";
28
- export declare const NOT_A_CONTRACT = "0x4444444444444444444444444444444444444444";
29
- export declare const APE_NFT = "0x5555555555555555555555555555555555555555";
30
- export declare const GAME_1155 = "0x6666666666666666666666666666666666666666";
31
- export declare const ONCHAIN_NFT = "0x7777777777777777777777777777777777777777";
32
- export declare const LIAR_165 = "0x8888888888888888888888888888888888888888";
33
- export declare const ALICE = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045";
34
- export declare const BOB = "0x388C818CA8B9251b393131C08a736A67ccB19297";
35
- export declare const SPENDER = "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45";
36
- /** A plain ERC-20 whose metadata is all well-formed strings. */
37
- export declare function erc20(opts: {
38
- decimals: bigint;
39
- symbol: string;
40
- name: string;
41
- totalSupply: bigint;
42
- balances?: Record<string, bigint>;
43
- allowances?: Record<string, bigint>;
44
- bytes32Metadata?: boolean;
45
- }): ContractHandler;
46
- /** An ERC-721 that declares itself properly through ERC-165. */
47
- export declare function erc721(opts: {
48
- name: string;
49
- symbol: string;
50
- owners: Record<string, string>;
51
- tokenUris: Record<string, string>;
52
- missingTokenRevert: string;
53
- interfaceIds?: ReadonlyArray<string>;
54
- answersEverything?: boolean;
55
- }): ContractHandler;
56
- /** An ERC-1155, which has no ownerOf and whose uri carries the {id} template. */
57
- export declare function erc1155(opts: {
58
- uriTemplate: string;
59
- balances: Record<string, bigint>;
60
- interfaceIds?: ReadonlyArray<string>;
61
- }): ContractHandler;
62
- export type ChainStub = {
63
- readonly reader: ChainReader;
64
- /** Every read that left the package, in order, for assertions about what was asked. */
65
- readonly reads: ChainRead[];
66
- };
67
- export declare function chainStub(opts: {
68
- readonly contracts: Record<string, ContractHandler>;
69
- readonly nativeBalances?: Record<string, bigint>;
70
- readonly code?: Record<string, string>;
71
- readonly blockNumber?: bigint;
72
- readonly multicall3Address?: string;
73
- /** Answer the batch call itself with this instead, to test an unusable answer. */
74
- readonly brokenBatchAnswer?: string;
75
- /**
76
- * A Multicall3-shaped contract that implements `aggregate3` and nothing
77
- * else, so `getEthBalance` and `getBlockNumber` reach its fallback and
78
- * answer with no data. Real: `multicall3Address` is a caller-supplied
79
- * address, and an aggregator that is not the canonical deployment has no
80
- * obligation to carry the helpers.
81
- */
82
- readonly withoutMulticallHelpers?: boolean;
83
- }): ChainStub;
84
- export declare const UNISWAP_LIST: {
85
- id: string;
86
- name: string;
87
- tokens: {
88
- chainId: number;
89
- address: string;
90
- symbol: string;
91
- name: string;
92
- decimals: number;
93
- }[];
94
- };
95
- /** A second reputable list that happens to carry a different address for USDC. */
96
- export declare const RIVAL_LIST: {
97
- id: string;
98
- name: string;
99
- tokens: {
100
- chainId: number;
101
- address: string;
102
- symbol: string;
103
- name: string;
104
- decimals: number;
105
- }[];
106
- };
107
- /** The Cyrillic \u0421 impostor. It is not the same string, and it looks identical. */
108
- export declare const CYRILLIC_LIST: {
109
- id: string;
110
- tokens: {
111
- chainId: number;
112
- address: string;
113
- symbol: string;
114
- name: string;
115
- decimals: number;
116
- }[];
117
- };
package/dist/fixtures.js DELETED
@@ -1,222 +0,0 @@
1
- /**
2
- * Recorded chain answers, and a reader stub that serves them.
3
- *
4
- * Every test in this package drives `_setChainReader` with one of these. None
5
- * of them opens a socket, because there is nothing under the seam to open one
6
- * with — a suite that reached a public RPC endpoint would fail on a CI runner
7
- * with no egress and flake on somebody else's rate limit.
8
- *
9
- * The tokens here are the ones that break naive readers, and they are
10
- * modelled on real contracts rather than invented:
11
- *
12
- * - a six-decimal stablecoin, the ordinary case;
13
- * - a `bytes32` symbol, which is what MKR and other 2017-era tokens return
14
- * because the ABI was not settled when they deployed;
15
- * - a token whose `decimals()` reverts, which must come back as unknown and
16
- * never as 18;
17
- * - two impostors claiming a listed ticker, one of them with a Cyrillic \u0421;
18
- * - an address with no code at all.
19
- *
20
- * The Multicall3 stub does a real ABI round trip through `tool-onchain`'s
21
- * codec rather than a hand-written fake of one: it decodes the batch this
22
- * package encoded and encodes the `(bool,bytes)[]` the package decodes. A
23
- * fixture that faked the encoding would pass against a broken encoder.
24
- */
25
- import { MULTICALL3_ADDRESS, abiDecode, abiEncodeCall } from "@crewhaus/tool-onchain";
26
- import { SELECTOR } from "./lib/erc";
27
- // ─── encoding helpers ───────────────────────────────────────────────────────
28
- const hexBody = (value) => value.replace(/^0x/, "");
29
- /** A 32-byte word from an unsigned integer. */
30
- export const word = (value) => value.toString(16).padStart(64, "0");
31
- export const encodeUint = (value) => `0x${word(value)}`;
32
- export const encodeAddress = (address) => `0x${hexBody(address).toLowerCase().padStart(64, "0")}`;
33
- export const encodeBool = (value) => encodeUint(value ? 1n : 0n);
34
- /** A dynamic `string` return: offset, length, then the bytes, padded. */
35
- export function encodeString(text) {
36
- const bytes = new TextEncoder().encode(text);
37
- let data = "";
38
- for (const b of bytes)
39
- data += b.toString(16).padStart(2, "0");
40
- const padded = data.padEnd(Math.ceil(bytes.length / 32) * 64, "0");
41
- return `0x${word(32n)}${word(BigInt(bytes.length))}${padded}`;
42
- }
43
- /** The pre-standard `bytes32` symbol: the text, left-aligned, NUL-padded. */
44
- export function encodeBytes32String(text) {
45
- const bytes = new TextEncoder().encode(text);
46
- if (bytes.length > 32)
47
- throw new Error(`"${text}" does not fit in a bytes32`);
48
- let data = "";
49
- for (const b of bytes)
50
- data += b.toString(16).padStart(2, "0");
51
- return `0x${data.padEnd(64, "0")}`;
52
- }
53
- const strip = (call) => `0x${call.slice(10)}`;
54
- async function encodeCall(signature, args) {
55
- const out = await abiEncodeCall.execute({ signature, args });
56
- if (typeof out !== "string")
57
- throw new Error("AbiEncodeCall did not return a string");
58
- return JSON.parse(out).data;
59
- }
60
- /** `Error(string)` revert data — the selector is the one Solidity emits. */
61
- export async function revertWith(message) {
62
- return encodeCall("Error(string)", [message]);
63
- }
64
- export const answers = (data) => ({ ok: true, data });
65
- export const reverts = (revertData) => ({ ok: false, data: revertData });
66
- /** What a contract without that function returns: nothing at all. */
67
- export const NOTHING = { ok: true, data: "0x" };
68
- export const USDC = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48";
69
- export const IMPOSTOR_USDC = "0x1111111111111111111111111111111111111111";
70
- export const CYRILLIC_USDC = "0x2222222222222222222222222222222222222222";
71
- export const MKR = "0x9f8f72aa9304c8b593d555f12ef6589cc3a579a2";
72
- export const BROKEN = "0x3333333333333333333333333333333333333333";
73
- export const NOT_A_CONTRACT = "0x4444444444444444444444444444444444444444";
74
- export const APE_NFT = "0x5555555555555555555555555555555555555555";
75
- export const GAME_1155 = "0x6666666666666666666666666666666666666666";
76
- export const ONCHAIN_NFT = "0x7777777777777777777777777777777777777777";
77
- export const LIAR_165 = "0x8888888888888888888888888888888888888888";
78
- export const ALICE = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045";
79
- export const BOB = "0x388C818CA8B9251b393131C08a736A67ccB19297";
80
- export const SPENDER = "0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45";
81
- const addressArg = (args, index = 0) => `0x${args.slice(index * 64 + 24, index * 64 + 64)}`.toLowerCase();
82
- const uintArg = (args, index = 0) => BigInt(`0x${args.slice(index * 64, index * 64 + 64) || "0"}`);
83
- /** A plain ERC-20 whose metadata is all well-formed strings. */
84
- export function erc20(opts) {
85
- const text = opts.bytes32Metadata ? encodeBytes32String : encodeString;
86
- return (selector, args) => {
87
- switch (selector) {
88
- case SELECTOR.decimals:
89
- return answers(encodeUint(opts.decimals));
90
- case SELECTOR.symbol:
91
- return answers(text(opts.symbol));
92
- case SELECTOR.name:
93
- return answers(text(opts.name));
94
- case SELECTOR.totalSupply:
95
- return answers(encodeUint(opts.totalSupply));
96
- case SELECTOR.balanceOf:
97
- return answers(encodeUint(opts.balances?.[addressArg(args)] ?? 0n));
98
- case SELECTOR.allowance:
99
- return answers(encodeUint(opts.allowances?.[`${addressArg(args, 0)}:${addressArg(args, 1)}`] ?? 0n));
100
- default:
101
- return NOTHING;
102
- }
103
- };
104
- }
105
- /** An ERC-721 that declares itself properly through ERC-165. */
106
- export function erc721(opts) {
107
- const supported = new Set(opts.interfaceIds ?? ["01ffc9a7", "80ac58cd", "5b5e139f"]);
108
- return (selector, args) => {
109
- if (selector === SELECTOR.supportsInterface) {
110
- const id = args.slice(0, 8).toLowerCase();
111
- return answers(encodeBool(opts.answersEverything === true || supported.has(id)));
112
- }
113
- if (selector === SELECTOR.name)
114
- return answers(encodeString(opts.name));
115
- if (selector === SELECTOR.symbol)
116
- return answers(encodeString(opts.symbol));
117
- if (selector === SELECTOR.ownerOf) {
118
- const owner = opts.owners[uintArg(args).toString()];
119
- return owner === undefined ? reverts(opts.missingTokenRevert) : answers(encodeAddress(owner));
120
- }
121
- if (selector === SELECTOR.tokenURI) {
122
- const uri = opts.tokenUris[uintArg(args).toString()];
123
- return uri === undefined ? reverts(opts.missingTokenRevert) : answers(encodeString(uri));
124
- }
125
- return NOTHING;
126
- };
127
- }
128
- /** An ERC-1155, which has no ownerOf and whose uri carries the {id} template. */
129
- export function erc1155(opts) {
130
- const supported = new Set(opts.interfaceIds ?? ["01ffc9a7", "d9b67a26", "0e89341c"]);
131
- return (selector, args) => {
132
- if (selector === SELECTOR.supportsInterface) {
133
- return answers(encodeBool(supported.has(args.slice(0, 8).toLowerCase())));
134
- }
135
- if (selector === SELECTOR.uri)
136
- return answers(encodeString(opts.uriTemplate));
137
- if (selector === SELECTOR.balanceOf1155) {
138
- return answers(encodeUint(opts.balances[`${addressArg(args, 0)}:${uintArg(args, 1)}`] ?? 0n));
139
- }
140
- return NOTHING;
141
- };
142
- }
143
- export function chainStub(opts) {
144
- const reads = [];
145
- const multicall = (opts.multicall3Address ?? MULTICALL3_ADDRESS).toLowerCase();
146
- const handlerFor = (address) => opts.contracts[address.toLowerCase()];
147
- const dispatch = (target, data) => {
148
- const selector = data.slice(0, 10).toLowerCase();
149
- const args = data.slice(10);
150
- if (target.toLowerCase() === multicall) {
151
- if (opts.withoutMulticallHelpers === true)
152
- return NOTHING;
153
- if (selector === SELECTOR.getBlockNumber)
154
- return answers(encodeUint(opts.blockNumber ?? 0n));
155
- if (selector === SELECTOR.getEthBalance) {
156
- return answers(encodeUint(opts.nativeBalances?.[addressArg(args)] ?? 0n));
157
- }
158
- return NOTHING;
159
- }
160
- const handler = handlerFor(target);
161
- // An address with no code answers every call with nothing. That is the
162
- // whole point of the NOT_A_CONTRACT fixture.
163
- return handler === undefined ? NOTHING : handler(selector, args);
164
- };
165
- const reader = async (read) => {
166
- reads.push(read);
167
- if (read.method === "eth_getCode") {
168
- const address = String(read.params[0]).toLowerCase();
169
- if (opts.code?.[address] !== undefined)
170
- return opts.code[address];
171
- return handlerFor(address) === undefined ? "0x" : "0x60806040";
172
- }
173
- if (read.method === "eth_getBalance") {
174
- return encodeUint(opts.nativeBalances?.[String(read.params[0]).toLowerCase()] ?? 0n);
175
- }
176
- const call = read.params[0];
177
- if (call.to.toLowerCase() !== multicall || !call.data.startsWith("0x82ad56cb")) {
178
- const answer = dispatch(call.to, call.data);
179
- // An unbatched eth_call cannot return a revert as data: the node errors.
180
- if (!answer.ok)
181
- throw new Error(`execution reverted (${answer.data})`);
182
- return answer.data;
183
- }
184
- if (opts.brokenBatchAnswer !== undefined)
185
- return opts.brokenBatchAnswer;
186
- const decoded = await abiDecode.execute({
187
- data: strip(call.data),
188
- types: ["(address,bool,bytes)[]"],
189
- });
190
- if (typeof decoded !== "string")
191
- throw new Error("AbiDecode did not return a string");
192
- const [batch] = JSON.parse(decoded).values;
193
- const results = batch.map(([to, , callData]) => {
194
- const answer = dispatch(to, callData);
195
- return [answer.ok, answer.data];
196
- });
197
- return strip(await encodeCall("f((bool,bytes)[])", [results]));
198
- };
199
- return { reader, reads };
200
- }
201
- // ─── token lists ────────────────────────────────────────────────────────────
202
- export const UNISWAP_LIST = {
203
- id: "uniswap-default",
204
- name: "Uniswap Labs Default",
205
- tokens: [
206
- { chainId: 1, address: USDC, symbol: "USDC", name: "USD Coin", decimals: 6 },
207
- { chainId: 1, address: MKR, symbol: "MKR", name: "Maker", decimals: 18 },
208
- ],
209
- };
210
- /** A second reputable list that happens to carry a different address for USDC. */
211
- export const RIVAL_LIST = {
212
- id: "rival-list",
213
- name: "Somebody Else's List",
214
- tokens: [{ chainId: 1, address: IMPOSTOR_USDC, symbol: "USDC", name: "USD Coin", decimals: 6 }],
215
- };
216
- /** The Cyrillic \u0421 impostor. It is not the same string, and it looks identical. */
217
- export const CYRILLIC_LIST = {
218
- id: "shady-list",
219
- tokens: [
220
- { chainId: 1, address: CYRILLIC_USDC, symbol: "USD\u0421", name: "USD Coin", decimals: 6 },
221
- ],
222
- };