@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.
@@ -0,0 +1,663 @@
1
+ import { projectLog, scanLogs } from "./logs";
2
+ import { ChainReadError, field, hexToBigint, requireObject, toHexQuantity, unixToIso, } from "./quantity";
3
+ import { callOrThrow } from "./rpc";
4
+ import { TRANSFER_TOPIC, addressToTopic, decodeTransfer, feeBreakdown, projectReceipt, projectTransaction, } from "./transfers";
5
+ /** `amountMinor` is a `number`, so this is the ceiling every row has to clear. */
6
+ const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER);
7
+ export const UNKNOWN_UNITS = Object.freeze({
8
+ decimals: null,
9
+ minorUnitDecimals: null,
10
+ symbol: null,
11
+ });
12
+ /**
13
+ * Collect the history, or refuse.
14
+ *
15
+ * The range must already be resolved to numbers and pinned by the caller. A
16
+ * sync whose upper bound is still the string `latest` cannot hand back a cursor
17
+ * anybody can resume from, because the block it stopped at is not a block it
18
+ * can name.
19
+ */
20
+ export async function collectHistory(client, options) {
21
+ if (options.from > options.to) {
22
+ throw new ChainReadError(`the sync range is inverted: fromBlock ${options.from} is after toBlock ${options.to}`);
23
+ }
24
+ const budget = {
25
+ client,
26
+ maxCalls: options.maxCalls,
27
+ logChunks: 0,
28
+ logCalls: 0,
29
+ silentTruncations: 0,
30
+ verifications: 0,
31
+ hydratedBlocks: 0,
32
+ receiptsRead: 0,
33
+ blockHashes: new Map(),
34
+ timestamps: new Map(),
35
+ };
36
+ const movements = [];
37
+ let selfTransfers = 0;
38
+ let sentProof;
39
+ const parked = [];
40
+ if (options.includeTokens) {
41
+ const logs = await scanTransfers(client, options, budget);
42
+ for (const log of logs) {
43
+ const decoded = decodeTransfer(log);
44
+ if (decoded === null) {
45
+ // A log carrying the Transfer topic that does not decode is not an
46
+ // absent transfer. Parking it keeps "there was nothing" and "there was
47
+ // something I could not read" apart, which is the distinction a
48
+ // reconciliation is built on.
49
+ parked.push({
50
+ id: `${log.transactionHash}:log:${log.logIndex}`,
51
+ reason: "undecodable",
52
+ why: "this log carries the Transfer topic but not the topics a Transfer event has, so what moved cannot be read from it",
53
+ asset: log.address,
54
+ rawAmount: null,
55
+ txHash: log.transactionHash,
56
+ blockNumber: log.blockNumber,
57
+ });
58
+ continue;
59
+ }
60
+ // The filter asked the endpoint for logs with this address in a topic.
61
+ // The DECODED parties are what the row is built from, and they are what
62
+ // is checked: a filter is a request, not a proof, and acting on the
63
+ // request while the answer says something else is how a row lands on the
64
+ // wrong side of a ledger.
65
+ const isSender = decoded.from === options.address;
66
+ const isRecipient = decoded.to === options.address;
67
+ if (!isSender && !isRecipient) {
68
+ throw new ChainReadError(`the endpoint returned a Transfer in ${log.transactionHash} between ${decoded.from} and ${decoded.to}, neither of which is ${options.address} — it is not answering the question that was asked`);
69
+ }
70
+ if (isSender && isRecipient) {
71
+ // A transfer to oneself changes no balance. One row would be a debit
72
+ // that never happened; two rows would net to zero through a
73
+ // reconciliation that then has two unmatched lines.
74
+ selfTransfers += 1;
75
+ continue;
76
+ }
77
+ if (decoded.standard === "erc1155") {
78
+ // The filter asked for the ERC-20/721 Transfer topic, so a
79
+ // TransferSingle cannot be an answer to it. Labelling it erc20 and
80
+ // moving on would put a multi-token amount in a row that says it is a
81
+ // fungible one.
82
+ throw new ChainReadError(`the endpoint returned an ERC-1155 TransferSingle in ${log.transactionHash} for a filter on the ERC-20/721 Transfer topic — it is not answering the question that was asked`);
83
+ }
84
+ movements.push({
85
+ id: `${log.transactionHash}:log:${log.logIndex}`,
86
+ kind: "token",
87
+ standard: decoded.standard,
88
+ asset: decoded.token,
89
+ tokenId: decoded.tokenId ?? null,
90
+ counterparty: isSender ? decoded.to : decoded.from,
91
+ raw: BigInt(decoded.amount),
92
+ direction: isSender ? "debit" : "credit",
93
+ txHash: log.transactionHash,
94
+ blockNumber: BigInt(log.blockNumber),
95
+ blockHash: log.blockHash,
96
+ ordinal: log.logIndex,
97
+ // A revert discards the logs with everything else, so a log that exists
98
+ // is a call that succeeded. Nothing is inferred here beyond that.
99
+ status: "success",
100
+ });
101
+ }
102
+ }
103
+ if (options.includeNative || options.includeFees) {
104
+ const native = await scanBlocksForNative(client, options, budget);
105
+ movements.push(...native.movements);
106
+ selfTransfers += native.selfTransfers;
107
+ sentProof = native.sentProof;
108
+ }
109
+ const { rows, detail, parked: moreParked } = await toRows(movements, options, budget);
110
+ parked.push(...moreParked);
111
+ // Parked movements count against the ceiling too. They are the same
112
+ // movements, carried in a different array because they would not fit a row,
113
+ // and a caller that has to read all of them has the same problem whichever
114
+ // array they are in.
115
+ if (rows.length + parked.length > options.maxRows) {
116
+ throw new ChainReadError(`this range holds more than maxRows (${options.maxRows}) movements for ${options.address} — ${rows.length} row(s) and ${parked.length} that could not be written as one. Refusing to return the first ${options.maxRows} of them, because a truncated statement is indistinguishable from a complete one once it leaves here. Sync a smaller range, or raise maxRows.`);
117
+ }
118
+ return {
119
+ rows,
120
+ detail,
121
+ unrepresentable: parked,
122
+ logChunks: budget.logChunks,
123
+ logCalls: budget.logCalls,
124
+ silentTruncations: budget.silentTruncations,
125
+ verifications: budget.verifications,
126
+ hydratedBlocks: budget.hydratedBlocks,
127
+ receiptsRead: budget.receiptsRead,
128
+ selfTransfers,
129
+ ...(sentProof === undefined ? {} : { sentProof }),
130
+ };
131
+ }
132
+ // ---------------------------------------------------------------------------
133
+ // source 1: logged token transfers
134
+ // ---------------------------------------------------------------------------
135
+ /**
136
+ * Two scans, because an address is a party to a Transfer in two different topic
137
+ * positions and no single filter matches either-or across positions.
138
+ *
139
+ * ERC-20 and ERC-721 share the topic and the positions, so both come out of
140
+ * these two. ERC-1155 does not — `TransferSingle` carries the operator at
141
+ * position 1 and the parties at 2 and 3, and `TransferBatch` keeps its amounts
142
+ * in data — and it is reported as excluded rather than scanned for badly.
143
+ */
144
+ async function scanTransfers(client, options, budget) {
145
+ const topic = addressToTopic(options.address);
146
+ // The allow-list is applied in the filter the endpoint sees, not after the
147
+ // logs come back: an unrestricted scan of a busy address pulls in every
148
+ // airdrop that ever touched it, and the cheapest place to not have those is
149
+ // to not fetch them.
150
+ //
151
+ // ONE value drives both the request and the check on the answer. A filter is
152
+ // a request, not a proof — the same reason the decoded parties are checked
153
+ // below — and an endpoint that ignores `address` answers with every token
154
+ // that touched the wallet. Those rows would land in a reconciliation under a
155
+ // `coverage` line saying they were never scanned for, which is how an
156
+ // attacker-supplied airdrop becomes a credit in somebody's books.
157
+ const allowed = options.onlyKnownTokens && options.tokenUnits.size > 0
158
+ ? new Set(options.tokenUnits.keys())
159
+ : null;
160
+ const addressFilter = allowed === null ? {} : { address: [...allowed] };
161
+ const outgoing = { ...addressFilter, topics: [TRANSFER_TOPIC, topic] };
162
+ const incoming = { ...addressFilter, topics: [TRANSFER_TOPIC, null, topic] };
163
+ const seen = new Set();
164
+ const merged = [];
165
+ for (const filter of [outgoing, incoming]) {
166
+ const report = await scanLogs(client, filter, {
167
+ from: options.from,
168
+ to: options.to,
169
+ span: options.span,
170
+ suspectAt: options.suspectAt,
171
+ maxLogs: options.maxRows,
172
+ maxCalls: Math.max(1, options.maxCalls - client.calls),
173
+ });
174
+ budget.logChunks += report.chunks;
175
+ budget.logCalls += report.calls;
176
+ budget.silentTruncations += report.silentTruncations;
177
+ budget.verifications += report.verifications;
178
+ for (const log of report.logs) {
179
+ if (allowed !== null && !allowed.has(log.address)) {
180
+ throw new ChainReadError(`the endpoint returned a ${log.address} log for a filter that named ${allowed.size} token(s), and that is not one of them — it is not answering the question that was asked, and a token nobody listed has no decimals here to be read in`);
181
+ }
182
+ rememberBlockHash(budget, log.blockNumber, log.blockHash, "a log");
183
+ // `scanLogs` dedupes within one scan; a transfer between two accounts the
184
+ // caller happens to hold both of comes back from BOTH scans, and the key
185
+ // is the same three fields for the same reason it is there.
186
+ const key = `${log.blockHash}:${log.transactionHash}:${log.logIndex}`;
187
+ if (seen.has(key))
188
+ continue;
189
+ seen.add(key);
190
+ merged.push(log);
191
+ }
192
+ }
193
+ return merged;
194
+ }
195
+ // ---------------------------------------------------------------------------
196
+ // source 2: native value and gas, from the blocks themselves
197
+ // ---------------------------------------------------------------------------
198
+ /**
199
+ * Hydrate every block in the range and keep the transactions this address was a
200
+ * party to.
201
+ *
202
+ * There is no `eth_getTransactionsByAddress`. This is the only way to see a
203
+ * plain value transfer over a public endpoint, and it costs one request per
204
+ * block — so the range is bounded, and a range past the bound is a refusal.
205
+ * Returning the token rows alone and calling it a history is the failure this
206
+ * package is organised against: a wallet's ETH payments would simply not be in
207
+ * the statement, and nothing in it would say so.
208
+ */
209
+ async function scanBlocksForNative(client, options, budget) {
210
+ const span = options.to - options.from + 1n;
211
+ if (span > BigInt(options.maxHydratedBlocks)) {
212
+ throw new ChainReadError(`blocks ${options.from}–${options.to} is ${span} blocks and native value can only be read by hydrating each one, which is over maxHydratedBlocks (${options.maxHydratedBlocks}). Sync a narrower range, raise maxHydratedBlocks, or drop "native" and "fees" from include — but then the rows are logged token transfers only, and a native payment will be missing from them.`);
213
+ }
214
+ const movements = [];
215
+ let selfTransfers = 0;
216
+ /** Every transaction the blocks showed this address SENDING, row or no row. */
217
+ let sent = 0;
218
+ for (let number = options.from; number <= options.to; number += 1n) {
219
+ spend(budget, `hydrating block ${number}`);
220
+ const raw = await callOrThrow(client, "eth_getBlockByNumber", [toHexQuantity(number), true], `hydrating block ${number}`);
221
+ if (raw === null || raw === undefined) {
222
+ throw new ChainReadError(`block ${number} is inside the pinned sync range ${options.from}–${options.to} but this endpoint says it does not exist — the range is wrong, or this endpoint is not serving the whole chain`);
223
+ }
224
+ budget.hydratedBlocks += 1;
225
+ const block = requireObject(raw, `block ${number}`);
226
+ requireBlockNumber(block, number);
227
+ const blockHash = stringField(block, "hash", `block ${number}`);
228
+ rememberBlockHash(budget, number.toString(), blockHash.toLowerCase(), "a hydrated block");
229
+ budget.timestamps.set(number.toString(), hexToBigint(field(block, "timestamp"), `block ${number} timestamp`));
230
+ const entries = field(block, "transactions");
231
+ if (!Array.isArray(entries)) {
232
+ throw new ChainReadError(`block ${number} came back with no transactions array, so whether this address moved native value in it cannot be read`);
233
+ }
234
+ for (let position = 0; position < entries.length; position++) {
235
+ const entry = entries[position];
236
+ if (typeof entry === "string") {
237
+ // The endpoint ignored `fullTransactions: true` and answered with
238
+ // hashes. Filtering an array of hashes on `from` matches nothing, and
239
+ // nothing about that empty result looks different from a block this
240
+ // address was not in — a prefix of the history that reads as complete.
241
+ throw new ChainReadError(`block ${number} came back as a list of transaction hashes although it was asked for full transactions, so its native transfers cannot be read. This endpoint ignores the fullTransactions flag: use one that honours it, or drop "native" and "fees" from include and accept a token-only statement.`);
242
+ }
243
+ const tx = projectTransaction(requireObject(entry, `a transaction in block ${number}`));
244
+ const isSender = tx.from === options.address;
245
+ const isRecipient = tx.to === options.address;
246
+ // Counted before anything can skip it: the proof below is about what the
247
+ // BLOCKS held, not about what became a row. A zero-value send still
248
+ // raised the nonce, and leaving it out here would turn the proof into a
249
+ // second statement of the row rules.
250
+ if (isSender)
251
+ sent += 1;
252
+ if (!isSender && !isRecipient)
253
+ continue;
254
+ // The receipt is read for the REVERT STATUS as much as for the fee: a
255
+ // reverted transaction's value transfer is discarded with the rest of its
256
+ // state changes, so crediting `tx.value` without reading the status books
257
+ // money that was returned. It is skipped only when neither row could come
258
+ // out of it — a zero-value transaction this address did not send.
259
+ const needsReceipt = (options.includeNative && BigInt(tx.value) > 0n) || (options.includeFees && isSender);
260
+ if (!needsReceipt)
261
+ continue;
262
+ spend(budget, `reading the receipt for ${tx.hash}`);
263
+ const receiptRaw = await callOrThrow(client, "eth_getTransactionReceipt", [tx.hash], `reading the receipt for ${tx.hash}`);
264
+ if (receiptRaw === null || receiptRaw === undefined) {
265
+ throw new ChainReadError(`transaction ${tx.hash} is in block ${number} but this endpoint has no receipt for it, so whether it reverted cannot be read — and a reverted transaction moved nothing. Refusing rather than booking a movement that may not have happened.`);
266
+ }
267
+ budget.receiptsRead += 1;
268
+ const receiptObject = requireObject(receiptRaw, `the receipt for ${tx.hash}`);
269
+ const receipt = projectReceipt(receiptObject, projectLog);
270
+ const reverted = receipt.status === "reverted";
271
+ if (options.includeNative && BigInt(tx.value) > 0n && !reverted) {
272
+ if (isSender && isRecipient) {
273
+ selfTransfers += 1;
274
+ }
275
+ else {
276
+ movements.push({
277
+ id: `${tx.hash}:native`,
278
+ kind: "native",
279
+ standard: "native",
280
+ asset: "native",
281
+ tokenId: null,
282
+ // A contract creation has no `to`. The value went to the contract
283
+ // the receipt names, and "an unnamed party" for it would be a row
284
+ // nobody can trace.
285
+ counterparty: isSender ? (tx.to ?? receipt.contractAddress) : tx.from,
286
+ raw: BigInt(tx.value),
287
+ direction: isSender ? "debit" : "credit",
288
+ txHash: tx.hash,
289
+ blockNumber: number,
290
+ blockHash: blockHash.toLowerCase(),
291
+ ordinal: ordinalOf(receipt, position),
292
+ status: receipt.status,
293
+ });
294
+ }
295
+ }
296
+ // One fee row per transaction SENT, keyed on the transaction. The wallet
297
+ // can appear in a dozen logs of one swap; a fee attributed per log
298
+ // charges the gas a dozen times and the reconciliation is off by exactly
299
+ // that. Fees never come from the log path, so it cannot happen here.
300
+ if (options.includeFees && isSender) {
301
+ const fees = feeBreakdown(receiptObject, receipt);
302
+ if (fees.totalFeeWei !== null && BigInt(fees.totalFeeWei) > 0n) {
303
+ movements.push({
304
+ id: `${tx.hash}:fee`,
305
+ kind: "fee",
306
+ standard: "fee",
307
+ asset: "native",
308
+ tokenId: null,
309
+ counterparty: null,
310
+ raw: BigInt(fees.totalFeeWei),
311
+ direction: "debit",
312
+ txHash: tx.hash,
313
+ blockNumber: number,
314
+ blockHash: blockHash.toLowerCase(),
315
+ ordinal: ordinalOf(receipt, position),
316
+ status: receipt.status,
317
+ feeModel: fees.model,
318
+ });
319
+ }
320
+ }
321
+ }
322
+ }
323
+ return { movements, selfTransfers, sentProof: await proveSent(client, options, budget, sent) };
324
+ }
325
+ /**
326
+ * Check the blocks against the account's own nonce, and refuse when they are
327
+ * short.
328
+ *
329
+ * Two reads, whatever the range. A nonce rises by one per transaction sent, so
330
+ * its rise across the range is exactly how many this address must appear as the
331
+ * sender of — and a hydrated block that came back short is otherwise invisible,
332
+ * because there is no second way to ask for it.
333
+ *
334
+ * MORE transactions than the nonce accounts for is not a refusal: OP-stack
335
+ * deposits and other system transactions appear in blocks without raising an
336
+ * ordinary nonce, and refusing there would make this unusable on the chains it
337
+ * is most often pointed at. Nothing is missing in that direction, and the
338
+ * count is reported rather than swallowed.
339
+ */
340
+ async function proveSent(client, options, budget, sent) {
341
+ const at = await nonceAt(client, budget, options.address, options.to);
342
+ // Block zero has no predecessor to read, and an account's nonce before the
343
+ // chain existed is zero for every account.
344
+ const before = options.from === 0n
345
+ ? { ok: true, value: 0n }
346
+ : await nonceAt(client, budget, options.address, options.from - 1n);
347
+ if (!at.ok || !before.ok) {
348
+ const why = !at.ok ? at.why : !before.ok ? before.why : "";
349
+ return {
350
+ outcome: "unproved",
351
+ sent,
352
+ why: `this endpoint would not serve this account's nonce at a block in the range (${why}), so whether its blocks held every transaction this address sent could not be checked. A pruned endpoint answers historical state this way; an archive one does not.`,
353
+ };
354
+ }
355
+ const expected = at.value - before.value;
356
+ if (expected < 0n) {
357
+ return {
358
+ outcome: "unproved",
359
+ sent,
360
+ why: `this endpoint says the account's nonce FELL from ${before.value} to ${at.value} across blocks ${options.from}–${options.to}, which no chain does — the two reads are not from one history, so neither is evidence about these blocks`,
361
+ };
362
+ }
363
+ if (BigInt(sent) < expected) {
364
+ throw new ChainReadError(`this endpoint's blocks showed ${sent} transaction(s) sent by ${options.address} over blocks ${options.from}–${options.to}, but its own nonce for that account rose by ${expected} across the same range — at least ${expected - BigInt(sent)} transaction it sent is missing from the blocks it served, and each one is a fee and possibly a payment. Refusing rather than returning a statement that is short by an unknown amount: sync a narrower range, or use an endpoint that serves whole blocks.`);
365
+ }
366
+ if (BigInt(sent) > expected) {
367
+ return {
368
+ outcome: "moreThanTheNonce",
369
+ sent,
370
+ nonceAccountsFor: Number(expected),
371
+ why: "the blocks held more transactions from this address than its nonce rose by, which is what a system or deposit transaction looks like on a rollup. Nothing is missing; the extra ones are in the rows",
372
+ };
373
+ }
374
+ return { outcome: "proved", sent };
375
+ }
376
+ /**
377
+ * One historical nonce, or the reason there is none.
378
+ *
379
+ * Returned rather than thrown: a pruned endpoint refusing state from before its
380
+ * window is a fact about the endpoint, not a reason to throw away a sync that
381
+ * is otherwise fine. The caller decides what an unreadable answer means.
382
+ */
383
+ async function nonceAt(client, budget, address, block) {
384
+ spend(budget, `reading this account's nonce at block ${block}`);
385
+ const outcome = await client.call("eth_getTransactionCount", [address, toHexQuantity(block)]);
386
+ if (!outcome.ok)
387
+ return { ok: false, why: `at block ${block}: ${outcome.message}` };
388
+ try {
389
+ return { ok: true, value: hexToBigint(outcome.result, `the nonce at block ${block}`) };
390
+ }
391
+ catch (error) {
392
+ return { ok: false, why: error instanceof Error ? error.message : String(error) };
393
+ }
394
+ }
395
+ // ---------------------------------------------------------------------------
396
+ // movements become rows, or become parked
397
+ // ---------------------------------------------------------------------------
398
+ async function toRows(movements, options, budget) {
399
+ const ordered = [...movements].sort(byBlockThenKindThenOrdinal);
400
+ const rows = [];
401
+ const detail = [];
402
+ const parked = [];
403
+ for (const movement of ordered) {
404
+ const timestamp = await timestampOf(budget, movement.blockNumber);
405
+ const iso = unixToIso(timestamp);
406
+ const date = calendarDate(iso);
407
+ if (iso === null || date === null) {
408
+ parked.push({
409
+ id: movement.id,
410
+ reason: "timestampOutOfRange",
411
+ why: `block ${movement.blockNumber} is stamped ${timestamp}, which is past what a calendar date can express — the row has no date to carry`,
412
+ asset: movement.asset,
413
+ rawAmount: movement.raw.toString(),
414
+ txHash: movement.txHash,
415
+ blockNumber: movement.blockNumber.toString(),
416
+ });
417
+ continue;
418
+ }
419
+ const units = unitsFor(movement, options);
420
+ const scaled = toMinorUnits(movement.raw, units);
421
+ if (!scaled.ok) {
422
+ parked.push({
423
+ id: movement.id,
424
+ reason: scaled.reason,
425
+ why: scaled.why,
426
+ asset: movement.asset,
427
+ rawAmount: movement.raw.toString(),
428
+ txHash: movement.txHash,
429
+ blockNumber: movement.blockNumber.toString(),
430
+ });
431
+ continue;
432
+ }
433
+ rows.push({
434
+ id: movement.id,
435
+ // A calendar date in UTC, because a block timestamp is UTC and nothing on
436
+ // the chain knows the ledger's timezone. A movement just before midnight
437
+ // local time lands on the chain's day, not the bookkeeper's.
438
+ date,
439
+ description: describe(movement, units),
440
+ // Negative for money leaving the account, which is the convention the row
441
+ // shape documents and every consumer of it relies on. Zero is written as
442
+ // zero rather than `-0`, which some consumers print and none of them mean.
443
+ amountMinor: scaled.value === 0 ? 0 : movement.direction === "debit" ? -scaled.value : scaled.value,
444
+ direction: movement.direction,
445
+ reference: movement.txHash,
446
+ // No running balance. It would need an opening balance per asset that
447
+ // nothing here has, and a computed one would be a number a reconciliation
448
+ // trusts and should not.
449
+ balanceMinor: null,
450
+ });
451
+ detail.push({
452
+ id: movement.id,
453
+ kind: movement.kind,
454
+ standard: movement.standard,
455
+ asset: movement.asset,
456
+ symbol: units.symbol,
457
+ assetDecimals: units.decimals,
458
+ minorUnitDecimals: units.minorUnitDecimals ?? units.decimals,
459
+ rawAmount: movement.raw.toString(),
460
+ tokenId: movement.tokenId,
461
+ counterparty: movement.counterparty,
462
+ txHash: movement.txHash,
463
+ blockNumber: movement.blockNumber.toString(),
464
+ blockHash: movement.blockHash,
465
+ timestamp: timestamp.toString(),
466
+ timestampIso: iso,
467
+ status: movement.status,
468
+ ...(movement.feeModel === undefined ? {} : { feeModel: movement.feeModel }),
469
+ });
470
+ }
471
+ return { rows, detail, parked };
472
+ }
473
+ /**
474
+ * A base-unit amount as the ledger's minor units, exactly or not at all.
475
+ *
476
+ * Both failures are real and neither is rounding's business. Wei is eighteen
477
+ * decimals and `Number.MAX_SAFE_INTEGER` is about nine thousandths of one ETH,
478
+ * so a plain ETH payment does not fit in this field at all until the caller
479
+ * says what unit the ledger keeps ETH in — and 0.5 USDC does not fit a
480
+ * cent-denominated ledger however it is asked.
481
+ */
482
+ export function toMinorUnits(raw, units) {
483
+ const drop = units.decimals === null || units.minorUnitDecimals === null
484
+ ? 0
485
+ : units.decimals - units.minorUnitDecimals;
486
+ if (drop < 0) {
487
+ throw new ChainReadError(`minorUnitDecimals (${units.minorUnitDecimals}) is finer than the asset's own decimals (${units.decimals}) — there are no digits there to scale up into`);
488
+ }
489
+ const divisor = 10n ** BigInt(drop);
490
+ const remainder = raw % divisor;
491
+ if (remainder !== 0n) {
492
+ return {
493
+ ok: false,
494
+ reason: "belowMinorUnit",
495
+ why: `${raw} base units is ${remainder} short of a whole minor unit at ${drop} decimal places — rounding it would put a number in the ledger that the chain does not contain`,
496
+ };
497
+ }
498
+ const quotient = raw / divisor;
499
+ if (quotient > MAX_SAFE) {
500
+ return {
501
+ ok: false,
502
+ reason: "exceedsSafeInteger",
503
+ why: `${quotient} minor units is past Number.MAX_SAFE_INTEGER (${MAX_SAFE}), and the row's amountMinor is a JS number — name this asset's decimals and the ledger's minorUnitDecimals so it scales into range, rather than taking a value that has already lost its low digits`,
504
+ };
505
+ }
506
+ return { ok: true, value: Number(quotient) };
507
+ }
508
+ /**
509
+ * Which units a movement is measured in.
510
+ *
511
+ * An ERC-721 is counted in whole tokens and has no decimals to scale by — a
512
+ * declared `decimals` for that contract would be about something else, and
513
+ * dividing a count of one by it produces either dust or a zero.
514
+ */
515
+ function unitsFor(movement, options) {
516
+ if (movement.asset === "native")
517
+ return options.nativeUnits;
518
+ const declared = options.tokenUnits.get(movement.asset);
519
+ if (movement.standard === "erc721") {
520
+ return { decimals: null, minorUnitDecimals: null, symbol: declared?.symbol ?? null };
521
+ }
522
+ return declared ?? UNKNOWN_UNITS;
523
+ }
524
+ /**
525
+ * The `YYYY-MM-DD` the row shape wants, or `null` when the instant cannot be
526
+ * written that way.
527
+ *
528
+ * `Date.prototype.toISOString` switches to the EXPANDED year format outside
529
+ * 0000–9999 — a block stamped in the year 33658 serialises as
530
+ * `+033658-…`, and slicing ten characters off that gives `+033658-0`, which is
531
+ * not a date and which `LedgerReconcile`'s schema rejects at the far end of the
532
+ * pipeline rather than here. So the slice is validated, not assumed.
533
+ */
534
+ export function calendarDate(iso) {
535
+ if (iso === null)
536
+ return null;
537
+ const date = iso.slice(0, 10);
538
+ return /^\d{4}-\d{2}-\d{2}$/.test(date) ? date : null;
539
+ }
540
+ function describe(movement, units) {
541
+ const asset = units.symbol ?? (movement.asset === "native" ? "native value" : movement.asset);
542
+ if (movement.kind === "fee") {
543
+ return `gas fee for ${movement.txHash}${movement.status === "reverted" ? " (the transaction reverted; the fee was still charged)" : ""}`;
544
+ }
545
+ // A pre-Byzantium receipt does not record whether the call reverted, and this
546
+ // row counts the value as having moved. That is a convention, not a reading,
547
+ // and it travels with the row because the row is what a bookkeeper sees.
548
+ const caveat = movement.status === "unknown"
549
+ ? " (this receipt records no status, so whether the call reverted is not known and the value is counted as moved)"
550
+ : "";
551
+ const way = movement.direction === "debit" ? "to" : "from";
552
+ const other = movement.counterparty ?? "an unnamed party";
553
+ if (movement.standard === "erc721") {
554
+ return `ERC-721 ${asset} #${movement.tokenId ?? "?"} ${way} ${other}${caveat}`;
555
+ }
556
+ if (movement.kind === "native")
557
+ return `${asset} transfer ${way} ${other}${caveat}`;
558
+ return `ERC-20 ${asset} transfer ${way} ${other}${caveat}`;
559
+ }
560
+ /**
561
+ * A total order that does not depend on anything a provider chose.
562
+ *
563
+ * Block number, then kind, then the movement's own index — the log's own
564
+ * `logIndex` and the receipt's own `transactionIndex`, both assigned by the
565
+ * chain — then the id as the final tiebreak, so two runs against the same range
566
+ * produce byte-identical rows. Sorting on the order logs or transactions
567
+ * happened to ARRIVE in would be sorting on the endpoint's mood.
568
+ */
569
+ function byBlockThenKindThenOrdinal(a, b) {
570
+ if (a.blockNumber !== b.blockNumber)
571
+ return a.blockNumber < b.blockNumber ? -1 : 1;
572
+ const rank = (m) => (m.kind === "native" ? 0 : m.kind === "token" ? 1 : 2);
573
+ if (rank(a) !== rank(b))
574
+ return rank(a) - rank(b);
575
+ if (a.ordinal !== b.ordinal)
576
+ return a.ordinal - b.ordinal;
577
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
578
+ }
579
+ // ---------------------------------------------------------------------------
580
+ // the shared bookkeeping both sources write into
581
+ // ---------------------------------------------------------------------------
582
+ /**
583
+ * A block's timestamp, read once.
584
+ *
585
+ * The hydrated path already has it. The log path does not, and a row needs a
586
+ * date — so one header per distinct block that produced a row, cached, and a
587
+ * block that has vanished between the scan and this read is a refusal rather
588
+ * than a row dated from somewhere else.
589
+ */
590
+ async function timestampOf(budget, number) {
591
+ const key = number.toString();
592
+ const known = budget.timestamps.get(key);
593
+ if (known !== undefined)
594
+ return known;
595
+ spend(budget, `reading the header of block ${number}`);
596
+ const raw = await callOrThrow(budget.client, "eth_getBlockByNumber", [toHexQuantity(number), false], `reading the header of block ${number}`);
597
+ if (raw === null || raw === undefined) {
598
+ throw new ChainReadError(`block ${number} produced a row but this endpoint now says it has no such block — the chain moved under the sync, so the rows would mix two histories`);
599
+ }
600
+ const block = requireObject(raw, `block ${number}`);
601
+ requireBlockNumber(block, number);
602
+ rememberBlockHash(budget, key, stringField(block, "hash", `block ${number}`).toLowerCase(), "a block header");
603
+ const timestamp = hexToBigint(field(block, "timestamp"), `block ${number} timestamp`);
604
+ budget.timestamps.set(key, timestamp);
605
+ return timestamp;
606
+ }
607
+ /**
608
+ * One height, one hash — across the log scan, the hydrated blocks and the
609
+ * header reads alike.
610
+ *
611
+ * Each source sees the chain at a different moment, so this is where a reorg
612
+ * mid-sync becomes visible: a statement assembled from two histories reconciles
613
+ * against neither.
614
+ */
615
+ function rememberBlockHash(budget, number, hash, source) {
616
+ const known = budget.blockHashes.get(number);
617
+ if (known === undefined) {
618
+ budget.blockHashes.set(number, hash);
619
+ return;
620
+ }
621
+ if (known !== hash) {
622
+ throw new ChainReadError(`block ${number} came back with hash ${hash} from ${source} after an earlier request said ${known} — the chain reorganised while this sync was running, so the rows would mix two histories. Re-run with more confirmations.`);
623
+ }
624
+ }
625
+ function spend(budget, what) {
626
+ if (budget.client.calls >= budget.maxCalls) {
627
+ throw new ChainReadError(`this sync needed more than maxCalls (${budget.maxCalls}) requests and stopped before ${what} — refusing to return a partial history. Sync a smaller range, or narrow it with tokens.`);
628
+ }
629
+ }
630
+ /**
631
+ * The body has to be the block that was asked for.
632
+ *
633
+ * Every row's date and every entry in the reorg map is keyed by the number in
634
+ * the REQUEST while its contents come out of the RESPONSE, so an endpoint that
635
+ * answers block 123 with block 456 dates a movement from somewhere else and
636
+ * pins the wrong hash at that height. The log scan already refuses a log from
637
+ * outside the range it asked about; this is the same check on the other source.
638
+ */
639
+ function requireBlockNumber(block, asked) {
640
+ const answered = hexToBigint(field(block, "number"), `block ${asked}: its own number`);
641
+ if (answered !== asked) {
642
+ throw new ChainReadError(`block ${asked} was asked for and the endpoint answered with block ${answered} — it is not answering the question that was asked, so nothing in that body can be dated or hashed to the block it was requested for`);
643
+ }
644
+ }
645
+ /**
646
+ * A transaction's place in its block, as the CHAIN numbers it.
647
+ *
648
+ * The receipt carries `transactionIndex`, and it is read rather than the
649
+ * position in the array the endpoint sent: the array's order is the endpoint's
650
+ * to choose, and sorting on it makes the row order a property of which provider
651
+ * answered. The position is the fallback for an endpoint that omits the field,
652
+ * which is the only case where there is nothing better.
653
+ */
654
+ function ordinalOf(receipt, position) {
655
+ return receipt.transactionIndex ?? position;
656
+ }
657
+ function stringField(source, key, what) {
658
+ const value = field(source, key);
659
+ if (typeof value !== "string") {
660
+ throw new ChainReadError(`${what}: expected a string ${key}, got ${JSON.stringify(value)}`);
661
+ }
662
+ return value;
663
+ }