@odatano/x402 0.2.0 → 0.3.1

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 +35 -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 +45 -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 +86 -0
  14. package/srv/client/errors.js +107 -0
  15. package/srv/client/fetch.d.ts +2 -2
  16. package/srv/client/fetch.js +71 -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 +111 -43
  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,41 @@ 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.1] - 2026-05-15
8
+
9
+ ### Fixed
10
+ - **`gateService` now emits the canonical x402 v2 body on the wire** instead of CAP's OData-wrapped shape. The gate writes `httpRes.status(402).json(body)` directly when an Express response is reachable, then calls `req.reject(402, ...)` as the chain-terminator: the synchronous throw stops CAP's handler pipeline so the gated `on` handler never runs, and CAP's render attempt no-ops on `headersSent`. Non-HTTP transports (event invocations, `$batch` reuse) fall back to plain `req.reject`. Validated against `@sap/cds ^9`. Third-party x402 clients now interop with CAP-gated services without any unwrap shim. Closes the "Known issues" item from 0.3.0.
11
+
12
+ ### Removed (breaking)
13
+ - **`unwrapCapEnvelope`** helper and its calls from `x402Fetch` / `x402Axios`. With the server fix above, the v2 body lands at the top level on the wire and the defensive unwrap is dead code. The export is gone; consumers who pulled it in (e.g. for wrapping the wrappers) should remove the import. **Pair the upgrade**: if you upgrade the `@odatano/x402` client to 0.3.1, upgrade the server in the same step, since 0.3.1 clients no longer unwrap a 0.3.0-style wrapped body.
14
+
15
+ ### Changed
16
+ - `srv/middleware/cap.ts` , new `send402` helper and `getHttpRes` accessor; the one 402 emit site routes through `send402`. The two 500 `req.reject` paths (pricing-resolver throw, facilitator throw) are unchanged.
17
+ - Test suite: 232 tests across 21 suites. CAP middleware tests gained 3 cases (canonical-wire-shape regression, `headersSent` defensive fallback, no-`http.res` transport fallback). Client tests dropped 7 cases tied to `unwrapCapEnvelope`.
18
+
19
+ ## [0.3.0] - 2026-05-15
20
+
21
+ ### Added
22
+ - **`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).
23
+ - **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()`.
24
+ - **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).
25
+ - **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).
26
+ - **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).
27
+ - **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).
28
+ - **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.
29
+
30
+ ### Fixed
31
+ - **`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.
32
+
33
+ ### Known issues
34
+ - 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.
35
+ - `PaymentClaim.payTo` , verified recipient address now populated on the claim (was previously only on the requirements entry). Useful for `onAccepted` audit and receipts.
36
+
37
+ ### Changed
38
+ - 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).
39
+ - `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).
40
+ - `srv/facilitator/verify.ts` decodes the envelope BEFORE selecting a requirements entry; multi-accept depends on knowing which `(payTo, asset)` the tx actually credited.
41
+
7
42
  ## [0.2.0] - 2026-05-15
8
43
 
9
44
  ### 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.1",
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,62 @@ 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)
46
- throw error;
47
57
  const body = error.response.data;
