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 +10 -0
- package/LICENSE +21 -0
- package/README.md +77 -2
- package/bin/chainfess.mjs +7 -0
- package/fixtures/demo/rpc.json +50 -0
- package/fixtures/demo/session.jsonl +8 -0
- package/package.json +11 -4
- package/src/audit.mjs +344 -0
- package/src/blocks.mjs +127 -0
- package/src/canonical.mjs +19 -0
- package/src/classify.mjs +111 -0
- package/src/cli.mjs +231 -0
- package/src/hook.mjs +202 -0
- package/src/keccak.mjs +75 -0
- package/src/ledger.mjs +216 -0
- package/src/parse.mjs +621 -0
- package/src/pubkey.mjs +5 -0
- package/src/replay.mjs +67 -0
- package/src/report.mjs +273 -0
- package/src/rpc.mjs +225 -0
- package/src/sign.mjs +139 -0
package/src/replay.mjs
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// A recorded chain answering JSON-RPC from a fixture. Used by `demo` and by tests. Never touches the network.
|
|
2
|
+
// world: { chainId, latest, latestTs, blockTime, nonces:{addr:n}, code:{addr:hex}, txs:[{hash,from,to,nonce,block,value,status,contractAddress}], pending:[…] }
|
|
3
|
+
export function createReplay(world) {
|
|
4
|
+
const log = { requests: 0, blocksFull: [], blocksHeader: [], hashes: [], receipts: [], methods: {}, bodies: [] };
|
|
5
|
+
const blockTime = world.blockTime ?? 0.104;
|
|
6
|
+
const txByHash = new Map();
|
|
7
|
+
const byBlock = new Map();
|
|
8
|
+
for (const t of world.txs || []) {
|
|
9
|
+
txByHash.set(t.hash.toLowerCase(), t);
|
|
10
|
+
if (t.block !== null && t.block !== undefined) {
|
|
11
|
+
if (!byBlock.has(t.block)) byBlock.set(t.block, []);
|
|
12
|
+
byBlock.get(t.block).push(t);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
const tsOf = (n) => {
|
|
16
|
+
if (world.timestampAt) return world.timestampAt(n);
|
|
17
|
+
return Math.floor(world.latestTs - (world.latest - n) * blockTime);
|
|
18
|
+
};
|
|
19
|
+
const hex = (n) => '0x' + Number(n).toString(16);
|
|
20
|
+
const rpcTx = (t) => ({
|
|
21
|
+
hash: t.hash, from: t.from, to: t.to ?? null, nonce: hex(t.nonce),
|
|
22
|
+
blockNumber: t.block === null || t.block === undefined ? null : hex(t.block),
|
|
23
|
+
value: t.value ?? '0x0', input: t.input ?? '0x',
|
|
24
|
+
});
|
|
25
|
+
const answer = (method, params) => {
|
|
26
|
+
log.methods[method] = (log.methods[method] || 0) + 1;
|
|
27
|
+
switch (method) {
|
|
28
|
+
case 'eth_chainId': return hex(world.chainId);
|
|
29
|
+
case 'eth_blockNumber': return hex(world.latest);
|
|
30
|
+
case 'eth_getTransactionCount': return hex((world.nonces || {})[String(params[0]).toLowerCase()] ?? 0);
|
|
31
|
+
case 'eth_getCode': return (world.code || {})[String(params[0]).toLowerCase()] ?? '0x';
|
|
32
|
+
case 'eth_getBlockByNumber': {
|
|
33
|
+
const n = params[0] === 'latest' ? world.latest : Number(params[0]);
|
|
34
|
+
if (world.prunedBelow !== undefined && n < world.prunedBelow) return null;
|
|
35
|
+
if (n > world.latest || n < 0) return null;
|
|
36
|
+
(params[1] ? log.blocksFull : log.blocksHeader).push(n);
|
|
37
|
+
const list = byBlock.get(n) || [];
|
|
38
|
+
return { number: hex(n), timestamp: hex(tsOf(n)), transactions: params[1] ? list.map(rpcTx) : list.map((t) => t.hash) };
|
|
39
|
+
}
|
|
40
|
+
case 'eth_getTransactionByHash': {
|
|
41
|
+
const h = String(params[0]).toLowerCase();
|
|
42
|
+
log.hashes.push(h);
|
|
43
|
+
const t = txByHash.get(h);
|
|
44
|
+
return t ? rpcTx(t) : null;
|
|
45
|
+
}
|
|
46
|
+
case 'eth_getTransactionReceipt': {
|
|
47
|
+
const h = String(params[0]).toLowerCase();
|
|
48
|
+
log.receipts.push(h);
|
|
49
|
+
const t = txByHash.get(h);
|
|
50
|
+
if (!t || t.block === null || t.block === undefined) return null;
|
|
51
|
+
return { transactionHash: t.hash, status: t.status ?? '0x1', contractAddress: t.contractAddress ?? null, blockNumber: hex(t.block) };
|
|
52
|
+
}
|
|
53
|
+
default:
|
|
54
|
+
throw new Error(`replay: no answer for ${method}`);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
const fetchImpl = async (_url, init) => {
|
|
58
|
+
log.requests++;
|
|
59
|
+
log.bodies.push(init.body);
|
|
60
|
+
if (world.fail && world.fail(log)) return { status: 503, json: async () => ({}) };
|
|
61
|
+
const req = JSON.parse(init.body);
|
|
62
|
+
const one = (r) => ({ jsonrpc: '2.0', id: r.id, result: answer(r.method, r.params) });
|
|
63
|
+
const res = Array.isArray(req) ? req.map(one) : one(req);
|
|
64
|
+
return { status: 200, json: async () => res };
|
|
65
|
+
};
|
|
66
|
+
return { fetchImpl, log };
|
|
67
|
+
}
|
package/src/report.mjs
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// Report JSON and text. Only whitelisted fields (BRIEF §3.5): command text, tool input and log excerpts never get here.
|
|
2
|
+
import { toChecksum } from './keccak.mjs';
|
|
3
|
+
import { STATUS } from './classify.mjs';
|
|
4
|
+
import { DEFAULT_RPC } from './rpc.mjs';
|
|
5
|
+
|
|
6
|
+
export const TOOL_VERSION = '0.1.0';
|
|
7
|
+
const iso = (ms) => (ms === null || ms === undefined ? null : new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z'));
|
|
8
|
+
|
|
9
|
+
export function weiToEth(wei) {
|
|
10
|
+
const w = BigInt(wei);
|
|
11
|
+
const whole = w / 10n ** 18n;
|
|
12
|
+
const frac = (w % 10n ** 18n).toString().padStart(18, '0').replace(/0+$/, '');
|
|
13
|
+
return frac ? `${whole}.${frac}` : `${whole}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const STATUS_ORDER = [STATUS.UNACCOUNTED, STATUS.FOREIGN, STATUS.PARTIAL, STATUS.ACCOUNTED, STATUS.PRE, STATUS.POST, STATUS.PENDING, STATUS.OBSERVED, STATUS.RECENT];
|
|
17
|
+
|
|
18
|
+
export function buildReport(a, { demo = false, now = null } = {}) {
|
|
19
|
+
const generated = now ?? Date.now();
|
|
20
|
+
const rows = [...a.rows].sort((x, y) => STATUS_ORDER.indexOf(x.status) - STATUS_ORDER.indexOf(y.status) || (x.tx.block ?? Infinity) - (y.tx.block ?? Infinity) || x.tx.nonce - y.tx.nonce);
|
|
21
|
+
const txs = rows.map((r) => ({
|
|
22
|
+
hash: r.tx.hash,
|
|
23
|
+
status: r.status,
|
|
24
|
+
finding: r.finding,
|
|
25
|
+
from: toChecksum(r.tx.from),
|
|
26
|
+
to: r.tx.to ? toChecksum(r.tx.to) : null,
|
|
27
|
+
toLabel: r.toLabel ?? null,
|
|
28
|
+
nonce: r.tx.nonce,
|
|
29
|
+
block: r.tx.block,
|
|
30
|
+
time: r.tx.time ?? null,
|
|
31
|
+
valueWei: r.tx.valueWei,
|
|
32
|
+
valueEth: weiToEth(r.tx.valueWei),
|
|
33
|
+
reverted: r.tx.reverted ?? null,
|
|
34
|
+
contractCreation: r.tx.to === null,
|
|
35
|
+
createdAddress: r.tx.createdAddress ? toChecksum(r.tx.createdAddress) : null,
|
|
36
|
+
mentioned: r.mentioned,
|
|
37
|
+
source: r.source ? { kind: r.source.kind, line: r.source.line } : { kind: null, line: null },
|
|
38
|
+
pattern: r.pattern ?? null,
|
|
39
|
+
}));
|
|
40
|
+
const count = (s) => txs.filter((t) => t.status === s).length;
|
|
41
|
+
const mine = rows.filter((r) => a.wallets.includes(r.tx.from));
|
|
42
|
+
const mentioned = { final: 0, text: 0, none: 0 };
|
|
43
|
+
for (const r of mine) mentioned[r.mentioned]++;
|
|
44
|
+
const postDerived = a.walletRows.reduce((s, w) => s + w.uAfter, 0);
|
|
45
|
+
const findings = txs.filter((t) => t.finding).length;
|
|
46
|
+
const unscanned = a.ledger.unscannedRanges.map((r) => ({ from: r.from, to: r.to }));
|
|
47
|
+
const exitCode = demo ? 0 : unscanned.length ? 4 : findings ? 1 : 0; // the demo process exits 0 whatever its fixture holds
|
|
48
|
+
const stats = a.stats;
|
|
49
|
+
return {
|
|
50
|
+
v: 1,
|
|
51
|
+
tool: 'chainfess',
|
|
52
|
+
toolVersion: TOOL_VERSION,
|
|
53
|
+
demo,
|
|
54
|
+
generatedAt: iso(generated),
|
|
55
|
+
chainId: a.chainId,
|
|
56
|
+
logs: {
|
|
57
|
+
main: stats.main,
|
|
58
|
+
subagents: stats.subagents,
|
|
59
|
+
toolResults: stats.toolResults,
|
|
60
|
+
broadcast: stats.broadcast,
|
|
61
|
+
unreadableLines: a.sink.unreadableLines,
|
|
62
|
+
otherOverlapping: a.otherCount,
|
|
63
|
+
otherOverlappingFiles: a.otherNames,
|
|
64
|
+
includedOtherSessions: stats.includedOther ?? 0,
|
|
65
|
+
missingToolResults: stats.missingToolResults,
|
|
66
|
+
skippedToolResults: stats.skippedToolResults,
|
|
67
|
+
skippedBroadcast: stats.skippedBroadcast,
|
|
68
|
+
broadcastTruncated: stats.broadcastTruncated,
|
|
69
|
+
notLookedUp: a.candidates.notLookedUp,
|
|
70
|
+
note: unreadableOutputsNote(stats),
|
|
71
|
+
},
|
|
72
|
+
session: {
|
|
73
|
+
tStart: iso(a.tStartMs), tEnd: iso(a.tEndMs),
|
|
74
|
+
startBlock: a.startBlock, endBlock: a.endBlock, blocks: a.endBlock - a.startBlock + 1,
|
|
75
|
+
note: a.rangeNotes.length ? a.rangeNotes.join('; ') : null,
|
|
76
|
+
},
|
|
77
|
+
wallets: a.walletRows.map((w) => ({
|
|
78
|
+
address: toChecksum(w.wallet), nonce: w.nonce, inLog: w.inLog, notInLog: w.notInLog,
|
|
79
|
+
uIn: w.uIn, uOut: w.uOut, uAfter: w.uAfter, uBefore: w.uBefore, neverSent: w.neverSent,
|
|
80
|
+
})),
|
|
81
|
+
scan: {
|
|
82
|
+
plannedBlocks: a.ledger.plannedBlocks, scannedBlocks: a.ledger.scannedBlocks,
|
|
83
|
+
gaps: a.ledger.gaps.map((g) => ({ from: g.from, to: g.to })), unscanned,
|
|
84
|
+
seconds: Math.round(a.ledger.scanSeconds * 10) / 10,
|
|
85
|
+
blocksPerSec: a.ledger.scanSeconds > 0 ? Math.round(a.ledger.scannedBlocks / a.ledger.scanSeconds) : null,
|
|
86
|
+
requests: a.rpcStats.requests,
|
|
87
|
+
fallbackUsed: a.rpcStats.fallbackUsed,
|
|
88
|
+
},
|
|
89
|
+
summary: {
|
|
90
|
+
findings,
|
|
91
|
+
accounted: count(STATUS.ACCOUNTED), partial: count(STATUS.PARTIAL), unaccounted: count(STATUS.UNACCOUNTED),
|
|
92
|
+
foreignSigner: count(STATUS.FOREIGN), preSession: count(STATUS.PRE), postSession: count(STATUS.POST) + postDerived,
|
|
93
|
+
pending: count(STATUS.PENDING), recentOther: count(STATUS.RECENT), observed: count(STATUS.OBSERVED),
|
|
94
|
+
unresolvedHashes: a.ledger.unresolvedHashes,
|
|
95
|
+
localOnlyHashes: a.candidates.localOnly + a.sink.bare.size + a.candidates.notLookedUp,
|
|
96
|
+
mentioned,
|
|
97
|
+
},
|
|
98
|
+
txs,
|
|
99
|
+
exitCode,
|
|
100
|
+
verify: { rpc: a.usedDefaultRpc ? DEFAULT_RPC : null },
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function unreadableOutputsNote(stats) {
|
|
105
|
+
const m = stats.missingToolResults;
|
|
106
|
+
const s = stats.skippedToolResults;
|
|
107
|
+
const b = stats.skippedBroadcast;
|
|
108
|
+
if (!m && !s && !b && !stats.broadcastTruncated) return null;
|
|
109
|
+
const parts = [];
|
|
110
|
+
if (m) parts.push(`${m} saved tool output${m === 1 ? '' : 's'} missing from disk`);
|
|
111
|
+
if (s) parts.push(`${s} saved tool output${s === 1 ? '' : 's'} over the 512 MB read limit`);
|
|
112
|
+
if (b) parts.push(`${b} broadcast file${b === 1 ? '' : 's'} over 5 MB or not readable`);
|
|
113
|
+
if (stats.broadcastTruncated) parts.push('broadcast search stopped at its directory limit');
|
|
114
|
+
return `This log refers to outputs that could not be read: ${parts.join(', ')}. "not in this log" lines may be explained by those unreadable outputs.`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------- text ----------
|
|
118
|
+
|
|
119
|
+
const short = (h, a = 8, b = 6) => (h ? `${h.slice(0, a)}…${h.slice(-b)}` : '-');
|
|
120
|
+
const n = (x) => Number(x).toLocaleString('en-US');
|
|
121
|
+
const plural = (c, word) => `${c} ${word}${c === 1 ? '' : 's'}`;
|
|
122
|
+
const hhmmss = (isoTime) => (isoTime ? `${isoTime.slice(11, 19)} UTC` : '-');
|
|
123
|
+
|
|
124
|
+
function palette(color) {
|
|
125
|
+
const wrap = (code) => (s) => (color ? `\x1b[${code}m${s}\x1b[0m` : String(s));
|
|
126
|
+
return { bold: wrap('1'), dim: wrap('2'), red: wrap('1;31'), yellow: wrap('33'), green: wrap('32'), cyan: wrap('36'), inv: wrap('1;30;43') };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function span(report) {
|
|
130
|
+
const { tStart, tEnd } = report.session;
|
|
131
|
+
if (!tStart || !tEnd) return 'range set by flags';
|
|
132
|
+
const d1 = tStart.slice(0, 10);
|
|
133
|
+
const d2 = tEnd.slice(0, 10);
|
|
134
|
+
return `${d1} ${tStart.slice(11, 16)}–${d2 === d1 ? '' : d2 + ' '}${tEnd.slice(11, 16)} UTC`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function target(t) {
|
|
138
|
+
if (t.contractCreation) return t.createdAddress ? `creates ${short(t.createdAddress, 6, 4)}` : 'creates a contract';
|
|
139
|
+
const label = t.toLabel && t.toLabel !== 'eoa' ? ` (${t.toLabel})` : '';
|
|
140
|
+
return `→ ${short(t.to, 6, 4)}${label}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function flags(t) {
|
|
144
|
+
const f = [];
|
|
145
|
+
if (t.reverted) f.push('reverted');
|
|
146
|
+
return f.length ? ` [${f.join(', ')}]` : '';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function nonceLine(w, report) {
|
|
150
|
+
if (w.neverSent) return ['nonce 0: this wallet has never sent a transaction.'];
|
|
151
|
+
const parts = [];
|
|
152
|
+
if (w.uIn) parts.push(`${w.uIn} inside this session (below)`);
|
|
153
|
+
const other = w.uOut - w.uAfter - w.uBefore;
|
|
154
|
+
if (w.uBefore) parts.push(`${w.uBefore} before it`);
|
|
155
|
+
if (w.uAfter) parts.push(`${w.uAfter} after it`);
|
|
156
|
+
if (other > 0) parts.push(report.scan.unscanned.length ? `${other} not placed (unscanned ranges remain)` : `${other} outside it`);
|
|
157
|
+
const first = `nonce ${w.nonce}: this wallet has sent ${w.nonce} tx. ${w.inLog} ${w.inLog === 1 ? 'appears' : 'appear'} in this log.`;
|
|
158
|
+
return w.notInLog ? [first, `${w.notInLog} do${w.notInLog === 1 ? 'es' : ''} not: ${parts.join(', ')}.`] : [first];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function renderText(report, { color = false, quiet = false } = {}) {
|
|
162
|
+
const c = palette(color);
|
|
163
|
+
const lines = [];
|
|
164
|
+
const tag = report.demo ? `${c.inv(' DEMO — fixture data ')} ` : '';
|
|
165
|
+
const head = (s) => lines.push(tag + s);
|
|
166
|
+
head(c.bold(`CHAINFESS AUDIT — chain ${report.chainId} — ${report.generatedAt.slice(0, 10)} ${report.generatedAt.slice(11, 16)} UTC`));
|
|
167
|
+
const logBits = [];
|
|
168
|
+
if (report.logs.subagents) logBits.push(plural(report.logs.subagents, 'subagent'));
|
|
169
|
+
if (report.logs.toolResults) logBits.push(plural(report.logs.toolResults, 'saved output'));
|
|
170
|
+
if (report.logs.broadcast) logBits.push(plural(report.logs.broadcast, 'broadcast file'));
|
|
171
|
+
if (report.logs.includedOtherSessions) logBits.push(plural(report.logs.includedOtherSessions, 'other session'));
|
|
172
|
+
const walletList = report.wallets.map((w) => short(w.address, 7, 4)).join(', ');
|
|
173
|
+
head(`wallet ${walletList} log ${report.logs.main.join(', ')}${logBits.length ? ` (+${logBits.join(', ')})` : ''}`);
|
|
174
|
+
head(`session ${span(report)} → blocks ${n(report.session.startBlock)}–${n(report.session.endBlock)} (${n(report.session.blocks)} blocks)`);
|
|
175
|
+
for (const w of report.wallets) {
|
|
176
|
+
for (const line of nonceLine(w, report)) head((report.wallets.length > 1 ? `${short(w.address, 7, 4)} ` : '') + line);
|
|
177
|
+
}
|
|
178
|
+
const gaps = report.scan.gaps.length;
|
|
179
|
+
head(report.scan.plannedBlocks
|
|
180
|
+
? `scanned ${n(report.scan.scannedBlocks)} blocks in ${plural(gaps, 'gap')} (${report.demo ? 'recorded replies' : `${report.scan.seconds} s`})`
|
|
181
|
+
: 'scanned 0 blocks: the nonces in this log leave no gap inside the session');
|
|
182
|
+
if (report.session.note) head(c.dim(`note: ${report.session.note}`));
|
|
183
|
+
lines.push('');
|
|
184
|
+
|
|
185
|
+
const by = (s) => report.txs.filter((t) => t.status === s);
|
|
186
|
+
const findings = report.txs.filter((t) => t.finding);
|
|
187
|
+
lines.push(findings.length ? c.red(`FINDINGS (${findings.length})`) : c.green('FINDINGS (0)'));
|
|
188
|
+
for (const t of by(STATUS.UNACCOUNTED)) {
|
|
189
|
+
lines.push(` ${c.red('not in this log')}`);
|
|
190
|
+
lines.push(` ${t.hash}`);
|
|
191
|
+
lines.push(c.dim(` nonce ${t.nonce} block ${n(t.block)} ${hhmmss(t.time)} ${t.valueEth} ETH ${target(t)}${flags(t)}`));
|
|
192
|
+
}
|
|
193
|
+
for (const t of by(STATUS.FOREIGN)) {
|
|
194
|
+
lines.push(` ${c.red('signed by a wallet not registered here')}`);
|
|
195
|
+
lines.push(` ${t.hash}`);
|
|
196
|
+
lines.push(c.dim(` from ${t.from}`));
|
|
197
|
+
lines.push(c.dim(` pattern ${t.pattern} ${t.source.kind} L${t.source.line} block ${n(t.block)} ${hhmmss(t.time)}${flags(t)}`));
|
|
198
|
+
}
|
|
199
|
+
if (!findings.length) {
|
|
200
|
+
for (const w of report.wallets) {
|
|
201
|
+
const anyMine = report.txs.some((t) => t.from === w.address && t.block !== null && t.block >= report.session.startBlock && t.block <= report.session.endBlock);
|
|
202
|
+
if (!anyMine && !w.neverSent && !report.scan.unscanned.length) {
|
|
203
|
+
lines.push(` 0 transactions from ${w.address} between blocks ${n(report.session.startBlock)}–${n(report.session.endBlock)} (${span(report)}). Nothing to reconcile.`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const section = (title, list, row, paint = (s) => s) => {
|
|
209
|
+
if (!list.length) return;
|
|
210
|
+
lines.push(paint(`${title} (${list.length})`));
|
|
211
|
+
for (const t of list) lines.push(` ${row(t)}`);
|
|
212
|
+
};
|
|
213
|
+
const where = (t) => (t.source && t.source.kind ? `${t.source.kind}${t.source.line ? ` L${t.source.line}` : ''}${t.pattern ? ` (${t.pattern})` : ''}` : 'no source');
|
|
214
|
+
if (!quiet) {
|
|
215
|
+
section('PARTIAL MATCH ONLY', by(STATUS.PARTIAL), (t) => `${short(t.hash)} nonce ${t.nonce} partial form at ${where(t)}${flags(t)}`, c.yellow);
|
|
216
|
+
section('ACCOUNTED', by(STATUS.ACCOUNTED), (t) => `${short(t.hash)} nonce ${t.nonce} ${where(t)} mentioned: ${t.mentioned}${flags(t)}`, c.green);
|
|
217
|
+
section('SENT BEFORE THIS SESSION, referenced in log', by(STATUS.PRE), (t) => `${short(t.hash)} nonce ${t.nonce} block ${n(t.block)}`, c.dim);
|
|
218
|
+
section('SENT AFTER THIS SESSION', by(STATUS.POST), (t) => `${short(t.hash)} nonce ${t.nonce} block ${n(t.block)}`, c.dim);
|
|
219
|
+
section('PENDING at audit time; re-run later', by(STATUS.PENDING), (t) => `${short(t.hash)} nonce ${t.nonce}`, c.yellow);
|
|
220
|
+
const observed = by(STATUS.OBSERVED);
|
|
221
|
+
section('OTHER WALLETS referenced (read)', observed.slice(0, 8), (t) => `${short(t.hash)} from ${short(t.from, 7, 4)} ${where(t)}`, c.dim);
|
|
222
|
+
if (observed.length > 8) lines.push(c.dim(` … and ${observed.length - 8} more (--json lists them)`));
|
|
223
|
+
} else if (report.summary.accounted) {
|
|
224
|
+
lines.push(`accounted: ${report.summary.accounted}`);
|
|
225
|
+
}
|
|
226
|
+
const m = report.summary.mentioned;
|
|
227
|
+
const total = m.final + m.text + m.none;
|
|
228
|
+
if (!quiet && total) lines.push(c.dim(`the agent's own words mention ${m.final + m.text} of ${total} transactions`));
|
|
229
|
+
const recent = by(STATUS.RECENT);
|
|
230
|
+
if (recent.length) lines.push(c.dim(`other wallets' txs mined during a tool call; cannot tell a read from a send: ${recent.length} (--json lists them)`));
|
|
231
|
+
|
|
232
|
+
lines.push('');
|
|
233
|
+
for (const line of footer(report)) for (const piece of wrap(line, 96)) lines.push(c.dim(piece));
|
|
234
|
+
return lines.join('\n') + '\n';
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function wrap(line, width) {
|
|
238
|
+
const indent = line.match(/^\s*/)[0];
|
|
239
|
+
if (indent) return [line]; // commands stay whole so they can be copied
|
|
240
|
+
const out = [];
|
|
241
|
+
let cur = '';
|
|
242
|
+
for (const word of line.split(' ')) {
|
|
243
|
+
if (cur && cur.length + word.length + 1 > width) { out.push(cur); cur = word; } else cur = cur ? `${cur} ${word}` : word;
|
|
244
|
+
}
|
|
245
|
+
if (cur) out.push(cur);
|
|
246
|
+
return out;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function footer(report) {
|
|
250
|
+
const out = [];
|
|
251
|
+
const logs = report.logs;
|
|
252
|
+
const un = report.summary.unresolvedHashes;
|
|
253
|
+
if (un) out.push(`Looked up, not a tx: ${un} ${un === 1 ? 'hash' : 'hashes'} in the log ${un === 1 ? 'is' : 'are'} not a transaction on chain ${report.chainId}.`);
|
|
254
|
+
const lo = report.summary.localOnlyHashes;
|
|
255
|
+
if (lo) out.push(`Not looked up, stays local: ${lo} 32-byte ${lo === 1 ? 'value' : 'values'} with nothing saying ${lo === 1 ? 'it is' : 'they are'} a tx hash. Never sent to the RPC; matched here against this wallet's txs found by the scan.`);
|
|
256
|
+
if (logs.unreadableLines) out.push(`${logs.unreadableLines} unreadable line${logs.unreadableLines === 1 ? '' : 's'} in the log; hex strings in them were still searched.`);
|
|
257
|
+
if (logs.note) out.push(logs.note);
|
|
258
|
+
if (report.wallets.some((w) => w.nonce > 0 && w.inLog === 0)) out.push('No hash of this wallet is in the log, so the whole audited range was scanned.');
|
|
259
|
+
if (report.scan.unscanned.length) out.push(`Unscanned block ranges: ${report.scan.unscanned.map((r) => `${r.from}–${r.to}`).join(', ')}. Txs in them are not listed; re-run.`);
|
|
260
|
+
if (report.scan.fallbackUsed) out.push('The primary RPC failed; fallback RPC was used, about 15x slower.');
|
|
261
|
+
if (report.summary.pending) out.push('Pending txs: re-run after inclusion.');
|
|
262
|
+
if (logs.otherOverlapping) out.push(`${logs.otherOverlapping} other session log${logs.otherOverlapping === 1 ? '' : 's'} overlap${logs.otherOverlapping === 1 ? 's' : ''} this range (${logs.otherOverlappingFiles.slice(0, 3).join(', ')}${logs.otherOverlappingFiles.length > 3 ? ', …' : ''}); add them with --log or --all-sessions before drawing conclusions.`);
|
|
263
|
+
if (report.summary.unaccounted) out.push('"not in this log" means exactly that. A human, a cron job or another session on the same wallet produces the same line. Chainfess does not say who sent it.');
|
|
264
|
+
if (report.summary.foreignSigner) out.push('"signed by a wallet not registered here": the hash came out of a sending command, mined during that call, from a wallet you did not pass with --wallet.');
|
|
265
|
+
out.push('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.');
|
|
266
|
+
const rpcFlag = report.verify && report.verify.rpc ? ` --rpc-url ${report.verify.rpc}` : ' --rpc-url $RPC_URL';
|
|
267
|
+
const first = report.txs.find((t) => t.finding) || report.txs.find((t) => t.block !== null);
|
|
268
|
+
const verify = [];
|
|
269
|
+
if (first) verify.push(`cast tx ${first.hash}${rpcFlag}`);
|
|
270
|
+
if (report.wallets[0]) verify.push(`cast nonce ${report.wallets[0].address}${rpcFlag}`);
|
|
271
|
+
if (verify.length) out.push('Verify:', ...verify.map((v) => ` ${v}`));
|
|
272
|
+
return out;
|
|
273
|
+
}
|
package/src/rpc.mjs
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// JSON-RPC client (BRIEF §3.4). The only HTTP client in the audit path.
|
|
2
|
+
export const DEFAULT_RPC = 'https://robinhood-rpc.publicnode.com';
|
|
3
|
+
export const FALLBACK_RPC = 'https://robinhood.drpc.org';
|
|
4
|
+
|
|
5
|
+
export class RpcUnavailableError extends Error {
|
|
6
|
+
constructor(message) { super(message); this.name = 'RpcUnavailableError'; }
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const USER_AGENT = 'chainfess/0.1';
|
|
10
|
+
const GROW_AFTER = 20;
|
|
11
|
+
const THROTTLE_STATUS = new Set([429, 502, 503]);
|
|
12
|
+
const THROTTLE_CODES = new Set([-32005, -32029, -32603]);
|
|
13
|
+
const SIZE_STEPS = [100, 50, 25, 10];
|
|
14
|
+
const START_IN_FLIGHT = 2;
|
|
15
|
+
const MAX_IN_FLIGHT = 3;
|
|
16
|
+
// publicnode, measured 05-11: about 7,500 full blocks of burst at 170/s, then a refill near 60-80/s; 429s clear within 5 s.
|
|
17
|
+
// So the client paces by items per second and backs off gently: a hard back-off here drops the rate far below what the RPC allows.
|
|
18
|
+
const START_RATE = 170;
|
|
19
|
+
const MIN_RATE = 20;
|
|
20
|
+
const FALLBACK_RATE = 6;
|
|
21
|
+
const MIN_GAP_MS = 30;
|
|
22
|
+
const RAISE_AFTER = 5;
|
|
23
|
+
const FIRST_HOLD_MS = 3000;
|
|
24
|
+
|
|
25
|
+
class Throttle extends Error {}
|
|
26
|
+
|
|
27
|
+
function isThrottleError(err) {
|
|
28
|
+
if (!err || typeof err !== 'object') return false;
|
|
29
|
+
return THROTTLE_CODES.has(err.code) && /rate|limit|too many/i.test(String(err.message || ''));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** origin only, so a key in a custom RPC URL path or query is never printed */
|
|
33
|
+
export function safeUrl(url) {
|
|
34
|
+
try { return new URL(url).origin; } catch { return '(invalid url)'; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createRpc({
|
|
38
|
+
url, fallbackUrl = null, chainId, fetchImpl, onRequest,
|
|
39
|
+
paceMs = 300, tries = 6, timeoutMs = 20000,
|
|
40
|
+
sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
41
|
+
random = Math.random,
|
|
42
|
+
}) {
|
|
43
|
+
const doFetch = (...a) => (fetchImpl || globalThis.fetch)(...a);
|
|
44
|
+
const state = {
|
|
45
|
+
url, single: false, fallbackUsed: false, requests: 0, byMethod: {},
|
|
46
|
+
failedInARow: 0, id: 0,
|
|
47
|
+
maxInFlight: START_IN_FLIGHT, inFlight: 0, successes: 0, raises: 0, rate: START_RATE, nextStart: 0, waiters: [],
|
|
48
|
+
holdUntil: 0, hold: FIRST_HOLD_MS, lastThrottle: 0, throttles: 0,
|
|
49
|
+
};
|
|
50
|
+
const paced = paceMs > 0;
|
|
51
|
+
|
|
52
|
+
async function acquire(items) {
|
|
53
|
+
while (state.inFlight >= state.maxInFlight) await new Promise((r) => state.waiters.push(r));
|
|
54
|
+
state.inFlight++;
|
|
55
|
+
const now = Date.now();
|
|
56
|
+
const start = Math.max(now, state.nextStart, state.holdUntil);
|
|
57
|
+
state.nextStart = start + (paced ? Math.max(MIN_GAP_MS, (items / state.rate) * 1000) : 0);
|
|
58
|
+
if (start > now) await sleep(start - now);
|
|
59
|
+
}
|
|
60
|
+
function release() {
|
|
61
|
+
state.inFlight--;
|
|
62
|
+
const w = state.waiters.shift();
|
|
63
|
+
if (w) w();
|
|
64
|
+
}
|
|
65
|
+
function noteSuccess() {
|
|
66
|
+
state.hold = FIRST_HOLD_MS;
|
|
67
|
+
if (++state.successes >= GROW_AFTER) {
|
|
68
|
+
state.successes = 0;
|
|
69
|
+
state.maxInFlight = Math.min(MAX_IN_FLIGHT, state.maxInFlight + 1);
|
|
70
|
+
}
|
|
71
|
+
if (!state.single && ++state.raises >= RAISE_AFTER) {
|
|
72
|
+
state.raises = 0;
|
|
73
|
+
state.rate = Math.min(START_RATE, state.rate * 1.1);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** One wave of 429s counts once; everything in flight waits together. */
|
|
77
|
+
function noteThrottle() {
|
|
78
|
+
state.successes = 0;
|
|
79
|
+
state.raises = 0;
|
|
80
|
+
state.throttles++;
|
|
81
|
+
const now = Date.now();
|
|
82
|
+
if (now - state.lastThrottle < 1000) return;
|
|
83
|
+
state.lastThrottle = now;
|
|
84
|
+
state.maxInFlight = 1;
|
|
85
|
+
state.rate = Math.max(state.single ? FALLBACK_RATE : MIN_RATE, state.rate * 0.75);
|
|
86
|
+
state.holdUntil = now + state.hold + random() * 500;
|
|
87
|
+
state.hold = Math.min(32000, state.hold * 1.5);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** One HTTP POST. Returns results aligned with items; throws on any failure. */
|
|
91
|
+
async function post(items) {
|
|
92
|
+
await acquire(items.length);
|
|
93
|
+
try {
|
|
94
|
+
const reqs = items.map(([method, params]) => ({ jsonrpc: '2.0', id: ++state.id, method, params: params ?? [] }));
|
|
95
|
+
state.requests++;
|
|
96
|
+
for (const r of reqs) state.byMethod[r.method] = (state.byMethod[r.method] || 0) + 1;
|
|
97
|
+
if (onRequest) onRequest({ method: reqs[0].method, count: reqs.length });
|
|
98
|
+
const body = JSON.stringify(reqs.length === 1 ? reqs[0] : reqs);
|
|
99
|
+
const res = await doFetch(state.url, {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
headers: { 'content-type': 'application/json', 'user-agent': USER_AGENT },
|
|
102
|
+
body,
|
|
103
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
104
|
+
});
|
|
105
|
+
if (THROTTLE_STATUS.has(res.status)) throw new Throttle(`HTTP ${res.status}`);
|
|
106
|
+
if (res.status !== 200) throw new Error(`HTTP ${res.status}`);
|
|
107
|
+
const parsed = await res.json();
|
|
108
|
+
const arr = Array.isArray(parsed) ? parsed : [parsed];
|
|
109
|
+
if (arr.length < reqs.length) throw new Throttle('fewer results than requested');
|
|
110
|
+
const byId = new Map(arr.map((x) => [x && x.id, x]));
|
|
111
|
+
const out = [];
|
|
112
|
+
for (const r of reqs) {
|
|
113
|
+
const x = byId.get(r.id);
|
|
114
|
+
if (!x) throw new Throttle('missing result');
|
|
115
|
+
if (x.error) {
|
|
116
|
+
if (isThrottleError(x.error)) throw new Throttle('rpc rate limit');
|
|
117
|
+
throw new Error(`rpc error ${x.error.code}`);
|
|
118
|
+
}
|
|
119
|
+
out.push(x.result === undefined ? null : x.result);
|
|
120
|
+
}
|
|
121
|
+
noteSuccess();
|
|
122
|
+
return out;
|
|
123
|
+
} catch (e) {
|
|
124
|
+
if (e instanceof Throttle) noteThrottle();
|
|
125
|
+
throw e;
|
|
126
|
+
} finally {
|
|
127
|
+
release();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function switchToFallback() {
|
|
132
|
+
if (state.single || !fallbackUrl) return false;
|
|
133
|
+
state.url = fallbackUrl;
|
|
134
|
+
state.single = true;
|
|
135
|
+
state.fallbackUsed = true;
|
|
136
|
+
state.rate = FALLBACK_RATE; // <= 6 r/s
|
|
137
|
+
state.maxInFlight = 1;
|
|
138
|
+
state.failedInARow = 0;
|
|
139
|
+
try {
|
|
140
|
+
if (chainId != null) {
|
|
141
|
+
const [got] = await post([['eth_chainId', []]]);
|
|
142
|
+
if (Number(got) !== Number(chainId)) throw new Error('fallback is on another chain');
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
fallbackDead = true;
|
|
146
|
+
}
|
|
147
|
+
return !fallbackDead;
|
|
148
|
+
}
|
|
149
|
+
let fallbackDead = false;
|
|
150
|
+
|
|
151
|
+
const backoff = (k) => sleep(Math.min(32000, 1000 * 2 ** k) + random() * 500);
|
|
152
|
+
|
|
153
|
+
/** Runs items with retry and shrinking batches. Throws when out of tries. */
|
|
154
|
+
async function attempt(items, { size, nonNull, maxTries }) {
|
|
155
|
+
const results = new Array(items.length);
|
|
156
|
+
let pending = items.map((_, i) => i);
|
|
157
|
+
let failures = 0;
|
|
158
|
+
let cur = Math.min(size, Math.max(items.length, 1));
|
|
159
|
+
const limit = maxTries ?? tries;
|
|
160
|
+
while (pending.length) {
|
|
161
|
+
const chunkSize = state.single ? 1 : cur;
|
|
162
|
+
const idx = pending.slice(0, chunkSize);
|
|
163
|
+
try {
|
|
164
|
+
const got = await post(idx.map((i) => items[i]));
|
|
165
|
+
if (nonNull && got.some((x) => x === null)) {
|
|
166
|
+
for (let j = 0; j < idx.length; j++) if (got[j] !== null) results[idx[j]] = got[j];
|
|
167
|
+
pending = pending.filter((i) => results[i] === undefined || results[i] === null);
|
|
168
|
+
noteThrottle();
|
|
169
|
+
throw new Error('null in a range the chain has passed');
|
|
170
|
+
}
|
|
171
|
+
for (let j = 0; j < idx.length; j++) results[idx[j]] = got[j];
|
|
172
|
+
pending = pending.slice(idx.length);
|
|
173
|
+
} catch (e) {
|
|
174
|
+
failures++;
|
|
175
|
+
if (failures >= limit) throw e;
|
|
176
|
+
const throttled = e instanceof Throttle;
|
|
177
|
+
if (failures >= 3) {
|
|
178
|
+
const next = SIZE_STEPS.find((s) => s < cur);
|
|
179
|
+
if (next) cur = next;
|
|
180
|
+
}
|
|
181
|
+
if (!throttled) await backoff(failures - 1); // a throttle already holds every request
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return results;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function run(items, opts) {
|
|
188
|
+
if (!items.length) return [];
|
|
189
|
+
for (;;) {
|
|
190
|
+
if (fallbackDead) throw new RpcUnavailableError('all RPC endpoints failed');
|
|
191
|
+
try {
|
|
192
|
+
const out = await attempt(items, opts);
|
|
193
|
+
state.failedInARow = 0;
|
|
194
|
+
return out;
|
|
195
|
+
} catch (e) {
|
|
196
|
+
state.failedInARow++;
|
|
197
|
+
const quick = opts.quick || state.failedInARow >= 3;
|
|
198
|
+
if (quick && !state.single && fallbackUrl) {
|
|
199
|
+
if (await switchToFallback()) continue;
|
|
200
|
+
}
|
|
201
|
+
throw new RpcUnavailableError(e && e.message ? e.message : 'rpc failed');
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
/** `quick`: used by preflight, gives up on the primary after 2 tries. */
|
|
208
|
+
async call(method, params, { quick = false } = {}) {
|
|
209
|
+
const [r] = await run([[method, params]], { size: 1, quick, maxTries: quick ? 2 : undefined });
|
|
210
|
+
return r;
|
|
211
|
+
},
|
|
212
|
+
/** nonNull: a null result counts as a failed try (block we know exists). */
|
|
213
|
+
async batch(list, { size = 50, nonNull = false, quick = false } = {}) {
|
|
214
|
+
const out = [];
|
|
215
|
+
for (let i = 0; i < list.length; i += size) {
|
|
216
|
+
const part = list.slice(i, i + size);
|
|
217
|
+
out.push(...await run(part, { size, nonNull, quick, maxTries: quick ? 2 : undefined }));
|
|
218
|
+
}
|
|
219
|
+
return out;
|
|
220
|
+
},
|
|
221
|
+
stats: () => ({ requests: state.requests, fallbackUsed: state.fallbackUsed, byMethod: { ...state.byMethod }, throttles: state.throttles, rate: state.rate }),
|
|
222
|
+
concurrency: () => state.maxInFlight,
|
|
223
|
+
pause: sleep,
|
|
224
|
+
};
|
|
225
|
+
}
|