@crewhaus/tool-chainread 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +396 -0
- package/dist/fixtures.d.ts +123 -0
- package/dist/fixtures.js +309 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +1302 -0
- package/dist/lib/blocks.d.ts +113 -0
- package/dist/lib/blocks.js +192 -0
- package/dist/lib/clock.d.ts +34 -0
- package/dist/lib/clock.js +55 -0
- package/dist/lib/endpoint.d.ts +91 -0
- package/dist/lib/endpoint.js +320 -0
- package/dist/lib/history.d.ts +196 -0
- package/dist/lib/history.js +663 -0
- package/dist/lib/logs.d.ts +79 -0
- package/dist/lib/logs.js +284 -0
- package/dist/lib/quantity.d.ts +69 -0
- package/dist/lib/quantity.js +120 -0
- package/dist/lib/rpc.d.ts +63 -0
- package/dist/lib/rpc.js +330 -0
- package/dist/lib/transfers.d.ts +134 -0
- package/dist/lib/transfers.js +231 -0
- package/package.json +48 -0
package/README.md
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
# @crewhaus/tool-chainread
|
|
2
|
+
|
|
3
|
+
What the chain actually recorded, read over a public RPC endpoint you name.
|
|
4
|
+
|
|
5
|
+
| Tool | Answers |
|
|
6
|
+
|---|---|
|
|
7
|
+
| `EvmGetBlock` | what is in this block — by number, by tag, or by hash |
|
|
8
|
+
| `EvmBlockAtTimestamp` | which block was the chain at, at this moment |
|
|
9
|
+
| `EvmRpcHealth` | what is this endpoint, and what can it serve |
|
|
10
|
+
| `EvmNonceStatus` | is this account clear to send, or is something stuck |
|
|
11
|
+
| `EvmWaitForReceipt` | did it land, did it revert, or is it still pending |
|
|
12
|
+
| `EvmTransactionSummary` | what did this transaction actually move |
|
|
13
|
+
| `EvmEventScan` | every log matching this filter over this range |
|
|
14
|
+
| `OnchainTransactionsSync` | this address's history, as the rows a reconciler already reads |
|
|
15
|
+
|
|
16
|
+
`@crewhaus/tool-onchain` next door does the arithmetic offline — calldata, digests,
|
|
17
|
+
checksums, token units. This package is the half that dials.
|
|
18
|
+
|
|
19
|
+
## Nothing here signs or sends
|
|
20
|
+
|
|
21
|
+
No schema accepts a private key, a mnemonic, a keystore or a signed payload, and no
|
|
22
|
+
code path can reach `eth_sendRawTransaction`. That is not a promise about the
|
|
23
|
+
call sites: **every** method name goes through `assertReadOnlyMethod` from
|
|
24
|
+
`@crewhaus/chain-adapter-base` — the same allow-list the chain adapters use — before a
|
|
25
|
+
socket is opened, so a write method throws inside the transport whatever asks for it.
|
|
26
|
+
Both halves are asserted in `index.test.ts`: the schemas are walked for key-shaped
|
|
27
|
+
field names, and the stub endpoint's own request log is checked for anything that
|
|
28
|
+
sends.
|
|
29
|
+
|
|
30
|
+
## Three outcomes, not two
|
|
31
|
+
|
|
32
|
+
`EvmWaitForReceipt` exists because callers conflate three things that need three
|
|
33
|
+
different next moves:
|
|
34
|
+
|
|
35
|
+
| Outcome | What happened | What you do |
|
|
36
|
+
|---|---|---|
|
|
37
|
+
| `mined` + `status: "success"` | it landed and did what it said | carry on |
|
|
38
|
+
| `mined` + `status: "reverted"` | it landed and the call failed | fix the call — **do not** resend |
|
|
39
|
+
| `deadline` | still unmined when the clock ran out | wait longer, or speed it up |
|
|
40
|
+
|
|
41
|
+
A reverted transaction **has a receipt**, with `status: 0x0`. A tool that reports "no
|
|
42
|
+
receipt" for it tells the caller to broadcast again, and a failed transaction becomes
|
|
43
|
+
two. There is a fourth distinction inside the third: `knownToEndpoint` says whether
|
|
44
|
+
this endpoint is holding the transaction in its mempool or has never heard of it,
|
|
45
|
+
which is the difference between "slow" and "it never propagated, or it went to a
|
|
46
|
+
different chain".
|
|
47
|
+
|
|
48
|
+
A receipt that arrived but is not yet as deep as you asked for still reports as
|
|
49
|
+
`mined`, with `settled: false` — the clock running out does not un-mine anything.
|
|
50
|
+
`finality: "safe" | "finalized"` uses the chain's own tag instead of counting blocks;
|
|
51
|
+
an endpoint that does not serve that tag is a refusal, not a quiet fallback to
|
|
52
|
+
counting, because counting is not finality on a chain that reorganises in bursts.
|
|
53
|
+
|
|
54
|
+
There is one thing it deliberately does not do: explain a revert. Replaying the call
|
|
55
|
+
to recover the revert reason needs the state as it was at mining time, which needs an
|
|
56
|
+
archive node, and replaying it against the head instead produces a "reason" that is
|
|
57
|
+
sometimes right and always confident. Use `EvmRpcHealth` to find out whether your
|
|
58
|
+
endpoint keeps archive state, then replay it yourself if it does.
|
|
59
|
+
|
|
60
|
+
## The invariant `EvmBlockAtTimestamp` returns
|
|
61
|
+
|
|
62
|
+
> The returned block is the **last** block whose timestamp is `<= target`, and the
|
|
63
|
+
> block after it has a timestamp `> target`.
|
|
64
|
+
|
|
65
|
+
Half of that — "a block at or before the timestamp" — is satisfied by the genesis
|
|
66
|
+
block, which is why the second half is part of the contract. The result carries a
|
|
67
|
+
`certificate` with all three numbers so the invariant can be checked without another
|
|
68
|
+
call, and the block after is returned as `next`.
|
|
69
|
+
|
|
70
|
+
The boundaries are where a plausible wrong answer is indistinguishable from the right
|
|
71
|
+
one, so each one has a defined behaviour:
|
|
72
|
+
|
|
73
|
+
- **Before the first block.** Refused, naming the earliest block and its timestamp.
|
|
74
|
+
Answering "block 0" would send a caller to read state from before the chain existed.
|
|
75
|
+
- **At or past the head.** Returns the head with `atHead: true`, `next: null` and a
|
|
76
|
+
note that the answer will change as the chain advances. There is no block after the
|
|
77
|
+
head to prove a boundary with, and pretending otherwise is the lie.
|
|
78
|
+
- **Several blocks sharing one timestamp.** Returns the highest of them. Ties resolve
|
|
79
|
+
upward, because a caller reading state at the answer wants the state after
|
|
80
|
+
everything that happened at that instant.
|
|
81
|
+
- **Timestamps that go backwards.** Refused, naming both blocks. A binary search over
|
|
82
|
+
a non-monotonic sequence has more than one right answer, and every sample the search
|
|
83
|
+
takes is compared against every other so the assumption is checked rather than
|
|
84
|
+
assumed.
|
|
85
|
+
- **Milliseconds.** Refused by name. `Date.now()` pasted in would otherwise resolve to
|
|
86
|
+
"at or past the head" and answer with the head block, every time, looking entirely
|
|
87
|
+
ordinary.
|
|
88
|
+
|
|
89
|
+
The search is plain bisection: about 25 probes over a twenty-million-block chain, and
|
|
90
|
+
`fromBlock`/`toBlock` cut both the bracket and the probe count. Interpolating from an
|
|
91
|
+
average block time would converge faster on a chain with a steady block time and badly
|
|
92
|
+
on one that changed — Ethereum before and after the Merge is two different chains by
|
|
93
|
+
that measure — and the failure mode of a bad estimate is a wrong bracket, so the
|
|
94
|
+
faster thing is not the safer thing here.
|
|
95
|
+
|
|
96
|
+
## `EvmEventScan` refuses rather than under-reporting
|
|
97
|
+
|
|
98
|
+
Public endpoints cap log queries and disagree about how. Three behaviours, three
|
|
99
|
+
answers:
|
|
100
|
+
|
|
101
|
+
1. **An error naming a smaller range.** The suggested range is parsed out of the error
|
|
102
|
+
and used directly, clamped so a suggestion can only ever narrow the request — an
|
|
103
|
+
endpoint answering "try 10000 blocks" to a 500-block query is describing its own
|
|
104
|
+
limit, not this request. Only the first 512 characters of the message are read, and
|
|
105
|
+
the parser's quantifiers are bounded: this string is the one field in the exchange a
|
|
106
|
+
provider fills in freely, inside a body allowed sixteen megabytes, and an unbounded
|
|
107
|
+
digit run costs quadratic time to fail to match. When nothing usable is in the
|
|
108
|
+
message, the span is halved.
|
|
109
|
+
The decision to narrow is made on the *shape* of the failure (a JSON-RPC error, an
|
|
110
|
+
oversized body, a timeout), never by matching error text: providers phrase the same
|
|
111
|
+
cap five ways. A 401, 403 or 429 never narrows, because retrying a rate limit in
|
|
112
|
+
halves turns one rejected request into sixteen.
|
|
113
|
+
2. **An error about the result count.** Same path.
|
|
114
|
+
3. **Silent truncation.** The endpoint returns the first N logs of a range that held
|
|
115
|
+
more, with a 200 and a well-formed array and nothing saying it is short. This is
|
|
116
|
+
the one that matters, because a harness that scans for `Transfer` and finds nothing
|
|
117
|
+
concludes the transfer did not happen.
|
|
118
|
+
|
|
119
|
+
Truncation is caught by proof, not by a heuristic about round numbers. A chunk whose
|
|
120
|
+
count reaches `suspectAt` (default 1000) is split and re-queried; if the halves
|
|
121
|
+
together hold more logs than the whole did, the whole was truncated. The recovered set
|
|
122
|
+
is returned with `paging.silentTruncations` and a warning saying the endpoint will do
|
|
123
|
+
it again — and the working span is narrowed so the rest of the scan does not
|
|
124
|
+
rediscover the same cap chunk after chunk.
|
|
125
|
+
|
|
126
|
+
When the split cannot go further because the chunk is a single block, there is nothing
|
|
127
|
+
left to prove it with, and the scan **refuses**. So does a result larger than `maxLogs`
|
|
128
|
+
— slicing it here would produce exactly the indistinguishable-from-complete set the
|
|
129
|
+
rest of this is about. Three more refusals: a log from outside the range that was
|
|
130
|
+
asked for, a log marked `removed`, and the same block height coming back with two
|
|
131
|
+
different hashes, which is the chain reorganising mid-scan.
|
|
132
|
+
|
|
133
|
+
`complete: true` is the only value that field ever has. The alternative is an error.
|
|
134
|
+
|
|
135
|
+
The range is resolved to numbers before the first query, so `toBlock: "latest"` is
|
|
136
|
+
pinned once; a scan whose upper bound keeps moving is not one anybody can call
|
|
137
|
+
complete. `confirmations` keeps the whole range behind the head by that many blocks.
|
|
138
|
+
|
|
139
|
+
## `OnchainTransactionsSync` borrows the row shape rather than inventing one
|
|
140
|
+
|
|
141
|
+
The rows it returns are `@crewhaus/tool-money`'s `Transaction` — the same shape
|
|
142
|
+
`StatementParse` produces and `LedgerReconcile` consumes — imported, not
|
|
143
|
+
re-declared. A second declaration of it is a shape that drifts the first time a
|
|
144
|
+
field is added, and an onchain history that cannot be matched against a bank
|
|
145
|
+
statement is the whole point of the tool gone.
|
|
146
|
+
|
|
147
|
+
That shape has one field this package would never have written: `amountMinor` is
|
|
148
|
+
a JS `number`. A uint256 is not, and `Number.MAX_SAFE_INTEGER` is about nine
|
|
149
|
+
thousandths of one coin in wei — so a plain payment does not fit in the field at
|
|
150
|
+
all. Nothing is rounded to make it. A movement that cannot be written exactly is
|
|
151
|
+
**parked**, in an `unrepresentable` list, with the raw amount and the reason:
|
|
152
|
+
|
|
153
|
+
| Reason | What it means |
|
|
154
|
+
|---|---|
|
|
155
|
+
| `exceedsSafeInteger` | the amount is past what the row's number field holds. Name the asset's `decimals` and the ledger's `minorUnitDecimals` and it scales into range |
|
|
156
|
+
| `belowMinorUnit` | 1.234567 USDC against a ledger kept in cents. No rounding of it is the amount that moved |
|
|
157
|
+
| `timestampOutOfRange` | the block's timestamp is past what a calendar date can express, so the row has no date |
|
|
158
|
+
| `undecodable` | a log carrying the Transfer topic without a Transfer's topics |
|
|
159
|
+
|
|
160
|
+
The exact uint256 also travels beside every row it *did* fit, in `detail`, keyed
|
|
161
|
+
by the row's id. A reconciler reads `rows`; an auditor reads `detail`.
|
|
162
|
+
|
|
163
|
+
### Three sources, and the ones there are not
|
|
164
|
+
|
|
165
|
+
A public endpoint has no index by address, so a history has to be reconstructed:
|
|
166
|
+
|
|
167
|
+
1. **Logged token transfers**, from `eth_getLogs` with the address in the from
|
|
168
|
+
or the to topic position — two scans, merged and deduped. Paged by the same
|
|
169
|
+
`scanLogs` that `EvmEventScan` uses, so the silent-truncation proof, the
|
|
170
|
+
reorg check and the refusal-rather-than-a-prefix behaviour are the tested
|
|
171
|
+
ones and not a second implementation of them.
|
|
172
|
+
2. **Native value and gas**, by hydrating every block in the range and picking
|
|
173
|
+
out the transactions this address was a party to. That is one request per
|
|
174
|
+
block, so the range is bounded by `maxHydratedBlocks` and a range past it is
|
|
175
|
+
a **refusal** — returning the token rows alone and calling it a history would
|
|
176
|
+
leave every coin payment out of a statement that says it is complete.
|
|
177
|
+
3. Nothing else, and the output says so rather than the docs alone:
|
|
178
|
+
**internal native transfers** need a trace, **ERC-1155** puts its parties in
|
|
179
|
+
topic positions this filter does not look at and its batch amounts in data no
|
|
180
|
+
filter reaches, and **token metadata** is never read off a token contract —
|
|
181
|
+
an airdrop whose `decimals()` returns 2 turns dust into a five-figure row, so
|
|
182
|
+
decimals arrive from the caller or the raw base units are used unchanged.
|
|
183
|
+
|
|
184
|
+
`tokens` is an allow-list, and it is applied twice: in the `eth_getLogs` filter,
|
|
185
|
+
so an airdrop is never fetched, and again against the logs that come back. A
|
|
186
|
+
filter is a request, not a proof — an endpoint that ignores it answers with every
|
|
187
|
+
token that ever touched the wallet, and those rows would land in a
|
|
188
|
+
reconciliation under a `coverage` line saying they were never scanned for.
|
|
189
|
+
|
|
190
|
+
The block path has its own version of the truncation trap. An endpoint that
|
|
191
|
+
ignores `fullTransactions: true` answers with a list of hashes; filtering hashes
|
|
192
|
+
on `from` matches nothing, and that empty result is indistinguishable from a
|
|
193
|
+
block this wallet was never in. It is refused by name.
|
|
194
|
+
|
|
195
|
+
### What the blocks held, checked against the nonce
|
|
196
|
+
|
|
197
|
+
A block is one response and cannot be re-asked in halves, so the split-and-
|
|
198
|
+
compare proof the log scan uses has nothing to work with here. The account's own
|
|
199
|
+
nonce is the oracle instead: it rises once per transaction sent, so its rise
|
|
200
|
+
across the range is how many of them those blocks must contain.
|
|
201
|
+
|
|
202
|
+
Fewer is a **refusal** — each one missed is a fee row and possibly a payment.
|
|
203
|
+
More is not: a rollup's system and deposit transactions appear in blocks without
|
|
204
|
+
raising an ordinary nonce, and nothing is missing in that direction. A pruned
|
|
205
|
+
endpoint that will not serve a historical nonce leaves the check unrun, and
|
|
206
|
+
`sentProof` says `unproved` with the reason rather than reporting a completeness
|
|
207
|
+
nothing established. What the address *received* has no such oracle at all, and
|
|
208
|
+
`coverage.nativeTransfers` says so in the output.
|
|
209
|
+
|
|
210
|
+
### The things that double-count
|
|
211
|
+
|
|
212
|
+
**Gas is charged once per transaction.** A wallet can appear in a dozen logs of
|
|
213
|
+
one swap, and a fee attributed per log charges the gas a dozen times — the
|
|
214
|
+
reconciliation is then off by exactly that. Fee rows come only from the block
|
|
215
|
+
path, keyed on the transaction, so it cannot happen.
|
|
216
|
+
|
|
217
|
+
**A reverted transaction moved nothing.** Its value transfer is discarded with
|
|
218
|
+
the rest of its state changes and only the fee is charged, so a receipt is read
|
|
219
|
+
for every matching transaction — for the status as much as for the fee. A mined
|
|
220
|
+
transaction whose receipt this endpoint does not have is a refusal, because
|
|
221
|
+
booking the movement either way is a guess.
|
|
222
|
+
|
|
223
|
+
**A transfer to oneself is not a row.** It changes no balance; one row would be
|
|
224
|
+
a debit that never happened.
|
|
225
|
+
|
|
226
|
+
### The cursor names where it stopped
|
|
227
|
+
|
|
228
|
+
`cursor.nextFromBlock` is the pinned upper bound plus one — not the head at the
|
|
229
|
+
end of the run, which has moved, and a cursor set from it skips every block
|
|
230
|
+
mined while the sync ran. `confirmations` keeps the whole range behind the head.
|
|
231
|
+
|
|
232
|
+
Dates are UTC calendar dates, because a block timestamp is UTC and nothing on
|
|
233
|
+
the chain knows the ledger's timezone. `balanceMinor` is always `null`: there is
|
|
234
|
+
no opening balance here to run one from, and a computed one is a number a
|
|
235
|
+
reconciliation would trust.
|
|
236
|
+
|
|
237
|
+
## `EvmRpcHealth` says unknown when it means unknown
|
|
238
|
+
|
|
239
|
+
Archive support is decided by reading a historic balance and interpreting what comes
|
|
240
|
+
back, and the interesting case is the third one:
|
|
241
|
+
|
|
242
|
+
- the read succeeds → `yes`
|
|
243
|
+
- it fails with a pruning error (`missing trie node`, `state is not available`, …) → `no`
|
|
244
|
+
- anything else → `unknown`, with the error as the evidence
|
|
245
|
+
|
|
246
|
+
"We could not tell" and "it does not" lead to different decisions, and only one of them
|
|
247
|
+
means find another endpoint. The probe block must be more than 128 blocks behind the
|
|
248
|
+
head, because that is how much state a plain full node keeps: a successful read inside
|
|
249
|
+
that window distinguishes nothing, and a short-lived devnet is entirely inside it —
|
|
250
|
+
also reported as `unknown`, saying so.
|
|
251
|
+
|
|
252
|
+
Head age comes from an injected clock, and a head stamped in the *future* is reported
|
|
253
|
+
as `clockSkewSuspected` rather than as a very fresh chain. `compareWith` probes several
|
|
254
|
+
endpoints and flags the one thing that is never survivable: two endpoints reporting
|
|
255
|
+
different chain ids, which is a misconfiguration that otherwise shows up as data that
|
|
256
|
+
cannot be reconciled.
|
|
257
|
+
|
|
258
|
+
No latency is measured. One sample from behind a load balancer says nothing, and a
|
|
259
|
+
verdict that depends on it is a verdict that changes between two identical runs.
|
|
260
|
+
|
|
261
|
+
## `EvmNonceStatus` will not guess
|
|
262
|
+
|
|
263
|
+
`eth_getTransactionCount(address, "pending")` is not a standard. Plenty of endpoints do
|
|
264
|
+
not track a public mempool and answer it with the mined count, so `pending == latest`
|
|
265
|
+
means either "nothing is queued" or "this endpoint cannot see what is queued" — and a
|
|
266
|
+
confident `clear` from the second is how a harness broadcasts a duplicate.
|
|
267
|
+
|
|
268
|
+
Hand it the transaction hashes you broadcast and it can decide instead of guessing:
|
|
269
|
+
|
|
270
|
+
- a transaction this endpoint admits is unmined, on an account whose pending nonce has
|
|
271
|
+
not moved, proves the pending view is not mempool-aware → `mempoolVisible: false`,
|
|
272
|
+
verdict `unknown`
|
|
273
|
+
- a transaction waiting at a nonce above the pending nonce proves a hole at the pending
|
|
274
|
+
nonce, which nothing after can be mined past → verdict `gap`
|
|
275
|
+
- a hash the endpoint has forgotten, at a nonce the account has already passed, was
|
|
276
|
+
replaced or superseded by something else (pass the `nonce` you sent it with, or the
|
|
277
|
+
two are indistinguishable)
|
|
278
|
+
- a pending nonce *below* the latest nonce cannot be true of one node — two sequential
|
|
279
|
+
calls were answered by different machines — so both numbers are reported as unusable
|
|
280
|
+
rather than subtracted
|
|
281
|
+
|
|
282
|
+
With no hashes supplied, `gapDetection` says so: a gapped transaction sits in a node's
|
|
283
|
+
queued pool and is invisible to both nonce counts.
|
|
284
|
+
|
|
285
|
+
## `EvmTransactionSummary` says what it cannot see
|
|
286
|
+
|
|
287
|
+
A receipt contains logs and nothing else. Native value moved by a **contract** during
|
|
288
|
+
the call leaves no log — it is only visible in a trace, and `debug_traceTransaction` is
|
|
289
|
+
neither on a public endpoint's free tier nor on this package's read-only allow-list. So
|
|
290
|
+
`netDeltas.complete` is `false` and `completeness.internalNativeTransfers` is
|
|
291
|
+
`"excluded"`, in the output rather than only here. A net delta that claims to be
|
|
292
|
+
complete and is not is worse than no net delta: it reconciles.
|
|
293
|
+
|
|
294
|
+
A **reverted** transaction moved nothing. Its value transfer is discarded with the rest
|
|
295
|
+
of its state changes and only the fee is charged, so `netDeltas.nativeWei` is `0` and
|
|
296
|
+
`netDeltas.reverted` is `true`. A receipt with neither status nor revert — a
|
|
297
|
+
pre-Byzantium one, carrying a state root instead — does not record whether the call
|
|
298
|
+
reverted at all: the value is counted as moved, and `completeWhy` says that this is a
|
|
299
|
+
convention rather than a reading.
|
|
300
|
+
|
|
301
|
+
Fees are computed from the fields the receipt actually has, not from a chain-id table
|
|
302
|
+
that goes stale every time a rollup ships. An OP-stack receipt carries `l1Fee`, which is
|
|
303
|
+
charged *separately* from gas — leaving it out understates a Base transaction by most of
|
|
304
|
+
its cost. An Arbitrum receipt carries `gasUsedForL1`, whose gas is *already inside*
|
|
305
|
+
`gasUsed` — adding anything would double-count. The model that was applied travels with
|
|
306
|
+
the number.
|
|
307
|
+
|
|
308
|
+
Token amounts are raw base units. Resolving symbol and decimals costs an `eth_call` per
|
|
309
|
+
token and a token contract is free to report whatever it likes; `TokenUnits` in
|
|
310
|
+
`@crewhaus/tool-onchain` will format them against decimals you trust.
|
|
311
|
+
|
|
312
|
+
## The endpoint is vetted before the socket opens
|
|
313
|
+
|
|
314
|
+
Every other networked package here builds its URLs from constants, so a model never
|
|
315
|
+
picks a host. This one cannot — reading the chain you name means you name the RPC URL —
|
|
316
|
+
and an unguarded `fetch(input.rpcUrl)` is a server-side request forgery primitive with a
|
|
317
|
+
model steering. `http://169.254.169.254/` is the cloud metadata service.
|
|
318
|
+
|
|
319
|
+
So: the scheme must be http(s), credentials in the URL are refused, the host is checked
|
|
320
|
+
against the loopback, private, link-local, carrier-grade-NAT and multicast ranges —
|
|
321
|
+
through every `inet_aton` spelling, because `0177.0.0.1`, `0x7f000001` and `2130706433`
|
|
322
|
+
are all `127.0.0.1` — the name is resolved, the resolved address is checked too, and the
|
|
323
|
+
connection is pinned to the address that was vetted so a rebinding resolver cannot hand
|
|
324
|
+
back a different one at connect time. Error messages carry the origin only: provider
|
|
325
|
+
keys live in the path.
|
|
326
|
+
|
|
327
|
+
IPv6 is classified by expanding the address to its eight groups and doing arithmetic on
|
|
328
|
+
them, never by matching its text. One address has many spellings and the one a check is
|
|
329
|
+
written against is rarely the one that arrives: `http://[::ffff:169.254.169.254]/` never
|
|
330
|
+
reaches a guard in that form, because the URL parser re-serialises the embedded quad and
|
|
331
|
+
hands over `::ffff:a9fe:a9fe`. Every transition mechanism that carries an IPv4 address —
|
|
332
|
+
IPv4-mapped, IPv4-translated, NAT64's `64:ff9b::/96` (which is exactly what a DNS64
|
|
333
|
+
resolver answers for an IPv4-only name), 6to4's `2002::/16`, the deprecated
|
|
334
|
+
IPv4-compatible form — is resolved to the IPv4 it carries and judged on that.
|
|
335
|
+
|
|
336
|
+
A local anvil or hardhat node is the obvious casualty, so an **operator** can permit it:
|
|
337
|
+
|
|
338
|
+
```ts
|
|
339
|
+
import { setRpcEndpointPolicy } from "@crewhaus/tool-chainread";
|
|
340
|
+
|
|
341
|
+
setRpcEndpointPolicy({ allowPrivateHosts: true }); // a local devnet
|
|
342
|
+
setRpcEndpointPolicy({ allowedOrigins: ["https://mainnet.base.org"] }); // stricter still
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
Bound at boot by the runtime, exactly like `setEvmAdapterResolver` in `tool-evm`, and
|
|
346
|
+
deliberately **not** a field in any tool's input schema: a gate a model can open for
|
|
347
|
+
itself is not a gate. `EvmRpcHealth` reports the policy it is operating under.
|
|
348
|
+
|
|
349
|
+
This is a narrower guard than `@crewhaus/tool-fetch`'s `assertNotSsrf`, which is the
|
|
350
|
+
repo's canonical one and is not a dependency of this package. It covers the same ground
|
|
351
|
+
for the one shape of request made here — a single origin, no redirects followed, no
|
|
352
|
+
credentials attached — and a redirect is refused rather than followed, because only the
|
|
353
|
+
first URL is ours.
|
|
354
|
+
|
|
355
|
+
## Quantities
|
|
356
|
+
|
|
357
|
+
Every chain quantity crosses this boundary as a **decimal string**: wei, token amounts,
|
|
358
|
+
nonces, gas, block numbers, timestamps. A uint256 does not fit in a double, and reading
|
|
359
|
+
a balance as a number silently loses its low bits. Block numbers get the same treatment
|
|
360
|
+
even though today's heads fit — they are compared, subtracted and used as search bounds
|
|
361
|
+
all over this package, and one accidental `Number(...)` in that chain is a bug that only
|
|
362
|
+
shows up on a chain nobody tested against.
|
|
363
|
+
|
|
364
|
+
Inputs accept a decimal string, a `0x` hex string or a safe integer. A number past
|
|
365
|
+
`Number.MAX_SAFE_INTEGER` is refused rather than rounded: by then it has already lost
|
|
366
|
+
its low digits, and accepting it would be accepting a value the caller can no longer
|
|
367
|
+
see is wrong.
|
|
368
|
+
|
|
369
|
+
## Testing
|
|
370
|
+
|
|
371
|
+
No test in this package opens a socket or sends a DNS query. Three seams, all exported
|
|
372
|
+
beside the tools:
|
|
373
|
+
|
|
374
|
+
```ts
|
|
375
|
+
import { _setClock, _setDnsLookup, _setFetch, virtualClock } from "@crewhaus/tool-chainread";
|
|
376
|
+
import { makeChain, rpcStub } from "./fixtures"; // src/fixtures.ts, beside the tools
|
|
377
|
+
|
|
378
|
+
_setDnsLookup(async () => ({ address: "93.184.216.34", family: 4 }));
|
|
379
|
+
_setFetch(rpcStub(makeChain({ blocks: 500 }), { silentLogCap: 10 }).fetch);
|
|
380
|
+
_setClock(virtualClock());
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
`rpcStub` (in `src/fixtures.ts`) serves a recorded chain and takes the misbehaviours as knobs: a block-span cap
|
|
384
|
+
that errors with a suggested range, a result cap that errors, a result cap that
|
|
385
|
+
truncates silently, pruned archive state, an endpoint with no mempool view, no finality
|
|
386
|
+
tags, an HTTP rate limit, an unreachable host. Every request is recorded, so a test can
|
|
387
|
+
assert what a tool actually asked for — that a scan halved its range, that a search spent
|
|
388
|
+
the probes it claimed, that a poll stopped when it said it did.
|
|
389
|
+
|
|
390
|
+
`virtualClock` makes the deadline paths instant and deterministic: `EvmWaitForReceipt`'s
|
|
391
|
+
fifteen-minute wait costs microseconds, and the assertion is on the poll count and the
|
|
392
|
+
reason rather than on elapsed time.
|
|
393
|
+
|
|
394
|
+
Each seam restores its production implementation when passed `undefined`, and a suite
|
|
395
|
+
that sets one must restore it — the next file in the same bun process inherits it
|
|
396
|
+
otherwise.
|
|
@@ -0,0 +1,123 @@
|
|
|
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;
|