@crewhaus/tool-chainread 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 +201 -0
- package/NOTICE +9 -0
- package/README.md +20 -5
- package/dist/index.d.ts +1 -1
- package/dist/index.js +28 -4
- package/dist/lib/endpoint.d.ts +32 -4
- package/dist/lib/endpoint.js +85 -5
- package/dist/lib/rpc.d.ts +10 -0
- package/dist/lib/rpc.js +66 -38
- package/package.json +14 -9
- package/dist/fixtures.d.ts +0 -123
- package/dist/fixtures.js +0 -309
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
|
@@ -333,18 +333,33 @@ IPv4-mapped, IPv4-translated, NAT64's `64:ff9b::/96` (which is exactly what a DN
|
|
|
333
333
|
resolver answers for an IPv4-only name), 6to4's `2002::/16`, the deprecated
|
|
334
334
|
IPv4-compatible form — is resolved to the IPv4 it carries and judged on that.
|
|
335
335
|
|
|
336
|
-
|
|
336
|
+
With no configuration these tools may dial any public RPC origin. A spec narrows that
|
|
337
|
+
to a list, and a compiled bundle, `crewhaus run` and `crewhaus eval` apply it at boot:
|
|
338
|
+
|
|
339
|
+
```yaml
|
|
340
|
+
tool_config:
|
|
341
|
+
chainread:
|
|
342
|
+
allowed_origins: [https://mainnet.base.org] # the ONLY origins these tools dial
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
A `model_pool` candidate may carry the same block under its own `tool_config`. It
|
|
346
|
+
narrows the calls that model makes and never widens them: an origin must be on the
|
|
347
|
+
candidate's list AND on the agent's list, when there is one. A spec that sets the
|
|
348
|
+
list only on a candidate is held to it on that candidate's calls. `EvmRpcHealth`
|
|
349
|
+
reports it as `modelAllowedOrigins`.
|
|
350
|
+
|
|
351
|
+
A spec cannot open loopback or the private ranges: `allow_private_hosts` in the block
|
|
352
|
+
is refused, because a spec can come from a template or a pull request. A local anvil
|
|
353
|
+
or hardhat node is the casualty; a host that runs one opens it in code:
|
|
337
354
|
|
|
338
355
|
```ts
|
|
339
356
|
import { setRpcEndpointPolicy } from "@crewhaus/tool-chainread";
|
|
340
357
|
|
|
341
358
|
setRpcEndpointPolicy({ allowPrivateHosts: true }); // a local devnet
|
|
342
|
-
setRpcEndpointPolicy({ allowedOrigins: ["https://mainnet.base.org"] }); // stricter still
|
|
343
359
|
```
|
|
344
360
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
itself is not a gate. `EvmRpcHealth` reports the policy it is operating under.
|
|
361
|
+
Neither is a field in any tool's input schema: a gate a model can open for itself is
|
|
362
|
+
not a gate. `EvmRpcHealth` reports the policy it is operating under.
|
|
348
363
|
|
|
349
364
|
This is a narrower guard than `@crewhaus/tool-fetch`'s `assertNotSsrf`, which is the
|
|
350
365
|
repo's canonical one and is not a dependency of this package. It covers the same ground
|
package/dist/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { RegisteredTool } from "@crewhaus/tool-catalog";
|
|
|
2
2
|
import { type BlockSelector } from "./lib/blocks";
|
|
3
3
|
export { _setFetch, type RpcFetch, type RpcOutcome, openRpc } from "./lib/rpc";
|
|
4
4
|
export { _setClock, type Clock, virtualClock } from "./lib/clock";
|
|
5
|
-
export { _setDnsLookup, type DnsLookupFn, type RpcEndpointPolicy, RpcEndpointError, getRpcEndpointPolicy, setRpcEndpointPolicy, } from "./lib/endpoint";
|
|
5
|
+
export { _setDnsLookup, type ChainreadConfigInput, type DnsLookupFn, type RpcEndpointPolicy, RpcEndpointError, getRpcEndpointPolicy, registerChainreadConfig, setRpcEndpointPolicy, } from "./lib/endpoint";
|
|
6
6
|
export { ChainReadError } from "./lib/quantity";
|
|
7
7
|
export { parseSuggestedSpan, shouldNarrow, type RawLog } from "./lib/logs";
|
|
8
8
|
export { type AssetUnits, type HistoryReport, type ParkedRow, type RowDetail, type SentProof, collectHistory, toMinorUnits, } from "./lib/history";
|
package/dist/index.js
CHANGED
|
@@ -42,7 +42,7 @@ import { buildTool } from "@crewhaus/tool-builder";
|
|
|
42
42
|
import { z } from "zod";
|
|
43
43
|
import { fetchBlock, headNumber, searchBlockAtTimestamp, } from "./lib/blocks";
|
|
44
44
|
import { nowMs, sleep } from "./lib/clock";
|
|
45
|
-
import { getRpcEndpointPolicy } from "./lib/endpoint";
|
|
45
|
+
import { callOrigins, getRpcEndpointPolicy } from "./lib/endpoint";
|
|
46
46
|
import { UNKNOWN_UNITS, collectHistory } from "./lib/history";
|
|
47
47
|
import { projectLog, scanLogs } from "./lib/logs";
|
|
48
48
|
import { ChainReadError, hexToBigint, isAddress, isBlockTag, isHash32, json, lowerHex, requireObject, toBigint, toHexQuantity, unixToIso, } from "./lib/quantity";
|
|
@@ -50,7 +50,7 @@ import { callOrThrow, openRpc } from "./lib/rpc";
|
|
|
50
50
|
import { decodeApproval, decodeTransfer, feeBreakdown, projectReceipt, projectTransaction, } from "./lib/transfers";
|
|
51
51
|
export { _setFetch, openRpc } from "./lib/rpc";
|
|
52
52
|
export { _setClock, virtualClock } from "./lib/clock";
|
|
53
|
-
export { _setDnsLookup, RpcEndpointError, getRpcEndpointPolicy, setRpcEndpointPolicy, } from "./lib/endpoint";
|
|
53
|
+
export { _setDnsLookup, RpcEndpointError, getRpcEndpointPolicy, registerChainreadConfig, setRpcEndpointPolicy, } from "./lib/endpoint";
|
|
54
54
|
export { ChainReadError } from "./lib/quantity";
|
|
55
55
|
export { parseSuggestedSpan, shouldNarrow } from "./lib/logs";
|
|
56
56
|
export { collectHistory, toMinorUnits, } from "./lib/history";
|
|
@@ -93,9 +93,11 @@ const blockField = z
|
|
|
93
93
|
const addressField = z.string().length(42).describe("a 0x-prefixed 20-byte address");
|
|
94
94
|
const hashField = z.string().length(66).describe("a 0x-prefixed 32-byte hash");
|
|
95
95
|
async function client(input, ctx) {
|
|
96
|
+
const narrowTo = callOrigins(ctx?.toolConfig);
|
|
96
97
|
return openRpc(input.rpcUrl, {
|
|
97
98
|
...(ctx?.signal !== undefined ? { signal: ctx.signal } : {}),
|
|
98
99
|
...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}),
|
|
100
|
+
...(narrowTo !== undefined ? { allowedOrigins: narrowTo } : {}),
|
|
99
101
|
});
|
|
100
102
|
}
|
|
101
103
|
/**
|
|
@@ -150,6 +152,7 @@ async function resolveBlockNumber(rpc, value, what) {
|
|
|
150
152
|
// ---------------------------------------------------------------------------
|
|
151
153
|
export const evmGetBlock = buildTool({
|
|
152
154
|
name: "EvmGetBlock",
|
|
155
|
+
operativeArgs: [{ field: "rpcUrl", kind: "url" }],
|
|
153
156
|
description: "Read one block's header from a JSON-RPC endpoint — number, hash, parent, timestamp (unix and ISO), gas limit and usage, base fee and transaction count — by number, by tag, or by block hash. Use it to anchor anything that needs a block: a historical read, a confirmation count, or the time a range covers. It picks getBlockByHash or getBlockByNumber from the shape of what you pass, reports a chain with no base fee as legacy rather than putting a null into your fee arithmetic, and answers a block that does not exist with found:false rather than an error, because that is a fact about the chain and not a failure.",
|
|
154
157
|
inputSchema: z
|
|
155
158
|
.object({
|
|
@@ -197,6 +200,7 @@ export const evmGetBlock = buildTool({
|
|
|
197
200
|
});
|
|
198
201
|
export const evmBlockAtTimestamp = buildTool({
|
|
199
202
|
name: "EvmBlockAtTimestamp",
|
|
203
|
+
operativeArgs: [{ field: "rpcUrl", kind: "url" }],
|
|
200
204
|
description: "Find the block a chain was at, at a given moment: the LAST block whose timestamp is at or before the one you name, together with the block after it so the answer can be checked. Use it to pin a historical read — an end-of-quarter balance, the state before an incident — to a block number, instead of guessing from an average block time. A timestamp before the chain's first block is refused rather than answered with block zero, a timestamp at or past the head returns the head and says so, and a chain whose block timestamps run backwards is refused rather than searched, because a binary search there has no single right answer.",
|
|
201
205
|
inputSchema: z
|
|
202
206
|
.object({
|
|
@@ -275,6 +279,10 @@ export function parseTimestamp(value) {
|
|
|
275
279
|
}
|
|
276
280
|
export const evmRpcHealth = buildTool({
|
|
277
281
|
name: "EvmRpcHealth",
|
|
282
|
+
operativeArgs: [
|
|
283
|
+
{ field: "rpcUrl", kind: "url" },
|
|
284
|
+
{ field: "compareWith", kind: "url" },
|
|
285
|
+
],
|
|
278
286
|
description: "Probe a JSON-RPC endpoint and report what it is and what it can serve: chain id, head block and how old that head is, and whether it keeps archive state. Use it before trusting a chain read, and to compare several endpoints against each other — two endpoints reporting different chain ids is a misconfiguration that would otherwise show up as impossible data. Archive support is decided by reading a historic balance and interpreting the failure, and when the evidence does not settle it — a chain too short to tell, an error that means something else — it reports unknown rather than false, because 'we could not tell' and 'it does not' lead to different decisions.",
|
|
279
287
|
inputSchema: z
|
|
280
288
|
.object({
|
|
@@ -337,7 +345,7 @@ export const evmRpcHealth = buildTool({
|
|
|
337
345
|
unreachableIf: "the chainId or head probe failed",
|
|
338
346
|
archiveUnknownIf: "the probe block is within 128 blocks of the head, or the error was not a pruning error",
|
|
339
347
|
},
|
|
340
|
-
policy: describePolicy(),
|
|
348
|
+
policy: describePolicy(ctx),
|
|
341
349
|
});
|
|
342
350
|
},
|
|
343
351
|
});
|
|
@@ -358,9 +366,11 @@ async function probeEndpoint(url, ctx, timeoutMs, archiveProbeBlock, staleAfter)
|
|
|
358
366
|
const errors = [];
|
|
359
367
|
let rpc;
|
|
360
368
|
try {
|
|
369
|
+
const narrowTo = callOrigins(ctx?.toolConfig);
|
|
361
370
|
rpc = await openRpc(url, {
|
|
362
371
|
...(ctx?.signal !== undefined ? { signal: ctx.signal } : {}),
|
|
363
372
|
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
373
|
+
...(narrowTo !== undefined ? { allowedOrigins: narrowTo } : {}),
|
|
364
374
|
});
|
|
365
375
|
}
|
|
366
376
|
catch (err) {
|
|
@@ -492,15 +502,25 @@ function safeOrigin(url) {
|
|
|
492
502
|
return "(unparseable url)";
|
|
493
503
|
}
|
|
494
504
|
}
|
|
495
|
-
function describePolicy() {
|
|
505
|
+
function describePolicy(ctx) {
|
|
496
506
|
const policy = getRpcEndpointPolicy();
|
|
507
|
+
let narrowedTo = null;
|
|
508
|
+
try {
|
|
509
|
+
narrowedTo = callOrigins(ctx?.toolConfig) ?? null;
|
|
510
|
+
}
|
|
511
|
+
catch {
|
|
512
|
+
// Already reported on each endpoint by the probe that tried to use it.
|
|
513
|
+
}
|
|
497
514
|
return {
|
|
498
515
|
allowPrivateHosts: policy.allowPrivateHosts === true,
|
|
499
516
|
allowedOrigins: policy.allowedOrigins ?? null,
|
|
517
|
+
// This model's own tool_config block, which narrows the list above.
|
|
518
|
+
modelAllowedOrigins: narrowedTo,
|
|
500
519
|
};
|
|
501
520
|
}
|
|
502
521
|
export const evmNonceStatus = buildTool({
|
|
503
522
|
name: "EvmNonceStatus",
|
|
523
|
+
operativeArgs: [{ field: "rpcUrl", kind: "url" }],
|
|
504
524
|
description: "Compare an account's latest and pending nonce and say what that means: clear, transactions waiting, a nonce gap, or — when the evidence does not support a verdict — unknown. Use it before sending anything from an account a harness manages, and to find out why a broadcast seems stuck. Pass the transaction hashes you broadcast and it will tell you which are mined, which are still in this endpoint's mempool and which it has never heard of. It degrades loudly: many endpoints do not track a public mempool and answer the pending nonce with the latest one, so a confident 'clear' from those would be wrong, and this reports the endpoint's mempool visibility instead of guessing.",
|
|
505
525
|
inputSchema: z
|
|
506
526
|
.object({
|
|
@@ -611,6 +631,7 @@ export const evmNonceStatus = buildTool({
|
|
|
611
631
|
});
|
|
612
632
|
export const evmWaitForReceipt = buildTool({
|
|
613
633
|
name: "EvmWaitForReceipt",
|
|
634
|
+
operativeArgs: [{ field: "rpcUrl", kind: "url" }],
|
|
614
635
|
description: "Wait, with a deadline, for a transaction to be mined, and report which of three things happened: it was mined and succeeded, it was mined and REVERTED, or the deadline passed with it still unmined. Use it after broadcasting anything. The three outcomes are the point — a reverted transaction has a receipt with status 0x0, so a tool that reports 'no receipt' for it tells a caller to broadcast again, which is how a failed transaction becomes two. It also tells a transaction this endpoint has never seen apart from one it is holding in its mempool, and can wait for a number of confirmations or for the chain's own safe/finalized tag instead of a block count.",
|
|
615
636
|
inputSchema: z
|
|
616
637
|
.object({
|
|
@@ -760,6 +781,7 @@ async function settlement(rpc, receiptBlock, finality, wanted) {
|
|
|
760
781
|
}
|
|
761
782
|
export const evmTransactionSummary = buildTool({
|
|
762
783
|
name: "EvmTransactionSummary",
|
|
784
|
+
operativeArgs: [{ field: "rpcUrl", kind: "url" }],
|
|
763
785
|
description: "Explain one transaction in bookkeeping terms: who sent it, what it called, which tokens and how much native value moved, which approvals it granted, what it cost in fees, and the net change for an address you name. Use it to reconcile or to explain a transaction without reading raw logs. It is explicit about its own blind spots, in the output and not only in the docs: a receipt carries logs and no trace, so native value moved by a CONTRACT during the call is invisible here and the net deltas say so. Fees are computed from the fields the receipt actually has, so an OP-stack L1 data fee is added and an Arbitrum L1 charge is not double-counted.",
|
|
764
786
|
inputSchema: z
|
|
765
787
|
.object({
|
|
@@ -892,6 +914,7 @@ function netDeltas(who, tx, receipt, movements, fees) {
|
|
|
892
914
|
}
|
|
893
915
|
export const evmEventScan = buildTool({
|
|
894
916
|
name: "EvmEventScan",
|
|
917
|
+
operativeArgs: [{ field: "rpcUrl", kind: "url" }],
|
|
895
918
|
description: "Collect every log matching a filter across a block range, paging around whatever limits the endpoint imposes, and refuse rather than return a set it cannot prove is complete. Use it to find what happened onchain over a period — transfers to an address, every emission of one event — without hand-writing the paging. Public endpoints cap log queries and disagree about how: some answer with an error naming a smaller range, and some silently return the first N logs of a range that held more. The first is handled by halving and retrying; the second is caught by re-querying a suspicious chunk in halves and comparing the counts, because a missing log is an event that, to everything downstream, did not happen.",
|
|
896
919
|
inputSchema: z
|
|
897
920
|
.object({
|
|
@@ -1047,6 +1070,7 @@ const unitsField = z
|
|
|
1047
1070
|
.strict();
|
|
1048
1071
|
export const onchainTransactionsSync = buildTool({
|
|
1049
1072
|
name: "OnchainTransactionsSync",
|
|
1073
|
+
operativeArgs: [{ field: "rpcUrl", kind: "url" }],
|
|
1050
1074
|
description: "Pull one address's onchain history over a block range and return it as the same normalized rows a bank statement parses into, so a wallet can be reconciled against a ledger instead of read as raw logs. It emits the row shape StatementParse produces and LedgerReconcile consumes — id, date, description, amountMinor, direction, reference, balanceMinor — with the exact uint256 kept alongside each row rather than rounded into it. Use it to close the books on a wallet, or to sync incrementally: it returns a cursor pinned to the block it actually finished at. It refuses rather than under-reporting, the way EvmEventScan does, and it says in the output which movements it cannot see at all: internal native transfers need a trace, and ERC-1155 is not collected.",
|
|
1051
1075
|
inputSchema: z
|
|
1052
1076
|
.object({
|
package/dist/lib/endpoint.d.ts
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
* So the host is vetted before the socket opens, the vetted IP is what gets
|
|
12
12
|
* dialled (resolving here and letting `fetch` re-resolve at connect time is a
|
|
13
13
|
* DNS-rebinding TOCTOU), and the loopback/private ranges are refused unless an
|
|
14
|
-
* OPERATOR opened them — `setRpcEndpointPolicy
|
|
15
|
-
*
|
|
14
|
+
* OPERATOR opened them — `setRpcEndpointPolicy`, bound at boot from the spec's
|
|
15
|
+
* `tool_config.chainread` block (which can only narrow it), or by a host. It is
|
|
16
16
|
* deliberately not a field in any tool's input schema: a gate a model can open
|
|
17
17
|
* for itself is not a gate.
|
|
18
18
|
*
|
|
@@ -43,8 +43,36 @@ export type RpcEndpointPolicy = {
|
|
|
43
43
|
*/
|
|
44
44
|
readonly allowedOrigins?: ReadonlyArray<string>;
|
|
45
45
|
};
|
|
46
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* Bind the endpoint policy. A compiled bundle, `crewhaus run` and `crewhaus
|
|
48
|
+
* eval` bind it at boot through {@link registerChainreadConfig}, from the
|
|
49
|
+
* spec's `tool_config.chainread` block; a host may call it directly.
|
|
50
|
+
*/
|
|
47
51
|
export declare function setRpcEndpointPolicy(next: RpcEndpointPolicy): void;
|
|
52
|
+
/** The spec's `tool_config.chainread` block — or any chainread tool's own key. */
|
|
53
|
+
export type ChainreadConfigInput = {
|
|
54
|
+
readonly allowed_origins?: ReadonlyArray<string>;
|
|
55
|
+
readonly allowedOrigins?: ReadonlyArray<string>;
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* Deliver the spec's block at boot: `allowed_origins` becomes the ONLY RPC
|
|
59
|
+
* origins these tools may dial. It can only narrow. Opening loopback and the
|
|
60
|
+
* private ranges stays out of a spec's reach — a spec may come from a template
|
|
61
|
+
* or a pull request, and that address is every unauthenticated service on the
|
|
62
|
+
* box — so a block that tries is refused rather than ignored.
|
|
63
|
+
*/
|
|
64
|
+
export declare function registerChainreadConfig(input: ChainreadConfigInput): void;
|
|
65
|
+
/**
|
|
66
|
+
* The RPC origins a model-pool candidate's own `tool_config` block narrows
|
|
67
|
+
* THIS call to (`ToolExecuteContext.toolConfig`), or `undefined` when it sets
|
|
68
|
+
* none. It narrows only: an origin must be in this list AND in the list bound
|
|
69
|
+
* at boot, when there is one. A candidate that lists an origin the agent's
|
|
70
|
+
* block does not is refused there, not widened — the boot list is the
|
|
71
|
+
* operator's ceiling for every model in the pool. Before 0.7.1 this block was
|
|
72
|
+
* accepted by `compile --strict` and never read, so a spec that set the list
|
|
73
|
+
* only on a candidate dialled any public origin (C029).
|
|
74
|
+
*/
|
|
75
|
+
export declare function callOrigins(toolConfig: unknown): ReadonlyArray<string> | undefined;
|
|
48
76
|
/** Read the policy back — for `EvmRpcHealth`, which reports the posture it is operating under. */
|
|
49
77
|
export declare function getRpcEndpointPolicy(): RpcEndpointPolicy;
|
|
50
78
|
export type DnsLookupFn = (host: string) => Promise<{
|
|
@@ -88,4 +116,4 @@ export type VettedEndpoint = {
|
|
|
88
116
|
* the reason in the message, because every one of these is a refusal a model
|
|
89
117
|
* should read and act on rather than retry.
|
|
90
118
|
*/
|
|
91
|
-
export declare function vetEndpoint(rawUrl: string): Promise<VettedEndpoint>;
|
|
119
|
+
export declare function vetEndpoint(rawUrl: string, narrowTo?: ReadonlyArray<string>): Promise<VettedEndpoint>;
|
package/dist/lib/endpoint.js
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
* So the host is vetted before the socket opens, the vetted IP is what gets
|
|
12
12
|
* dialled (resolving here and letting `fetch` re-resolve at connect time is a
|
|
13
13
|
* DNS-rebinding TOCTOU), and the loopback/private ranges are refused unless an
|
|
14
|
-
* OPERATOR opened them — `setRpcEndpointPolicy
|
|
15
|
-
*
|
|
14
|
+
* OPERATOR opened them — `setRpcEndpointPolicy`, bound at boot from the spec's
|
|
15
|
+
* `tool_config.chainread` block (which can only narrow it), or by a host. It is
|
|
16
16
|
* deliberately not a field in any tool's input schema: a gate a model can open
|
|
17
17
|
* for itself is not a gate.
|
|
18
18
|
*
|
|
@@ -30,10 +30,82 @@ export class RpcEndpointError extends CrewhausError {
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
let policy = {};
|
|
33
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* Bind the endpoint policy. A compiled bundle, `crewhaus run` and `crewhaus
|
|
35
|
+
* eval` bind it at boot through {@link registerChainreadConfig}, from the
|
|
36
|
+
* spec's `tool_config.chainread` block; a host may call it directly.
|
|
37
|
+
*/
|
|
34
38
|
export function setRpcEndpointPolicy(next) {
|
|
35
39
|
policy = next;
|
|
36
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Deliver the spec's block at boot: `allowed_origins` becomes the ONLY RPC
|
|
43
|
+
* origins these tools may dial. It can only narrow. Opening loopback and the
|
|
44
|
+
* private ranges stays out of a spec's reach — a spec may come from a template
|
|
45
|
+
* or a pull request, and that address is every unauthenticated service on the
|
|
46
|
+
* box — so a block that tries is refused rather than ignored.
|
|
47
|
+
*/
|
|
48
|
+
export function registerChainreadConfig(input) {
|
|
49
|
+
const origins = originsOf(input);
|
|
50
|
+
if (origins === undefined)
|
|
51
|
+
return;
|
|
52
|
+
setRpcEndpointPolicy({ ...policy, allowedOrigins: origins });
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The RPC origins a model-pool candidate's own `tool_config` block narrows
|
|
56
|
+
* THIS call to (`ToolExecuteContext.toolConfig`), or `undefined` when it sets
|
|
57
|
+
* none. It narrows only: an origin must be in this list AND in the list bound
|
|
58
|
+
* at boot, when there is one. A candidate that lists an origin the agent's
|
|
59
|
+
* block does not is refused there, not widened — the boot list is the
|
|
60
|
+
* operator's ceiling for every model in the pool. Before 0.7.1 this block was
|
|
61
|
+
* accepted by `compile --strict` and never read, so a spec that set the list
|
|
62
|
+
* only on a candidate dialled any public origin (C029).
|
|
63
|
+
*/
|
|
64
|
+
export function callOrigins(toolConfig) {
|
|
65
|
+
if (toolConfig === undefined || toolConfig === null)
|
|
66
|
+
return undefined;
|
|
67
|
+
if (typeof toolConfig !== "object" || Array.isArray(toolConfig)) {
|
|
68
|
+
throw new RpcEndpointError("this model's tool_config block for the chain readers is not a mapping; write allowed_origins under it.");
|
|
69
|
+
}
|
|
70
|
+
return originsOf(toolConfig);
|
|
71
|
+
}
|
|
72
|
+
/** A block's allowed_origins, checked and reduced to origins; `undefined` when it sets none. */
|
|
73
|
+
function originsOf(input) {
|
|
74
|
+
const block = (input ?? {});
|
|
75
|
+
for (const key of ["allow_private_hosts", "allowPrivateHosts"]) {
|
|
76
|
+
if (Object.hasOwn(block, key)) {
|
|
77
|
+
throw new RpcEndpointError(`tool_config.chainread.${key} is not accepted: a spec cannot open loopback or private addresses. Remove it, and list the public RPC origins under allowed_origins.`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (Object.hasOwn(block, "allowed_origins") && Object.hasOwn(block, "allowedOrigins")) {
|
|
81
|
+
throw new RpcEndpointError("tool_config.chainread sets both allowed_origins and allowedOrigins. Write the list once, as allowed_origins.");
|
|
82
|
+
}
|
|
83
|
+
const raw = block["allowed_origins"] ?? block["allowedOrigins"];
|
|
84
|
+
if (raw === undefined)
|
|
85
|
+
return undefined;
|
|
86
|
+
if (!Array.isArray(raw) || raw.some((o) => typeof o !== "string")) {
|
|
87
|
+
throw new RpcEndpointError('tool_config.chainread.allowed_origins must be a list of origins, for example ["https://mainnet.base.org"].');
|
|
88
|
+
}
|
|
89
|
+
const origins = raw.map((o, i) => {
|
|
90
|
+
let url;
|
|
91
|
+
try {
|
|
92
|
+
url = new URL(o);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// Named by position, never by value: an entry that fails to parse is
|
|
96
|
+
// most often a keyed provider URL written without its scheme, and the
|
|
97
|
+
// key is in its path — the rule vetEndpoint keeps for rpcUrl.
|
|
98
|
+
throw new RpcEndpointError(`tool_config.chainread.allowed_origins[${i}] is not an origin — ${/^[a-z][a-z0-9+.-]*:\/\//i.test(o)
|
|
99
|
+
? "check the host and the port"
|
|
100
|
+
: "it must start with https:// (or http://)"}. Write it as https://host[:port]; the value is not repeated here, because a provider keeps its key in the path.`);
|
|
101
|
+
}
|
|
102
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
103
|
+
throw new RpcEndpointError(`tool_config.chainread.allowed_origins has "${url.protocol}//${url.host}", which is not http(s). Write it as https://host[:port].`);
|
|
104
|
+
}
|
|
105
|
+
return url.origin;
|
|
106
|
+
});
|
|
107
|
+
return origins;
|
|
108
|
+
}
|
|
37
109
|
/** Read the policy back — for `EvmRpcHealth`, which reports the posture it is operating under. */
|
|
38
110
|
export function getRpcEndpointPolicy() {
|
|
39
111
|
return policy;
|
|
@@ -257,13 +329,18 @@ export function isPrivateIp(address) {
|
|
|
257
329
|
* the reason in the message, because every one of these is a refusal a model
|
|
258
330
|
* should read and act on rather than retry.
|
|
259
331
|
*/
|
|
260
|
-
export async function vetEndpoint(rawUrl) {
|
|
332
|
+
export async function vetEndpoint(rawUrl, narrowTo) {
|
|
261
333
|
let url;
|
|
262
334
|
try {
|
|
263
335
|
url = new URL(rawUrl);
|
|
264
336
|
}
|
|
265
337
|
catch {
|
|
266
|
-
|
|
338
|
+
// Nothing of the value is repeated: a provider key lives in the path, and
|
|
339
|
+
// a URL that fails to parse is most often a keyed URL missing its scheme
|
|
340
|
+
// (rpc.ts, invariant 3). The hint is read from the shape alone.
|
|
341
|
+
throw new RpcEndpointError(`rpcUrl is not an absolute URL — ${/^[a-z][a-z0-9+.-]*:\/\//i.test(rawUrl)
|
|
342
|
+
? "check the host and the port"
|
|
343
|
+
: "it must start with https:// (or http://)"}. The value is not repeated here, because a provider keeps its key in the path.`);
|
|
267
344
|
}
|
|
268
345
|
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
269
346
|
throw new RpcEndpointError(`refusing scheme "${url.protocol.replace(":", "")}" — an RPC endpoint is http(s); a websocket is a different transport this package does not speak`);
|
|
@@ -277,6 +354,9 @@ export async function vetEndpoint(rawUrl) {
|
|
|
277
354
|
if (allowed !== undefined && !allowed.includes(url.origin)) {
|
|
278
355
|
throw new RpcEndpointError(`refusing "${url.origin}" — the operator's rpc allow-list is ${allowed.length === 0 ? "empty" : allowed.join(", ")}`);
|
|
279
356
|
}
|
|
357
|
+
if (narrowTo !== undefined && !narrowTo.includes(url.origin)) {
|
|
358
|
+
throw new RpcEndpointError(`refusing "${url.origin}" — this model's rpc allow-list (its own tool_config block) is ${narrowTo.length === 0 ? "empty" : narrowTo.join(", ")}`);
|
|
359
|
+
}
|
|
280
360
|
const host = url.hostname.toLowerCase();
|
|
281
361
|
const allowPrivate = policy.allowPrivateHosts === true;
|
|
282
362
|
if (!allowPrivate && (host === "localhost" || host.endsWith(".localhost"))) {
|
package/dist/lib/rpc.d.ts
CHANGED
|
@@ -36,6 +36,8 @@ export type RpcClientOptions = {
|
|
|
36
36
|
readonly signal?: AbortSignal;
|
|
37
37
|
readonly timeoutMs?: number;
|
|
38
38
|
readonly maxBytes?: number;
|
|
39
|
+
/** A per-call allow-list that narrows the boot one (see `callOrigins`). */
|
|
40
|
+
readonly allowedOrigins?: ReadonlyArray<string>;
|
|
39
41
|
};
|
|
40
42
|
export type RpcClient = {
|
|
41
43
|
/** Scheme + host + port. Safe to print; the path, which may hold a key, is not. */
|
|
@@ -55,6 +57,14 @@ export type RpcClient = {
|
|
|
55
57
|
export declare function openRpc(rawUrl: string, options?: RpcClientOptions): Promise<RpcClient>;
|
|
56
58
|
/** The longest JSON-RPC error message this package will carry. */
|
|
57
59
|
export declare const MAX_ERROR_MESSAGE_CHARS = 2000;
|
|
60
|
+
/**
|
|
61
|
+
* Take an endpoint's path and query out of a message this package did not
|
|
62
|
+
* write — a dialler's error, which may quote the URL it could not reach, the
|
|
63
|
+
* pinned-IP spelling of it included. The origin is kept (every message here
|
|
64
|
+
* names it anyway); the path and the query, where a provider keeps its key,
|
|
65
|
+
* become `<redacted>`.
|
|
66
|
+
*/
|
|
67
|
+
export declare function withoutPath(message: string, url: URL): string;
|
|
58
68
|
/** Turn a failed outcome into the refusal a tool throws, with the reason kept. */
|
|
59
69
|
export declare function failed(outcome: Extract<RpcOutcome, {
|
|
60
70
|
ok: false;
|
package/dist/lib/rpc.js
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
* transcript.
|
|
22
22
|
*/
|
|
23
23
|
import { assertReadOnlyMethod } from "@crewhaus/chain-adapter-base";
|
|
24
|
+
import { fetchRaw, readResponseBounded } from "@crewhaus/tool-safety/streams";
|
|
24
25
|
import { vetEndpoint } from "./endpoint";
|
|
25
26
|
import { ChainReadError } from "./quantity";
|
|
26
27
|
export const DEFAULT_TIMEOUT_MS = 20_000;
|
|
@@ -46,8 +47,13 @@ const pinnedFetch = async (req, pinnedIp) => {
|
|
|
46
47
|
const original = new URL(req.url);
|
|
47
48
|
const host = original.hostname;
|
|
48
49
|
const unbracketed = host.replace(/^\[/, "").replace(/\]$/, "");
|
|
50
|
+
// Both branches keep the body RAW (`fetchRaw`, Bun's `decompress: false`):
|
|
51
|
+
// otherwise Bun inflates a gzip, deflate, br or zstd reply in native code
|
|
52
|
+
// before any reader sees a byte, and a 260 KB gzip of zeros cost about
|
|
53
|
+
// 1 GB of RSS before `maxBytes` fired (C093). `readCapped` decodes it,
|
|
54
|
+
// under the cap.
|
|
49
55
|
if (pinnedIp === "" || unbracketed === pinnedIp)
|
|
50
|
-
return
|
|
56
|
+
return fetchRaw(req);
|
|
51
57
|
const pinnedUrl = new URL(original.toString());
|
|
52
58
|
pinnedUrl.hostname = pinnedIp.includes(":") ? `[${pinnedIp}]` : pinnedIp;
|
|
53
59
|
const headers = new Headers(req.headers);
|
|
@@ -64,7 +70,7 @@ const pinnedFetch = async (req, pinnedIp) => {
|
|
|
64
70
|
redirect: "manual",
|
|
65
71
|
tls: { serverName: host },
|
|
66
72
|
};
|
|
67
|
-
return
|
|
73
|
+
return fetchRaw(pinnedUrl.toString(), init);
|
|
68
74
|
};
|
|
69
75
|
let rpcFetch = pinnedFetch;
|
|
70
76
|
/**
|
|
@@ -84,7 +90,7 @@ export function _setFetch(fn) {
|
|
|
84
90
|
* mid-scan, which is precisely the rebinding the pin defends against.
|
|
85
91
|
*/
|
|
86
92
|
export async function openRpc(rawUrl, options = {}) {
|
|
87
|
-
const endpoint = await vetEndpoint(rawUrl);
|
|
93
|
+
const endpoint = await vetEndpoint(rawUrl, options.allowedOrigins);
|
|
88
94
|
return clientFor(endpoint, options);
|
|
89
95
|
}
|
|
90
96
|
function clientFor(endpoint, options) {
|
|
@@ -160,7 +166,9 @@ async function request(endpoint, spec) {
|
|
|
160
166
|
return {
|
|
161
167
|
ok: false,
|
|
162
168
|
kind: "transport",
|
|
163
|
-
message
|
|
169
|
+
// The dialler's message is somebody else's string, and one that
|
|
170
|
+
// quotes the URL it could not reach quotes the key in its path.
|
|
171
|
+
message: `could not reach ${origin}: ${withoutPath(error?.message ?? String(err), endpoint.url)}`,
|
|
164
172
|
};
|
|
165
173
|
}
|
|
166
174
|
// A redirect is not followed. An RPC endpoint that 302s is either
|
|
@@ -176,9 +184,16 @@ async function request(endpoint, spec) {
|
|
|
176
184
|
}
|
|
177
185
|
let text;
|
|
178
186
|
try {
|
|
179
|
-
text = await readCapped(res, spec.maxBytes);
|
|
187
|
+
text = await readCapped(res, spec.maxBytes, timer.signal);
|
|
180
188
|
}
|
|
181
189
|
catch (err) {
|
|
190
|
+
if (err instanceof BodyUnreadable) {
|
|
191
|
+
return {
|
|
192
|
+
ok: false,
|
|
193
|
+
kind: "malformed",
|
|
194
|
+
message: `${origin} answered ${spec.method} with a body that ${err.message}`,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
182
197
|
if (err instanceof BodyTooLarge) {
|
|
183
198
|
return {
|
|
184
199
|
ok: false,
|
|
@@ -264,45 +279,38 @@ async function request(endpoint, spec) {
|
|
|
264
279
|
}
|
|
265
280
|
class BodyTooLarge extends Error {
|
|
266
281
|
}
|
|
282
|
+
class BodyUnreadable extends Error {
|
|
283
|
+
}
|
|
267
284
|
/**
|
|
268
|
-
* Read a body with a ceiling, WHILE reading.
|
|
285
|
+
* Read a body with a ceiling on its DECODED size, WHILE reading.
|
|
269
286
|
*
|
|
270
287
|
* `await res.text()` would buffer whatever arrives first and check afterwards,
|
|
271
|
-
* which is no ceiling at all against an endpoint that streams
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
288
|
+
* which is no ceiling at all against an endpoint that streams — and a
|
|
289
|
+
* compressed body is inflated by the runtime before any reader sees it, so
|
|
290
|
+
* it arrives raw (see `pinnedFetch`) and is decoded here, with the decoder
|
|
291
|
+
* stopped at the cap. Stopping at the cap and refusing is also why the
|
|
292
|
+
* result is never parsed as a prefix: half a log array parses to a
|
|
293
|
+
* plausible SHORTER answer, which is the failure mode this whole package is
|
|
294
|
+
* organised against.
|
|
275
295
|
*/
|
|
276
|
-
async function readCapped(res, maxBytes) {
|
|
277
|
-
const
|
|
278
|
-
if (
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
let total = 0;
|
|
283
|
-
try {
|
|
284
|
-
for (;;) {
|
|
285
|
-
const { done, value } = await reader.read();
|
|
286
|
-
if (done)
|
|
287
|
-
break;
|
|
288
|
-
if (value === undefined)
|
|
289
|
-
continue;
|
|
290
|
-
total += value.byteLength;
|
|
291
|
-
if (total > maxBytes)
|
|
292
|
-
throw new BodyTooLarge();
|
|
293
|
-
chunks.push(value);
|
|
294
|
-
}
|
|
296
|
+
async function readCapped(res, maxBytes, signal) {
|
|
297
|
+
const read = await readResponseBounded(res, { maxBytes, signal });
|
|
298
|
+
if (read.ok) {
|
|
299
|
+
if (read.truncated)
|
|
300
|
+
throw new BodyTooLarge();
|
|
301
|
+
return read.text;
|
|
295
302
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
303
|
+
switch (read.code) {
|
|
304
|
+
case "unsupported-encoding":
|
|
305
|
+
throw new BodyUnreadable("uses a stack of content-encodings this package cannot decode within its cap");
|
|
306
|
+
case "decode-error":
|
|
307
|
+
case "auto-decompressed":
|
|
308
|
+
throw new BodyUnreadable("is labelled as compressed but could not be decoded");
|
|
309
|
+
default:
|
|
310
|
+
// Aborted, stalled, or the connection closed: the caller reports these
|
|
311
|
+
// as the transport failures they are.
|
|
312
|
+
throw new Error(read.reason);
|
|
304
313
|
}
|
|
305
|
-
return new TextDecoder().decode(joined);
|
|
306
314
|
}
|
|
307
315
|
/** The longest JSON-RPC error message this package will carry. */
|
|
308
316
|
export const MAX_ERROR_MESSAGE_CHARS = 2_000;
|
|
@@ -316,6 +324,26 @@ function snippet(text) {
|
|
|
316
324
|
const flat = text.replace(/\s+/g, " ").trim();
|
|
317
325
|
return flat.length > 200 ? `${flat.slice(0, 200)}…` : flat;
|
|
318
326
|
}
|
|
327
|
+
/**
|
|
328
|
+
* Take an endpoint's path and query out of a message this package did not
|
|
329
|
+
* write — a dialler's error, which may quote the URL it could not reach, the
|
|
330
|
+
* pinned-IP spelling of it included. The origin is kept (every message here
|
|
331
|
+
* names it anyway); the path and the query, where a provider keeps its key,
|
|
332
|
+
* become `<redacted>`.
|
|
333
|
+
*/
|
|
334
|
+
export function withoutPath(message, url) {
|
|
335
|
+
let out = message.split(url.href).join(url.origin);
|
|
336
|
+
const pieces = [
|
|
337
|
+
[`${url.pathname}${url.search}`, "/<redacted>"],
|
|
338
|
+
[url.pathname, "/<redacted>"],
|
|
339
|
+
[url.search, "?<redacted>"],
|
|
340
|
+
];
|
|
341
|
+
for (const [piece, mask] of pieces) {
|
|
342
|
+
if (piece.length > 1)
|
|
343
|
+
out = out.split(piece).join(mask);
|
|
344
|
+
}
|
|
345
|
+
return out;
|
|
346
|
+
}
|
|
319
347
|
/** Turn a failed outcome into the refusal a tool throws, with the reason kept. */
|
|
320
348
|
export function failed(outcome, what) {
|
|
321
349
|
const code = outcome.code === undefined ? "" : ` (code ${outcome.code})`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/tool-chainread",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deterministic EVM chain reads over a public RPC: blocks, transactions, receipts, logs, nonces, and an address's history as reconciler rows",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -8,19 +8,21 @@
|
|
|
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.
|
|
19
|
-
"@crewhaus/errors": "0.
|
|
20
|
-
"@crewhaus/tool-builder": "0.
|
|
21
|
-
"@crewhaus/tool-catalog": "0.
|
|
22
|
-
"@crewhaus/tool-money": "0.
|
|
23
|
-
"@crewhaus/tool-onchain": "0.
|
|
19
|
+
"@crewhaus/chain-adapter-base": "0.8.1",
|
|
20
|
+
"@crewhaus/errors": "0.8.1",
|
|
21
|
+
"@crewhaus/tool-builder": "0.8.1",
|
|
22
|
+
"@crewhaus/tool-catalog": "0.8.1",
|
|
23
|
+
"@crewhaus/tool-money": "0.8.1",
|
|
24
|
+
"@crewhaus/tool-onchain": "0.8.1",
|
|
25
|
+
"@crewhaus/tool-safety": "0.8.1",
|
|
24
26
|
"zod": "^3.23.8"
|
|
25
27
|
},
|
|
26
28
|
"devDependencies": {
|
|
@@ -44,5 +46,8 @@
|
|
|
44
46
|
"publishConfig": {
|
|
45
47
|
"access": "public"
|
|
46
48
|
},
|
|
47
|
-
"files": ["dist", "README.md", "LICENSE", "NOTICE"]
|
|
49
|
+
"files": ["dist", "README.md", "LICENSE", "NOTICE"],
|
|
50
|
+
"engines": {
|
|
51
|
+
"bun": ">=1.3.14"
|
|
52
|
+
}
|
|
48
53
|
}
|
package/dist/fixtures.d.ts
DELETED
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* A recorded chain, and an RPC endpoint that serves it.
|
|
3
|
-
*
|
|
4
|
-
* No test in this package opens a socket. The stub below answers the ten
|
|
5
|
-
* JSON-RPC methods these tools use, from data built here, and its knobs are the
|
|
6
|
-
* ways a real public endpoint misbehaves: a block-span cap that errors with a
|
|
7
|
-
* suggested range, a result cap that errors, a result cap that SILENTLY
|
|
8
|
-
* truncates, a pruned archive, a mempool the endpoint cannot see, and an HTTP
|
|
9
|
-
* layer that rate-limits. Those knobs are the fixtures that matter — the happy
|
|
10
|
-
* path is the easy half.
|
|
11
|
-
*
|
|
12
|
-
* Every request is recorded in `requests`, so a test can assert what a tool
|
|
13
|
-
* actually asked for: that a scan halved its range, that a search spent the
|
|
14
|
-
* probes it claimed, that a poll stopped when it said it did.
|
|
15
|
-
*/
|
|
16
|
-
import type { RpcFetch } from "./lib/rpc";
|
|
17
|
-
/** A deterministic 32-byte hash from a label, so fixtures are readable in a diff. */
|
|
18
|
-
export declare function hash32(label: string): string;
|
|
19
|
-
/** A deterministic 20-byte address from a label. */
|
|
20
|
-
export declare function address20(label: string): string;
|
|
21
|
-
export declare const TOKEN: string;
|
|
22
|
-
export declare const ALICE: string;
|
|
23
|
-
export declare const BOB: string;
|
|
24
|
-
export declare const ROUTER: string;
|
|
25
|
-
export declare const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
|
|
26
|
-
export declare const APPROVAL_TOPIC = "0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925";
|
|
27
|
-
export declare function topicFor(address: string): string;
|
|
28
|
-
export declare function word(value: bigint): string;
|
|
29
|
-
export type FixtureBlock = {
|
|
30
|
-
readonly number: bigint;
|
|
31
|
-
readonly timestamp: bigint;
|
|
32
|
-
readonly hash: string;
|
|
33
|
-
readonly baseFeePerGas: bigint | null;
|
|
34
|
-
};
|
|
35
|
-
export type FixtureLog = {
|
|
36
|
-
readonly blockNumber: bigint;
|
|
37
|
-
readonly logIndex: number;
|
|
38
|
-
readonly address: string;
|
|
39
|
-
readonly topics: ReadonlyArray<string>;
|
|
40
|
-
readonly data: string;
|
|
41
|
-
readonly transactionHash: string;
|
|
42
|
-
};
|
|
43
|
-
export type FixtureTx = {
|
|
44
|
-
readonly hash: string;
|
|
45
|
-
readonly from: string;
|
|
46
|
-
readonly to: string | null;
|
|
47
|
-
readonly value: bigint;
|
|
48
|
-
readonly nonce: bigint;
|
|
49
|
-
readonly input: string;
|
|
50
|
-
readonly blockNumber: bigint | null;
|
|
51
|
-
};
|
|
52
|
-
export type FixtureReceipt = {
|
|
53
|
-
readonly hash: string;
|
|
54
|
-
readonly blockNumber: bigint;
|
|
55
|
-
readonly status: "0x1" | "0x0" | null;
|
|
56
|
-
readonly gasUsed: bigint;
|
|
57
|
-
readonly effectiveGasPrice: bigint | null;
|
|
58
|
-
readonly l1Fee?: bigint;
|
|
59
|
-
readonly gasUsedForL1?: bigint;
|
|
60
|
-
readonly root?: string;
|
|
61
|
-
/** Set only by a contract creation, which is the one case a transaction has no `to`. */
|
|
62
|
-
readonly contractAddress?: string;
|
|
63
|
-
/** The transaction's place in its block, as the chain numbers it. Default 0. */
|
|
64
|
-
readonly transactionIndex?: number;
|
|
65
|
-
readonly logs: ReadonlyArray<FixtureLog>;
|
|
66
|
-
};
|
|
67
|
-
export type Chain = {
|
|
68
|
-
readonly chainId: bigint;
|
|
69
|
-
blocks: FixtureBlock[];
|
|
70
|
-
logs: FixtureLog[];
|
|
71
|
-
txs: Map<string, FixtureTx>;
|
|
72
|
-
receipts: Map<string, FixtureReceipt>;
|
|
73
|
-
/** address → { latest, pending } nonce. */
|
|
74
|
-
nonces: Map<string, {
|
|
75
|
-
latest: bigint;
|
|
76
|
-
pending: bigint;
|
|
77
|
-
}>;
|
|
78
|
-
head: bigint;
|
|
79
|
-
/** Tagged blocks the chain serves; a chain that serves neither is the pre-Merge case. */
|
|
80
|
-
safe: bigint | null;
|
|
81
|
-
finalized: bigint | null;
|
|
82
|
-
};
|
|
83
|
-
export type ChainOptions = {
|
|
84
|
-
readonly chainId?: bigint;
|
|
85
|
-
readonly blocks?: number;
|
|
86
|
-
readonly startTimestamp?: bigint;
|
|
87
|
-
readonly blockTime?: bigint;
|
|
88
|
-
readonly baseFee?: bigint | null;
|
|
89
|
-
};
|
|
90
|
-
export declare function makeChain(options?: ChainOptions): Chain;
|
|
91
|
-
/** Add `count` ERC-20 Transfer logs to one block, from ALICE to BOB. */
|
|
92
|
-
export declare function addTransferLogs(chain: Chain, blockNumber: bigint, count: number, token?: string): void;
|
|
93
|
-
export declare function blockOf(chain: Chain, number: bigint): FixtureBlock | undefined;
|
|
94
|
-
export type StubOptions = {
|
|
95
|
-
/** Error when a getLogs span exceeds this, naming a range that would have worked. */
|
|
96
|
-
readonly maxRangeBlocks?: bigint;
|
|
97
|
-
/** Error when more than this many logs match — the "query returned more than N results" family. */
|
|
98
|
-
readonly maxResults?: number;
|
|
99
|
-
/** Return the first N matches and say nothing. This is the one that needs catching. */
|
|
100
|
-
readonly silentLogCap?: number;
|
|
101
|
-
/** `no` prunes historic state; `error` fails the probe for an unrelated reason. */
|
|
102
|
-
readonly archive?: "yes" | "no" | "error";
|
|
103
|
-
/** When false, the pending nonce equals the latest one however full the mempool is. */
|
|
104
|
-
readonly mempoolAware?: boolean;
|
|
105
|
-
/** Answer every request with this HTTP status instead. */
|
|
106
|
-
readonly httpStatus?: number;
|
|
107
|
-
/** Throw from the dialer, as an unreachable host does. */
|
|
108
|
-
readonly unreachable?: boolean;
|
|
109
|
-
/** Serve neither `safe` nor `finalized`, as a pre-Merge or minimal endpoint does. */
|
|
110
|
-
readonly noFinalityTags?: boolean;
|
|
111
|
-
};
|
|
112
|
-
export type RpcStub = {
|
|
113
|
-
readonly fetch: RpcFetch;
|
|
114
|
-
/** Every request, in order: `{ method, params }`. */
|
|
115
|
-
readonly requests: Array<{
|
|
116
|
-
readonly method: string;
|
|
117
|
-
readonly params: ReadonlyArray<unknown>;
|
|
118
|
-
}>;
|
|
119
|
-
/** Requests for one method, for the assertions that count probes or polls. */
|
|
120
|
-
count(method: string): number;
|
|
121
|
-
};
|
|
122
|
-
/** Build an endpoint that serves `chain`, misbehaving in whatever way `options` says. */
|
|
123
|
-
export declare function rpcStub(chain: Chain, options?: StubOptions): RpcStub;
|
package/dist/fixtures.js
DELETED
|
@@ -1,309 +0,0 @@
|
|
|
1
|
-
/** A deterministic 32-byte hash from a label, so fixtures are readable in a diff. */
|
|
2
|
-
export function hash32(label) {
|
|
3
|
-
const hex = [...label].map((c) => c.charCodeAt(0).toString(16).padStart(2, "0")).join("");
|
|
4
|
-
return `0x${(hex + "0".repeat(64)).slice(0, 64)}`;
|
|
5
|
-
}
|
|
6
|
-
/** A deterministic 20-byte address from a label. */
|
|
7
|
-
export function address20(label) {
|
|
8
|
-
const hex = [...label].map((c) => c.charCodeAt(0).toString(16).padStart(2, "0")).join("");
|
|
9
|
-
return `0x${(hex + "0".repeat(40)).slice(0, 40)}`;
|
|
10
|
-
}
|
|
11
|
-
const hex = (value) => `0x${value.toString(16)}`;
|
|
12
|
-
export const TOKEN = address20("token");
|
|
13
|
-
export const ALICE = address20("alice");
|
|
14
|
-
export const BOB = address20("bob");
|
|
15
|
-
export const ROUTER = address20("router");
|
|
16
|
-
export const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
|
|
17
|
-
export const APPROVAL_TOPIC = "0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925";
|
|
18
|
-
export function topicFor(address) {
|
|
19
|
-
return `0x${"0".repeat(24)}${address.slice(2)}`;
|
|
20
|
-
}
|
|
21
|
-
export function word(value) {
|
|
22
|
-
return value.toString(16).padStart(64, "0");
|
|
23
|
-
}
|
|
24
|
-
export function makeChain(options = {}) {
|
|
25
|
-
const count = options.blocks ?? 100;
|
|
26
|
-
const start = options.startTimestamp ?? 1700000000n;
|
|
27
|
-
const step = options.blockTime ?? 12n;
|
|
28
|
-
const blocks = [];
|
|
29
|
-
for (let i = 0; i < count; i++) {
|
|
30
|
-
const number = BigInt(i);
|
|
31
|
-
blocks.push({
|
|
32
|
-
number,
|
|
33
|
-
timestamp: start + number * step,
|
|
34
|
-
hash: hash32(`block-${i}`),
|
|
35
|
-
baseFeePerGas: options.baseFee === undefined ? 1000000000n : options.baseFee,
|
|
36
|
-
});
|
|
37
|
-
}
|
|
38
|
-
return {
|
|
39
|
-
chainId: options.chainId ?? 8453n,
|
|
40
|
-
blocks,
|
|
41
|
-
logs: [],
|
|
42
|
-
txs: new Map(),
|
|
43
|
-
receipts: new Map(),
|
|
44
|
-
nonces: new Map(),
|
|
45
|
-
head: BigInt(count - 1),
|
|
46
|
-
safe: BigInt(Math.max(0, count - 32)),
|
|
47
|
-
finalized: BigInt(Math.max(0, count - 64)),
|
|
48
|
-
};
|
|
49
|
-
}
|
|
50
|
-
/** Add `count` ERC-20 Transfer logs to one block, from ALICE to BOB. */
|
|
51
|
-
export function addTransferLogs(chain, blockNumber, count, token = TOKEN) {
|
|
52
|
-
const existing = chain.logs.filter((l) => l.blockNumber === blockNumber).length;
|
|
53
|
-
for (let i = 0; i < count; i++) {
|
|
54
|
-
chain.logs.push({
|
|
55
|
-
blockNumber,
|
|
56
|
-
logIndex: existing + i,
|
|
57
|
-
address: token,
|
|
58
|
-
topics: [TRANSFER_TOPIC, topicFor(ALICE), topicFor(BOB)],
|
|
59
|
-
data: `0x${word(BigInt(1_000 + i))}`,
|
|
60
|
-
transactionHash: hash32(`tx-${blockNumber}-${i}`),
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
export function blockOf(chain, number) {
|
|
65
|
-
return chain.blocks.find((b) => b.number === number);
|
|
66
|
-
}
|
|
67
|
-
function renderBlock(chain, block, hydrate) {
|
|
68
|
-
const txs = [...chain.txs.values()].filter((t) => t.blockNumber === block.number);
|
|
69
|
-
return {
|
|
70
|
-
number: hex(block.number),
|
|
71
|
-
hash: block.hash,
|
|
72
|
-
parentHash: block.number === 0n ? hash32("genesis-parent") : hash32(`block-${block.number - 1n}`),
|
|
73
|
-
timestamp: hex(block.timestamp),
|
|
74
|
-
gasLimit: hex(30000000n),
|
|
75
|
-
gasUsed: hex(15000000n),
|
|
76
|
-
...(block.baseFeePerGas === null ? {} : { baseFeePerGas: hex(block.baseFeePerGas) }),
|
|
77
|
-
miner: address20("miner"),
|
|
78
|
-
transactions: hydrate ? txs.map((t) => renderTx(t)) : txs.map((t) => t.hash),
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
function renderTx(tx) {
|
|
82
|
-
return {
|
|
83
|
-
hash: tx.hash,
|
|
84
|
-
from: tx.from,
|
|
85
|
-
to: tx.to,
|
|
86
|
-
value: hex(tx.value),
|
|
87
|
-
nonce: hex(tx.nonce),
|
|
88
|
-
input: tx.input,
|
|
89
|
-
gas: hex(210000n),
|
|
90
|
-
gasPrice: hex(1500000000n),
|
|
91
|
-
maxFeePerGas: hex(2000000000n),
|
|
92
|
-
maxPriorityFeePerGas: hex(100000000n),
|
|
93
|
-
type: "0x2",
|
|
94
|
-
blockNumber: tx.blockNumber === null ? null : hex(tx.blockNumber),
|
|
95
|
-
blockHash: tx.blockNumber === null ? null : hash32(`block-${tx.blockNumber}`),
|
|
96
|
-
transactionIndex: tx.blockNumber === null ? null : "0x0",
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
function renderLog(log) {
|
|
100
|
-
return {
|
|
101
|
-
address: log.address,
|
|
102
|
-
topics: log.topics,
|
|
103
|
-
data: log.data,
|
|
104
|
-
blockNumber: hex(log.blockNumber),
|
|
105
|
-
blockHash: hash32(`block-${log.blockNumber}`),
|
|
106
|
-
transactionHash: log.transactionHash,
|
|
107
|
-
transactionIndex: "0x0",
|
|
108
|
-
logIndex: hex(BigInt(log.logIndex)),
|
|
109
|
-
removed: false,
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
function renderReceipt(receipt) {
|
|
113
|
-
return {
|
|
114
|
-
transactionHash: receipt.hash,
|
|
115
|
-
blockNumber: hex(receipt.blockNumber),
|
|
116
|
-
blockHash: hash32(`block-${receipt.blockNumber}`),
|
|
117
|
-
transactionIndex: hex(BigInt(receipt.transactionIndex ?? 0)),
|
|
118
|
-
gasUsed: hex(receipt.gasUsed),
|
|
119
|
-
...(receipt.status === null ? {} : { status: receipt.status }),
|
|
120
|
-
...(receipt.root === undefined ? {} : { root: receipt.root }),
|
|
121
|
-
...(receipt.effectiveGasPrice === null
|
|
122
|
-
? {}
|
|
123
|
-
: { effectiveGasPrice: hex(receipt.effectiveGasPrice) }),
|
|
124
|
-
...(receipt.l1Fee === undefined ? {} : { l1Fee: hex(receipt.l1Fee) }),
|
|
125
|
-
...(receipt.gasUsedForL1 === undefined ? {} : { gasUsedForL1: hex(receipt.gasUsedForL1) }),
|
|
126
|
-
contractAddress: receipt.contractAddress ?? null,
|
|
127
|
-
logs: receipt.logs.map(renderLog),
|
|
128
|
-
};
|
|
129
|
-
}
|
|
130
|
-
/** Build an endpoint that serves `chain`, misbehaving in whatever way `options` says. */
|
|
131
|
-
export function rpcStub(chain, options = {}) {
|
|
132
|
-
const requests = [];
|
|
133
|
-
const stub = {
|
|
134
|
-
requests,
|
|
135
|
-
count: (method) => requests.filter((r) => r.method === method).length,
|
|
136
|
-
fetch: async (req) => {
|
|
137
|
-
if (options.unreachable === true)
|
|
138
|
-
throw new TypeError("connect ECONNREFUSED");
|
|
139
|
-
const body = (await req.json());
|
|
140
|
-
const params = body.params ?? [];
|
|
141
|
-
requests.push({ method: body.method, params });
|
|
142
|
-
if (options.httpStatus !== undefined && options.httpStatus !== 200) {
|
|
143
|
-
return new Response(`{"error":"rate limited"}`, { status: options.httpStatus });
|
|
144
|
-
}
|
|
145
|
-
const answer = dispatch(chain, options, body.method, params);
|
|
146
|
-
const envelope = "error" in answer
|
|
147
|
-
? { jsonrpc: "2.0", id: body.id, error: answer.error }
|
|
148
|
-
: { jsonrpc: "2.0", id: body.id, result: answer.result };
|
|
149
|
-
return new Response(JSON.stringify(envelope), {
|
|
150
|
-
status: 200,
|
|
151
|
-
headers: { "content-type": "application/json" },
|
|
152
|
-
});
|
|
153
|
-
},
|
|
154
|
-
};
|
|
155
|
-
return stub;
|
|
156
|
-
}
|
|
157
|
-
function dispatch(chain, options, method, params) {
|
|
158
|
-
switch (method) {
|
|
159
|
-
case "eth_chainId":
|
|
160
|
-
return { result: hex(chain.chainId) };
|
|
161
|
-
case "net_version":
|
|
162
|
-
return { result: chain.chainId.toString() };
|
|
163
|
-
case "eth_blockNumber":
|
|
164
|
-
return { result: hex(chain.head) };
|
|
165
|
-
case "eth_getBlockByNumber": {
|
|
166
|
-
const tag = params[0];
|
|
167
|
-
const hydrate = params[1] === true;
|
|
168
|
-
const number = resolveTag(chain, options, tag);
|
|
169
|
-
if (number === null)
|
|
170
|
-
return { result: null };
|
|
171
|
-
const block = blockOf(chain, number);
|
|
172
|
-
if (block === undefined)
|
|
173
|
-
return { result: null };
|
|
174
|
-
const rendered = renderBlock(chain, block, hydrate);
|
|
175
|
-
// The pending block is the head's contents with no identity yet: a node
|
|
176
|
-
// answers it with number and hash both null.
|
|
177
|
-
if (tag === "pending")
|
|
178
|
-
return { result: { ...rendered, number: null, hash: null } };
|
|
179
|
-
return { result: rendered };
|
|
180
|
-
}
|
|
181
|
-
case "eth_getBlockByHash": {
|
|
182
|
-
const wanted = params[0].toLowerCase();
|
|
183
|
-
const block = chain.blocks.find((b) => b.hash === wanted);
|
|
184
|
-
return { result: block === undefined ? null : renderBlock(chain, block, params[1] === true) };
|
|
185
|
-
}
|
|
186
|
-
case "eth_getTransactionByHash": {
|
|
187
|
-
const tx = chain.txs.get(params[0].toLowerCase());
|
|
188
|
-
return { result: tx === undefined ? null : renderTx(tx) };
|
|
189
|
-
}
|
|
190
|
-
case "eth_getTransactionReceipt": {
|
|
191
|
-
const receipt = chain.receipts.get(params[0].toLowerCase());
|
|
192
|
-
return { result: receipt === undefined ? null : renderReceipt(receipt) };
|
|
193
|
-
}
|
|
194
|
-
case "eth_getTransactionCount": {
|
|
195
|
-
const who = params[0].toLowerCase();
|
|
196
|
-
const account = chain.nonces.get(who) ?? { latest: 0n, pending: 0n };
|
|
197
|
-
const tag = params[1];
|
|
198
|
-
if (typeof tag === "string" && tag.startsWith("0x")) {
|
|
199
|
-
// A HISTORICAL nonce, which is account state and not a counter the node
|
|
200
|
-
// keeps lying around: a pruned endpoint refuses it exactly as it
|
|
201
|
-
// refuses a historical balance, and a sync that proves its block
|
|
202
|
-
// coverage against this has to cope with both answers.
|
|
203
|
-
const at = BigInt(tag);
|
|
204
|
-
if (options.archive === "no" && chain.head - at > 128n) {
|
|
205
|
-
return { error: { code: -32000, message: "missing trie node 0xabc (path )" } };
|
|
206
|
-
}
|
|
207
|
-
const mined = [...chain.txs.values()].filter((t) => t.blockNumber !== null && t.from.toLowerCase() === who);
|
|
208
|
-
// Derived from the chain's own transactions so the two answers cannot
|
|
209
|
-
// disagree by accident: a test that wants them to disagree says so.
|
|
210
|
-
const base = chain.nonces.has(who) ? account.latest : BigInt(mined.length);
|
|
211
|
-
const after = mined.filter((t) => t.blockNumber > at).length;
|
|
212
|
-
const value = base - BigInt(after);
|
|
213
|
-
return { result: hex(value < 0n ? 0n : value) };
|
|
214
|
-
}
|
|
215
|
-
const wantsPending = tag === "pending";
|
|
216
|
-
// An endpoint with no mempool view answers both tags with the mined
|
|
217
|
-
// count, which is the case that makes a naive "pending === latest means
|
|
218
|
-
// clear" read wrong.
|
|
219
|
-
if (wantsPending && options.mempoolAware !== false)
|
|
220
|
-
return { result: hex(account.pending) };
|
|
221
|
-
return { result: hex(account.latest) };
|
|
222
|
-
}
|
|
223
|
-
case "eth_getBalance": {
|
|
224
|
-
const at = params[1];
|
|
225
|
-
const number = at.startsWith("0x") && at.length < 20 ? BigInt(at) : chain.head;
|
|
226
|
-
if (options.archive === "no" && chain.head - number > 128n) {
|
|
227
|
-
return { error: { code: -32000, message: "missing trie node 0xabc (path )" } };
|
|
228
|
-
}
|
|
229
|
-
if (options.archive === "error" && chain.head - number > 128n) {
|
|
230
|
-
return { error: { code: -32601, message: "the method eth_getBalance does not exist" } };
|
|
231
|
-
}
|
|
232
|
-
return { result: hex(1234000000000000000n) };
|
|
233
|
-
}
|
|
234
|
-
case "eth_getLogs":
|
|
235
|
-
return getLogs(chain, options, params[0]);
|
|
236
|
-
default:
|
|
237
|
-
return { error: { code: -32601, message: `the method ${method} does not exist` } };
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
function resolveTag(chain, options, tag) {
|
|
241
|
-
if (tag === "latest")
|
|
242
|
-
return chain.head;
|
|
243
|
-
if (tag === "earliest")
|
|
244
|
-
return 0n;
|
|
245
|
-
if (tag === "pending")
|
|
246
|
-
return chain.head;
|
|
247
|
-
if (tag === "safe" || tag === "finalized") {
|
|
248
|
-
if (options.noFinalityTags === true)
|
|
249
|
-
return null;
|
|
250
|
-
return tag === "safe" ? chain.safe : chain.finalized;
|
|
251
|
-
}
|
|
252
|
-
return BigInt(tag);
|
|
253
|
-
}
|
|
254
|
-
function getLogs(chain, options, filter) {
|
|
255
|
-
const from = BigInt(filter["fromBlock"]);
|
|
256
|
-
const to = BigInt(filter["toBlock"]);
|
|
257
|
-
const span = to - from + 1n;
|
|
258
|
-
if (options.maxRangeBlocks !== undefined && span > options.maxRangeBlocks) {
|
|
259
|
-
// Alchemy's shape: it names a range that would have worked, in hex.
|
|
260
|
-
const suggestedTo = from + options.maxRangeBlocks - 1n;
|
|
261
|
-
return {
|
|
262
|
-
error: {
|
|
263
|
-
code: -32600,
|
|
264
|
-
message: `Log response size exceeded. You can make eth_getLogs requests with up to a ${options.maxRangeBlocks} block range; based on your parameters this block range should work: [${hex(from)}, ${hex(suggestedTo)}]`,
|
|
265
|
-
},
|
|
266
|
-
};
|
|
267
|
-
}
|
|
268
|
-
const addressFilter = filter["address"];
|
|
269
|
-
const addresses = addressFilter === undefined
|
|
270
|
-
? null
|
|
271
|
-
: new Set((Array.isArray(addressFilter) ? addressFilter : [addressFilter]).map((a) => String(a).toLowerCase()));
|
|
272
|
-
const topics = filter["topics"];
|
|
273
|
-
const matched = chain.logs
|
|
274
|
-
.filter((log) => log.blockNumber >= from && log.blockNumber <= to)
|
|
275
|
-
.filter((log) => addresses === null || addresses.has(log.address.toLowerCase()))
|
|
276
|
-
.filter((log) => topicsMatch(log, topics))
|
|
277
|
-
.sort((a, b) => a.blockNumber === b.blockNumber
|
|
278
|
-
? a.logIndex - b.logIndex
|
|
279
|
-
: a.blockNumber < b.blockNumber
|
|
280
|
-
? -1
|
|
281
|
-
: 1);
|
|
282
|
-
if (options.maxResults !== undefined && matched.length > options.maxResults) {
|
|
283
|
-
return {
|
|
284
|
-
error: {
|
|
285
|
-
code: -32005,
|
|
286
|
-
message: `query returned more than ${options.maxResults} results`,
|
|
287
|
-
},
|
|
288
|
-
};
|
|
289
|
-
}
|
|
290
|
-
if (options.silentLogCap !== undefined && matched.length > options.silentLogCap) {
|
|
291
|
-
// The dangerous one: a 200, a well-formed array, and nothing saying it is
|
|
292
|
-
// short. This is what the scanner has to catch without being told.
|
|
293
|
-
return { result: matched.slice(0, options.silentLogCap).map(renderLog) };
|
|
294
|
-
}
|
|
295
|
-
return { result: matched.map(renderLog) };
|
|
296
|
-
}
|
|
297
|
-
function topicsMatch(log, topics) {
|
|
298
|
-
if (topics === undefined)
|
|
299
|
-
return true;
|
|
300
|
-
return topics.every((position, index) => {
|
|
301
|
-
if (position === null)
|
|
302
|
-
return true;
|
|
303
|
-
const actual = log.topics[index];
|
|
304
|
-
if (actual === undefined)
|
|
305
|
-
return false;
|
|
306
|
-
const wanted = Array.isArray(position) ? position : [position];
|
|
307
|
-
return wanted.some((t) => String(t).toLowerCase() === actual.toLowerCase());
|
|
308
|
-
});
|
|
309
|
-
}
|