x402trace 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.
Files changed (62) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/LICENSE +201 -0
  3. package/README.md +121 -0
  4. package/dist/chain/abi.d.ts +48 -0
  5. package/dist/chain/abi.js +34 -0
  6. package/dist/chain/client.d.ts +6 -0
  7. package/dist/chain/client.js +283 -0
  8. package/dist/chain/index.d.ts +4 -0
  9. package/dist/chain/index.js +4 -0
  10. package/dist/chain/retry.d.ts +18 -0
  11. package/dist/chain/retry.js +37 -0
  12. package/dist/chain/types.d.ts +94 -0
  13. package/dist/chain/types.js +12 -0
  14. package/dist/cli/color.d.ts +30 -0
  15. package/dist/cli/color.js +26 -0
  16. package/dist/cli/exit-codes.d.ts +12 -0
  17. package/dist/cli/exit-codes.js +11 -0
  18. package/dist/cli/format-result.d.ts +20 -0
  19. package/dist/cli/format-result.js +58 -0
  20. package/dist/cli/index.d.ts +26 -0
  21. package/dist/cli/index.js +130 -0
  22. package/dist/cli/inspect-command.d.ts +28 -0
  23. package/dist/cli/inspect-command.js +63 -0
  24. package/dist/cli/proxy-command.d.ts +74 -0
  25. package/dist/cli/proxy-command.js +197 -0
  26. package/dist/cli/replay.d.ts +44 -0
  27. package/dist/cli/replay.js +193 -0
  28. package/dist/cli.d.ts +2 -0
  29. package/dist/cli.js +20 -0
  30. package/dist/decoder/decoder.d.ts +20 -0
  31. package/dist/decoder/decoder.js +118 -0
  32. package/dist/decoder/format.d.ts +9 -0
  33. package/dist/decoder/format.js +50 -0
  34. package/dist/decoder/index.d.ts +5 -0
  35. package/dist/decoder/index.js +5 -0
  36. package/dist/decoder/parse.d.ts +53 -0
  37. package/dist/decoder/parse.js +179 -0
  38. package/dist/decoder/redact.d.ts +12 -0
  39. package/dist/decoder/redact.js +22 -0
  40. package/dist/decoder/types.d.ts +86 -0
  41. package/dist/decoder/types.js +9 -0
  42. package/dist/proxy/event-bus.d.ts +18 -0
  43. package/dist/proxy/event-bus.js +75 -0
  44. package/dist/proxy/id.d.ts +6 -0
  45. package/dist/proxy/id.js +8 -0
  46. package/dist/proxy/index.d.ts +5 -0
  47. package/dist/proxy/index.js +5 -0
  48. package/dist/proxy/jsonl-sink.d.ts +18 -0
  49. package/dist/proxy/jsonl-sink.js +44 -0
  50. package/dist/proxy/proxy.d.ts +21 -0
  51. package/dist/proxy/proxy.js +238 -0
  52. package/dist/proxy/types.d.ts +95 -0
  53. package/dist/proxy/types.js +32 -0
  54. package/dist/reconciliation/engine.d.ts +40 -0
  55. package/dist/reconciliation/engine.js +178 -0
  56. package/dist/reconciliation/index.d.ts +3 -0
  57. package/dist/reconciliation/index.js +3 -0
  58. package/dist/reconciliation/match.d.ts +31 -0
  59. package/dist/reconciliation/match.js +39 -0
  60. package/dist/reconciliation/types.d.ts +72 -0
  61. package/dist/reconciliation/types.js +17 -0
  62. package/package.json +85 -0
