chainfess 0.0.0-stage → 0.1.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/src/keccak.mjs ADDED
@@ -0,0 +1,75 @@
1
+ // keccak-256 (not NIST SHA3), EIP-55 checksums, and the check that a 32-byte value is the key of a wallet.
2
+ import { createECDH } from 'node:crypto';
3
+
4
+ const RC = [
5
+ 0x0000000000000001n, 0x0000000000008082n, 0x800000000000808an, 0x8000000080008000n,
6
+ 0x000000000000808bn, 0x0000000080000001n, 0x8000000080008081n, 0x8000000000008009n,
7
+ 0x000000000000008an, 0x0000000000000088n, 0x0000000080008009n, 0x000000008000000an,
8
+ 0x000000008000808bn, 0x800000000000008bn, 0x8000000000008089n, 0x8000000000008003n,
9
+ 0x8000000000008002n, 0x8000000000000080n, 0x000000000000800an, 0x800000008000000an,
10
+ 0x8000000080008081n, 0x8000000000008080n, 0x0000000080000001n, 0x8000000080008008n,
11
+ ];
12
+ const ROT = [1, 3, 6, 10, 15, 21, 28, 36, 45, 55, 2, 14, 27, 41, 56, 8, 25, 43, 62, 18, 39, 61, 20, 44];
13
+ const PIL = [10, 7, 11, 17, 18, 3, 5, 16, 8, 21, 24, 4, 15, 23, 19, 13, 12, 2, 20, 14, 22, 9, 6, 1];
14
+ const M = (1n << 64n) - 1n;
15
+ const rotl = (x, n) => ((x << BigInt(n)) | (x >> BigInt(64 - n))) & M;
16
+
17
+ function f(s) {
18
+ const c = new Array(5);
19
+ for (let r = 0; r < 24; r++) {
20
+ for (let i = 0; i < 5; i++) c[i] = s[i] ^ s[i + 5] ^ s[i + 10] ^ s[i + 15] ^ s[i + 20];
21
+ for (let i = 0; i < 5; i++) {
22
+ const d = c[(i + 4) % 5] ^ rotl(c[(i + 1) % 5], 1);
23
+ for (let j = 0; j < 25; j += 5) s[j + i] ^= d;
24
+ }
25
+ let t = s[1];
26
+ for (let i = 0; i < 24; i++) {
27
+ const j = PIL[i];
28
+ const b = s[j];
29
+ s[j] = rotl(t, ROT[i]);
30
+ t = b;
31
+ }
32
+ for (let j = 0; j < 25; j += 5) {
33
+ for (let i = 0; i < 5; i++) c[i] = s[j + i];
34
+ for (let i = 0; i < 5; i++) s[j + i] = c[i] ^ (~c[(i + 1) % 5] & M & c[(i + 2) % 5]);
35
+ }
36
+ s[0] ^= RC[r];
37
+ }
38
+ }
39
+
40
+ export function keccak256(input) {
41
+ const data = Buffer.isBuffer(input) ? input : Buffer.from(input);
42
+ const rate = 136;
43
+ const padded = Buffer.alloc(data.length + rate - (data.length % rate));
44
+ data.copy(padded);
45
+ padded[data.length] ^= 0x01;
46
+ padded[padded.length - 1] ^= 0x80;
47
+ const s = new Array(25).fill(0n);
48
+ for (let off = 0; off < padded.length; off += rate) {
49
+ for (let i = 0; i < rate / 8; i++) s[i] ^= padded.readBigUInt64LE(off + i * 8);
50
+ f(s);
51
+ }
52
+ const out = Buffer.alloc(32);
53
+ for (let i = 0; i < 4; i++) out.writeBigUInt64LE(s[i], i * 8);
54
+ return out;
55
+ }
56
+
57
+ export function toChecksum(address) {
58
+ if (!address) return address;
59
+ const a = String(address).toLowerCase().replace(/^0x/, '');
60
+ const h = keccak256(Buffer.from(a, 'ascii')).toString('hex');
61
+ let out = '0x';
62
+ for (let i = 0; i < a.length; i++) out += parseInt(h[i], 16) >= 8 ? a[i].toUpperCase() : a[i];
63
+ return out;
64
+ }
65
+
66
+ /** Address of a 32-byte private key given as 0x + 64 hex, or null when it is not a valid key. */
67
+ export function addressOfKey(hex32) {
68
+ try {
69
+ const ecdh = createECDH('secp256k1');
70
+ ecdh.setPrivateKey(Buffer.from(hex32.slice(2), 'hex'));
71
+ return '0x' + keccak256(ecdh.getPublicKey().subarray(1)).subarray(12).toString('hex');
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
package/src/ledger.mjs ADDED
@@ -0,0 +1,216 @@
1
+ // Nonce ledger and gap scan (BRIEF §2.2, §3.4). Knows nothing about logs: it takes a set of known hashes.
2
+ export const DEFAULT_MAX_SCAN_BLOCKS = 210000;
3
+ export const SCAN_BATCH = 100;
4
+ export const LOOKUP_BATCH = 50;
5
+ const SECOND_PASS_WAIT_MS = 30000;
6
+
7
+ export class ScanCapError extends Error {
8
+ constructor(planned, cap) {
9
+ super(`planned scan of ${planned} blocks is over the cap of ${cap}`);
10
+ this.name = 'ScanCapError';
11
+ this.planned = planned;
12
+ this.cap = cap;
13
+ }
14
+ }
15
+
16
+ const hex = (n) => '0x' + n.toString(16);
17
+ const num = (x) => (x === null || x === undefined ? null : Number(x));
18
+
19
+ export function normalizeTx(raw) {
20
+ return {
21
+ hash: String(raw.hash).toLowerCase(),
22
+ from: String(raw.from).toLowerCase(),
23
+ to: raw.to ? String(raw.to).toLowerCase() : null,
24
+ nonce: Number(raw.nonce),
25
+ block: num(raw.blockNumber),
26
+ valueWei: BigInt(raw.value ?? 0).toString(),
27
+ };
28
+ }
29
+
30
+ /** Pure: the block ranges that can hold the nonces this wallet's known txs do not explain. */
31
+ export function planWallet({ wallet, nonce, knownNonces, fromBlock, toBlock }) {
32
+ // knownNonces: Map nonce -> block. Range ends are inclusive: on a 0.1 s chain two nonces can share a block.
33
+ const segments = [];
34
+ const K = [...knownNonces.keys()].filter((k) => k < nonce).sort((a, b) => a - b);
35
+ const push = (lo, hi, nonceLo, nonceHi) => {
36
+ const clo = Math.max(lo, fromBlock);
37
+ const chi = Math.min(hi, toBlock);
38
+ if (clo > chi) return;
39
+ segments.push({ wallet, from: clo, to: chi, nonceLo, nonceHi });
40
+ };
41
+ if (nonce <= 0) return { segments, unknown: 0 };
42
+ if (K.length === 0) {
43
+ push(fromBlock, toBlock, 0, nonce - 1);
44
+ return { segments, unknown: nonce };
45
+ }
46
+ if (K[0] > 0) push(fromBlock, knownNonces.get(K[0]), 0, K[0] - 1);
47
+ for (let i = 0; i + 1 < K.length; i++) {
48
+ if (K[i + 1] - K[i] > 1) push(knownNonces.get(K[i]), knownNonces.get(K[i + 1]), K[i] + 1, K[i + 1] - 1);
49
+ }
50
+ const top = K[K.length - 1];
51
+ if (top < nonce - 1) push(knownNonces.get(top), toBlock, top + 1, nonce - 1);
52
+ return { segments, unknown: nonce - K.length };
53
+ }
54
+
55
+ export function mergeRanges(ranges) {
56
+ const sorted = ranges.map((r) => [r.from, r.to]).sort((a, b) => a[0] - b[0]);
57
+ const out = [];
58
+ for (const [a, b] of sorted) {
59
+ const last = out[out.length - 1];
60
+ if (last && a <= last.to + 1) last.to = Math.max(last.to, b);
61
+ else out.push({ from: a, to: b });
62
+ }
63
+ return out;
64
+ }
65
+
66
+ export const rangeBlocks = (ranges) => ranges.reduce((s, r) => s + (r.to - r.from + 1), 0);
67
+
68
+ /** Adds time, reverted, createdAddress to txs. headers: Map block -> unix seconds. */
69
+ export async function enrichTxs(rpc, txs, headers = new Map()) {
70
+ const need = [...new Set(txs.filter((t) => t.block !== null && !headers.has(t.block)).map((t) => t.block))];
71
+ if (need.length) {
72
+ const got = await rpc.batch(need.map((b) => ['eth_getBlockByNumber', [hex(b), false]]), { size: LOOKUP_BATCH, nonNull: true });
73
+ need.forEach((b, i) => headers.set(b, Number(got[i].timestamp)));
74
+ }
75
+ const noReceipt = txs.filter((t) => t.block !== null && t.reverted === undefined);
76
+ if (noReceipt.length) {
77
+ const got = await rpc.batch(noReceipt.map((t) => ['eth_getTransactionReceipt', [t.hash]]), { size: LOOKUP_BATCH });
78
+ noReceipt.forEach((t, i) => {
79
+ const r = got[i];
80
+ t.reverted = r ? Number(r.status) === 0 : null;
81
+ t.createdAddress = r && r.contractAddress ? String(r.contractAddress).toLowerCase() : null;
82
+ });
83
+ }
84
+ for (const t of txs) {
85
+ if (t.block === null) continue;
86
+ t.timestamp = headers.get(t.block);
87
+ t.time = new Date(t.timestamp * 1000).toISOString().replace('.000Z', 'Z');
88
+ t.contractCreation = t.to === null;
89
+ }
90
+ return txs;
91
+ }
92
+
93
+ export async function runLedger({
94
+ rpc, wallets, fromBlock, toBlock, knownHashes, maxScanBlocks = DEFAULT_MAX_SCAN_BLOCKS,
95
+ onPlan, onProgress, headers = new Map(),
96
+ }) {
97
+ const started = Date.now();
98
+ const list = [...new Set(wallets.map((w) => String(w).toLowerCase()))];
99
+ const walletSet = new Set(list);
100
+
101
+ // 1. nonces
102
+ const nonceAt = {};
103
+ const counts = await rpc.batch(list.map((w) => ['eth_getTransactionCount', [w, 'latest']]), { size: LOOKUP_BATCH, nonNull: true });
104
+ list.forEach((w, i) => { nonceAt[w] = Number(counts[i]); });
105
+
106
+ // 2. resolve every known hash once
107
+ const hashes = [...new Set([...(knownHashes || [])].map((h) => String(h).toLowerCase()))];
108
+ const resolved = new Map();
109
+ const got = await rpc.batch(hashes.map((h) => ['eth_getTransactionByHash', [h]]), { size: LOOKUP_BATCH });
110
+ hashes.forEach((h, i) => {
111
+ const raw = got[i];
112
+ if (!raw || String(raw.hash).toLowerCase() !== h) { resolved.set(h, { status: 'null' }); return; }
113
+ const tx = normalizeTx(raw);
114
+ resolved.set(h, { status: tx.block === null ? 'pending' : 'tx', tx });
115
+ });
116
+ const unresolvedHashes = [...resolved.values()].filter((r) => r.status === 'null').length;
117
+
118
+ // 3. known nonces per wallet, then the plan
119
+ const known = new Map(list.map((w) => [w, new Map()]));
120
+ for (const r of resolved.values()) {
121
+ if (r.status === 'tx' && walletSet.has(r.tx.from)) known.get(r.tx.from).set(r.tx.nonce, r.tx.block);
122
+ }
123
+ const segments = [];
124
+ for (const w of list) {
125
+ segments.push(...planWallet({ wallet: w, nonce: nonceAt[w], knownNonces: known.get(w), fromBlock, toBlock }).segments);
126
+ }
127
+ const gaps = mergeRanges(segments);
128
+ const plannedBlocks = rangeBlocks(gaps);
129
+ if (onPlan) await onPlan({ nonceAt, known, gaps, plannedBlocks, resolved });
130
+ if (plannedBlocks > maxScanBlocks) throw new ScanCapError(plannedBlocks, maxScanBlocks);
131
+
132
+ // 4. scan
133
+ const found = new Map();
134
+ const seenBlocks = new Set();
135
+ const failed = [];
136
+ const scanStarted = Date.now();
137
+ const scanChunk = async (blocks) => {
138
+ let res;
139
+ try {
140
+ res = await rpc.batch(blocks.map((b) => ['eth_getBlockByNumber', [hex(b), true]]), { size: blocks.length, nonNull: true });
141
+ } catch {
142
+ failed.push(...blocks);
143
+ return;
144
+ }
145
+ blocks.forEach((b, i) => {
146
+ const blk = res[i];
147
+ if (!blk || Number(blk.number) !== b || !Array.isArray(blk.transactions) || blk.transactions.some((t) => typeof t !== 'object')) {
148
+ failed.push(b);
149
+ return;
150
+ }
151
+ seenBlocks.add(b);
152
+ headers.set(b, Number(blk.timestamp));
153
+ for (const raw of blk.transactions) {
154
+ const from = String(raw.from).toLowerCase();
155
+ if (!walletSet.has(from)) continue;
156
+ const tx = normalizeTx(raw);
157
+ tx.block = b;
158
+ found.set(tx.hash, tx);
159
+ }
160
+ });
161
+ if (onProgress) {
162
+ const secs = (Date.now() - scanStarted) / 1000;
163
+ onProgress({ scannedBlocks: seenBlocks.size, plannedBlocks, blocksPerSec: secs > 0 ? seenBlocks.size / secs : null });
164
+ }
165
+ };
166
+ const remaining = (seg) => {
167
+ const have = new Set();
168
+ for (const t of found.values()) {
169
+ if (t.from === seg.wallet && t.nonce >= seg.nonceLo && t.nonce <= seg.nonceHi) have.add(t.nonce);
170
+ }
171
+ return seg.nonceHi - seg.nonceLo + 1 - have.size;
172
+ };
173
+ const scanBlocks = async (blocks, seg) => {
174
+ let i = 0;
175
+ while (i < blocks.length) {
176
+ if (seg && remaining(seg) <= 0) return; // every nonce of this segment is accounted for
177
+ const width = Math.max(1, rpc.concurrency ? rpc.concurrency() : 2);
178
+ const round = [];
179
+ for (let w = 0; w < width && i < blocks.length; w++, i += SCAN_BATCH) round.push(blocks.slice(i, i + SCAN_BATCH));
180
+ await Promise.all(round.map(scanChunk));
181
+ }
182
+ };
183
+ for (const seg of segments) {
184
+ const blocks = [];
185
+ for (let b = seg.from; b <= seg.to; b++) if (!seenBlocks.has(b)) blocks.push(b);
186
+ await scanBlocks(blocks, seg);
187
+ }
188
+ // Blocks lost to a long throttle, or before the switch to the fallback, get a second chance.
189
+ if (failed.length) {
190
+ const again = [...new Set(failed)].filter((b) => !seenBlocks.has(b)).sort((a, b) => a - b);
191
+ failed.length = 0;
192
+ if (rpc.pause) await rpc.pause(SECOND_PASS_WAIT_MS);
193
+ await scanBlocks(again, null);
194
+ }
195
+ const unscanned = [...new Set(failed)].filter((b) => !seenBlocks.has(b)).sort((a, b) => a - b);
196
+ const unscannedRanges = mergeRanges(unscanned.map((b) => ({ from: b, to: b })));
197
+
198
+ // 5. in-range txs of our wallets: from the known hashes plus the scan
199
+ const inRange = new Map();
200
+ for (const r of resolved.values()) {
201
+ if (r.status === 'tx' && walletSet.has(r.tx.from) && r.tx.block >= fromBlock && r.tx.block <= toBlock) inRange.set(r.tx.hash, r.tx);
202
+ }
203
+ for (const t of found.values()) {
204
+ if (t.block >= fromBlock && t.block <= toBlock && !inRange.has(t.hash)) inRange.set(t.hash, t);
205
+ }
206
+ const txs = [...inRange.values()].sort((a, b) => a.block - b.block || a.nonce - b.nonce);
207
+ await enrichTxs(rpc, txs, headers);
208
+ for (const w of list) for (const t of txs) if (t.from === w && t.nonce >= nonceAt[w]) nonceAt[w] = t.nonce + 1;
209
+
210
+ return {
211
+ nonceAt, resolved, txs, gaps, unscannedRanges,
212
+ scannedBlocks: seenBlocks.size, plannedBlocks, unresolvedHashes, known,
213
+ scanSeconds: seenBlocks.size ? (Date.now() - scanStarted) / 1000 : 0,
214
+ scannedHashes: new Set(found.keys()), seconds: (Date.now() - started) / 1000,
215
+ };
216
+ }