@parallel-protocol/x402-fetch 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Parallel Protocol
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,101 @@
1
+ # @parallel-protocol/x402-fetch
2
+
3
+ Payer-side x402 client for Parallel Protocol — the counterpart of the
4
+ [`@parallel-protocol/x402`](https://github.com/parallel-protocol/sdk-merchant/tree/main/packages/x402)
5
+ merchant middleware. It wraps `fetch` so that an x402 `402 Payment Required`
6
+ challenge is paid automatically and the request is retried with the payment
7
+ attached.
8
+
9
+ The agent only needs a funded wallet. No payment plumbing, no protocol
10
+ knowledge, no other setup.
11
+
12
+ ## Install
13
+
14
+ ```bash
15
+ npm install @parallel-protocol/x402-fetch
16
+ # or
17
+ bun add @parallel-protocol/x402-fetch
18
+ ```
19
+
20
+ ## Usage
21
+
22
+ ```ts
23
+ import { wrapFetchWithPayment } from "@parallel-protocol/x402-fetch";
24
+
25
+ const payFetch = wrapFetchWithPayment(fetch); // key from PARALLEL_PRIVATE_KEY
26
+
27
+ const res = await payFetch("https://api.example.com/premium-data");
28
+ const data = await res.json(); // paid for and delivered
29
+ ```
30
+
31
+ ## How it works
32
+
33
+ 1. The wrapped fetch performs the request; anything but a `402` passes through
34
+ untouched.
35
+ 2. On `402`, it decodes and validates the merchant's invoice
36
+ (`payment-required` header), picks the entry matching the configured chain
37
+ (preferring your `payWith` token when offered), and enforces the spend cap.
38
+ 3. It builds and signs the payment **locally** through the
39
+ [`@parallel-protocol/cli`](https://www.npmjs.com/package/@parallel-protocol/cli)
40
+ engine (a regular dependency), which selects the best route — direct
41
+ transfer, Parallelizer swap, savings deposit/redeem — from the wallet's
42
+ balances.
43
+ 4. It retries the request with the `X-PAYMENT` header; the merchant verifies
44
+ and settles through the Parallel facilitator.
45
+
46
+ Gas is sponsored by the facilitator — the wallet needs no ETH, only the
47
+ stablecoin it pays with (USDp, USDC or sUSDp). On a paid response, the tx hash
48
+ and route are available via the receipt helper:
49
+
50
+ ```ts
51
+ import { decodePaymentResponse } from "@parallel-protocol/x402-fetch";
52
+
53
+ const receipt = decodePaymentResponse(res); // { txHash, route, gasSponsored, … }
54
+ ```
55
+
56
+ ## Options
57
+
58
+ ```ts
59
+ wrapFetchWithPayment(fetch, {
60
+ chain: "base", // chain slug (default "base")
61
+ privateKey: "0x…", // defaults to PARALLEL_PRIVATE_KEY
62
+ payWith: "usdc", // force the spend token; default: balance-based
63
+ maxAmount: "1", // per-request spend cap, whole token units (default "1")
64
+ timeoutMs: 60_000, // payment-engine timeout (default 60s)
65
+ allowInsecure: false, // allow plain-http merchants (localhost always allowed)
66
+ });
67
+ ```
68
+
69
+ An unknown chain slug or a missing/malformed key throws `X402FetchError` at
70
+ wrap time, not on the first request. Payment failures reject with an
71
+ `X402FetchError` carrying a `code` (and an `engineCode` such as
72
+ `INSUFFICIENT_BALANCE` when the engine rejected).
73
+
74
+ ## Security model
75
+
76
+ - **Spend is capped.** Invoices above `maxAmount` (default **1 token**) are
77
+ rejected before anything is signed — a compromised merchant cannot drain the
78
+ wallet in one request. Raise the cap deliberately, per wrapper.
79
+ - **The invoice is not trusted.** Addresses, amounts and decimals are
80
+ validated; the decimals of known Parallel tokens cannot be re-declared by
81
+ the merchant, and invoices for a different chain fail loudly instead of
82
+ paying into the void.
83
+ - **The private key never leaves your machine.** Payments are EIP-3009 typed
84
+ messages signed locally; only signatures travel. The key does cross the
85
+ process boundary to the local signing engine (a child process of your own
86
+ user).
87
+ - **Plain http is refused** (except localhost) unless you opt in — an on-path
88
+ attacker on http could inject invoices.
89
+ - **Use a dedicated hot wallet** funded with only what the agent needs. Any
90
+ code running in your process can read your environment — never point an
91
+ account holding significant funds at an autonomous agent.
92
+ - A payment can settle even when the paid retry fails (network errors, merchant
93
+ bugs). Treat non-idempotent paid requests accordingly.
94
+
95
+ ## Scope & runtime
96
+
97
+ Targets **Parallel merchants** (header-carried invoices, as emitted by
98
+ `@parallel-protocol/x402`). Node and Bun; the payment engine runs as a
99
+ subprocess of your own runtime — no global installs, no downloads at payment
100
+ time. Payments are serialized per wrapper. Browser support and viem `Account`
101
+ signers (hardware and remote) are planned.
package/dist/index.cjs ADDED
@@ -0,0 +1,324 @@
1
+ 'use strict';
2
+
3
+ var chains = require('@parallel-protocol/chains');
4
+ var viem = require('viem');
5
+ var child_process = require('child_process');
6
+ var fs = require('fs');
7
+ var module$1 = require('module');
8
+ var path = require('path');
9
+ var util = require('util');
10
+
11
+ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
12
+
13
+ var path__default = /*#__PURE__*/_interopDefault(path);
14
+
15
+ // ../../node_modules/tsup/assets/cjs_shims.js
16
+ var getImportMetaUrl = () => typeof document === "undefined" ? new URL(`file:${__filename}`).href : document.currentScript && document.currentScript.tagName.toUpperCase() === "SCRIPT" ? document.currentScript.src : new URL("main.js", document.baseURI).href;
17
+ var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
18
+
19
+ // src/types.ts
20
+ var X402FetchError = class extends Error {
21
+ code;
22
+ /** Engine error code when the payment engine rejected, e.g. INSUFFICIENT_BALANCE. */
23
+ engineCode;
24
+ hint;
25
+ constructor(message, code, extra) {
26
+ super(message, extra?.cause !== void 0 ? { cause: extra.cause } : {});
27
+ this.name = "X402FetchError";
28
+ this.code = code;
29
+ if (extra?.engineCode !== void 0) this.engineCode = extra.engineCode;
30
+ if (extra?.hint !== void 0) this.hint = extra.hint;
31
+ }
32
+ };
33
+
34
+ // src/cli.ts
35
+ var execFileAsync = util.promisify(child_process.execFile);
36
+ function clip(value, max = 200) {
37
+ return String(value).replace(/[^\x20-\x7e]/g, "").slice(0, max);
38
+ }
39
+ function resolveCliBin() {
40
+ let pkgPath;
41
+ try {
42
+ const require2 = module$1.createRequire(importMetaUrl);
43
+ pkgPath = require2.resolve("@parallel-protocol/cli/package.json");
44
+ } catch (cause) {
45
+ throw new X402FetchError(
46
+ "@parallel-protocol/cli could not be resolved \u2014 is the package installed?",
47
+ "ENGINE_NOT_FOUND",
48
+ { cause }
49
+ );
50
+ }
51
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
52
+ const rel = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.parallel;
53
+ if (!rel) {
54
+ throw new X402FetchError(
55
+ "@parallel-protocol/cli declares no binary",
56
+ "ENGINE_NOT_FOUND"
57
+ );
58
+ }
59
+ return path__default.default.join(path__default.default.dirname(pkgPath), rel);
60
+ }
61
+ var cachedBin;
62
+ async function preparePayment(params) {
63
+ cachedBin ??= resolveCliBin();
64
+ const args = [
65
+ cachedBin,
66
+ "pay",
67
+ "prepare",
68
+ "--amount",
69
+ params.amount,
70
+ "--recipient",
71
+ params.recipient,
72
+ "--token",
73
+ params.token,
74
+ "--chain",
75
+ params.chain,
76
+ "--wallet",
77
+ "env",
78
+ "--json"
79
+ ];
80
+ if (params.payWith) args.push("--pay-with", params.payWith);
81
+ let stdout;
82
+ try {
83
+ ({ stdout } = await execFileAsync(process.execPath, args, {
84
+ env: { ...process.env, PARALLEL_PRIVATE_KEY: params.privateKey },
85
+ maxBuffer: 4 * 1024 * 1024,
86
+ timeout: params.timeoutMs,
87
+ killSignal: "SIGKILL",
88
+ ...params.signal && { signal: params.signal }
89
+ }));
90
+ } catch (err) {
91
+ const e = err;
92
+ if (e.name === "AbortError") throw err;
93
+ if (e.killed) {
94
+ throw new X402FetchError(
95
+ `payment engine timed out after ${params.timeoutMs}ms`,
96
+ "ENGINE_TIMEOUT"
97
+ );
98
+ }
99
+ try {
100
+ const parsed2 = JSON.parse(e.stderr ?? "");
101
+ if (parsed2.error?.code) {
102
+ throw new X402FetchError(
103
+ `payment preparation failed: ${clip(parsed2.error.message)}`,
104
+ "ENGINE_FAILED",
105
+ { engineCode: parsed2.error.code, hint: parsed2.error.hint }
106
+ );
107
+ }
108
+ } catch (inner) {
109
+ if (inner instanceof X402FetchError) throw inner;
110
+ }
111
+ throw new X402FetchError(
112
+ `payment preparation failed: ${clip(e.stderr?.trim() || e.message)}`,
113
+ "ENGINE_FAILED",
114
+ { cause: err }
115
+ );
116
+ }
117
+ let parsed;
118
+ try {
119
+ parsed = JSON.parse(stdout.trim());
120
+ } catch (cause) {
121
+ throw new X402FetchError(
122
+ "payment engine returned unparseable output",
123
+ "ENGINE_FAILED",
124
+ { cause }
125
+ );
126
+ }
127
+ if (!parsed.success || !parsed.data?.encoded) {
128
+ throw new X402FetchError(
129
+ `payment preparation failed: ${clip(JSON.stringify(parsed.error ?? parsed))}`,
130
+ "ENGINE_FAILED"
131
+ );
132
+ }
133
+ return parsed.data;
134
+ }
135
+
136
+ // src/fetch.ts
137
+ var PRIVATE_KEY_RE = /^0x[0-9a-fA-F]{64}$/;
138
+ var ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
139
+ var AMOUNT_RE = /^[0-9]{1,78}$/;
140
+ var LOCAL_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
141
+ function decodeInvoice(header) {
142
+ try {
143
+ const invoice = JSON.parse(
144
+ Buffer.from(header, "base64").toString("utf8")
145
+ );
146
+ return Array.isArray(invoice.accepts) ? invoice : void 0;
147
+ } catch {
148
+ return void 0;
149
+ }
150
+ }
151
+ function toCliToken(asset, chain) {
152
+ const lower = asset.toLowerCase();
153
+ if (chains.getUSDpAddress(chain)?.toLowerCase() === lower) return "usdp";
154
+ if (chains.getUSDCAddress(chain)?.toLowerCase() === lower) return "usdc";
155
+ if (chains.getSUSDpAddress(chain)?.toLowerCase() === lower) return "susdp";
156
+ return asset;
157
+ }
158
+ function payWithAddress(payWith, chain) {
159
+ if (payWith === "usdp") return chains.getUSDpAddress(chain);
160
+ if (payWith === "usdc") return chains.getUSDCAddress(chain);
161
+ return chains.getSUSDpAddress(chain);
162
+ }
163
+ function selectRequirement(invoice, chain, payWith) {
164
+ const network = `eip155:${chains.getChainId(chain)}`;
165
+ const candidates = invoice.accepts.filter(
166
+ (r) => r && r.scheme === "exact" && r.network === network
167
+ );
168
+ if (payWith) {
169
+ const preferred = payWithAddress(payWith, chain)?.toLowerCase();
170
+ const match = candidates.find((r) => r.asset?.toLowerCase() === preferred);
171
+ if (match) return match;
172
+ }
173
+ return candidates[0];
174
+ }
175
+ function resolvePayment(requirement, chain) {
176
+ if (!ADDRESS_RE.test(requirement.payTo ?? "")) {
177
+ throw new X402FetchError(
178
+ "invoice payTo is not an address",
179
+ "INVALID_INVOICE"
180
+ );
181
+ }
182
+ if (!ADDRESS_RE.test(requirement.asset ?? "")) {
183
+ throw new X402FetchError(
184
+ "invoice asset is not an address",
185
+ "INVALID_INVOICE"
186
+ );
187
+ }
188
+ if (!AMOUNT_RE.test(requirement.amount ?? "")) {
189
+ throw new X402FetchError(
190
+ "invoice amount is not a positive integer string",
191
+ "INVALID_INVOICE"
192
+ );
193
+ }
194
+ const raw = BigInt(requirement.amount);
195
+ if (raw === 0n) {
196
+ throw new X402FetchError("invoice amount is zero", "INVALID_INVOICE");
197
+ }
198
+ const token = toCliToken(requirement.asset, chain);
199
+ const declared = requirement.extra?.decimals;
200
+ const catalogued = chains.getTokenDecimals(chain, requirement.asset);
201
+ let decimals;
202
+ if (catalogued !== void 0) {
203
+ if (declared !== void 0 && declared !== catalogued) {
204
+ throw new X402FetchError(
205
+ `invoice declares ${declared} decimals for ${token} (expected ${catalogued})`,
206
+ "INVALID_INVOICE"
207
+ );
208
+ }
209
+ decimals = catalogued;
210
+ } else {
211
+ if (declared !== void 0 && (!Number.isInteger(declared) || declared < 0 || declared > 36)) {
212
+ throw new X402FetchError(
213
+ "invoice declares implausible decimals",
214
+ "INVALID_INVOICE"
215
+ );
216
+ }
217
+ decimals = declared ?? 18;
218
+ }
219
+ return { token, amount: viem.formatUnits(raw, decimals) };
220
+ }
221
+ function assertSecureUrl(url, allowInsecure) {
222
+ const parsed = new URL(url);
223
+ if (parsed.protocol === "http:" && !LOCAL_HOSTNAMES.has(parsed.hostname) && !allowInsecure) {
224
+ throw new X402FetchError(
225
+ `refusing to pay an invoice served over plain http (${parsed.origin}) \u2014 use https or set allowInsecure`,
226
+ "INSECURE_URL"
227
+ );
228
+ }
229
+ }
230
+ function wrapFetchWithPayment(baseFetch, options = {}) {
231
+ const chain = options.chain ?? "base";
232
+ if (!chains.isKnownChain(chain)) {
233
+ throw new X402FetchError(`Unknown chain "${chain}"`, "UNKNOWN_CHAIN");
234
+ }
235
+ const privateKey = options.privateKey ?? process.env.PARALLEL_PRIVATE_KEY;
236
+ if (!privateKey) {
237
+ throw new X402FetchError(
238
+ "A signing key is required \u2014 pass options.privateKey or set PARALLEL_PRIVATE_KEY",
239
+ "NO_SIGNING_KEY"
240
+ );
241
+ }
242
+ if (!PRIVATE_KEY_RE.test(privateKey)) {
243
+ throw new X402FetchError(
244
+ "The signing key is not a 32-byte 0x-prefixed hex string",
245
+ "NO_SIGNING_KEY"
246
+ );
247
+ }
248
+ const maxAmount = options.maxAmount ?? "1";
249
+ const timeoutMs = options.timeoutMs ?? 6e4;
250
+ let queue = Promise.resolve();
251
+ const exclusive = (task) => {
252
+ const run = queue.then(task);
253
+ queue = run.then(
254
+ () => void 0,
255
+ () => void 0
256
+ );
257
+ return run;
258
+ };
259
+ return async (input, init) => {
260
+ const request = input instanceof Request ? new Request(input, init) : new Request(String(input), init);
261
+ const res = await baseFetch(request.clone());
262
+ if (res.status !== 402) return res;
263
+ const header = res.headers.get("payment-required");
264
+ if (!header) return res;
265
+ const invoice = decodeInvoice(header);
266
+ if (!invoice) return res;
267
+ const requirement = selectRequirement(invoice, chain, options.payWith);
268
+ if (!requirement) {
269
+ const offered = [...new Set(invoice.accepts.map((r) => r?.network))];
270
+ throw new X402FetchError(
271
+ `invoice accepts payment on ${offered.join(", ") || "(nothing)"} but this fetch pays on ${chain}`,
272
+ "CHAIN_MISMATCH"
273
+ );
274
+ }
275
+ assertSecureUrl(request.url, options.allowInsecure ?? false);
276
+ const { token, amount } = resolvePayment(requirement, chain);
277
+ if (viem.parseUnits(amount, 36) > viem.parseUnits(maxAmount, 36)) {
278
+ throw new X402FetchError(
279
+ `invoice demands ${amount} ${token}, above the ${maxAmount} maxAmount cap`,
280
+ "AMOUNT_EXCEEDS_MAX"
281
+ );
282
+ }
283
+ const { encoded } = await exclusive(
284
+ () => preparePayment({
285
+ amount,
286
+ recipient: requirement.payTo,
287
+ token,
288
+ chain,
289
+ payWith: options.payWith,
290
+ privateKey,
291
+ timeoutMs,
292
+ signal: request.signal
293
+ })
294
+ );
295
+ const headers = new Headers(request.headers);
296
+ headers.set("X-PAYMENT", encoded);
297
+ const paid = new Request(request, {
298
+ headers,
299
+ redirect: "manual"
300
+ });
301
+ return baseFetch(paid);
302
+ };
303
+ }
304
+
305
+ // src/receipt.ts
306
+ function decodePaymentResponse(res) {
307
+ const header = res.headers.get("payment-response");
308
+ if (!header) return void 0;
309
+ try {
310
+ return JSON.parse(
311
+ Buffer.from(header, "base64").toString("utf8")
312
+ );
313
+ } catch (cause) {
314
+ throw new X402FetchError(
315
+ "payment-response header is not decodable",
316
+ "INVALID_INVOICE",
317
+ { cause }
318
+ );
319
+ }
320
+ }
321
+
322
+ exports.X402FetchError = X402FetchError;
323
+ exports.decodePaymentResponse = decodePaymentResponse;
324
+ exports.wrapFetchWithPayment = wrapFetchWithPayment;
@@ -0,0 +1,107 @@
1
+ import { Chain } from '@parallel-protocol/chains';
2
+ import { Address } from 'viem';
3
+
4
+ /**
5
+ * Minimal fetch signature this package wraps and returns. Structurally
6
+ * compatible with the global `fetch` without depending on ambient lib types.
7
+ */
8
+ type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
9
+ /** One entry of a 402 invoice's `accepts` array (x402 wire format). */
10
+ type PaymentRequirement = {
11
+ scheme: string;
12
+ /** eip155-form chain id, e.g. "eip155:8453". */
13
+ network: string;
14
+ asset: Address;
15
+ /** Amount in the asset's smallest unit; its decimals travel in `extra`. */
16
+ amount: string;
17
+ payTo: Address;
18
+ maxTimeoutSeconds?: number;
19
+ extra?: {
20
+ decimals?: number;
21
+ };
22
+ };
23
+ /**
24
+ * The decoded `payment-required` header a merchant returns with a 402.
25
+ * This client reads `accepts`; the other fields are wire-format documentation.
26
+ */
27
+ type PaymentRequired = {
28
+ x402Version: number;
29
+ resource?: {
30
+ url?: string;
31
+ description?: string;
32
+ mimeType?: string;
33
+ };
34
+ accepts: PaymentRequirement[];
35
+ error?: string;
36
+ };
37
+ /** The merchant's `payment-response` receipt on a settled request. */
38
+ type PaymentReceipt = {
39
+ txHash: string;
40
+ route: string;
41
+ chain?: string;
42
+ networkId?: string;
43
+ gasSponsored?: boolean;
44
+ };
45
+ /** Token the agent spends, overriding balance-based selection. */
46
+ type PayWith = "usdp" | "usdc" | "susdp";
47
+ type WrapFetchOptions = {
48
+ /** Chain slug the agent pays on (default: "base"). */
49
+ chain?: Chain;
50
+ /**
51
+ * Hot-wallet signing key; defaults to the `PARALLEL_PRIVATE_KEY` environment
52
+ * variable. The key never leaves this machine — only signatures are sent.
53
+ */
54
+ privateKey?: string;
55
+ /** Force which token to spend; defaults to balance-based selection. */
56
+ payWith?: PayWith;
57
+ /**
58
+ * Maximum spend per request, in whole token units (default: "1").
59
+ * Invoices above the cap are rejected before anything is signed.
60
+ */
61
+ maxAmount?: string;
62
+ /** Payment-engine timeout in milliseconds (default: 60_000). */
63
+ timeoutMs?: number;
64
+ /**
65
+ * Allow paying invoices served over plain http (default: false).
66
+ * Localhost is always allowed.
67
+ */
68
+ allowInsecure?: boolean;
69
+ };
70
+ type X402FetchErrorCode = "NO_SIGNING_KEY" | "UNKNOWN_CHAIN" | "CHAIN_MISMATCH" | "INVALID_INVOICE" | "AMOUNT_EXCEEDS_MAX" | "INSECURE_URL" | "ENGINE_NOT_FOUND" | "ENGINE_TIMEOUT" | "ENGINE_FAILED";
71
+ declare class X402FetchError extends Error {
72
+ readonly code: X402FetchErrorCode;
73
+ /** Engine error code when the payment engine rejected, e.g. INSUFFICIENT_BALANCE. */
74
+ readonly engineCode?: string;
75
+ readonly hint?: string;
76
+ constructor(message: string, code: X402FetchErrorCode, extra?: {
77
+ engineCode?: string;
78
+ hint?: string;
79
+ cause?: unknown;
80
+ });
81
+ }
82
+
83
+ /**
84
+ * Wrap a `fetch` so that an x402 `402 Payment Required` is paid automatically
85
+ * and the request is retried with the payment attached. The returned function
86
+ * is a drop-in `fetch` replacement — non-402 responses pass through untouched.
87
+ *
88
+ * @param baseFetch - The `fetch` implementation to wrap (usually the global).
89
+ * @param options - Chain, signing key and spend-policy overrides.
90
+ * @returns A `fetch` that settles 402 challenges transparently.
91
+ * @throws {X402FetchError} At wrap time if the chain is unknown or no valid
92
+ * signing key is available. Payment failures reject the returned promise.
93
+ *
94
+ * @example
95
+ * const payFetch = wrapFetchWithPayment(fetch); // key from PARALLEL_PRIVATE_KEY
96
+ * const res = await payFetch("https://api.example.com/premium-data");
97
+ */
98
+ declare function wrapFetchWithPayment(baseFetch: FetchLike, options?: WrapFetchOptions): FetchLike;
99
+
100
+ /**
101
+ * Decode the merchant's `payment-response` receipt (tx hash, route, gas
102
+ * sponsorship) from a paid response. Returns `undefined` when the header is
103
+ * absent — i.e. the request did not go through a payment.
104
+ */
105
+ declare function decodePaymentResponse(res: Response): PaymentReceipt | undefined;
106
+
107
+ export { type FetchLike, type PayWith, type PaymentReceipt, type PaymentRequired, type PaymentRequirement, type WrapFetchOptions, X402FetchError, type X402FetchErrorCode, decodePaymentResponse, wrapFetchWithPayment };
@@ -0,0 +1,107 @@
1
+ import { Chain } from '@parallel-protocol/chains';
2
+ import { Address } from 'viem';
3
+
4
+ /**
5
+ * Minimal fetch signature this package wraps and returns. Structurally
6
+ * compatible with the global `fetch` without depending on ambient lib types.
7
+ */
8
+ type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
9
+ /** One entry of a 402 invoice's `accepts` array (x402 wire format). */
10
+ type PaymentRequirement = {
11
+ scheme: string;
12
+ /** eip155-form chain id, e.g. "eip155:8453". */
13
+ network: string;
14
+ asset: Address;
15
+ /** Amount in the asset's smallest unit; its decimals travel in `extra`. */
16
+ amount: string;
17
+ payTo: Address;
18
+ maxTimeoutSeconds?: number;
19
+ extra?: {
20
+ decimals?: number;
21
+ };
22
+ };
23
+ /**
24
+ * The decoded `payment-required` header a merchant returns with a 402.
25
+ * This client reads `accepts`; the other fields are wire-format documentation.
26
+ */
27
+ type PaymentRequired = {
28
+ x402Version: number;
29
+ resource?: {
30
+ url?: string;
31
+ description?: string;
32
+ mimeType?: string;
33
+ };
34
+ accepts: PaymentRequirement[];
35
+ error?: string;
36
+ };
37
+ /** The merchant's `payment-response` receipt on a settled request. */
38
+ type PaymentReceipt = {
39
+ txHash: string;
40
+ route: string;
41
+ chain?: string;
42
+ networkId?: string;
43
+ gasSponsored?: boolean;
44
+ };
45
+ /** Token the agent spends, overriding balance-based selection. */
46
+ type PayWith = "usdp" | "usdc" | "susdp";
47
+ type WrapFetchOptions = {
48
+ /** Chain slug the agent pays on (default: "base"). */
49
+ chain?: Chain;
50
+ /**
51
+ * Hot-wallet signing key; defaults to the `PARALLEL_PRIVATE_KEY` environment
52
+ * variable. The key never leaves this machine — only signatures are sent.
53
+ */
54
+ privateKey?: string;
55
+ /** Force which token to spend; defaults to balance-based selection. */
56
+ payWith?: PayWith;
57
+ /**
58
+ * Maximum spend per request, in whole token units (default: "1").
59
+ * Invoices above the cap are rejected before anything is signed.
60
+ */
61
+ maxAmount?: string;
62
+ /** Payment-engine timeout in milliseconds (default: 60_000). */
63
+ timeoutMs?: number;
64
+ /**
65
+ * Allow paying invoices served over plain http (default: false).
66
+ * Localhost is always allowed.
67
+ */
68
+ allowInsecure?: boolean;
69
+ };
70
+ type X402FetchErrorCode = "NO_SIGNING_KEY" | "UNKNOWN_CHAIN" | "CHAIN_MISMATCH" | "INVALID_INVOICE" | "AMOUNT_EXCEEDS_MAX" | "INSECURE_URL" | "ENGINE_NOT_FOUND" | "ENGINE_TIMEOUT" | "ENGINE_FAILED";
71
+ declare class X402FetchError extends Error {
72
+ readonly code: X402FetchErrorCode;
73
+ /** Engine error code when the payment engine rejected, e.g. INSUFFICIENT_BALANCE. */
74
+ readonly engineCode?: string;
75
+ readonly hint?: string;
76
+ constructor(message: string, code: X402FetchErrorCode, extra?: {
77
+ engineCode?: string;
78
+ hint?: string;
79
+ cause?: unknown;
80
+ });
81
+ }
82
+
83
+ /**
84
+ * Wrap a `fetch` so that an x402 `402 Payment Required` is paid automatically
85
+ * and the request is retried with the payment attached. The returned function
86
+ * is a drop-in `fetch` replacement — non-402 responses pass through untouched.
87
+ *
88
+ * @param baseFetch - The `fetch` implementation to wrap (usually the global).
89
+ * @param options - Chain, signing key and spend-policy overrides.
90
+ * @returns A `fetch` that settles 402 challenges transparently.
91
+ * @throws {X402FetchError} At wrap time if the chain is unknown or no valid
92
+ * signing key is available. Payment failures reject the returned promise.
93
+ *
94
+ * @example
95
+ * const payFetch = wrapFetchWithPayment(fetch); // key from PARALLEL_PRIVATE_KEY
96
+ * const res = await payFetch("https://api.example.com/premium-data");
97
+ */
98
+ declare function wrapFetchWithPayment(baseFetch: FetchLike, options?: WrapFetchOptions): FetchLike;
99
+
100
+ /**
101
+ * Decode the merchant's `payment-response` receipt (tx hash, route, gas
102
+ * sponsorship) from a paid response. Returns `undefined` when the header is
103
+ * absent — i.e. the request did not go through a payment.
104
+ */
105
+ declare function decodePaymentResponse(res: Response): PaymentReceipt | undefined;
106
+
107
+ export { type FetchLike, type PayWith, type PaymentReceipt, type PaymentRequired, type PaymentRequirement, type WrapFetchOptions, X402FetchError, type X402FetchErrorCode, decodePaymentResponse, wrapFetchWithPayment };
package/dist/index.js ADDED
@@ -0,0 +1,314 @@
1
+ import { isKnownChain, getChainId, getTokenDecimals, getUSDpAddress, getUSDCAddress, getSUSDpAddress } from '@parallel-protocol/chains';
2
+ import { parseUnits, formatUnits } from 'viem';
3
+ import { execFile } from 'child_process';
4
+ import { readFileSync } from 'fs';
5
+ import { createRequire } from 'module';
6
+ import path from 'path';
7
+ import { promisify } from 'util';
8
+
9
+ // src/fetch.ts
10
+
11
+ // src/types.ts
12
+ var X402FetchError = class extends Error {
13
+ code;
14
+ /** Engine error code when the payment engine rejected, e.g. INSUFFICIENT_BALANCE. */
15
+ engineCode;
16
+ hint;
17
+ constructor(message, code, extra) {
18
+ super(message, extra?.cause !== void 0 ? { cause: extra.cause } : {});
19
+ this.name = "X402FetchError";
20
+ this.code = code;
21
+ if (extra?.engineCode !== void 0) this.engineCode = extra.engineCode;
22
+ if (extra?.hint !== void 0) this.hint = extra.hint;
23
+ }
24
+ };
25
+
26
+ // src/cli.ts
27
+ var execFileAsync = promisify(execFile);
28
+ function clip(value, max = 200) {
29
+ return String(value).replace(/[^\x20-\x7e]/g, "").slice(0, max);
30
+ }
31
+ function resolveCliBin() {
32
+ let pkgPath;
33
+ try {
34
+ const require2 = createRequire(import.meta.url);
35
+ pkgPath = require2.resolve("@parallel-protocol/cli/package.json");
36
+ } catch (cause) {
37
+ throw new X402FetchError(
38
+ "@parallel-protocol/cli could not be resolved \u2014 is the package installed?",
39
+ "ENGINE_NOT_FOUND",
40
+ { cause }
41
+ );
42
+ }
43
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
44
+ const rel = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.parallel;
45
+ if (!rel) {
46
+ throw new X402FetchError(
47
+ "@parallel-protocol/cli declares no binary",
48
+ "ENGINE_NOT_FOUND"
49
+ );
50
+ }
51
+ return path.join(path.dirname(pkgPath), rel);
52
+ }
53
+ var cachedBin;
54
+ async function preparePayment(params) {
55
+ cachedBin ??= resolveCliBin();
56
+ const args = [
57
+ cachedBin,
58
+ "pay",
59
+ "prepare",
60
+ "--amount",
61
+ params.amount,
62
+ "--recipient",
63
+ params.recipient,
64
+ "--token",
65
+ params.token,
66
+ "--chain",
67
+ params.chain,
68
+ "--wallet",
69
+ "env",
70
+ "--json"
71
+ ];
72
+ if (params.payWith) args.push("--pay-with", params.payWith);
73
+ let stdout;
74
+ try {
75
+ ({ stdout } = await execFileAsync(process.execPath, args, {
76
+ env: { ...process.env, PARALLEL_PRIVATE_KEY: params.privateKey },
77
+ maxBuffer: 4 * 1024 * 1024,
78
+ timeout: params.timeoutMs,
79
+ killSignal: "SIGKILL",
80
+ ...params.signal && { signal: params.signal }
81
+ }));
82
+ } catch (err) {
83
+ const e = err;
84
+ if (e.name === "AbortError") throw err;
85
+ if (e.killed) {
86
+ throw new X402FetchError(
87
+ `payment engine timed out after ${params.timeoutMs}ms`,
88
+ "ENGINE_TIMEOUT"
89
+ );
90
+ }
91
+ try {
92
+ const parsed2 = JSON.parse(e.stderr ?? "");
93
+ if (parsed2.error?.code) {
94
+ throw new X402FetchError(
95
+ `payment preparation failed: ${clip(parsed2.error.message)}`,
96
+ "ENGINE_FAILED",
97
+ { engineCode: parsed2.error.code, hint: parsed2.error.hint }
98
+ );
99
+ }
100
+ } catch (inner) {
101
+ if (inner instanceof X402FetchError) throw inner;
102
+ }
103
+ throw new X402FetchError(
104
+ `payment preparation failed: ${clip(e.stderr?.trim() || e.message)}`,
105
+ "ENGINE_FAILED",
106
+ { cause: err }
107
+ );
108
+ }
109
+ let parsed;
110
+ try {
111
+ parsed = JSON.parse(stdout.trim());
112
+ } catch (cause) {
113
+ throw new X402FetchError(
114
+ "payment engine returned unparseable output",
115
+ "ENGINE_FAILED",
116
+ { cause }
117
+ );
118
+ }
119
+ if (!parsed.success || !parsed.data?.encoded) {
120
+ throw new X402FetchError(
121
+ `payment preparation failed: ${clip(JSON.stringify(parsed.error ?? parsed))}`,
122
+ "ENGINE_FAILED"
123
+ );
124
+ }
125
+ return parsed.data;
126
+ }
127
+
128
+ // src/fetch.ts
129
+ var PRIVATE_KEY_RE = /^0x[0-9a-fA-F]{64}$/;
130
+ var ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
131
+ var AMOUNT_RE = /^[0-9]{1,78}$/;
132
+ var LOCAL_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
133
+ function decodeInvoice(header) {
134
+ try {
135
+ const invoice = JSON.parse(
136
+ Buffer.from(header, "base64").toString("utf8")
137
+ );
138
+ return Array.isArray(invoice.accepts) ? invoice : void 0;
139
+ } catch {
140
+ return void 0;
141
+ }
142
+ }
143
+ function toCliToken(asset, chain) {
144
+ const lower = asset.toLowerCase();
145
+ if (getUSDpAddress(chain)?.toLowerCase() === lower) return "usdp";
146
+ if (getUSDCAddress(chain)?.toLowerCase() === lower) return "usdc";
147
+ if (getSUSDpAddress(chain)?.toLowerCase() === lower) return "susdp";
148
+ return asset;
149
+ }
150
+ function payWithAddress(payWith, chain) {
151
+ if (payWith === "usdp") return getUSDpAddress(chain);
152
+ if (payWith === "usdc") return getUSDCAddress(chain);
153
+ return getSUSDpAddress(chain);
154
+ }
155
+ function selectRequirement(invoice, chain, payWith) {
156
+ const network = `eip155:${getChainId(chain)}`;
157
+ const candidates = invoice.accepts.filter(
158
+ (r) => r && r.scheme === "exact" && r.network === network
159
+ );
160
+ if (payWith) {
161
+ const preferred = payWithAddress(payWith, chain)?.toLowerCase();
162
+ const match = candidates.find((r) => r.asset?.toLowerCase() === preferred);
163
+ if (match) return match;
164
+ }
165
+ return candidates[0];
166
+ }
167
+ function resolvePayment(requirement, chain) {
168
+ if (!ADDRESS_RE.test(requirement.payTo ?? "")) {
169
+ throw new X402FetchError(
170
+ "invoice payTo is not an address",
171
+ "INVALID_INVOICE"
172
+ );
173
+ }
174
+ if (!ADDRESS_RE.test(requirement.asset ?? "")) {
175
+ throw new X402FetchError(
176
+ "invoice asset is not an address",
177
+ "INVALID_INVOICE"
178
+ );
179
+ }
180
+ if (!AMOUNT_RE.test(requirement.amount ?? "")) {
181
+ throw new X402FetchError(
182
+ "invoice amount is not a positive integer string",
183
+ "INVALID_INVOICE"
184
+ );
185
+ }
186
+ const raw = BigInt(requirement.amount);
187
+ if (raw === 0n) {
188
+ throw new X402FetchError("invoice amount is zero", "INVALID_INVOICE");
189
+ }
190
+ const token = toCliToken(requirement.asset, chain);
191
+ const declared = requirement.extra?.decimals;
192
+ const catalogued = getTokenDecimals(chain, requirement.asset);
193
+ let decimals;
194
+ if (catalogued !== void 0) {
195
+ if (declared !== void 0 && declared !== catalogued) {
196
+ throw new X402FetchError(
197
+ `invoice declares ${declared} decimals for ${token} (expected ${catalogued})`,
198
+ "INVALID_INVOICE"
199
+ );
200
+ }
201
+ decimals = catalogued;
202
+ } else {
203
+ if (declared !== void 0 && (!Number.isInteger(declared) || declared < 0 || declared > 36)) {
204
+ throw new X402FetchError(
205
+ "invoice declares implausible decimals",
206
+ "INVALID_INVOICE"
207
+ );
208
+ }
209
+ decimals = declared ?? 18;
210
+ }
211
+ return { token, amount: formatUnits(raw, decimals) };
212
+ }
213
+ function assertSecureUrl(url, allowInsecure) {
214
+ const parsed = new URL(url);
215
+ if (parsed.protocol === "http:" && !LOCAL_HOSTNAMES.has(parsed.hostname) && !allowInsecure) {
216
+ throw new X402FetchError(
217
+ `refusing to pay an invoice served over plain http (${parsed.origin}) \u2014 use https or set allowInsecure`,
218
+ "INSECURE_URL"
219
+ );
220
+ }
221
+ }
222
+ function wrapFetchWithPayment(baseFetch, options = {}) {
223
+ const chain = options.chain ?? "base";
224
+ if (!isKnownChain(chain)) {
225
+ throw new X402FetchError(`Unknown chain "${chain}"`, "UNKNOWN_CHAIN");
226
+ }
227
+ const privateKey = options.privateKey ?? process.env.PARALLEL_PRIVATE_KEY;
228
+ if (!privateKey) {
229
+ throw new X402FetchError(
230
+ "A signing key is required \u2014 pass options.privateKey or set PARALLEL_PRIVATE_KEY",
231
+ "NO_SIGNING_KEY"
232
+ );
233
+ }
234
+ if (!PRIVATE_KEY_RE.test(privateKey)) {
235
+ throw new X402FetchError(
236
+ "The signing key is not a 32-byte 0x-prefixed hex string",
237
+ "NO_SIGNING_KEY"
238
+ );
239
+ }
240
+ const maxAmount = options.maxAmount ?? "1";
241
+ const timeoutMs = options.timeoutMs ?? 6e4;
242
+ let queue = Promise.resolve();
243
+ const exclusive = (task) => {
244
+ const run = queue.then(task);
245
+ queue = run.then(
246
+ () => void 0,
247
+ () => void 0
248
+ );
249
+ return run;
250
+ };
251
+ return async (input, init) => {
252
+ const request = input instanceof Request ? new Request(input, init) : new Request(String(input), init);
253
+ const res = await baseFetch(request.clone());
254
+ if (res.status !== 402) return res;
255
+ const header = res.headers.get("payment-required");
256
+ if (!header) return res;
257
+ const invoice = decodeInvoice(header);
258
+ if (!invoice) return res;
259
+ const requirement = selectRequirement(invoice, chain, options.payWith);
260
+ if (!requirement) {
261
+ const offered = [...new Set(invoice.accepts.map((r) => r?.network))];
262
+ throw new X402FetchError(
263
+ `invoice accepts payment on ${offered.join(", ") || "(nothing)"} but this fetch pays on ${chain}`,
264
+ "CHAIN_MISMATCH"
265
+ );
266
+ }
267
+ assertSecureUrl(request.url, options.allowInsecure ?? false);
268
+ const { token, amount } = resolvePayment(requirement, chain);
269
+ if (parseUnits(amount, 36) > parseUnits(maxAmount, 36)) {
270
+ throw new X402FetchError(
271
+ `invoice demands ${amount} ${token}, above the ${maxAmount} maxAmount cap`,
272
+ "AMOUNT_EXCEEDS_MAX"
273
+ );
274
+ }
275
+ const { encoded } = await exclusive(
276
+ () => preparePayment({
277
+ amount,
278
+ recipient: requirement.payTo,
279
+ token,
280
+ chain,
281
+ payWith: options.payWith,
282
+ privateKey,
283
+ timeoutMs,
284
+ signal: request.signal
285
+ })
286
+ );
287
+ const headers = new Headers(request.headers);
288
+ headers.set("X-PAYMENT", encoded);
289
+ const paid = new Request(request, {
290
+ headers,
291
+ redirect: "manual"
292
+ });
293
+ return baseFetch(paid);
294
+ };
295
+ }
296
+
297
+ // src/receipt.ts
298
+ function decodePaymentResponse(res) {
299
+ const header = res.headers.get("payment-response");
300
+ if (!header) return void 0;
301
+ try {
302
+ return JSON.parse(
303
+ Buffer.from(header, "base64").toString("utf8")
304
+ );
305
+ } catch (cause) {
306
+ throw new X402FetchError(
307
+ "payment-response header is not decodable",
308
+ "INVALID_INVOICE",
309
+ { cause }
310
+ );
311
+ }
312
+ }
313
+
314
+ export { X402FetchError, decodePaymentResponse, wrapFetchWithPayment };
package/package.json ADDED
@@ -0,0 +1,75 @@
1
+ {
2
+ "name": "@parallel-protocol/x402-fetch",
3
+ "version": "0.1.0",
4
+ "description": "Payer-side x402 client for Parallel Protocol — wraps fetch so a 402 challenge is paid automatically; the agent only needs a funded wallet",
5
+ "author": "Parallel Protocol <contact@parallel.best>",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/parallel-protocol/sdk-merchant.git",
9
+ "directory": "packages/x402-fetch"
10
+ },
11
+ "keywords": [
12
+ "x402",
13
+ "402",
14
+ "agent-payments",
15
+ "stablecoin",
16
+ "fetch",
17
+ "parallel-protocol"
18
+ ],
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "homepage": "https://github.com/parallel-protocol/sdk-merchant",
23
+ "bugs": {
24
+ "url": "https://github.com/parallel-protocol/sdk-merchant/issues"
25
+ },
26
+ "license": "MIT",
27
+ "sideEffects": false,
28
+ "type": "module",
29
+ "main": "./dist/index.cjs",
30
+ "module": "./dist/index.js",
31
+ "types": "./dist/index.d.ts",
32
+ "exports": {
33
+ ".": {
34
+ "import": {
35
+ "types": "./dist/index.d.ts",
36
+ "default": "./dist/index.js"
37
+ },
38
+ "require": {
39
+ "types": "./dist/index.d.cts",
40
+ "default": "./dist/index.cjs"
41
+ }
42
+ },
43
+ "./package.json": "./package.json"
44
+ },
45
+ "files": [
46
+ "dist",
47
+ "README.md",
48
+ "LICENSE"
49
+ ],
50
+ "publishConfig": {
51
+ "registry": "https://registry.npmjs.org",
52
+ "access": "public"
53
+ },
54
+ "scripts": {
55
+ "build": "tsup",
56
+ "dev": "tsup --watch",
57
+ "clean": "rm -rf dist coverage",
58
+ "type-check": "tsc --noEmit",
59
+ "test": "vitest"
60
+ },
61
+ "dependencies": {
62
+ "@parallel-protocol/chains": "^0.2.0",
63
+ "@parallel-protocol/cli": "^0.2.3"
64
+ },
65
+ "peerDependencies": {
66
+ "viem": "^2.0.0"
67
+ },
68
+ "devDependencies": {
69
+ "@types/node": "^22.0.0",
70
+ "tsup": "^8.0.0",
71
+ "typescript": "^5.9.0",
72
+ "viem": "^2.47.0",
73
+ "vitest": "^4.0.14"
74
+ }
75
+ }