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,283 @@
1
+ import { createPublicClient, decodeEventLog, http } from "viem";
2
+ import { baseSepolia } from "viem/chains";
3
+ import { AUTHORIZATION_USED_EVENT, BASE_SEPOLIA_USDC, USDC_TRANSFER_EVENT } from "./abi.js";
4
+ import { withRetry } from "./retry.js";
5
+ const DEFAULT_RPC_URL = "https://sepolia.base.org";
6
+ const DEFAULT_POLL_INTERVAL_MS = 4_000;
7
+ function isNotFoundError(err) {
8
+ if (!(err instanceof Error))
9
+ return false;
10
+ const name = err.name;
11
+ if (name === "TransactionReceiptNotFoundError" ||
12
+ name === "TransactionNotFoundError" ||
13
+ name === "BlockNotFoundError") {
14
+ return true;
15
+ }
16
+ return /not\s+(be\s+)?found/i.test(err.message);
17
+ }
18
+ /**
19
+ * Build a read-only chain client for Base Sepolia. Never holds private
20
+ * keys; never broadcasts transactions. Only reads logs + receipts.
21
+ */
22
+ export function createChainClient(opts = {}) {
23
+ const transport = opts.transport ??
24
+ http(opts.rpcUrl ?? DEFAULT_RPC_URL, { timeout: opts.rpcTimeoutMs ?? 30_000 });
25
+ const client = createPublicClient({ chain: baseSepolia, transport });
26
+ const retries = opts.retries ?? 3;
27
+ const subscribers = new Set();
28
+ async function getBlockNumber() {
29
+ return withRetry(() => client.getBlockNumber(), { retries });
30
+ }
31
+ function findTransferLog(logs, expectedToken) {
32
+ const tokenFilter = (expectedToken ?? BASE_SEPOLIA_USDC).toLowerCase();
33
+ for (const log of logs) {
34
+ if (log.address.toLowerCase() !== tokenFilter)
35
+ continue;
36
+ try {
37
+ const decoded = decodeEventLog({
38
+ abi: [USDC_TRANSFER_EVENT],
39
+ data: log.data,
40
+ topics: log.topics,
41
+ });
42
+ if (decoded.eventName === "Transfer") {
43
+ const args = decoded.args;
44
+ return { log, ...args };
45
+ }
46
+ }
47
+ catch {
48
+ // not a Transfer log on this contract; skip
49
+ }
50
+ }
51
+ return null;
52
+ }
53
+ function findAuthorizationNonce(logs, expectedToken) {
54
+ const tokenFilter = (expectedToken ?? BASE_SEPOLIA_USDC).toLowerCase();
55
+ for (const log of logs) {
56
+ if (log.address.toLowerCase() !== tokenFilter)
57
+ continue;
58
+ try {
59
+ const decoded = decodeEventLog({
60
+ abi: [AUTHORIZATION_USED_EVENT],
61
+ data: log.data,
62
+ topics: log.topics,
63
+ });
64
+ if (decoded.eventName === "AuthorizationUsed") {
65
+ return decoded.args.nonce;
66
+ }
67
+ }
68
+ catch {
69
+ // not AuthorizationUsed; skip
70
+ }
71
+ }
72
+ return undefined;
73
+ }
74
+ async function buildTransferFromReceipt(txHash, expectedToken) {
75
+ let receipt;
76
+ try {
77
+ receipt = await withRetry(() => client.getTransactionReceipt({ hash: txHash }), { retries });
78
+ }
79
+ catch (err) {
80
+ // viem throws TransactionReceiptNotFoundError when the receipt
81
+ // isn't available yet (tx pending OR doesn't exist).
82
+ if (isNotFoundError(err))
83
+ return null;
84
+ throw err;
85
+ }
86
+ if (receipt.status !== "success")
87
+ return null;
88
+ const found = findTransferLog(receipt.logs, expectedToken);
89
+ if (!found) {
90
+ // No Transfer log on the expected token. If there are Transfer logs
91
+ // for a different token, surface that as a mismatch hint.
92
+ for (const log of receipt.logs) {
93
+ try {
94
+ const decoded = decodeEventLog({
95
+ abi: [USDC_TRANSFER_EVENT],
96
+ data: log.data,
97
+ topics: log.topics,
98
+ });
99
+ if (decoded.eventName === "Transfer") {
100
+ return { mismatch: { actualToken: log.address } };
101
+ }
102
+ }
103
+ catch {
104
+ // skip
105
+ }
106
+ }
107
+ return null;
108
+ }
109
+ const nonce = findAuthorizationNonce(receipt.logs, expectedToken);
110
+ const block = await withRetry(() => client.getBlock({ blockHash: receipt.blockHash }), {
111
+ retries,
112
+ });
113
+ return {
114
+ txHash,
115
+ blockNumber: receipt.blockNumber,
116
+ blockTimestamp: Number(block.timestamp),
117
+ from: found.from,
118
+ to: found.to,
119
+ value: found.value,
120
+ tokenAddress: (expectedToken ?? BASE_SEPOLIA_USDC),
121
+ ...(nonce ? { authorizationNonce: nonce } : {}),
122
+ };
123
+ }
124
+ async function getTransferByTxHash(hash) {
125
+ const result = await buildTransferFromReceipt(hash);
126
+ if (!result)
127
+ return null;
128
+ if ("mismatch" in result)
129
+ return null;
130
+ return result;
131
+ }
132
+ async function verifyTransfer(opts) {
133
+ const expectedToken = (opts.expectedToken ?? BASE_SEPOLIA_USDC);
134
+ // buildTransferFromReceipt returns null for not-found / non-success
135
+ // receipts; only rethrows on unrecoverable RPC errors, which we let
136
+ // bubble up to the caller.
137
+ const result = await buildTransferFromReceipt(opts.txHash, expectedToken);
138
+ if (result && "mismatch" in result) {
139
+ return {
140
+ kind: "wrong_token",
141
+ txHash: opts.txHash,
142
+ expectedToken,
143
+ actualToken: result.mismatch.actualToken,
144
+ };
145
+ }
146
+ if (!result) {
147
+ // Distinguish pending from not_found vs reverted by inspecting the tx
148
+ // itself.
149
+ let tx;
150
+ try {
151
+ tx = await withRetry(() => client.getTransaction({ hash: opts.txHash }), { retries });
152
+ }
153
+ catch (err) {
154
+ if (isNotFoundError(err)) {
155
+ return { kind: "not_found", txHash: opts.txHash };
156
+ }
157
+ throw err;
158
+ }
159
+ if (!tx)
160
+ return { kind: "not_found", txHash: opts.txHash };
161
+ if (tx.blockNumber === null)
162
+ return { kind: "pending", txHash: opts.txHash };
163
+ // Has a block but receipt was null → must be reverted.
164
+ return { kind: "reverted", txHash: opts.txHash };
165
+ }
166
+ const transfer = result;
167
+ if (opts.expectedTo && transfer.to.toLowerCase() !== opts.expectedTo.toLowerCase()) {
168
+ return { kind: "wrong_recipient", transfer, expectedTo: opts.expectedTo };
169
+ }
170
+ if (opts.expectedAmount !== undefined && transfer.value !== opts.expectedAmount) {
171
+ return { kind: "wrong_amount", transfer, expectedAmount: opts.expectedAmount };
172
+ }
173
+ if (opts.expectedFrom && transfer.from.toLowerCase() !== opts.expectedFrom.toLowerCase()) {
174
+ // Treat "wrong from" as a recipient-class mismatch with a different
175
+ // surface; future variant could split this. v0.1: bundle into
176
+ // wrong_recipient with the offending address — but expectedTo is
177
+ // semantically about the receiver. To keep the discriminant clean,
178
+ // we surface from-mismatches as wrong_recipient with the expected
179
+ // *to* echoed, since reconciliation cares about all four fields
180
+ // together. The downstream engine checks transfer.from separately.
181
+ return { kind: "wrong_recipient", transfer, expectedTo: opts.expectedTo ?? transfer.to };
182
+ }
183
+ const head = await getBlockNumber();
184
+ const confirmations = head >= transfer.blockNumber ? head - transfer.blockNumber + 1n : 0n;
185
+ return { kind: "confirmed", transfer, confirmations };
186
+ }
187
+ function subscribeUsdcTransfers(subOpts = {}) {
188
+ const pollMs = subOpts.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
189
+ const queue = [];
190
+ const resolvers = [];
191
+ let done = false;
192
+ let cursor = null;
193
+ let stopFn = null;
194
+ const pumpQueue = () => {
195
+ while (queue.length > 0 && resolvers.length > 0) {
196
+ const next = queue.shift();
197
+ const resolve = resolvers.shift();
198
+ resolve({ value: next, done: false });
199
+ }
200
+ };
201
+ const finish = () => {
202
+ done = true;
203
+ while (resolvers.length > 0) {
204
+ resolvers.shift()({ value: undefined, done: true });
205
+ }
206
+ if (stopFn) {
207
+ const s = stopFn;
208
+ stopFn = null;
209
+ s();
210
+ }
211
+ };
212
+ const poll = async () => {
213
+ try {
214
+ const head = await getBlockNumber();
215
+ if (cursor === null) {
216
+ cursor = subOpts.fromBlock ?? head;
217
+ }
218
+ if (head <= cursor)
219
+ return;
220
+ const fromBlock = cursor + 1n;
221
+ const toBlock = head;
222
+ const logs = await withRetry(() => client.getLogs({
223
+ address: BASE_SEPOLIA_USDC,
224
+ event: USDC_TRANSFER_EVENT,
225
+ fromBlock,
226
+ toBlock,
227
+ ...(subOpts.payer ? { args: { from: subOpts.payer } } : {}),
228
+ }), { retries });
229
+ // For each Transfer, fetch the receipt to enrich with the AuthorizationUsed nonce.
230
+ for (const log of logs) {
231
+ if (!log.transactionHash)
232
+ continue;
233
+ const enriched = await buildTransferFromReceipt(log.transactionHash);
234
+ if (!enriched || "mismatch" in enriched)
235
+ continue;
236
+ queue.push(enriched);
237
+ }
238
+ cursor = head;
239
+ pumpQueue();
240
+ }
241
+ catch {
242
+ // Per ARCHITECTURE.md: "mark the watch as degraded — the proxy
243
+ // keeps logging regardless." We swallow + retry next tick. Future
244
+ // versions can surface a "subscription.degraded" event.
245
+ }
246
+ };
247
+ const interval = setInterval(() => void poll(), pollMs);
248
+ // Kick off the first poll immediately so callers don't wait pollMs.
249
+ void poll();
250
+ stopFn = () => clearInterval(interval);
251
+ const handle = {
252
+ [Symbol.asyncIterator]: () => ({
253
+ next() {
254
+ if (queue.length > 0) {
255
+ return Promise.resolve({ value: queue.shift(), done: false });
256
+ }
257
+ if (done)
258
+ return Promise.resolve({ value: undefined, done: true });
259
+ return new Promise((res) => resolvers.push(res));
260
+ },
261
+ return() {
262
+ finish();
263
+ return Promise.resolve({ value: undefined, done: true });
264
+ },
265
+ }),
266
+ unsubscribe: finish,
267
+ };
268
+ subscribers.add({ stop: finish });
269
+ return handle;
270
+ }
271
+ async function close() {
272
+ for (const s of subscribers)
273
+ s.stop();
274
+ subscribers.clear();
275
+ }
276
+ return {
277
+ getTransferByTxHash,
278
+ verifyTransfer,
279
+ subscribeUsdcTransfers,
280
+ getBlockNumber,
281
+ close,
282
+ };
283
+ }
@@ -0,0 +1,4 @@
1
+ export { createChainClient } from "./client.js";
2
+ export { withRetry, type RetryOptions } from "./retry.js";
3
+ export { AUTHORIZATION_USED_EVENT, BASE_SEPOLIA_USDC, USDC_TRANSFER_EVENT } from "./abi.js";
4
+ export { type Address, type ChainClient, type ChainClientOptions, type ChainTransfer, type VerifyTransferOptions, type VerifyTransferResult, } from "./types.js";
@@ -0,0 +1,4 @@
1
+ export { createChainClient } from "./client.js";
2
+ export { withRetry } from "./retry.js";
3
+ export { AUTHORIZATION_USED_EVENT, BASE_SEPOLIA_USDC, USDC_TRANSFER_EVENT } from "./abi.js";
4
+ export {} from "./types.js";
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Exponential-backoff retry for transient RPC failures.
3
+ *
4
+ * Per ARCHITECTURE.md § Chain RPC client: "RPC failures retry with
5
+ * exponential backoff (max 3 attempts), then mark the watch as
6
+ * degraded — the proxy keeps logging regardless."
7
+ *
8
+ * Retry classifier defaults to "anything that isn't an instance of
9
+ * Error with name === 'AbortError'" — callers can override to be
10
+ * stricter (e.g., only retry on 5xx / connection-reset / timeout).
11
+ */
12
+ export interface RetryOptions {
13
+ readonly retries?: number;
14
+ readonly baseDelayMs?: number;
15
+ readonly maxDelayMs?: number;
16
+ readonly shouldRetry?: (err: unknown, attempt: number) => boolean;
17
+ }
18
+ export declare function withRetry<T>(fn: () => Promise<T>, opts?: RetryOptions): Promise<T>;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Exponential-backoff retry for transient RPC failures.
3
+ *
4
+ * Per ARCHITECTURE.md § Chain RPC client: "RPC failures retry with
5
+ * exponential backoff (max 3 attempts), then mark the watch as
6
+ * degraded — the proxy keeps logging regardless."
7
+ *
8
+ * Retry classifier defaults to "anything that isn't an instance of
9
+ * Error with name === 'AbortError'" — callers can override to be
10
+ * stricter (e.g., only retry on 5xx / connection-reset / timeout).
11
+ */
12
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
13
+ export async function withRetry(fn, opts = {}) {
14
+ const retries = opts.retries ?? 3;
15
+ const base = opts.baseDelayMs ?? 200;
16
+ const max = opts.maxDelayMs ?? 2_000;
17
+ const shouldRetry = opts.shouldRetry ?? defaultShouldRetry;
18
+ let attempt = 0;
19
+ // attempt 0 = initial call; retries are extra attempts after that.
20
+ while (true) {
21
+ try {
22
+ return await fn();
23
+ }
24
+ catch (err) {
25
+ if (attempt >= retries || !shouldRetry(err, attempt))
26
+ throw err;
27
+ const delay = Math.min(max, base * Math.pow(2, attempt));
28
+ await sleep(delay);
29
+ attempt += 1;
30
+ }
31
+ }
32
+ }
33
+ function defaultShouldRetry(err) {
34
+ if (err instanceof Error && err.name === "AbortError")
35
+ return false;
36
+ return true;
37
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * X402-12 chain client types.
3
+ *
4
+ * `ChainTransfer` matches the shape declared in ARCHITECTURE.md §
5
+ * Key interfaces. `VerifyTransferResult` is the user-facing return
6
+ * from `verifyTransfer({txHash, expected*})` per the X402-12 ticket —
7
+ * 7 discriminated variants covering all four edge cases the ticket
8
+ * names (tx not yet mined, tx reverted, wrong recipient/amount, tx
9
+ * not found) plus the success case and two finer-grained mismatch
10
+ * variants the reconciliation engine will want to distinguish.
11
+ */
12
+ import type { Hex } from "viem";
13
+ export type Address = `0x${string}`;
14
+ export interface ChainTransfer {
15
+ readonly txHash: Hex;
16
+ readonly blockNumber: bigint;
17
+ readonly blockTimestamp: number;
18
+ readonly from: Address;
19
+ readonly to: Address;
20
+ readonly value: bigint;
21
+ readonly tokenAddress: Address;
22
+ /** EIP-3009 `AuthorizationUsed.nonce` from the same tx, if present. */
23
+ readonly authorizationNonce?: Hex;
24
+ }
25
+ export type VerifyTransferResult = {
26
+ readonly kind: "confirmed";
27
+ readonly transfer: ChainTransfer;
28
+ readonly confirmations: bigint;
29
+ } | {
30
+ readonly kind: "reverted";
31
+ readonly txHash: Hex;
32
+ } | {
33
+ readonly kind: "pending";
34
+ readonly txHash: Hex;
35
+ } | {
36
+ readonly kind: "not_found";
37
+ readonly txHash: Hex;
38
+ } | {
39
+ readonly kind: "wrong_recipient";
40
+ readonly transfer: ChainTransfer;
41
+ readonly expectedTo: Address;
42
+ } | {
43
+ readonly kind: "wrong_amount";
44
+ readonly transfer: ChainTransfer;
45
+ readonly expectedAmount: bigint;
46
+ } | {
47
+ readonly kind: "wrong_token";
48
+ readonly txHash: Hex;
49
+ readonly expectedToken: Address;
50
+ readonly actualToken: Address;
51
+ };
52
+ export interface VerifyTransferOptions {
53
+ readonly txHash: Hex;
54
+ readonly expectedFrom?: Address;
55
+ readonly expectedTo?: Address;
56
+ readonly expectedAmount?: bigint;
57
+ /** Defaults to Base Sepolia USDC. */
58
+ readonly expectedToken?: Address;
59
+ }
60
+ export interface ChainClientOptions {
61
+ /** RPC URL. Default: `https://sepolia.base.org`. */
62
+ readonly rpcUrl?: string;
63
+ /** Per-RPC-call timeout in ms. Default 30_000 (matches the X402-12 ticket). */
64
+ readonly rpcTimeoutMs?: number;
65
+ /** Retry attempts on transient RPC failures. Default 3 per ARCHITECTURE.md. */
66
+ readonly retries?: number;
67
+ /**
68
+ * Test escape hatch: inject a custom viem `Transport`. When set, `rpcUrl`
69
+ * is ignored. Used by unit tests to mock RPC responses.
70
+ */
71
+ readonly transport?: any;
72
+ }
73
+ export interface ChainClient {
74
+ /** One-shot lookup. Returns null if tx not found or reverted. */
75
+ getTransferByTxHash(hash: Hex): Promise<ChainTransfer | null>;
76
+ /** Compare a tx hash against expected fields; classify into one of 7 variants. */
77
+ verifyTransfer(opts: VerifyTransferOptions): Promise<VerifyTransferResult>;
78
+ /**
79
+ * Live USDC `Transfer` watcher. Polls for new blocks since `fromBlock`
80
+ * (default: current block at subscribe time). Each emitted `ChainTransfer`
81
+ * is enriched with the matching `AuthorizationUsed.nonce` if present.
82
+ */
83
+ subscribeUsdcTransfers(opts?: {
84
+ fromBlock?: bigint;
85
+ payer?: Address;
86
+ pollIntervalMs?: number;
87
+ }): AsyncIterable<ChainTransfer> & {
88
+ unsubscribe(): void;
89
+ };
90
+ /** Current head block number. Used to compute confirmations. */
91
+ getBlockNumber(): Promise<bigint>;
92
+ /** Tear down any watchers. */
93
+ close(): Promise<void>;
94
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * X402-12 chain client types.
3
+ *
4
+ * `ChainTransfer` matches the shape declared in ARCHITECTURE.md §
5
+ * Key interfaces. `VerifyTransferResult` is the user-facing return
6
+ * from `verifyTransfer({txHash, expected*})` per the X402-12 ticket —
7
+ * 7 discriminated variants covering all four edge cases the ticket
8
+ * names (tx not yet mined, tx reverted, wrong recipient/amount, tx
9
+ * not found) plus the success case and two finer-grained mismatch
10
+ * variants the reconciliation engine will want to distinguish.
11
+ */
12
+ export {};
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Minimal ANSI color helpers. Honours `NO_COLOR` (https://no-color.org)
3
+ * and TTY detection so output is clean when piped to grep / a file /
4
+ * `tee`. No external dependency — we render six escape codes by hand.
5
+ */
6
+ declare const CODES: {
7
+ readonly red: "\u001B[31m";
8
+ readonly green: "\u001B[32m";
9
+ readonly yellow: "\u001B[33m";
10
+ readonly cyan: "\u001B[36m";
11
+ readonly dim: "\u001B[2m";
12
+ readonly bold: "\u001B[1m";
13
+ };
14
+ export type ColorName = keyof typeof CODES;
15
+ export interface Colorizer {
16
+ readonly enabled: boolean;
17
+ paint(code: ColorName, text: string): string;
18
+ }
19
+ export interface ColorOptions {
20
+ /** Force-enable / force-disable. When undefined, auto-detect from TTY + NO_COLOR. */
21
+ readonly enabled?: boolean;
22
+ /** Stream to interrogate for `isTTY`. Default: `process.stdout`. */
23
+ readonly stream?: {
24
+ readonly isTTY?: boolean;
25
+ };
26
+ /** Environment to interrogate for `NO_COLOR`. Default: `process.env`. */
27
+ readonly env?: Readonly<Record<string, string | undefined>>;
28
+ }
29
+ export declare function createColorizer(opts?: ColorOptions): Colorizer;
30
+ export {};
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Minimal ANSI color helpers. Honours `NO_COLOR` (https://no-color.org)
3
+ * and TTY detection so output is clean when piped to grep / a file /
4
+ * `tee`. No external dependency — we render six escape codes by hand.
5
+ */
6
+ const RESET = "\x1b[0m";
7
+ const CODES = {
8
+ red: "\x1b[31m",
9
+ green: "\x1b[32m",
10
+ yellow: "\x1b[33m",
11
+ cyan: "\x1b[36m",
12
+ dim: "\x1b[2m",
13
+ bold: "\x1b[1m",
14
+ };
15
+ export function createColorizer(opts = {}) {
16
+ const stream = opts.stream ?? process.stdout;
17
+ const env = opts.env ?? process.env;
18
+ const auto = typeof stream.isTTY === "boolean" && stream.isTTY === true && !("NO_COLOR" in env);
19
+ const enabled = opts.enabled ?? auto;
20
+ return {
21
+ enabled,
22
+ paint(code, text) {
23
+ return enabled ? `${CODES[code]}${text}${RESET}` : text;
24
+ },
25
+ };
26
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * X402-14 CLI exit codes. Per the X402-14 acceptance criteria:
3
+ * 0 = success
4
+ * 1 = usage error (bad flags, missing required args)
5
+ * 2 = runtime error (proxy crash, log file unreadable, etc.)
6
+ *
7
+ * Centralised so subcommands and tests reference the same constants.
8
+ */
9
+ export declare const EXIT_SUCCESS: 0;
10
+ export declare const EXIT_USAGE: 1;
11
+ export declare const EXIT_RUNTIME: 2;
12
+ export type ExitCode = typeof EXIT_SUCCESS | typeof EXIT_USAGE | typeof EXIT_RUNTIME;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * X402-14 CLI exit codes. Per the X402-14 acceptance criteria:
3
+ * 0 = success
4
+ * 1 = usage error (bad flags, missing required args)
5
+ * 2 = runtime error (proxy crash, log file unreadable, etc.)
6
+ *
7
+ * Centralised so subcommands and tests reference the same constants.
8
+ */
9
+ export const EXIT_SUCCESS = 0;
10
+ export const EXIT_USAGE = 1;
11
+ export const EXIT_RUNTIME = 2;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Renders `ReconciliationResult`s for stdout. The JSONL log is the
3
+ * canonical record; this is the human-facing line per [SPEC.md § 3 user
4
+ * flow](../../SPEC.md#3-user-flow) — the `RECONCILED ⚠
5
+ * settled-but-server-thinks-not` headline for the canonical #1062
6
+ * detection.
7
+ *
8
+ * `formatJson` is `JSON.stringify(result)` so output is grep-friendly
9
+ * (X402-14 acceptance criterion).
10
+ */
11
+ import type { ReconciliationResult } from "../reconciliation/types.js";
12
+ import type { Colorizer } from "./color.js";
13
+ export declare function formatResultHuman(result: ReconciliationResult, color: Colorizer): string;
14
+ /**
15
+ * JSON-stringify a `ReconciliationResult`. `bigint`s in the embedded
16
+ * `ChainTransfer` (blockNumber, value) and the value_mismatch
17
+ * expected/actual are coerced to strings — JSON has no bigint and we
18
+ * want lossless representation downstream.
19
+ */
20
+ export declare function formatResultJson(result: ReconciliationResult): string;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Renders `ReconciliationResult`s for stdout. The JSONL log is the
3
+ * canonical record; this is the human-facing line per [SPEC.md § 3 user
4
+ * flow](../../SPEC.md#3-user-flow) — the `RECONCILED ⚠
5
+ * settled-but-server-thinks-not` headline for the canonical #1062
6
+ * detection.
7
+ *
8
+ * `formatJson` is `JSON.stringify(result)` so output is grep-friendly
9
+ * (X402-14 acceptance criterion).
10
+ */
11
+ export function formatResultHuman(result, color) {
12
+ const stamp = color.paint("dim", `[${result.t}]`);
13
+ const id = short(result.exchangeId);
14
+ switch (result.kind) {
15
+ case "settled_on_chain": {
16
+ const headline = color.paint("yellow", "RECONCILED ⚠ settled-but-server-thinks-not");
17
+ const tx = color.paint("cyan", short(result.onChain.txHash, 10));
18
+ const auth = result.pending.payment.payload.authorization;
19
+ return `${stamp} ${headline} id=${id} tx=${tx} value=${auth.value} payer=${shortAddr(auth.from)} → payee=${shortAddr(auth.to)} gap=${result.gapMs}ms`;
20
+ }
21
+ case "not_settled": {
22
+ const headline = color.paint("green", "NOT_SETTLED ✓ facilitator was right");
23
+ const auth = result.pending.payment.payload.authorization;
24
+ const reason = result.pending.errorReason ? ` reason=${result.pending.errorReason}` : "";
25
+ return `${stamp} ${headline} id=${id} payer=${shortAddr(auth.from)} value=${auth.value} waited=${result.waitedMs}ms${reason}`;
26
+ }
27
+ case "value_mismatch": {
28
+ const headline = color.paint("red", "VALUE_MISMATCH ⚠ on-chain transfer differs from authorized value");
29
+ return `${stamp} ${headline} id=${id} tx=${short(result.onChain.txHash, 10)} expected=${result.expected} actual=${result.actual}`;
30
+ }
31
+ case "recipient_mismatch": {
32
+ const headline = color.paint("red", "RECIPIENT_MISMATCH ⚠ on-chain transfer paid the wrong address");
33
+ return `${stamp} ${headline} id=${id} tx=${short(result.onChain.txHash, 10)} expected=${shortAddr(result.expectedPayee)} actual=${shortAddr(result.actualPayee)}`;
34
+ }
35
+ }
36
+ }
37
+ /**
38
+ * JSON-stringify a `ReconciliationResult`. `bigint`s in the embedded
39
+ * `ChainTransfer` (blockNumber, value) and the value_mismatch
40
+ * expected/actual are coerced to strings — JSON has no bigint and we
41
+ * want lossless representation downstream.
42
+ */
43
+ export function formatResultJson(result) {
44
+ return JSON.stringify(result, bigintReplacer);
45
+ }
46
+ function bigintReplacer(_key, value) {
47
+ return typeof value === "bigint" ? value.toString() : value;
48
+ }
49
+ function short(value, head = 8) {
50
+ if (value.length <= head + 4)
51
+ return value;
52
+ return `${value.slice(0, head)}…`;
53
+ }
54
+ function shortAddr(value) {
55
+ if (!value.startsWith("0x") || value.length < 12)
56
+ return value;
57
+ return `${value.slice(0, 6)}…${value.slice(-4)}`;
58
+ }
@@ -0,0 +1,26 @@
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 { type ExitCode } from "./exit-codes.js";
15
+ export interface CliContext {
16
+ readonly stdout: NodeJS.WritableStream;
17
+ readonly stderr: NodeJS.WritableStream;
18
+ readonly env: Readonly<Record<string, string | undefined>>;
19
+ /**
20
+ * Resolves when the process should shut down (SIGINT/SIGTERM in
21
+ * production, immediately for many tests). Only consulted by the
22
+ * long-running `proxy` subcommand.
23
+ */
24
+ readonly waitForShutdown?: () => Promise<void>;
25
+ }
26
+ export declare function runCli(argv: readonly string[], ctx: CliContext): Promise<ExitCode>;