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/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ First release.
6
+
7
+ - `chainfess demo`: bundled fixture, recorded RPC replies, no wallet, no network.
8
+ - `chainfess audit`: Claude Code session logs (`.jsonl`), subagent transcripts, saved tool outputs and Foundry `broadcast/` files, compared with the wallet's nonce and blocks on Robinhood Chain.
9
+ - Four outcomes, deterministic, no LLM: Accounted, Partial, Unaccounted, Foreign signer.
10
+ - `chainfess sign` and `chainfess verify` for signed reports.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Chainfess
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,78 @@
1
- # Temporary Holding Version
1
+ # chainfess
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Your agent says X. The chain says Y. Chainfess lists every transaction the log does not account for.
4
+
5
+ Try it in 10 seconds, no wallet, no network:
6
+
7
+ ```
8
+ npx chainfess demo
9
+ ```
10
+
11
+ Audit a past session:
12
+
13
+ ```
14
+ npx chainfess audit --wallet 0x…
15
+ ```
16
+
17
+ Default log = latest Claude Code session for the current directory.
18
+
19
+ ## What it does
20
+
21
+ The wallet's nonce says how many transactions it has ever sent. The report shows how many of them this log explains.
22
+
23
+ Four outcomes, deterministic, no LLM: Accounted · Partial · **Unaccounted** · **Foreign signer**.
24
+
25
+ | Status | Rule | Label |
26
+ |---|---|---|
27
+ | Accounted | A tx of your wallet, in the session's blocks, whose full hash is in the log, a subagent transcript, a saved tool output or a broadcast file | accounted (source and line) |
28
+ | Partial | Same, but the log only has a shortened form with the same prefix and suffix | partial match only |
29
+ | **Unaccounted** | A tx of your wallet, in the session's blocks, with no full or partial match | **not in this log** |
30
+ | **Foreign signer** | A tx from a wallet you did not pass, whose hash first came out of a recognised sending command, mined during that call | **signed by a wallet not registered here** |
31
+
32
+ Not findings, shown for information: sent before the session, sent after it, pending, another wallet's tx mined during a tool call (cannot tell a read from a send), another wallet's tx referenced (read). Hex strings that are not transactions on this chain are one count in the footer.
33
+
34
+ Unaccounted means "not in this log". A human, a cron job or another session on the same wallet produces the same line. Chainfess does not say who sent it.
35
+
36
+ Exit codes: 0 no findings, 1 findings, 2 input error, 3 RPC unavailable, 4 partial scan (unscanned ranges remain).
37
+
38
+ ## Privacy
39
+
40
+ `audit` and `demo` run on your machine. The log never leaves it; `audit`'s only network calls are JSON-RPC reads with block numbers, values the log labels as tx hashes, and your wallet address. Only the optional `sign` contacts our server: it sends the wallets, block range, hashes and a hash of the log, never the log.
41
+
42
+ A private key has the same shape as a tx hash, so a 32-byte hex value goes to the RPC only when its context says it is a tx hash: the value of a `transactionHash`, `txHash` or `hash` field or label, the bare output of a plain `cast send` or `forge` call, an explorer `/tx/` URL, or the argument of `cast tx`, `cast receipt`, `cast run` or `eth_getTransactionByHash`. A value with `key`, `secret`, `mnemonic`, `seed`, `password`, `token` or `private` on its line or in its JSON object, in a shell assignment, or that is the private key of a wallet you passed, never leaves your machine. Neither does anything else. Those local values are still matched against the wallet's txs found by the block scan, so your own tx stays Accounted; the scan may just be longer. The footer counts both: "looked up, not a tx" and "not looked up, stays local".
43
+
44
+ Reports show hashes, addresses, values, the command pattern name, the source kind and a line reference. Never command text, never secrets.
45
+
46
+ ## Verify
47
+
48
+ Verify the rules yourself: `cast tx <hash>` and `cast nonce <wallet>` reproduce every number. The report footer prints both lines.
49
+
50
+ ## Scope
51
+
52
+ Works with Claude Code session logs (`.jsonl`), including subagent transcripts and Foundry `broadcast/` files. EOA wallets only. Robinhood Chain by default; any EVM RPC with `--rpc` (then limits apply, unmeasured).
53
+
54
+ ## Limits
55
+
56
+ Not audited. Nothing is prevented. An agent with shell access can edit its own local log; chain facts stay true, log-based lines then mislead.
57
+
58
+ A script that signs with another wallet without a recognised key flag or command pattern shows only as "other wallet's tx mined during this tool call", not as a finding. Another wallet's hash printed with no hash label stays local, so it is not listed at all. So does a sending command that also printed a tx of your own wallet, or txs of more than two other wallets, because that output is a dump, not a send.
59
+
60
+ No balance deltas: the free RPC refuses historical balances. A planned scan over 210,000 blocks (about 6 h of chain, about 58 minutes to scan at 60 blocks/s, measured on 5 Nov 2026 over 30,000 blocks on the public RPC) is refused unless you pass `--max-hours` or a block range. The plan is the worst case: a scan stops a gap once its nonces are found, and a hash with no label can make the plan larger. Over 512 MB, a saved tool output is not read; the footer says so, because "not in this log" lines may then be explained by that output.
61
+
62
+ ## Signed report
63
+
64
+ burn 0.01% of supply in $CFESS (exact amount set from the contract at launch) from the audited wallet, run `npx chainfess sign --burn-tx 0x…`, get an ed25519-signed statement of chain facts that our server re-checked itself. The server never sees your log, so which hashes you listed is your claim, not ours. `npx chainfess verify <statement.json>` checks it offline.
65
+
66
+ $CFESS launches 6 Nov 2026 (UTC date). No CA exists yet; any $CFESS you see before it is posted on chainfess.pro and on @chainfesspro is not ours. The signed-report service switches on with the CA. The free CLI never needs the coin.
67
+
68
+ ## Options
69
+
70
+ ```
71
+ chainfess audit --wallet 0x… [--log f]… [--all-sessions] [--rpc url] [--chain-id n]
72
+ [--from ISO] [--to ISO] [--from-block n] [--to-block n] [--max-hours h]
73
+ [--yes] [--json] [--no-subagents] [--quiet] [--debug]
74
+ ```
75
+
76
+ `--no-subagents` skips subagent transcripts only; the main session's saved tool outputs are still read.
77
+
78
+ MIT.
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../src/cli.mjs';
3
+
4
+ main(process.argv.slice(2)).then(
5
+ (code) => { process.exitCode = code; },
6
+ (e) => { process.stderr.write(`unexpected error: ${e && e.message ? e.message : 'unknown'}\n`); process.exitCode = 3; },
7
+ );
@@ -0,0 +1,50 @@
1
+ {
2
+ "chainId": 4663,
3
+ "latest": 100000000,
4
+ "latestTs": 1793793600,
5
+ "blockTime": 0.104,
6
+ "demoWallets": [
7
+ "0xdea1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1de"
8
+ ],
9
+ "demoNow": "2026-11-04T12:00:00Z",
10
+ "nonces": {
11
+ "0xdea1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1de": 9
12
+ },
13
+ "code": {
14
+ "0x77777777777777777777777777777777777777c0": "0x60806040"
15
+ },
16
+ "txs": [
17
+ {
18
+ "hash": "0x7485fc9a91208a06a1442ca82f0a4c7b6e0101cb2ae7c0b683331197aacc8ae5",
19
+ "from": "0xdea1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1de",
20
+ "to": "0x77777777777777777777777777777777777777c0",
21
+ "nonce": 7,
22
+ "block": 99920404,
23
+ "value": "0x2386f26fc10000"
24
+ },
25
+ {
26
+ "hash": "0x92b9e5a06df6044d67d658b7c163a105c31041bd43a113e9c3a3826e647ec6d8",
27
+ "from": "0xdea1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1de",
28
+ "to": "0x5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5eed",
29
+ "nonce": 8,
30
+ "block": 99925962,
31
+ "value": "0x3782dace9d90000"
32
+ },
33
+ {
34
+ "hash": "0xa51161f809ec4dc3cc2e9abb17549184e4cf02ba5b5a35ea75e035df2ee0dc7c",
35
+ "from": "0x5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5e5eed",
36
+ "to": "0x77777777777777777777777777777777777777c0",
37
+ "nonce": 41,
38
+ "block": 99924443,
39
+ "value": "0x0"
40
+ },
41
+ {
42
+ "hash": "0x3928db72ff86102897ced08fab73a41f2654295f298154c16929be838d36ef42",
43
+ "from": "0x0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b",
44
+ "to": "0x77777777777777777777777777777777777777c0",
45
+ "nonce": 3,
46
+ "block": 90000000,
47
+ "value": "0x0"
48
+ }
49
+ ]
50
+ }
@@ -0,0 +1,8 @@
1
+ {"type":"user","timestamp":"2026-11-04T09:40:00.000Z","sessionId":"sess-1","uuid":"u159","parentUuid":null,"isSidechain":false,"message":{"role":"user","content":"Deploy the vault and fund it, then tell me what you did."}}
2
+ {"type":"assistant","timestamp":"2026-11-04T09:42:00.000Z","sessionId":"sess-1","uuid":"u160","parentUuid":null,"isSidechain":false,"message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_d1","name":"Bash","input":{"command":"cast send 0x77777777777777777777777777777777777777c0 --value 0.01ether --rpc-url $RPC"}}]}}
3
+ {"type":"user","timestamp":"2026-11-04T09:42:03.000Z","sessionId":"sess-1","uuid":"u161","parentUuid":null,"isSidechain":false,"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_d1","content":"status 1\ntransactionHash 0x7485fc9a91208a06a1442ca82f0a4c7b6e0101cb2ae7c0b683331197aacc8ae5"}]}}
4
+ {"type":"assistant","timestamp":"2026-11-04T09:45:00.000Z","sessionId":"sess-1","uuid":"u162","parentUuid":null,"isSidechain":false,"message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_d2","name":"Bash","input":{"command":"cast tx 0x3928db72ff86102897ced08fab73a41f2654295f298154c16929be838d36ef42 --rpc-url $RPC"}}]}}
5
+ {"type":"user","timestamp":"2026-11-04T09:45:01.000Z","sessionId":"sess-1","uuid":"u163","parentUuid":null,"isSidechain":false,"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_d2","content":"from 0x0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b\nblockNumber 90000000"}]}}
6
+ {"type":"assistant","timestamp":"2026-11-04T09:49:00.000Z","sessionId":"sess-1","uuid":"u164","parentUuid":null,"isSidechain":false,"message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_d3","name":"Bash","input":{"command":"cast send 0x77777777777777777777777777777777777777c0 --private-key $OTHER_KEY --rpc-url $RPC"}}]}}
7
+ {"type":"user","timestamp":"2026-11-04T09:49:03.000Z","sessionId":"sess-1","uuid":"u165","parentUuid":null,"isSidechain":false,"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_d3","content":"status 1\ntransactionHash 0xa51161f809ec4dc3cc2e9abb17549184e4cf02ba5b5a35ea75e035df2ee0dc7c"}]}}
8
+ {"type":"assistant","timestamp":"2026-11-04T09:54:00.000Z","sessionId":"sess-1","uuid":"u166","parentUuid":null,"isSidechain":false,"message":{"role":"assistant","content":[{"type":"text","text":"Done. Funded the vault in one transaction: 0x7485fc…cc8ae5."}]}}
package/package.json CHANGED
@@ -1,6 +1,13 @@
1
1
  {
2
2
  "name": "chainfess",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Compares a coding agent's session log with what its wallet did on chain and lists every transaction the log does not account for.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": { "chainfess": "bin/chainfess.mjs" },
8
+ "engines": { "node": ">=20" },
9
+ "files": ["bin", "src", "fixtures/demo", "README.md", "CHANGELOG.md", "LICENSE"],
10
+ "scripts": { "test": "node test/run.mjs" },
11
+ "keywords": ["robinhood-chain", "claude-code", "agent", "audit", "nonce", "evm", "cli"],
12
+ "dependencies": {}
13
+ }
package/src/audit.mjs ADDED
@@ -0,0 +1,344 @@
1
+ // The audit pipeline (BRIEF §3.2). Everything local except JSON-RPC reads through src/rpc.mjs.
2
+ import { existsSync, statSync } from 'node:fs';
3
+ import { basename, resolve } from 'node:path';
4
+ import { createRpc, DEFAULT_RPC, FALLBACK_RPC, RpcUnavailableError, safeUrl } from './rpc.mjs';
5
+ import { runLedger, enrichTxs, ScanCapError, DEFAULT_MAX_SCAN_BLOCKS } from './ledger.mjs';
6
+ import { mapRange, MARGIN_END, MARGIN_START } from './blocks.mjs';
7
+ import { findProjectDir, newestLog, overlappingLogs, parseAll } from './parse.mjs';
8
+ import { classify, dumpCalls, mentionFlag, partialMatch, STATUS } from './classify.mjs';
9
+ import { addressOfKey } from './keccak.mjs';
10
+ import { buildReport } from './report.mjs';
11
+
12
+ export const CHAIN_ID = 4663;
13
+ // Sustained rate of the public RPC, measured 05-11-2026 over 30,000 full blocks. The first ~7,500 blocks run near 170/s.
14
+ export const SCAN_BLOCKS_PER_SEC = 60;
15
+ const RATE_SOURCE = `${SCAN_BLOCKS_PER_SEC} blocks/s, measured on 5 Nov 2026 over 30,000 blocks on the public RPC`;
16
+ export const BLOCKS_PER_HOUR = 35000;
17
+ export const MAX_LOOKUPS = 20000;
18
+ const CONFIRM_ABOVE_SEC = 600;
19
+ const MAX_KEY_CHECKS = 5000;
20
+
21
+ export function formatDuration(sec) {
22
+ const s = Math.ceil(sec);
23
+ if (s < 90) return `${s} s`;
24
+ if (s < 5400) return `${Math.ceil(s / 60)} min`;
25
+ return `${(s / 3600).toFixed(1)} h`;
26
+ }
27
+
28
+ export class InputError extends Error {
29
+ constructor(message) { super(message); this.name = 'InputError'; }
30
+ }
31
+ export class Declined extends Error {}
32
+
33
+ export function normalizeWallets(list) {
34
+ const arr = (list || []).map((w) => String(w));
35
+ if (!arr.length) throw new InputError('--wallet 0x… is required');
36
+ for (const w of arr) if (!/^0x[0-9a-fA-F]{40}$/.test(w)) throw new InputError(`not a wallet address: ${w.slice(0, 44)}`);
37
+ return [...new Set(arr.map((w) => w.toLowerCase()))];
38
+ }
39
+
40
+ function parseIso(s, flag) {
41
+ const t = Date.parse(s);
42
+ if (!Number.isFinite(t)) throw new InputError(`${flag} is not an ISO UTC time`);
43
+ return t;
44
+ }
45
+ function parseBlockFlag(v, flag) {
46
+ if (v === undefined || v === null) return null;
47
+ const n = Number(v);
48
+ if (!Number.isInteger(n) || n < 0) throw new InputError(`${flag} is not a block number`);
49
+ return n;
50
+ }
51
+
52
+ function resolveLogs(opts, cwd) {
53
+ const given = (opts.logs || []).map((p) => resolve(cwd, p));
54
+ for (const p of given) {
55
+ if (!existsSync(p) || !statSync(p).isFile()) throw new InputError(`log not found: ${p}`);
56
+ }
57
+ if (given.length) return { logs: given, explicit: true };
58
+ const { dir, lookedIn } = findProjectDir(cwd, opts.projectsDir);
59
+ const newest = dir ? newestLog(dir) : null;
60
+ if (!newest) throw new InputError(`no Claude Code session log for this directory; looked in ${lookedIn}. Pass --log <file>.`);
61
+ return { logs: [newest], explicit: false };
62
+ }
63
+
64
+ /**
65
+ * The only values that may go to an RPC: hash context somewhere in the log, no key context anywhere, not a wallet's own key.
66
+ * Everything else stays in this process and is matched against txs the block scan finds.
67
+ */
68
+ export function lookupCandidates(sink, wallets) {
69
+ const walletSet = new Set(wallets);
70
+ const out = [];
71
+ let localOnly = 0;
72
+ let notLookedUp = 0;
73
+ let checks = 0;
74
+ for (const h of sink.hashes.keys()) {
75
+ if (!sink.positive.has(h) || sink.keyLike.has(h)) { localOnly++; continue; }
76
+ if (checks++ < MAX_KEY_CHECKS) {
77
+ const a = addressOfKey(h);
78
+ if (a && walletSet.has(a)) { localOnly++; continue; }
79
+ }
80
+ if (out.length >= MAX_LOOKUPS) { notLookedUp++; continue; }
81
+ out.push(h);
82
+ }
83
+ return { hashes: out, localOnly, notLookedUp };
84
+ }
85
+
86
+ function buildCtx(sink, walletSet, startBlock, endBlock) {
87
+ return {
88
+ wallets: walletSet, startBlock, endBlock, tEnd: sink.tEnd,
89
+ occurrence: (hash) => {
90
+ const rec = sink.hashes.get(hash);
91
+ const bare = sink.bare.get(hash.slice(2));
92
+ if (!rec && !bare) return null;
93
+ return { ...(rec || { first: null, other: null }), bare: bare || null };
94
+ },
95
+ partial: (hash) => partialMatch(hash, sink.partials),
96
+ tool: (id) => sink.tools.get(id) || null,
97
+ finals: [...sink.finals.values()],
98
+ textFull: sink.textFull,
99
+ textPartials: sink.textPartials,
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Runs everything but rendering. opts: wallets, logs, allSessions, rpc, chainId, from, to, fromBlock, toBlock, maxHours,
105
+ * noSubagents, yes, cwd, fetchImpl, paceMs, sleep, confirm, progress, demo.
106
+ */
107
+ export async function runAudit(opts) {
108
+ const wallets = normalizeWallets(opts.wallets);
109
+ const walletSet = new Set(wallets);
110
+ const cwd = opts.cwd || process.cwd();
111
+ const started = Date.now();
112
+ const fromBlockFlag = parseBlockFlag(opts.fromBlock, '--from-block');
113
+ const toBlockFlag = parseBlockFlag(opts.toBlock, '--to-block');
114
+ const fromT = opts.from ? parseIso(opts.from, '--from') : null;
115
+ const toT = opts.to ? parseIso(opts.to, '--to') : null;
116
+ if (opts.maxHours !== undefined && opts.maxHours !== null && !(Number(opts.maxHours) > 0)) throw new InputError('--max-hours must be a positive number');
117
+ const maxScanBlocks = opts.maxHours ? Math.ceil(Number(opts.maxHours) * BLOCKS_PER_HOUR) : DEFAULT_MAX_SCAN_BLOCKS;
118
+ const progress = opts.progress || (() => {});
119
+ const { logs: mainLogs, explicit } = resolveLogs(opts, cwd);
120
+
121
+ // Local parse first: a bad log must not cost a single request.
122
+ let { sink, stats } = await parseAll({
123
+ mainLogs, includeSubagents: !opts.noSubagents, cwd: opts.noBroadcast ? null : cwd,
124
+ maxToolResultBytes: opts.maxToolResultBytes, maxLineChars: opts.maxLineChars,
125
+ onBytes: opts.onBytes,
126
+ });
127
+ if (sink.validEntries === 0 && sink.unreadableLines > 0) throw new InputError(`no readable entries in the log (${sink.unreadableLines} unreadable lines)`);
128
+ const hasExplicitRange = (fromBlockFlag !== null || fromT !== null) && (toBlockFlag !== null || toT !== null);
129
+ if (sink.tStart === null && !hasExplicitRange) throw new InputError('log has no timestamped entries; pass --from/--to or --from-block/--to-block');
130
+
131
+ const tStartMs = fromT ?? sink.tStart;
132
+ const tEndMs = toT ?? sink.tEnd;
133
+
134
+ // Other sessions of the same project that overlap this span.
135
+ let otherLogs = [];
136
+ if (tStartMs !== null && tEndMs !== null) otherLogs = overlappingLogs(mainLogs, tStartMs, tEndMs);
137
+ let otherCount = otherLogs.length;
138
+ let otherNames = otherLogs.map((p) => basename(p));
139
+ if (opts.allSessions && otherLogs.length) {
140
+ // Evidence only: the audited span stays the span of the logs asked for.
141
+ ({ sink, stats } = await parseAll({
142
+ mainLogs, evidenceLogs: otherLogs, includeSubagents: !opts.noSubagents, cwd: opts.noBroadcast ? null : cwd,
143
+ maxToolResultBytes: opts.maxToolResultBytes, maxLineChars: opts.maxLineChars, onBytes: opts.onBytes,
144
+ }));
145
+ otherCount = 0;
146
+ otherNames = [];
147
+ stats.includedOther = otherLogs.length;
148
+ }
149
+
150
+ // Preflight.
151
+ const chainId = opts.chainId !== undefined && opts.chainId !== null ? Number(opts.chainId) : CHAIN_ID;
152
+ if (opts.chainId !== undefined && opts.chainId !== null && !Number.isInteger(chainId)) throw new InputError('--chain-id is not a number');
153
+ if (opts.chainId !== undefined && opts.chainId !== null && !opts.rpc) throw new InputError('--chain-id needs --rpc');
154
+ const url = opts.rpc || DEFAULT_RPC;
155
+ const fallbackUrl = opts.rpc ? null : (opts.fallback === false ? null : FALLBACK_RPC);
156
+ const rpc = createRpc({ url, fallbackUrl, chainId, fetchImpl: opts.fetchImpl, paceMs: opts.paceMs ?? 300, sleep: opts.sleep });
157
+ let latest;
158
+ let latestTs;
159
+ try {
160
+ const got = await rpc.call('eth_chainId', [], { quick: true });
161
+ if (Number(got) !== chainId) {
162
+ throw new InputError(`the RPC at ${safeUrl(rpc.url || url)} is on chain ${Number(got)}, expected ${chainId}. Use --rpc <url> --chain-id <n> to audit another chain.`);
163
+ }
164
+ latest = Number(await rpc.call('eth_blockNumber', [], { quick: true }));
165
+ const head = await rpc.call('eth_getBlockByNumber', ['0x' + latest.toString(16), false], { quick: true });
166
+ if (!head) throw new RpcUnavailableError('no latest block');
167
+ latestTs = Number(head.timestamp);
168
+ } catch (e) {
169
+ if (e instanceof InputError) throw e;
170
+ throw new RpcUnavailableError(`RPC unavailable: tried ${[url, fallbackUrl].filter(Boolean).map(safeUrl).join(' and ')}. Pass --rpc <url> to use another endpoint.`);
171
+ }
172
+
173
+ // Blocks.
174
+ const headers = new Map();
175
+ let startBlock = fromBlockFlag;
176
+ let endBlock = toBlockFlag;
177
+ let rangeNotes = [];
178
+ if (startBlock === null || endBlock === null) {
179
+ const effectiveStart = tStartMs ?? tEndMs;
180
+ const effectiveEnd = tEndMs ?? tStartMs;
181
+ const mapped = await mapRange({
182
+ rpc, tStartMs: effectiveStart, tEndMs: effectiveEnd, latest, latestTs, cache: headers,
183
+ marginStart: fromT === null ? MARGIN_START : 0, marginEnd: toT === null ? MARGIN_END : 0,
184
+ });
185
+ if (startBlock === null) startBlock = mapped.startBlock;
186
+ if (endBlock === null) endBlock = mapped.endBlock;
187
+ rangeNotes = mapped.notes;
188
+ }
189
+ if (endBlock > latest) endBlock = latest;
190
+ if (startBlock > endBlock) throw new InputError(`block range is empty: ${startBlock} to ${endBlock}`);
191
+
192
+ // Ledger.
193
+ const candidates = lookupCandidates(sink, wallets);
194
+ const onPlan = async ({ plannedBlocks, gaps }) => {
195
+ const estimate = Math.ceil(plannedBlocks / SCAN_BLOCKS_PER_SEC);
196
+ progress(`scan plan: ${plannedBlocks.toLocaleString('en-US')} blocks in ${gaps.length} gap${gaps.length === 1 ? "" : "s"}, up to about ${formatDuration(estimate)} at ${RATE_SOURCE}`);
197
+ if (plannedBlocks > maxScanBlocks) return; // the ledger refuses with the numbers
198
+ if (estimate > CONFIRM_ABOVE_SEC && !opts.yes) {
199
+ const ok = opts.confirm ? await opts.confirm(`Estimated scan is over 10 minutes (about ${formatDuration(estimate)}). Continue?`) : false;
200
+ if (!ok) throw new Declined(`scan needs confirmation (${plannedBlocks} blocks, about ${formatDuration(estimate)}); pass --yes`);
201
+ }
202
+ };
203
+ let ledger;
204
+ try {
205
+ ledger = await runLedger({
206
+ rpc, wallets, fromBlock: startBlock, toBlock: endBlock, knownHashes: candidates.hashes,
207
+ maxScanBlocks, onPlan, headers,
208
+ onProgress: ({ scannedBlocks, plannedBlocks, blocksPerSec }) => {
209
+ const rate = blocksPerSec ? `, ${Math.round(blocksPerSec)} blocks/s measured, about ${formatDuration((plannedBlocks - scannedBlocks) / blocksPerSec)} left at most` : '';
210
+ progress(`scanned ${scannedBlocks.toLocaleString('en-US')} / ${plannedBlocks.toLocaleString('en-US')} blocks${rate}`, true);
211
+ },
212
+ });
213
+ } catch (e) {
214
+ if (e instanceof ScanCapError) {
215
+ const hours = Math.ceil(e.planned / BLOCKS_PER_HOUR);
216
+ throw new InputError(`refusing: this audit would scan ${e.planned} blocks, over the cap of ${e.cap} (about ${formatDuration(e.planned / SCAN_BLOCKS_PER_SEC)} at ${RATE_SOURCE}). To allow it: --max-hours ${hours}. To scan less: --from-block <n> --to-block <n>.`);
217
+ }
218
+ throw e;
219
+ }
220
+
221
+ // Classify.
222
+ const ctx = buildCtx(sink, walletSet, startBlock, endBlock);
223
+ const items = new Map();
224
+ for (const t of ledger.txs) items.set(t.hash, t);
225
+ const others = [];
226
+ for (const [hash, r] of ledger.resolved) {
227
+ if (r.status === 'null' || items.has(hash)) continue;
228
+ others.push(r.tx);
229
+ items.set(hash, r.tx);
230
+ }
231
+ await enrichForeign(rpc, others, headers);
232
+ ctx.dumps = dumpCalls([...items.values()].filter((t) => t.block !== null), ctx);
233
+
234
+ const rows = [];
235
+ for (const t of items.values()) {
236
+ const res = classify(t, ctx);
237
+ rows.push({ tx: t, ...res, mentioned: mentionFlag(t.hash, ctx) });
238
+ }
239
+ await labelTargets(rpc, rows, opts.tokenAddress);
240
+
241
+ // The header numbers of §2.2.5, with what the scan learned.
242
+ const walletRows = wallets.map((w) => summarizeWallet(w, ledger, rows, ctx, startBlock, endBlock));
243
+
244
+ const seconds = (Date.now() - started) / 1000;
245
+ const reqs = rpc.stats();
246
+ return {
247
+ wallets, chainId, rows, walletRows, ledger, stats, sink, candidates, startBlock, endBlock, tStartMs, tEndMs,
248
+ rangeNotes, otherCount, otherNames, rpcStats: reqs, seconds, usedDefaultRpc: !opts.rpc, explicitLogs: explicit, kEmpty: walletRows.some((w) => w.inLog === 0 && w.nonce > 0),
249
+ mainLogs,
250
+ };
251
+ }
252
+
253
+ async function enrichForeign(rpc, others, headers) {
254
+ const mined = others.filter((t) => t.block !== null);
255
+ // A block dump can print thousands of third-party txs; timestamps are needed for the window, receipts only when few.
256
+ const manyRecent = mined.length > 200;
257
+ if (manyRecent) for (const t of mined) t.reverted = null;
258
+ await enrichTxs(rpc, mined, headers);
259
+ }
260
+
261
+ async function labelTargets(rpc, rows, tokenAddress) {
262
+ const token = tokenAddress ? String(tokenAddress).toLowerCase() : null;
263
+ const need = [...new Set(rows
264
+ .filter((r) => r.status !== STATUS.RECENT && r.tx.to && r.tx.to !== token)
265
+ .map((r) => r.tx.to))];
266
+ const codes = new Map();
267
+ if (need.length) {
268
+ const got = await rpc.batch(need.map((a) => ['eth_getCode', [a, 'latest']]), { size: 50 });
269
+ need.forEach((a, i) => codes.set(a, got[i]));
270
+ }
271
+ for (const r of rows) {
272
+ const to = r.tx.to;
273
+ if (!to) r.toLabel = null;
274
+ else if (to === token) r.toLabel = 'token';
275
+ else if (!codes.has(to)) r.toLabel = null;
276
+ else r.toLabel = codes.get(to) && codes.get(to) !== '0x' ? 'contract' : 'eoa';
277
+ }
278
+ }
279
+
280
+ /** u_in, u_out and, when the nonce order settles it, how many of u_out are after the session. */
281
+ export function summarizeWallet(wallet, ledger, rows, ctx, startBlock, endBlock) {
282
+ const nonce = ledger.nonceAt[wallet] ?? 0;
283
+ const mine = rows.filter((r) => r.tx.from === wallet && r.tx.block !== null && r.tx.nonce < nonce);
284
+ const inLogNonces = new Set();
285
+ const inRangeNotInLog = new Set();
286
+ for (const r of mine) {
287
+ const occ = ctx.occurrence(r.tx.hash) || ctx.partial(r.tx.hash);
288
+ if (occ) inLogNonces.add(r.tx.nonce);
289
+ else if (r.tx.block >= startBlock && r.tx.block <= endBlock) inRangeNotInLog.add(r.tx.nonce);
290
+ }
291
+ const inLog = inLogNonces.size;
292
+ const notInLog = Math.max(0, nonce - inLog);
293
+ const uIn = inRangeNotInLog.size;
294
+ const uOut = Math.max(0, notInLog - uIn);
295
+ let uAfter = 0;
296
+ let uBefore = 0;
297
+ if (ledger.unscannedRanges.length === 0 && uOut > 0) {
298
+ const inside = mine.filter((r) => r.tx.block >= startBlock && r.tx.block <= endBlock).map((r) => r.tx.nonce);
299
+ const below = mine.filter((r) => r.tx.block < startBlock).map((r) => r.tx.nonce);
300
+ const above = mine.filter((r) => r.tx.block > endBlock).map((r) => r.tx.nonce);
301
+ const seen = new Set(mine.map((r) => r.tx.nonce));
302
+ const loIn = inside.length ? Math.min(...inside) : null;
303
+ const hiIn = inside.length ? Math.max(...inside) : null;
304
+ const hiBelow = below.length ? Math.max(...below) : null;
305
+ const loAbove = above.length ? Math.min(...above) : null;
306
+ const countFree = (a, b) => { // nonces in [a, b] with no known tx
307
+ let c = 0;
308
+ for (const n of seen) if (n >= a && n <= b) c++;
309
+ return Math.max(0, b - a + 1 - c);
310
+ };
311
+ if (loIn !== null) {
312
+ uBefore = countFree(0, loIn - 1);
313
+ uAfter = countFree(hiIn + 1, nonce - 1);
314
+ } else {
315
+ if (hiBelow !== null && loAbove === null) {
316
+ uBefore = countFree(0, hiBelow);
317
+ uAfter = countFree(hiBelow + 1, nonce - 1);
318
+ } else if (loAbove !== null && hiBelow === null) {
319
+ uBefore = countFree(0, loAbove - 1);
320
+ uAfter = countFree(loAbove, nonce - 1);
321
+ } else if (hiBelow !== null && loAbove !== null) {
322
+ uBefore = countFree(0, hiBelow);
323
+ uAfter = countFree(loAbove, nonce - 1);
324
+ }
325
+ }
326
+ }
327
+ return { wallet, nonce, inLog, notInLog, uIn, uOut, uAfter, uBefore, neverSent: nonce === 0 };
328
+ }
329
+
330
+ /** What `chainfess sign` posts (§3.1). Only hashes that resolved to txs of these wallets; never other candidates, never the log. */
331
+ export async function prepareAudit(opts) {
332
+ const a = await runAudit(opts);
333
+ const hashes = a.rows
334
+ .filter((r) => r.status === STATUS.ACCOUNTED && a.wallets.includes(r.tx.from))
335
+ .map((r) => r.tx.hash);
336
+ return {
337
+ wallets: a.wallets, fromBlock: a.startBlock, toBlock: a.endBlock, hashes, logSha256: a.stats.logSha256,
338
+ };
339
+ }
340
+
341
+ export async function auditReport(opts) {
342
+ const a = await runAudit(opts);
343
+ return { report: buildReport(a, { demo: !!opts.demo, now: opts.now }), audit: a };
344
+ }