paygate 5.0.0 → 5.3.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/README.md CHANGED
@@ -129,6 +129,26 @@ The agent-side [`payagent`](https://www.npmjs.com/package/payagent) CLI and SDK
129
129
  | `facilitatorUrl` | No | Manifest value | Compatibility override. In v3, merchant capabilities are authoritative. |
130
130
  | `timeout` | No | `30000` | ArisPay/facilitator call timeout in ms. |
131
131
  | `cacheTtlMs` | No | `300000` | Merchant capability cache TTL. |
132
+ | `selfSettle` | No | — | `{ privateKey, rpcUrl? }`. Submit settlements from your own funded key: verification runs against the facilitator (free), then the SDK submits the EIP-3009 `transferWithAuthorization` itself. You pay chain gas (~$0.001/settle on Base) and nobody else — no facilitator fee, no subsidy that ends. The key is any funded EOA; it pays gas only and never receives or holds customer funds. Replay protection is on-chain (the EIP-3009 nonce). Defaults to public Base RPCs; set `rpcUrl` for other networks or your own provider. |
133
+
134
+ ### Self-settle
135
+
136
+ ```ts
137
+ const pw = paygate({
138
+ merchantId: "m_123",
139
+ selfSettle: { privateKey: process.env.SETTLE_KEY! }, // funded with a few $ of Base ETH
140
+ });
141
+ ```
142
+
143
+ Requires the optional peer dependency `ethers` (v6): `npm install ethers`. The
144
+ facilitator path never loads it.
145
+
146
+ Verified end-to-end against Circle's real USDC on Base Sepolia (2026-07-19): a
147
+ signed EIP-3009 authorization settled on-chain via `submitSelfSettle` — 0.01
148
+ USDC transferred, gas paid by the self-settle key — tx
149
+ [`0xe151fe05…dda4cb0`](https://sepolia.basescan.org/tx/0xe151fe058f2e03f74f7a6ec4bea95224515592bf3bee8f689b37caf04dda4cb0)
150
+ (`AuthorizationUsed` + `Transfer` events). This confirms Circle USDC accepts the
151
+ 9-arg split-signature `transferWithAuthorization` form the SDK submits.
132
152
 
133
153
  ### Compatibility mode
134
154
 
package/dist/core.d.ts CHANGED
@@ -13,6 +13,34 @@
13
13
  export declare const DEFAULT_FACILITATOR = "https://facilitator.arispay.app";
14
14
  export declare const DEFAULT_API_URL = "https://api.arispay.app";
15
15
  export declare const CAPABILITY_CACHE_TTL_MS: number;
16
+ export type PaygateCurrency = "USD" | "EUR";
17
+ /**
18
+ * Build the response headers for an x402 402 challenge in the canonical
19
+ * wire format observed across Bazaar-listed endpoints.
20
+ *
21
+ * - `payment-required: <base64(JSON)>` — primary, what 95.3% of live
22
+ * x402 endpoints emit (audited 2026-04-30; see
23
+ * docs/x402-wire-format-audit.md). base64 also dodges the Latin1
24
+ * `ByteString` constraint on Node response headers, so a description
25
+ * containing an em dash or smart quote no longer 500s the response.
26
+ *
27
+ * - `X-Payment-Requirements: <raw JSON of {x402Version, accepts}>` —
28
+ * paygate's pre-v5.1 header name, kept for one minor cycle as a
29
+ * deprecated alias so existing `payagent` consumers (and any other
30
+ * client that reads our legacy emit) keep working. Will be removed
31
+ * in paygate v6. Strip non-printable-ASCII defensively in case a
32
+ * description picks up Unicode in the meantime.
33
+ *
34
+ * - `WWW-Authenticate: x402` — RFC 7235 challenge advertisement.
35
+ *
36
+ * - `Access-Control-Expose-Headers` — without this, browser-based
37
+ * agents (Anthropic MCP-in-browser, Chrome WebMCP, …) can't read
38
+ * `payment-required` cross-origin. 80% of live endpoints don't
39
+ * bother; we do.
40
+ *
41
+ * Exported for tests; consumers should not rely on this signature.
42
+ */
43
+ export declare function buildChallengeHeaders(challengeBody: Record<string, unknown>, accepts: X402PaymentAccept[]): Record<string, string>;
16
44
  /**
17
45
  * v3 config.
18
46
  *
@@ -37,12 +65,39 @@ export interface PaygateConfig {
37
65
  */
38
66
  network?: string;
39
67
  /**
40
- * USDC contract address. Auto-derived from network if omitted.
41
- * Ignored in the v3 path (merchant manifest is authoritative).
68
+ * Settlement currency. Default `"USD"` (USDC). `"EUR"` settles in Circle
69
+ * EURC; `priceCents` then means integer EUR cents (the cents 6-decimals
70
+ * conversion factor is the same 10^4 for both assets).
71
+ *
72
+ * v2-shim path: resolves the EURC address for the network (Base mainnet +
73
+ * Base Sepolia known; other networks need an explicit `asset`).
74
+ * v3 path: selects the merchant-manifest rail whose `assetName` matches
75
+ * (`EURC` for EUR); errors clearly if the merchant has no EUR rail.
76
+ */
77
+ currency?: PaygateCurrency;
78
+ /**
79
+ * Settlement token contract address. Auto-derived from `network` +
80
+ * `currency` if omitted. Ignored in the v3 path (merchant manifest is
81
+ * authoritative).
42
82
  */
43
83
  asset?: string;
44
84
  /** Override the default facilitator URL. */
45
85
  facilitatorUrl?: string;
86
+ /**
87
+ * Self-settle: submit the settlement transaction from your own funded key
88
+ * instead of the facilitator's relayer. You pay chain gas (~$0.001 per
89
+ * settle on Base) and nobody else — the never-sponsor / never-charge
90
+ * policy's direct-payout path. Verification still runs against the
91
+ * facilitator (free — no chain writes). Replay protection is on-chain:
92
+ * EIP-3009 consumes the authorization nonce, so a duplicate submit
93
+ * reverts.
94
+ */
95
+ selfSettle?: {
96
+ /** 0x-hex private key of any funded EOA. Pays gas only; never receives or holds customer funds. */
97
+ privateKey: string;
98
+ /** JSON-RPC URL for the settlement network. Defaults to the public Base RPCs for `eip155:8453` / `eip155:84532`. */
99
+ rpcUrl?: string;
100
+ };
46
101
  /** Override the default apps/api URL (for staging / self-hosted). */
47
102
  apiUrl?: string;
48
103
  /** Facilitator call timeout in ms. Default 30s. */
@@ -1 +1 @@
1
- {"version":3,"file":"core.d.ts","sourceRoot":"","sources":["../src/core.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"core.d.ts","sourceRoot":"","sources":["../src/core.ts"],"names":[],"mappings":"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"}
package/dist/core.js CHANGED
@@ -11,31 +11,93 @@
11
11
  * See docs/paygate-v2.md §7.
12
12
  */
13
13
  // ── Default facilitator ─────────────────────────────
14
- export const DEFAULT_FACILITATOR = 'https://facilitator.arispay.app';
15
- export const DEFAULT_API_URL = 'https://api.arispay.app';
14
+ export const DEFAULT_FACILITATOR = "https://facilitator.arispay.app";
15
+ export const DEFAULT_API_URL = "https://api.arispay.app";
16
16
  export const CAPABILITY_CACHE_TTL_MS = 5 * 60 * 1000;
17
17
  // ── Known USDC addresses per network ───────────────
18
+ // Canonical source is `@arispay/x402-core` (USDC_BASE_MAINNET, etc.).
19
+ // This duplicate is intentional: `paygate` is a published, standalone npm
20
+ // package with `ethers` as its only runtime dep (see paygate CLAUDE.md). It
21
+ // must NOT take a workspace dep on the private `@arispay/x402-core` package
22
+ // — npm consumers wouldn't resolve it. Keep these four values in sync with
23
+ // packages/x402-core/src/constants.ts by hand. Do not "DRY" by importing —
24
+ // see P2-4 blocker discussion.
18
25
  const USDC_ADDRESSES = {
19
- 'eip155:84532': '0x036CbD53842c5426634e7929541eC2318f3dCF7e', // Base Sepolia
20
- 'eip155:8453': '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', // Base Mainnet
21
- 'eip155:1': '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // Ethereum
22
- 'eip155:137': '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359', // Polygon
26
+ "eip155:84532": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", // Base Sepolia
27
+ "eip155:8453": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", // Base Mainnet
28
+ "eip155:1": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // Ethereum
29
+ "eip155:137": "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359", // Polygon
30
+ };
31
+ // Circle EURC — the EUR settlement asset (`currency: "EUR"`). Entries exist
32
+ // only for networks where the address + EIP-712 domain were verified
33
+ // on-chain (eth_call name()/version(), 2026-06-11). Same FiatToken family
34
+ // as USDC → EIP-3009 supported, 6 decimals, domain name "EURC" version "2"
35
+ // on both networks. EUR on other networks requires an explicit `asset`.
36
+ const EURC_ADDRESSES = {
37
+ "eip155:84532": "0x808456652fdb597867f38412077A9182bf77359F", // Base Sepolia
38
+ "eip155:8453": "0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42", // Base Mainnet
23
39
  };
24
40
  const NETWORK_ALIASES = {
25
- 'base-sepolia': 'eip155:84532',
26
- 'base': 'eip155:8453',
27
- 'ethereum': 'eip155:1',
28
- 'polygon': 'eip155:137',
41
+ "base-sepolia": "eip155:84532",
42
+ base: "eip155:8453",
43
+ ethereum: "eip155:1",
44
+ polygon: "eip155:137",
29
45
  };
30
46
  function normalizeNetwork(network) {
31
47
  return NETWORK_ALIASES[network] ?? network;
32
48
  }
49
+ /**
50
+ * Build the response headers for an x402 402 challenge in the canonical
51
+ * wire format observed across Bazaar-listed endpoints.
52
+ *
53
+ * - `payment-required: <base64(JSON)>` — primary, what 95.3% of live
54
+ * x402 endpoints emit (audited 2026-04-30; see
55
+ * docs/x402-wire-format-audit.md). base64 also dodges the Latin1
56
+ * `ByteString` constraint on Node response headers, so a description
57
+ * containing an em dash or smart quote no longer 500s the response.
58
+ *
59
+ * - `X-Payment-Requirements: <raw JSON of {x402Version, accepts}>` —
60
+ * paygate's pre-v5.1 header name, kept for one minor cycle as a
61
+ * deprecated alias so existing `payagent` consumers (and any other
62
+ * client that reads our legacy emit) keep working. Will be removed
63
+ * in paygate v6. Strip non-printable-ASCII defensively in case a
64
+ * description picks up Unicode in the meantime.
65
+ *
66
+ * - `WWW-Authenticate: x402` — RFC 7235 challenge advertisement.
67
+ *
68
+ * - `Access-Control-Expose-Headers` — without this, browser-based
69
+ * agents (Anthropic MCP-in-browser, Chrome WebMCP, …) can't read
70
+ * `payment-required` cross-origin. 80% of live endpoints don't
71
+ * bother; we do.
72
+ *
73
+ * Exported for tests; consumers should not rely on this signature.
74
+ */
75
+ export function buildChallengeHeaders(challengeBody, accepts) {
76
+ const canonical = Buffer.from(JSON.stringify(challengeBody), "utf8").toString("base64");
77
+ const legacy = JSON.stringify({ x402Version: 2, accepts })
78
+ .replace(/[\r\n\0]/g, " ")
79
+ .replace(/[^\x20-\x7E]/g, "?");
80
+ return {
81
+ "payment-required": canonical,
82
+ "X-Payment-Requirements": legacy,
83
+ "WWW-Authenticate": "x402",
84
+ "Access-Control-Expose-Headers": "payment-required, x-payment-response, www-authenticate",
85
+ };
86
+ }
33
87
  const USDC_DOMAIN_NAMES = {
34
- 'eip155:84532': 'USDC',
35
- 'eip155:8453': 'USD Coin',
36
- 'eip155:1': 'USD Coin',
37
- 'eip155:137': 'USD Coin',
88
+ "eip155:84532": "USDC",
89
+ "eip155:8453": "USD Coin",
90
+ "eip155:1": "USD Coin",
91
+ "eip155:137": "USD Coin",
38
92
  };
93
+ // EURC's EIP-712 domain name is "EURC" on every verified network.
94
+ const EURC_DOMAIN_NAME = "EURC";
95
+ /** EIP-712 domain name for the selected currency's asset on a network. */
96
+ function domainNameFor(currency, network) {
97
+ if (currency === "EUR")
98
+ return EURC_DOMAIN_NAME;
99
+ return USDC_DOMAIN_NAMES[network] ?? "USD Coin";
100
+ }
39
101
  const DEFAULT_DISCOVERY_LIMIT = {
40
102
  maxProbes: 10,
41
103
  windowMs: 60_000,
@@ -63,13 +125,15 @@ function warnV2Shim() {
63
125
  return;
64
126
  v2ShimWarned = true;
65
127
  // eslint-disable-next-line no-console
66
- console.warn('[paygate] v2 config detected (`wallet` + `network`). Pass `merchantId` instead; v2 will be removed in v4. See https://paygate.arispay.app/docs#v3-migration');
128
+ console.warn("[paygate] v2 config detected (`wallet` + `network`). Pass `merchantId` instead; v2 will be removed in v4. See https://paygate.arispay.app/docs#v3-migration");
67
129
  }
68
130
  async function fetchMerchantConfig(config) {
69
131
  if (!config.merchantId) {
70
- throw new Error('paygate: merchantId is required for v3 flow');
132
+ throw new Error("paygate: merchantId is required for v3 flow");
71
133
  }
72
- const cached = capabilityCache.get(config.merchantId);
134
+ const currency = config.currency ?? "USD";
135
+ const cacheKey = `${config.merchantId}:${currency}`;
136
+ const cached = capabilityCache.get(cacheKey);
73
137
  const cacheTtl = config.cacheTtlMs ?? CAPABILITY_CACHE_TTL_MS;
74
138
  if (cached && Date.now() - cached.fetchedAt < cacheTtl) {
75
139
  return cached;
@@ -90,7 +154,7 @@ async function fetchMerchantConfig(config) {
90
154
  throw new Error(`paygate: failed to fetch merchant capabilities: ${err instanceof Error ? err.message : String(err)}`);
91
155
  }
92
156
  if (res.status === 404) {
93
- capabilityCache.delete(config.merchantId);
157
+ capabilityCache.delete(cacheKey);
94
158
  throw new Error(`paygate: merchantId "${config.merchantId}" not found at ${apiUrl}`);
95
159
  }
96
160
  if (res.status >= 500 && cached) {
@@ -100,20 +164,34 @@ async function fetchMerchantConfig(config) {
100
164
  throw new Error(`paygate: capability fetch failed HTTP ${res.status}`);
101
165
  }
102
166
  const manifest = (await res.json());
103
- const rail = manifest.rails?.[0];
167
+ const wantedAssetName = currency === "EUR" ? "EURC" : "USDC";
168
+ // Prefer the rail matching the requested currency; USD callers keep the
169
+ // historical rails[0] fallback so pre-EUR manifests behave identically.
170
+ const rail = manifest.rails?.find((r) => r.assetName === wantedAssetName) ??
171
+ (currency === "USD" ? manifest.rails?.[0] : undefined);
104
172
  if (!rail) {
105
- throw new Error(`paygate: merchant "${config.merchantId}" has no active payout rail — add a PayoutMethod in the dashboard`);
173
+ throw new Error(currency === "EUR"
174
+ ? `paygate: merchant "${config.merchantId}" has no EUR (EURC) payout rail — add one in the dashboard or use currency: "USD"`
175
+ : `paygate: merchant "${config.merchantId}" has no active payout rail — add a PayoutMethod in the dashboard`);
176
+ }
177
+ // v3 manifests must specify an HTTPS facilitator per rail. The SDK no longer
178
+ // silently falls back to the production default, which would hide
179
+ // misconfiguration and let a rogue facilitator claim successful settlement (L7).
180
+ const facilitator = rail.facilitator;
181
+ if (!facilitator || !facilitator.startsWith("https://")) {
182
+ throw new Error(`paygate: merchant "${config.merchantId}" rail has invalid facilitator URL`);
106
183
  }
107
184
  const resolved = {
108
185
  network: rail.network,
109
186
  asset: rail.asset,
187
+ domainName: domainNameFor(currency, rail.network),
110
188
  payTo: rail.payTo,
111
- facilitator: rail.facilitator ?? DEFAULT_FACILITATOR,
112
- trustMinTier: manifest.trust?.minTier ?? 'any',
189
+ facilitator,
190
+ trustMinTier: manifest.trust?.minTier ?? "any",
113
191
  slug: manifest.merchant.slug,
114
192
  fetchedAt: Date.now(),
115
193
  };
116
- capabilityCache.set(config.merchantId, resolved);
194
+ capabilityCache.set(cacheKey, resolved);
117
195
  return resolved;
118
196
  }
119
197
  /**
@@ -124,23 +202,26 @@ async function fetchMerchantConfig(config) {
124
202
  function resolveV2Shim(config) {
125
203
  warnV2Shim();
126
204
  if (!config.wallet) {
127
- throw new Error('paygate: wallet is required in v2-shim mode');
205
+ throw new Error("paygate: wallet is required in v2-shim mode");
128
206
  }
129
207
  if (!config.network) {
130
- throw new Error('paygate: network is required in v2-shim mode');
208
+ throw new Error("paygate: network is required in v2-shim mode");
131
209
  }
210
+ const currency = config.currency ?? "USD";
132
211
  const network = normalizeNetwork(config.network);
133
- const asset = config.asset ?? USDC_ADDRESSES[network];
212
+ const knownAddresses = currency === "EUR" ? EURC_ADDRESSES : USDC_ADDRESSES;
213
+ const asset = config.asset ?? knownAddresses[network];
134
214
  if (!asset) {
135
- throw new Error(`paygate: unsupported network ${network} — no known USDC address`);
215
+ throw new Error(`paygate: unsupported network ${network} — no known ${currency === "EUR" ? "EURC" : "USDC"} address (pass \`asset\` explicitly)`);
136
216
  }
137
217
  return {
138
218
  network,
139
219
  asset,
220
+ domainName: domainNameFor(currency, network),
140
221
  payTo: config.wallet,
141
222
  facilitator: config.facilitatorUrl ?? DEFAULT_FACILITATOR,
142
- trustMinTier: 'any',
143
- slug: 'v2-shim',
223
+ trustMinTier: "any",
224
+ slug: "v2-shim",
144
225
  fetchedAt: Date.now(),
145
226
  };
146
227
  }
@@ -148,7 +229,7 @@ function resolveV2Shim(config) {
148
229
  async function resolvePriceCents(routePrice, request) {
149
230
  const v3 = routePrice.priceCents;
150
231
  if (v3 !== undefined) {
151
- const cents = typeof v3 === 'function' ? await v3(request) : v3;
232
+ const cents = typeof v3 === "function" ? await v3(request) : v3;
152
233
  if (!Number.isInteger(cents) || cents < 1) {
153
234
  throw new Error(`paygate: priceCents must be a positive integer. Got ${JSON.stringify(cents)}. If you meant $0.10, use priceCents: 10.`);
154
235
  }
@@ -157,17 +238,78 @@ async function resolvePriceCents(routePrice, request) {
157
238
  const v2 = routePrice.price;
158
239
  if (v2 !== undefined) {
159
240
  warnV2Shim();
160
- const dollars = typeof v2 === 'function' ? await v2(request) : v2;
161
- if (typeof dollars !== 'number' || dollars <= 0) {
162
- throw new Error('paygate: price must be a positive number');
241
+ const dollars = typeof v2 === "function" ? await v2(request) : v2;
242
+ if (typeof dollars !== "number" || dollars <= 0) {
243
+ throw new Error("paygate: price must be a positive number");
163
244
  }
164
245
  return Math.round(dollars * 100);
165
246
  }
166
- throw new Error('paygate: RoutePrice requires `priceCents` (preferred) or `price` (v2 compat)');
247
+ throw new Error("paygate: RoutePrice requires `priceCents` (preferred) or `price` (v2 compat)");
248
+ }
249
+ // ── Self-settle (seller-submitted settlement) ──────────────────────────
250
+ // EIP-3009 transferWithAuthorization, split-signature form — present on
251
+ // USDC and EURC, the assets the default facilitator stands behind.
252
+ const EIP3009_ABI = [
253
+ "function transferWithAuthorization(address from, address to, uint256 value, uint256 validAfter, uint256 validBefore, bytes32 nonce, uint8 v, bytes32 r, bytes32 s)",
254
+ ];
255
+ const DEFAULT_RPC_BY_NETWORK = {
256
+ "eip155:8453": "https://mainnet.base.org",
257
+ "eip155:84532": "https://sepolia.base.org",
258
+ base: "https://mainnet.base.org",
259
+ "base-sepolia": "https://sepolia.base.org",
260
+ };
261
+ async function submitSelfSettle(selfSettle, paymentPayload, accept, timeoutMs) {
262
+ const rpcUrl = selfSettle.rpcUrl ?? DEFAULT_RPC_BY_NETWORK[accept.network];
263
+ if (!rpcUrl) {
264
+ return {
265
+ success: false,
266
+ errorReason: "SELF_SETTLE_CONFIG",
267
+ errorMessage: `paygate: no default RPC for network "${accept.network}" — set selfSettle.rpcUrl`,
268
+ };
269
+ }
270
+ // `ethers` is an optional peer dependency — only self-settle needs it, so
271
+ // the facilitator path never pulls it. Surface a clear install hint rather
272
+ // than a raw MODULE_NOT_FOUND if a self-settle merchant hasn't installed it.
273
+ let ethers;
274
+ try {
275
+ ethers = await import("ethers");
276
+ }
277
+ catch {
278
+ return {
279
+ success: false,
280
+ errorReason: "SELF_SETTLE_CONFIG",
281
+ errorMessage: "paygate: self-settle requires the optional peer dependency `ethers` — run `npm install ethers`",
282
+ };
283
+ }
284
+ try {
285
+ const { JsonRpcProvider, Wallet, Contract, Signature } = ethers;
286
+ const provider = new JsonRpcProvider(rpcUrl);
287
+ const wallet = new Wallet(selfSettle.privateKey, provider);
288
+ const token = new Contract(accept.asset, EIP3009_ABI, wallet);
289
+ const auth = paymentPayload.payload.authorization;
290
+ const sig = Signature.from(paymentPayload.payload.signature);
291
+ const tx = await token.transferWithAuthorization(auth.from, auth.to, auth.value, auth.validAfter, auth.validBefore, auth.nonce, sig.v, sig.r, sig.s);
292
+ const receipt = await tx.wait(1, timeoutMs);
293
+ if (!receipt || receipt.status !== 1) {
294
+ return {
295
+ success: false,
296
+ errorReason: "SETTLEMENT_REVERTED",
297
+ errorMessage: "paygate: self-settle transaction reverted",
298
+ };
299
+ }
300
+ return { success: true, transaction: receipt.hash, payer: auth.from };
301
+ }
302
+ catch (err) {
303
+ return {
304
+ success: false,
305
+ errorReason: "SELF_SETTLE_ERROR",
306
+ errorMessage: err instanceof Error ? err.message : "paygate: self-settle failed",
307
+ };
308
+ }
167
309
  }
168
310
  function decodePaymentHeader(header) {
169
311
  try {
170
- const decoded = Buffer.from(header, 'base64').toString('utf8');
312
+ const decoded = Buffer.from(header, "base64").toString("utf8");
171
313
  return JSON.parse(decoded);
172
314
  }
173
315
  catch {
@@ -179,9 +321,7 @@ export async function handlePaywall(config, routePrice, resourceUrl, paymentHead
179
321
  // Resolve the merchant: v3 fetch vs v2 shim
180
322
  let resolved;
181
323
  try {
182
- resolved = config.merchantId
183
- ? await fetchMerchantConfig(config)
184
- : resolveV2Shim(config);
324
+ resolved = config.merchantId ? await fetchMerchantConfig(config) : resolveV2Shim(config);
185
325
  }
186
326
  catch (err) {
187
327
  return {
@@ -189,8 +329,8 @@ export async function handlePaywall(config, routePrice, resourceUrl, paymentHead
189
329
  error: {
190
330
  statusCode: 500,
191
331
  body: {
192
- error: err instanceof Error ? err.message : 'paygate: configuration error',
193
- code: 'PAYGATE_CONFIG',
332
+ error: err instanceof Error ? err.message : "paygate: configuration error",
333
+ code: "PAYGATE_CONFIG",
194
334
  },
195
335
  },
196
336
  };
@@ -206,39 +346,38 @@ export async function handlePaywall(config, routePrice, resourceUrl, paymentHead
206
346
  error: {
207
347
  statusCode: 429,
208
348
  body: {
209
- error: 'Too many discovery requests. Try again later.',
210
- code: 'DISCOVERY_RATE_LIMITED',
349
+ error: "Too many discovery requests. Try again later.",
350
+ code: "DISCOVERY_RATE_LIMITED",
211
351
  },
212
352
  },
213
353
  };
214
354
  }
215
355
  }
216
- const domainName = USDC_DOMAIN_NAMES[resolved.network] ?? 'USD Coin';
217
356
  const accepts = [
218
357
  {
219
- scheme: 'exact',
358
+ scheme: "exact",
220
359
  network: resolved.network,
221
360
  amount: priceBaseUnits,
222
361
  resource: resourceUrl,
223
362
  asset: resolved.asset,
224
363
  payTo: resolved.payTo,
225
- extra: { name: domainName, version: '2' },
364
+ extra: { name: resolved.domainName, version: "2" },
226
365
  },
227
366
  ];
228
- const requirementsStr = JSON.stringify({ x402Version: 2, accepts }).replace(/[\r\n\0]/g, ' ');
367
+ const challengeBody = {
368
+ error: "Payment Required",
369
+ x402Version: 2,
370
+ accepts,
371
+ facilitator: resolved.facilitator,
372
+ trustMinTier: resolved.trustMinTier,
373
+ gasSponsored: true,
374
+ };
229
375
  return {
230
376
  paid: false,
231
377
  challenge: {
232
378
  statusCode: 402,
233
- headers: { 'X-Payment-Requirements': requirementsStr },
234
- body: {
235
- error: 'Payment Required',
236
- x402Version: 2,
237
- accepts,
238
- facilitator: resolved.facilitator,
239
- trustMinTier: resolved.trustMinTier,
240
- gasSponsored: true,
241
- },
379
+ headers: buildChallengeHeaders(challengeBody, accepts),
380
+ body: challengeBody,
242
381
  },
243
382
  };
244
383
  }
@@ -252,41 +391,90 @@ export async function handlePaywall(config, routePrice, resourceUrl, paymentHead
252
391
  paid: false,
253
392
  error: {
254
393
  statusCode: 400,
255
- body: { error: 'Invalid X-Payment header', code: 'INVALID_PAYMENT' },
394
+ body: { error: "Invalid X-Payment header", code: "INVALID_PAYMENT" },
256
395
  },
257
396
  };
258
397
  }
259
398
  // Settle via facilitator
260
- const domainName = USDC_DOMAIN_NAMES[resolved.network] ?? 'USD Coin';
261
399
  const accept = {
262
- scheme: 'exact',
400
+ scheme: "exact",
263
401
  network: resolved.network,
264
402
  amount: priceBaseUnits,
265
403
  resource: resourceUrl,
266
404
  asset: resolved.asset,
267
405
  payTo: resolved.payTo,
268
- extra: { name: domainName, version: '2' },
406
+ extra: { name: resolved.domainName, version: "2" },
269
407
  };
270
408
  try {
271
- const res = await fetch(`${resolved.facilitator}/settle`, {
272
- method: 'POST',
273
- headers: { 'Content-Type': 'application/json' },
274
- body: JSON.stringify({
275
- x402Version: 2,
276
- paymentPayload,
277
- paymentRequirements: accept,
278
- }),
279
- signal: AbortSignal.timeout(config.timeout ?? 30_000),
280
- });
281
- const settlement = (await res.json());
409
+ let settlement;
410
+ if (config.selfSettle) {
411
+ // Verify via the facilitator (free — no chain writes), then submit
412
+ // the settlement from the seller's own key: gas is the seller's,
413
+ // per the never-sponsor / never-charge policy.
414
+ const verifyRes = await fetch(`${resolved.facilitator}/verify`, {
415
+ method: "POST",
416
+ headers: { "Content-Type": "application/json" },
417
+ body: JSON.stringify({
418
+ x402Version: 2,
419
+ paymentPayload,
420
+ paymentRequirements: accept,
421
+ }),
422
+ signal: AbortSignal.timeout(config.timeout ?? 30_000),
423
+ });
424
+ const verdict = (await verifyRes.json());
425
+ if (!verdict.isValid) {
426
+ return {
427
+ paid: false,
428
+ error: {
429
+ statusCode: 402,
430
+ body: {
431
+ error: verdict.invalidReason || "Payment verification failed",
432
+ code: verdict.invalidReason || "VERIFICATION_FAILED",
433
+ },
434
+ },
435
+ };
436
+ }
437
+ settlement = await submitSelfSettle(config.selfSettle, paymentPayload, accept, config.timeout ?? 30_000);
438
+ }
439
+ else {
440
+ const res = await fetch(`${resolved.facilitator}/settle`, {
441
+ method: "POST",
442
+ headers: { "Content-Type": "application/json" },
443
+ body: JSON.stringify({
444
+ x402Version: 2,
445
+ paymentPayload,
446
+ paymentRequirements: accept,
447
+ }),
448
+ signal: AbortSignal.timeout(config.timeout ?? 30_000),
449
+ });
450
+ settlement = (await res.json());
451
+ }
282
452
  if (!settlement.success) {
283
453
  return {
284
454
  paid: false,
285
455
  error: {
286
456
  statusCode: 402,
287
457
  body: {
288
- error: settlement.errorMessage || settlement.errorReason || 'Payment settlement failed',
289
- code: settlement.errorReason || 'SETTLEMENT_FAILED',
458
+ error: settlement.errorMessage || settlement.errorReason || "Payment settlement failed",
459
+ code: settlement.errorReason || "SETTLEMENT_FAILED",
460
+ },
461
+ },
462
+ };
463
+ }
464
+ // Validate the successful settlement before trusting it (L7). Require a
465
+ // non-empty transaction hash and, when the facilitator reports a payer,
466
+ // ensure it matches the address that signed the authorization.
467
+ const expectedPayer = paymentPayload.payload.authorization.from;
468
+ if (typeof settlement.transaction !== "string" ||
469
+ settlement.transaction.length === 0 ||
470
+ (settlement.payer && settlement.payer.toLowerCase() !== expectedPayer.toLowerCase())) {
471
+ return {
472
+ paid: false,
473
+ error: {
474
+ statusCode: 502,
475
+ body: {
476
+ error: "Facilitator returned an invalid settlement",
477
+ code: "INVALID_SETTLEMENT",
290
478
  },
291
479
  },
292
480
  };
@@ -297,18 +485,18 @@ export async function handlePaywall(config, routePrice, resourceUrl, paymentHead
297
485
  receipt: {
298
486
  paid: true,
299
487
  txHash: settlement.transaction,
300
- trustTier: 'any', // SDK path has no tier check in v1
488
+ trustTier: "any", // SDK path has no tier check in v1
301
489
  merchant: resolved.slug,
302
490
  },
303
491
  };
304
492
  }
305
493
  catch (err) {
306
- const message = err instanceof Error ? err.message : 'Facilitator unreachable';
494
+ const message = err instanceof Error ? err.message : "Facilitator unreachable";
307
495
  return {
308
496
  paid: false,
309
497
  error: {
310
498
  statusCode: 502,
311
- body: { error: `Facilitator error: ${message}`, code: 'FACILITATOR_ERROR' },
499
+ body: { error: `Facilitator error: ${message}`, code: "FACILITATOR_ERROR" },
312
500
  },
313
501
  };
314
502
  }