@tollstile/x402 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Paradigm AI Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,146 @@
1
+ # @tollstile/x402
2
+
3
+ The [x402](https://github.com/x402-foundation/x402) V2 rail for Tollstile. Agents pay per request in USDC (or another EVM token) with a signed authorization; Tollstile verifies it through a facilitator before your handler runs and settles it after the handler succeeded.
4
+
5
+ - **Schemes:** `exact` (EIP-3009 `transferWithAuthorization`) for fixed prices, `upto` (Permit2) for `upTo()` prices.
6
+ - **Transports:** HTTP (`PAYMENT-REQUIRED` / `PAYMENT-SIGNATURE` / `PAYMENT-RESPONSE`, standard base64 JSON) and MCP (`_meta["x402/payment"]`, receipt in `_meta["x402/payment-response"]`, payment-required as an `isError` tool result).
7
+ - **Reconciliation:** on-chain, through your JSON-RPC endpoint. x402 facilitators have no status endpoint and `/settle` is not idempotent.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install tollstile @tollstile/x402
13
+ ```
14
+
15
+ ## Example
16
+
17
+ ```ts
18
+ import { Hono } from 'hono';
19
+ import { createTollstile, memoryLedger, upTo } from 'tollstile';
20
+ import { tollstile } from '@tollstile/hono';
21
+ import { x402 } from '@tollstile/x402';
22
+
23
+ const toll = createTollstile({
24
+ rails: [
25
+ x402({
26
+ network: 'eip155:84532', // Base Sepolia
27
+ payTo: '0xYourAddress',
28
+ denomination: 'USD', // 1 USDC = 1 USD, stated explicitly
29
+ rpcUrl: 'https://sepolia.base.org',
30
+ upto: { facilitatorAddress: '0xd407e409E34E0b9afb99EcCeb609bDbcD5e7f1bf' }, // from GET /supported
31
+ }),
32
+ ],
33
+ ledger: memoryLedger(),
34
+ secret: process.env.TOLLSTILE_SECRET, // 32+ random characters
35
+ });
36
+
37
+ const app = new Hono();
38
+ app.get('/weather', tollstile(toll.price('$0.01')), (c) => c.json({ sunny: true }));
39
+ app.post('/generate', tollstile(toll.price(upTo('$0.10'))), async (c) => {
40
+ await c.get('payment').fulfill({ amount: '$0.03' }); // settles 0.03 USDC of the 0.10 authorized
41
+ return c.json({ text: '…' });
42
+ });
43
+
44
+ setInterval(() => void toll.reconcile(), 60_000);
45
+ ```
46
+
47
+ Start with `testRail()` from `tollstile` for local development; switching to `x402()` does not change handlers.
48
+
49
+ ## Options
50
+
51
+ | Option | Default | Description |
52
+ |---|---|---|
53
+ | `network` | — | CAIP-2 EVM network. Built-in assets: `eip155:8453` (Base, USDC "USD Coin" v2) and `eip155:84532` (Base Sepolia, USDC "USDC" v2). |
54
+ | `payTo` | — | Your receiving address. Every payment is checked against it. |
55
+ | `denomination` | — | Conversion at par, e.g. `"USD"` for USDC. Required unless `rate` is set; the built-in USDC only accepts `"USD"`. |
56
+ | `rate` | — | `(price: Money) => Promise<bigint>`: atomic asset units for a price, for assets not at par. The quote fixes the result for the payer. |
57
+ | `asset` | built-in USDC | `{ code, address, decimals, name, version }` with the token's EIP-712 domain. Required on other networks; decimals 6–18. |
58
+ | `facilitator` | x402.org on Base Sepolia only | `{ url, headers?: () => Promise<Record<string, string>> }`. `headers` runs per request (e.g. a CDP JWT). **Required on mainnet and every other network**; the testnet facilitator is never used silently. |
59
+ | `rpcUrl` | — | JSON-RPC endpoint for `network`, used only by reconciliation. Must support the `finalized` block tag and `eth_getLogs`. |
60
+ | `upto` | disabled | `{ facilitatorAddress }` enables `upTo()` prices. Use the address your facilitator lists for `upto` in `GET /supported`; payers bind their Permit2 signature to it. |
61
+ | `maxTimeoutSeconds` | `60` | Advertised to payers, who sign authorizations valid for about this long. **The handler and settlement must both finish before it runs out** (facilitators also keep a few seconds of margin), or settlement is rejected after the service was delivered. Raise it for slow handlers. |
62
+ | `fetch` | global `fetch` | Injected for tests and custom transports. |
63
+
64
+ ## Capabilities
65
+
66
+ | Capability | Value | Why |
67
+ |---|---|---|
68
+ | `flows` | `['authorization']` | verify → handler → settle. `exact` cannot be refunded or voided, so settling upfront would charge for work that failed. |
69
+ | `authorization` | `single` | One signed authorization pays for one request. After a released charge (handler failed, nothing settled) the same payment can be retried. |
70
+ | `variableAmount` | `true` with `upto` | Permit2 `upto` authorizes a maximum and settles the fulfilled amount. |
71
+ | `quotes` | `true` | The quote token travels in `accepts[].extra.tollstileQuote`. x402 V2 clients must echo every advertised `extra` field, so it comes back in `accepted`. |
72
+ | `refund` / `partialRefund` | `false` | Neither scheme has a refund; `refund()` throws `UNREACHABLE`. |
73
+ | `lookup` | `true` | On-chain, see below. |
74
+
75
+ `livemode` is `true`, including on testnets.
76
+
77
+ ### How verification works
78
+
79
+ 1. Read `PAYMENT-SIGNATURE` (HTTP) or `_meta["x402/payment"]` (MCP). Only `x402Version: 2` is accepted.
80
+ 2. If `accepted.extra.tollstileQuote` is present, open the quote and derive the requirements from its x402 offer; otherwise use the route's fixed price (dynamic routes need the quote).
81
+ 3. `accepted` must equal those requirements (every field except `extra` identical; `extra` must contain everything advertised, as in the reference `paymentRequirementsMatchAccepted`). The signed authorization must name `payTo`, the exact amount (or the `upto` maximum), the asset, the upto proxy as spender, and the configured facilitator.
82
+ 4. Call the facilitator `/verify` with **this server's** requirements, never the client's.
83
+ 5. `payer` is the signer's address in lowercase hex. The proof id is `network:asset:payer:nonce`, so a replayed payment maps to the same authorization. `limit` is the authorized value converted back to money; `expiresAt` is `validBefore` (or the Permit2 deadline).
84
+
85
+ Denials use core's error codes (SPEC §12). Every rejection by this rail is `402` with `error.code: "proof_invalid"` and the rail's reason in `error.detail` (e.g. `accepted_mismatch`, `recipient_mismatch`, or the facilitator's `invalidReason`, sanitized to `[a-z0-9_]`, whether it came as HTTP 200 or a non-2xx JSON body); a forged or expired quote is `quote_invalid`. Clients branch on `code`, never on `detail`. An unreachable or timed-out facilitator, a non-JSON answer, or `unexpected_verify_error` is `503 payment_unavailable` and the handler does not run.
86
+
87
+ **Retries and idempotency.** The 402 advertises the x402 `payment-identifier` extension. A client that sends `extensions["payment-identifier"].info.id` (16–128 characters of `[A-Za-z0-9_-]`) gets it used as the idempotency key; an `Idempotency-Key` header (or MCP `_meta["tollstile/idempotency-key"]`) takes precedence. A malformed id is `proof_invalid` / `payment_identifier_invalid`. With a key, a retry while the first request runs is `409 request_in_progress`; without one, a second presentation of the same signature is `409 proof_already_used`. After the payment **settled**, the facilitator rejects the used nonce, but the rail still reports which authorization the payment identifies, so core answers `409 already_paid` (same `Idempotency-Key` or payment identifier) or `409 proof_already_used` instead of asking the client to pay again. The same holds when the quote in a retried payment has expired. A payload whose signature the facilitator rejects proves no identity and is `402 proof_invalid`. A retry after the handler failed (charge released) runs again with the same signature.
88
+
89
+ ### How settlement works
90
+
91
+ `/settle` is called with the stored payload and requirements; for `upto`, `amount` is the fulfilled amount at the quoted ratio, rounded down. `success: false` is a rejection (`failed`), except `settlement_pending` and `unexpected_settle_error`, which — like timeouts, transport failures, and non-JSON answers — are `PROVIDER_TIMEOUT`: core records `unknown` and reconciliation asks the chain. Tollstile never calls `/settle` twice on a hunch.
92
+
93
+ ### How lookup works
94
+
95
+ All reads happen at the `finalized` block.
96
+
97
+ - **exact:** `authorizationState(payer, nonce)` on the token. If used, find the token's `AuthorizationUsed(payer, nonce)` log and require a `Transfer(payer, payTo, value)` in the same successful transaction → `settled` with that transaction hash. An `AuthorizationCanceled` log → `none`.
98
+ - **upto:** Permit2 `nonceBitmap(payer, nonce >> 8)`. If the bit is set, find `Transfer(payer, payTo)` logs of the asset and accept one only if its transaction called the upto proxy (`settle` or `settleWithPermit`) with this nonce, owner, and token; the settled amount comes from the log. An `UnorderedNonceInvalidation` covering the nonce → `none`.
99
+ - **Unused nonce:** `none` only once the finalized block is past `validBefore` (or the deadline), when no later block can include it. Before that, lookup throws `PROVIDER_TIMEOUT` and the charge stays `unknown` until the next run.
100
+ - **Used nonce with no recognizable evidence** (e.g. a facilitator that settles through a batching contract): `PROVIDER_TIMEOUT` with a message to investigate. Tollstile does not guess.
101
+
102
+ Logs are searched between the charge's creation time (minus 10 minutes of clock-skew margin) and the signature's deadline. Block timestamps strictly increase, which bounds that block range without assuming a block time.
103
+
104
+ Selectors and topics are precomputed constants in `src/abi.ts` (no keccak dependency); each is documented with its signature.
105
+
106
+ ### Stored data
107
+
108
+ The authorization's `data` holds the payer's signed payload, because settlement may run in another process after a crash. It never appears in errors, events, or receipts. The rail implements `redact`: once a charge is final, core replaces `paymentPayload` and `paymentRequirements` with `null`, keeping the scheme, network, asset, `payTo`, payer, nonce, deadline, and amounts that `lookup` needs. While a charge is `unknown` the payload stays, so reconciliation can still settle it. A `released` charge (the handler failed before anything settled) is not redacted, so the same payment can be retried.
109
+
110
+ ## Verification status
111
+
112
+ **Tested only against fakes and the reference library.**
113
+
114
+ - `test/x402.test.ts` drives real flows through `createTollstile` with a fake facilitator and a fake JSON-RPC node sharing one simulated chain (injected `fetch`): 402 → pay with the echoed quote → 200 for `exact` and `upto` (fulfilled amount); dynamic prices; tampered `accepted` amount, recipient, network, asset, and timeout; signatures to another recipient, amount, spender, or facilitator; tampered and expired quotes; facilitator unreachable, hanging (abort), and `unexpected_verify_error` → 503; replay after settlement (`409 proof_already_used`, or `409 already_paid` with the same `Idempotency-Key` even after the quote expired) and concurrent replay (`409 proof_already_used`); a facilitator signature rejection stays `402 proof_invalid`; `payment-identifier` as idempotency key (`409 request_in_progress` in flight, `409 already_paid` after settlement, malformed id rejected); canonical lowercase payer; retry after a released charge; rejected settlement as 200 and non-2xx (fresh 402 `settlement_rejected` after the handler); `settlement_pending` → `unknown` → reconciled to `settled` from chain evidence (exact and upto) without a second `/settle`; unmined authorization kept `unknown` until expired, then `failed`; payer cancellation (EIP-3009 and Permit2 invalidation, with a decoy transfer) → nothing charged; RPC down → stays `unknown`; crash after fulfillment → settled by reconciliation; MCP challenge and receipt; redaction (payload kept until final and while `unknown`, kept after a release and dropped once the retry settles, dropped after settle, rejection, and reconciliation; lookup still works on redacted data); configuration errors.
115
+ - `test/rail-conformance.test.ts` runs core's `railConformance()` kit against the same fakes for the `exact` scheme, with lost settle responses, settle failures before any effect, and tampered proofs: all 9 cases pass, none skipped. The kit prices routes with a fixed string, so `upto` is not exercised by it (it is covered in `x402.test.ts`).
116
+ - `test/conformance.test.ts` round-trips headers through `@x402/core` 2.25.0 (`decodePaymentRequiredHeader`, `encodePaymentSignatureHeader`, `decodePaymentResponseHeader`, V2 schema guards) and checks that the reference `x402ResourceServer.findMatchingRequirements` accepts what this rail advertises.
117
+ - ABI selectors and event topics were computed with keccak-256 and cross-checked against known selectors (`balanceOf`, `transferWithAuthorization`, `Transfer`); calldata layouts follow `x402UptoPermit2Proxy.sol` at x402-foundation/x402 `3a6605e`.
118
+
119
+ **Live verification status.** The `exact` flow has been verified on Base Sepolia with x402.org, including a successful USDC transfer, replay rejection, handler failure followed by retry, and persistence across a process restart with the SQLite ledger. `upto`, reconciliation after an ambiguous settlement, and production providers still require separate verification.
120
+
121
+ ### Verify live on Base Sepolia with the x402.org facilitator
122
+
123
+ 1. **Wallets.** Create two test wallets: a receiver (`payTo`) and a payer. Fund the payer with Base Sepolia USDC from https://faucet.circle.com. For `exact`, the payer needs no ETH (the facilitator pays gas). For `upto`, the payer must approve Permit2 (`0x000000000022D473030F116dDEE9F6B43aC78BA3`) for USDC once, which needs a little Base Sepolia ETH.
124
+ 2. **Facilitator address.** `curl https://x402.org/facilitator/supported` and copy `extra.facilitatorAddress` of the `upto` / `eip155:84532` entry (it was `0xd407e409E34E0b9afb99EcCeb609bDbcD5e7f1bf` on 2026-09-15).
125
+ 3. **Server.** Run the example above with `network: 'eip155:84532'`, your `payTo`, `denomination: 'USD'`, `rpcUrl: 'https://sepolia.base.org'` (or a provider URL), `upto: { facilitatorAddress }`, a Postgres or memory ledger, and `onEvent: console.log`.
126
+ 4. **Unpaid request.** `curl -i localhost:3000/weather` → `402`, `cache-control: no-store`, and a `PAYMENT-REQUIRED` header; `echo <header> | base64 -d` shows `scheme: "exact"`, `amount: "10000"`, and `extra.tollstileQuote`.
127
+ 5. **Paid request with the reference client.**
128
+ ```ts
129
+ import { x402Client, wrapFetchWithPayment, decodePaymentResponseHeader } from '@x402/fetch';
130
+ import { ExactEvmScheme } from '@x402/evm/exact/client';
131
+ import { UptoEvmScheme } from '@x402/evm/upto/client';
132
+ import { privateKeyToAccount } from 'viem/accounts';
133
+
134
+ const signer = privateKeyToAccount(process.env.PAYER_KEY as `0x${string}`);
135
+ const client = new x402Client().register('eip155:84532', new ExactEvmScheme(signer)).register('eip155:84532', new UptoEvmScheme(signer));
136
+ const pay = wrapFetchWithPayment(fetch, client);
137
+
138
+ const weather = await pay('http://localhost:3000/weather');
139
+ console.log(weather.status, decodePaymentResponseHeader(weather.headers.get('payment-response') ?? ''));
140
+ const generate = await pay('http://localhost:3000/generate', { method: 'POST' });
141
+ console.log(generate.status, decodePaymentResponseHeader(generate.headers.get('payment-response') ?? ''));
142
+ ```
143
+ Expect `200` and a transaction hash. On https://sepolia.basescan.org, `/weather` shows a 0.01 USDC transfer to `payTo` with an `AuthorizationUsed` event; `/generate` shows 0.03 USDC through the upto proxy `0x4020…0002`. The ledger shows `settled/completed` charges.
144
+ 6. **Replay.** Log the `PAYMENT-SIGNATURE` header the client sent (wrap its `fetch`) and send it again with `curl -H` → `409` with `error.code: "proof_already_used"` (or `already_paid` when you also resend the same `Idempotency-Key`), no second transfer.
145
+ 7. **Handler failure.** Make the handler return 500 once → the charge is `released/failed`, no transfer; resend the same header within `maxTimeoutSeconds` → `200` and one transfer.
146
+ 8. **Reconciliation.** Point `facilitator.url` at a small proxy that forwards `/verify` normally but forwards `/settle` to x402.org and then answers `504` → the charge is `unknown`. Wait for the finalized block to pass the settlement (a few minutes on Base Sepolia), run `toll.reconcile({ olderThanMs: 0 })` → `settled` with the on-chain hash and no second `/settle`. Repeat with a proxy that drops `/settle` without forwarding it, wait until finality passes `validBefore`, reconcile → `failed`, no transfer.
@@ -0,0 +1,129 @@
1
+ import { Money, JsonObject, Rail } from 'tollstile';
2
+
3
+ /** A token that settles x402 payments on an EVM network. */
4
+ type X402Asset = {
5
+ /** Asset code recorded on offers, e.g. `"USDC"`. */
6
+ readonly code: string;
7
+ readonly address: string;
8
+ readonly decimals: number;
9
+ /** EIP-712 domain name of the token contract, advertised as `extra.name`. */
10
+ readonly name: string;
11
+ /** EIP-712 domain version of the token contract, advertised as `extra.version`. */
12
+ readonly version: string;
13
+ };
14
+
15
+ type Fetch = typeof fetch;
16
+
17
+ type X402FacilitatorOptions = {
18
+ /** Base URL; `/verify` and `/settle` are appended. */
19
+ readonly url: string;
20
+ /** Headers for each request, e.g. a freshly signed CDP JWT. Called once per request. */
21
+ readonly headers?: () => Promise<Record<string, string>>;
22
+ };
23
+ type X402Options = {
24
+ /** CAIP-2 network, e.g. `"eip155:84532"` (Base Sepolia) or `"eip155:8453"` (Base). */
25
+ readonly network: string;
26
+ /** Your address. Payments are signed to it and checked against it. */
27
+ readonly payTo: string;
28
+ /**
29
+ * The currency the asset is pegged to, for conversion at par: `"USD"` for USDC. Set exactly one
30
+ * of `denomination` and `rate`; Tollstile never assumes a conversion.
31
+ */
32
+ readonly denomination?: string;
33
+ /** Converts a price to atomic units of the asset when it is not at par. The quote fixes the result. */
34
+ readonly rate?: (price: Money) => Promise<bigint>;
35
+ /** Defaults to USDC on Base and Base Sepolia. Required on other networks. */
36
+ readonly asset?: X402Asset;
37
+ /**
38
+ * The facilitator that verifies and settles. Defaults to https://x402.org/facilitator on Base
39
+ * Sepolia only; required everywhere else.
40
+ */
41
+ readonly facilitator?: X402FacilitatorOptions;
42
+ /**
43
+ * JSON-RPC endpoint for `network`. Facilitators have no status endpoint and `/settle` is not
44
+ * idempotent, so reconciliation reads authorization nonces and transfer logs from the chain.
45
+ */
46
+ readonly rpcUrl: string;
47
+ /**
48
+ * Enables `upTo()` prices with the `upto` scheme (Permit2). `facilitatorAddress` is the address
49
+ * your facilitator lists for `upto` in `GET /supported`; payers bind their signature to it.
50
+ */
51
+ readonly upto?: {
52
+ readonly facilitatorAddress: string;
53
+ };
54
+ /**
55
+ * How long a payer's signature stays valid. Defaults to 60. The handler and settlement must both
56
+ * finish before it runs out, or settlement is rejected after the service was delivered.
57
+ */
58
+ readonly maxTimeoutSeconds?: number;
59
+ readonly fetch?: Fetch;
60
+ };
61
+
62
+ type Scheme = 'exact' | 'upto';
63
+ /** One entry of x402 V2 `accepts`. */
64
+ type PaymentRequirements = {
65
+ readonly scheme: Scheme;
66
+ readonly network: string;
67
+ /** Atomic units. For `upto`, the maximum at verification and the actual amount at settlement. */
68
+ readonly amount: string;
69
+ readonly asset: string;
70
+ readonly payTo: string;
71
+ readonly maxTimeoutSeconds: number;
72
+ readonly extra: {
73
+ readonly [key: string]: string;
74
+ };
75
+ };
76
+
77
+ /**
78
+ * What the ledger stores for a verified x402 payment. Settlement and reconciliation, possibly in
79
+ * another process, work from this record alone.
80
+ *
81
+ * `paymentPayload` carries the payer's signature, which settling after a crash needs. It never
82
+ * appears in errors, events, or receipts, and `redact` replaces it and `paymentRequirements` with
83
+ * `null` once the charge is final; everything `lookup` uses stays.
84
+ */
85
+ type X402Data = {
86
+ readonly scheme: Scheme;
87
+ readonly network: string;
88
+ readonly asset: string;
89
+ readonly payTo: string;
90
+ readonly payer: string;
91
+ /** exact: the EIP-3009 bytes32 nonce, lowercase hex. upto: the Permit2 uint256 nonce, decimal. */
92
+ readonly nonce: string;
93
+ /** Unix seconds: the EIP-3009 `validBefore`, or the Permit2 `deadline`. */
94
+ readonly validBefore: string;
95
+ /** Atomic units the payer signed for: the exact value, or the upto maximum. */
96
+ readonly authorizedAmount: string;
97
+ /** The price `authorizedAmount` covers, in micro-units. upto settles at this quoted ratio. */
98
+ readonly limitMicros: string;
99
+ /** The client's PaymentPayload, forwarded unchanged to `/settle`. `null` once redacted. */
100
+ readonly paymentPayload: JsonObject | null;
101
+ /** The requirements this server derived and verified against. `null` once redacted. */
102
+ readonly paymentRequirements: PaymentRequirements | null;
103
+ };
104
+
105
+ type X402Rail = Rail<'x402', X402Data>;
106
+ /**
107
+ * The x402 V2 rail: `exact` (EIP-3009) for fixed prices and `upto` (Permit2) for `upTo()` prices,
108
+ * over HTTP (`PAYMENT-REQUIRED` / `PAYMENT-SIGNATURE` / `PAYMENT-RESPONSE`) and MCP
109
+ * (`_meta["x402/payment"]`). Payments are verified before the handler and settled after it.
110
+ *
111
+ * @example
112
+ * ```ts
113
+ * const toll = createTollstile({
114
+ * rails: [
115
+ * x402({
116
+ * network: 'eip155:84532',
117
+ * payTo: '0xYourAddress',
118
+ * denomination: 'USD',
119
+ * rpcUrl: 'https://sepolia.base.org',
120
+ * }),
121
+ * ],
122
+ * ledger: memoryLedger(),
123
+ * secret: process.env.TOLLSTILE_SECRET,
124
+ * });
125
+ * ```
126
+ */
127
+ declare function x402(options: X402Options): X402Rail;
128
+
129
+ export { type X402Asset, type X402Data, type X402FacilitatorOptions, type X402Options, type PaymentRequirements as X402PaymentRequirements, type X402Rail, x402 };
package/dist/index.js ADDED
@@ -0,0 +1,789 @@
1
+ // src/x402-rail.ts
2
+ import {
3
+ money,
4
+ TollstileError as TollstileError6,
5
+ toAssetUnits
6
+ } from "tollstile";
7
+
8
+ // src/facilitator.ts
9
+ import { TollstileError as TollstileError2 } from "tollstile";
10
+
11
+ // src/provider-fetch.ts
12
+ import { TollstileError } from "tollstile";
13
+
14
+ // src/wire.ts
15
+ var BASE64 = /^[A-Za-z0-9+/]*={0,2}$/;
16
+ function encodeBase64Json(value) {
17
+ const bytes = new TextEncoder().encode(JSON.stringify(value));
18
+ let binary = "";
19
+ for (const byte of bytes) binary += String.fromCharCode(byte);
20
+ return btoa(binary);
21
+ }
22
+ function decodeBase64Json(value) {
23
+ if (value.length % 4 !== 0 || !BASE64.test(value)) return { ok: false };
24
+ const bytes = Uint8Array.from(atob(value), (char) => char.charCodeAt(0));
25
+ return parseJson(new TextDecoder().decode(bytes));
26
+ }
27
+ function parseJson(text) {
28
+ try {
29
+ return { ok: true, value: JSON.parse(text) };
30
+ } catch {
31
+ return { ok: false };
32
+ }
33
+ }
34
+ function isObject(value) {
35
+ return typeof value === "object" && value !== null && !Array.isArray(value);
36
+ }
37
+ function textField(record, key) {
38
+ const value = record[key];
39
+ return typeof value === "string" ? value : void 0;
40
+ }
41
+ function objectField(record, key) {
42
+ const value = record[key];
43
+ return isObject(value) ? value : void 0;
44
+ }
45
+ function jsonEqual(a, b) {
46
+ if (Array.isArray(a) || Array.isArray(b)) {
47
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
48
+ const other = b;
49
+ return a.every((item, index) => jsonEqual(item, other[index]));
50
+ }
51
+ if (isObject(a) || isObject(b)) {
52
+ if (!isObject(a) || !isObject(b)) return false;
53
+ const keys = Object.keys(a);
54
+ return keys.length === Object.keys(b).length && keys.every((key) => key in b && jsonEqual(a[key], b[key]));
55
+ }
56
+ return a === b;
57
+ }
58
+ function containsSubset(expected, actual) {
59
+ if (!isObject(expected)) return jsonEqual(expected, actual);
60
+ if (!isObject(actual)) return false;
61
+ return Object.entries(expected).every(([key, value]) => containsSubset(value, actual[key]));
62
+ }
63
+
64
+ // src/provider-fetch.ts
65
+ async function postJson(fetcher, request) {
66
+ let status;
67
+ let text;
68
+ try {
69
+ const response = await fetcher(request.url, {
70
+ method: "POST",
71
+ headers: { ...request.headers, "content-type": "application/json" },
72
+ body: JSON.stringify(request.body),
73
+ signal: request.signal
74
+ });
75
+ status = response.status;
76
+ text = await response.text();
77
+ } catch (error) {
78
+ if (request.signal.aborted) {
79
+ throw new TollstileError("PROVIDER_TIMEOUT", `${request.label} did not answer in time.`, { cause: error });
80
+ }
81
+ throw new TollstileError("PROVIDER_UNAVAILABLE", `${request.label} could not be reached.`, { cause: error });
82
+ }
83
+ const parsed = parseJson(text);
84
+ return { status, body: parsed.ok ? parsed.value : void 0 };
85
+ }
86
+
87
+ // src/facilitator.ts
88
+ var LABEL = "x402 facilitator";
89
+ var REASON = /^[a-z0-9_]{1,80}$/;
90
+ var TRANSACTION = /^0x[0-9a-fA-F]{64}$/;
91
+ var UNEXPECTED_VERIFY = "unexpected_verify_error";
92
+ var AMBIGUOUS_SETTLE = /* @__PURE__ */ new Set(["settlement_pending", "unexpected_settle_error"]);
93
+ function facilitatorClient(options, fetcher) {
94
+ const base = options.url.replace(/\/+$/, "");
95
+ const post = async (path, request, operation) => postJson(fetcher, {
96
+ url: `${base}/${path}`,
97
+ body: request,
98
+ headers: options.headers === void 0 ? {} : await options.headers(),
99
+ signal: operation.signal,
100
+ label: `${LABEL} /${path}`
101
+ });
102
+ return {
103
+ async verify(request, operation) {
104
+ const { status, body } = await post("verify", request, operation);
105
+ if (!isObject(body) || typeof body.isValid !== "boolean" || body.isValid && !ok(status)) {
106
+ throw new TollstileError2("PROVIDER_UNAVAILABLE", `${LABEL} /verify answered HTTP ${String(status)} without a VerifyResponse.`);
107
+ }
108
+ if (body.isValid) return { isValid: true, payer: textField(body, "payer") };
109
+ const invalidReason = reason(textField(body, "invalidReason"), "verification_failed");
110
+ if (invalidReason === UNEXPECTED_VERIFY) {
111
+ throw new TollstileError2("PROVIDER_UNAVAILABLE", `${LABEL} /verify reported ${UNEXPECTED_VERIFY}.`);
112
+ }
113
+ return { isValid: false, invalidReason };
114
+ },
115
+ async settle(request, operation) {
116
+ const { status, body } = await post("settle", request, operation);
117
+ if (!isObject(body) || typeof body.success !== "boolean" || body.success && !ok(status)) {
118
+ throw new TollstileError2(
119
+ "PROVIDER_TIMEOUT",
120
+ `${LABEL} /settle answered HTTP ${String(status)} without a SettleResponse, so whether it broadcast a transfer is unknown.`
121
+ );
122
+ }
123
+ if (!body.success) {
124
+ const errorReason = reason(textField(body, "errorReason"), "settlement_failed");
125
+ if (AMBIGUOUS_SETTLE.has(errorReason)) {
126
+ throw new TollstileError2("PROVIDER_TIMEOUT", `${LABEL} /settle reported ${errorReason}; the transfer may still confirm on chain.`);
127
+ }
128
+ return { success: false, errorReason };
129
+ }
130
+ const transaction = textField(body, "transaction");
131
+ if (transaction === void 0 || !TRANSACTION.test(transaction)) {
132
+ throw new TollstileError2("PROVIDER_TIMEOUT", `${LABEL} /settle reported success without a transaction hash.`);
133
+ }
134
+ return { success: true, transaction };
135
+ }
136
+ };
137
+ }
138
+ function ok(status) {
139
+ return status >= 200 && status < 300;
140
+ }
141
+ function reason(value, fallback) {
142
+ return value !== void 0 && REASON.test(value) ? value : fallback;
143
+ }
144
+
145
+ // src/json-rpc.ts
146
+ import { TollstileError as TollstileError3 } from "tollstile";
147
+ var LABEL2 = "x402 JSON-RPC node";
148
+ var QUANTITY = /^0x[0-9a-fA-F]{1,64}$/;
149
+ var DATA = /^0x[0-9a-fA-F]*$/;
150
+ function jsonRpcChain(url, fetcher) {
151
+ let id = 0;
152
+ const request = async (method, params, signal) => {
153
+ id += 1;
154
+ const { status, body } = await postJson(fetcher, {
155
+ url,
156
+ body: { jsonrpc: "2.0", id, method, params },
157
+ headers: {},
158
+ signal,
159
+ label: `${LABEL2} ${method}`
160
+ });
161
+ if (!isObject(body) || !("result" in body)) {
162
+ const code = isObject(body) && isObject(body.error) && typeof body.error.code === "number" ? ` (error ${String(body.error.code)})` : "";
163
+ throw unavailable(`${method} answered HTTP ${String(status)} without a result${code}.`);
164
+ }
165
+ return body.result ?? null;
166
+ };
167
+ return {
168
+ async block(tag, signal) {
169
+ const result = await request("eth_getBlockByNumber", [typeof tag === "bigint" ? quantity(tag) : tag, false], signal);
170
+ const number = isObject(result) ? hexQuantity(result, "number") : void 0;
171
+ const timestamp = isObject(result) ? hexQuantity(result, "timestamp") : void 0;
172
+ if (number === void 0 || timestamp === void 0) throw unavailable(`eth_getBlockByNumber returned no block for ${String(tag)}.`);
173
+ return { number, timestamp };
174
+ },
175
+ async call(to, data, block, signal) {
176
+ const result = await request("eth_call", [{ to, data }, quantity(block)], signal);
177
+ if (typeof result !== "string" || !DATA.test(result)) throw unavailable("eth_call returned malformed data.");
178
+ return result;
179
+ },
180
+ async logs(filter, signal) {
181
+ const result = await request(
182
+ "eth_getLogs",
183
+ [
184
+ {
185
+ address: filter.address,
186
+ topics: [...filter.topics],
187
+ fromBlock: quantity(filter.fromBlock),
188
+ toBlock: quantity(filter.toBlock)
189
+ }
190
+ ],
191
+ signal
192
+ );
193
+ if (!Array.isArray(result)) throw unavailable("eth_getLogs returned malformed logs.");
194
+ return parseLogs(result);
195
+ },
196
+ async receiptLogs(transactionHash, signal) {
197
+ const result = await request("eth_getTransactionReceipt", [transactionHash], signal);
198
+ if (!isObject(result) || !Array.isArray(result.logs)) throw unavailable(`eth_getTransactionReceipt returned no receipt for ${transactionHash}.`);
199
+ if (result.status !== "0x1") return [];
200
+ return parseLogs(result.logs);
201
+ },
202
+ async transaction(transactionHash, signal) {
203
+ const result = await request("eth_getTransactionByHash", [transactionHash], signal);
204
+ const input = isObject(result) ? textField(result, "input") : void 0;
205
+ if (!isObject(result) || input === void 0 || !DATA.test(input)) {
206
+ throw unavailable(`eth_getTransactionByHash returned no transaction for ${transactionHash}.`);
207
+ }
208
+ return { to: textField(result, "to") ?? null, input };
209
+ }
210
+ };
211
+ }
212
+ function parseLogs(values) {
213
+ return values.map((value) => {
214
+ const log = isObject(value) ? parseLog(value) : void 0;
215
+ if (log === void 0) throw unavailable("a JSON-RPC log entry is malformed.");
216
+ return log;
217
+ }).filter((log) => log !== "removed");
218
+ }
219
+ function parseLog(value) {
220
+ if (value.removed === true) return "removed";
221
+ const address2 = textField(value, "address");
222
+ const data = textField(value, "data");
223
+ const transactionHash = textField(value, "transactionHash");
224
+ const topics = Array.isArray(value.topics) ? value.topics : [];
225
+ if (address2 === void 0 || data === void 0 || !DATA.test(data) || transactionHash === void 0) return void 0;
226
+ if (!topics.every((topic) => typeof topic === "string")) return void 0;
227
+ return { address: address2, data, transactionHash, topics: topics.map((topic) => topic.toLowerCase()) };
228
+ }
229
+ function hexQuantity(record, key) {
230
+ const value = textField(record, key);
231
+ return value !== void 0 && QUANTITY.test(value) ? BigInt(value) : void 0;
232
+ }
233
+ function quantity(value) {
234
+ return `0x${value.toString(16)}`;
235
+ }
236
+ function unavailable(message) {
237
+ return new TollstileError3("PROVIDER_UNAVAILABLE", `${LABEL2}: ${message}`);
238
+ }
239
+
240
+ // src/networks.ts
241
+ var KNOWN_NETWORKS = {
242
+ "eip155:8453": {
243
+ asset: { code: "USDC", address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", decimals: 6, name: "USD Coin", version: "2" },
244
+ testnet: false,
245
+ facilitator: null,
246
+ peg: "USD"
247
+ },
248
+ "eip155:84532": {
249
+ asset: { code: "USDC", address: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", decimals: 6, name: "USDC", version: "2" },
250
+ testnet: true,
251
+ facilitator: "https://x402.org/facilitator",
252
+ peg: "USD"
253
+ }
254
+ };
255
+ var PERMIT2_ADDRESS = "0x000000000022D473030F116dDEE9F6B43aC78BA3";
256
+ var UPTO_PROXY_ADDRESS = "0x4020A4f3b7b90ccA423B9fabCc0CE57C6C240002";
257
+ var ADDRESS = /^0x[0-9a-fA-F]{40}$/;
258
+ function isAddress(value) {
259
+ return ADDRESS.test(value);
260
+ }
261
+ function sameAddress(a, b) {
262
+ return a.toLowerCase() === b.toLowerCase();
263
+ }
264
+
265
+ // src/options.ts
266
+ import { TollstileError as TollstileError4 } from "tollstile";
267
+ var CAIP2_EVM = /^eip155:[1-9]\d{0,19}$/;
268
+ var CURRENCY = /^[A-Z]{3}$/;
269
+ var DEFAULT_MAX_TIMEOUT_SECONDS = 60;
270
+ function resolveOptions(options) {
271
+ const { network } = options;
272
+ if (!CAIP2_EVM.test(network)) {
273
+ throw invalid(`network "${network}" is not a CAIP-2 EVM network. Use "eip155:<chainId>", e.g. "eip155:84532".`);
274
+ }
275
+ const known = KNOWN_NETWORKS[network];
276
+ const asset = options.asset ?? known?.asset;
277
+ if (asset === void 0) {
278
+ throw invalid(`network "${network}" has no built-in asset. Pass asset: { code, address, decimals, name, version }.`);
279
+ }
280
+ if (!isAddress(asset.address)) throw invalid(`asset address "${asset.address}" is not an EVM address.`);
281
+ if (!Number.isInteger(asset.decimals) || asset.decimals < 6 || asset.decimals > 18) {
282
+ throw invalid(`asset ${asset.code} has ${String(asset.decimals)} decimals; x402 assets need between 6 and 18.`);
283
+ }
284
+ if (!isAddress(options.payTo)) throw invalid(`payTo "${options.payTo}" is not an EVM address.`);
285
+ if (!URL.canParse(options.rpcUrl)) throw invalid("rpcUrl must be an absolute URL.");
286
+ const facilitator = options.facilitator ?? (known === void 0 || known.facilitator === null ? void 0 : { url: known.facilitator });
287
+ if (facilitator === void 0) {
288
+ throw invalid(
289
+ `network "${network}" needs an explicit facilitator: { url, headers }. The public x402.org facilitator is used by default only on Base Sepolia.`
290
+ );
291
+ }
292
+ if (!URL.canParse(facilitator.url)) throw invalid("facilitator.url must be an absolute URL.");
293
+ if (options.upto !== void 0 && !isAddress(options.upto.facilitatorAddress)) {
294
+ throw invalid(`upto.facilitatorAddress "${options.upto.facilitatorAddress}" is not an EVM address.`);
295
+ }
296
+ const maxTimeoutSeconds = options.maxTimeoutSeconds ?? DEFAULT_MAX_TIMEOUT_SECONDS;
297
+ if (!Number.isInteger(maxTimeoutSeconds) || maxTimeoutSeconds <= 0) {
298
+ throw invalid(`maxTimeoutSeconds must be a positive integer, got ${String(maxTimeoutSeconds)}.`);
299
+ }
300
+ return {
301
+ network,
302
+ asset,
303
+ payTo: options.payTo,
304
+ basis: resolveBasis(options, known === void 0 || options.asset !== void 0 ? null : known.peg, asset.code),
305
+ facilitator,
306
+ rpcUrl: options.rpcUrl,
307
+ upto: options.upto ?? null,
308
+ maxTimeoutSeconds,
309
+ fetch: options.fetch ?? ((input, init) => fetch(input, init))
310
+ };
311
+ }
312
+ function resolveBasis(options, peg, code) {
313
+ if (options.denomination !== void 0 && options.rate !== void 0) {
314
+ throw invalid("set either denomination (conversion at par) or rate, not both.");
315
+ }
316
+ if (options.rate !== void 0) return { kind: "rate", rate: options.rate };
317
+ if (options.denomination === void 0) {
318
+ throw invalid(
319
+ `${code} needs an explicit conversion basis. Use denomination: "${peg ?? "USD"}" if one ${code} is worth one ${peg ?? "unit of your price currency"}, or rate: (price) => atomic units.`
320
+ );
321
+ }
322
+ if (!CURRENCY.test(options.denomination)) throw invalid(`denomination "${options.denomination}" is not a currency code like "USD".`);
323
+ if (peg !== null && options.denomination !== peg) {
324
+ throw invalid(`the built-in ${code} is pegged to ${peg}, not ${options.denomination}. Use rate for other currencies.`);
325
+ }
326
+ return { kind: "par", currency: options.denomination };
327
+ }
328
+ function invalid(message) {
329
+ return new TollstileError4("CONFIG_INVALID", `x402: ${message}`);
330
+ }
331
+
332
+ // src/payment-payload.ts
333
+ var PAYMENT_SIGNATURE_HEADER = "payment-signature";
334
+ var PAYMENT_META = "x402/payment";
335
+ var UINT = /^\d{1,78}$/;
336
+ var HEX_UINT = /^0x[0-9a-fA-F]{1,64}$/;
337
+ var BYTES32 = /^0x[0-9a-fA-F]{64}$/;
338
+ var MAX_UINT256 = 2n ** 256n - 1n;
339
+ function readProof(context) {
340
+ const value = context.transport === "mcp" ? readMeta(context) : readHeader(context);
341
+ if (value === "absent") return { status: "absent" };
342
+ if (value === "malformed") return { status: "invalid", reason: "payload_malformed" };
343
+ if (value.x402Version !== 2) return { status: "invalid", reason: "unsupported_x402_version" };
344
+ const accepted = objectField(value, "accepted");
345
+ const payload = objectField(value, "payload");
346
+ if (accepted === void 0 || payload === void 0) return { status: "invalid", reason: "payload_malformed" };
347
+ return { status: "present", paymentPayload: value, accepted, payload };
348
+ }
349
+ function readHeader(context) {
350
+ const header = context.request?.headers.get(PAYMENT_SIGNATURE_HEADER);
351
+ if (header === null || header === void 0) return "absent";
352
+ const decoded = decodeBase64Json(header.trim());
353
+ return decoded.ok && isObject(decoded.value) ? decoded.value : "malformed";
354
+ }
355
+ function readMeta(context) {
356
+ const value = context.mcp?.meta[PAYMENT_META];
357
+ if (value === void 0) return "absent";
358
+ return isObject(value) ? value : "malformed";
359
+ }
360
+ var PAYMENT_IDENTIFIER_EXTENSION = "payment-identifier";
361
+ var PAYMENT_ID = /^[a-zA-Z0-9_-]{16,128}$/;
362
+ function paymentIdentifier(paymentPayload) {
363
+ const extensions = objectField(paymentPayload, "extensions");
364
+ const extension = extensions === void 0 ? void 0 : objectField(extensions, PAYMENT_IDENTIFIER_EXTENSION);
365
+ const info = extension === void 0 ? void 0 : objectField(extension, "info");
366
+ if (info === void 0 || info.id === void 0) return { status: "none" };
367
+ return typeof info.id === "string" && PAYMENT_ID.test(info.id) ? { status: "present", id: info.id } : { status: "invalid" };
368
+ }
369
+ function parseEip3009(payload) {
370
+ const authorization = objectField(payload, "authorization");
371
+ if (authorization === void 0 || textField(payload, "signature") === void 0) return void 0;
372
+ const from = address(authorization, "from");
373
+ const to = address(authorization, "to");
374
+ const value = uint(authorization, "value");
375
+ const validAfter = uint(authorization, "validAfter");
376
+ const validBefore = uint(authorization, "validBefore");
377
+ const nonce = textField(authorization, "nonce");
378
+ if (from === void 0 || to === void 0 || value === void 0 || validAfter === void 0 || validBefore === void 0 || nonce === void 0 || !BYTES32.test(nonce)) {
379
+ return void 0;
380
+ }
381
+ return { from, to, value, validAfter, validBefore, nonce: nonce.toLowerCase() };
382
+ }
383
+ function parsePermit2(payload) {
384
+ const authorization = objectField(payload, "permit2Authorization");
385
+ if (authorization === void 0 || textField(payload, "signature") === void 0) return void 0;
386
+ const permitted = objectField(authorization, "permitted");
387
+ const witness = objectField(authorization, "witness");
388
+ if (permitted === void 0 || witness === void 0) return void 0;
389
+ const from = address(authorization, "from");
390
+ const token = address(permitted, "token");
391
+ const amount = uint(permitted, "amount");
392
+ const spender = address(authorization, "spender");
393
+ const nonce = uint(authorization, "nonce");
394
+ const deadline = uint(authorization, "deadline");
395
+ const to = address(witness, "to");
396
+ const facilitator = address(witness, "facilitator");
397
+ const validAfter = uint(witness, "validAfter");
398
+ if (from === void 0 || token === void 0 || amount === void 0 || spender === void 0 || nonce === void 0 || deadline === void 0 || to === void 0 || facilitator === void 0 || validAfter === void 0) {
399
+ return void 0;
400
+ }
401
+ return { from, token, amount, spender, nonce, deadline, to, facilitator, validAfter };
402
+ }
403
+ function address(record, key) {
404
+ const value = textField(record, key);
405
+ return value !== void 0 && isAddress(value) ? value : void 0;
406
+ }
407
+ function uint(record, key) {
408
+ const value = textField(record, key);
409
+ if (value === void 0 || !(UINT.test(value) || HEX_UINT.test(value))) return void 0;
410
+ const parsed = BigInt(value);
411
+ return parsed <= MAX_UINT256 ? parsed : void 0;
412
+ }
413
+
414
+ // src/payment-requirements.ts
415
+ var QUOTE_EXTRA_KEY = "tollstileQuote";
416
+ function requirementsFor(settings, terms) {
417
+ const { asset } = settings;
418
+ const quote = terms.quoteToken === null ? {} : { [QUOTE_EXTRA_KEY]: terms.quoteToken };
419
+ const upto = terms.variable && settings.upto !== null ? { assetTransferMethod: "permit2", facilitatorAddress: settings.upto.facilitatorAddress } : {};
420
+ return {
421
+ scheme: terms.variable ? "upto" : "exact",
422
+ network: settings.network,
423
+ amount: terms.amount,
424
+ asset: asset.address,
425
+ payTo: settings.payTo,
426
+ maxTimeoutSeconds: settings.maxTimeoutSeconds,
427
+ extra: { name: asset.name, version: asset.version, ...upto, ...quote }
428
+ };
429
+ }
430
+ function acceptedMatches(requirements, accepted) {
431
+ const { extra, ...core } = requirements;
432
+ const { extra: acceptedExtra, ...acceptedCore } = accepted;
433
+ return jsonEqual(core, acceptedCore) && containsSubset(extra, acceptedExtra);
434
+ }
435
+ var PAYMENT_IDENTIFIER = {
436
+ info: { required: false },
437
+ schema: {
438
+ $schema: "https://json-schema.org/draft/2020-12/schema",
439
+ type: "object",
440
+ properties: {
441
+ required: { type: "boolean" },
442
+ id: { type: "string", minLength: 16, maxLength: 128, pattern: "^[a-zA-Z0-9_-]+$" }
443
+ },
444
+ required: ["required"]
445
+ }
446
+ };
447
+ function paymentRequired(requirements, context) {
448
+ return {
449
+ x402Version: 2,
450
+ resource: { url: resourceUrl(context) },
451
+ accepts: [requirements],
452
+ extensions: { [PAYMENT_IDENTIFIER_EXTENSION]: PAYMENT_IDENTIFIER }
453
+ };
454
+ }
455
+ function resourceUrl(context) {
456
+ if (context.transport === "mcp" && context.mcp !== null) return `mcp://tool/${context.mcp.tool}`;
457
+ return context.request?.url ?? context.resource;
458
+ }
459
+
460
+ // src/settlement-lookup.ts
461
+ import { TollstileError as TollstileError5 } from "tollstile";
462
+
463
+ // src/abi.ts
464
+ var AUTHORIZATION_STATE_SELECTOR = "0xe94a0102";
465
+ var NONCE_BITMAP_SELECTOR = "0x4fe02b44";
466
+ var UPTO_SETTLE_SELECTOR = "0xff11e7b4";
467
+ var UPTO_SETTLE_WITH_PERMIT_SELECTOR = "0x016c1748";
468
+ var TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
469
+ var AUTHORIZATION_USED_TOPIC = "0x98de503528ee59b575ef0c0a2576a82497bfc029a5685b209e9ec333479b10a5";
470
+ var AUTHORIZATION_CANCELED_TOPIC = "0x1cdd46ff242716cdaa72d159d339a485b3438398348d68f09d7c8c0a59353d81";
471
+ var UNORDERED_NONCE_INVALIDATION_TOPIC = "0x3704902f963766a4e561bbaab6e6cdc1b1dd12f6e9e99648da8843b3f46b918d";
472
+ var WORD = 64;
473
+ var HEX = /^0x(?:[0-9a-fA-F]{2})*$/;
474
+ function word(value) {
475
+ const digits = typeof value === "bigint" ? value.toString(16) : value.slice(2).toLowerCase();
476
+ return `0x${digits.padStart(WORD, "0")}`;
477
+ }
478
+ function encodeCall(selector, ...words) {
479
+ return selector + words.map((value) => value.slice(2)).join("");
480
+ }
481
+ function readWord(data, index, offset = 0) {
482
+ if (!HEX.test(data)) return void 0;
483
+ const start = 2 + offset * 2 + index * WORD;
484
+ const slice = data.slice(start, start + WORD);
485
+ return slice.length === WORD ? BigInt(`0x${slice}`) : void 0;
486
+ }
487
+ function selectorOf(calldata) {
488
+ return calldata.slice(0, 10).toLowerCase();
489
+ }
490
+
491
+ // src/settlement-lookup.ts
492
+ var CLOCK_SKEW_SECONDS = 600n;
493
+ async function lookupSettlement(chain, data, charge, signal) {
494
+ const finalized = await chain.block("finalized", signal);
495
+ const lastValidSecond = data.scheme === "exact" ? BigInt(data.validBefore) - 1n : BigInt(data.validBefore);
496
+ const used = data.scheme === "exact" ? await eip3009NonceUsed(chain, data, finalized, signal) : await permit2NonceUsed(chain, data, finalized, signal);
497
+ if (!used) {
498
+ if (finalized.timestamp > lastValidSecond) return { status: "none" };
499
+ throw pending(`${data.payer}'s authorization is unused but valid until ${data.validBefore}; ask again after it expires.`);
500
+ }
501
+ const range = await searchRange(chain, finalized, BigInt(Math.floor(charge.createdAt.getTime() / 1e3)) - CLOCK_SKEW_SECONDS, lastValidSecond, signal);
502
+ const evidence = data.scheme === "exact" ? await eip3009Evidence(chain, data, range, signal) : await permit2Evidence(chain, data, range, signal);
503
+ if (evidence === "cancelled") return { status: "none" };
504
+ if (evidence === void 0) {
505
+ throw pending(
506
+ `the nonce of ${data.payer}'s authorization is used, but no settlement or cancellation was found in blocks ${String(range.fromBlock)}-${String(range.toBlock)}. Investigate before resolving by hand.`
507
+ );
508
+ }
509
+ return {
510
+ status: "settled",
511
+ reference: evidence.transaction,
512
+ details: { transaction: evidence.transaction, network: data.network, payer: data.payer, amount: evidence.amount }
513
+ };
514
+ }
515
+ async function searchRange(chain, finalized, fromSecond, lastValidSecond, signal) {
516
+ const back = finalized.timestamp > fromSecond ? finalized.timestamp - fromSecond : 0n;
517
+ const fromBlock = finalized.number > back ? finalized.number - back : 0n;
518
+ const start = await chain.block(fromBlock, signal);
519
+ const ahead = lastValidSecond > start.timestamp ? lastValidSecond - start.timestamp : 0n;
520
+ const toBlock = fromBlock + ahead < finalized.number ? fromBlock + ahead : finalized.number;
521
+ return { fromBlock, toBlock };
522
+ }
523
+ async function eip3009NonceUsed(chain, data, at, signal) {
524
+ const result = await chain.call(data.asset, encodeCall(AUTHORIZATION_STATE_SELECTOR, word(data.payer), word(data.nonce)), at.number, signal);
525
+ const state = readWord(result, 0);
526
+ if (state === void 0) throw pending("authorizationState returned malformed data.");
527
+ return state === 1n;
528
+ }
529
+ async function permit2NonceUsed(chain, data, at, signal) {
530
+ const nonce = BigInt(data.nonce);
531
+ const result = await chain.call(PERMIT2_ADDRESS, encodeCall(NONCE_BITMAP_SELECTOR, word(data.payer), word(nonce >> 8n)), at.number, signal);
532
+ const bitmap = readWord(result, 0);
533
+ if (bitmap === void 0) throw pending("Permit2 nonceBitmap returned malformed data.");
534
+ return (bitmap >> (nonce & 0xffn) & 1n) === 1n;
535
+ }
536
+ async function eip3009Evidence(chain, data, range, signal) {
537
+ const used = await chain.logs({ address: data.asset, topics: [AUTHORIZATION_USED_TOPIC, word(data.payer), word(data.nonce)], ...range }, signal);
538
+ for (const log of used) {
539
+ const receipt = await chain.receiptLogs(log.transactionHash, signal);
540
+ if (receipt.some((entry) => isTransfer(entry, data) && readWord(entry.data, 0) === BigInt(data.authorizedAmount))) {
541
+ return { transaction: log.transactionHash, amount: data.authorizedAmount };
542
+ }
543
+ }
544
+ const cancelled = await chain.logs({ address: data.asset, topics: [AUTHORIZATION_CANCELED_TOPIC, word(data.payer), word(data.nonce)], ...range }, signal);
545
+ return cancelled.length > 0 ? "cancelled" : void 0;
546
+ }
547
+ async function permit2Evidence(chain, data, range, signal) {
548
+ const transfers = await chain.logs({ address: data.asset, topics: [TRANSFER_TOPIC, word(data.payer), word(data.payTo)], ...range }, signal);
549
+ for (const log of transfers) {
550
+ const transaction = await chain.transaction(log.transactionHash, signal);
551
+ const amount = readWord(log.data, 0);
552
+ if (amount !== void 0 && transaction.to !== null && sameAddress(transaction.to, UPTO_PROXY_ADDRESS) && settlesNonce(transaction.input, data)) {
553
+ return { transaction: log.transactionHash, amount: amount.toString() };
554
+ }
555
+ }
556
+ const invalidations = await chain.logs({ address: PERMIT2_ADDRESS, topics: [UNORDERED_NONCE_INVALIDATION_TOPIC, word(data.payer)], ...range }, signal);
557
+ const nonce = BigInt(data.nonce);
558
+ const cancelled = invalidations.some((log) => readWord(log.data, 0) === nonce >> 8n && ((readWord(log.data, 1) ?? 0n) >> (nonce & 0xffn) & 1n) === 1n);
559
+ return cancelled ? "cancelled" : void 0;
560
+ }
561
+ var SETTLE_LAYOUTS = {
562
+ [UPTO_SETTLE_SELECTOR]: 0,
563
+ [UPTO_SETTLE_WITH_PERMIT_SELECTOR]: 5
564
+ };
565
+ function settlesNonce(input, data) {
566
+ const base = SETTLE_LAYOUTS[selectorOf(input)];
567
+ if (base === void 0) return false;
568
+ return readWord(input, base, 4) === BigInt(data.asset) && readWord(input, base + 2, 4) === BigInt(data.nonce) && readWord(input, base + 5, 4) === BigInt(data.payer);
569
+ }
570
+ function isTransfer(log, data) {
571
+ return sameAddress(log.address, data.asset) && log.topics[0] === TRANSFER_TOPIC && log.topics[1] === word(data.payer) && log.topics[2] === word(data.payTo);
572
+ }
573
+ function pending(message) {
574
+ return new TollstileError5("PROVIDER_TIMEOUT", `x402 settlement lookup: ${message}`);
575
+ }
576
+
577
+ // src/x402-rail.ts
578
+ var PAYMENT_REQUIRED_HEADER = "payment-required";
579
+ var PAYMENT_RESPONSE_HEADER = "payment-response";
580
+ var PAYMENT_RESPONSE_META = "x402/payment-response";
581
+ var NAME = "x402";
582
+ var MAX_DATE_SECONDS = 8640000000000n;
583
+ function x402(options) {
584
+ const settings = resolveOptions(options);
585
+ const facilitator = facilitatorClient(settings.facilitator, settings.fetch);
586
+ const chain = jsonRpcChain(settings.rpcUrl, settings.fetch);
587
+ const { asset, network } = settings;
588
+ return {
589
+ name: NAME,
590
+ livemode: true,
591
+ capabilities: {
592
+ // exact cannot be refunded, so the payer is charged only after the handler succeeded.
593
+ flows: ["authorization"],
594
+ authorization: "single",
595
+ variableAmount: settings.upto !== null,
596
+ quotes: true,
597
+ refund: false,
598
+ partialRefund: false,
599
+ lookup: true
600
+ },
601
+ async offer({ price, variable }) {
602
+ if (variable && settings.upto === null) return null;
603
+ const amount = await assetUnits(settings, price);
604
+ if (amount === null) return null;
605
+ return {
606
+ rail: NAME,
607
+ asset: { code: asset.code, network, scale: asset.decimals },
608
+ amount: amount.toString(),
609
+ basis: settings.basis.kind,
610
+ details: {}
611
+ };
612
+ },
613
+ challenge(quote, quoteToken, offer, context) {
614
+ const requirements = requirementsFor(settings, { amount: offer.amount, variable: quote.variable, quoteToken });
615
+ const body = paymentRequired(requirements, context);
616
+ return Promise.resolve({
617
+ headers: [[PAYMENT_REQUIRED_HEADER, encodeBase64Json(body)]],
618
+ accepts: body,
619
+ mcp: { style: "x402", paymentRequired: body }
620
+ });
621
+ },
622
+ async verify(context, terms, operation) {
623
+ const proof = readProof(context);
624
+ if (proof.status !== "present") return proof;
625
+ const identifier = paymentIdentifier(proof.paymentPayload);
626
+ if (identifier.status === "invalid") return invalid2("payment_identifier_invalid");
627
+ const idempotency = identifier.status === "present" ? { idempotencyKey: identifier.id } : {};
628
+ const extra = objectField(proof.accepted, "extra");
629
+ const quoteToken = extra === void 0 ? void 0 : textField(extra, QUOTE_EXTRA_KEY);
630
+ const priced = quoteToken === void 0 ? await routeTerms(settings, terms) : quotedTerms(settings, await terms.openQuote(quoteToken));
631
+ if ("status" in priced) {
632
+ const identified = priced.reason === "quote_invalid" ? identify(proof.payload, settings) : void 0;
633
+ return identified === void 0 ? priced : { ...priced, proofId: identified, ...idempotency };
634
+ }
635
+ if (priced.variable && settings.upto === null) return invalid2("variable_amount_unsupported");
636
+ const requirements = requirementsFor(settings, { amount: priced.amount, variable: priced.variable, quoteToken: quoteToken ?? null });
637
+ if (!acceptedMatches(requirements, proof.accepted)) return invalid2("accepted_mismatch");
638
+ const signed = requirements.scheme === "exact" ? signedEip3009(proof.payload, requirements) : signedPermit2(proof.payload, requirements, settings);
639
+ if ("status" in signed) return signed;
640
+ const verdict = await facilitator.verify({ x402Version: 2, paymentPayload: proof.paymentPayload, paymentRequirements: requirements }, operation);
641
+ if (!verdict.isValid) {
642
+ const genuine = !verdict.invalidReason.includes("signature");
643
+ return genuine ? { ...invalid2(verdict.invalidReason), proofId: proofIdOf(settings, signed), ...idempotency } : invalid2(verdict.invalidReason);
644
+ }
645
+ if (verdict.payer !== void 0 && !sameAddress(verdict.payer, signed.payer)) return invalid2("payer_mismatch");
646
+ const limit = settings.basis.kind === "par" ? money(settings.basis.currency, signed.authorizedAmount / 10n ** BigInt(asset.decimals - 6)) : priced.price;
647
+ const data = {
648
+ scheme: requirements.scheme,
649
+ network,
650
+ asset: asset.address,
651
+ payTo: settings.payTo,
652
+ payer: signed.payer,
653
+ nonce: signed.nonce,
654
+ validBefore: signed.validBefore.toString(),
655
+ authorizedAmount: signed.authorizedAmount.toString(),
656
+ limitMicros: limit.micros.toString(),
657
+ paymentPayload: proof.paymentPayload,
658
+ paymentRequirements: requirements
659
+ };
660
+ return {
661
+ status: "valid",
662
+ proofId: proofIdOf(settings, signed),
663
+ ...idempotency,
664
+ payer: signed.payer,
665
+ quote: priced.quote,
666
+ limit,
667
+ expiresAt: new Date(Number(signed.validBefore < MAX_DATE_SECONDS ? signed.validBefore : MAX_DATE_SECONDS) * 1e3),
668
+ data
669
+ };
670
+ },
671
+ async settle(authorization, charge, operation) {
672
+ const { data } = authorization;
673
+ if (data.paymentPayload === null || data.paymentRequirements === null) {
674
+ return { status: "rejected", reason: "payment_evidence_redacted" };
675
+ }
676
+ const amount = settledUnits(data, charge).toString();
677
+ const response = await facilitator.settle(
678
+ { x402Version: 2, paymentPayload: data.paymentPayload, paymentRequirements: { ...data.paymentRequirements, amount } },
679
+ operation
680
+ );
681
+ if (!response.success) return { status: "rejected", reason: response.errorReason };
682
+ return {
683
+ status: "settled",
684
+ reference: response.transaction,
685
+ details: { transaction: response.transaction, network: data.network, payer: data.payer, amount }
686
+ };
687
+ },
688
+ refund() {
689
+ return Promise.reject(
690
+ new TollstileError6("UNREACHABLE", "x402 exact and upto payments cannot be refunded, and the rail declares refund: false.")
691
+ );
692
+ },
693
+ // The signature expires at validBefore; there is nothing to cancel with the facilitator.
694
+ release() {
695
+ return Promise.resolve();
696
+ },
697
+ lookup(authorization, charge, operation) {
698
+ return lookupSettlement(chain, authorization.data, charge, operation.signal);
699
+ },
700
+ // The signature is only needed to settle; lookup works from the nonce, payer, and deadline.
701
+ redact(data) {
702
+ return { ...data, paymentPayload: null, paymentRequirements: null };
703
+ },
704
+ receipt(authorization, charge, context) {
705
+ if (charge.settlement === null) return { headers: [], meta: {} };
706
+ const { data } = authorization;
707
+ const response = {
708
+ success: true,
709
+ transaction: charge.settlement.reference,
710
+ network: data.network,
711
+ payer: data.payer,
712
+ amount: settledUnits(data, charge).toString()
713
+ };
714
+ return context.transport === "mcp" ? { headers: [], meta: { [PAYMENT_RESPONSE_META]: response } } : { headers: [[PAYMENT_RESPONSE_HEADER, encodeBase64Json(response)]], meta: {} };
715
+ }
716
+ };
717
+ }
718
+ function quotedTerms(settings, quote) {
719
+ if (quote === void 0) return invalid2("quote_invalid");
720
+ const offer = quote.offers.find((candidate) => candidate.rail === NAME);
721
+ if (offer === void 0) return invalid2("quote_offer_missing");
722
+ const { asset } = offer;
723
+ if (asset.network !== settings.network || asset.code !== settings.asset.code || asset.scale !== settings.asset.decimals) {
724
+ return invalid2("quote_offer_mismatch");
725
+ }
726
+ return { price: quote.price, amount: offer.amount, variable: quote.variable, quote };
727
+ }
728
+ async function routeTerms(settings, terms) {
729
+ if (terms.price === null) return invalid2("quote_required");
730
+ const amount = await assetUnits(settings, terms.price);
731
+ if (amount === null) return invalid2("price_unsupported");
732
+ return { price: terms.price, amount: amount.toString(), variable: terms.variable, quote: null };
733
+ }
734
+ async function assetUnits(settings, price) {
735
+ const { basis, asset } = settings;
736
+ if (basis.kind === "par") return price.currency === basis.currency ? toAssetUnits(price, asset.decimals) : null;
737
+ const amount = await basis.rate(price);
738
+ if (amount <= 0n) {
739
+ throw new TollstileError6("CONFIG_INVALID", `x402: rate() converted a price to ${amount.toString()} ${asset.code} units; it must be positive.`);
740
+ }
741
+ return amount;
742
+ }
743
+ function proofIdOf(settings, signed) {
744
+ return [settings.network, settings.asset.address, signed.payer, signed.nonce].join(":").toLowerCase();
745
+ }
746
+ function identify(payload, settings) {
747
+ const eip3009 = parseEip3009(payload);
748
+ if (eip3009 !== void 0) return proofIdOf(settings, { payer: eip3009.from.toLowerCase(), nonce: eip3009.nonce });
749
+ const permit2 = parsePermit2(payload);
750
+ return permit2 === void 0 ? void 0 : proofIdOf(settings, { payer: permit2.from.toLowerCase(), nonce: permit2.nonce.toString() });
751
+ }
752
+ function signedEip3009(payload, requirements) {
753
+ const authorization = parseEip3009(payload);
754
+ if (authorization === void 0) return invalid2("payload_invalid");
755
+ if (!sameAddress(authorization.to, requirements.payTo)) return invalid2("recipient_mismatch");
756
+ if (authorization.value !== BigInt(requirements.amount)) return invalid2("amount_mismatch");
757
+ return {
758
+ payer: authorization.from.toLowerCase(),
759
+ nonce: authorization.nonce,
760
+ validBefore: authorization.validBefore,
761
+ authorizedAmount: authorization.value
762
+ };
763
+ }
764
+ function signedPermit2(payload, requirements, settings) {
765
+ const authorization = parsePermit2(payload);
766
+ if (authorization === void 0 || settings.upto === null) return invalid2("payload_invalid");
767
+ if (!sameAddress(authorization.token, requirements.asset)) return invalid2("asset_mismatch");
768
+ if (!sameAddress(authorization.to, requirements.payTo)) return invalid2("recipient_mismatch");
769
+ if (authorization.amount !== BigInt(requirements.amount)) return invalid2("amount_mismatch");
770
+ if (!sameAddress(authorization.spender, UPTO_PROXY_ADDRESS)) return invalid2("spender_mismatch");
771
+ if (!sameAddress(authorization.facilitator, settings.upto.facilitatorAddress)) return invalid2("facilitator_mismatch");
772
+ return {
773
+ payer: authorization.from.toLowerCase(),
774
+ nonce: authorization.nonce.toString(),
775
+ validBefore: authorization.deadline,
776
+ authorizedAmount: authorization.amount
777
+ };
778
+ }
779
+ function settledUnits(data, charge) {
780
+ const authorized = BigInt(data.authorizedAmount);
781
+ if (data.scheme === "exact") return authorized;
782
+ return authorized * charge.amount.micros / BigInt(data.limitMicros);
783
+ }
784
+ function invalid2(reason2) {
785
+ return { status: "invalid", reason: reason2 };
786
+ }
787
+ export {
788
+ x402
789
+ };
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@tollstile/x402",
3
+ "version": "0.1.0",
4
+ "description": "x402 payments for APIs and MCP tools: accept USDC with the exact and upto schemes, verify through a facilitator, and reconcile on-chain. A Tollstile rail.",
5
+ "keywords": [
6
+ "x402",
7
+ "x402-server",
8
+ "usdc",
9
+ "base",
10
+ "stablecoin",
11
+ "agent-payments",
12
+ "machine-payments",
13
+ "ai-agents",
14
+ "mcp-payments",
15
+ "api-monetization",
16
+ "pay-per-call",
17
+ "http-402"
18
+ ],
19
+ "homepage": "https://tollstile.com",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/tollstile/tollstile.git",
23
+ "directory": "packages/x402"
24
+ },
25
+ "bugs": {
26
+ "url": "https://github.com/tollstile/tollstile/issues"
27
+ },
28
+ "author": "Paradigm AI Inc.",
29
+ "license": "MIT",
30
+ "type": "module",
31
+ "sideEffects": false,
32
+ "exports": {
33
+ ".": {
34
+ "types": "./dist/index.d.ts",
35
+ "default": "./dist/index.js"
36
+ }
37
+ },
38
+ "files": [
39
+ "dist"
40
+ ],
41
+ "peerDependencies": {
42
+ "tollstile": "^0.1.0"
43
+ },
44
+ "devDependencies": {
45
+ "@x402/core": "2.25.0",
46
+ "tollstile": "^0.1.0"
47
+ },
48
+ "scripts": {
49
+ "build": "tsup",
50
+ "typecheck": "tsc -p tsconfig.json"
51
+ }
52
+ }