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 +20 -0
- package/dist/core.d.ts +57 -2
- package/dist/core.d.ts.map +1 -1
- package/dist/core.js +263 -75
- package/dist/core.js.map +1 -1
- package/dist/core.test.js +287 -30
- package/dist/core.test.js.map +1 -1
- package/dist/express.d.ts +2 -2
- package/dist/express.d.ts.map +1 -1
- package/dist/express.js +10 -10
- package/dist/fastify.d.ts +3 -3
- package/dist/fastify.d.ts.map +1 -1
- package/dist/fastify.js +13 -11
- package/dist/fastify.js.map +1 -1
- package/dist/ghost-partner.d.ts +58 -0
- package/dist/ghost-partner.d.ts.map +1 -0
- package/dist/ghost-partner.js +95 -0
- package/dist/ghost-partner.js.map +1 -0
- package/dist/ghost-partner.test.d.ts +2 -0
- package/dist/ghost-partner.test.d.ts.map +1 -0
- package/dist/ghost-partner.test.js +101 -0
- package/dist/ghost-partner.test.js.map +1 -0
- package/dist/index.d.ts +7 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -4
- package/dist/index.js.map +1 -1
- package/package.json +24 -10
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
|
-
*
|
|
41
|
-
*
|
|
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. */
|
package/dist/core.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"core.d.ts","sourceRoot":"","sources":["../src/core.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAGH,eAAO,MAAM,mBAAmB,oCAAoC,CAAC;AACrE,eAAO,MAAM,eAAe,4BAA4B,CAAC;AACzD,eAAO,MAAM,uBAAuB,QAAgB,CAAC;
|
|
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 =
|
|
15
|
-
export const DEFAULT_API_URL =
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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(
|
|
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(
|
|
132
|
+
throw new Error("paygate: merchantId is required for v3 flow");
|
|
71
133
|
}
|
|
72
|
-
const
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
112
|
-
trustMinTier: manifest.trust?.minTier ??
|
|
189
|
+
facilitator,
|
|
190
|
+
trustMinTier: manifest.trust?.minTier ?? "any",
|
|
113
191
|
slug: manifest.merchant.slug,
|
|
114
192
|
fetchedAt: Date.now(),
|
|
115
193
|
};
|
|
116
|
-
capabilityCache.set(
|
|
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(
|
|
205
|
+
throw new Error("paygate: wallet is required in v2-shim mode");
|
|
128
206
|
}
|
|
129
207
|
if (!config.network) {
|
|
130
|
-
throw new Error(
|
|
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
|
|
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:
|
|
143
|
-
slug:
|
|
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 ===
|
|
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 ===
|
|
161
|
-
if (typeof dollars !==
|
|
162
|
-
throw new Error(
|
|
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(
|
|
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,
|
|
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 :
|
|
193
|
-
code:
|
|
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:
|
|
210
|
-
code:
|
|
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:
|
|
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:
|
|
364
|
+
extra: { name: resolved.domainName, version: "2" },
|
|
226
365
|
},
|
|
227
366
|
];
|
|
228
|
-
const
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
406
|
+
extra: { name: resolved.domainName, version: "2" },
|
|
269
407
|
};
|
|
270
408
|
try {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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 ||
|
|
289
|
-
code: settlement.errorReason ||
|
|
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:
|
|
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 :
|
|
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:
|
|
499
|
+
body: { error: `Facilitator error: ${message}`, code: "FACILITATOR_ERROR" },
|
|
312
500
|
},
|
|
313
501
|
};
|
|
314
502
|
}
|