@odatano/x402 0.2.0 → 0.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.
Files changed (62) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +5 -0
  3. package/cds-plugin.js +2 -0
  4. package/db/x402-grants.cds +49 -0
  5. package/db/x402-receipts.cds +44 -0
  6. package/package.json +4 -3
  7. package/srv/bridge.d.ts +9 -12
  8. package/srv/bridge.js +10 -13
  9. package/srv/client/axios.d.ts +1 -1
  10. package/srv/client/axios.js +47 -8
  11. package/srv/client/envelope.d.ts +1 -1
  12. package/srv/client/envelope.js +1 -1
  13. package/srv/client/errors.d.ts +107 -0
  14. package/srv/client/errors.js +144 -0
  15. package/srv/client/fetch.d.ts +2 -2
  16. package/srv/client/fetch.js +76 -11
  17. package/srv/client/pay-handlers.d.ts +4 -4
  18. package/srv/client/pay-handlers.js +3 -3
  19. package/srv/client/types.d.ts +19 -7
  20. package/srv/client/types.js +3 -3
  21. package/srv/core/asset.d.ts +1 -1
  22. package/srv/core/decode.d.ts +2 -2
  23. package/srv/core/decode.js +5 -5
  24. package/srv/core/errors.js +3 -3
  25. package/srv/core/network.d.ts +1 -1
  26. package/srv/core/network.js +1 -1
  27. package/srv/core/requirements.d.ts +37 -5
  28. package/srv/core/requirements.js +43 -4
  29. package/srv/core/types.d.ts +68 -6
  30. package/srv/core/types.js +3 -3
  31. package/srv/core/validate.d.ts +31 -7
  32. package/srv/core/validate.js +84 -9
  33. package/srv/facilitator/adapter.d.ts +8 -8
  34. package/srv/facilitator/adapter.js +5 -5
  35. package/srv/facilitator/http.d.ts +4 -4
  36. package/srv/facilitator/http.js +5 -5
  37. package/srv/facilitator/nonce.d.ts +4 -4
  38. package/srv/facilitator/nonce.js +4 -4
  39. package/srv/facilitator/server.d.ts +68 -0
  40. package/srv/facilitator/server.js +167 -0
  41. package/srv/facilitator/settle.d.ts +2 -2
  42. package/srv/facilitator/settle.js +4 -4
  43. package/srv/facilitator/verify.d.ts +5 -5
  44. package/srv/facilitator/verify.js +19 -5
  45. package/srv/helpers/build-unsigned-tx.d.ts +5 -5
  46. package/srv/helpers/build-unsigned-tx.js +3 -3
  47. package/srv/helpers/verify-confirmed.d.ts +1 -1
  48. package/srv/helpers/verify-confirmed.js +1 -1
  49. package/srv/index.d.ts +4 -2
  50. package/srv/index.js +9 -3
  51. package/srv/middleware/cap.d.ts +47 -9
  52. package/srv/middleware/cap.js +84 -41
  53. package/srv/middleware/express.d.ts +15 -10
  54. package/srv/middleware/express.js +18 -19
  55. package/srv/middleware/grants.d.ts +64 -0
  56. package/srv/middleware/grants.js +113 -0
  57. package/srv/middleware/pricing.d.ts +41 -0
  58. package/srv/middleware/pricing.js +78 -0
  59. package/srv/middleware/receipts.d.ts +38 -0
  60. package/srv/middleware/receipts.js +68 -0
  61. package/srv/plugin.d.ts +2 -2
  62. package/srv/plugin.js +2 -2
