x402trace 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +71 -0
- package/LICENSE +201 -0
- package/README.md +121 -0
- package/dist/chain/abi.d.ts +48 -0
- package/dist/chain/abi.js +34 -0
- package/dist/chain/client.d.ts +6 -0
- package/dist/chain/client.js +283 -0
- package/dist/chain/index.d.ts +4 -0
- package/dist/chain/index.js +4 -0
- package/dist/chain/retry.d.ts +18 -0
- package/dist/chain/retry.js +37 -0
- package/dist/chain/types.d.ts +94 -0
- package/dist/chain/types.js +12 -0
- package/dist/cli/color.d.ts +30 -0
- package/dist/cli/color.js +26 -0
- package/dist/cli/exit-codes.d.ts +12 -0
- package/dist/cli/exit-codes.js +11 -0
- package/dist/cli/format-result.d.ts +20 -0
- package/dist/cli/format-result.js +58 -0
- package/dist/cli/index.d.ts +26 -0
- package/dist/cli/index.js +130 -0
- package/dist/cli/inspect-command.d.ts +28 -0
- package/dist/cli/inspect-command.js +63 -0
- package/dist/cli/proxy-command.d.ts +74 -0
- package/dist/cli/proxy-command.js +197 -0
- package/dist/cli/replay.d.ts +44 -0
- package/dist/cli/replay.js +193 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +20 -0
- package/dist/decoder/decoder.d.ts +20 -0
- package/dist/decoder/decoder.js +118 -0
- package/dist/decoder/format.d.ts +9 -0
- package/dist/decoder/format.js +50 -0
- package/dist/decoder/index.d.ts +5 -0
- package/dist/decoder/index.js +5 -0
- package/dist/decoder/parse.d.ts +53 -0
- package/dist/decoder/parse.js +179 -0
- package/dist/decoder/redact.d.ts +12 -0
- package/dist/decoder/redact.js +22 -0
- package/dist/decoder/types.d.ts +86 -0
- package/dist/decoder/types.js +9 -0
- package/dist/proxy/event-bus.d.ts +18 -0
- package/dist/proxy/event-bus.js +75 -0
- package/dist/proxy/id.d.ts +6 -0
- package/dist/proxy/id.js +8 -0
- package/dist/proxy/index.d.ts +5 -0
- package/dist/proxy/index.js +5 -0
- package/dist/proxy/jsonl-sink.d.ts +18 -0
- package/dist/proxy/jsonl-sink.js +44 -0
- package/dist/proxy/proxy.d.ts +21 -0
- package/dist/proxy/proxy.js +238 -0
- package/dist/proxy/types.d.ts +95 -0
- package/dist/proxy/types.js +32 -0
- package/dist/reconciliation/engine.d.ts +40 -0
- package/dist/reconciliation/engine.js +178 -0
- package/dist/reconciliation/index.d.ts +3 -0
- package/dist/reconciliation/index.js +3 -0
- package/dist/reconciliation/match.d.ts +31 -0
- package/dist/reconciliation/match.js +39 -0
- package/dist/reconciliation/types.d.ts +72 -0
- package/dist/reconciliation/types.js +17 -0
- package/package.json +85 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline JSONL replay. Reads a log written by `x402trace proxy
|
|
3
|
+
* --reconcile`, reconstructs the four event streams the engine cares
|
|
4
|
+
* about (proxy `exchange.closed`, decoder `exchange.payment`, chain
|
|
5
|
+
* `chain.transfer`), and feeds them into a fresh engine.
|
|
6
|
+
*
|
|
7
|
+
* The engine runs on a *virtual clock* driven by each line's `t`
|
|
8
|
+
* field so the `not_settled` sweep fires at the right virtual moment
|
|
9
|
+
* even though the whole file replays in milliseconds.
|
|
10
|
+
*
|
|
11
|
+
* Per [ARCHITECTURE.md § JSONL record format](../../ARCHITECTURE.md#jsonl-record-format):
|
|
12
|
+
* "the file IS the API." This reader is the consumer side of that
|
|
13
|
+
* contract.
|
|
14
|
+
*/
|
|
15
|
+
import { createReadStream } from "node:fs";
|
|
16
|
+
import { createInterface } from "node:readline";
|
|
17
|
+
import { createReconciliationEngine } from "../reconciliation/engine.js";
|
|
18
|
+
/**
|
|
19
|
+
* Replay the whole file, collect every emitted `ReconciliationResult`.
|
|
20
|
+
*
|
|
21
|
+
* Returns once the file is exhausted and the engine has been ticked
|
|
22
|
+
* once past the final virtual timestamp so any still-pending entry
|
|
23
|
+
* past `watchTimeoutMs` fires `not_settled`.
|
|
24
|
+
*/
|
|
25
|
+
export async function replayLog(opts) {
|
|
26
|
+
let virtualNow = 0;
|
|
27
|
+
const engine = createReconciliationEngine({
|
|
28
|
+
watchTimeoutMs: opts.watchTimeoutMs ?? 60_000,
|
|
29
|
+
// setInterval clamps at int32 max (~24.8d); pick a value well
|
|
30
|
+
// inside that window. Replay drives the sweep manually via `tick()`
|
|
31
|
+
// so the timer should never actually fire during a replay run.
|
|
32
|
+
sweepIntervalMs: 2_147_483_647,
|
|
33
|
+
now: () => virtualNow,
|
|
34
|
+
});
|
|
35
|
+
const results = [];
|
|
36
|
+
const resultsSub = engine.results();
|
|
37
|
+
const resultsTask = (async () => {
|
|
38
|
+
for await (const r of resultsSub)
|
|
39
|
+
results.push(r);
|
|
40
|
+
})();
|
|
41
|
+
let linesTotal = 0;
|
|
42
|
+
let linesSkipped = 0;
|
|
43
|
+
let proxyClosed = 0;
|
|
44
|
+
let decoderPayments = 0;
|
|
45
|
+
let chainTransfers = 0;
|
|
46
|
+
const stream = createReadStream(opts.logPath, { encoding: "utf8" });
|
|
47
|
+
const rl = createInterface({ input: stream, crlfDelay: Infinity });
|
|
48
|
+
for await (const rawLine of rl) {
|
|
49
|
+
linesTotal++;
|
|
50
|
+
const line = rawLine.trim();
|
|
51
|
+
if (!line) {
|
|
52
|
+
linesSkipped++;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
let record;
|
|
56
|
+
try {
|
|
57
|
+
record = JSON.parse(line);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
linesSkipped++;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (typeof record.event !== "string" || typeof record.t !== "string") {
|
|
64
|
+
linesSkipped++;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const ts = Date.parse(record.t);
|
|
68
|
+
if (Number.isFinite(ts)) {
|
|
69
|
+
// Advance virtual time monotonically. If lines are out of order
|
|
70
|
+
// we stay at the highest seen — never rewind.
|
|
71
|
+
if (ts > virtualNow)
|
|
72
|
+
virtualNow = ts;
|
|
73
|
+
engine.tick();
|
|
74
|
+
}
|
|
75
|
+
switch (record.event) {
|
|
76
|
+
case "exchange.closed": {
|
|
77
|
+
engine.ingestProxyEvent(record);
|
|
78
|
+
proxyClosed++;
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
case "exchange.payment": {
|
|
82
|
+
engine.ingestDecoderEvent(record);
|
|
83
|
+
decoderPayments++;
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
case "chain.transfer": {
|
|
87
|
+
const transfer = reviveChainTransfer(record);
|
|
88
|
+
if (transfer) {
|
|
89
|
+
engine.ingestChainTransfer(transfer);
|
|
90
|
+
chainTransfers++;
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
linesSkipped++;
|
|
94
|
+
}
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
default: {
|
|
98
|
+
// exchange.opened / exchange.challenge / exchange.settlement /
|
|
99
|
+
// reconcile.result / proxy.error / decoder.error: not needed
|
|
100
|
+
// for offline reconciliation (the engine joins on closed +
|
|
101
|
+
// payment only). Don't count these as skipped errors.
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// Drive the sweep one last time at the final timestamp + slack so
|
|
107
|
+
// anything still pending past the watch window fires `not_settled`.
|
|
108
|
+
virtualNow += (opts.watchTimeoutMs ?? 60_000) + 1;
|
|
109
|
+
engine.tick();
|
|
110
|
+
await engine.close();
|
|
111
|
+
resultsSub.unsubscribe();
|
|
112
|
+
await resultsTask;
|
|
113
|
+
return {
|
|
114
|
+
counts: {
|
|
115
|
+
linesTotal,
|
|
116
|
+
linesSkipped,
|
|
117
|
+
proxyClosed,
|
|
118
|
+
decoderPayments,
|
|
119
|
+
chainTransfers,
|
|
120
|
+
results: results.length,
|
|
121
|
+
},
|
|
122
|
+
results,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* `chain.transfer` lines store `bigint` fields as strings (JSON has no
|
|
127
|
+
* bigint). Resurrect them so the engine's match function sees real
|
|
128
|
+
* `bigint`s.
|
|
129
|
+
*/
|
|
130
|
+
function reviveChainTransfer(record) {
|
|
131
|
+
const txHash = record.txHash;
|
|
132
|
+
const from = record.from;
|
|
133
|
+
const to = record.to;
|
|
134
|
+
const tokenAddress = record.tokenAddress;
|
|
135
|
+
if (typeof txHash !== "string" ||
|
|
136
|
+
typeof from !== "string" ||
|
|
137
|
+
typeof to !== "string" ||
|
|
138
|
+
typeof tokenAddress !== "string") {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
const blockNumber = toBigint(record.blockNumber);
|
|
142
|
+
const value = toBigint(record.value);
|
|
143
|
+
if (blockNumber === null || value === null)
|
|
144
|
+
return null;
|
|
145
|
+
const blockTimestamp = typeof record.blockTimestamp === "number"
|
|
146
|
+
? record.blockTimestamp
|
|
147
|
+
: Number(record.blockTimestamp);
|
|
148
|
+
if (!Number.isFinite(blockTimestamp))
|
|
149
|
+
return null;
|
|
150
|
+
const nonce = typeof record.authorizationNonce === "string"
|
|
151
|
+
? record.authorizationNonce
|
|
152
|
+
: undefined;
|
|
153
|
+
return {
|
|
154
|
+
txHash: txHash,
|
|
155
|
+
blockNumber,
|
|
156
|
+
blockTimestamp,
|
|
157
|
+
from: from,
|
|
158
|
+
to: to,
|
|
159
|
+
value,
|
|
160
|
+
tokenAddress: tokenAddress,
|
|
161
|
+
...(nonce ? { authorizationNonce: nonce } : {}),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
function toBigint(value) {
|
|
165
|
+
if (typeof value === "bigint")
|
|
166
|
+
return value;
|
|
167
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
168
|
+
return BigInt(value);
|
|
169
|
+
if (typeof value === "string" && /^-?\d+$/.test(value)) {
|
|
170
|
+
try {
|
|
171
|
+
return BigInt(value);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
/** Test seam: serialise a ChainTransfer to its JSONL-stored shape. */
|
|
180
|
+
export function chainTransferToJsonl(transfer, t = new Date().toISOString()) {
|
|
181
|
+
return {
|
|
182
|
+
event: "chain.transfer",
|
|
183
|
+
t,
|
|
184
|
+
txHash: transfer.txHash,
|
|
185
|
+
blockNumber: transfer.blockNumber.toString(),
|
|
186
|
+
blockTimestamp: transfer.blockTimestamp,
|
|
187
|
+
from: transfer.from,
|
|
188
|
+
to: transfer.to,
|
|
189
|
+
value: transfer.value.toString(),
|
|
190
|
+
tokenAddress: transfer.tokenAddress,
|
|
191
|
+
...(transfer.authorizationNonce ? { authorizationNonce: transfer.authorizationNonce } : {}),
|
|
192
|
+
};
|
|
193
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* X402-14 binary entry. `package.json` `bin.x402trace` points at
|
|
4
|
+
* `dist/cli.js` (the compiled form of this file). Pure shim: wire real
|
|
5
|
+
* `process` handles to `runCli` and translate its return value to
|
|
6
|
+
* `process.exit`.
|
|
7
|
+
*
|
|
8
|
+
* Keep this file dependency-light — actual command logic lives under
|
|
9
|
+
* `src/cli/`. The reason for the split is that `runCli` is testable in
|
|
10
|
+
* isolation (with injected stdout/stderr/env/waitForShutdown) while
|
|
11
|
+
* `process.exit` cannot be safely called from a vitest worker.
|
|
12
|
+
*/
|
|
13
|
+
import { runCli } from "./cli/index.js";
|
|
14
|
+
void runCli(process.argv.slice(2), {
|
|
15
|
+
stdout: process.stdout,
|
|
16
|
+
stderr: process.stderr,
|
|
17
|
+
env: process.env,
|
|
18
|
+
}).then((code) => {
|
|
19
|
+
process.exit(code);
|
|
20
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streaming decoder. Consumes raw `ProxyEvent`s from the proxy and emits
|
|
3
|
+
* structured `DecodedEvent`s. Pure-function-ish: holds no I/O state,
|
|
4
|
+
* just a small in-memory map of partially-known exchanges so it can
|
|
5
|
+
* correlate header data across the open/close pair.
|
|
6
|
+
*/
|
|
7
|
+
import type { ProxyEvent } from "../proxy/types.js";
|
|
8
|
+
import type { DecodedEvent } from "./types.js";
|
|
9
|
+
export interface DecoderOptions {
|
|
10
|
+
/** When false (default), signatures in payment payloads are replaced with "[REDACTED]". */
|
|
11
|
+
readonly logSecrets?: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface Decoder {
|
|
14
|
+
/**
|
|
15
|
+
* Decode a single raw proxy event. Returns 0–N structured events.
|
|
16
|
+
* Caller is responsible for sinking them (event bus, JSONL, stdout).
|
|
17
|
+
*/
|
|
18
|
+
decode(event: ProxyEvent): DecodedEvent[];
|
|
19
|
+
}
|
|
20
|
+
export declare function createDecoder(opts?: DecoderOptions): Decoder;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { detectVersion, findPaymentHeader, findSettlementHeader, parseChallengeBody, parsePaymentHeader, parseSettlementHeader, } from "./parse.js";
|
|
2
|
+
import { redactPaymentPayload } from "./redact.js";
|
|
3
|
+
export function createDecoder(opts = {}) {
|
|
4
|
+
const logSecrets = opts.logSecrets ?? false;
|
|
5
|
+
return {
|
|
6
|
+
decode(event) {
|
|
7
|
+
const out = [];
|
|
8
|
+
if (event.event === "exchange.opened") {
|
|
9
|
+
// Look for a v1 X-PAYMENT or v2 PAYMENT-SIGNATURE in the request headers.
|
|
10
|
+
const hit = findPaymentHeader(event.request.headers);
|
|
11
|
+
if (hit) {
|
|
12
|
+
const parsed = parsePaymentHeader(hit.value, hit.version);
|
|
13
|
+
if (parsed.ok) {
|
|
14
|
+
out.push({
|
|
15
|
+
event: "exchange.payment",
|
|
16
|
+
t: event.t,
|
|
17
|
+
id: event.id,
|
|
18
|
+
x402Version: hit.version,
|
|
19
|
+
payment: redactPaymentPayload(parsed.value, logSecrets),
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
out.push({
|
|
24
|
+
event: "decoder.error",
|
|
25
|
+
t: event.t,
|
|
26
|
+
id: event.id,
|
|
27
|
+
stage: "payment",
|
|
28
|
+
message: parsed.message,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
if (event.event === "exchange.closed") {
|
|
35
|
+
// 402: parse the response body as a challenge.
|
|
36
|
+
if (event.response.status === 402 && event.response.body !== undefined) {
|
|
37
|
+
const parsed = parseChallengeBody(event.response.body);
|
|
38
|
+
if (parsed.ok) {
|
|
39
|
+
out.push({
|
|
40
|
+
event: "exchange.challenge",
|
|
41
|
+
t: event.t,
|
|
42
|
+
id: event.id,
|
|
43
|
+
x402Version: parsed.value.x402Version,
|
|
44
|
+
challenge: parsed.value.requirements,
|
|
45
|
+
...(parsed.value.error ? { raw402Error: parsed.value.error } : {}),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
out.push({
|
|
50
|
+
event: "decoder.error",
|
|
51
|
+
t: event.t,
|
|
52
|
+
id: event.id,
|
|
53
|
+
stage: "challenge",
|
|
54
|
+
message: parsed.message,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// Settlement: look for X-PAYMENT-RESPONSE / PAYMENT-RESPONSE header.
|
|
59
|
+
const settlementHit = findSettlementHeader(event.response.headers);
|
|
60
|
+
if (settlementHit) {
|
|
61
|
+
const parsed = parseSettlementHeader(settlementHit.value);
|
|
62
|
+
if (parsed.ok) {
|
|
63
|
+
out.push({
|
|
64
|
+
event: "exchange.settlement",
|
|
65
|
+
t: event.t,
|
|
66
|
+
id: event.id,
|
|
67
|
+
settlement: parsed.value,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
out.push({
|
|
72
|
+
event: "decoder.error",
|
|
73
|
+
t: event.t,
|
|
74
|
+
id: event.id,
|
|
75
|
+
stage: "settlement",
|
|
76
|
+
message: parsed.message,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
else if (event.outcome.kind === "paid" &&
|
|
81
|
+
"rawPaymentResponseHeader" in event.outcome &&
|
|
82
|
+
event.outcome.rawPaymentResponseHeader) {
|
|
83
|
+
// Proxy already extracted the header; decode from there as a fallback.
|
|
84
|
+
const parsed = parseSettlementHeader(event.outcome.rawPaymentResponseHeader);
|
|
85
|
+
if (parsed.ok) {
|
|
86
|
+
out.push({
|
|
87
|
+
event: "exchange.settlement",
|
|
88
|
+
t: event.t,
|
|
89
|
+
id: event.id,
|
|
90
|
+
settlement: parsed.value,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Side note: the proxy already classified outcome via `event.outcome`
|
|
95
|
+
// (paid | rejected | upstream_timeout | unknown). The decoder
|
|
96
|
+
// doesn't re-emit it — downstream tools can read either signal.
|
|
97
|
+
// Use detectVersion as a soft-validation hint; not directly emitted
|
|
98
|
+
// but useful in future extensions.
|
|
99
|
+
void detectVersion(event.response.headers);
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
if (event.event === "proxy.error") {
|
|
103
|
+
// Pass through as a decoder error; the proxy already wrote its
|
|
104
|
+
// own proxy.error event to JSONL, so this is just for live
|
|
105
|
+
// subscribers that only listen on the decoder bus.
|
|
106
|
+
out.push({
|
|
107
|
+
event: "decoder.error",
|
|
108
|
+
t: event.t,
|
|
109
|
+
...(event.id ? { id: event.id } : {}),
|
|
110
|
+
stage: "payment",
|
|
111
|
+
message: `upstream/proxy error: ${event.message}`,
|
|
112
|
+
});
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { DecodedEvent } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Human-readable single-line summary of a decoded event. Designed for
|
|
4
|
+
* `pnpm proxy` / `x402trace proxy` stdout where compactness matters.
|
|
5
|
+
* Full structure goes to JSONL.
|
|
6
|
+
*/
|
|
7
|
+
export declare function formatHuman(event: DecodedEvent): string;
|
|
8
|
+
/** JSON-line representation. Pure JSON.stringify with stable key order via field-by-field copy. */
|
|
9
|
+
export declare function formatJson(event: DecodedEvent): string;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Human-readable single-line summary of a decoded event. Designed for
|
|
3
|
+
* `pnpm proxy` / `x402trace proxy` stdout where compactness matters.
|
|
4
|
+
* Full structure goes to JSONL.
|
|
5
|
+
*/
|
|
6
|
+
export function formatHuman(event) {
|
|
7
|
+
const stamp = `[${event.t}]`;
|
|
8
|
+
switch (event.event) {
|
|
9
|
+
case "exchange.challenge": {
|
|
10
|
+
const r = event.challenge;
|
|
11
|
+
const amt = r.maxAmountRequired;
|
|
12
|
+
const errSuffix = event.raw402Error ? ` (error=${event.raw402Error})` : "";
|
|
13
|
+
return `${stamp} 402 challenge id=${short(event.id)} v${event.x402Version} ${r.scheme}/${r.network} amount=${amt} payTo=${shortAddr(r.payTo)} asset=${shortAddr(r.asset)}${errSuffix}`;
|
|
14
|
+
}
|
|
15
|
+
case "exchange.payment": {
|
|
16
|
+
const auth = event.payment.payload.authorization;
|
|
17
|
+
return `${stamp} X-PAYMENT id=${short(event.id)} v${event.x402Version} from=${shortAddr(auth.from)} → to=${shortAddr(auth.to)} value=${auth.value} nonce=${short(auth.nonce)} validBefore=${auth.validBefore}`;
|
|
18
|
+
}
|
|
19
|
+
case "exchange.settlement": {
|
|
20
|
+
const s = event.settlement;
|
|
21
|
+
const status = s.success === true
|
|
22
|
+
? "✓ success"
|
|
23
|
+
: s.isValid === false
|
|
24
|
+
? `✗ invalid: ${s.invalidReason ?? "?"}`
|
|
25
|
+
: s.errorReason
|
|
26
|
+
? `✗ error: ${s.errorReason}`
|
|
27
|
+
: "?";
|
|
28
|
+
const tx = s.transaction ? ` tx=${short(s.transaction)}` : "";
|
|
29
|
+
return `${stamp} settlement id=${short(event.id)} ${status}${tx}`;
|
|
30
|
+
}
|
|
31
|
+
case "decoder.error": {
|
|
32
|
+
const idStr = event.id ? ` id=${short(event.id)}` : "";
|
|
33
|
+
return `${stamp} decoder.error stage=${event.stage}${idStr} message="${event.message}"`;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** JSON-line representation. Pure JSON.stringify with stable key order via field-by-field copy. */
|
|
38
|
+
export function formatJson(event) {
|
|
39
|
+
return JSON.stringify(event);
|
|
40
|
+
}
|
|
41
|
+
function short(value, head = 8) {
|
|
42
|
+
if (value.length <= head + 4)
|
|
43
|
+
return value;
|
|
44
|
+
return `${value.slice(0, head)}…`;
|
|
45
|
+
}
|
|
46
|
+
function shortAddr(value) {
|
|
47
|
+
if (!value.startsWith("0x") || value.length < 12)
|
|
48
|
+
return value;
|
|
49
|
+
return `${value.slice(0, 6)}…${value.slice(-4)}`;
|
|
50
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { createDecoder, type Decoder, type DecoderOptions } from "./decoder.js";
|
|
2
|
+
export { formatHuman, formatJson } from "./format.js";
|
|
3
|
+
export { redactPaymentPayload } from "./redact.js";
|
|
4
|
+
export { detectVersion, findPaymentHeader, findSettlementHeader, parseChallengeBody, parsePaymentHeader, parseSettlementHeader, type ParseResult, } from "./parse.js";
|
|
5
|
+
export { type DecodedEvent, type FacilitatorResponse, type LogFormat, type PaymentAuthorization, type PaymentPayload, type PaymentRequirements, type X402Version, } from "./types.js";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { createDecoder } from "./decoder.js";
|
|
2
|
+
export { formatHuman, formatJson } from "./format.js";
|
|
3
|
+
export { redactPaymentPayload } from "./redact.js";
|
|
4
|
+
export { detectVersion, findPaymentHeader, findSettlementHeader, parseChallengeBody, parsePaymentHeader, parseSettlementHeader, } from "./parse.js";
|
|
5
|
+
export {} from "./types.js";
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { FacilitatorResponse, PaymentPayload, PaymentRequirements, X402Version } from "./types.js";
|
|
2
|
+
interface ParseError {
|
|
3
|
+
readonly ok: false;
|
|
4
|
+
readonly message: string;
|
|
5
|
+
}
|
|
6
|
+
interface ParseOk<T> {
|
|
7
|
+
readonly ok: true;
|
|
8
|
+
readonly value: T;
|
|
9
|
+
}
|
|
10
|
+
export type ParseResult<T> = ParseOk<T> | ParseError;
|
|
11
|
+
/**
|
|
12
|
+
* Detect the x402 protocol version from headers + body. Returns 1
|
|
13
|
+
* (default) when no clear v2 marker is present.
|
|
14
|
+
*/
|
|
15
|
+
export declare function detectVersion(headers: Record<string, string>, body?: unknown): X402Version;
|
|
16
|
+
/**
|
|
17
|
+
* Find the (request) payment header value, preferring v1 then v2.
|
|
18
|
+
*/
|
|
19
|
+
export declare function findPaymentHeader(headers: Record<string, string>): {
|
|
20
|
+
name: string;
|
|
21
|
+
value: string;
|
|
22
|
+
version: X402Version;
|
|
23
|
+
} | null;
|
|
24
|
+
/**
|
|
25
|
+
* Find the (response) settlement header value.
|
|
26
|
+
*/
|
|
27
|
+
export declare function findSettlementHeader(headers: Record<string, string>): {
|
|
28
|
+
name: string;
|
|
29
|
+
value: string;
|
|
30
|
+
version: X402Version;
|
|
31
|
+
} | null;
|
|
32
|
+
/**
|
|
33
|
+
* Parse a 402 response body (raw text) into a v1 / v2 challenge.
|
|
34
|
+
* Returns the first `accepts[]` entry — v0.1 picks the head; downstream
|
|
35
|
+
* tools can extend.
|
|
36
|
+
*/
|
|
37
|
+
export declare function parseChallengeBody(rawBody: string): ParseResult<{
|
|
38
|
+
requirements: PaymentRequirements;
|
|
39
|
+
x402Version: X402Version;
|
|
40
|
+
error?: string;
|
|
41
|
+
}>;
|
|
42
|
+
/**
|
|
43
|
+
* Parse a base64-encoded X-PAYMENT / PAYMENT-SIGNATURE header value
|
|
44
|
+
* into a typed `PaymentPayload`. Uses the x402 SDK for v1 fast-path
|
|
45
|
+
* and falls back to a hand-rolled shape check for v2.
|
|
46
|
+
*/
|
|
47
|
+
export declare function parsePaymentHeader(headerValue: string, version: X402Version): ParseResult<PaymentPayload>;
|
|
48
|
+
/**
|
|
49
|
+
* Parse a base64-encoded X-PAYMENT-RESPONSE / PAYMENT-RESPONSE header
|
|
50
|
+
* value into a `FacilitatorResponse`.
|
|
51
|
+
*/
|
|
52
|
+
export declare function parseSettlementHeader(headerValue: string): ParseResult<FacilitatorResponse>;
|
|
53
|
+
export {};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure parsers from raw HTTP signal → typed x402 structures.
|
|
3
|
+
*
|
|
4
|
+
* Per CLAUDE.md hard rule 4 ("Don't reproduce code from x402 SDKs.
|
|
5
|
+
* Use them as dependencies."), we use the `x402` package's
|
|
6
|
+
* `exact.evm.decodePayment` for the v1 X-PAYMENT header. The v2
|
|
7
|
+
* `PAYMENT-SIGNATURE` header is base64-encoded JSON of a similar
|
|
8
|
+
* shape; the SDK's v1-only decoder rejects it, so we add a
|
|
9
|
+
* minimal forward-compatible parser inline. v0.1 produces the same
|
|
10
|
+
* normalized `PaymentPayload` shape for both surfaces.
|
|
11
|
+
*/
|
|
12
|
+
import { exact } from "x402/schemes";
|
|
13
|
+
const PAYMENT_HEADER_V1 = "x-payment";
|
|
14
|
+
const PAYMENT_HEADER_V2 = "payment-signature";
|
|
15
|
+
const SETTLEMENT_HEADER_V1 = "x-payment-response";
|
|
16
|
+
const SETTLEMENT_HEADER_V2 = "payment-response";
|
|
17
|
+
/**
|
|
18
|
+
* Detect the x402 protocol version from headers + body. Returns 1
|
|
19
|
+
* (default) when no clear v2 marker is present.
|
|
20
|
+
*/
|
|
21
|
+
export function detectVersion(headers, body) {
|
|
22
|
+
if (headers[PAYMENT_HEADER_V2] || headers[SETTLEMENT_HEADER_V2])
|
|
23
|
+
return 2;
|
|
24
|
+
if (body &&
|
|
25
|
+
typeof body === "object" &&
|
|
26
|
+
"x402Version" in body &&
|
|
27
|
+
body.x402Version === 2) {
|
|
28
|
+
return 2;
|
|
29
|
+
}
|
|
30
|
+
return 1;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Find the (request) payment header value, preferring v1 then v2.
|
|
34
|
+
*/
|
|
35
|
+
export function findPaymentHeader(headers) {
|
|
36
|
+
if (headers[PAYMENT_HEADER_V1]) {
|
|
37
|
+
return { name: PAYMENT_HEADER_V1, value: headers[PAYMENT_HEADER_V1], version: 1 };
|
|
38
|
+
}
|
|
39
|
+
if (headers[PAYMENT_HEADER_V2]) {
|
|
40
|
+
return { name: PAYMENT_HEADER_V2, value: headers[PAYMENT_HEADER_V2], version: 2 };
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Find the (response) settlement header value.
|
|
46
|
+
*/
|
|
47
|
+
export function findSettlementHeader(headers) {
|
|
48
|
+
if (headers[SETTLEMENT_HEADER_V1]) {
|
|
49
|
+
return { name: SETTLEMENT_HEADER_V1, value: headers[SETTLEMENT_HEADER_V1], version: 1 };
|
|
50
|
+
}
|
|
51
|
+
if (headers[SETTLEMENT_HEADER_V2]) {
|
|
52
|
+
return { name: SETTLEMENT_HEADER_V2, value: headers[SETTLEMENT_HEADER_V2], version: 2 };
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Parse a 402 response body (raw text) into a v1 / v2 challenge.
|
|
58
|
+
* Returns the first `accepts[]` entry — v0.1 picks the head; downstream
|
|
59
|
+
* tools can extend.
|
|
60
|
+
*/
|
|
61
|
+
export function parseChallengeBody(rawBody) {
|
|
62
|
+
let parsed;
|
|
63
|
+
try {
|
|
64
|
+
parsed = JSON.parse(rawBody);
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
return { ok: false, message: `challenge body is not JSON: ${err.message}` };
|
|
68
|
+
}
|
|
69
|
+
if (!parsed || typeof parsed !== "object") {
|
|
70
|
+
return { ok: false, message: "challenge body is not an object" };
|
|
71
|
+
}
|
|
72
|
+
const obj = parsed;
|
|
73
|
+
const x402Version = obj.x402Version === 2 ? 2 : 1;
|
|
74
|
+
if (!Array.isArray(obj.accepts) || obj.accepts.length === 0) {
|
|
75
|
+
return { ok: false, message: "challenge body missing accepts[]" };
|
|
76
|
+
}
|
|
77
|
+
const first = obj.accepts[0];
|
|
78
|
+
if (!first || typeof first !== "object") {
|
|
79
|
+
return { ok: false, message: "challenge accepts[0] is not an object" };
|
|
80
|
+
}
|
|
81
|
+
const r = first;
|
|
82
|
+
for (const k of ["scheme", "network", "maxAmountRequired", "resource", "payTo", "asset"]) {
|
|
83
|
+
if (typeof r[k] !== "string") {
|
|
84
|
+
return { ok: false, message: `challenge accepts[0].${k} missing or wrong type` };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
ok: true,
|
|
89
|
+
value: {
|
|
90
|
+
requirements: first,
|
|
91
|
+
x402Version,
|
|
92
|
+
...(obj.error ? { error: obj.error } : {}),
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function decodeBase64Json(value) {
|
|
97
|
+
let decoded;
|
|
98
|
+
try {
|
|
99
|
+
decoded = Buffer.from(value, "base64").toString("utf8");
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
return { ok: false, message: `not valid base64: ${err.message}` };
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
return { ok: true, value: JSON.parse(decoded) };
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
return { ok: false, message: `base64 decode is not JSON: ${err.message}` };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function shapeCheckPaymentPayload(parsed) {
|
|
112
|
+
if (!parsed || typeof parsed !== "object") {
|
|
113
|
+
return { ok: false, message: "payment payload is not an object" };
|
|
114
|
+
}
|
|
115
|
+
const obj = parsed;
|
|
116
|
+
if (obj.scheme !== "exact" && typeof obj.scheme !== "string") {
|
|
117
|
+
return { ok: false, message: "payment payload missing scheme" };
|
|
118
|
+
}
|
|
119
|
+
if (typeof obj.network !== "string") {
|
|
120
|
+
return { ok: false, message: "payment payload missing network" };
|
|
121
|
+
}
|
|
122
|
+
const payload = obj.payload;
|
|
123
|
+
if (!payload || typeof payload !== "object") {
|
|
124
|
+
return { ok: false, message: "payment payload.payload missing" };
|
|
125
|
+
}
|
|
126
|
+
if (typeof payload.signature !== "string") {
|
|
127
|
+
return { ok: false, message: "payment payload.signature missing" };
|
|
128
|
+
}
|
|
129
|
+
const auth = payload.authorization;
|
|
130
|
+
if (!auth || typeof auth !== "object") {
|
|
131
|
+
return { ok: false, message: "payment payload.authorization missing" };
|
|
132
|
+
}
|
|
133
|
+
for (const k of ["from", "to", "value", "validAfter", "validBefore", "nonce"]) {
|
|
134
|
+
if (typeof auth[k] !== "string") {
|
|
135
|
+
return { ok: false, message: `payment payload.authorization.${k} missing` };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return { ok: true, value: parsed };
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Parse a base64-encoded X-PAYMENT / PAYMENT-SIGNATURE header value
|
|
142
|
+
* into a typed `PaymentPayload`. Uses the x402 SDK for v1 fast-path
|
|
143
|
+
* and falls back to a hand-rolled shape check for v2.
|
|
144
|
+
*/
|
|
145
|
+
export function parsePaymentHeader(headerValue, version) {
|
|
146
|
+
if (version === 1) {
|
|
147
|
+
try {
|
|
148
|
+
const decoded = exact.evm.decodePayment(headerValue);
|
|
149
|
+
return shapeCheckPaymentPayload(decoded);
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
// Fall through to manual decode — some captured fixtures may not be
|
|
153
|
+
// perfectly schema-conforming and we still want to surface what we can.
|
|
154
|
+
const fallback = decodeBase64Json(headerValue);
|
|
155
|
+
if (!fallback.ok) {
|
|
156
|
+
return { ok: false, message: `v1 SDK decode failed: ${err.message}` };
|
|
157
|
+
}
|
|
158
|
+
return shapeCheckPaymentPayload(fallback.value);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// v2: hand-rolled.
|
|
162
|
+
const decoded = decodeBase64Json(headerValue);
|
|
163
|
+
if (!decoded.ok)
|
|
164
|
+
return decoded;
|
|
165
|
+
return shapeCheckPaymentPayload(decoded.value);
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Parse a base64-encoded X-PAYMENT-RESPONSE / PAYMENT-RESPONSE header
|
|
169
|
+
* value into a `FacilitatorResponse`.
|
|
170
|
+
*/
|
|
171
|
+
export function parseSettlementHeader(headerValue) {
|
|
172
|
+
const decoded = decodeBase64Json(headerValue);
|
|
173
|
+
if (!decoded.ok)
|
|
174
|
+
return decoded;
|
|
175
|
+
if (!decoded.value || typeof decoded.value !== "object") {
|
|
176
|
+
return { ok: false, message: "settlement payload is not an object" };
|
|
177
|
+
}
|
|
178
|
+
return { ok: true, value: decoded.value };
|
|
179
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { PaymentPayload } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Redact the EIP-3009 signature from a payment payload.
|
|
4
|
+
*
|
|
5
|
+
* Per the X402-11 ticket: "Redact private keys / signatures by default;
|
|
6
|
+
* expose with --log-secrets flag." The signature itself is not strictly
|
|
7
|
+
* a secret (it's broadcast on-chain in the eventual settlement tx), but
|
|
8
|
+
* v0.1 redacts it by default to avoid surprising users who pipe their
|
|
9
|
+
* JSONL into pastebins / share with teammates. Operators who want the
|
|
10
|
+
* full signature flip the flag explicitly.
|
|
11
|
+
*/
|
|
12
|
+
export declare function redactPaymentPayload(payment: PaymentPayload, logSecrets: boolean): PaymentPayload;
|