@mooring-live/x402-client 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,168 @@
1
- # Temporary Holding Version
1
+ # @mooring-live/x402-client
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm](https://img.shields.io/npm/v/%40mooring-live%2Fx402-client)](https://www.npmjs.com/package/@mooring-live/x402-client)
4
+
5
+ Pay [x402](https://x402.org)-protected APIs from a **Mooring spending card** on Stellar.
6
+
7
+ A card is a Soroban custom account (`__check_auth`) that holds USDC and enforces a spending
8
+ policy — periodic budget, per-payment cap, merchant allowlist, expiry, freeze/cancel — inside the
9
+ authorization path. This package is the agent-side client: it detects a `402`, checks the card's
10
+ policy locally, signs the payment with the agent key so the **card** is the payer, and turns every
11
+ refusal into a typed error instead of a bare `402` — `CardPolicyDenied` when the card's policy
12
+ said no, `PaymentError` for everything else.
13
+
14
+ The server side stays stock x402 (`@x402/express` + `@x402/stellar` + any facilitator built on
15
+ them — OpenZeppelin Channels and Rail402 are both verified); only the client scheme is Mooring's,
16
+ because the stock one cannot sign for a contract account.
17
+
18
+ Apache-2.0. **Stellar testnet only** — not audited, not on mainnet.
19
+
20
+ ## Install
21
+
22
+ ```sh
23
+ npm i @mooring-live/x402-client @stellar/stellar-sdk
24
+ ```
25
+
26
+ `@stellar/stellar-sdk` is a dependency of the client, but you import `Keypair` from it yourself,
27
+ so install it alongside. The CLI (`npm i -g @mooring-live/cli`) wraps the same client for shell use.
28
+
29
+ Requires Node.js 20+.
30
+
31
+ ## Usage
32
+
33
+ ```ts
34
+ import { Keypair } from "@stellar/stellar-sdk";
35
+ import { CardPolicyDenied, PaymentError, createMooringFetch, getSettlement } from "@mooring-live/x402-client";
36
+
37
+ const fetchWithPayment = createMooringFetch({
38
+ card: "CAJPWJBF…AHCJ", // card contract address (the payer)
39
+ agent: Keypair.fromSecret(process.env.AGENT_SECRET!), // the card's registered signer
40
+ network: "stellar:testnet",
41
+ });
42
+
43
+ const res = await fetchWithPayment("https://api.example.com/weather");
44
+ console.log(await res.json());
45
+ console.log("settled:", getSettlement(res)?.transaction);
46
+ ```
47
+
48
+ That is the whole integration: `fetchWithPayment` is a drop-in `fetch`. A `402` is answered by
49
+ paying from the card and retrying; a `200` carries a `PAYMENT-RESPONSE` header that
50
+ `getSettlement` decodes into `{ success, transaction, network, payer }`, where `payer` is the
51
+ card.
52
+
53
+ ## Handling a refusal
54
+
55
+ ```ts
56
+ try {
57
+ await fetchWithPayment(url);
58
+ } catch (err) {
59
+ if (err instanceof CardPolicyDenied) {
60
+ // err.reason: "over_budget" | "not_allowlisted" | "expired" | …
61
+ // err.stage: "precheck" | "simulate" | "verify" | "settle"
62
+ // err.contractError: the card's error code, when the contract spoke (1–8)
63
+ console.error(`payment refused: ${err.reason} at ${err.stage}`);
64
+ } else if (err instanceof PaymentError) {
65
+ // Not the card's verdict. err.kind: "rejected" | "unconfirmed"
66
+ console.error(`payment ${err.kind}: ${err.detail}`, err.transaction);
67
+ } else {
68
+ throw err; // a network/server problem the client did not classify
69
+ }
70
+ }
71
+ ```
72
+
73
+ **Failed payments are never retried automatically**, at any stage.
74
+
75
+ `CardPolicyDenied` means exactly one thing: **the card's policy refused**. Everything else is a
76
+ `PaymentError`, so a caller can treat the first as a policy decision without further checks.
77
+
78
+ | `PaymentError.kind` | When | Was the card debited? |
79
+ |---|---|---|
80
+ | `rejected` | the facilitator refused before submitting — a malformed payload, a fee above its ceiling, an expiration too far out, a 5xx — or the client's own CAP-71 credential guard fired before signing | no |
81
+ | `unconfirmed` | the settlement state is unknown or contradictory: the server reported a failure for a transaction the ledger accepted, the transaction was never observed within the reconciliation window, or the server answered a bare `402` with no `PAYMENT-REQUIRED` and no `PAYMENT-RESPONSE` | possibly — reconcile `err.transaction` and the card's `spent` before retrying |
82
+
83
+ The facilitator answers `settle_exact_stellar_transaction_failed` both for a transaction the
84
+ ledger rejected and for one it stopped waiting for, so a settle failure carrying a hash is
85
+ reconciled against the RPC (~10 s) before it is named: a transaction the ledger accepted is never
86
+ reported as a policy denial.
87
+
88
+ ### Stages
89
+
90
+ | Stage | When | Reached the network? |
91
+ |---|---|---|
92
+ | `precheck` | the local policy mirror refused, before anything was signed | two read-only simulations of the card's views |
93
+ | `simulate` | the card's own `__check_auth` refused, in the enforcing simulation the client runs after signing | no — nothing left the process, no fee paid |
94
+ | `verify` | the facilitator refused the payload | yes; nothing submitted on-chain |
95
+ | `settle` | the settlement transaction failed on-chain — confirmed with the RPC, and attributed to the card only when the failure's diagnostics name it | yes; a transaction was submitted and failed |
96
+
97
+ ### Reasons
98
+
99
+ `frozen`, `cancelled`, `expired`, `not_allowlisted`, `over_per_tx_cap`, `over_budget`,
100
+ `wrong_token`, `insufficient_balance`, `bad_signature`, `wrong_context`, `unknown`.
101
+
102
+ Card contract codes map as #1 `bad_signature`, #2 `wrong_context`, #3 `frozen`, #4 `cancelled`,
103
+ #5 `expired`, #6 `not_allowlisted`, #7 `over_per_tx_cap`, #8 `over_budget`. `wrong_token` and
104
+ `insufficient_balance` are pre-check-only verdicts. `unknown` is **not a policy verdict** — only
105
+ an unclassified one: the card refused but the client could not read which rule. It covers a card
106
+ that could not be read (the pre-check fails **closed**), a facilitator `simulation_failed` the
107
+ re-run pre-check no longer explains, and an on-chain failure whose diagnostics do not name the
108
+ card.
109
+
110
+ ## Allowing a merchant from a denial
111
+
112
+ A `not_allowlisted` denial carries `err.merchant` (`payTo`, and when known `amount`, `network` and
113
+ `url`, the service URL with its query string removed). When `url` is known the message ends with a
114
+ link the card's owner can open to allow the merchant:
115
+
116
+ ```
117
+ https://app.mooring.live/allow?card=<C…>&url=<service url without query>
118
+ ```
119
+
120
+ `allowMerchantUrl(appUrl, card, serviceUrl)` builds the same link. Point it at another deployment
121
+ of the app with the `appUrl` option (default `https://app.mooring.live`).
122
+
123
+ ## Options
124
+
125
+ | Option | Type | Default | Meaning |
126
+ |---|---|---|---|
127
+ | `card` | `string` | — | card contract address (`C…`); it is the payer |
128
+ | `agent` | `Keypair` | — | the card's registered signer |
129
+ | `network` | `"stellar:testnet" \| "stellar:pubnet"` | — | CAIP-2 network |
130
+ | `rpcUrl` | `string` | testnet default; **required** on pubnet | Soroban RPC endpoint |
131
+ | `precheck` | `boolean` | `true` | run the local policy mirror before signing |
132
+ | `appUrl` | `string` | `https://app.mooring.live` | base URL of the Mooring app used in the allow link |
133
+ | `onDenial` | `(d: CardPolicyDenied) => void` | — | called at every denial stage |
134
+ | `onPaymentError` | `(e: PaymentError) => void` | — | called for every non-policy failure |
135
+ | `fetch` | `typeof fetch` | `globalThis.fetch` | transport (`createMooringFetch` only) |
136
+
137
+ ## API
138
+
139
+ | Export | What it does |
140
+ |---|---|
141
+ | `createMooringFetch(opts)` | a `fetch` that pays from the card and rejects with `CardPolicyDenied` or `PaymentError`, never a bare `402`; builds a fresh client per call, so it is safe to share |
142
+ | `createMooringClient(opts)` | the underlying `x402Client`. **Single-flight per instance** — x402's hooks carry no request identity, so build one per payment. Used directly it is stock x402: it throws generic errors and returns bare `402`s, and only `onDenial` / `onPaymentError` carry the typed reason |
143
+ | `getSettlement(res)` | decodes `PAYMENT-RESPONSE`; `null` when absent or undecodable (a settlement you cannot read is not a failed payment) |
144
+ | `CardPolicyDenied`, `DenialReason`, `DenialStage`, `CARD_ERROR_CODES`, `classifyContractError`, `cardDenialFromEvents` | typed denials (payment-path codes 1-8) |
145
+ | `CARD_ERRORS` | the full contract error table (codes 1-13, including owner-operation errors like `InvalidLabel`) mapped to short user-facing sentences |
146
+ | `PaymentError` | every failure that is *not* the card's policy (`rejected` / `unconfirmed`) |
147
+ | `reconcileSettlement(rpcUrl, network, hash)` | asks the ledger what became of a settlement transaction |
148
+ | `precheck(info, merchants, terms, now)` | the local policy mirror, on its own |
149
+ | `readCardInfo(rpcUrl, passphrase, card)`, `readMerchants(…)` | read-only card views (`info()`, `merchants()`); `CardInfo.label` is the owner-chosen display name |
150
+ | `CardExactStellarScheme` | the x402 `exact` scheme client whose payer is the card |
151
+ | `signCardAuthEntries(tx, card, agent, expirationLedger)` | signs a card auth entry with the agent key |
152
+
153
+ ## Notes
154
+
155
+ - The pre-check is **advisory**. The card contract is the authority and runs again at settlement;
156
+ the pre-check only avoids spending a signature and a round-trip on an obvious refusal.
157
+ - Muxed (`M…`) destinations are not payable — a Soroban `Address` does not exist for them.
158
+ - The client records legacy (v1) auth credentials (`useUpgradedAuth: false`) because the deployed
159
+ OZ Channels stack rejects CAP-71 `ADDRESS_V2` as malformed. It also **asserts** what the RPC
160
+ actually recorded and refuses to sign a v2 card entry, and `@stellar/stellar-sdk` is pinned
161
+ exactly to `17.0.1` while the workaround stands. All three go away together; see
162
+ `docs/x402-integration.md`.
163
+ - The auth entry expires at `latestLedger + max(2, ceil(maxTimeoutSeconds / 6) - 2)` — a
164
+ deliberately conservative ledger estimate, so the expiry stays inside the window the facilitator
165
+ computes from its own.
166
+
167
+ Full guide: [`docs/x402-integration.md`](../../docs/x402-integration.md). Testnet evidence:
168
+ [`docs/testnet.md`](../../docs/testnet.md).
package/dist/card.d.ts ADDED
@@ -0,0 +1,60 @@
1
+ export type CardState = 0 | 1 | 2;
2
+ export interface CardPolicy {
3
+ period_amount: bigint;
4
+ period_duration: bigint;
5
+ max_per_tx: bigint;
6
+ expiry: bigint;
7
+ }
8
+ export interface CardInfo {
9
+ owner: string;
10
+ signer: Uint8Array;
11
+ token: string;
12
+ label: string;
13
+ policy: CardPolicy;
14
+ state: CardState;
15
+ period: {
16
+ start: bigint;
17
+ spent: bigint;
18
+ };
19
+ remaining: bigint;
20
+ balance: bigint;
21
+ allow_count: number;
22
+ }
23
+ /** `CardInfo` as `scValToNative` hands it back, before normalization. */
24
+ export type RawCardInfo = {
25
+ owner: string;
26
+ signer: Uint8Array;
27
+ token: string;
28
+ label?: string;
29
+ policy: CardPolicy;
30
+ state: number;
31
+ period: {
32
+ start: bigint;
33
+ spent: bigint;
34
+ };
35
+ remaining: bigint;
36
+ balance: bigint;
37
+ allow_count: number;
38
+ };
39
+ /**
40
+ * Normalizes a raw `info()` result into `CardInfo`: `signer` becomes a
41
+ * plain `Uint8Array` (`scValToNative` hands back a node `Buffer`, which is one
42
+ * — copying it keeps node's type out of the published API), `allow_count` a
43
+ * `number`, and
44
+ * `state` is range-checked before it is narrowed. Every other field is already
45
+ * the `bigint` the contract declared, and is passed through untouched.
46
+ *
47
+ * @throws if `state` is not one of the contract's three variants.
48
+ * @throws if `label` is missing (a card deployed from a pre-label factory).
49
+ */
50
+ export declare function normalizeInfo(raw: RawCardInfo): CardInfo;
51
+ /**
52
+ * Normalizes a raw `merchants()` result into strkeys. `scValToNative` already
53
+ * renders an `ScAddress` as a strkey, so the guard is a no-op today; it keeps
54
+ * the reader honest if a future SDK hands back the raw XDR instead.
55
+ */
56
+ export declare function normalizeMerchants(list: readonly unknown[]): string[];
57
+ /** Reads the card's `info()` view via simulation (no signing, no fees). */
58
+ export declare function readCardInfo(rpcUrl: string, networkPassphrase: string, card: string): Promise<CardInfo>;
59
+ /** Reads the card's `merchants()` view via simulation. */
60
+ export declare function readMerchants(rpcUrl: string, networkPassphrase: string, card: string): Promise<string[]>;
package/dist/card.js ADDED
@@ -0,0 +1,67 @@
1
+ import { Address, contract, scValToNative } from "@stellar/stellar-sdk";
2
+ /**
3
+ * Simulates a read-only contract method and returns its parsed result.
4
+ *
5
+ * `AssembledTransaction.build` without a `publicKey` simulates from a null
6
+ * account, so nothing is signed, submitted or charged.
7
+ */
8
+ async function simulateView(rpcUrl, networkPassphrase, card, method) {
9
+ const tx = await contract.AssembledTransaction.build({
10
+ contractId: card,
11
+ method,
12
+ args: [],
13
+ networkPassphrase,
14
+ rpcUrl,
15
+ parseResultXdr: (v) => scValToNative(v),
16
+ });
17
+ return tx.result;
18
+ }
19
+ /**
20
+ * Normalizes a raw `info()` result into `CardInfo`: `signer` becomes a
21
+ * plain `Uint8Array` (`scValToNative` hands back a node `Buffer`, which is one
22
+ * — copying it keeps node's type out of the published API), `allow_count` a
23
+ * `number`, and
24
+ * `state` is range-checked before it is narrowed. Every other field is already
25
+ * the `bigint` the contract declared, and is passed through untouched.
26
+ *
27
+ * @throws if `state` is not one of the contract's three variants.
28
+ * @throws if `label` is missing (a card deployed from a pre-label factory).
29
+ */
30
+ export function normalizeInfo(raw) {
31
+ const state = Number(raw.state);
32
+ if (state !== 0 && state !== 1 && state !== 2) {
33
+ throw new Error(`Unknown card state: ${raw.state}`);
34
+ }
35
+ if (typeof raw.label !== "string") {
36
+ throw new Error("card info has no label (card deployed from a pre-label factory?)");
37
+ }
38
+ return {
39
+ owner: raw.owner,
40
+ signer: new Uint8Array(raw.signer),
41
+ token: raw.token,
42
+ label: raw.label,
43
+ policy: raw.policy,
44
+ state: state,
45
+ period: raw.period,
46
+ remaining: raw.remaining,
47
+ balance: raw.balance,
48
+ allow_count: Number(raw.allow_count),
49
+ };
50
+ }
51
+ /**
52
+ * Normalizes a raw `merchants()` result into strkeys. `scValToNative` already
53
+ * renders an `ScAddress` as a strkey, so the guard is a no-op today; it keeps
54
+ * the reader honest if a future SDK hands back the raw XDR instead.
55
+ */
56
+ export function normalizeMerchants(list) {
57
+ return list.map((a) => typeof a === "string" ? a : Address.fromScAddress(a).toString());
58
+ }
59
+ /** Reads the card's `info()` view via simulation (no signing, no fees). */
60
+ export async function readCardInfo(rpcUrl, networkPassphrase, card) {
61
+ return normalizeInfo(await simulateView(rpcUrl, networkPassphrase, card, "info"));
62
+ }
63
+ /** Reads the card's `merchants()` view via simulation. */
64
+ export async function readMerchants(rpcUrl, networkPassphrase, card) {
65
+ return normalizeMerchants(await simulateView(rpcUrl, networkPassphrase, card, "merchants"));
66
+ }
67
+ //# sourceMappingURL=card.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"card.js","sourceRoot":"","sources":["../src/card.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,aAAa,EAAO,MAAM,sBAAsB,CAAC;AAsC7E;;;;;GAKG;AACH,KAAK,UAAU,YAAY,CACzB,MAAc,EACd,iBAAyB,EACzB,IAAY,EACZ,MAAc;IAEd,MAAM,EAAE,GAAG,MAAM,QAAQ,CAAC,oBAAoB,CAAC,KAAK,CAAI;QACtD,UAAU,EAAE,IAAI;QAChB,MAAM;QACN,IAAI,EAAE,EAAE;QACR,iBAAiB;QACjB,MAAM;QACN,cAAc,EAAE,CAAC,CAAY,EAAE,EAAE,CAAC,aAAa,CAAC,CAAC,CAAM;KACxD,CAAC,CAAC;IACH,OAAO,EAAE,CAAC,MAAM,CAAC;AACnB,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,UAAU,aAAa,CAAC,GAAgB;IAC5C,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;IAChC,IAAI,KAAK,KAAK,CAAC,IAAI,KAAK,KAAK,CAAC,IAAI,KAAK,KAAK,CAAC,EAAE,CAAC;QAC9C,MAAM,IAAI,KAAK,CAAC,uBAAuB,GAAG,CAAC,KAAK,EAAE,CAAC,CAAC;IACtD,CAAC;IACD,IAAI,OAAO,GAAG,CAAC,KAAK,KAAK,QAAQ,EAAE,CAAC;QAClC,MAAM,IAAI,KAAK,CAAC,kEAAkE,CAAC,CAAC;IACtF,CAAC;IACD,OAAO;QACL,KAAK,EAAE,GAAG,CAAC,KAAK;QAChB,MAAM,EAAE,IAAI,UAAU,CAAC,GAAG,CAAC,MAAM,CAAC;QAClC,KAAK,EAAE,GAAG,CAAC,KAAK;QAChB,KAAK,EAAE,GAAG,CAAC,KAAK;QAChB,MAAM,EAAE,GAAG,CAAC,MAAM;QAClB,KAAK,EAAE,KAAkB;QACzB,MAAM,EAAE,GAAG,CAAC,MAAM;QAClB,SAAS,EAAE,GAAG,CAAC,SAAS;QACxB,OAAO,EAAE,GAAG,CAAC,OAAO;QACpB,WAAW,EAAE,MAAM,CAAC,GAAG,CAAC,WAAW,CAAC;KACrC,CAAC;AACJ,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,kBAAkB,CAAC,IAAwB;IACzD,OAAO,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CACpB,OAAO,CAAC,KAAK,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,aAAa,CAAC,CAAkB,CAAC,CAAC,QAAQ,EAAE,CACjF,CAAC;AACJ,CAAC;AAED,2EAA2E;AAC3E,MAAM,CAAC,KAAK,UAAU,YAAY,CAChC,MAAc,EACd,iBAAyB,EACzB,IAAY;IAEZ,OAAO,aAAa,CAAC,MAAM,YAAY,CAAc,MAAM,EAAE,iBAAiB,EAAE,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC;AACjG,CAAC;AAED,0DAA0D;AAC1D,MAAM,CAAC,KAAK,UAAU,aAAa,CACjC,MAAc,EACd,iBAAyB,EACzB,IAAY;IAEZ,OAAO,kBAAkB,CAAC,MAAM,YAAY,CAAY,MAAM,EAAE,iBAAiB,EAAE,IAAI,EAAE,WAAW,CAAC,CAAC,CAAC;AACzG,CAAC"}
@@ -0,0 +1,77 @@
1
+ import type { Keypair } from "@stellar/stellar-sdk";
2
+ import { x402Client } from "@x402/fetch";
3
+ import { CardPolicyDenied, PaymentError } from "./denial.js";
4
+ import { type StellarNetwork } from "./scheme.js";
5
+ export interface MooringClientOptions {
6
+ card: string;
7
+ agent: Keypair;
8
+ network: StellarNetwork;
9
+ rpcUrl?: string;
10
+ /** Run the local policy pre-check before signing (default true). */
11
+ precheck?: boolean;
12
+ /** Base URL of the Mooring app used in the allow link on a denial (default `DEFAULT_APP_URL`). */
13
+ appUrl?: string;
14
+ /** Called whenever the card's policy denies a payment, at any stage. */
15
+ onDenial?: (denial: CardPolicyDenied) => void;
16
+ /** Called whenever a payment fails for a reason that is *not* the card's policy. */
17
+ onPaymentError?: (error: PaymentError) => void;
18
+ }
19
+ export interface Settlement {
20
+ success: boolean;
21
+ transaction: string;
22
+ network: string;
23
+ payer?: string;
24
+ errorReason?: string;
25
+ }
26
+ /** Everything `createMooringFetch` can reject with. */
27
+ type PaymentFailure = CardPolicyDenied | PaymentError;
28
+ /**
29
+ * Internal: the last failure recorded by the hooks, so the fetch wrapper can
30
+ * rethrow it typed. `@x402/fetch` turns an aborted payment into a generic
31
+ * `Error` and returns verify/settle failures as a plain 402 response, so the
32
+ * hooks are the only place where the reason survives.
33
+ */
34
+ declare class FailureBox {
35
+ last?: PaymentFailure;
36
+ set(failure: PaymentFailure, opts: MooringClientOptions): void;
37
+ }
38
+ /**
39
+ * Builds an x402 client that pays from a Mooring card.
40
+ *
41
+ * The card enforces the budget, allowlist and expiry on-chain; this client
42
+ * mirrors that policy locally so an over-limit payment is refused before the
43
+ * agent key signs anything, and classifies every other outcome into
44
+ * `CardPolicyDenied` (the card's policy refused) or `PaymentError` (anything
45
+ * else).
46
+ *
47
+ * **One instance is single-flight.** x402's hook contexts carry no request
48
+ * identity, so the failure recorded by one payment cannot be told apart from
49
+ * another's: do not run concurrent payments through the same client. Build one
50
+ * per payment, or use `createMooringFetch`, which does that for you.
51
+ *
52
+ * Used directly, the returned `x402Client` still throws x402's own generic
53
+ * errors and hands back bare 402 responses: only `onDenial` / `onPaymentError`
54
+ * surface the typed reason. `createMooringFetch` is what turns them into
55
+ * thrown `CardPolicyDenied` / `PaymentError`.
56
+ */
57
+ export declare function createMooringClient(opts: MooringClientOptions, box?: FailureBox, requestUrl?: string): x402Client;
58
+ /**
59
+ * A `fetch` that pays x402-protected requests from the card, and rejects with
60
+ * `CardPolicyDenied` (the card's policy refused) or `PaymentError` (anything
61
+ * else) — never a bare 402 — whenever the payment does not deliver the
62
+ * resource. Failed payments are never retried.
63
+ *
64
+ * Each call builds its own client and failure scope — `x402Client.fromConfig`
65
+ * does no I/O — so concurrent requests through the same returned `fetch` cannot
66
+ * observe each other's failures.
67
+ */
68
+ export declare function createMooringFetch(opts: MooringClientOptions & {
69
+ fetch?: typeof globalThis.fetch;
70
+ }): typeof globalThis.fetch;
71
+ /**
72
+ * Decodes the PAYMENT-RESPONSE header of a paid response. Returns `null` when
73
+ * the header is absent or undecodable — a settlement we cannot read is not a
74
+ * failed payment, and must never turn a 200 into an error.
75
+ */
76
+ export declare function getSettlement(res: Response): Settlement | null;
77
+ export {};
package/dist/client.js ADDED
@@ -0,0 +1,272 @@
1
+ import { decodePaymentResponseHeader, wrapFetchWithPayment, x402Client } from "@x402/fetch";
2
+ import { getNetworkPassphrase, getRpcUrl } from "@x402/stellar";
3
+ import { readCardInfo, readMerchants } from "./card.js";
4
+ import { CARD_ERROR_CODES, CardPolicyDenied, DEFAULT_APP_URL, PaymentError, cardDenialFromEvents, classifyContractError, isAbsoluteHttpUrl, stripQuery, } from "./denial.js";
5
+ import { precheck } from "./precheck.js";
6
+ import { reconcileSettlement } from "./reconcile.js";
7
+ import { CardExactStellarScheme } from "./scheme.js";
8
+ /**
9
+ * Internal: the last failure recorded by the hooks, so the fetch wrapper can
10
+ * rethrow it typed. `@x402/fetch` turns an aborted payment into a generic
11
+ * `Error` and returns verify/settle failures as a plain 402 response, so the
12
+ * hooks are the only place where the reason survives.
13
+ */
14
+ class FailureBox {
15
+ last;
16
+ set(failure, opts) {
17
+ this.last = failure;
18
+ if (failure instanceof CardPolicyDenied)
19
+ opts.onDenial?.(failure);
20
+ else
21
+ opts.onPaymentError?.(failure);
22
+ }
23
+ }
24
+ /**
25
+ * Builds an x402 client that pays from a Mooring card.
26
+ *
27
+ * The card enforces the budget, allowlist and expiry on-chain; this client
28
+ * mirrors that policy locally so an over-limit payment is refused before the
29
+ * agent key signs anything, and classifies every other outcome into
30
+ * `CardPolicyDenied` (the card's policy refused) or `PaymentError` (anything
31
+ * else).
32
+ *
33
+ * **One instance is single-flight.** x402's hook contexts carry no request
34
+ * identity, so the failure recorded by one payment cannot be told apart from
35
+ * another's: do not run concurrent payments through the same client. Build one
36
+ * per payment, or use `createMooringFetch`, which does that for you.
37
+ *
38
+ * Used directly, the returned `x402Client` still throws x402's own generic
39
+ * errors and hands back bare 402 responses: only `onDenial` / `onPaymentError`
40
+ * surface the typed reason. `createMooringFetch` is what turns them into
41
+ * thrown `CardPolicyDenied` / `PaymentError`.
42
+ */
43
+ export function createMooringClient(opts, box = new FailureBox(), requestUrl) {
44
+ const rpcUrl = getRpcUrl(opts.network, opts.rpcUrl ? { url: opts.rpcUrl } : undefined);
45
+ const passphrase = getNetworkPassphrase(opts.network);
46
+ const scheme = new CardExactStellarScheme({
47
+ card: opts.card,
48
+ agent: opts.agent,
49
+ network: opts.network,
50
+ rpcUrl: opts.rpcUrl,
51
+ appUrl: opts.appUrl,
52
+ requestUrl,
53
+ // A failure raised inside the scheme (the card refusing the enforcing
54
+ // simulation, or the CAP-71 credential guard) travels out as a thrown
55
+ // error, but it must reach the hooks and the box like every other stage.
56
+ onDenial: (denial) => box.set(denial, opts),
57
+ onPaymentError: (error) => box.set(error, opts),
58
+ });
59
+ /** What a denial carries so the owner can allow the merchant: its terms, the card and the app. */
60
+ const denialContext = (req) => ({
61
+ merchant: {
62
+ payTo: req.payTo,
63
+ amount: req.amount,
64
+ network: req.network,
65
+ ...(requestUrl ? { url: requestUrl } : {}),
66
+ },
67
+ card: opts.card,
68
+ appUrl: opts.appUrl ?? DEFAULT_APP_URL,
69
+ });
70
+ const evaluate = async (req) => {
71
+ const [info, merchants] = await Promise.all([
72
+ readCardInfo(rpcUrl, passphrase, opts.card),
73
+ readMerchants(rpcUrl, passphrase, opts.card),
74
+ ]);
75
+ return precheck(info, merchants, { payTo: req.payTo, amount: req.amount, asset: req.asset }, Math.floor(Date.now() / 1000));
76
+ };
77
+ /**
78
+ * Classifies a refusal that happened *before* anything was submitted.
79
+ *
80
+ * The facilitator does not say whose rule it applied, so the card is asked
81
+ * instead: if the card's own state explains the refusal it is a policy
82
+ * denial, and `simulation_failed` is policy-shaped too (a card denial is a
83
+ * simulation failure — that is the whole of finding the reason on-chain, and
84
+ * a race can produce one the re-run pre-check no longer sees). Every other
85
+ * reason the facilitator can give — a malformed payload, a fee above its
86
+ * ceiling, an expiration too far out — is not the card's verdict and must
87
+ * not be reported as one.
88
+ */
89
+ const classifyRefusal = async (stage, text, req) => {
90
+ const parsed = classifyContractError(text);
91
+ if (parsed && parsed.code in CARD_ERROR_CODES) {
92
+ return new CardPolicyDenied(parsed.reason, stage, {
93
+ contractError: parsed.code,
94
+ detail: text || undefined,
95
+ ...denialContext(req),
96
+ });
97
+ }
98
+ let reason = null;
99
+ try {
100
+ reason = await evaluate(req);
101
+ }
102
+ catch {
103
+ /* the card could not be read; fall through to the reason string */
104
+ }
105
+ if (reason) {
106
+ return new CardPolicyDenied(reason, stage, { detail: text || undefined, ...denialContext(req) });
107
+ }
108
+ if (text.includes("simulation_failed")) {
109
+ return new CardPolicyDenied("unknown", stage, { detail: text || undefined, ...denialContext(req) });
110
+ }
111
+ return new PaymentError({ kind: "rejected", detail: text || undefined });
112
+ };
113
+ /**
114
+ * Decides what a failed settlement means, by asking the ledger.
115
+ *
116
+ * A settle failure that carries a transaction hash is *not* evidence the
117
+ * payment did not happen: the facilitator returns the same
118
+ * `settle_exact_stellar_transaction_failed` when its poll times out, and
119
+ * inclusion can follow that answer. So the hash is reconciled before the
120
+ * failure is named — a transaction the ledger accepted is never reported as
121
+ * a policy denial, however the server described it.
122
+ */
123
+ const classifySettleFailure = async (settle, req) => {
124
+ const reason = settle.errorReason ?? "settle failed";
125
+ const hash = settle.transaction;
126
+ if (!hash)
127
+ return classifyRefusal("settle", reason, req);
128
+ let outcome;
129
+ try {
130
+ outcome = await reconcileSettlement(rpcUrl, opts.network, hash);
131
+ }
132
+ catch (err) {
133
+ return new PaymentError({
134
+ kind: "unconfirmed",
135
+ transaction: hash,
136
+ detail: `${reason}; reconciliation failed: ${err instanceof Error ? err.message : String(err)}`,
137
+ });
138
+ }
139
+ if (outcome.status === "SUCCESS") {
140
+ return new PaymentError({
141
+ kind: "unconfirmed",
142
+ transaction: hash,
143
+ detail: "settled on-chain but the server reported failure; reconcile",
144
+ });
145
+ }
146
+ if (outcome.status === "NOT_FOUND") {
147
+ return new PaymentError({
148
+ kind: "unconfirmed",
149
+ transaction: hash,
150
+ detail: `${reason}; not observed on-chain within the reconciliation window`,
151
+ });
152
+ }
153
+ const parsed = cardDenialFromEvents(outcome.events, opts.card);
154
+ return new CardPolicyDenied(parsed?.reason ?? "unknown", "settle", {
155
+ contractError: parsed?.code,
156
+ detail: `${reason} tx=${hash}`,
157
+ ...denialContext(req),
158
+ });
159
+ };
160
+ const client = x402Client.fromConfig({
161
+ schemes: [{ network: opts.network, client: scheme }],
162
+ // The card enforces limits on-chain; x402's USD-based client caps would only get in the way.
163
+ spendControls: false,
164
+ });
165
+ client.onBeforePaymentCreation(async ({ selectedRequirements }) => {
166
+ if (opts.precheck === false)
167
+ return;
168
+ let reason;
169
+ try {
170
+ reason = await evaluate(selectedRequirements);
171
+ }
172
+ catch (err) {
173
+ // Fail closed: a card we cannot read is a card we cannot spend from.
174
+ const detail = `pre-check unavailable: ${err instanceof Error ? err.message : String(err)}`;
175
+ box.set(new CardPolicyDenied("unknown", "precheck", { detail, ...denialContext(selectedRequirements) }), opts);
176
+ return { abort: true, reason: detail };
177
+ }
178
+ if (reason) {
179
+ box.set(new CardPolicyDenied(reason, "precheck", denialContext(selectedRequirements)), opts);
180
+ return { abort: true, reason };
181
+ }
182
+ });
183
+ client.onPaymentResponse(async ({ requirements, settleResponse, paymentRequired, error }) => {
184
+ if (settleResponse && !settleResponse.success) {
185
+ box.set(await classifySettleFailure(settleResponse, requirements), opts);
186
+ return;
187
+ }
188
+ if (paymentRequired && !settleResponse) {
189
+ // Verify failed. The facilitator does not say why; ask the card.
190
+ const text = paymentRequired.error ?? error?.message ?? "";
191
+ box.set(await classifyRefusal("verify", text, requirements), opts);
192
+ }
193
+ });
194
+ return client;
195
+ }
196
+ /**
197
+ * A `fetch` that pays x402-protected requests from the card, and rejects with
198
+ * `CardPolicyDenied` (the card's policy refused) or `PaymentError` (anything
199
+ * else) — never a bare 402 — whenever the payment does not deliver the
200
+ * resource. Failed payments are never retried.
201
+ *
202
+ * Each call builds its own client and failure scope — `x402Client.fromConfig`
203
+ * does no I/O — so concurrent requests through the same returned `fetch` cannot
204
+ * observe each other's failures.
205
+ */
206
+ export function createMooringFetch(opts) {
207
+ const transport = opts.fetch ?? globalThis.fetch;
208
+ return (async (input, init) => {
209
+ const box = new FailureBox();
210
+ // Only an absolute http(s) URL can become an allow link; anything else
211
+ // (a relative path, another scheme) is left off the denial entirely.
212
+ const rawUrl = input instanceof Request ? input.url : String(input);
213
+ const requestUrl = isAbsoluteHttpUrl(rawUrl) ? stripQuery(rawUrl) : undefined;
214
+ const wrapped = wrapFetchWithPayment(transport, createMooringClient(opts, box, requestUrl));
215
+ try {
216
+ const res = await wrapped(input, init);
217
+ // Verify and settle failures come back as a non-2xx response, not a
218
+ // throw; the hooks have recorded why, so surface it typed.
219
+ if (box.last)
220
+ throw box.last;
221
+ const settlement = getSettlement(res);
222
+ if (settlement && !settlement.success) {
223
+ // The hooks classify every settlement they see; reaching here means
224
+ // the header said "failed" without the hook firing, so the settlement
225
+ // state is exactly as unknown as the header is unexplained.
226
+ box.set(new PaymentError({
227
+ kind: "unconfirmed",
228
+ transaction: settlement.transaction || undefined,
229
+ detail: settlement.errorReason ?? "settle failed",
230
+ }), opts);
231
+ throw box.last;
232
+ }
233
+ if (res.status === 402) {
234
+ // The payment was made and the resource still withheld, with nothing
235
+ // in the response to say what happened to it. `@x402/express` answers
236
+ // exactly this from its settlement path, *after* the facilitator may
237
+ // have submitted — so this is an unknown settlement, not a denial.
238
+ box.set(new PaymentError({
239
+ kind: "unconfirmed",
240
+ detail: "402 without PAYMENT-REQUIRED or PAYMENT-RESPONSE; settlement state unknown",
241
+ }), opts);
242
+ throw box.last;
243
+ }
244
+ return res;
245
+ }
246
+ catch (err) {
247
+ // A pre-check abort reaches us as a generic Error from @x402/fetch.
248
+ if (err instanceof CardPolicyDenied || err instanceof PaymentError)
249
+ throw err;
250
+ if (box.last)
251
+ throw box.last;
252
+ throw err;
253
+ }
254
+ });
255
+ }
256
+ /**
257
+ * Decodes the PAYMENT-RESPONSE header of a paid response. Returns `null` when
258
+ * the header is absent or undecodable — a settlement we cannot read is not a
259
+ * failed payment, and must never turn a 200 into an error.
260
+ */
261
+ export function getSettlement(res) {
262
+ const header = res.headers.get("PAYMENT-RESPONSE") ?? res.headers.get("X-PAYMENT-RESPONSE");
263
+ if (!header)
264
+ return null;
265
+ try {
266
+ return decodePaymentResponseHeader(header);
267
+ }
268
+ catch {
269
+ return null;
270
+ }
271
+ }
272
+ //# sourceMappingURL=client.js.map