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 +25 -2
- package/README.md +22 -10
- package/dist/chain/abi.d.ts +35 -0
- package/dist/chain/abi.js +29 -0
- package/dist/chain/client.js +34 -1
- package/dist/chain/types.d.ts +24 -0
- package/dist/cli/explain-command.d.ts +36 -0
- package/dist/cli/explain-command.js +162 -0
- package/dist/cli/index.d.ts +6 -4
- package/dist/cli/index.js +39 -5
- package/dist/cli/validate-command.d.ts +56 -0
- package/dist/cli/validate-command.js +141 -0
- package/dist/diagnose/format.d.ts +23 -0
- package/dist/diagnose/format.js +68 -0
- package/dist/diagnose/index.d.ts +12 -0
- package/dist/diagnose/index.js +11 -0
- package/dist/diagnose/rules.d.ts +93 -0
- package/dist/diagnose/rules.js +276 -0
- package/dist/diagnose/types.d.ts +100 -0
- package/dist/diagnose/types.js +25 -0
- package/package.json +1 -1
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
|
|
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.
|
|
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
|
-
|
|
88
|
-
x402trace
|
|
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
|
|
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** (
|
|
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.
|
|
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
|
-
-
|
|
101
|
-
- `x402trace
|
|
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
|
|
104
|
-
-
|
|
105
|
-
- Reconciliation
|
|
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
|
|
package/dist/chain/abi.d.ts
CHANGED
|
@@ -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
|
+
];
|
package/dist/chain/client.js
CHANGED
|
@@ -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
|
}
|
package/dist/chain/types.d.ts
CHANGED
|
@@ -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
|
+
}
|
package/dist/cli/index.d.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* CLI surface. Four subcommands:
|
|
3
3
|
*
|
|
4
|
-
* x402trace proxy
|
|
5
|
-
* x402trace inspect
|
|
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.
|
|
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
|
-
*
|
|
2
|
+
* CLI surface. Four subcommands:
|
|
3
3
|
*
|
|
4
|
-
* x402trace proxy
|
|
5
|
-
* x402trace inspect
|
|
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.
|
|
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.
|
|
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>;
|