package/CHANGELOG.md CHANGED
@@ -4,6 +4,29 @@ All notable changes to `@odatano/x402` are documented here. The format follows [
4
4
 
5
5
  **Pre-1.0 caveat:** minor versions may include breaking changes until `1.0.0`.
6
6
 
7
+ ## [0.3.0] - 2026-05-15
8
+
9
+ ### Added
10
+ - **`createFacilitatorRouter()`** , reference HTTP facilitator. Returns an Express `Router` exposing `POST /verify-settle`, `GET /supported`, and an open `GET /healthz` liveness probe. Composable with any auth scheme via the `auth(req)` hook; defaults to `localFacilitator()`. Facilitator-side audit hooks (`onRejected`, `onPending`) fill the gap left by `onAccepted` (which is invoked client-side by `httpFacilitator()`). See [`examples/facilitator-server/`](examples/facilitator-server/) and [`docs/facilitator-protocol.md`](docs/facilitator-protocol.md#reference-implementation).
11
+ - **Multi-accept payment options** , `routePricing` (and `priceUnits`) now accept `RouteOption[]` so a single route can offer e.g. "0.5 ADA *or* 0.1 USDM". The buyer picks one implicitly by which `(payTo, asset)` the payment tx credits; new `pickRequirement()` selector in `srv/core/validate.ts` routes the tx to the matching entry before the six strict checks run. Single-entry behaviour is bit-identical to v0.2. New builder: `buildPaymentRequirementsMulti()`.
12
+ - **Dynamic `PriceResolver`** , `routePricing` can be a function `(PricingContext) => PriceSpec | null | Promise<...>`. Returning `null` passes the request through ungated, enabling free-tier, role-based, or per-payload pricing. `PricingContext` exposes `event`, `target` (CAP), `path`/`method`/`query` (Express), and `headers`. See [`docs/usage.md`](docs/usage.md#pricespec-and-priceresolver).
13
+ - **Receipts persistence (CAP)** , new `receipts?: boolean | { entity?: string }` option on `gateService`. When set, one INSERT per accepted payment, post-settle, pre-response. Default entity `odatano.x402.X402Receipts` ships in `db/x402-receipts.cds` and is auto-discovered by CAP. INSERT failures are logged and never block the response. See [`docs/usage.md`](docs/usage.md#receipts-persistence-receipts).
14
+ - **Subscription / time-limited grants (CAP)** , new `grants?: boolean | { ttlSeconds?: number; entity?: string }` option on `gateService`. On accepted payment the gate issues an opaque token and returns it via `X-PAYMENT-GRANT` / `X-PAYMENT-GRANT-EXPIRES` response headers; subsequent requests presenting the token on `X-PAYMENT-GRANT` bypass the 402 + verify+settle pipeline until expiry. Default TTL 3600s. Grants are single-route (strict URL equality). Default entity `odatano.x402.X402Grants` ships in `db/x402-grants.cds`. DB failures during issue or lookup are swallowed: failing DB never denies a paying buyer their response. See [`docs/usage.md`](docs/usage.md#subscription--time-limited-grants-grants).
15
+ - **Typed client errors** , new `X402PaymentError` class (with `kind`, `code`, `accepts`, `httpStatus`, `serverError`, `cause` fields). Thrown by `x402Fetch` and `x402Axios` to surface payment failures. Pay-handler errors are ALWAYS wrapped (with the original on `.cause`); add `errorOnFailure: true` to opt into typed throws on unrecovered 402s instead of the previous return-the-response / re-throw-AxiosError behaviour. Helpers `parseErrorCode` and `paymentErrorFromBody` are exported for consumers wrapping the wrappers. See [`docs/usage.md`](docs/usage.md#client-side-errors-x402paymenterror).
16
+ - **Browser-buyer example** , `examples/browser-buyer/` Vite scaffold showing CIP-30 wallet + `x402Fetch` wiring. Documents the typical "unsigned-from-server, signed-by-wallet" architecture (server exposes `POST /pay/intent` via `buildUnsignedPaymentTx`; browser signs via CIP-30). Includes CORS notes for cross-origin deployments.
17
+
18
+ ### Fixed
19
+ - **`x402Fetch` / `x402Axios` now interop with `gateService`** out of the box. CAP's `req.reject(402, body)` wraps the canonical v2 body inside its standard OData error envelope (`{ error: { message: "<json>", code: "402", ... } }`), so previous client wrappers saw `body.x402Version === undefined` and bailed without retrying. Both clients now defensively unwrap the OData envelope before validating shape. Reported by the CHAINFEED team; matches the workaround they were shipping in `scripts/buyer-pay-and-fetch.ts`. New helper `unwrapCapEnvelope` is exported for consumers wrapping the wrappers.
20
+
21
+ ### Known issues
22
+ - The CAP `gateService` still emits 402 responses wrapped in CAP's OData error envelope on the wire (because `req.reject` is the only documented abort path). Third-party x402 clients hitting a CAP-gated server will see the wrapped shape; only `@odatano/x402`'s own clients unwrap it. Direct-write-to-`req.http.res` is the planned symmetric fix but needs validation against `@sap/cds` ^9 internals; tracked for v0.3.1.
23
+ - `PaymentClaim.payTo` , verified recipient address now populated on the claim (was previously only on the requirements entry). Useful for `onAccepted` audit and receipts.
24
+
25
+ ### Changed
26
+ - Test count: 177 → 230 (HTTP-server round-trips, multi-accept + dynamic-pricing across `requirements`/`validate`/`verify`/`cap`/`express`, 4 receipts cases, 5 grants cases, 13 client-error cases).
27
+ - `srv/middleware/{cap,express}.ts` now emit `accepts[]` via `buildPaymentRequirementsMulti()`; single-entry callers are unaffected (one-entry array produces a body byte-identical to v0.2).
28
+ - `srv/facilitator/verify.ts` decodes the envelope BEFORE selecting a requirements entry; multi-accept depends on knowing which `(payTo, asset)` the tx actually credited.
29
+
7
30
  ## [0.2.0] - 2026-05-15
8
31
 
9
32
  ### Added
package/README.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # @odatano/x402
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/@odatano/x402?color=cb3837&logo=npm)](https://www.npmjs.com/package/@odatano/x402)
4
+ [![tests](https://github.com/ODATANO/x402/actions/workflows/test.yaml/badge.svg)](https://github.com/ODATANO/x402/actions/workflows/test.yaml)
5
+ [![@odatano/core](https://img.shields.io/github/package-json/dependency-version/ODATANO/x402/peer/@odatano/core?label=%40odatano%2Fcore&color=0e7c66)](https://www.npmjs.com/package/@odatano/core)
6
+ [![spec](https://img.shields.io/badge/Cardano--x402-v2-blueviolet)](https://github.com/masumi-network/x402-cardano)
7
+
3
8
  x402 payment gating for SAP CAP applications, backed by Cardano.
4
9
 
5
10
  Wire a single `before('*')` hook into your CAP service. Every gated request returns **HTTP 402 Payment Required** until the caller proves on-chain settlement. Asset-agnostic: pay in ADA, USDM, or any native asset.
package/cds-plugin.js CHANGED
@@ -7,4 +7,6 @@
7
7
  * (outDir: ".") so this require path resolves both in dev (tsx) and
8
8
  * after `npm run build`.
9
9
  */
10
+ // CAP loads this file as a CommonJS entrypoint, so `require()` is required here.
11
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
10
12
  module.exports = require('./srv/plugin');
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Time-limited access grants issued after an accepted x402 payment.
3
+ *
4
+ * Pattern: pay once, get N seconds of free access to the same route.
5
+ * The server issues an opaque random token on `accepted`, returns it via
6
+ * the `X-PAYMENT-GRANT` response header, and the buyer presents it on
7
+ * subsequent requests via `X-PAYMENT-GRANT` request header. Until the
8
+ * grant expires, the gate skips the 402 + verify+settle pipeline.
9
+ *
10
+ * Replay defense: each grant is single-route + time-bound. A grant
11
+ * issued for `/Quotes` cannot be used against `/getBestPrice` (cheap
12
+ * route boundary, the picker enforces equality). Stolen tokens are
13
+ * useful only until expiry.
14
+ *
15
+ * Cleanup: expired rows accumulate. The `lookupGrant` helper checks
16
+ * `expiresAt < now` and reports `expired`; consumers may run their own
17
+ * cleanup (`DELETE … WHERE expiresAt < now()`) on whatever schedule
18
+ * fits. The library does NOT auto-prune.
19
+ */
20
+
21
+ namespace odatano.x402;
22
+
23
+ entity X402Grants {
24
+ key id : UUID;
25
+
26
+ @description: 'Opaque random token, base64url 32 bytes.'
27
+ token : String(64) @assert.unique;
28
+
29
+ @description: 'Resource URL this grant unlocks (exact match).'
30
+ route : String(500);
31
+
32
+ @description: 'Sender address from the original payment (if resolved).'
33
+ payerAddr : String(120);
34
+
35
+ @description: 'Tx hash of the payment that bought this grant.'
36
+ txHash : String(64);
37
+
38
+ @description: 'Asset of the underlying payment, audit only.'
39
+ asset : String(120);
40
+
41
+ @description: 'cardano:mainnet | cardano:preprod | cardano:preview.'
42
+ network : String(20);
43
+
44
+ @description: 'Server-side timestamp at issue.'
45
+ issuedAt : Timestamp;
46
+
47
+ @description: 'Server-side timestamp when the grant becomes invalid.'
48
+ expiresAt : Timestamp;
49
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Optional persistence for accepted x402 payments.
3
+ *
4
+ * Enabled per-service by setting `receipts: true` on `gateService(opts)`.
5
+ * The gate writes one row per accepted payment, after settle confirms,
6
+ * BEFORE the response is served. Insert failures are logged and never
7
+ * block the response, the canonical record is on chain regardless.
8
+ *
9
+ * Namespace and entity name are stable, consumers can SELECT against
10
+ * `odatano.x402.X402Receipts` directly or extend it in their own model.
11
+ */
12
+
13
+ namespace odatano.x402;
14
+
15
+ entity X402Receipts {
16
+ key id : UUID;
17
+
18
+ @description: 'Lowercase 64-char hex of the buyer''s settled payment tx.'
19
+ txHash : String(64) @assert.unique;
20
+
21
+ @description: 'Sender address (first input bech32) if the facilitator resolved it.'
22
+ payerAddr : String(120);
23
+
24
+ @description: 'Recipient bech32 the route required.'
25
+ payTo : String(120);
26
+
27
+ @description: 'v2 asset string, ''lovelace'' or ''<policy>.<nameHex>''.'
28
+ asset : String(120);
29
+
30
+ @description: 'Amount paid in raw units, BigInt-safe string.'
31
+ amount : String(32);
32
+
33
+ @description: 'cardano:mainnet | cardano:preprod | cardano:preview.'
34
+ network : String(20);
35
+
36
+ @description: 'Resource URL the buyer paid for (request originalUrl or cap://<event>).'
37
+ route : String(500);
38
+
39
+ @description: '<txHash>#<index> of the UTxO that was the replay nonce.'
40
+ nonceRef : String(80);
41
+
42
+ @description: 'Server-side timestamp when the receipt was written (post-settle).'
43
+ at : Timestamp;
44
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@odatano/x402",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "x402 Cardano-v2 payment library for SAP CAP applications",
5
5
  "license": "Apache-2.0",
6
6
  "main": "srv/index.js",
@@ -37,8 +37,8 @@
37
37
  }
38
38
  },
39
39
  "devDependencies": {
40
- "@cap-js/cds-types": "^0.15.0",
41
40
  "@cap-js/cds-typer": "^0.38.0",
41
+ "@cap-js/cds-types": "^0.15.0",
42
42
  "@cap-js/sqlite": "^2",
43
43
  "@odatano/core": "^1.7.8",
44
44
  "@odatano/x402": ".",
@@ -53,6 +53,7 @@
53
53
  "jest": "^29",
54
54
  "ts-jest": "^29",
55
55
  "tsx": "^4",
56
- "typescript": "^5"
56
+ "typescript": "^5",
57
+ "typescript-eslint": "^8.59.3"
57
58
  }
58
59
  }
package/srv/bridge.d.ts CHANGED
@@ -3,18 +3,15 @@
3
3
  *
4
4
  * The x402 modules (facilitator, helpers, middleware) all import from
5
5
  * here so the underlying ODATANO surface is the only thing they couple
6
- * to and so renames in core (`getTransaction` → `getTransactionByHash`)
6
+ * to, and so renames in core (`getTransaction` → `getTransactionByHash`)
7
7
  * stay isolated to this file.
8
8
  *
9
- * Two methods needed by Cardano-x402-v2 are NOT (yet) first-class on
10
- * `@odatano/core@1.7.7`:
11
- * - `isUtxoUnspent(txHash, outputIndex)` for replay-defense check 5b
12
- * - `getCurrentSlot()` for TTL check 6
9
+ * Two methods specific to Cardano-x402-v2 are first-class on
10
+ * `@odatano/core` since `1.7.8` (our minimum peer):
11
+ * - `isUtxoUnspent(txHash, outputIndex)` for replay-defense check 5b
12
+ * - `getCurrentSlot()` for TTL check 6
13
13
  *
14
- * Both are implemented here as **shims** on top of existing core
15
- * methods, so x402 works against an unmodified 1.7.7. When ODATANO
16
- * exposes either method natively (planned ≥1.7.8), we can swap the
17
- * shim for a direct call without touching downstream code.
14
+ * Both are called through directly here; no shim layer remains.
18
15
  */
19
16
  export interface BridgeAsset {
20
17
  unit: string;
@@ -49,18 +46,18 @@ export declare function getProtocolParameters(): Promise<unknown>;
49
46
  export declare function submitTransaction(signedCborHex: string): Promise<string>;
50
47
  /**
51
48
  * Current chain tip slot. First-class method on `CardanoClient` since
52
- * `@odatano/core@1.7.8` wraps `getLatestBlock().slot` with a
49
+ * `@odatano/core@1.7.8`, wraps `getLatestBlock().slot` with a
53
50
  * `ProviderUnavailableError` translation so consumers don't deal with
54
51
  * `null` slots.
55
52
  */
56
53
  export declare function getCurrentSlot(): Promise<number>;
57
54
  /**
58
55
  * Check whether a UTxO is still unspent. First-class method since
59
- * `@odatano/core@1.7.8` backed by `consumed_by` (Blockfrost) /
56
+ * `@odatano/core@1.7.8`, backed by `consumed_by` (Blockfrost) /
60
57
  * `is_spent` (Koios) / `queryLedgerState/utxo` (Ogmios).
61
58
  *
62
59
  * Returns `false` for txs that don't exist on chain or for
63
- * out-of-range output indices both are "not spendable" from the
60
+ * out-of-range output indices, both are "not spendable" from the
64
61
  * caller's perspective.
65
62
  */
66
63
  export declare function isUtxoUnspent(txHash: string, outputIndex: number): Promise<boolean>;
package/srv/bridge.js CHANGED
@@ -4,18 +4,15 @@
4
4
  *
5
5
  * The x402 modules (facilitator, helpers, middleware) all import from
6
6
  * here so the underlying ODATANO surface is the only thing they couple
7
- * to and so renames in core (`getTransaction` → `getTransactionByHash`)
7
+ * to, and so renames in core (`getTransaction` → `getTransactionByHash`)
8
8
  * stay isolated to this file.
9
9
  *
10
- * Two methods needed by Cardano-x402-v2 are NOT (yet) first-class on
11
- * `@odatano/core@1.7.7`:
12
- * - `isUtxoUnspent(txHash, outputIndex)` for replay-defense check 5b
13
- * - `getCurrentSlot()` for TTL check 6
10
+ * Two methods specific to Cardano-x402-v2 are first-class on
11
+ * `@odatano/core` since `1.7.8` (our minimum peer):
12
+ * - `isUtxoUnspent(txHash, outputIndex)` for replay-defense check 5b
13
+ * - `getCurrentSlot()` for TTL check 6
14
14
  *
15
- * Both are implemented here as **shims** on top of existing core
16
- * methods, so x402 works against an unmodified 1.7.7. When ODATANO
17
- * exposes either method natively (planned ≥1.7.8), we can swap the
18
- * shim for a direct call without touching downstream code.
15
+ * Both are called through directly here; no shim layer remains.
19
16
  */
20
17
  Object.defineProperty(exports, "__esModule", { value: true });
21
18
  exports.parseTransaction = void 0;
@@ -120,7 +117,7 @@ async function submitTransaction(signedCborHex) {
120
117
  }
121
118
  /**
122
119
  * Current chain tip slot. First-class method on `CardanoClient` since
123
- * `@odatano/core@1.7.8` wraps `getLatestBlock().slot` with a
120
+ * `@odatano/core@1.7.8`, wraps `getLatestBlock().slot` with a
124
121
  * `ProviderUnavailableError` translation so consumers don't deal with
125
122
  * `null` slots.
126
123
  */
@@ -130,11 +127,11 @@ async function getCurrentSlot() {
130
127
  }
131
128
  /**
132
129
  * Check whether a UTxO is still unspent. First-class method since
133
- * `@odatano/core@1.7.8` backed by `consumed_by` (Blockfrost) /
130
+ * `@odatano/core@1.7.8`, backed by `consumed_by` (Blockfrost) /
134
131
  * `is_spent` (Koios) / `queryLedgerState/utxo` (Ogmios).
135
132
  *
136
133
  * Returns `false` for txs that don't exist on chain or for
137
- * out-of-range output indices both are "not spendable" from the
134
+ * out-of-range output indices, both are "not spendable" from the
138
135
  * caller's perspective.
139
136
  */
140
137
  async function isUtxoUnspent(txHash, outputIndex) {
@@ -150,6 +147,6 @@ async function isUtxoUnspent(txHash, outputIndex) {
150
147
  // parseTransaction is exported from @odatano/core's barrel and runs
151
148
  // entirely client-side. Re-export so x402 users don't need a second
152
149
  // import for tx introspection. We declare the type loosely (unknown
153
- // CBOR-parsed shape) consumers cast to ODATANO's `ParsedTransaction`
150
+ // CBOR-parsed shape), consumers cast to ODATANO's `ParsedTransaction`
154
151
  // from `@odatano/core` directly if they need the structured fields.
155
152
  exports.parseTransaction = od ? od.parseTransaction : undefined;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `x402Axios` attach a response interceptor to an existing axios
2
+ * `x402Axios`, attach a response interceptor to an existing axios
3
3
  * instance so 402 responses trigger a payment and retry.
4
4
  *
5
5
  * **No hard axios dependency.** We use structural typing for the
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  /**
3
- * `x402Axios` attach a response interceptor to an existing axios
3
+ * `x402Axios`, attach a response interceptor to an existing axios
4
4
  * instance so 402 responses trigger a payment and retry.
5
5
  *
6
6
  * **No hard axios dependency.** We use structural typing for the
@@ -19,6 +19,7 @@
19
19
  Object.defineProperty(exports, "__esModule", { value: true });
20
20
  exports.x402Axios = x402Axios;
21
21
  const envelope_1 = require("./envelope");
22
+ const errors_1 = require("./errors");
22
23
  // Marker key on the config to break infinite-retry loops.
23
24
  const RETRY_KEY = '__x402_x402Retries';
24
25
  function isAxiosError(e) {
@@ -36,26 +37,64 @@ function x402Axios(instance, opts) {
36
37
  const maxRetries = opts.maxRetries ?? 1;
37
38
  const selectFirst = (a) => a[0];
38
39
  const select = opts.selectAccepts ?? selectFirst;
40
+ function maybeWrap(body, kind, httpStatus, cause) {
41
+ if (body && body.x402Version === 2 && Array.isArray(body.accepts)) {
42
+ return (0, errors_1.paymentErrorFromBody)(body, { kind, httpStatus, cause });
43
+ }
44
+ return new errors_1.X402PaymentError({
45
+ message: `x402Axios: ${kind.replace('_', ' ')}`,
46
+ kind: 'invalid_402_body',
47
+ httpStatus,
48
+ ...(cause !== undefined ? { cause } : {}),
49
+ });
50
+ }
39
51
  instance.interceptors.response.use((response) => response, async (error) => {
40
52
  if (!isAxiosError(error) || error.response?.status !== 402 || !error.config) {
41
53
  throw error;
42
54
  }
43
55
  const cfg = error.config;
44
56
  const retries = Number(cfg[RETRY_KEY] ?? 0);
45
- if (retries >= maxRetries)
57
+ // CAP-style servers wrap the v2 body inside an OData error envelope.
58
+ // Defensively unwrap before validating shape.
59
+ const body = (0, errors_1.unwrapCapEnvelope)(error.response.data);
60
+ const status = error.response.status;
61
+ if (retries >= maxRetries) {
62
+ if (opts.errorOnFailure) {
63
+ throw maybeWrap(body, 'retries_exhausted', status, error);
64
+ }
46
65
  throw error;
47
- const body = error.response.data;
48
- if (body?.x402Version !== 2 || !Array.isArray(body.accepts) || body.accepts.length === 0) {
66
+ }
67
+ if (!body || body.x402Version !== 2 || !Array.isArray(body.accepts) || body.accepts.length === 0) {
68
+ if (opts.errorOnFailure) {
69
+ throw maybeWrap(body, 'invalid_402_body', status, error);
70
+ }
49
71
  throw error;
50
72
  }
51
73
  const chosen = select(body.accepts);
52
- if (!chosen)
74
+ if (!chosen) {
75
+ if (opts.errorOnFailure) {
76
+ throw maybeWrap(body, 'server_rejected', status, error);
77
+ }
53
78
  throw error;
54
- const { signedTxCborHex, nonceRef } = await opts.pay(chosen);
79
+ }
80
+ // Wrap pay-handler throws unconditionally, callers benefit from
81
+ // the structured shape regardless of errorOnFailure.
82
+ let payResult;
83
+ try {
84
+ payResult = await opts.pay(chosen);
85
+ }
86
+ catch (err) {
87
+ throw new errors_1.X402PaymentError({
88
+ message: `x402Axios: pay handler failed: ${err?.message ?? String(err)}`,
89
+ kind: 'pay_handler_failed',
90
+ accepts: body.accepts,
91
+ cause: err,
92
+ });
93
+ }
55
94
  const header = (0, envelope_1.encodePaymentEnvelope)({
56
95
  network: chosen.network,
57
- signedTxCborHex,
58
- nonceRef,
96
+ signedTxCborHex: payResult.signedTxCborHex,
97
+ nonceRef: payResult.nonceRef,
59
98
  });
60
99
  const nextCfg = {
61
100
  ...cfg,
@@ -13,7 +13,7 @@
13
13
  * }
14
14
  * }))
15
15
  *
16
- * Pure function no chain calls, no I/O. Callable from any runtime
16
+ * Pure function, no chain calls, no I/O. Callable from any runtime
17
17
  * that has `Buffer` (Node) or a polyfill (browser bundlers usually
18
18
  * provide one via `buffer`).
19
19
  */
@@ -14,7 +14,7 @@
14
14
  * }
15
15
  * }))
16
16
  *
17
- * Pure function no chain calls, no I/O. Callable from any runtime
17
+ * Pure function, no chain calls, no I/O. Callable from any runtime
18
18
  * that has `Buffer` (Node) or a polyfill (browser bundlers usually
19
19
  * provide one via `buffer`).
20
20
  */
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Typed client-side errors thrown by `x402Fetch` / `x402Axios`.
3
+ *
4
+ * The server-side `X402Error` (in `srv/core/errors.ts`) is a different
5
+ * class with a different purpose: it's thrown inside the decode +
6
+ * validate pipeline and carries one of the canonical `X402Code` values.
7
+ *
8
+ * `X402PaymentError` here is the client-facing analog: it surfaces in
9
+ * caller code (browser, CLI, server-to-server) when a payment attempt
10
+ * cannot complete. Callers can `instanceof`-check or pattern-match on
11
+ * `.kind` to distinguish:
12
+ *
13
+ * - 'server_rejected' , the server returned 402 with a structured
14
+ * body. `code` carries the server's
15
+ * canonical X402Code (e.g. `wrong_recipient`),
16
+ * `serverError` carries the raw error string
17
+ * for human display.
18
+ * - 'pay_handler_failed' , the user-supplied `pay` callback threw
19
+ * (wallet rejection, no funds, signer error,
20
+ * etc). `cause` holds the original error.
21
+ * - 'retries_exhausted' , the request was still 402 after
22
+ * `maxRetries` payment attempts. Same shape
23
+ * as `server_rejected` but indicates
24
+ * repeated failure.
25
+ * - 'invalid_402_body' , the server returned 402 but the body
26
+ * wasn't a v2 PaymentRequirementsBody.
27
+ *
28
+ * The class is plain (no abstract methods, no fluent builders) so users
29
+ * can construct it themselves if they're wrapping the wrappers.
30
+ */
31
+ import type { PaymentRequirementEntry, PaymentRequirementsBody } from '../core/types';
32
+ export type X402PaymentErrorKind = 'server_rejected' | 'retries_exhausted' | 'pay_handler_failed' | 'invalid_402_body';
33
+ export interface X402PaymentErrorInit {
34
+ message: string;
35
+ kind: X402PaymentErrorKind;
36
+ /** Canonical X402Code from the server, when known. */
37
+ code?: string;
38
+ /** `accepts[]` from the 402 body, for the caller to retry against. */
39
+ accepts?: PaymentRequirementEntry[];
40
+ /** HTTP status code that triggered the error (usually 402). */
41
+ httpStatus?: number;
42
+ /** Verbatim `error` string from the 402 body, for human display. */
43
+ serverError?: string;
44
+ /** Wrapped underlying error (wallet rejection, axios error, etc.). */
45
+ cause?: unknown;
46
+ }
47
+ /**
48
+ * Thrown by `x402Fetch` and `x402Axios` when a payment attempt fails or
49
+ * is exhausted.
50
+ *
51
+ * `instanceof X402PaymentError` is the reliable runtime check; the
52
+ * `.kind` field is the discriminator for switching on cause.
53
+ */
54
+ export declare class X402PaymentError extends Error {
55
+ readonly kind: X402PaymentErrorKind;
56
+ readonly code?: string;
57
+ readonly accepts?: PaymentRequirementEntry[];
58
+ readonly httpStatus?: number;
59
+ readonly serverError?: string;
60
+ readonly cause?: unknown;
61
+ constructor(init: X402PaymentErrorInit);
62
+ }
63
+ /**
64
+ * Defensively unwrap a CAP / OData error envelope around a v2 body.
65
+ *
66
+ * Background: `gateService` in this package historically (≤ v0.2)
67
+ * reaches a 402 via `req.reject(402, JSON.stringify(body))`, which
68
+ * CAP wraps into its standard OData error shape, putting the canonical
69
+ * v2 body inside `error.message` as a JSON string:
70
+ *
71
+ * { "error": { "message": "{\"x402Version\":2, ... }", "code": "402", ... } }
72
+ *
73
+ * The Express middleware emits the v2 body at the top level directly.
74
+ * This helper detects the CAP wrap and returns the unwrapped candidate
75
+ * so client wrappers can validate v2 shape uniformly. Non-CAP bodies
76
+ * pass through untouched.
77
+ *
78
+ * Symmetric server-side fix (emit canonical body even through CAP) is
79
+ * a separate concern, deferred until we can validate the CAP-version
80
+ * specific behaviour. The unwrap below keeps `x402Fetch` /
81
+ * `x402Axios` working against both shapes.
82
+ */
83
+ export declare function unwrapCapEnvelope(parsed: unknown): unknown;
84
+ /**
85
+ * Parse the (server, canonical) error string from a `PaymentRequirementsBody`.
86
+ *
87
+ * The middleware encodes failures as `"<base error> (<code>): <reason>"`
88
+ * (see `cap.ts` / `express.ts`). We pull the code out so callers can
89
+ * dispatch on it without re-parsing the wire string. The reason is
90
+ * preserved in `.serverError`.
91
+ *
92
+ * Returns `undefined` when the body has no recognizable code (e.g. the
93
+ * MISSING_HEADER path, where the middleware omits the parenthesised
94
+ * suffix).
95
+ */
96
+ export declare function parseErrorCode(serverError?: string): string | undefined;
97
+ /**
98
+ * Build an `X402PaymentError` from a parsed 402 body. `kind` defaults
99
+ * to `server_rejected`; pass `'retries_exhausted'` when called after
100
+ * the retry loop gave up.
101
+ */
102
+ export declare function paymentErrorFromBody(body: PaymentRequirementsBody, init?: {
103
+ kind?: X402PaymentErrorKind;
104
+ httpStatus?: number;
105
+ cause?: unknown;
106
+ }): X402PaymentError;
107
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1,144 @@
1
+ "use strict";
2
+ /**
3
+ * Typed client-side errors thrown by `x402Fetch` / `x402Axios`.
4
+ *
5
+ * The server-side `X402Error` (in `srv/core/errors.ts`) is a different
6
+ * class with a different purpose: it's thrown inside the decode +
7
+ * validate pipeline and carries one of the canonical `X402Code` values.
8
+ *
9
+ * `X402PaymentError` here is the client-facing analog: it surfaces in
10
+ * caller code (browser, CLI, server-to-server) when a payment attempt
11
+ * cannot complete. Callers can `instanceof`-check or pattern-match on
12
+ * `.kind` to distinguish:
13
+ *
14
+ * - 'server_rejected' , the server returned 402 with a structured
15
+ * body. `code` carries the server's
16
+ * canonical X402Code (e.g. `wrong_recipient`),
17
+ * `serverError` carries the raw error string
18
+ * for human display.
19
+ * - 'pay_handler_failed' , the user-supplied `pay` callback threw
20
+ * (wallet rejection, no funds, signer error,
21
+ * etc). `cause` holds the original error.
22
+ * - 'retries_exhausted' , the request was still 402 after
23
+ * `maxRetries` payment attempts. Same shape
24
+ * as `server_rejected` but indicates
25
+ * repeated failure.
26
+ * - 'invalid_402_body' , the server returned 402 but the body
27
+ * wasn't a v2 PaymentRequirementsBody.
28
+ *
29
+ * The class is plain (no abstract methods, no fluent builders) so users
30
+ * can construct it themselves if they're wrapping the wrappers.
31
+ */
32
+ Object.defineProperty(exports, "__esModule", { value: true });
33
+ exports.X402PaymentError = void 0;
34
+ exports.unwrapCapEnvelope = unwrapCapEnvelope;
35
+ exports.parseErrorCode = parseErrorCode;
36
+ exports.paymentErrorFromBody = paymentErrorFromBody;
37
+ /**
38
+ * Thrown by `x402Fetch` and `x402Axios` when a payment attempt fails or
39
+ * is exhausted.
40
+ *
41
+ * `instanceof X402PaymentError` is the reliable runtime check; the
42
+ * `.kind` field is the discriminator for switching on cause.
43
+ */
44
+ class X402PaymentError extends Error {
45
+ kind;
46
+ code;
47
+ accepts;
48
+ httpStatus;
49
+ serverError;
50
+ // Override Error's `cause` typing, ours is `unknown` to allow any value.
51
+ cause;
52
+ constructor(init) {
53
+ super(init.message);
54
+ this.name = 'X402PaymentError';
55
+ this.kind = init.kind;
56
+ if (init.code !== undefined)
57
+ this.code = init.code;
58
+ if (init.accepts !== undefined)
59
+ this.accepts = init.accepts;
60
+ if (init.httpStatus !== undefined)
61
+ this.httpStatus = init.httpStatus;
62
+ if (init.serverError !== undefined)
63
+ this.serverError = init.serverError;
64
+ if (init.cause !== undefined)
65
+ this.cause = init.cause;
66
+ // V8: keep stack trace pointing at caller, not constructor.
67
+ if (typeof Error.captureStackTrace === 'function') {
68
+ Error
69
+ .captureStackTrace(this, X402PaymentError);
70
+ }
71
+ }
72
+ }
73
+ exports.X402PaymentError = X402PaymentError;
74
+ /**
75
+ * Defensively unwrap a CAP / OData error envelope around a v2 body.
76
+ *
77
+ * Background: `gateService` in this package historically (≤ v0.2)
78
+ * reaches a 402 via `req.reject(402, JSON.stringify(body))`, which
79
+ * CAP wraps into its standard OData error shape, putting the canonical
80
+ * v2 body inside `error.message` as a JSON string:
81
+ *
82
+ * { "error": { "message": "{\"x402Version\":2, ... }", "code": "402", ... } }
83
+ *
84
+ * The Express middleware emits the v2 body at the top level directly.
85
+ * This helper detects the CAP wrap and returns the unwrapped candidate
86
+ * so client wrappers can validate v2 shape uniformly. Non-CAP bodies
87
+ * pass through untouched.
88
+ *
89
+ * Symmetric server-side fix (emit canonical body even through CAP) is
90
+ * a separate concern, deferred until we can validate the CAP-version
91
+ * specific behaviour. The unwrap below keeps `x402Fetch` /
92
+ * `x402Axios` working against both shapes.
93
+ */
94
+ function unwrapCapEnvelope(parsed) {
95
+ if (!parsed || typeof parsed !== 'object')
96
+ return parsed;
97
+ const maybeError = parsed.error;
98
+ if (!maybeError || typeof maybeError !== 'object')
99
+ return parsed;
100
+ const msg = maybeError.message;
101
+ if (typeof msg !== 'string')
102
+ return parsed;
103
+ try {
104
+ return JSON.parse(msg);
105
+ }
106
+ catch {
107
+ return parsed;
108
+ }
109
+ }
110
+ /**
111
+ * Parse the (server, canonical) error string from a `PaymentRequirementsBody`.
112
+ *
113
+ * The middleware encodes failures as `"<base error> (<code>): <reason>"`
114
+ * (see `cap.ts` / `express.ts`). We pull the code out so callers can
115
+ * dispatch on it without re-parsing the wire string. The reason is
116
+ * preserved in `.serverError`.
117
+ *
118
+ * Returns `undefined` when the body has no recognizable code (e.g. the
119
+ * MISSING_HEADER path, where the middleware omits the parenthesised
120
+ * suffix).
121
+ */
122
+ function parseErrorCode(serverError) {
123
+ if (!serverError)
124
+ return undefined;
125
+ const m = serverError.match(/\(([a-z_]+)\)/);
126
+ return m ? m[1] : undefined;
127
+ }
128
+ /**
129
+ * Build an `X402PaymentError` from a parsed 402 body. `kind` defaults
130
+ * to `server_rejected`; pass `'retries_exhausted'` when called after
131
+ * the retry loop gave up.
132
+ */
133
+ function paymentErrorFromBody(body, init = {}) {
134
+ const code = parseErrorCode(body.error);
135
+ return new X402PaymentError({
136
+ message: body.error ?? 'payment required',
137
+ kind: init.kind ?? 'server_rejected',
138
+ ...(code !== undefined ? { code } : {}),
139
+ accepts: body.accepts,
140
+ httpStatus: init.httpStatus ?? 402,
141
+ ...(body.error !== undefined ? { serverError: body.error } : {}),
142
+ ...(init.cause !== undefined ? { cause: init.cause } : {}),
143
+ });
144
+ }