@odatano/x402 0.1.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.
- package/.github/workflows/test.yaml +49 -0
- package/CHANGELOG.md +57 -0
- package/README.md +29 -281
- package/cds-plugin.js +2 -0
- package/db/x402-grants.cds +49 -0
- package/db/x402-receipts.cds +44 -0
- package/package.json +11 -4
- package/srv/bridge.d.ts +9 -12
- package/srv/bridge.js +10 -13
- package/srv/cds-augment.d.ts +17 -0
- package/srv/client/axios.d.ts +38 -0
- package/srv/client/axios.js +107 -0
- package/srv/client/envelope.d.ts +33 -0
- package/srv/client/envelope.js +52 -0
- package/srv/client/errors.d.ts +107 -0
- package/srv/client/errors.js +144 -0
- package/srv/client/fetch.d.ts +30 -0
- package/srv/client/fetch.js +141 -0
- package/srv/client/pay-handlers.d.ts +41 -0
- package/srv/client/pay-handlers.js +47 -0
- package/srv/client/types.d.ts +56 -0
- package/srv/client/types.js +10 -0
- package/srv/core/asset.d.ts +1 -1
- package/srv/core/decode.d.ts +2 -2
- package/srv/core/decode.js +5 -5
- package/srv/core/errors.js +3 -3
- package/srv/core/network.d.ts +1 -1
- package/srv/core/network.js +1 -1
- package/srv/core/requirements.d.ts +37 -5
- package/srv/core/requirements.js +43 -4
- package/srv/core/types.d.ts +68 -6
- package/srv/core/types.js +3 -3
- package/srv/core/validate.d.ts +31 -7
- package/srv/core/validate.js +84 -9
- package/srv/facilitator/adapter.d.ts +69 -0
- package/srv/facilitator/adapter.js +52 -0
- package/srv/facilitator/http.d.ts +43 -0
- package/srv/facilitator/http.js +99 -0
- package/srv/facilitator/nonce.d.ts +4 -4
- package/srv/facilitator/nonce.js +4 -4
- package/srv/facilitator/server.d.ts +68 -0
- package/srv/facilitator/server.js +167 -0
- package/srv/facilitator/settle.d.ts +2 -2
- package/srv/facilitator/settle.js +4 -4
- package/srv/facilitator/verify.d.ts +5 -5
- package/srv/facilitator/verify.js +19 -5
- package/srv/helpers/build-unsigned-tx.d.ts +5 -5
- package/srv/helpers/build-unsigned-tx.js +3 -3
- package/srv/helpers/verify-confirmed.d.ts +1 -1
- package/srv/helpers/verify-confirmed.js +1 -1
- package/srv/index.d.ts +11 -2
- package/srv/index.js +23 -3
- package/srv/middleware/cap.d.ts +53 -8
- package/srv/middleware/cap.js +87 -43
- package/srv/middleware/express.d.ts +22 -9
- package/srv/middleware/express.js +21 -21
- package/srv/middleware/grants.d.ts +64 -0
- package/srv/middleware/grants.js +113 -0
- package/srv/middleware/pricing.d.ts +41 -0
- package/srv/middleware/pricing.js +78 -0
- package/srv/middleware/receipts.d.ts +38 -0
- package/srv/middleware/receipts.js +68 -0
- package/srv/plugin.d.ts +2 -2
- package/srv/plugin.js +2 -2
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
name: tests
|
|
2
|
+
|
|
3
|
+
# Run on every push to main and every pull request. Manual dispatch
|
|
4
|
+
# lets us re-run the workflow from the Actions tab without a commit.
|
|
5
|
+
on:
|
|
6
|
+
push:
|
|
7
|
+
branches: [main]
|
|
8
|
+
pull_request:
|
|
9
|
+
workflow_dispatch:
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
test:
|
|
13
|
+
name: Node ${{ matrix.node }} on ubuntu-latest
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
|
|
16
|
+
strategy:
|
|
17
|
+
# One Node failing should not cancel the other matrix legs;
|
|
18
|
+
# often a regression hits one version only.
|
|
19
|
+
fail-fast: false
|
|
20
|
+
matrix:
|
|
21
|
+
# Matches ODATANO core's support range.
|
|
22
|
+
node: ['20.x', '22.x']
|
|
23
|
+
|
|
24
|
+
steps:
|
|
25
|
+
- name: Checkout
|
|
26
|
+
uses: actions/checkout@v4
|
|
27
|
+
|
|
28
|
+
- name: Setup Node ${{ matrix.node }}
|
|
29
|
+
uses: actions/setup-node@v4
|
|
30
|
+
with:
|
|
31
|
+
node-version: ${{ matrix.node }}
|
|
32
|
+
cache: 'npm'
|
|
33
|
+
|
|
34
|
+
- name: Install dependencies
|
|
35
|
+
# `npm ci` enforces the lockfile and refuses to mutate it.
|
|
36
|
+
# Faster and more deterministic than `npm install` for CI.
|
|
37
|
+
run: npm ci
|
|
38
|
+
|
|
39
|
+
- name: Lint
|
|
40
|
+
run: npm run lint
|
|
41
|
+
|
|
42
|
+
- name: Type-check + build
|
|
43
|
+
# tsc -p tsconfig.build.json emits .js/.d.ts next to .ts.
|
|
44
|
+
# We don't ship those emits from CI; the step just verifies
|
|
45
|
+
# the build passes against the declared types.
|
|
46
|
+
run: npm run build
|
|
47
|
+
|
|
48
|
+
- name: Run tests
|
|
49
|
+
run: npm test
|
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@odatano/x402` are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/) and the project adheres to [Semantic Versioning](https://semver.org/).
|
|
4
|
+
|
|
5
|
+
**Pre-1.0 caveat:** minor versions may include breaking changes until `1.0.0`.
|
|
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
|
+
|
|
30
|
+
## [0.2.0] - 2026-05-15
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
- **Client helpers**: `x402Fetch`, `x402Axios`, `createBridgePayHandler`, `encodePaymentEnvelope` for symmetric server + client usage. See [`docs/usage.md`](docs/usage.md#5-client-side-auto-handle-402-x402fetch--x402axios).
|
|
34
|
+
- **Facilitator adapter pattern**: `Facilitator` interface, `localFacilitator()` (default, in-process via `@odatano/core`), `httpFacilitator()` for delegating verify+settle to a hosted service. HTTP wire format documented in [`docs/facilitator-protocol.md`](docs/facilitator-protocol.md).
|
|
35
|
+
- **`facilitator` option** on `gateService` and `x402Middleware` for swapping in the local default, an HTTP delegate, or a mock for tests.
|
|
36
|
+
- **GitHub Actions CI** (`.github/workflows/test.yaml`): runs lint + build + tests on Node 20.x and 22.x for every push to `main` and every pull request.
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
- Test count: 144 → 177 (added 4 client suites and 2 facilitator-adapter suites).
|
|
40
|
+
|
|
41
|
+
### Notes
|
|
42
|
+
- `0.1.0` consumers upgrade without code changes. The new `facilitator` option defaults to the previous in-process behaviour, so existing call sites are untouched.
|
|
43
|
+
|
|
44
|
+
## [0.1.0] - 2026-05-13
|
|
45
|
+
|
|
46
|
+
### Added
|
|
47
|
+
- Initial release. Cardano-x402-v2 payment gating for SAP CAP and Express.
|
|
48
|
+
- `gateService(srv, opts)` for CAP `before('*')` integration.
|
|
49
|
+
- `x402Middleware(opts)` for plain Express routes.
|
|
50
|
+
- Facilitator pipeline: decode, validate (six mandatory checks), `checkNonceUnspent`, `settle` (submit + poll-until-confirmed), `onAccepted` audit callback.
|
|
51
|
+
- Helpers: `verifyConfirmedPayment` (post-paid flow), `buildUnsignedPaymentTx` (browser-buyer flow).
|
|
52
|
+
- CAP plugin auto-discovery via `cds-plugin.js`.
|
|
53
|
+
- 144 unit tests across 13 suites.
|
|
54
|
+
|
|
55
|
+
### Spec compatibility
|
|
56
|
+
- Implements Cardano-x402-**v2** only. v1 envelopes are rejected with `unsupported_version`; v1-style network strings (`cardano-preprod` with hyphen) are rejected with `invalid_network_format`.
|
|
57
|
+
- v1 and v2 facilitators cannot share a route: they use different header names (`X-PAYMENT` vs `PAYMENT-SIGNATURE`) and incompatible 402 bodies. To migrate from v1, replace the middleware in one commit; clients must upgrade simultaneously.
|
package/README.md
CHANGED
|
@@ -1,32 +1,25 @@
|
|
|
1
1
|
# @odatano/x402
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/@odatano/x402)
|
|
4
|
+
[](https://github.com/ODATANO/x402/actions/workflows/test.yaml)
|
|
5
|
+
[](https://www.npmjs.com/package/@odatano/core)
|
|
6
|
+
[](https://github.com/masumi-network/x402-cardano)
|
|
7
|
+
|
|
3
8
|
x402 payment gating for SAP CAP applications, backed by Cardano.
|
|
4
9
|
|
|
5
|
-
Wire a single `before('*')` hook into your CAP service
|
|
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.
|
|
6
11
|
|
|
7
12
|
Implements the **Cardano-x402-v2** spec on top of [`@odatano/core`](https://www.npmjs.com/package/@odatano/core).
|
|
8
13
|
|
|
9
|
-
---
|
|
10
|
-
|
|
11
|
-
## What is x402?
|
|
12
|
-
|
|
13
|
-
[x402](https://www.x402.org/) is the dormant HTTP `402 Payment Required` status code, revived. Servers respond `402` with a machine-readable body describing the price, asset, and recipient. Clients build, sign, and submit a payment, then retry the request with a `PAYMENT-SIGNATURE` header. Settlement happens on-chain.
|
|
14
|
-
|
|
15
|
-
The original x402 spec is Coinbase / EVM-flavoured. The **Cardano-x402-v2** spec (in progress at [masumi-network/x402-cardano](https://github.com/masumi-network/x402-cardano)) adapts it to Cardano's UTxO model. This library is a from-scratch v2 implementation in TypeScript.
|
|
16
|
-
|
|
17
|
-
---
|
|
18
|
-
|
|
19
14
|
## Install
|
|
20
15
|
|
|
21
16
|
```bash
|
|
22
17
|
npm install @odatano/x402 @odatano/core
|
|
23
18
|
```
|
|
24
19
|
|
|
25
|
-
`@odatano/core` (the Cardano bridge) is a peer dependency
|
|
20
|
+
`@odatano/core` (the Cardano bridge) is a peer dependency. Install whichever version meets `>=1.7.8`.
|
|
26
21
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
## Quick Start — CAP service gate
|
|
22
|
+
## Quick Start
|
|
30
23
|
|
|
31
24
|
```typescript
|
|
32
25
|
// srv/prices-service.ts
|
|
@@ -38,16 +31,10 @@ export class PricesService extends cds.ApplicationService {
|
|
|
38
31
|
gateService(this, {
|
|
39
32
|
payTo: 'addr_test1...your-preprod-address...',
|
|
40
33
|
network: 'cardano:preprod',
|
|
41
|
-
asset: 'lovelace',
|
|
34
|
+
asset: 'lovelace', // or '<policy>.<nameHex>' for native tokens
|
|
42
35
|
routePricing: {
|
|
43
|
-
Quotes:
|
|
44
|
-
getBestPrice:
|
|
45
|
-
},
|
|
46
|
-
description: 'Synthetic price feed',
|
|
47
|
-
onAccepted: async (claim, req) => {
|
|
48
|
-
// Optional audit — runs once per accepted payment, after settlement.
|
|
49
|
-
// Errors here are logged but don't block the response.
|
|
50
|
-
console.log(`paid ${claim.amountUnits} ${claim.asset} (tx=${claim.txHash})`);
|
|
36
|
+
Quotes: '500000', // 0.5 ADA per Quotes read
|
|
37
|
+
getBestPrice: '1000000', // 1 ADA per getBestPrice action call
|
|
51
38
|
},
|
|
52
39
|
});
|
|
53
40
|
return super.init();
|
|
@@ -71,280 +58,41 @@ Configure the Cardano backend in `package.json`:
|
|
|
71
58
|
}
|
|
72
59
|
```
|
|
73
60
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
```http
|
|
77
|
-
HTTP/1.1 402 Payment Required
|
|
78
|
-
Content-Type: application/json
|
|
79
|
-
```
|
|
80
|
-
```json
|
|
81
|
-
{
|
|
82
|
-
"x402Version": 2,
|
|
83
|
-
"error": "PAYMENT-SIGNATURE header is required",
|
|
84
|
-
"accepts": [{
|
|
85
|
-
"scheme": "exact",
|
|
86
|
-
"network": "cardano:preprod",
|
|
87
|
-
"asset": "lovelace",
|
|
88
|
-
"amount": "500000",
|
|
89
|
-
"payTo": "addr_test1...",
|
|
90
|
-
"resource": {
|
|
91
|
-
"url": "/odata/v4/prices/Quotes",
|
|
92
|
-
"description": "Synthetic price feed",
|
|
93
|
-
"mimeType": "application/json"
|
|
94
|
-
},
|
|
95
|
-
"assetTransferMethod": "default",
|
|
96
|
-
"maxTimeoutSeconds": 600
|
|
97
|
-
}]
|
|
98
|
-
}
|
|
99
|
-
```
|
|
100
|
-
|
|
101
|
-
A working version of this is in [`examples/cap-app/`](examples/cap-app/).
|
|
102
|
-
|
|
103
|
-
---
|
|
104
|
-
|
|
105
|
-
## Usage patterns
|
|
106
|
-
|
|
107
|
-
### 1. CAP service gate (`gateService`)
|
|
108
|
-
|
|
109
|
-
For OData-served entities and bound/unbound actions. Pricing keys can be entity names (for CRUD) or action names — the gate tries both.
|
|
110
|
-
|
|
111
|
-
```typescript
|
|
112
|
-
gateService(this, {
|
|
113
|
-
payTo, network, asset,
|
|
114
|
-
routePricing: { Quotes: '500000', getBestPrice: '1000000' },
|
|
115
|
-
});
|
|
116
|
-
```
|
|
117
|
-
|
|
118
|
-
When a payment is accepted, the verified `PaymentClaim` is stashed on `req.payment` for downstream handlers, and an `X-PAYMENT-RESPONSE` header is set on the response.
|
|
119
|
-
|
|
120
|
-
### 2. Express middleware (`x402Middleware`)
|
|
121
|
-
|
|
122
|
-
For plain Express routes (e.g. mounted alongside CAP via `cds.on('bootstrap', app => …)`):
|
|
123
|
-
|
|
124
|
-
```typescript
|
|
125
|
-
import { x402Middleware } from '@odatano/x402';
|
|
126
|
-
|
|
127
|
-
app.use('/api/premium', x402Middleware({
|
|
128
|
-
payTo, network, asset,
|
|
129
|
-
priceUnits: '1000000',
|
|
130
|
-
skipPaths: /(\$metadata|^\/?$)/i,
|
|
131
|
-
onAccepted: async (claim, req) => { /* audit */ },
|
|
132
|
-
}));
|
|
133
|
-
```
|
|
134
|
-
|
|
135
|
-
### 3. Programmatic — verify a post-paid tx
|
|
136
|
-
|
|
137
|
-
For subscription / pre-paid flows where the buyer hands you a tx hash:
|
|
138
|
-
|
|
139
|
-
```typescript
|
|
140
|
-
import { verifyConfirmedPayment } from '@odatano/x402';
|
|
141
|
-
|
|
142
|
-
const result = await verifyConfirmedPayment({
|
|
143
|
-
txHash: 'ab8f…',
|
|
144
|
-
requiredAmount: '1000000',
|
|
145
|
-
asset: 'lovelace',
|
|
146
|
-
payTo: 'addr_test1...',
|
|
147
|
-
network: 'cardano:preprod',
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
if (result.ok) {
|
|
151
|
-
// result.amountUnits is what was actually paid (may exceed requiredAmount)
|
|
152
|
-
} else {
|
|
153
|
-
// result.code: 'pending' | 'wrong_asset' | 'insufficient_amount' | ...
|
|
154
|
-
}
|
|
155
|
-
```
|
|
156
|
-
|
|
157
|
-
### 4. Programmatic — server-side unsigned-tx builder for browser buyers
|
|
158
|
-
|
|
159
|
-
When the buyer's CIP-30 wallet can sign but not coin-select:
|
|
160
|
-
|
|
161
|
-
```typescript
|
|
162
|
-
import { buildUnsignedPaymentTx, buildPaymentRequirements, flatRequirements } from '@odatano/x402';
|
|
163
|
-
|
|
164
|
-
const body = buildPaymentRequirements({ amount: '1000000', asset: 'lovelace', payTo, network: 'cardano:preprod', resource: '/r' });
|
|
165
|
-
const requirements = flatRequirements(body);
|
|
166
|
-
|
|
167
|
-
const { unsignedTxCborHex, txHashHex, nonceRef } = await buildUnsignedPaymentTx({
|
|
168
|
-
buyerBech32: 'addr_test1...buyer...',
|
|
169
|
-
requirements,
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
// Browser signs unsignedTxCborHex via CIP-30, then assembles the
|
|
173
|
-
// PAYMENT-SIGNATURE envelope with `nonceRef` as payload.nonce.
|
|
174
|
-
```
|
|
175
|
-
|
|
176
|
-
---
|
|
177
|
-
|
|
178
|
-
## Configuration reference
|
|
179
|
-
|
|
180
|
-
### `gateService(srv, options)` / `x402Middleware(options)`
|
|
181
|
-
|
|
182
|
-
| Option | Type | Required | Default | Notes |
|
|
183
|
-
|---|---|---|---|---|
|
|
184
|
-
| `payTo` | `string` (bech32) | yes | — | Recipient address |
|
|
185
|
-
| `network` | `'cardano:mainnet' \| 'cardano:preprod' \| 'cardano:preview'` | yes | — | **v2 uses colon separator** (v1 hyphen rejected) |
|
|
186
|
-
| `asset` | `string` | yes | — | `'lovelace'` for ADA, or `'<policyIdHex>.<assetNameHex>'` for native tokens |
|
|
187
|
-
| `priceUnits` | `string \| number \| bigint` | one of priceUnits / routePricing | — | Single price for everything under the mount |
|
|
188
|
-
| `routePricing` | `Record<string, string \| number \| bigint>` | one of priceUnits / routePricing | — | Per-entity / per-action prices. Unmapped keys pass through unless `priceUnits` is also set |
|
|
189
|
-
| `skipPaths` | `RegExp` | no | matches `$metadata`, `$batch`, root, `/index` | Express only — paths to bypass |
|
|
190
|
-
| `description` | `string` | no | `''` | Embedded in `accepts[0].resource.description` |
|
|
191
|
-
| `mimeType` | `string` | no | `'application/json'` | Embedded in `accepts[0].resource.mimeType` |
|
|
192
|
-
| `assetTransferMethod` | `'default' \| 'masumi' \| 'script'` | no | `'default'` | v2 field; MVP supports only `default` |
|
|
193
|
-
| `maxTimeoutSeconds` | `number` | no | `600` | Buyer-side TTL hint |
|
|
194
|
-
| `extra` | `Record<string, unknown>` | no | — | Free-form extras (decimals, fingerprint, UI hints) |
|
|
195
|
-
| `settlePollBudgetMs` | `number` | no | `60_000` | How long to poll for chain confirmation before returning `402 pending` |
|
|
196
|
-
| `allowNoTtl` | `boolean` | no | `false` | If `true`, accept txs with no validity-range upper bound |
|
|
197
|
-
| `onAccepted` | `(claim, req) => void \| Promise<void>` | no | — | Audit callback. Errors logged, never block response |
|
|
198
|
-
| `resourceUrl` | `(req) => string` | no (CAP only) | derives from `req.http.req.originalUrl` | Override the resource URL emitted in the 402 body |
|
|
199
|
-
|
|
200
|
-
---
|
|
201
|
-
|
|
202
|
-
## The buyer flow
|
|
203
|
-
|
|
204
|
-
```
|
|
205
|
-
Server Buyer (browser / CLI)
|
|
206
|
-
│ │
|
|
207
|
-
│ ◄── GET /odata/v4/prices/Quotes ─────│
|
|
208
|
-
│ │
|
|
209
|
-
│ ──── 402 + accepts[0] (price, payTo) ►│
|
|
210
|
-
│ │
|
|
211
|
-
│ ┌────────┴────────┐
|
|
212
|
-
│ │ build tx │
|
|
213
|
-
│ │ sign via CIP-30 │
|
|
214
|
-
│ │ base64-encode │
|
|
215
|
-
│ └────────┬────────┘
|
|
216
|
-
│ │
|
|
217
|
-
│ ◄── GET /odata/v4/prices/Quotes ─────│
|
|
218
|
-
│ PAYMENT-SIGNATURE: <base64> │
|
|
219
|
-
│ │
|
|
220
|
-
┌──────┴──────┐ │
|
|
221
|
-
│ 6 checks │ │
|
|
222
|
-
│ + submit │ │
|
|
223
|
-
│ + poll │ │
|
|
224
|
-
│ + onAccepted│ │
|
|
225
|
-
└──────┬──────┘ │
|
|
226
|
-
│ │
|
|
227
|
-
│ ─────── 200 OK + data ───────────────►│
|
|
228
|
-
│ X-PAYMENT-RESPONSE: <base64> │
|
|
229
|
-
```
|
|
230
|
-
|
|
231
|
-
`PAYMENT-SIGNATURE` envelope shape:
|
|
232
|
-
|
|
233
|
-
```json
|
|
234
|
-
{
|
|
235
|
-
"x402Version": 2,
|
|
236
|
-
"scheme": "exact",
|
|
237
|
-
"network": "cardano:preprod",
|
|
238
|
-
"payload": {
|
|
239
|
-
"transaction": "<base64-CBOR of signed tx>",
|
|
240
|
-
"nonce": "<txHash>#<outputIndex>"
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
```
|
|
244
|
-
|
|
245
|
-
The `nonce` references a UTxO that **must also appear as an input of the payment tx**. Once the tx settles, that UTxO is consumed — replay defense is on-chain, no DB table needed.
|
|
246
|
-
|
|
247
|
-
---
|
|
248
|
-
|
|
249
|
-
## Six mandatory facilitator checks
|
|
61
|
+
`cds watch`, then probe a gated route: it returns `402` with a v2-shape body. A working example lives in [`examples/cap-app/`](examples/cap-app/).
|
|
250
62
|
|
|
251
|
-
|
|
63
|
+
## What's in the box
|
|
252
64
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
| 3 | Sum of payTo outputs for asset ≥ required | `insufficient_amount` |
|
|
258
|
-
| 4 | Exact policy + asset-name match | `wrong_asset` |
|
|
259
|
-
| 5 | Nonce UTxO referenced as tx input **and** unspent on chain | `nonce_not_referenced` / `replay_detected` |
|
|
260
|
-
| 6 | Validity-range upper bound still in future | `expired_ttl` |
|
|
65
|
+
- **`gateService(srv, opts)`** for CAP services and **`x402Middleware(opts)`** for plain Express routes.
|
|
66
|
+
- **`x402Fetch` / `x402Axios`** wrappers that auto-handle 402 on the client side.
|
|
67
|
+
- **`Facilitator` adapter:** `localFacilitator()` (default, in-process via `@odatano/core`) or `httpFacilitator()` to delegate verify+settle to a hosted service.
|
|
68
|
+
- **Helpers:** `buildUnsignedPaymentTx` (browser-buyer flow), `verifyConfirmedPayment` (post-paid / subscription).
|
|
261
69
|
|
|
262
|
-
|
|
70
|
+
## Documentation
|
|
263
71
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
┌──────────────────┐
|
|
272
|
-
consumer │ CAP application │
|
|
273
|
-
│ │
|
|
274
|
-
│ gateService(this,│
|
|
275
|
-
│ { … }) │
|
|
276
|
-
└────────┬─────────┘
|
|
277
|
-
│
|
|
278
|
-
▼
|
|
279
|
-
┌──────────────────┐ bridge.ts
|
|
280
|
-
│ @odatano/x402 │ ──────────────┐
|
|
281
|
-
│ │ │
|
|
282
|
-
│ core/ ──────┤ pure logic │
|
|
283
|
-
│ facilitator/ ────┤ chain-touching│
|
|
284
|
-
│ middleware/ ────┤ Express + CAP │
|
|
285
|
-
│ helpers/ ────┤ tx-build, │
|
|
286
|
-
│ │ verify-post-paid
|
|
287
|
-
└──────────────────┘ │
|
|
288
|
-
▼
|
|
289
|
-
┌────────────────────────┐
|
|
290
|
-
│ @odatano/core 1.7.8 │
|
|
291
|
-
│ ┌──────┬──────┬─────┐ │
|
|
292
|
-
│ │ Blkf │Koios │Ogms │ │
|
|
293
|
-
│ └──────┴──────┴─────┘ │
|
|
294
|
-
└────────────────────────┘
|
|
295
|
-
│
|
|
296
|
-
▼
|
|
297
|
-
Cardano network
|
|
298
|
-
```
|
|
299
|
-
|
|
300
|
-
Pure modules (`core/*`) are decoupled from the bridge and can be unit-tested without any chain backend. The facilitator orchestrates `decode → validate → checkNonceUnspent → settle → onAccepted`.
|
|
301
|
-
|
|
302
|
-
---
|
|
72
|
+
| Doc | Covers |
|
|
73
|
+
|---|---|
|
|
74
|
+
| [`docs/usage.md`](docs/usage.md) | All five usage patterns + full configuration reference |
|
|
75
|
+
| [`docs/protocol.md`](docs/protocol.md) | Buyer-flow diagram, `PAYMENT-SIGNATURE` envelope, the six mandatory facilitator checks |
|
|
76
|
+
| [`docs/architecture.md`](docs/architecture.md) | Module layout, pure-vs-chain split, plugin auto-discovery |
|
|
77
|
+
| [`docs/facilitator-protocol.md`](docs/facilitator-protocol.md) | HTTP wire format for the hosted-facilitator pattern (`httpFacilitator()`) |
|
|
78
|
+
| [`CHANGELOG.md`](CHANGELOG.md) | Versioned changes, latest first |
|
|
303
79
|
|
|
304
80
|
## Requirements
|
|
305
81
|
|
|
306
82
|
- Node.js 22+
|
|
307
83
|
- `@sap/cds >= 9` (peer)
|
|
308
84
|
- `@odatano/core >= 1.7.8` (peer)
|
|
309
|
-
- `express ^4` (peer)
|
|
85
|
+
- `express ^4` (peer), only if you use `x402Middleware`
|
|
310
86
|
- A Cardano backend reachable via `@odatano/core` (Blockfrost / Koios / Ogmios)
|
|
311
87
|
|
|
312
|
-
---
|
|
313
|
-
|
|
314
88
|
## Development
|
|
315
89
|
|
|
316
90
|
```bash
|
|
317
|
-
npm install # Workspace install
|
|
318
|
-
npm run build # tsc
|
|
319
|
-
npm test #
|
|
320
|
-
npm run test:coverage # Coverage report
|
|
321
|
-
npm run watch # cds watch — for the root project itself
|
|
322
|
-
```
|
|
323
|
-
|
|
324
|
-
The example app:
|
|
325
|
-
|
|
326
|
-
```bash
|
|
327
|
-
cd examples/cap-app
|
|
328
|
-
npm start # cds-serve with in-memory SQLite
|
|
329
|
-
```
|
|
330
|
-
|
|
331
|
-
Then probe:
|
|
332
|
-
|
|
333
|
-
```bash
|
|
334
|
-
curl -s http://localhost:4004/odata/v4/prices/Quotes | jq .
|
|
335
|
-
# → 402 with v2 body
|
|
91
|
+
npm install # Workspace install: covers root + examples/*
|
|
92
|
+
npm run build # tsc, emits .js/.d.ts next to .ts (outDir: .)
|
|
93
|
+
npm test # 177 tests, ~13s
|
|
336
94
|
```
|
|
337
95
|
|
|
338
|
-
---
|
|
339
|
-
|
|
340
|
-
## Versioning + spec compatibility
|
|
341
|
-
|
|
342
|
-
- **Library:** `0.1.0` (pre-1.0; expect minor breakages between minors until 1.0)
|
|
343
|
-
- **Spec:** Cardano-x402-**v2** only. v1 envelopes are rejected with `unsupported_version`. v1-style network strings (`cardano-preprod`) are rejected with `invalid_network_format`.
|
|
344
|
-
- **Coexistence:** A v1 facilitator and a v2 facilitator can't share the same route — they use different header names (`X-PAYMENT` vs `PAYMENT-SIGNATURE`) and incompatible 402 bodies. To migrate from v1, replace the middleware in one commit; clients must upgrade simultaneously.
|
|
345
|
-
|
|
346
|
-
---
|
|
347
|
-
|
|
348
96
|
## License
|
|
349
97
|
|
|
350
98
|
Apache-2.0.
|
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,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@odatano/x402",
|
|
3
|
-
"version": "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",
|
|
7
|
-
"types": "srv/
|
|
7
|
+
"types": "srv/cds-augment.d.ts",
|
|
8
8
|
"publishConfig": {
|
|
9
9
|
"access": "public"
|
|
10
10
|
},
|
|
@@ -26,13 +26,19 @@
|
|
|
26
26
|
"@emurgo/cardano-serialization-lib-nodejs": "^15.0.3"
|
|
27
27
|
},
|
|
28
28
|
"peerDependencies": {
|
|
29
|
+
"@cap-js/cds-types": ">=0.15",
|
|
29
30
|
"@odatano/core": ">=1.7.8",
|
|
30
31
|
"@sap/cds": ">=9",
|
|
31
32
|
"express": "^4"
|
|
32
33
|
},
|
|
34
|
+
"peerDependenciesMeta": {
|
|
35
|
+
"@cap-js/cds-types": {
|
|
36
|
+
"optional": true
|
|
37
|
+
}
|
|
38
|
+
},
|
|
33
39
|
"devDependencies": {
|
|
34
|
-
"@cap-js/cds-types": "^0.15.0",
|
|
35
40
|
"@cap-js/cds-typer": "^0.38.0",
|
|
41
|
+
"@cap-js/cds-types": "^0.15.0",
|
|
36
42
|
"@cap-js/sqlite": "^2",
|
|
37
43
|
"@odatano/core": "^1.7.8",
|
|
38
44
|
"@odatano/x402": ".",
|
|
@@ -47,6 +53,7 @@
|
|
|
47
53
|
"jest": "^29",
|
|
48
54
|
"ts-jest": "^29",
|
|
49
55
|
"tsx": "^4",
|
|
50
|
-
"typescript": "^5"
|
|
56
|
+
"typescript": "^5",
|
|
57
|
+
"typescript-eslint": "^8.59.3"
|
|
51
58
|
}
|
|
52
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
|
|
6
|
+
* to, and so renames in core (`getTransaction` → `getTransactionByHash`)
|
|
7
7
|
* stay isolated to this file.
|
|
8
8
|
*
|
|
9
|
-
* Two methods
|
|
10
|
-
* `@odatano/core
|
|
11
|
-
* - `isUtxoUnspent(txHash, outputIndex)`
|
|
12
|
-
* - `getCurrentSlot()`
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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>;
|