x402trace 0.1.0 → 0.2.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 CHANGED
@@ -11,6 +11,28 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
11
11
 
12
12
 
13
13
 
14
+ ## [0.2.0] — 2026-05-12
15
+
16
+ The v0.2 pre/post-payment debugger. v0.1 owned mid-flight (proxy) + post-settlement (reconcile); v0.2 adds pre-flight (`validate`) and offline failure diagnosis (`explain`), sharing a new pure rule engine in `src/diagnose/`. Closes pain ranks #3 (generic 402 with no error reason) and #4 (wallet-state pre-flight gap) from the X402-6 ranking. 278 tests, same Base Sepolia / `exact` EVM scope as v0.1 per [ADR-002](./DECISIONS.md#adr-002-v02-feature-pick--validate-primary--explain-paired). Apache-2.0.
17
+
18
+ ### Added
19
+
20
+ - **`x402trace validate <wallet> <service-url>` (X402-21)** — read-only pre-flight before signing. Fetches the 402 challenge, queries on-chain USDC balance + EIP-3009 nonce status + wallet kind (EOA vs Smart Wallet via `getCode`), synthesises a hypothetical `PaymentPayload`, runs the diagnose engine, renders a plain-English report. Exits `0` for `would-succeed`, `2` for `would-fail`, `0` for `uncertain` (or `2` with `--strict`).
21
+ - **`x402trace explain <jsonl-log-file>` (X402-21)** — read a JSONL log produced by `proxy --reconcile`, find every exchange where `reconcile.result.kind != 'settled_on_chain'` plus every `decoder.error`, run the same rule engine against captured state, print per-failure prose with actionable fixes. CI-friendly: exits `2` if any failures rendered, `0` if log was clean.
22
+ - **`src/diagnose/` (X402-21)** — pure rule engine (no I/O, no `Date.now`). 10 rules covering network match, scheme match, recipient match, value sufficiency, `validBefore` / `validAfter` window, payer USDC balance, EIP-3009 nonce freshness, wallet kind (EOA + Smart Wallet; ERC-6492 deferred to v0.3 per ADR-002), and Base Sepolia USDC asset address. Each rule returns `pass` / `fail` / `skip`; `skip` means the context lacked the data (e.g. `explain` doesn't have live wallet state). Top-level status is `would-succeed` / `would-fail` / `uncertain` (the latter when the two critical chain-state rules are skipped).
23
+ - **Chain client extensions (X402-21)** — `getUsdcBalance(wallet)`, `isNonceConsumed(authorizer, nonce)`, `detectWalletKind(wallet)` read-only methods on `ChainClient`, plus a narrow `USDC_READ_ABI` (just `balanceOf` + `authorizationState`). Used by `validate`; reusable by future v0.3 features.
24
+ - **v0.2 feature pick (X402-20)** — ADR-002 records the decision + 4 rejected alternatives. SPEC.md § 5 flipped from "v0.2 stretch (deferred)" to "v0.2 scope" with a new v0.3 stretch list catching the deferrals. CLAUDE.md current-focus flipped to v0.2.
25
+ - **CI release workflow `contents: write` fix** — release.yml's `Create GitHub Release` step had failed on the v0.1.0 cut with HTTP 403 because the job had `contents: read`. Promoted to `write` so v0.2.0+ tag-pushes self-create the GitHub Release without manual intervention.
26
+
27
+ ### Tests
28
+
29
+ - 278 total (+63 from v0.1.0's 215). New: 36 unit tests for `diagnose-rules`, 14 for `validate-command`, 9 for `explain-command`, +4 for `cli-dispatcher` covering the new subcommands.
30
+
31
+ ### Notes
32
+
33
+ - `package.json` bin layout unchanged from v0.1.0 — `x402trace` resolves to four subcommands (`proxy`, `inspect`, `validate`, `explain`).
34
+ - `tsconfig.build.json` is still the published-bundle config; tarball stays ~62 files / ~156 KB.
35
+
14
36
  ## [0.1.0] — 2026-05-12
15
37
 
16
38
  The v0.1 wedge: a local proxy + timeout-reconciliation engine that catches the canonical [coinbase/x402#1062](https://github.com/coinbase/x402/issues/1062) symptom — buyer is debited on-chain but the facilitator/server thinks the payment failed. Verified end-to-end on real Base Sepolia + the live `x402.org/facilitator` (tx [`0x116ccf73…ba52`](https://sepolia.basescan.org/tx/0x116ccf73fa77eda19aea149606042f1e848e8afe2f719a0d2890dd2b2ff0ba52)). 215 tests, GitHub Actions CI on Node 20 + 22, Apache-2.0.
@@ -38,7 +60,7 @@ The v0.1 wedge: a local proxy + timeout-reconciliation engine that catches the c
38
60
  - **GitHub Actions CI (X402-18)** — `.github/workflows/ci.yml` runs `pnpm typecheck`, `pnpm lint`, `pnpm test`, `pnpm build` on every PR + push to `v1` / `staging` / `main`. **Node 20 + 22 matrix**, pnpm-store cached via `actions/setup-node@v4`, 10-min job timeout, in-progress runs cancelled when a fresher commit lands on the same ref. `pnpm test` auto-skips `tests/e2e/chain-live.test.ts` (gated on `X402_E2E=1`), so the live-Base-Sepolia suite never burns testnet funds in CI. Companion `.github/workflows/release.yml` is the X402-19 substrate: tag-triggered (`v*.*.*`), runs the same quality bar, builds, and has the npm-publish step commented out until [X402-19](https://vahdatfardin.atlassian.net/browse/X402-19) wires the `NPM_TOKEN` secret. The CI badge in `README.md` (placeholder since X402-16) lights up on the first run.
39
61
  - **Unit-test coverage hardening (X402-17)** — Week-4 risk-reduction pass on the decoder, reconciliation, and CLI. Per the ticket spec ("chase risk reduction, not coverage targets"), targeted the gaps that break silently: (a) the streaming decoder's dispatch logic (`createDecoder().decode()`) had zero direct tests — new `tests/unit/decoder-decoder.test.ts` (11 tests) covers settlement-header parse errors, the `outcome.rawPaymentResponseHeader` fallback path, and `proxy.error` passthrough; (b) base64 + unicode edge cases on `parsePaymentHeader` / `parseChallengeBody` / `parseSettlementHeader` mirroring the [coinbase/x402#865](https://github.com/coinbase/x402/issues/865) surface area — CJK + emoji in `description` and `extra.name` round-trip, padding-stripped base64 tolerated, JSON primitives rejected, URL-safe base64 contract documented; (c) reconciliation `extractErrorReason` paths (non-JSON rawBody, JSON without `error`, missing rawBody), the `unknown` proxy outcome branch the X402-15 demo relies on, chain-transfer race conditions (arrival before pending join, duplicate transfers after match); (d) `x402trace proxy` env-var-vs-flag precedence — extracted `resolveProxyConfig(opts, env)` as a pure function from `proxy-command.ts` (small refactor for testability), then pinned the contract from [ARCHITECTURE.md § Configuration](./ARCHITECTURE.md#configuration) with 28 unit tests across `X402TRACE_UPSTREAM`, `X402TRACE_PORT`, `X402TRACE_LOG`, `X402TRACE_RECONCILE` (including non-truthy strings), `BASE_RPC_URL`, `X402TRACE_WATCH_TIMEOUT_MS`, `X402TRACE_UPSTREAM_TIMEOUT_MS`. Pipeline: **215 tests / 4 e2e skipped** (+57 vs the X402-16 baseline). Coverage: reconciliation **97% → 100%** lines/branches/funcs, decoder **79% → 90%**, src/cli **80.8% → 82%**.
40
62
  - **README rewrite (X402-16)**: replaced the pre-release skeleton with a 115-line README that hits all seven X402-16 required sections — elevator pitch, the problem (with the verbatim 502 the buyer sees), 30-second quickstart (`git clone` → `pnpm install` → `cp .env.example .env` → `./examples/e2e-timeout-reconciliation.sh`), how-it-works (4 bullets + ASCII flow diagram of proxy → server → facilitator → chain → engine), CLI reference (links to `--help` rather than duplicating), v0.2 roadmap pulled from [SPEC.md § 5](./SPEC.md#5-v02-stretch-deferred-not-killed), differentiation, contributing, license. Three badges added: Apache-2.0 license (live), GitHub Actions CI (placeholder — wires up automatically when [X402-18](https://vahdatfardin.atlassian.net/browse/X402-18) lands the workflow), npm version (placeholder — wires up automatically when [X402-19](https://vahdatfardin.atlassian.net/browse/X402-19) publishes `x402trace@0.1.0`). The captured X402-15 settlement tx [`0x116ccf73…ba52`](https://sepolia.basescan.org/tx/0x116ccf73fa77eda19aea149606042f1e848e8afe2f719a0d2890dd2b2ff0ba52) is linked from the quickstart's "what success looks like" output block; the asciinema cast is referenced by relative path for local `asciinema play` until [X402-23](https://vahdatfardin.atlassian.net/browse/X402-23) uploads it for an embeddable URL.
41
- - **End-to-end testnet demo (X402-15)** in `examples/e2e-timeout-reconciliation.sh` + `examples/README.md` + recorded asciinema cast at `examples/cast/e2e-timeout-reconciliation.cast`. The flagship reproduction of the canonical [#1062](https://github.com/coinbase/x402/issues/1062) scenario, runnable against real Base Sepolia + the real `x402.org/facilitator`. Choreography: dogfood server with new `DEMO_SLEEP_MS=10000` knob sleeps 10s *during* the x402-hono paymentMiddleware's protected handler — x402-hono verifies before the handler and settles after, so the post-handler /settle still broadcasts on-chain even though the proxy in front gave up at 5s. `x402trace proxy --reconcile --upstream-timeout-ms 5000` returns 502 to the client (the canonical "I thought it failed" signal), pends the exchange, watches Base Sepolia via the chain client's `subscribeUsdcTransfers`, matches the EIP-3009 nonce, and emits `RECONCILED ⚠ settled-but-server-thinks-not` with the live tx hash. **Verified end-to-end on Base Sepolia 2026-05-12** with on-chain settlement tx [`0x116ccf73…ba52`](https://sepolia.basescan.org/tx/0x116ccf73fa77eda19aea149606042f1e848e8afe2f719a0d2890dd2b2ff0ba52) (block 41402768) — captured in the committed asciinema cast; reconcile gap from proxy timeout to chain-detected was **11.9 seconds**. New `--upstream-timeout-ms` CLI flag on `x402trace proxy` exposes `ProxyOptions.upstreamTimeoutMs` to the surface. New `demoSleepMs` / `demoFailAfterSleep` fields on `DogfoodConfig` plus `DEMO_SLEEP_MS` / `DEMO_FAIL_AFTER_SLEEP` env wiring in `loadServerConfig` (`demoFailAfterSleep` is documented but NOT used in the canonical demo because x402-hono skips /settle on 5xx responses). 9 new tests — 8 unit tests covering env validation (positive/negative/zero/non-numeric) and CLI help inclusion, plus 1 hermetic integration test (`tests/integration/demo-timeout-reconciliation.test.ts`) that reproduces the full demo storyline against the mock facilitator with a synthetic `ChainTransfer`. Pipeline: 158 tests / 4 e2e skipped, typecheck + lint clean. README rewritten with a quick-demo hero section.
63
+ - **End-to-end testnet demo (X402-15)** in `examples/e2e-timeout-reconciliation.sh` + `examples/README.md` + recorded asciinema cast at `examples/cast/e2e-timeout-reconciliation.cast`. The flagship reproduction of the canonical [#1062](https://github.com/coinbase/x402/issues/1062) scenario, runnable against real Base Sepolia + the real `x402.org/facilitator`. Choreography: dogfood server with new `DEMO_SLEEP_MS=10000` knob sleeps 10s _during_ the x402-hono paymentMiddleware's protected handler — x402-hono verifies before the handler and settles after, so the post-handler /settle still broadcasts on-chain even though the proxy in front gave up at 5s. `x402trace proxy --reconcile --upstream-timeout-ms 5000` returns 502 to the client (the canonical "I thought it failed" signal), pends the exchange, watches Base Sepolia via the chain client's `subscribeUsdcTransfers`, matches the EIP-3009 nonce, and emits `RECONCILED ⚠ settled-but-server-thinks-not` with the live tx hash. **Verified end-to-end on Base Sepolia 2026-05-12** with on-chain settlement tx [`0x116ccf73…ba52`](https://sepolia.basescan.org/tx/0x116ccf73fa77eda19aea149606042f1e848e8afe2f719a0d2890dd2b2ff0ba52) (block 41402768) — captured in the committed asciinema cast; reconcile gap from proxy timeout to chain-detected was **11.9 seconds**. New `--upstream-timeout-ms` CLI flag on `x402trace proxy` exposes `ProxyOptions.upstreamTimeoutMs` to the surface. New `demoSleepMs` / `demoFailAfterSleep` fields on `DogfoodConfig` plus `DEMO_SLEEP_MS` / `DEMO_FAIL_AFTER_SLEEP` env wiring in `loadServerConfig` (`demoFailAfterSleep` is documented but NOT used in the canonical demo because x402-hono skips /settle on 5xx responses). 9 new tests — 8 unit tests covering env validation (positive/negative/zero/non-numeric) and CLI help inclusion, plus 1 hermetic integration test (`tests/integration/demo-timeout-reconciliation.test.ts`) that reproduces the full demo storyline against the mock facilitator with a synthetic `ChainTransfer`. Pipeline: 158 tests / 4 e2e skipped, typecheck + lint clean. README rewritten with a quick-demo hero section.
42
64
  - **CLI binary (X402-14)** in `src/cli/` + `src/cli.ts`: the user-facing surface for v0.1, composing the four substrates (Proxy / Decoder / Chain / Reconciliation) into a single `x402trace` command. Two subcommands — `x402trace proxy --upstream <url> [--port 8402] [--log human|json] [--log-file <path>] [--log-secrets] [--reconcile] [--rpc-url <url>] [--watch-timeout-ms <n>]` runs the live pipeline; `x402trace inspect <jsonl-log-file> [--log human|json] [--watch-timeout-ms <n>]` replays a captured log and re-runs reconciliation offline. Built on `commander@14`. Modules — `index.ts` (commander dispatch + `runCli` testable entry), `proxy-command.ts` (live pipeline wiring; opens a second `JsonlSink` against the same log path for `chain.transfer` and `reconcile.result` records — **closes the X402-13 deferred acceptance bullet** "All reconciliation events written to a local JSONL log file"), `inspect-command.ts` (offline replay), `replay.ts` (JSONL reader driving a virtual-clock engine via the new `engine.tick()` sweep method), `format-result.ts` (human + JSON renderers for `ReconciliationResult` — the canonical `RECONCILED ⚠ settled-but-server-thinks-not` headline per [SPEC.md § 3](./SPEC.md#3-user-flow)), `color.ts` (TTY + `NO_COLOR`-aware ANSI helpers; no `chalk` dep), `exit-codes.ts` (0 success / 1 usage / 2 runtime per the X402-14 ticket). 28 new unit tests + 1 smoke test that drives the full proxy + decoder + JSONL pipeline against the dogfood rig and replays the captured log through `inspect`. Pipeline: 149 tests / 4 e2e skipped, typecheck + lint clean. `scripts/proxy.ts` is now a thin shim that defers to `runCli`; `pnpm proxy` and `pnpm x402trace` both work, and `npx x402trace` will work once published.
43
65
  - **Timeout reconciliation engine (X402-13)** in `src/reconciliation/`: the headline feature of the v0.1 wedge. Four modules — `types.ts` (`PendingExchange` + `ReconciliationResult` 4-variant discriminated union: `settled_on_chain` / `not_settled` / `value_mismatch` / `recipient_mismatch`), `match.ts` (pure `matchPendingAgainstTransfer` checking `(payer, payee, value, nonce)` exact-equality; case-insensitive on addresses + nonce), `engine.ts` (`createReconciliationEngine({watchTimeoutMs?, sweepIntervalMs?, now?}) → Engine` with three ingest methods for proxy / decoder / chain streams, in-memory pending-set with two-half-join semantics, periodic sweep for `not_settled` after `watchTimeoutMs` (default 60_000), injectable clock for tests), `index.ts`. 17 unit tests covering the match function + engine lifecycle (rejected outcomes flag, paid outcomes don't, settled_on_chain emit, not_settled timeout, value/recipient mismatch, arrival-order independence) + 2-test integration that **reproduces the canonical [#1062](https://github.com/coinbase/x402/issues/1062) scenario** end-to-end: mock facilitator rejects → engine emits `settled_on_chain` when matching ChainTransfer arrives. Pipeline: 120 tests / 4 e2e skipped, no new deps.
44
66
  - Local mock x402 facilitator (X402-3) at `src/dogfood/mock-facilitator.ts` implementing v1 `POST /verify` and `POST /settle` with canned-success responses. Unblocks integration testing without on-chain USDC and provides the test harness `TESTING.md` calls for. `scripts/mock-facilitator.ts` is the standalone entry; `tests/integration/dogfood-paid-flow.test.ts` asserts the full 402 → signed retry → 200 + `X-PAYMENT-RESPONSE` flow against it.
@@ -67,5 +89,6 @@ The v0.1 wedge: a local proxy + timeout-reconciliation engine that catches the c
67
89
 
68
90
  ---
69
91
 
70
- [Unreleased]: https://github.com/fardinvahdat/x402trace/compare/v0.1.0...HEAD
92
+ [Unreleased]: https://github.com/fardinvahdat/x402trace/compare/v0.2.0...HEAD
93
+ [0.2.0]: https://github.com/fardinvahdat/x402trace/releases/tag/v0.2.0
71
94
  [0.1.0]: https://github.com/fardinvahdat/x402trace/releases/tag/v0.1.0
package/README.md CHANGED
@@ -84,25 +84,37 @@ Full architecture: [ARCHITECTURE.md](./ARCHITECTURE.md). Wedge rationale: [DECIS
84
84
  ## CLI
85
85
 
86
86
  ```bash
87
- x402trace proxy --upstream <url> [--reconcile] [--log human|json] …
88
- x402trace inspect <jsonl-log-file> [--log human|json] …
87
+ # v0.1 during/after payment
88
+ x402trace proxy --upstream <url> [--reconcile] [--log human|json] …
89
+ x402trace inspect <jsonl-log-file> [--log human|json] …
90
+
91
+ # v0.2 — before/explaining payment
92
+ x402trace validate <wallet> <service-url> [--strict] [--log human|json]
93
+ x402trace explain <jsonl-log-file> [--log human|json]
89
94
  ```
90
95
 
91
- The authoritative flag list is `x402trace --help` / `x402trace proxy --help` / `x402trace inspect --help` — they're the source of truth and are wired into the unit tests. See also [`pnpm x402trace --help`](./src/cli/index.ts) directly in the repo.
96
+ The full pre/during/post-payment debugger:
97
+
98
+ - **`validate <wallet> <service>`** — read-only pre-flight before signing. Fetches the 402, queries USDC balance + EIP-3009 nonce + wallet kind, runs 10 diagnostic rules, prints a plain-English report. Exits 0 if the payment would succeed, 2 if it would fail. Closes [pain rank #4](./dogfood-notes.md#top-painful-moments-synthesized---x402-6) (wallet-state pre-flight gap).
99
+ - **`explain <jsonl-log>`** — read a JSONL log produced by `proxy --reconcile`, find every exchange that didn't `settled_on_chain`, run the same rule engine against the captured state, print per-failure prose with actionable fixes. CI-friendly: exits 2 if any failures, 0 if clean. Closes [pain rank #3](./dogfood-notes.md#top-painful-moments-synthesized---x402-6) (generic 402 with no error reason).
100
+
101
+ The authoritative flag list is `x402trace --help` (or per-subcommand `--help`) — they're the source of truth and are wired into the unit tests.
92
102
 
93
103
  ## Roadmap
94
104
 
95
- **v0.1** (current, ~6 weeks from project start) — local proxy + timeout reconciliation + structured logs. Base Sepolia, `x402.org/facilitator`, `exact` EVM scheme only. Detect-and-notify, no auto-refund. Wedge accepted in [ADR-001](./DECISIONS.md).
105
+ **v0.1.0** (shipped 2026-05-12 as [`x402trace@0.1.0`](https://www.npmjs.com/package/x402trace)) — local proxy + timeout reconciliation + structured logs. Base Sepolia, `x402.org/facilitator`, `exact` EVM scheme only. Detect-and-notify, no auto-refund. Wedge accepted in [ADR-001](./DECISIONS.md#adr-001-v01-wedge). Verified by three independent live Base Sepolia settlements.
106
+
107
+ **v0.2** (current) — `validate` (pre-flight) + `explain` (offline 402 diagnosis), sharing a new `src/diagnose/` rule engine. Same scope tightening as v0.1: Base Sepolia, single facilitator, `exact` EVM, read-only. Decision: [ADR-002](./DECISIONS.md#adr-002-v02-feature-pick--validate-primary--explain-paired).
96
108
 
97
- **v0.2 stretch** (from [SPEC.md § 5](./SPEC.md#5-v02-stretch-deferred-not-killed), ordered by ranked dogfood pain):
109
+ **v0.3 stretch** (kept, not killed — full list in [SPEC.md § 5](./SPEC.md#5-v02-scope-picked-in-adr-002)):
98
110
 
99
111
  - Mainnet (after ≥1 week of clean testnet traffic)
100
- - `x402trace inspect <captured-402.json>` — pure-function offline 402 decode
101
- - `x402trace doctor <wallet> <service>` pre-flight wallet/service check
112
+ - ERC-6492 wallet-kind support in `validate`
113
+ - `x402trace diff`cross-facilitator behavior comparison
102
114
  - `x402trace bazaar-check` — Bazaar indexing diagnostics
103
- - `x402trace versions` — SDK-skew audit across `x402`, `x402-fetch`, facilitator
104
- - Multi-facilitator support (CDP, PayAI, x402-rs)
105
- - Reconciliation **actions** beyond JSONL (webhook, structured remediation)
115
+ - `x402trace versions` — SDK-skew audit
116
+ - `--watch` daemon mode with alerting integrations
117
+ - Reconciliation actions beyond JSONL (webhook, optional auto-retry)
106
118
 
107
119
  ## Differentiation
108
120
 
@@ -46,3 +46,38 @@ export declare const AUTHORIZATION_USED_EVENT: {
46
46
  readonly type: "bytes32";
47
47
  }];
48
48
  };
49
+ /**
50
+ * Read-only USDC functions used by `validate` (X402-21). `balanceOf`
51
+ * and EIP-3009's `authorizationState` give us pre-flight visibility
52
+ * without signing anything. Kept narrow — we only declare the
53
+ * functions we actually call, so importing this ABI subset doesn't
54
+ * advertise capabilities x402trace doesn't use.
55
+ */
56
+ export declare const USDC_READ_ABI: readonly [{
57
+ readonly type: "function";
58
+ readonly stateMutability: "view";
59
+ readonly name: "balanceOf";
60
+ readonly inputs: readonly [{
61
+ readonly name: "account";
62
+ readonly type: "address";
63
+ }];
64
+ readonly outputs: readonly [{
65
+ readonly name: "";
66
+ readonly type: "uint256";
67
+ }];
68
+ }, {
69
+ readonly type: "function";
70
+ readonly stateMutability: "view";
71
+ readonly name: "authorizationState";
72
+ readonly inputs: readonly [{
73
+ readonly name: "authorizer";
74
+ readonly type: "address";
75
+ }, {
76
+ readonly name: "nonce";
77
+ readonly type: "bytes32";
78
+ }];
79
+ readonly outputs: readonly [{
80
+ readonly name: "";
81
+ readonly type: "bool";
82
+ }];
83
+ }];
package/dist/chain/abi.js CHANGED
@@ -32,3 +32,32 @@ export const AUTHORIZATION_USED_EVENT = {
32
32
  { indexed: true, name: "nonce", type: "bytes32" },
33
33
  ],
34
34
  };
35
+ /**
36
+ * Read-only USDC functions used by `validate` (X402-21). `balanceOf`
37
+ * and EIP-3009's `authorizationState` give us pre-flight visibility
38
+ * without signing anything. Kept narrow — we only declare the
39
+ * functions we actually call, so importing this ABI subset doesn't
40
+ * advertise capabilities x402trace doesn't use.
41
+ */
42
+ export const USDC_READ_ABI = [
43
+ {
44
+ type: "function",
45
+ stateMutability: "view",
46
+ name: "balanceOf",
47
+ inputs: [{ name: "account", type: "address" }],
48
+ outputs: [{ name: "", type: "uint256" }],
49
+ },
50
+ {
51
+ // EIP-3009: returns true iff the nonce was already consumed for
52
+ // this authorizer. `transferWithAuthorization` flips this from
53
+ // false to true atomically with the Transfer.
54
+ type: "function",
55
+ stateMutability: "view",
56
+ name: "authorizationState",
57
+ inputs: [
58
+ { name: "authorizer", type: "address" },
59
+ { name: "nonce", type: "bytes32" },
60
+ ],
61
+ outputs: [{ name: "", type: "bool" }],
62
+ },
63
+ ];
@@ -1,6 +1,6 @@
1
1
  import { createPublicClient, decodeEventLog, http } from "viem";
2
2
  import { baseSepolia } from "viem/chains";
3
- import { AUTHORIZATION_USED_EVENT, BASE_SEPOLIA_USDC, USDC_TRANSFER_EVENT } from "./abi.js";
3
+ import { AUTHORIZATION_USED_EVENT, BASE_SEPOLIA_USDC, USDC_READ_ABI, USDC_TRANSFER_EVENT, } from "./abi.js";
4
4
  import { withRetry } from "./retry.js";
5
5
  const DEFAULT_RPC_URL = "https://sepolia.base.org";
6
6
  const DEFAULT_POLL_INTERVAL_MS = 4_000;
@@ -273,11 +273,44 @@ export function createChainClient(opts = {}) {
273
273
  s.stop();
274
274
  subscribers.clear();
275
275
  }
276
+ // ─── X402-21 v0.2 read-only pre-flight helpers ─────────────────
277
+ // Used by `validate`. All three are pure reads; never broadcast.
278
+ async function getUsdcBalance(wallet) {
279
+ return withRetry(() => client.readContract({
280
+ address: BASE_SEPOLIA_USDC,
281
+ abi: USDC_READ_ABI,
282
+ functionName: "balanceOf",
283
+ args: [wallet],
284
+ }), { retries });
285
+ }
286
+ async function isNonceConsumed(authorizer, nonce) {
287
+ return withRetry(() => client.readContract({
288
+ address: BASE_SEPOLIA_USDC,
289
+ abi: USDC_READ_ABI,
290
+ functionName: "authorizationState",
291
+ args: [authorizer, nonce],
292
+ }), { retries });
293
+ }
294
+ async function detectWalletKind(wallet) {
295
+ try {
296
+ const code = await withRetry(() => client.getCode({ address: wallet }), { retries });
297
+ // viem returns undefined for accounts with no code (EOA).
298
+ if (code === undefined || code === "0x")
299
+ return "eoa";
300
+ return "smart-wallet";
301
+ }
302
+ catch {
303
+ return "unknown";
304
+ }
305
+ }
276
306
  return {
277
307
  getTransferByTxHash,
278
308
  verifyTransfer,
279
309
  subscribeUsdcTransfers,
280
310
  getBlockNumber,
311
+ getUsdcBalance,
312
+ isNonceConsumed,
313
+ detectWalletKind,
281
314
  close,
282
315
  };
283
316
  }
@@ -89,6 +89,30 @@ export interface ChainClient {
89
89
  };
90
90
  /** Current head block number. Used to compute confirmations. */
91
91
  getBlockNumber(): Promise<bigint>;
92
+ /**
93
+ * X402-21 v0.2: read-only USDC balance for `validate`'s pre-flight
94
+ * check. Returns the raw token amount (no decimals applied) so the
95
+ * diagnose engine compares as bigints.
96
+ */
97
+ getUsdcBalance(wallet: Address): Promise<bigint>;
98
+ /**
99
+ * X402-21 v0.2: EIP-3009 nonce status. Returns true iff
100
+ * `(authorizer, nonce)` has already been consumed on the USDC
101
+ * contract. Used by the `nonce-fresh` diagnostic rule.
102
+ */
103
+ isNonceConsumed(authorizer: Address, nonce: Hex): Promise<boolean>;
104
+ /**
105
+ * X402-21 v0.2: best-effort wallet kind classification.
106
+ * - `eoa` if the address has no contract code
107
+ * - `smart-wallet` if it does
108
+ * - `unknown` if the chain call errored
109
+ *
110
+ * v0.2 doesn't distinguish ERC-6492 (undeployed Smart Wallet) — that's
111
+ * a v0.3 gap per ADR-002. A real ERC-6492 wallet that's never been
112
+ * deployed shows up here as `eoa` (no code), which the diagnose
113
+ * engine surfaces as a soft warning rather than a hard pass.
114
+ */
115
+ detectWalletKind(wallet: Address): Promise<"eoa" | "smart-wallet" | "unknown">;
92
116
  /** Tear down any watchers. */
93
117
  close(): Promise<void>;
94
118
  }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * `x402trace explain <jsonl-log-file>` — offline plain-English
3
+ * diagnosis (X402-21 v0.2 per [ADR-002](../../DECISIONS.md#adr-002-v02-feature-pick--validate-primary--explain-paired)).
4
+ *
5
+ * Pure-offline: never opens an HTTP server, never makes an RPC call.
6
+ * Reads the JSONL log a `proxy --reconcile` run produced, walks it
7
+ * once to build a per-exchange index, then renders a diagnose report
8
+ * for every exchange whose reconcile outcome wasn't `settled_on_chain`
9
+ * plus every standalone `decoder.error` event.
10
+ *
11
+ * Distinct from `inspect` (X402-14):
12
+ * - `inspect` re-runs the reconciliation match logic; output is
13
+ * reconcile-engine-shaped (`settled_on_chain` / `not_settled` / …).
14
+ * - `explain` re-runs the diagnose rule engine; output is
15
+ * per-failure prose with actionable fixes.
16
+ *
17
+ * The two are complementary — `inspect` says "the match logic still
18
+ * says 12 of your 50 exchanges failed reconciliation"; `explain` says
19
+ * "the recipient address on exchange `94c15089` doesn't match the
20
+ * challenge's payTo, fix with …".
21
+ */
22
+ import type { LogFormat } from "../decoder/types.js";
23
+ import { type Colorizer } from "./color.js";
24
+ import { type ExitCode } from "./exit-codes.js";
25
+ export interface ExplainCommandOptions {
26
+ readonly logFile?: string;
27
+ readonly log?: LogFormat;
28
+ }
29
+ export interface ExplainRunContext {
30
+ readonly stdout: NodeJS.WritableStream;
31
+ readonly stderr: NodeJS.WritableStream;
32
+ readonly color?: Colorizer;
33
+ /** Injectable clock for deterministic tests. */
34
+ readonly now?: () => Date;
35
+ }
36
+ export declare function runExplainCommand(opts: ExplainCommandOptions, ctx: ExplainRunContext): Promise<ExitCode>;
@@ -0,0 +1,162 @@
1
+ /**
2
+ * `x402trace explain <jsonl-log-file>` — offline plain-English
3
+ * diagnosis (X402-21 v0.2 per [ADR-002](../../DECISIONS.md#adr-002-v02-feature-pick--validate-primary--explain-paired)).
4
+ *
5
+ * Pure-offline: never opens an HTTP server, never makes an RPC call.
6
+ * Reads the JSONL log a `proxy --reconcile` run produced, walks it
7
+ * once to build a per-exchange index, then renders a diagnose report
8
+ * for every exchange whose reconcile outcome wasn't `settled_on_chain`
9
+ * plus every standalone `decoder.error` event.
10
+ *
11
+ * Distinct from `inspect` (X402-14):
12
+ * - `inspect` re-runs the reconciliation match logic; output is
13
+ * reconcile-engine-shaped (`settled_on_chain` / `not_settled` / …).
14
+ * - `explain` re-runs the diagnose rule engine; output is
15
+ * per-failure prose with actionable fixes.
16
+ *
17
+ * The two are complementary — `inspect` says "the match logic still
18
+ * says 12 of your 50 exchanges failed reconciliation"; `explain` says
19
+ * "the recipient address on exchange `94c15089` doesn't match the
20
+ * challenge's payTo, fix with …".
21
+ */
22
+ import { readFile, stat } from "node:fs/promises";
23
+ import { diagnose, formatReportHuman, formatReportJson } from "../diagnose/index.js";
24
+ import { createColorizer } from "./color.js";
25
+ import { EXIT_RUNTIME, EXIT_SUCCESS, EXIT_USAGE } from "./exit-codes.js";
26
+ export async function runExplainCommand(opts, ctx) {
27
+ const logFile = opts.logFile;
28
+ if (!logFile) {
29
+ ctx.stderr.write("error: <jsonl-log-file> argument is required\n");
30
+ return EXIT_USAGE;
31
+ }
32
+ try {
33
+ const s = await stat(logFile);
34
+ if (!s.isFile()) {
35
+ ctx.stderr.write(`error: ${logFile} is not a file\n`);
36
+ return EXIT_USAGE;
37
+ }
38
+ }
39
+ catch {
40
+ ctx.stderr.write(`error: cannot read ${logFile}\n`);
41
+ return EXIT_USAGE;
42
+ }
43
+ const format = opts.log ?? "human";
44
+ const color = ctx.color ?? createColorizer({ stream: ctx.stdout });
45
+ const now = (ctx.now ?? (() => new Date()))();
46
+ // ─── Walk the log once, indexing by exchange id ────────────────
47
+ // For each exchange we need the challenge (requirements), the
48
+ // payment (authorization), and the reconcile.result if any. We
49
+ // *don't* need walletState — the log doesn't capture it.
50
+ const challenges = new Map();
51
+ const payments = new Map();
52
+ const reconcileResults = new Map();
53
+ const decoderErrors = [];
54
+ const raw = await readFile(logFile, "utf8");
55
+ let lineNum = 0;
56
+ for (const line of raw.split("\n")) {
57
+ lineNum += 1;
58
+ if (!line.trim())
59
+ continue;
60
+ let rec;
61
+ try {
62
+ rec = JSON.parse(line);
63
+ }
64
+ catch {
65
+ // Bad line — skip silently. `inspect` already covers this surface
66
+ // (it logs counts.linesSkipped); duplicate the count handling here.
67
+ continue;
68
+ }
69
+ if (!rec || typeof rec !== "object")
70
+ continue;
71
+ const r = rec;
72
+ const event = typeof r.event === "string" ? r.event : null;
73
+ const id = typeof r.id === "string" ? r.id : null;
74
+ if (event === "exchange.challenge" && id && typeof r.challenge === "object") {
75
+ challenges.set(id, r.challenge);
76
+ }
77
+ else if (event === "exchange.payment" && id && typeof r.payment === "object") {
78
+ payments.set(id, r.payment);
79
+ }
80
+ else if (event === "reconcile.result" && typeof r.exchangeId === "string") {
81
+ reconcileResults.set(r.exchangeId, {
82
+ kind: typeof r.kind === "string" ? r.kind : "unknown",
83
+ ts: typeof r.t === "string" ? r.t : "",
84
+ });
85
+ }
86
+ else if (event === "decoder.error") {
87
+ decoderErrors.push({
88
+ ts: typeof r.t === "string" ? r.t : "",
89
+ ...(id ? { id } : {}),
90
+ stage: typeof r.stage === "string" ? r.stage : "unknown",
91
+ message: typeof r.message === "string" ? r.message : "",
92
+ });
93
+ }
94
+ }
95
+ // ─── Filter: which exchanges deserve an "explain" block? ───────
96
+ // - Any reconcile.result whose kind != settled_on_chain → that exchange failed.
97
+ // - Any decoder.error → render its message + the exchange context (if id available).
98
+ const explainableExchangeIds = new Set();
99
+ for (const [id, result] of reconcileResults) {
100
+ if (result.kind !== "settled_on_chain")
101
+ explainableExchangeIds.add(id);
102
+ }
103
+ // ─── Render ────────────────────────────────────────────────────
104
+ const counts = {
105
+ linesTotal: lineNum,
106
+ explained: 0,
107
+ decoderErrors: decoderErrors.length,
108
+ };
109
+ if (format === "human") {
110
+ ctx.stdout.write(`${color.paint("bold", `explain ${logFile}`)} ${color.paint("dim", `(${challenges.size} exchanges captured, ${reconcileResults.size} reconciled)`)}\n\n`);
111
+ }
112
+ for (const id of explainableExchangeIds) {
113
+ const requirements = challenges.get(id);
114
+ const payment = payments.get(id);
115
+ const result = reconcileResults.get(id);
116
+ if (!requirements) {
117
+ // Can't diagnose without a challenge to compare against.
118
+ if (format === "human") {
119
+ ctx.stdout.write(`${color.paint("yellow", "⚠")} exchange ${color.paint("bold", id.slice(0, 8))}…: reconcile said ${result?.kind} but no challenge in log — skipping\n`);
120
+ }
121
+ continue;
122
+ }
123
+ const diagCtx = {
124
+ requirements,
125
+ ...(payment ? { payment } : {}),
126
+ now,
127
+ };
128
+ const report = diagnose(diagCtx);
129
+ counts.explained += 1;
130
+ if (format === "human") {
131
+ ctx.stdout.write(`${color.paint("bold", `─── exchange ${id.slice(0, 8)}… ${result?.kind} at ${result?.ts} ───`)}\n`);
132
+ ctx.stdout.write(`${formatReportHuman(report, color)}\n\n`);
133
+ }
134
+ else {
135
+ ctx.stdout.write(`${JSON.stringify({ event: "explain.exchange", exchangeId: id, reconcileKind: result?.kind, ts: result?.ts })}\n`);
136
+ ctx.stdout.write(`${formatReportJson(report)}\n`);
137
+ }
138
+ }
139
+ // Standalone decoder errors (no diagnose run — just surface them).
140
+ for (const err of decoderErrors) {
141
+ if (format === "human") {
142
+ const idLabel = err.id ? `${err.id.slice(0, 8)}…` : "(no id)";
143
+ ctx.stdout.write(`${color.paint("red", "✗")} decoder.error ${color.paint("bold", idLabel)} stage=${err.stage}: ${err.message}\n`);
144
+ }
145
+ else {
146
+ ctx.stdout.write(`${JSON.stringify({ event: "explain.decoder-error", ...err })}\n`);
147
+ }
148
+ }
149
+ // Trailer counts so downstream tools can sanity-check.
150
+ if (format === "human") {
151
+ ctx.stdout.write(`\n${color.paint("dim", `explained ${counts.explained} failed exchange(s), ${counts.decoderErrors} decoder error(s) from ${counts.linesTotal} lines`)}\n`);
152
+ }
153
+ else {
154
+ ctx.stdout.write(`${JSON.stringify({ event: "explain.summary", ...counts })}\n`);
155
+ }
156
+ // Exit code: 2 if any exchange or decoder error was rendered; 0 if
157
+ // the log was clean. This makes `explain` usable in CI to gate
158
+ // "no reconciliation failures since last deploy".
159
+ if (counts.explained > 0 || counts.decoderErrors > 0)
160
+ return EXIT_RUNTIME;
161
+ return EXIT_SUCCESS;
162
+ }
@@ -1,11 +1,13 @@
1
1
  /**
2
- * X402-14 CLI surface. Two subcommands:
2
+ * CLI surface. Four subcommands:
3
3
  *
4
- * x402trace proxy --upstream <url> [--port 8402] [--log human|json] [--reconcile]
5
- * x402trace inspect <jsonl-log-file> [--log human|json] …
4
+ * x402trace proxy --upstream <url> [--reconcile] [--log human|json] … (X402-14)
5
+ * x402trace inspect <jsonl-log-file> [--log human|json] … (X402-14)
6
+ * x402trace validate <wallet> <service-url> [--strict] [--log human|json] (X402-21 v0.2)
7
+ * x402trace explain <jsonl-log-file> [--log human|json] (X402-21 v0.2)
6
8
  *
7
9
  * `commander` does the parsing; we own the wiring + exit-code
8
- * discipline. See X402-14 Jira for acceptance criteria.
10
+ * discipline.
9
11
  *
10
12
  * `runCli` is the testable entry; `src/cli.ts` is the bin shim that
11
13
  * calls it with the real `process` handles.
package/dist/cli/index.js CHANGED
@@ -1,19 +1,23 @@
1
1
  /**
2
- * X402-14 CLI surface. Two subcommands:
2
+ * CLI surface. Four subcommands:
3
3
  *
4
- * x402trace proxy --upstream <url> [--port 8402] [--log human|json] [--reconcile]
5
- * x402trace inspect <jsonl-log-file> [--log human|json] …
4
+ * x402trace proxy --upstream <url> [--reconcile] [--log human|json] … (X402-14)
5
+ * x402trace inspect <jsonl-log-file> [--log human|json] … (X402-14)
6
+ * x402trace validate <wallet> <service-url> [--strict] [--log human|json] (X402-21 v0.2)
7
+ * x402trace explain <jsonl-log-file> [--log human|json] (X402-21 v0.2)
6
8
  *
7
9
  * `commander` does the parsing; we own the wiring + exit-code
8
- * discipline. See X402-14 Jira for acceptance criteria.
10
+ * discipline.
9
11
  *
10
12
  * `runCli` is the testable entry; `src/cli.ts` is the bin shim that
11
13
  * calls it with the real `process` handles.
12
14
  */
13
15
  import "dotenv/config";
14
16
  import { Command } from "commander";
17
+ import { runExplainCommand } from "./explain-command.js";
15
18
  import { runInspectCommand } from "./inspect-command.js";
16
19
  import { runProxyCommand } from "./proxy-command.js";
20
+ import { runValidateCommand } from "./validate-command.js";
17
21
  import { EXIT_SUCCESS, EXIT_USAGE } from "./exit-codes.js";
18
22
  // Keep in sync with package.json `version`. A runtime read from
19
23
  // package.json would be more durable but requires JSON-module
@@ -21,7 +25,7 @@ import { EXIT_SUCCESS, EXIT_USAGE } from "./exit-codes.js";
21
25
  // both `tsx src/cli.ts` (dev) and `dist/cli.js` (published) — folding
22
26
  // that into a follow-up cleanup. Until then, the X402-19 release
23
27
  // checklist + the changelog-cut step are the guards against drift.
24
- const VERSION = "0.1.0";
28
+ const VERSION = "0.2.0";
25
29
  export async function runCli(argv, ctx) {
26
30
  const program = new Command();
27
31
  program
@@ -88,6 +92,36 @@ export async function runCli(argv, ctx) {
88
92
  : {}),
89
93
  }, { stdout: ctx.stdout, stderr: ctx.stderr });
90
94
  });
95
+ program
96
+ .command("validate")
97
+ .description("Pre-flight check: would <wallet> succeed paying <service-url>? Read-only, no signing.")
98
+ .argument("<wallet>", "Payer wallet address (0x-prefixed, 20 bytes)")
99
+ .argument("<service-url>", "Service URL that responds 402 with an x402 challenge")
100
+ .option("--log <human|json>", "Stdout format (default 'human')", validateLogFormat)
101
+ .option("--rpc-url <url>", "Base Sepolia RPC URL (env BASE_RPC_URL)")
102
+ .option("--strict", "Treat `uncertain` (key chain checks skipped) as a failure — exit 2 instead of 0")
103
+ .action(async (wallet, service, flags) => {
104
+ const log = flags.log;
105
+ exit = await runValidateCommand({
106
+ wallet,
107
+ service,
108
+ ...(log !== undefined ? { log } : {}),
109
+ ...(flags.rpcUrl !== undefined ? { rpcUrl: flags.rpcUrl } : {}),
110
+ ...(flags.strict !== undefined ? { strict: flags.strict } : {}),
111
+ }, { stdout: ctx.stdout, stderr: ctx.stderr, env: ctx.env });
112
+ });
113
+ program
114
+ .command("explain")
115
+ .description("Plain-English diagnosis of every failed exchange in a captured JSONL log. Offline.")
116
+ .argument("<jsonl-log-file>", "Path to a JSONL log written by `x402trace proxy --reconcile`")
117
+ .option("--log <human|json>", "Stdout format (default 'human')", validateLogFormat)
118
+ .action(async (logFile, flags) => {
119
+ const log = flags.log;
120
+ exit = await runExplainCommand({
121
+ logFile,
122
+ ...(log !== undefined ? { log } : {}),
123
+ }, { stdout: ctx.stdout, stderr: ctx.stderr });
124
+ });
91
125
  try {
92
126
  await program.parseAsync([...argv], { from: "user" });
93
127
  }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * `x402trace validate <wallet> <service-url>` — pre-flight check
3
+ * (X402-21 v0.2, per [ADR-002](../../DECISIONS.md#adr-002-v02-feature-pick--validate-primary--explain-paired)).
4
+ *
5
+ * Reads a service's 402 challenge, queries the wallet's on-chain state,
6
+ * builds a `DiagnosticContext`, and runs the `diagnose()` engine. No
7
+ * signing, no broadcasting — wallet provided is just the address.
8
+ *
9
+ * Exit codes match the v0.1 convention:
10
+ * 0 = would succeed (or uncertain — see --strict)
11
+ * 1 = usage error
12
+ * 2 = would fail OR runtime error
13
+ *
14
+ * Flow:
15
+ * 1. GET <service-url> (no X-PAYMENT) → expect 402 + challenge body
16
+ * 2. Decode challenge via the v0.1 `parseChallengeBody`
17
+ * 3. Synthesise a `PaymentPayload` that would satisfy the challenge
18
+ * (so payment-side rules can sanity-check the requirements
19
+ * *themselves* — e.g. invalid asset, weird scheme — before we
20
+ * worry about wallet state).
21
+ * 4. Query: USDC balance, EIP-3009 nonce state for the synthesised
22
+ * nonce, wallet kind.
23
+ * 5. Build context + run engine + render.
24
+ */
25
+ import { type ChainClient } from "../chain/index.js";
26
+ import type { LogFormat } from "../decoder/types.js";
27
+ import { type Colorizer } from "./color.js";
28
+ import { type ExitCode } from "./exit-codes.js";
29
+ export interface ValidateCommandOptions {
30
+ readonly wallet?: string;
31
+ readonly service?: string;
32
+ readonly log?: LogFormat;
33
+ readonly rpcUrl?: string;
34
+ /**
35
+ * When true, the "uncertain" overall status (key chain checks
36
+ * skipped) exits 2 instead of 0. Default false — `uncertain` is a
37
+ * warning, not a failure, since the user might have an RPC outage.
38
+ */
39
+ readonly strict?: boolean;
40
+ }
41
+ export interface ValidateRunContext {
42
+ readonly stdout: NodeJS.WritableStream;
43
+ readonly stderr: NodeJS.WritableStream;
44
+ readonly env: Readonly<Record<string, string | undefined>>;
45
+ readonly color?: Colorizer;
46
+ /**
47
+ * Injectable chain-client factory so tests can mock without standing
48
+ * up a real Base Sepolia RPC. Defaults to `createChainClient`.
49
+ */
50
+ readonly chainFactory?: (rpcUrl?: string) => ChainClient;
51
+ /** Injectable fetch for the 402 GET. Defaults to global `fetch`. */
52
+ readonly fetch?: typeof fetch;
53
+ /** Injectable clock for deterministic tests. */
54
+ readonly now?: () => Date;
55
+ }
56
+ export declare function runValidateCommand(opts: ValidateCommandOptions, ctx: ValidateRunContext): Promise<ExitCode>;