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
|
@@ -0,0 +1,141 @@
|
|
|
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 { createChainClient } from "../chain/index.js";
|
|
26
|
+
import { parseChallengeBody } from "../decoder/parse.js";
|
|
27
|
+
import { diagnose, formatReportHuman, formatReportJson } from "../diagnose/index.js";
|
|
28
|
+
import { createColorizer } from "./color.js";
|
|
29
|
+
import { EXIT_RUNTIME, EXIT_SUCCESS, EXIT_USAGE } from "./exit-codes.js";
|
|
30
|
+
const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
|
|
31
|
+
// Conservative synthesised nonce — all-zero. The nonce-fresh rule will
|
|
32
|
+
// query `authorizationState` for it; an unused all-zero nonce will
|
|
33
|
+
// almost certainly come back `false` (i.e. fresh), so the simulation
|
|
34
|
+
// is realistic for a wallet that hasn't paid this service yet.
|
|
35
|
+
const SYNTHESISED_NONCE = `0x${"0".repeat(64)}`;
|
|
36
|
+
export async function runValidateCommand(opts, ctx) {
|
|
37
|
+
if (!opts.wallet || !opts.service) {
|
|
38
|
+
ctx.stderr.write("error: usage — x402trace validate <wallet> <service-url>\n");
|
|
39
|
+
return EXIT_USAGE;
|
|
40
|
+
}
|
|
41
|
+
if (!ADDRESS_RE.test(opts.wallet)) {
|
|
42
|
+
ctx.stderr.write(`error: wallet is not a valid 0x-prefixed 20-byte address: ${opts.wallet}\n`);
|
|
43
|
+
return EXIT_USAGE;
|
|
44
|
+
}
|
|
45
|
+
const wallet = opts.wallet;
|
|
46
|
+
let serviceUrl;
|
|
47
|
+
try {
|
|
48
|
+
serviceUrl = new URL(opts.service);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
ctx.stderr.write(`error: service is not a valid URL: ${opts.service}\n`);
|
|
52
|
+
return EXIT_USAGE;
|
|
53
|
+
}
|
|
54
|
+
const format = opts.log ?? "human";
|
|
55
|
+
const color = ctx.color ?? createColorizer({ stream: ctx.stdout });
|
|
56
|
+
const fetchFn = ctx.fetch ?? fetch;
|
|
57
|
+
const now = (ctx.now ?? (() => new Date()))();
|
|
58
|
+
// ─── Step 1: fetch the 402 challenge ───────────────────────────
|
|
59
|
+
let challenge;
|
|
60
|
+
try {
|
|
61
|
+
const res = await fetchFn(serviceUrl.toString(), { method: "GET" });
|
|
62
|
+
if (res.status !== 402) {
|
|
63
|
+
ctx.stderr.write(`error: expected ${color.paint("bold", "402")} from ${serviceUrl}, got ${res.status} — is this an x402-protected endpoint?\n`);
|
|
64
|
+
return EXIT_RUNTIME;
|
|
65
|
+
}
|
|
66
|
+
const bodyText = await res.text();
|
|
67
|
+
const parsed = parseChallengeBody(bodyText);
|
|
68
|
+
if (!parsed.ok) {
|
|
69
|
+
ctx.stderr.write(`error: ${parsed.message}\n`);
|
|
70
|
+
return EXIT_RUNTIME;
|
|
71
|
+
}
|
|
72
|
+
challenge = parsed.value.requirements;
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
ctx.stderr.write(`error: failed to fetch ${serviceUrl}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
76
|
+
return EXIT_RUNTIME;
|
|
77
|
+
}
|
|
78
|
+
// ─── Step 2: synthesise a PaymentPayload the wallet WOULD sign ─
|
|
79
|
+
// Pre-flight has no real signature; we synthesise the auth so the
|
|
80
|
+
// payment-side rules can lint the challenge shape itself. The
|
|
81
|
+
// validBefore is set to (now + maxTimeoutSeconds), matching what a
|
|
82
|
+
// well-behaved client would produce.
|
|
83
|
+
const validBeforeSec = Math.floor(now.getTime() / 1000) + (challenge.maxTimeoutSeconds ?? 300);
|
|
84
|
+
const synthesised = {
|
|
85
|
+
x402Version: 1,
|
|
86
|
+
scheme: challenge.scheme,
|
|
87
|
+
network: challenge.network,
|
|
88
|
+
payload: {
|
|
89
|
+
signature: "[SIMULATED]",
|
|
90
|
+
authorization: {
|
|
91
|
+
from: wallet,
|
|
92
|
+
to: challenge.payTo,
|
|
93
|
+
value: challenge.maxAmountRequired,
|
|
94
|
+
validAfter: "0",
|
|
95
|
+
validBefore: String(validBeforeSec),
|
|
96
|
+
nonce: SYNTHESISED_NONCE,
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
// ─── Step 3: query on-chain wallet state ───────────────────────
|
|
101
|
+
const chainFactory = ctx.chainFactory ?? createChainClient;
|
|
102
|
+
const chain = chainFactory(opts.rpcUrl ?? ctx.env.BASE_RPC_URL);
|
|
103
|
+
let walletState;
|
|
104
|
+
try {
|
|
105
|
+
const [balance, nonceConsumed, walletKind] = await Promise.all([
|
|
106
|
+
chain.getUsdcBalance(wallet),
|
|
107
|
+
chain.isNonceConsumed(wallet, SYNTHESISED_NONCE),
|
|
108
|
+
chain.detectWalletKind(wallet),
|
|
109
|
+
]);
|
|
110
|
+
walletState = { usdcBalance: balance, nonceConsumed, walletKind };
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
ctx.stderr.write(`${color.paint("yellow", "warn:")} chain state query failed — running offline diagnosis only (${err instanceof Error ? err.message : String(err)})\n`);
|
|
114
|
+
walletState = {};
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
await chain.close?.();
|
|
118
|
+
}
|
|
119
|
+
// ─── Step 4: build context, run engine, render ────────────────
|
|
120
|
+
const diagCtx = {
|
|
121
|
+
requirements: challenge,
|
|
122
|
+
payment: synthesised,
|
|
123
|
+
walletState,
|
|
124
|
+
now,
|
|
125
|
+
};
|
|
126
|
+
const report = diagnose(diagCtx);
|
|
127
|
+
if (format === "human") {
|
|
128
|
+
// One-line header so users see what was checked at a glance.
|
|
129
|
+
ctx.stdout.write(`${color.paint("bold", `validate ${wallet}`)} → ${color.paint("dim", serviceUrl.toString())}\n`);
|
|
130
|
+
ctx.stdout.write(`${formatReportHuman(report, color)}\n`);
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
ctx.stdout.write(`${formatReportJson(report)}\n`);
|
|
134
|
+
}
|
|
135
|
+
// ─── Step 5: exit code ─────────────────────────────────────────
|
|
136
|
+
if (report.overallStatus === "would-fail")
|
|
137
|
+
return EXIT_RUNTIME;
|
|
138
|
+
if (report.overallStatus === "uncertain" && opts.strict)
|
|
139
|
+
return EXIT_RUNTIME;
|
|
140
|
+
return EXIT_SUCCESS;
|
|
141
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* X402-21 v0.2 diagnose formatters.
|
|
3
|
+
*
|
|
4
|
+
* The diagnose engine is pure; renderers live here so the engine
|
|
5
|
+
* stays testable without ANSI strings. `format-result.ts` in the CLI
|
|
6
|
+
* layer uses the same colour-aware helpers — we mirror that style so
|
|
7
|
+
* `validate` / `explain` output looks of-a-piece with `proxy` /
|
|
8
|
+
* `inspect`.
|
|
9
|
+
*/
|
|
10
|
+
import type { Colorizer } from "../cli/color.js";
|
|
11
|
+
import type { DiagnosticReport, OverallStatus } from "./types.js";
|
|
12
|
+
/**
|
|
13
|
+
* Human-readable report. One headline + one line per rule. The
|
|
14
|
+
* "fix" line for failures is indented under the rule it belongs to.
|
|
15
|
+
*/
|
|
16
|
+
export declare function formatReportHuman(report: DiagnosticReport, color: Colorizer): string;
|
|
17
|
+
/**
|
|
18
|
+
* Machine-readable report. One JSON object per line plus a final
|
|
19
|
+
* summary line, all newline-delimited so the output is grep- and
|
|
20
|
+
* `jq`-friendly. Matches the JSONL ergonomics of `proxy --log json`.
|
|
21
|
+
*/
|
|
22
|
+
export declare function formatReportJson(report: DiagnosticReport): string;
|
|
23
|
+
export type { OverallStatus };
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* X402-21 v0.2 diagnose formatters.
|
|
3
|
+
*
|
|
4
|
+
* The diagnose engine is pure; renderers live here so the engine
|
|
5
|
+
* stays testable without ANSI strings. `format-result.ts` in the CLI
|
|
6
|
+
* layer uses the same colour-aware helpers — we mirror that style so
|
|
7
|
+
* `validate` / `explain` output looks of-a-piece with `proxy` /
|
|
8
|
+
* `inspect`.
|
|
9
|
+
*/
|
|
10
|
+
const STATUS_MARKERS = {
|
|
11
|
+
pass: "✓",
|
|
12
|
+
fail: "✗",
|
|
13
|
+
skip: "·",
|
|
14
|
+
};
|
|
15
|
+
const STATUS_COLOURS = {
|
|
16
|
+
pass: "green",
|
|
17
|
+
fail: "red",
|
|
18
|
+
skip: "dim",
|
|
19
|
+
};
|
|
20
|
+
const OVERALL_HEADLINE = {
|
|
21
|
+
"would-succeed": "✓ would succeed",
|
|
22
|
+
"would-fail": "✗ would fail",
|
|
23
|
+
uncertain: "⚠ uncertain (key checks skipped)",
|
|
24
|
+
};
|
|
25
|
+
const OVERALL_COLOURS = {
|
|
26
|
+
"would-succeed": "green",
|
|
27
|
+
"would-fail": "red",
|
|
28
|
+
uncertain: "yellow",
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Human-readable report. One headline + one line per rule. The
|
|
32
|
+
* "fix" line for failures is indented under the rule it belongs to.
|
|
33
|
+
*/
|
|
34
|
+
export function formatReportHuman(report, color) {
|
|
35
|
+
const headline = color.paint(OVERALL_COLOURS[report.overallStatus], `${OVERALL_HEADLINE[report.overallStatus]}`);
|
|
36
|
+
const lines = [color.paint("bold", `diagnose: ${headline}`), ""];
|
|
37
|
+
for (const r of report.results) {
|
|
38
|
+
lines.push(` ${color.paint(STATUS_COLOURS[r.status], STATUS_MARKERS[r.status])} ${color.paint("bold", r.rule)}: ${r.message}`);
|
|
39
|
+
if (r.status === "fail" && r.fix) {
|
|
40
|
+
lines.push(` ${color.paint("dim", "fix:")} ${r.fix}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return lines.join("\n");
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Machine-readable report. One JSON object per line plus a final
|
|
47
|
+
* summary line, all newline-delimited so the output is grep- and
|
|
48
|
+
* `jq`-friendly. Matches the JSONL ergonomics of `proxy --log json`.
|
|
49
|
+
*/
|
|
50
|
+
export function formatReportJson(report) {
|
|
51
|
+
const lines = [];
|
|
52
|
+
for (const r of report.results) {
|
|
53
|
+
lines.push(JSON.stringify({ event: "diagnose.result", ...r }));
|
|
54
|
+
}
|
|
55
|
+
lines.push(JSON.stringify({
|
|
56
|
+
event: "diagnose.summary",
|
|
57
|
+
overallStatus: report.overallStatus,
|
|
58
|
+
counts: countByStatus(report.results),
|
|
59
|
+
}));
|
|
60
|
+
return lines.join("\n");
|
|
61
|
+
}
|
|
62
|
+
function countByStatus(results) {
|
|
63
|
+
return {
|
|
64
|
+
pass: results.filter((r) => r.status === "pass").length,
|
|
65
|
+
fail: results.filter((r) => r.status === "fail").length,
|
|
66
|
+
skip: results.filter((r) => r.status === "skip").length,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* X402-21 v0.2 diagnose module — public surface.
|
|
3
|
+
*
|
|
4
|
+
* The `diagnose()` function is the heart: pure, deterministic,
|
|
5
|
+
* dependency-free. CLI subcommands `validate` and `explain` are thin
|
|
6
|
+
* adapters that build a `DiagnosticContext` from their respective
|
|
7
|
+
* inputs (live RPC vs. captured JSONL) and render the resulting
|
|
8
|
+
* `DiagnosticReport`.
|
|
9
|
+
*/
|
|
10
|
+
export { diagnose, ALL_RULES } from "./rules.js";
|
|
11
|
+
export { formatReportHuman, formatReportJson } from "./format.js";
|
|
12
|
+
export type { DiagnosticContext, DiagnosticResult, DiagnosticReport, DiagnosticRule, DiagnosticStatus, OverallStatus, WalletState, } from "./types.js";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* X402-21 v0.2 diagnose module — public surface.
|
|
3
|
+
*
|
|
4
|
+
* The `diagnose()` function is the heart: pure, deterministic,
|
|
5
|
+
* dependency-free. CLI subcommands `validate` and `explain` are thin
|
|
6
|
+
* adapters that build a `DiagnosticContext` from their respective
|
|
7
|
+
* inputs (live RPC vs. captured JSONL) and render the resulting
|
|
8
|
+
* `DiagnosticReport`.
|
|
9
|
+
*/
|
|
10
|
+
export { diagnose, ALL_RULES } from "./rules.js";
|
|
11
|
+
export { formatReportHuman, formatReportJson } from "./format.js";
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* X402-21 v0.2 diagnose rules.
|
|
3
|
+
*
|
|
4
|
+
* Each rule answers one yes/no question about a payment context.
|
|
5
|
+
* Rules are pure — no I/O, no Date.now(), no random. They return
|
|
6
|
+
* `skip` when the context lacks the data they need (which happens
|
|
7
|
+
* routinely: `validate` populates `walletState` but synthesises a
|
|
8
|
+
* `payment`, `explain` has a captured `payment` but no live
|
|
9
|
+
* `walletState`).
|
|
10
|
+
*
|
|
11
|
+
* Adding a rule:
|
|
12
|
+
* 1. Write the function below.
|
|
13
|
+
* 2. Add it to `ALL_RULES` at the bottom.
|
|
14
|
+
* 3. Add a unit test in `tests/unit/diagnose-rules.test.ts`.
|
|
15
|
+
*
|
|
16
|
+
* A rule failing should always include a `fix` field. The fix is what
|
|
17
|
+
* the user reads first when something's broken — it has to be
|
|
18
|
+
* actionable.
|
|
19
|
+
*/
|
|
20
|
+
import type { DiagnosticContext, DiagnosticResult, DiagnosticRule } from "./types.js";
|
|
21
|
+
/**
|
|
22
|
+
* Network mismatch is the most common config-level mistake — signing
|
|
23
|
+
* for `base` instead of `base-sepolia` (or vice versa) fails verify
|
|
24
|
+
* with a generic error that doesn't name the field. We name it loudly.
|
|
25
|
+
*/
|
|
26
|
+
export declare const networkMatchRule: DiagnosticRule;
|
|
27
|
+
/**
|
|
28
|
+
* Scheme mismatch — v0.1/v0.2 only support `exact` EVM per ADR-001.
|
|
29
|
+
* If the requirements ask for a different scheme, that's a service-
|
|
30
|
+
* side mismatch with our tool.
|
|
31
|
+
*/
|
|
32
|
+
export declare const schemeMatchRule: DiagnosticRule;
|
|
33
|
+
/**
|
|
34
|
+
* The recipient in the EIP-3009 authorization MUST match the service's
|
|
35
|
+
* `payTo`. If not, the payment goes to the wrong address. Case-
|
|
36
|
+
* insensitive — EIP-55 addresses can come back from different sources
|
|
37
|
+
* in different casings.
|
|
38
|
+
*/
|
|
39
|
+
export declare const recipientMatchRule: DiagnosticRule;
|
|
40
|
+
/**
|
|
41
|
+
* The signed `value` MUST cover `maxAmountRequired`. The service is
|
|
42
|
+
* free to settle less than requested, but never more than authorized.
|
|
43
|
+
*/
|
|
44
|
+
export declare const valueSufficientRule: DiagnosticRule;
|
|
45
|
+
/**
|
|
46
|
+
* `validBefore` must be in the future at diagnosis time. Expired
|
|
47
|
+
* authorizations are a common failure when the user signs and then
|
|
48
|
+
* the facilitator/network takes too long.
|
|
49
|
+
*/
|
|
50
|
+
export declare const validBeforeRule: DiagnosticRule;
|
|
51
|
+
/**
|
|
52
|
+
* `validAfter` must be in the past at diagnosis time. Rare in practice
|
|
53
|
+
* (most signers set it to 0), but a client signing too far in the
|
|
54
|
+
* future would fail to settle until then.
|
|
55
|
+
*/
|
|
56
|
+
export declare const validAfterRule: DiagnosticRule;
|
|
57
|
+
/**
|
|
58
|
+
* Live wallet balance covers the signed value. This is the big
|
|
59
|
+
* pre-flight check `validate` adds — discovered live in X402-3 when
|
|
60
|
+
* our test wallet was empty.
|
|
61
|
+
*/
|
|
62
|
+
export declare const payerBalanceRule: DiagnosticRule;
|
|
63
|
+
/**
|
|
64
|
+
* EIP-3009 nonces are single-use per `(authorizer, contract)`. A
|
|
65
|
+
* re-used nonce will fail settlement — either because the previous
|
|
66
|
+
* settlement already consumed it, or because the user is replaying.
|
|
67
|
+
*/
|
|
68
|
+
export declare const nonceFreshRule: DiagnosticRule;
|
|
69
|
+
/**
|
|
70
|
+
* Wallet kind affects signature verification. EOAs sign with secp256k1
|
|
71
|
+
* (verifiable by ecrecover); Smart Wallets use ERC-1271 (verifiable
|
|
72
|
+
* by calling the wallet contract); ERC-6492 wraps undeployed-Smart-
|
|
73
|
+
* Wallet signatures. v0.2 supports EOA + Smart Wallet detection;
|
|
74
|
+
* ERC-6492 is a documented v0.3 gap.
|
|
75
|
+
*/
|
|
76
|
+
export declare const walletKindRule: DiagnosticRule;
|
|
77
|
+
/**
|
|
78
|
+
* The asset address in the authorization context must match the
|
|
79
|
+
* service's required asset (USDC on Base Sepolia, `0x036C…CF7e`).
|
|
80
|
+
* v0.1's PaymentPayload doesn't carry the asset directly — it lives
|
|
81
|
+
* in the EIP-712 domain that signers attach implicitly — so this rule
|
|
82
|
+
* cross-checks via the network: a `base-sepolia` payload implicitly
|
|
83
|
+
* targets `0x036C…CF7e`.
|
|
84
|
+
*
|
|
85
|
+
* This is a documentation rule for now; once we capture the EIP-712
|
|
86
|
+
* domain in the payment payload (v0.3) we can do a hard check.
|
|
87
|
+
*/
|
|
88
|
+
export declare const assetAddressRule: DiagnosticRule;
|
|
89
|
+
export declare const ALL_RULES: readonly DiagnosticRule[];
|
|
90
|
+
export declare function diagnose(ctx: DiagnosticContext, rules?: readonly DiagnosticRule[]): {
|
|
91
|
+
results: DiagnosticResult[];
|
|
92
|
+
overallStatus: "would-succeed" | "would-fail" | "uncertain";
|
|
93
|
+
};
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* X402-21 v0.2 diagnose rules.
|
|
3
|
+
*
|
|
4
|
+
* Each rule answers one yes/no question about a payment context.
|
|
5
|
+
* Rules are pure — no I/O, no Date.now(), no random. They return
|
|
6
|
+
* `skip` when the context lacks the data they need (which happens
|
|
7
|
+
* routinely: `validate` populates `walletState` but synthesises a
|
|
8
|
+
* `payment`, `explain` has a captured `payment` but no live
|
|
9
|
+
* `walletState`).
|
|
10
|
+
*
|
|
11
|
+
* Adding a rule:
|
|
12
|
+
* 1. Write the function below.
|
|
13
|
+
* 2. Add it to `ALL_RULES` at the bottom.
|
|
14
|
+
* 3. Add a unit test in `tests/unit/diagnose-rules.test.ts`.
|
|
15
|
+
*
|
|
16
|
+
* A rule failing should always include a `fix` field. The fix is what
|
|
17
|
+
* the user reads first when something's broken — it has to be
|
|
18
|
+
* actionable.
|
|
19
|
+
*/
|
|
20
|
+
function pass(rule, message) {
|
|
21
|
+
return { rule, status: "pass", message };
|
|
22
|
+
}
|
|
23
|
+
function fail(rule, message, fix) {
|
|
24
|
+
return { rule, status: "fail", message, fix };
|
|
25
|
+
}
|
|
26
|
+
function skip(rule, message) {
|
|
27
|
+
return { rule, status: "skip", message };
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Network mismatch is the most common config-level mistake — signing
|
|
31
|
+
* for `base` instead of `base-sepolia` (or vice versa) fails verify
|
|
32
|
+
* with a generic error that doesn't name the field. We name it loudly.
|
|
33
|
+
*/
|
|
34
|
+
export const networkMatchRule = {
|
|
35
|
+
name: "network-match",
|
|
36
|
+
run(ctx) {
|
|
37
|
+
if (!ctx.payment)
|
|
38
|
+
return skip(this.name, "no payment payload to check network against");
|
|
39
|
+
if (ctx.payment.network === ctx.requirements.network) {
|
|
40
|
+
return pass(this.name, `network matches: ${ctx.requirements.network}`);
|
|
41
|
+
}
|
|
42
|
+
return fail(this.name, `payment is for ${ctx.payment.network}, but the service requires ${ctx.requirements.network}`, `re-sign the payment with network='${ctx.requirements.network}'`);
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Scheme mismatch — v0.1/v0.2 only support `exact` EVM per ADR-001.
|
|
47
|
+
* If the requirements ask for a different scheme, that's a service-
|
|
48
|
+
* side mismatch with our tool.
|
|
49
|
+
*/
|
|
50
|
+
export const schemeMatchRule = {
|
|
51
|
+
name: "scheme-match",
|
|
52
|
+
run(ctx) {
|
|
53
|
+
if (!ctx.payment)
|
|
54
|
+
return skip(this.name, "no payment payload to check scheme against");
|
|
55
|
+
if (ctx.payment.scheme === ctx.requirements.scheme) {
|
|
56
|
+
return pass(this.name, `scheme matches: ${ctx.requirements.scheme}`);
|
|
57
|
+
}
|
|
58
|
+
return fail(this.name, `payment uses scheme '${ctx.payment.scheme}', service expects '${ctx.requirements.scheme}'`, `re-sign with the correct scheme; x402trace v0.2 only supports 'exact' EVM`);
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* The recipient in the EIP-3009 authorization MUST match the service's
|
|
63
|
+
* `payTo`. If not, the payment goes to the wrong address. Case-
|
|
64
|
+
* insensitive — EIP-55 addresses can come back from different sources
|
|
65
|
+
* in different casings.
|
|
66
|
+
*/
|
|
67
|
+
export const recipientMatchRule = {
|
|
68
|
+
name: "recipient-match",
|
|
69
|
+
run(ctx) {
|
|
70
|
+
if (!ctx.payment)
|
|
71
|
+
return skip(this.name, "no payment payload");
|
|
72
|
+
const authTo = ctx.payment.payload.authorization.to.toLowerCase();
|
|
73
|
+
const required = ctx.requirements.payTo.toLowerCase();
|
|
74
|
+
if (authTo === required) {
|
|
75
|
+
return pass(this.name, `recipient matches: ${ctx.requirements.payTo}`);
|
|
76
|
+
}
|
|
77
|
+
return fail(this.name, `authorization.to=${ctx.payment.payload.authorization.to}, requirements.payTo=${ctx.requirements.payTo}`, `re-sign with to=${ctx.requirements.payTo}`);
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* The signed `value` MUST cover `maxAmountRequired`. The service is
|
|
82
|
+
* free to settle less than requested, but never more than authorized.
|
|
83
|
+
*/
|
|
84
|
+
export const valueSufficientRule = {
|
|
85
|
+
name: "value-sufficient",
|
|
86
|
+
run(ctx) {
|
|
87
|
+
if (!ctx.payment)
|
|
88
|
+
return skip(this.name, "no payment payload");
|
|
89
|
+
let signed;
|
|
90
|
+
let required;
|
|
91
|
+
try {
|
|
92
|
+
signed = BigInt(ctx.payment.payload.authorization.value);
|
|
93
|
+
required = BigInt(ctx.requirements.maxAmountRequired);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return fail(this.name, `value field is not a valid integer (authorization.value=${ctx.payment.payload.authorization.value}, requirements.maxAmountRequired=${ctx.requirements.maxAmountRequired})`, `both fields must be base-unit integers as strings (no decimals, no 0x prefix)`);
|
|
97
|
+
}
|
|
98
|
+
if (signed >= required) {
|
|
99
|
+
return pass(this.name, `signed ${signed} >= required ${required}`);
|
|
100
|
+
}
|
|
101
|
+
return fail(this.name, `signed value ${signed} is less than required ${required}`, `re-sign with value >= ${required}`);
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* `validBefore` must be in the future at diagnosis time. Expired
|
|
106
|
+
* authorizations are a common failure when the user signs and then
|
|
107
|
+
* the facilitator/network takes too long.
|
|
108
|
+
*/
|
|
109
|
+
export const validBeforeRule = {
|
|
110
|
+
name: "valid-before",
|
|
111
|
+
run(ctx) {
|
|
112
|
+
if (!ctx.payment)
|
|
113
|
+
return skip(this.name, "no payment payload");
|
|
114
|
+
const validBeforeSec = Number(ctx.payment.payload.authorization.validBefore);
|
|
115
|
+
if (!Number.isFinite(validBeforeSec)) {
|
|
116
|
+
return fail(this.name, `validBefore is not a valid Unix timestamp: ${ctx.payment.payload.authorization.validBefore}`, `set validBefore to a Unix timestamp in seconds`);
|
|
117
|
+
}
|
|
118
|
+
const nowSec = Math.floor(ctx.now.getTime() / 1000);
|
|
119
|
+
if (nowSec < validBeforeSec) {
|
|
120
|
+
const remaining = validBeforeSec - nowSec;
|
|
121
|
+
return pass(this.name, `validBefore=${validBeforeSec} is ${remaining}s in the future`);
|
|
122
|
+
}
|
|
123
|
+
const expiredBy = nowSec - validBeforeSec;
|
|
124
|
+
return fail(this.name, `validBefore=${validBeforeSec} expired ${expiredBy}s ago (now=${nowSec})`, `re-sign the authorization with a later validBefore (typical: now + ${ctx.requirements.maxTimeoutSeconds ?? 300}s)`);
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
/**
|
|
128
|
+
* `validAfter` must be in the past at diagnosis time. Rare in practice
|
|
129
|
+
* (most signers set it to 0), but a client signing too far in the
|
|
130
|
+
* future would fail to settle until then.
|
|
131
|
+
*/
|
|
132
|
+
export const validAfterRule = {
|
|
133
|
+
name: "valid-after",
|
|
134
|
+
run(ctx) {
|
|
135
|
+
if (!ctx.payment)
|
|
136
|
+
return skip(this.name, "no payment payload");
|
|
137
|
+
const validAfterSec = Number(ctx.payment.payload.authorization.validAfter);
|
|
138
|
+
if (!Number.isFinite(validAfterSec)) {
|
|
139
|
+
return fail(this.name, `validAfter is not a valid Unix timestamp: ${ctx.payment.payload.authorization.validAfter}`, `set validAfter to a Unix timestamp in seconds (0 is the common default)`);
|
|
140
|
+
}
|
|
141
|
+
const nowSec = Math.floor(ctx.now.getTime() / 1000);
|
|
142
|
+
if (nowSec >= validAfterSec) {
|
|
143
|
+
return pass(this.name, `validAfter=${validAfterSec} (already valid)`);
|
|
144
|
+
}
|
|
145
|
+
const waitSec = validAfterSec - nowSec;
|
|
146
|
+
return fail(this.name, `validAfter=${validAfterSec} is ${waitSec}s in the future`, `re-sign with validAfter <= ${nowSec} (use 0 unless you have a specific reason)`);
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
/**
|
|
150
|
+
* Live wallet balance covers the signed value. This is the big
|
|
151
|
+
* pre-flight check `validate` adds — discovered live in X402-3 when
|
|
152
|
+
* our test wallet was empty.
|
|
153
|
+
*/
|
|
154
|
+
export const payerBalanceRule = {
|
|
155
|
+
name: "payer-balance",
|
|
156
|
+
run(ctx) {
|
|
157
|
+
if (!ctx.payment)
|
|
158
|
+
return skip(this.name, "no payment payload");
|
|
159
|
+
if (ctx.walletState?.usdcBalance === undefined) {
|
|
160
|
+
return skip(this.name, "no on-chain wallet balance in context (run `validate` for live check)");
|
|
161
|
+
}
|
|
162
|
+
let needed;
|
|
163
|
+
try {
|
|
164
|
+
needed = BigInt(ctx.payment.payload.authorization.value);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return skip(this.name, "payment value is not a parsable bigint");
|
|
168
|
+
}
|
|
169
|
+
if (ctx.walletState.usdcBalance >= needed) {
|
|
170
|
+
return pass(this.name, `wallet has ${ctx.walletState.usdcBalance} USDC (raw), needs ${needed}`);
|
|
171
|
+
}
|
|
172
|
+
const shortfall = needed - ctx.walletState.usdcBalance;
|
|
173
|
+
return fail(this.name, `wallet has ${ctx.walletState.usdcBalance} USDC (raw), needs ${needed} — short by ${shortfall}`, `fund the wallet with at least ${shortfall} more USDC (raw units)`);
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
/**
|
|
177
|
+
* EIP-3009 nonces are single-use per `(authorizer, contract)`. A
|
|
178
|
+
* re-used nonce will fail settlement — either because the previous
|
|
179
|
+
* settlement already consumed it, or because the user is replaying.
|
|
180
|
+
*/
|
|
181
|
+
export const nonceFreshRule = {
|
|
182
|
+
name: "nonce-fresh",
|
|
183
|
+
run(ctx) {
|
|
184
|
+
if (!ctx.payment)
|
|
185
|
+
return skip(this.name, "no payment payload");
|
|
186
|
+
if (ctx.walletState?.nonceConsumed === undefined) {
|
|
187
|
+
return skip(this.name, "no on-chain nonce status in context (run `validate` for live check)");
|
|
188
|
+
}
|
|
189
|
+
if (!ctx.walletState.nonceConsumed) {
|
|
190
|
+
return pass(this.name, `nonce ${ctx.payment.payload.authorization.nonce.slice(0, 10)}… is fresh`);
|
|
191
|
+
}
|
|
192
|
+
return fail(this.name, `nonce ${ctx.payment.payload.authorization.nonce} has already been consumed on this USDC contract`, `generate a fresh nonce (32 random bytes) and re-sign`);
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
/**
|
|
196
|
+
* Wallet kind affects signature verification. EOAs sign with secp256k1
|
|
197
|
+
* (verifiable by ecrecover); Smart Wallets use ERC-1271 (verifiable
|
|
198
|
+
* by calling the wallet contract); ERC-6492 wraps undeployed-Smart-
|
|
199
|
+
* Wallet signatures. v0.2 supports EOA + Smart Wallet detection;
|
|
200
|
+
* ERC-6492 is a documented v0.3 gap.
|
|
201
|
+
*/
|
|
202
|
+
export const walletKindRule = {
|
|
203
|
+
name: "wallet-kind",
|
|
204
|
+
run(ctx) {
|
|
205
|
+
if (!ctx.walletState?.walletKind) {
|
|
206
|
+
return skip(this.name, "wallet kind not detected (run `validate` against a live wallet)");
|
|
207
|
+
}
|
|
208
|
+
if (ctx.walletState.walletKind === "eoa" || ctx.walletState.walletKind === "smart-wallet") {
|
|
209
|
+
return pass(this.name, `wallet kind: ${ctx.walletState.walletKind}`);
|
|
210
|
+
}
|
|
211
|
+
return fail(this.name, "wallet kind could not be classified", "v0.2 supports EOA + Smart Wallet only; ERC-6492 / unknown kinds are a v0.3 gap");
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
/**
|
|
215
|
+
* The asset address in the authorization context must match the
|
|
216
|
+
* service's required asset (USDC on Base Sepolia, `0x036C…CF7e`).
|
|
217
|
+
* v0.1's PaymentPayload doesn't carry the asset directly — it lives
|
|
218
|
+
* in the EIP-712 domain that signers attach implicitly — so this rule
|
|
219
|
+
* cross-checks via the network: a `base-sepolia` payload implicitly
|
|
220
|
+
* targets `0x036C…CF7e`.
|
|
221
|
+
*
|
|
222
|
+
* This is a documentation rule for now; once we capture the EIP-712
|
|
223
|
+
* domain in the payment payload (v0.3) we can do a hard check.
|
|
224
|
+
*/
|
|
225
|
+
export const assetAddressRule = {
|
|
226
|
+
name: "asset-address",
|
|
227
|
+
run(ctx) {
|
|
228
|
+
// Best-effort: warn if requirements specify an unexpected asset
|
|
229
|
+
// for base-sepolia. The standard Base Sepolia USDC is well-known.
|
|
230
|
+
const BASE_SEPOLIA_USDC = "0x036cbd53842c5426634e7929541ec2318f3dcf7e";
|
|
231
|
+
if (ctx.requirements.network !== "base-sepolia") {
|
|
232
|
+
return skip(this.name, `network ${ctx.requirements.network} not validated by this rule (v0.2 covers base-sepolia only)`);
|
|
233
|
+
}
|
|
234
|
+
if (ctx.requirements.asset.toLowerCase() === BASE_SEPOLIA_USDC) {
|
|
235
|
+
return pass(this.name, "asset is canonical Base Sepolia USDC");
|
|
236
|
+
}
|
|
237
|
+
return fail(this.name, `requirements.asset=${ctx.requirements.asset}, expected Base Sepolia USDC ${BASE_SEPOLIA_USDC}`, `the service is asking for payment in a non-canonical token; verify with the service operator`);
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
export const ALL_RULES = [
|
|
241
|
+
networkMatchRule,
|
|
242
|
+
schemeMatchRule,
|
|
243
|
+
recipientMatchRule,
|
|
244
|
+
valueSufficientRule,
|
|
245
|
+
validBeforeRule,
|
|
246
|
+
validAfterRule,
|
|
247
|
+
payerBalanceRule,
|
|
248
|
+
nonceFreshRule,
|
|
249
|
+
walletKindRule,
|
|
250
|
+
assetAddressRule,
|
|
251
|
+
];
|
|
252
|
+
/**
|
|
253
|
+
* Apply every rule in `rules` to `ctx`. Returns the per-rule results
|
|
254
|
+
* in declaration order plus an overall status:
|
|
255
|
+
*
|
|
256
|
+
* - `would-succeed`: at least one `pass`, no `fail`s, no critical `skip`s
|
|
257
|
+
* - `would-fail`: at least one `fail`
|
|
258
|
+
* - `uncertain`: no `fail`s but critical checks were skipped (e.g.
|
|
259
|
+
* `validate` couldn't reach the chain, so balance + nonce were
|
|
260
|
+
* skipped — we can't claim the payment would succeed)
|
|
261
|
+
*
|
|
262
|
+
* "Critical" skips are the on-chain-state rules — `payer-balance` and
|
|
263
|
+
* `nonce-fresh`. Pure-static checks skipping just means the rule
|
|
264
|
+
* doesn't apply (e.g. no payment payload to check signature against).
|
|
265
|
+
*/
|
|
266
|
+
const CRITICAL_RULES = new Set(["payer-balance", "nonce-fresh"]);
|
|
267
|
+
export function diagnose(ctx, rules = ALL_RULES) {
|
|
268
|
+
const results = rules.map((r) => r.run(ctx));
|
|
269
|
+
const anyFail = results.some((r) => r.status === "fail");
|
|
270
|
+
if (anyFail)
|
|
271
|
+
return { results, overallStatus: "would-fail" };
|
|
272
|
+
const criticalSkipped = results.some((r) => r.status === "skip" && CRITICAL_RULES.has(r.rule));
|
|
273
|
+
if (criticalSkipped)
|
|
274
|
+
return { results, overallStatus: "uncertain" };
|
|
275
|
+
return { results, overallStatus: "would-succeed" };
|
|
276
|
+
}
|