@@ -0,0 +1,130 @@
1
+ /**
2
+ * X402-14 CLI surface. Two subcommands:
3
+ *
4
+ * x402trace proxy --upstream <url> [--port 8402] [--log human|json] [--reconcile] …
5
+ * x402trace inspect <jsonl-log-file> [--log human|json] …
6
+ *
7
+ * `commander` does the parsing; we own the wiring + exit-code
8
+ * discipline. See X402-14 Jira for acceptance criteria.
9
+ *
10
+ * `runCli` is the testable entry; `src/cli.ts` is the bin shim that
11
+ * calls it with the real `process` handles.
12
+ */
13
+ import "dotenv/config";
14
+ import { Command } from "commander";
15
+ import { runInspectCommand } from "./inspect-command.js";
16
+ import { runProxyCommand } from "./proxy-command.js";
17
+ import { EXIT_SUCCESS, EXIT_USAGE } from "./exit-codes.js";
18
+ // Keep in sync with package.json `version`. A runtime read from
19
+ // package.json would be more durable but requires JSON-module
20
+ // resolution gymnastics in NodeNext + a relative path that works for
21
+ // both `tsx src/cli.ts` (dev) and `dist/cli.js` (published) — folding
22
+ // that into a follow-up cleanup. Until then, the X402-19 release
23
+ // checklist + the changelog-cut step are the guards against drift.
24
+ const VERSION = "0.1.0";
25
+ export async function runCli(argv, ctx) {
26
+ const program = new Command();
27
+ program
28
+ .name("x402trace")
29
+ .description("Local CLI for debugging x402 payment flows on Base — catches timeout reconciliation failures.")
30
+ .version(VERSION, "-v, --version")
31
+ .helpOption("-h, --help", "Show this message")
32
+ .configureOutput({
33
+ writeOut: (str) => ctx.stdout.write(str),
34
+ writeErr: (str) => ctx.stderr.write(str),
35
+ })
36
+ // commander would call process.exit on parse errors; we route it
37
+ // through our own exit-code constants instead.
38
+ .exitOverride();
39
+ let exit = EXIT_SUCCESS;
40
+ program
41
+ .command("proxy")
42
+ .description("Start the local HTTP proxy with live x402 capture and reconciliation.")
43
+ .requiredOption("--upstream <url>", "Upstream HTTP base URL to forward to")
44
+ .option("--port <n>", "Listen port (default 8402)")
45
+ .option("--log <human|json>", "Stdout format (default 'human')", validateLogFormat)
46
+ .option("--log-file <path>", "JSONL log file path (default ./x402trace.jsonl, env X402TRACE_LOG)")
47
+ .option("--log-secrets", "Keep raw EIP-3009 signatures in the log instead of redacting (default off)")
48
+ .option("--reconcile", "Subscribe to Base Sepolia and reconcile facilitator rejections against on-chain settlements")
49
+ .option("--rpc-url <url>", "Base Sepolia RPC URL (env BASE_RPC_URL)")
50
+ .option("--watch-timeout-ms <n>", "How long to watch the chain for a rejected exchange before giving up (default 60000)")
51
+ .option("--upstream-timeout-ms <n>", "Per-request timeout to the upstream server; on timeout the proxy emits `upstream_timeout` and returns 502 (default 30000)")
52
+ .action(async (flags) => {
53
+ const log = flags.log;
54
+ exit = await runProxyCommand({
55
+ ...(flags.upstream !== undefined ? { upstream: flags.upstream } : {}),
56
+ ...(flags.port !== undefined ? { port: Number(flags.port) } : {}),
57
+ ...(flags.logFile !== undefined ? { logFile: flags.logFile } : {}),
58
+ ...(log !== undefined ? { log } : {}),
59
+ ...(flags.logSecrets !== undefined ? { logSecrets: flags.logSecrets } : {}),
60
+ ...(flags.reconcile !== undefined ? { reconcile: flags.reconcile } : {}),
61
+ ...(flags.rpcUrl !== undefined ? { rpcUrl: flags.rpcUrl } : {}),
62
+ ...(flags.watchTimeoutMs !== undefined
63
+ ? { watchTimeoutMs: Number(flags.watchTimeoutMs) }
64
+ : {}),
65
+ ...(flags.upstreamTimeoutMs !== undefined
66
+ ? { upstreamTimeoutMs: Number(flags.upstreamTimeoutMs) }
67
+ : {}),
68
+ }, {
69
+ stdout: ctx.stdout,
70
+ stderr: ctx.stderr,
71
+ env: ctx.env,
72
+ waitForShutdown: ctx.waitForShutdown ?? defaultWaitForSignals,
73
+ });
74
+ });
75
+ program
76
+ .command("inspect")
77
+ .description("Replay a captured JSONL log and re-run reconciliation offline.")
78
+ .argument("<jsonl-log-file>", "Path to a JSONL log written by `x402trace proxy --reconcile`")
79
+ .option("--log <human|json>", "Stdout format (default 'human')", validateLogFormat)
80
+ .option("--watch-timeout-ms <n>", "Watch window applied during replay (default 60000)")
81
+ .action(async (logFile, flags) => {
82
+ const log = flags.log;
83
+ exit = await runInspectCommand({
84
+ logFile,
85
+ ...(log !== undefined ? { log } : {}),
86
+ ...(flags.watchTimeoutMs !== undefined
87
+ ? { watchTimeoutMs: Number(flags.watchTimeoutMs) }
88
+ : {}),
89
+ }, { stdout: ctx.stdout, stderr: ctx.stderr });
90
+ });
91
+ try {
92
+ await program.parseAsync([...argv], { from: "user" });
93
+ }
94
+ catch (err) {
95
+ // commander's `exitOverride()` throws a CommanderError on
96
+ // help/version/usage exits. Translate.
97
+ const code = err.code;
98
+ const exitCode = err.exitCode;
99
+ if (code === "commander.helpDisplayed" || code === "commander.version") {
100
+ return EXIT_SUCCESS;
101
+ }
102
+ if (exitCode !== undefined && exitCode !== 0) {
103
+ return EXIT_USAGE;
104
+ }
105
+ ctx.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
106
+ return EXIT_USAGE;
107
+ }
108
+ return exit;
109
+ }
110
+ function validateLogFormat(value) {
111
+ if (value !== "human" && value !== "json") {
112
+ throw new Error(`--log must be 'human' or 'json' (got '${value}')`);
113
+ }
114
+ return value;
115
+ }
116
+ /**
117
+ * Production-mode shutdown gate: resolve on the first SIGINT or
118
+ * SIGTERM. Used only by the long-running `proxy` subcommand.
119
+ */
120
+ function defaultWaitForSignals() {
121
+ return new Promise((resolve) => {
122
+ const onSig = () => {
123
+ process.off("SIGINT", onSig);
124
+ process.off("SIGTERM", onSig);
125
+ resolve();
126
+ };
127
+ process.on("SIGINT", onSig);
128
+ process.on("SIGTERM", onSig);
129
+ });
130
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * `x402trace inspect <jsonl-log-file>` — replay a saved JSONL log,
3
+ * re-run the reconciliation engine against it, print every result.
4
+ *
5
+ * Pure-offline: never opens an HTTP server, never makes an RPC call.
6
+ * Reads the existing `chain.transfer` lines the live `proxy --reconcile`
7
+ * already persisted, joins them against `exchange.closed` /
8
+ * `exchange.payment`, emits `ReconciliationResult`s through the same
9
+ * formatter the live command uses.
10
+ *
11
+ * Useful for: re-running reconciliation after tweaking match logic;
12
+ * diagnosing a past outage from its captured log; CI smoke tests that
13
+ * don't want to spin up a real proxy.
14
+ */
15
+ import type { LogFormat } from "../decoder/types.js";
16
+ import { type Colorizer } from "./color.js";
17
+ import { type ExitCode } from "./exit-codes.js";
18
+ export interface InspectCommandOptions {
19
+ readonly logFile?: string;
20
+ readonly log?: LogFormat;
21
+ readonly watchTimeoutMs?: number;
22
+ }
23
+ export interface InspectRunContext {
24
+ readonly stdout: NodeJS.WritableStream;
25
+ readonly stderr: NodeJS.WritableStream;
26
+ readonly color?: Colorizer;
27
+ }
28
+ export declare function runInspectCommand(opts: InspectCommandOptions, ctx: InspectRunContext): Promise<ExitCode>;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * `x402trace inspect <jsonl-log-file>` — replay a saved JSONL log,
3
+ * re-run the reconciliation engine against it, print every result.
4
+ *
5
+ * Pure-offline: never opens an HTTP server, never makes an RPC call.
6
+ * Reads the existing `chain.transfer` lines the live `proxy --reconcile`
7
+ * already persisted, joins them against `exchange.closed` /
8
+ * `exchange.payment`, emits `ReconciliationResult`s through the same
9
+ * formatter the live command uses.
10
+ *
11
+ * Useful for: re-running reconciliation after tweaking match logic;
12
+ * diagnosing a past outage from its captured log; CI smoke tests that
13
+ * don't want to spin up a real proxy.
14
+ */
15
+ import { stat } from "node:fs/promises";
16
+ import { createColorizer } from "./color.js";
17
+ import { EXIT_RUNTIME, EXIT_SUCCESS, EXIT_USAGE } from "./exit-codes.js";
18
+ import { formatResultHuman, formatResultJson } from "./format-result.js";
19
+ import { replayLog } from "./replay.js";
20
+ const DEFAULT_WATCH_TIMEOUT_MS = 60_000;
21
+ export async function runInspectCommand(opts, ctx) {
22
+ const logFile = opts.logFile;
23
+ if (!logFile) {
24
+ ctx.stderr.write("error: <jsonl-log-file> argument is required\n");
25
+ return EXIT_USAGE;
26
+ }
27
+ try {
28
+ const s = await stat(logFile);
29
+ if (!s.isFile()) {
30
+ ctx.stderr.write(`error: ${logFile} is not a regular file\n`);
31
+ return EXIT_USAGE;
32
+ }
33
+ }
34
+ catch {
35
+ ctx.stderr.write(`error: cannot read log file: ${logFile}\n`);
36
+ return EXIT_USAGE;
37
+ }
38
+ const format = opts.log ?? "human";
39
+ const color = ctx.color ?? createColorizer({ stream: ctx.stdout });
40
+ try {
41
+ const report = await replayLog({
42
+ logPath: logFile,
43
+ watchTimeoutMs: opts.watchTimeoutMs ?? DEFAULT_WATCH_TIMEOUT_MS,
44
+ });
45
+ if (format === "human") {
46
+ ctx.stdout.write(color.paint("dim", `replayed ${report.counts.linesTotal} lines (skipped=${report.counts.linesSkipped}, closed=${report.counts.proxyClosed}, payments=${report.counts.decoderPayments}, transfers=${report.counts.chainTransfers}, results=${report.counts.results})`) + "\n");
47
+ }
48
+ for (const result of report.results) {
49
+ ctx.stdout.write(format === "human"
50
+ ? `${formatResultHuman(result, color)}\n`
51
+ : `${formatResultJson(result)}\n`);
52
+ }
53
+ if (format === "json") {
54
+ // Trailer record with replay counts so JSON consumers can sanity-check.
55
+ ctx.stdout.write(`${JSON.stringify({ event: "inspect.summary", ...report.counts })}\n`);
56
+ }
57
+ return EXIT_SUCCESS;
58
+ }
59
+ catch (err) {
60
+ ctx.stderr.write(`${color.paint("red", "inspect failed:")} ${err instanceof Error ? err.message : String(err)}\n`);
61
+ return EXIT_RUNTIME;
62
+ }
63
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * `x402trace proxy` — start the local HTTP proxy with live decoder +
3
+ * (optionally) chain subscription + reconciliation engine.
4
+ *
5
+ * Wires together the four substrates built across X402-10 → X402-13.
6
+ * Per [SPEC.md § 3 user flow](../../SPEC.md#3-user-flow) the headline
7
+ * line is `RECONCILED ⚠ settled-but-server-thinks-not`; that's rendered
8
+ * here (via `format-result.ts`) and persisted to JSONL as a
9
+ * `reconcile.result` record — the canonical artifact downstream tools
10
+ * read.
11
+ *
12
+ * Closes the X402-13 deferred acceptance bullet: every emitted
13
+ * `ReconciliationResult` and every observed `ChainTransfer` is written
14
+ * to the same JSONL log as the proxy events.
15
+ */
16
+ import { type LogFormat } from "../decoder/index.js";
17
+ import { type Colorizer } from "./color.js";
18
+ import { type ExitCode } from "./exit-codes.js";
19
+ export interface ProxyCommandOptions {
20
+ readonly upstream?: string;
21
+ readonly port?: number;
22
+ readonly logFile?: string;
23
+ readonly log?: LogFormat;
24
+ readonly logSecrets?: boolean;
25
+ readonly reconcile?: boolean;
26
+ readonly rpcUrl?: string;
27
+ readonly watchTimeoutMs?: number;
28
+ readonly upstreamTimeoutMs?: number;
29
+ }
30
+ export interface RunContext {
31
+ readonly stdout: NodeJS.WritableStream;
32
+ readonly stderr: NodeJS.WritableStream;
33
+ readonly env: Readonly<Record<string, string | undefined>>;
34
+ /** Resolves with the chosen exit code once SIGINT/SIGTERM is observed. */
35
+ readonly waitForShutdown: () => Promise<void>;
36
+ readonly color?: Colorizer;
37
+ }
38
+ /**
39
+ * Pure config resolver for the `proxy` subcommand. Encapsulates the
40
+ * flag-vs-env-vs-default precedence rules from
41
+ * [ARCHITECTURE.md § Configuration](../../ARCHITECTURE.md#configuration)
42
+ * so they can be unit-tested without spinning up a real proxy.
43
+ *
44
+ * Precedence (highest wins): CLI flag → env var → built-in default.
45
+ *
46
+ * Returns either a fully-resolved config or a `usage_error` when the
47
+ * required `--upstream` is missing from both flag and env.
48
+ */
49
+ export type ResolveProxyConfigResult = {
50
+ readonly ok: true;
51
+ readonly config: ResolvedProxyConfig;
52
+ } | {
53
+ readonly ok: false;
54
+ readonly message: string;
55
+ };
56
+ export interface ResolvedProxyConfig {
57
+ readonly upstream: string;
58
+ readonly port: number;
59
+ readonly logPath: string;
60
+ readonly format: LogFormat;
61
+ readonly logSecrets: boolean;
62
+ readonly reconcile: boolean;
63
+ readonly rpcUrl: string | undefined;
64
+ readonly watchTimeoutMs: number;
65
+ readonly upstreamTimeoutMs: number | undefined;
66
+ }
67
+ export declare function resolveProxyConfig(opts: ProxyCommandOptions, env: Readonly<Record<string, string | undefined>>): ResolveProxyConfigResult;
68
+ /**
69
+ * Run the `proxy` subcommand. Returns the exit code; callers
70
+ * (`src/cli.ts`) translate this to `process.exit`. Tests inject a
71
+ * fast-resolving `waitForShutdown` so the command can run end-to-end
72
+ * without a real signal.
73
+ */
74
+ export declare function runProxyCommand(opts: ProxyCommandOptions, ctx: RunContext): Promise<ExitCode>;
@@ -0,0 +1,197 @@
1
+ /**
2
+ * `x402trace proxy` — start the local HTTP proxy with live decoder +
3
+ * (optionally) chain subscription + reconciliation engine.
4
+ *
5
+ * Wires together the four substrates built across X402-10 → X402-13.
6
+ * Per [SPEC.md § 3 user flow](../../SPEC.md#3-user-flow) the headline
7
+ * line is `RECONCILED ⚠ settled-but-server-thinks-not`; that's rendered
8
+ * here (via `format-result.ts`) and persisted to JSONL as a
9
+ * `reconcile.result` record — the canonical artifact downstream tools
10
+ * read.
11
+ *
12
+ * Closes the X402-13 deferred acceptance bullet: every emitted
13
+ * `ReconciliationResult` and every observed `ChainTransfer` is written
14
+ * to the same JSONL log as the proxy events.
15
+ */
16
+ import { createChainClient } from "../chain/index.js";
17
+ import { createDecoder, formatHuman, formatJson } from "../decoder/index.js";
18
+ import { JsonlSink } from "../proxy/jsonl-sink.js";
19
+ import { createProxy } from "../proxy/index.js";
20
+ import { createReconciliationEngine } from "../reconciliation/index.js";
21
+ import { createColorizer } from "./color.js";
22
+ import { EXIT_RUNTIME, EXIT_SUCCESS, EXIT_USAGE } from "./exit-codes.js";
23
+ import { formatResultHuman, formatResultJson } from "./format-result.js";
24
+ import { chainTransferToJsonl } from "./replay.js";
25
+ const DEFAULT_PORT = 8402;
26
+ const DEFAULT_LOG_PATH = "./x402trace.jsonl";
27
+ const DEFAULT_WATCH_TIMEOUT_MS = 60_000;
28
+ export function resolveProxyConfig(opts, env) {
29
+ const upstream = opts.upstream ?? env.X402TRACE_UPSTREAM;
30
+ if (!upstream) {
31
+ return { ok: false, message: "--upstream is required" };
32
+ }
33
+ return {
34
+ ok: true,
35
+ config: {
36
+ upstream,
37
+ port: opts.port ?? toIntOrNull(env.X402TRACE_PORT) ?? DEFAULT_PORT,
38
+ logPath: opts.logFile ?? env.X402TRACE_LOG ?? DEFAULT_LOG_PATH,
39
+ format: opts.log ?? "human",
40
+ logSecrets: opts.logSecrets ?? false,
41
+ reconcile: opts.reconcile ?? truthy(env.X402TRACE_RECONCILE),
42
+ rpcUrl: opts.rpcUrl ?? env.BASE_RPC_URL,
43
+ watchTimeoutMs: opts.watchTimeoutMs ??
44
+ toIntOrNull(env.X402TRACE_WATCH_TIMEOUT_MS) ??
45
+ DEFAULT_WATCH_TIMEOUT_MS,
46
+ upstreamTimeoutMs: opts.upstreamTimeoutMs ?? toIntOrNull(env.X402TRACE_UPSTREAM_TIMEOUT_MS) ?? undefined,
47
+ },
48
+ };
49
+ }
50
+ /**
51
+ * Run the `proxy` subcommand. Returns the exit code; callers
52
+ * (`src/cli.ts`) translate this to `process.exit`. Tests inject a
53
+ * fast-resolving `waitForShutdown` so the command can run end-to-end
54
+ * without a real signal.
55
+ */
56
+ export async function runProxyCommand(opts, ctx) {
57
+ const resolved = resolveProxyConfig(opts, ctx.env);
58
+ if (!resolved.ok) {
59
+ ctx.stderr.write(`error: ${resolved.message}\n`);
60
+ return EXIT_USAGE;
61
+ }
62
+ const { upstream, port, logPath, format, logSecrets, reconcile, rpcUrl, watchTimeoutMs, upstreamTimeoutMs, } = resolved.config;
63
+ const color = ctx.color ?? createColorizer({ stream: ctx.stdout });
64
+ // The proxy owns its own JsonlSink for proxy events; we open a
65
+ // SECOND sink against the same path for chain + reconcile events.
66
+ // Both use O_APPEND so kernel-level atomicity covers per-line writes.
67
+ const proxyHandle = await createProxy({
68
+ upstream,
69
+ port,
70
+ logPath,
71
+ ...(upstreamTimeoutMs !== undefined ? { upstreamTimeoutMs } : {}),
72
+ });
73
+ const sideSink = new JsonlSink(logPath);
74
+ await sideSink.open();
75
+ ctx.stdout.write(color.paint("bold", `x402trace proxy listening on ${proxyHandle.url}`) + ` → ${upstream}\n`);
76
+ ctx.stdout.write(`${color.paint("dim", "log:")} ${logPath} ${color.paint("dim", "format:")} ${format} ${color.paint("dim", "secrets:")} ${logSecrets ? "shown" : "redacted"} ${color.paint("dim", "reconcile:")} ${reconcile ? "on" : "off"}\n`);
77
+ const decoder = createDecoder({ logSecrets });
78
+ const engine = reconcile ? createReconciliationEngine({ watchTimeoutMs }) : null;
79
+ const chain = reconcile ? createChainClient({ ...(rpcUrl ? { rpcUrl } : {}) }) : null;
80
+ // --- Proxy / decoder event loop ---
81
+ const proxySub = proxyHandle.events.subscribe();
82
+ const proxyTask = (async () => {
83
+ for await (const event of proxySub) {
84
+ if (event.event === "exchange.opened") {
85
+ ctx.stdout.write(`${color.paint("dim", `[${event.t}]`)} HTTP ${event.request.method} ${event.request.path} (id=${event.id.slice(0, 8)})\n`);
86
+ }
87
+ else if (event.event === "exchange.closed") {
88
+ ctx.stdout.write(`${color.paint("dim", `[${event.t}]`)} HTTP ${event.response.status} ${event.outcome.kind} (id=${event.id.slice(0, 8)}, ${event.durationMs}ms)\n`);
89
+ }
90
+ else if (event.event === "proxy.error") {
91
+ ctx.stderr.write(`${color.paint("red", `[${event.t}] proxy error:`)} ${event.message}\n`);
92
+ }
93
+ engine?.ingestProxyEvent(event);
94
+ for (const decoded of decoder.decode(event)) {
95
+ engine?.ingestDecoderEvent(decoded);
96
+ ctx.stdout.write(format === "human" ? ` ${formatHuman(decoded)}\n` : `${formatJson(decoded)}\n`);
97
+ }
98
+ }
99
+ })();
100
+ // --- Chain subscription (only when --reconcile) ---
101
+ let chainSub = null;
102
+ let chainTask = null;
103
+ if (engine && chain) {
104
+ chainSub = chain.subscribeUsdcTransfers();
105
+ chainTask = (async () => {
106
+ try {
107
+ for await (const transfer of chainSub) {
108
+ sideSink.append(chainTransferToJsonl(transfer, new Date().toISOString()));
109
+ engine.ingestChainTransfer(transfer);
110
+ }
111
+ }
112
+ catch (err) {
113
+ ctx.stderr.write(`${color.paint("red", "chain subscription error:")} ${errMsg(err)}\n`);
114
+ }
115
+ })();
116
+ }
117
+ // --- Reconciliation result loop ---
118
+ let resultSub = null;
119
+ let resultTask = null;
120
+ if (engine) {
121
+ resultSub = engine.results();
122
+ resultTask = (async () => {
123
+ for await (const result of resultSub) {
124
+ sideSink.append({ event: "reconcile.result", ...serialiseBigints(result) });
125
+ ctx.stdout.write(format === "human"
126
+ ? `${formatResultHuman(result, color)}\n`
127
+ : `${formatResultJson(result)}\n`);
128
+ }
129
+ })();
130
+ }
131
+ // --- Wait for shutdown ---
132
+ try {
133
+ await ctx.waitForShutdown();
134
+ }
135
+ catch (err) {
136
+ ctx.stderr.write(`${color.paint("red", "runtime error:")} ${errMsg(err)}\n`);
137
+ await teardown();
138
+ return EXIT_RUNTIME;
139
+ }
140
+ await teardown();
141
+ return EXIT_SUCCESS;
142
+ async function teardown() {
143
+ proxySub.unsubscribe();
144
+ resultSub?.unsubscribe();
145
+ chainSub?.unsubscribe();
146
+ await proxyHandle.close();
147
+ await chain?.close();
148
+ await engine?.close();
149
+ await sideSink.close();
150
+ await proxyTask;
151
+ if (resultTask)
152
+ await resultTask;
153
+ if (chainTask)
154
+ await chainTask;
155
+ }
156
+ }
157
+ /**
158
+ * Returns a copy of `result` where any embedded `bigint` is coerced to
159
+ * a string so `JSON.stringify` doesn't throw. The strings round-trip
160
+ * via `BigInt(str)` in the replay reader.
161
+ */
162
+ function serialiseBigints(result) {
163
+ // Walk one level deep — the only bigints are in `onChain` (ChainTransfer)
164
+ // and the top-level `expected`/`actual` of value_mismatch.
165
+ const out = {};
166
+ for (const [k, v] of Object.entries(result)) {
167
+ if (typeof v === "bigint") {
168
+ out[k] = v.toString();
169
+ }
170
+ else if (v && typeof v === "object" && !Array.isArray(v)) {
171
+ const nested = {};
172
+ for (const [k2, v2] of Object.entries(v)) {
173
+ nested[k2] = typeof v2 === "bigint" ? v2.toString() : v2;
174
+ }
175
+ out[k] = nested;
176
+ }
177
+ else {
178
+ out[k] = v;
179
+ }
180
+ }
181
+ return out;
182
+ }
183
+ function toIntOrNull(value) {
184
+ if (!value)
185
+ return null;
186
+ const n = Number(value);
187
+ return Number.isFinite(n) ? n : null;
188
+ }
189
+ function truthy(value) {
190
+ if (!value)
191
+ return false;
192
+ const v = value.toLowerCase();
193
+ return v === "1" || v === "true" || v === "yes" || v === "on";
194
+ }
195
+ function errMsg(err) {
196
+ return err instanceof Error ? err.message : String(err);
197
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Offline JSONL replay. Reads a log written by `x402trace proxy
3
+ * --reconcile`, reconstructs the four event streams the engine cares
4
+ * about (proxy `exchange.closed`, decoder `exchange.payment`, chain
5
+ * `chain.transfer`), and feeds them into a fresh engine.
6
+ *
7
+ * The engine runs on a *virtual clock* driven by each line's `t`
8
+ * field so the `not_settled` sweep fires at the right virtual moment
9
+ * even though the whole file replays in milliseconds.
10
+ *
11
+ * Per [ARCHITECTURE.md § JSONL record format](../../ARCHITECTURE.md#jsonl-record-format):
12
+ * "the file IS the API." This reader is the consumer side of that
13
+ * contract.
14
+ */
15
+ import type { ChainTransfer } from "../chain/types.js";
16
+ import type { ReconciliationEngine } from "../reconciliation/engine.js";
17
+ import type { ReconciliationResult } from "../reconciliation/types.js";
18
+ export interface ReplayOptions {
19
+ readonly logPath: string;
20
+ readonly watchTimeoutMs?: number;
21
+ }
22
+ export interface ReplayCounts {
23
+ readonly linesTotal: number;
24
+ readonly linesSkipped: number;
25
+ readonly proxyClosed: number;
26
+ readonly decoderPayments: number;
27
+ readonly chainTransfers: number;
28
+ readonly results: number;
29
+ }
30
+ export interface ReplayReport {
31
+ readonly counts: ReplayCounts;
32
+ readonly results: readonly ReconciliationResult[];
33
+ }
34
+ /**
35
+ * Replay the whole file, collect every emitted `ReconciliationResult`.
36
+ *
37
+ * Returns once the file is exhausted and the engine has been ticked
38
+ * once past the final virtual timestamp so any still-pending entry
39
+ * past `watchTimeoutMs` fires `not_settled`.
40
+ */
41
+ export declare function replayLog(opts: ReplayOptions): Promise<ReplayReport>;
42
+ /** Test seam: serialise a ChainTransfer to its JSONL-stored shape. */
43
+ export declare function chainTransferToJsonl(transfer: ChainTransfer, t?: string): Record<string, unknown>;
44
+ export type { ReconciliationEngine };