48
- if (body?.x402Version !== 2 || !Array.isArray(body.accepts) || body.accepts.length === 0) {
58
+ const status = error.response.status;
59
+ if (retries >= maxRetries) {
60
+ if (opts.errorOnFailure) {
61
+ throw maybeWrap(body, 'retries_exhausted', status, error);
62
+ }
63
+ throw error;
64
+ }
65
+ if (!body || body.x402Version !== 2 || !Array.isArray(body.accepts) || body.accepts.length === 0) {
66
+ if (opts.errorOnFailure) {
67
+ throw maybeWrap(body, 'invalid_402_body', status, error);
68
+ }
49
69
  throw error;
50
70
  }
51
71
  const chosen = select(body.accepts);
52
- if (!chosen)
72
+ if (!chosen) {
73
+ if (opts.errorOnFailure) {
74
+ throw maybeWrap(body, 'server_rejected', status, error);
75
+ }
53
76
  throw error;
54
- const { signedTxCborHex, nonceRef } = await opts.pay(chosen);
77
+ }
78
+ // Wrap pay-handler throws unconditionally, callers benefit from
79
+ // the structured shape regardless of errorOnFailure.
80
+ let payResult;
81
+ try {
82
+ payResult = await opts.pay(chosen);
83
+ }
84
+ catch (err) {
85
+ throw new errors_1.X402PaymentError({
86
+ message: `x402Axios: pay handler failed: ${err?.message ?? String(err)}`,
87
+ kind: 'pay_handler_failed',
88
+ accepts: body.accepts,
89
+ cause: err,
90
+ });
91
+ }
55
92
  const header = (0, envelope_1.encodePaymentEnvelope)({
56
93
  network: chosen.network,
57
- signedTxCborHex,
58
- nonceRef,
94
+ signedTxCborHex: payResult.signedTxCborHex,
95
+ nonceRef: payResult.nonceRef,
59
96
  });
60
97
  const nextCfg = {
61
98
  ...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,86 @@
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
+ * Parse the (server, canonical) error string from a `PaymentRequirementsBody`.
65
+ *
66
+ * The middleware encodes failures as `"<base error> (<code>): <reason>"`
67
+ * (see `cap.ts` / `express.ts`). We pull the code out so callers can
68
+ * dispatch on it without re-parsing the wire string. The reason is
69
+ * preserved in `.serverError`.
70
+ *
71
+ * Returns `undefined` when the body has no recognizable code (e.g. the
72
+ * MISSING_HEADER path, where the middleware omits the parenthesised
73
+ * suffix).
74
+ */
75
+ export declare function parseErrorCode(serverError?: string): string | undefined;
76
+ /**
77
+ * Build an `X402PaymentError` from a parsed 402 body. `kind` defaults
78
+ * to `server_rejected`; pass `'retries_exhausted'` when called after
79
+ * the retry loop gave up.
80
+ */
81
+ export declare function paymentErrorFromBody(body: PaymentRequirementsBody, init?: {
82
+ kind?: X402PaymentErrorKind;
83
+ httpStatus?: number;
84
+ cause?: unknown;
85
+ }): X402PaymentError;
86
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1,107 @@
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.parseErrorCode = parseErrorCode;
35
+ exports.paymentErrorFromBody = paymentErrorFromBody;
36
+ /**
37
+ * Thrown by `x402Fetch` and `x402Axios` when a payment attempt fails or
38
+ * is exhausted.
39
+ *
40
+ * `instanceof X402PaymentError` is the reliable runtime check; the
41
+ * `.kind` field is the discriminator for switching on cause.
42
+ */
43
+ class X402PaymentError extends Error {
44
+ kind;
45
+ code;
46
+ accepts;
47
+ httpStatus;
48
+ serverError;
49
+ // Override Error's `cause` typing, ours is `unknown` to allow any value.
50
+ cause;
51
+ constructor(init) {
52
+ super(init.message);
53
+ this.name = 'X402PaymentError';
54
+ this.kind = init.kind;
55
+ if (init.code !== undefined)
56
+ this.code = init.code;
57
+ if (init.accepts !== undefined)
58
+ this.accepts = init.accepts;
59
+ if (init.httpStatus !== undefined)
60
+ this.httpStatus = init.httpStatus;
61
+ if (init.serverError !== undefined)
62
+ this.serverError = init.serverError;
63
+ if (init.cause !== undefined)
64
+ this.cause = init.cause;
65
+ // V8: keep stack trace pointing at caller, not constructor.
66
+ if (typeof Error.captureStackTrace === 'function') {
67
+ Error
68
+ .captureStackTrace(this, X402PaymentError);
69
+ }
70
+ }
71
+ }
72
+ exports.X402PaymentError = X402PaymentError;
73
+ /**
74
+ * Parse the (server, canonical) error string from a `PaymentRequirementsBody`.
75
+ *
76
+ * The middleware encodes failures as `"<base error> (<code>): <reason>"`
77
+ * (see `cap.ts` / `express.ts`). We pull the code out so callers can
78
+ * dispatch on it without re-parsing the wire string. The reason is
79
+ * preserved in `.serverError`.
80
+ *
81
+ * Returns `undefined` when the body has no recognizable code (e.g. the
82
+ * MISSING_HEADER path, where the middleware omits the parenthesised
83
+ * suffix).
84
+ */
85
+ function parseErrorCode(serverError) {
86
+ if (!serverError)
87
+ return undefined;
88
+ const m = serverError.match(/\(([a-z_]+)\)/);
89
+ return m ? m[1] : undefined;
90
+ }
91
+ /**
92
+ * Build an `X402PaymentError` from a parsed 402 body. `kind` defaults
93
+ * to `server_rejected`; pass `'retries_exhausted'` when called after
94
+ * the retry loop gave up.
95
+ */
96
+ function paymentErrorFromBody(body, init = {}) {
97
+ const code = parseErrorCode(body.error);
98
+ return new X402PaymentError({
99
+ message: body.error ?? 'payment required',
100
+ kind: init.kind ?? 'server_rejected',
101
+ ...(code !== undefined ? { code } : {}),
102
+ accepts: body.accepts,
103
+ httpStatus: init.httpStatus ?? 402,
104
+ ...(body.error !== undefined ? { serverError: body.error } : {}),
105
+ ...(init.cause !== undefined ? { cause: init.cause } : {}),
106
+ });
107
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `x402Fetch` drop-in fetch wrapper that auto-handles 402 responses.
2
+ * `x402Fetch`, drop-in fetch wrapper that auto-handles 402 responses.
3
3
  *
4
4
  * On a 402 response the wrapper:
5
5
  * 1. Parses the body as a v2 `PaymentRequirementsBody`.
@@ -10,7 +10,7 @@
10
10
  *
11
11
  * Non-402 responses are passed through untouched. After `maxRetries`
12
12
  * payment attempts, the last response (whether 402 or other) is
13
- * returned to the caller never an infinite loop.
13
+ * returned to the caller, never an infinite loop.
14
14
  *
15
15
  * Native fetch is used by default (Node ≥18, all modern browsers).
16
16
  * Pass `opts.fetch` to override (testing, custom agents, etc.).