uvd-x402-sdk 2.87.0 → 2.89.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +235 -0
  2. package/dist/adapters/index.d.mts +1 -1
  3. package/dist/adapters/index.d.ts +1 -1
  4. package/dist/adapters/index.js.map +1 -1
  5. package/dist/adapters/index.mjs.map +1 -1
  6. package/dist/backend/index.d.mts +2 -2
  7. package/dist/backend/index.d.ts +2 -2
  8. package/dist/backend/index.js +138 -0
  9. package/dist/backend/index.js.map +1 -1
  10. package/dist/backend/index.mjs +137 -1
  11. package/dist/backend/index.mjs.map +1 -1
  12. package/dist/erc8128/index.js.map +1 -1
  13. package/dist/erc8128/index.mjs.map +1 -1
  14. package/dist/{index-D8buGZWY.d.mts → index-13ZUDF7c.d.mts} +32 -2
  15. package/dist/{index-CWZUj5mz.d.ts → index-B5B9EK3x.d.ts} +32 -2
  16. package/dist/{index-DOBhTF-j.d.mts → index-BWN0Haa0.d.mts} +476 -2
  17. package/dist/{index-DOBhTF-j.d.ts → index-BWN0Haa0.d.ts} +476 -2
  18. package/dist/{index-BHfYxpzI.d.mts → index-CAJg-_cr.d.mts} +181 -2
  19. package/dist/{index-D67eNxY8.d.ts → index-Cd1Mpznb.d.ts} +181 -2
  20. package/dist/index.d.mts +4 -4
  21. package/dist/index.d.ts +4 -4
  22. package/dist/index.js +472 -8
  23. package/dist/index.js.map +1 -1
  24. package/dist/index.mjs +464 -9
  25. package/dist/index.mjs.map +1 -1
  26. package/dist/providers/algorand/index.d.mts +1 -1
  27. package/dist/providers/algorand/index.d.ts +1 -1
  28. package/dist/providers/algorand/index.js.map +1 -1
  29. package/dist/providers/algorand/index.mjs.map +1 -1
  30. package/dist/providers/evm/index.d.mts +1 -1
  31. package/dist/providers/evm/index.d.ts +1 -1
  32. package/dist/providers/evm/index.js.map +1 -1
  33. package/dist/providers/evm/index.mjs.map +1 -1
  34. package/dist/providers/near/index.d.mts +1 -1
  35. package/dist/providers/near/index.d.ts +1 -1
  36. package/dist/providers/near/index.js.map +1 -1
  37. package/dist/providers/near/index.mjs.map +1 -1
  38. package/dist/providers/solana/index.d.mts +1 -1
  39. package/dist/providers/solana/index.d.ts +1 -1
  40. package/dist/providers/solana/index.js.map +1 -1
  41. package/dist/providers/solana/index.mjs.map +1 -1
  42. package/dist/providers/stellar/index.d.mts +1 -1
  43. package/dist/providers/stellar/index.d.ts +1 -1
  44. package/dist/providers/stellar/index.js.map +1 -1
  45. package/dist/providers/stellar/index.mjs.map +1 -1
  46. package/dist/providers/sui/index.d.mts +1 -1
  47. package/dist/providers/sui/index.d.ts +1 -1
  48. package/dist/providers/sui/index.js.map +1 -1
  49. package/dist/providers/sui/index.mjs.map +1 -1
  50. package/dist/providers/xrpl/index.d.mts +1 -1
  51. package/dist/providers/xrpl/index.d.ts +1 -1
  52. package/dist/providers/xrpl/index.js.map +1 -1
  53. package/dist/providers/xrpl/index.mjs.map +1 -1
  54. package/dist/react/index.d.mts +3 -3
  55. package/dist/react/index.d.ts +3 -3
  56. package/dist/react/index.js +463 -8
  57. package/dist/react/index.js.map +1 -1
  58. package/dist/react/index.mjs +463 -8
  59. package/dist/react/index.mjs.map +1 -1
  60. package/dist/react/picker/index.d.mts +1 -1
  61. package/dist/react/picker/index.d.ts +1 -1
  62. package/dist/utils/index.d.mts +2 -2
  63. package/dist/utils/index.d.ts +2 -2
  64. package/dist/utils/index.js.map +1 -1
  65. package/dist/utils/index.mjs.map +1 -1
  66. package/dist/{validation-mSLW4tcO.d.mts → validation-B95g0MqC.d.mts} +1 -1
  67. package/dist/{validation-C7p3a6Ep.d.ts → validation-_Tcjm2gu.d.ts} +1 -1
  68. package/package.json +1 -1
  69. package/src/backend/index.ts +260 -0
  70. package/src/client/X402Client.ts +232 -9
  71. package/src/index.ts +28 -0
  72. package/src/policy.ts +804 -0
  73. package/src/types/index.ts +41 -0
@@ -1,4 +1,4 @@
1
- import { i as X402Header, X as X402Version, C as ChainConfig, j as X402PayloadData, k as X402HeaderV1, l as X402PaymentOption, m as X402HeaderV2, T as TokenType, n as PaymentTokenMetadata, o as X402HeaderName, p as PaymentHeaders } from './index-DOBhTF-j.mjs';
1
+ import { j as X402Header, X as X402Version, C as ChainConfig, k as X402PayloadData, l as X402HeaderV1, m as X402PaymentOption, n as X402HeaderV2, T as TokenType, o as PaymentTokenMetadata, p as X402HeaderName, q as PaymentHeaders } from './index-BWN0Haa0.mjs';
2
2
 
3
3
  /**
4
4
  * uvd-x402-sdk - x402 Protocol Utilities
@@ -1,4 +1,4 @@
1
- import { i as X402Header, X as X402Version, C as ChainConfig, j as X402PayloadData, k as X402HeaderV1, l as X402PaymentOption, m as X402HeaderV2, T as TokenType, n as PaymentTokenMetadata, o as X402HeaderName, p as PaymentHeaders } from './index-DOBhTF-j.js';
1
+ import { j as X402Header, X as X402Version, C as ChainConfig, k as X402PayloadData, l as X402HeaderV1, m as X402PaymentOption, n as X402HeaderV2, T as TokenType, o as PaymentTokenMetadata, p as X402HeaderName, q as PaymentHeaders } from './index-BWN0Haa0.js';
2
2
 
3
3
  /**
4
4
  * uvd-x402-sdk - x402 Protocol Utilities
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uvd-x402-sdk",
3
- "version": "2.87.0",
3
+ "version": "2.89.0",
4
4
  "description": "x402 Payment SDK - Gasless crypto payments across 25 blockchains via Ultravioleta facilitator. Supports EVM (including Scroll, SKALE Base, Robinhood Chain), Solana, Fogo, Stellar, NEAR, Algorand, Sui, and XRP Ledger. Features: ERC-8004 Trustless Agents, Escrow/Refunds, multi-stablecoin (USDC, EURC, AUSD, PYUSD, USDT, USDG).",
5
5
  "author": "Ultravioleta DAO <ultravioletadao@gmail.com>",
6
6
  "license": "MIT",
@@ -3685,6 +3685,42 @@ export function supportsRelayedFeedback(network: string): boolean {
3685
3685
  return (RELAYED_FEEDBACK_NETWORKS as readonly string[]).includes(wireNetwork(network));
3686
3686
  }
3687
3687
 
3688
+ /**
3689
+ * Networks where the facilitator serves the rater-authored feedback rail on
3690
+ * **Solana**, through `POST /feedback/solana/prepare` + `/submit`.
3691
+ *
3692
+ * A separate list from {@link RELAYED_FEEDBACK_NETWORKS} on purpose, and the
3693
+ * two must never be merged. That one names the chains where Execution Market
3694
+ * deployed a `FeedbackDelegate` and the facilitator verified it on-chain; it
3695
+ * routes to `/feedback/evm/*`. Adding `solana` to it would send a Solana
3696
+ * rating to the EVM route, which answers 400 — and would claim a delegate that
3697
+ * does not exist and is not missing.
3698
+ *
3699
+ * Solana needs no delegate at all: account 0 of the program's `give_feedback`
3700
+ * instruction is already declared `[signer, writable] client`, so the rater
3701
+ * signs as the author natively and the facilitator only co-signs as fee payer.
3702
+ * Nothing is delegated because nothing has to be.
3703
+ *
3704
+ * Both networks are served by the deployed facilitator (v2.16.0, measured
3705
+ * against `GET /supported`).
3706
+ */
3707
+ export const SOLANA_FEEDBACK_NETWORKS: readonly Erc8004Network[] = [
3708
+ 'solana',
3709
+ 'solana-devnet',
3710
+ ] as const;
3711
+
3712
+ /**
3713
+ * Whether `network` serves the rater-authored feedback rail on Solana.
3714
+ *
3715
+ * A routing hint, never the authority: the facilitator re-resolves the
3716
+ * programs and the RPC provider per request and answers
3717
+ * `400 "<network> is not a Solana network served by this facilitator"` for
3718
+ * anything else.
3719
+ */
3720
+ export function supportsSolanaFeedback(network: string): boolean {
3721
+ return (SOLANA_FEEDBACK_NETWORKS as readonly string[]).includes(wireNetwork(network));
3722
+ }
3723
+
3688
3724
  /**
3689
3725
  * An EIP-7702 authorization, as a wallet produces it.
3690
3726
  *
@@ -3821,6 +3857,84 @@ export interface SubmitRelayFeedbackRequest {
3821
3857
  authorization?: RelayAuthorizationParams;
3822
3858
  }
3823
3859
 
3860
+ /**
3861
+ * Request body for `POST /feedback/solana/prepare`.
3862
+ *
3863
+ * `rater` is REQUIRED here and is a base58 Solana pubkey — an `0x` address is
3864
+ * refused (`400 "rater must be a base58 Solana pubkey on this network"`).
3865
+ * `agentId` is the agent's asset pubkey, also base58.
3866
+ */
3867
+ export interface PrepareSolanaFeedbackRequest {
3868
+ x402Version: 1 | 2;
3869
+ /** One of {@link SOLANA_FEEDBACK_NETWORKS} */
3870
+ network: Erc8004Network;
3871
+ /**
3872
+ * The rating, plus who signs it.
3873
+ *
3874
+ * **Set `score`.** It is optional on the wire and the ATOM Engine ignores an
3875
+ * unscored feedback entirely: the record lands on the agent, the transaction
3876
+ * succeeds, and the program reports `had_impact=false` — reputation stays at
3877
+ * zero however much unscored feedback accumulates, and it is not retroactive.
3878
+ * Omit it only when a deliberately non-scoring record is what you want.
3879
+ */
3880
+ feedback: FeedbackParams & { rater: string };
3881
+ }
3882
+
3883
+ /**
3884
+ * Response from `POST /feedback/solana/prepare`.
3885
+ *
3886
+ * Carries an UNSIGNED transaction whose `client` account is the rater. Sign it
3887
+ * with the rater's key and hand it back to {@link Erc8004Client.submitSolanaFeedback}.
3888
+ */
3889
+ export interface PrepareSolanaFeedbackResponse {
3890
+ success: boolean;
3891
+ /**
3892
+ * base64 of the bincode-serialised unsigned **legacy** transaction — read it
3893
+ * with `Transaction.from()`, not `VersionedTransaction`.
3894
+ *
3895
+ * It expects TWO signatures: the rater's, which you add, and the fee
3896
+ * payer's, which `/feedback/solana/submit` adds. Do not re-encode the
3897
+ * message or rebuild the instruction — the facilitator rebuilds it from the
3898
+ * declared parameters and refuses to co-sign anything that is not
3899
+ * byte-for-byte what it offered.
3900
+ */
3901
+ transaction?: string;
3902
+ /** Who must sign as `client`, i.e. who the chain will record as the author */
3903
+ rater?: string;
3904
+ /** Who pays the fee. Still the facilitator — that is the point */
3905
+ feePayer?: string;
3906
+ /** The blockhash baked into the message. Submit before it expires */
3907
+ blockhash?: string;
3908
+ /**
3909
+ * Last block height at which this transaction is still valid.
3910
+ *
3911
+ * Past it the network drops the transaction; nothing is written and nothing
3912
+ * is charged. Prepare again rather than resubmitting.
3913
+ */
3914
+ lastValidBlockHeight?: number;
3915
+ error?: string;
3916
+ network: Erc8004Network;
3917
+ }
3918
+
3919
+ /**
3920
+ * Request body for `POST /feedback/solana/submit`.
3921
+ *
3922
+ * The feedback parameters are not redundant with `prepare`: the facilitator
3923
+ * re-derives the message from them plus the blockhash carried by
3924
+ * {@link transaction}, and refuses to co-sign anything that does not match
3925
+ * byte for byte. Signing arbitrary blobs would turn the fee-payer keypair into
3926
+ * a public signing oracle — one `system_program::transfer` would empty the
3927
+ * wallet with the facilitator's signature on it.
3928
+ */
3929
+ export interface SubmitSolanaFeedbackRequest {
3930
+ x402Version: 1 | 2;
3931
+ network: Erc8004Network;
3932
+ /** Exactly what was sent to `prepare`, `rater` included */
3933
+ feedback: FeedbackParams & { rater: string };
3934
+ /** base64 of the rater-signed transaction */
3935
+ transaction: string;
3936
+ }
3937
+
3824
3938
  /**
3825
3939
  * Request body for `POST /feedback/response/evm/prepare`.
3826
3940
  *
@@ -4876,6 +4990,152 @@ export class Erc8004Client {
4876
4990
  }
4877
4991
  }
4878
4992
 
4993
+ /**
4994
+ * Ask the facilitator for the Solana transaction the rater must sign.
4995
+ *
4996
+ * Step 1 of the rater-authored rail on Solana. Writes nothing on-chain and
4997
+ * costs nothing: it resolves the programs and the agent asset, reads the
4998
+ * registry collection and a finalized blockhash, and hands back an UNSIGNED
4999
+ * transaction whose `client` account is the rater.
5000
+ *
5001
+ * Why this exists, and why it is not the EVM rail: the program's
5002
+ * `give_feedback` instruction declares account 0 as
5003
+ * `[signer, writable] client (feedback author / fee payer)`, and plain
5004
+ * `POST /feedback` puts the FACILITATOR's keypair there — so the chain
5005
+ * records the facilitator as the author of the rating. Solana carries several
5006
+ * signers per transaction natively, so the rater signs as `client` while the
5007
+ * facilitator stays the fee payer. **No delegation and no program change**,
5008
+ * which is why {@link SOLANA_FEEDBACK_NETWORKS} is its own list and `solana`
5009
+ * must never appear in {@link RELAYED_FEEDBACK_NETWORKS} — that one routes to
5010
+ * `/feedback/evm/*`, which answers 400 here.
5011
+ *
5012
+ * What to do with the answer:
5013
+ * 1. Deserialise `transaction` — base64 of the bincode-serialised **legacy**
5014
+ * transaction, which is `@solana/web3.js`'s `Transaction.from()`, not
5015
+ * `VersionedTransaction` — and add the RATER's ed25519 signature. Sign the
5016
+ * message as it came: re-encoding it changes bytes the facilitator will
5017
+ * compare. The fee payer's slot stays empty; `submit` fills it.
5018
+ * 2. Hand it back to {@link Erc8004Client.submitSolanaFeedback} with the SAME
5019
+ * feedback parameters, before `lastValidBlockHeight` passes.
5020
+ *
5021
+ * @param request - Network, rater pubkey and feedback parameters
5022
+ * @returns The unsigned transaction, the fee payer and the blockhash window
5023
+ *
5024
+ * @example
5025
+ * ```ts
5026
+ * import { Transaction } from '@solana/web3.js';
5027
+ *
5028
+ * const prep = await erc8004.prepareSolanaFeedback({
5029
+ * x402Version: 1,
5030
+ * network: 'solana',
5031
+ * feedback: {
5032
+ * agentId: '7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgHkv',
5033
+ * rater: raterPubkey, // base58: who the chain records as the author
5034
+ * value: 87,
5035
+ * score: 95, // without it the rating counts for nothing
5036
+ * tag1: 'quality',
5037
+ * },
5038
+ * });
5039
+ *
5040
+ * const tx = Transaction.from(Buffer.from(prep.transaction!, 'base64'));
5041
+ * tx.partialSign(raterKeypair);
5042
+ * ```
5043
+ */
5044
+ async prepareSolanaFeedback(
5045
+ request: PrepareSolanaFeedbackRequest,
5046
+ ): Promise<PrepareSolanaFeedbackResponse> {
5047
+ const url = `${this.baseUrl}/feedback/solana/prepare`;
5048
+
5049
+ const controller = new AbortController();
5050
+ const timeoutId = setTimeout(() => controller.abort(), this.timeout);
5051
+
5052
+ try {
5053
+ const response = await fetch(url, {
5054
+ method: 'POST',
5055
+ headers: {
5056
+ 'Content-Type': 'application/json',
5057
+ 'Accept': 'application/json',
5058
+ },
5059
+ body: JSON.stringify({ ...request, network: wireNetwork(request.network) }),
5060
+ signal: controller.signal,
5061
+ });
5062
+
5063
+ clearTimeout(timeoutId);
5064
+
5065
+ if (!response.ok) {
5066
+ const errorText = await response.text();
5067
+ return {
5068
+ success: false,
5069
+ error: `Facilitator error: ${response.status} - ${errorText}`,
5070
+ network: request.network,
5071
+ };
5072
+ }
5073
+
5074
+ return await response.json();
5075
+ } catch (error) {
5076
+ clearTimeout(timeoutId);
5077
+ return {
5078
+ success: false,
5079
+ error: error instanceof Error ? error.message : 'Unknown error',
5080
+ network: request.network,
5081
+ };
5082
+ }
5083
+ }
5084
+
5085
+ /**
5086
+ * Co-sign and send a rater-signed Solana feedback transaction.
5087
+ *
5088
+ * Step 2 of the rater-authored rail on Solana. What lands on-chain has the
5089
+ * RATER as `client`, so the agent's feedback lists the rater rather than the
5090
+ * facilitator — and only the rater can revoke it.
5091
+ *
5092
+ * Pass back the same feedback parameters {@link Erc8004Client.prepareSolanaFeedback}
5093
+ * was given. They are not redundant: the facilitator re-derives the message
5094
+ * from them plus the blockhash inside the submitted transaction, and refuses
5095
+ * to co-sign anything that is not byte-for-byte what it built
5096
+ * (`400 "submitted transaction does not match the one this facilitator built"`).
5097
+ * The rater's signature is verified BEFORE the facilitator adds its own, so a
5098
+ * transaction the network would reject never costs a fee.
5099
+ *
5100
+ * @param request - Feedback parameters plus the rater-signed transaction
5101
+ * @returns Feedback response with the transaction signature
5102
+ */
5103
+ async submitSolanaFeedback(
5104
+ request: SubmitSolanaFeedbackRequest,
5105
+ ): Promise<FeedbackResponse> {
5106
+ const url = `${this.baseUrl}/feedback/solana/submit`;
5107
+
5108
+ try {
5109
+ const { response, error } = await this.writeJson(url, {
5110
+ ...request,
5111
+ network: wireNetwork(request.network),
5112
+ });
5113
+
5114
+ if (error) {
5115
+ return {
5116
+ success: false,
5117
+ error: error.error,
5118
+ network: request.network,
5119
+ ...failureFields(error),
5120
+ };
5121
+ }
5122
+
5123
+ return await response.json();
5124
+ } catch (error) {
5125
+ // Timeout or connection failure: the write may already be on-chain.
5126
+ // Retryable, never replayable -- and on this rail a resubmission needs a
5127
+ // fresh `prepare`, because the blockhash the rater signed over expires.
5128
+ return {
5129
+ success: false,
5130
+ error: error instanceof Error ? error.message : 'Unknown error',
5131
+ network: request.network,
5132
+ retryable: true,
5133
+ safeToReplay: false,
5134
+ retryAfterSeconds: DEFAULT_RETRY_AFTER_SECONDS,
5135
+ };
5136
+ }
5137
+ }
5138
+
4879
5139
  /**
4880
5140
  * Revoke previously submitted feedback
4881
5141
  *
@@ -25,6 +25,14 @@ import type {
25
25
  WalletAdapter,
26
26
  } from '../types';
27
27
  import { X402Error, DEFAULT_CONFIG } from '../types';
28
+ import {
29
+ PurchasePolicy,
30
+ PolicyRefusedError,
31
+ decideOnChallenge,
32
+ noReadableOffer,
33
+ canonicalRecipient,
34
+ } from '../policy';
35
+ import type { PolicyApproval, ReadChallenge } from '../policy';
28
36
  import {
29
37
  SUPPORTED_CHAINS,
30
38
  getChainByName,
@@ -40,6 +48,52 @@ import {
40
48
  buildTokenMetadata,
41
49
  } from '../utils';
42
50
 
51
+ /**
52
+ * The x402 scheme vocabulary, shared by all three implementations.
53
+ *
54
+ * The same five the Rust facilitator's closed `Scheme` enum holds and the same
55
+ * five the Python SDK keys `KNOWN_SCHEMES` on. A scheme outside this set is one
56
+ * no implementation in the stack can name, so an entry carrying one is unreadable
57
+ * and is counted by the name the seller used.
58
+ *
59
+ * Kept as a set of known NAMES rather than of payable ones because the two are
60
+ * different questions, and conflating them is what rule 7 is about: naming what a
61
+ * seller offered is how a caller learns to go find a facilitator that implements
62
+ * it. See {@link CLIENT_PAYABLE_SCHEMES} for the other question.
63
+ */
64
+ export const KNOWN_SCHEMES: ReadonlySet<string> = new Set([
65
+ 'exact',
66
+ 'upto',
67
+ 'escrow',
68
+ 'commerce',
69
+ 'fhe-transfer',
70
+ ]);
71
+
72
+ /**
73
+ * Of those, the ones the BUYER path in this client can actually sign.
74
+ *
75
+ * One entry, and that is the point: the payload builder stamps
76
+ * `scheme: 'exact'` into everything it produces, so `exact` is the only thing
77
+ * this client can honestly present. `escrow` and `commerce` are implemented on
78
+ * the SELLER side (`src/backend`) and in the pre-auth builder, and `upto` and
79
+ * `fhe-transfer` are not implemented here at all.
80
+ *
81
+ * # Why this is a SECOND set and not a narrower first one
82
+ *
83
+ * `parse402` used to accept any entry carrying an amount, a payee and a network,
84
+ * so a well-formed offer asking for a scheme this build cannot present was read
85
+ * as payable and then **signed as `exact`** -- a payment offered to the seller
86
+ * under a scheme it never asked for. Recognising a scheme is not being able to
87
+ * pay it. Collapsing these two sets into one re-opens exactly that hole: widen
88
+ * this one to the five known names and a well-formed `escrow` offer gets signed
89
+ * as `exact` again.
90
+ *
91
+ * Both cases end the same way for the caller -- the entry is excluded and counted
92
+ * by its scheme name -- and `no-readable-offer` says "no offer in this challenge
93
+ * is one this build **can pay**", which is the frame both of them sit in.
94
+ */
95
+ export const CLIENT_PAYABLE_SCHEMES: ReadonlySet<string> = new Set(['exact']);
96
+
43
97
  /**
44
98
  * X402Client - Main SDK client for x402 payments
45
99
  *
@@ -82,6 +136,17 @@ export class X402Client {
82
136
  // Event emitter
83
137
  private eventHandlers: Map<X402Event, Set<X402EventHandler<X402Event>>> = new Map();
84
138
 
139
+ /**
140
+ * What this buyer is allowed to sign, evaluated against the offer in hand
141
+ * before anything is signed.
142
+ *
143
+ * `permissive()` when the caller supplied none, and that asymmetry is
144
+ * deliberate: this SDK had no budget before 2.89.0, so switching one on
145
+ * silently would refuse payments consumers are making today, while whoever
146
+ * sits down to WRITE a policy gets the deny-by-default one.
147
+ */
148
+ private readonly purchasePolicy: PurchasePolicy;
149
+
85
150
  constructor(config: X402ClientConfig = {}) {
86
151
  this.config = {
87
152
  ...DEFAULT_CONFIG,
@@ -106,9 +171,21 @@ export class X402Client {
106
171
  }
107
172
  }
108
173
 
174
+ this.purchasePolicy = config.policy ?? PurchasePolicy.permissive();
175
+
109
176
  this.log('X402Client initialized', { config: this.config });
110
177
  }
111
178
 
179
+ /**
180
+ * The policy in force, for a caller that wants to record a settled payment
181
+ * against it with {@link PurchasePolicy.recordSpend}.
182
+ *
183
+ * Nothing on the payment path ever widens it.
184
+ */
185
+ get policy(): PurchasePolicy {
186
+ return this.purchasePolicy;
187
+ }
188
+
112
189
  // ============================================================================
113
190
  // PUBLIC API - Wallet Connection
114
191
  // ============================================================================
@@ -375,8 +452,19 @@ export class X402Client {
375
452
  }
376
453
 
377
454
  const tokenType: TokenType = options.tokenType || 'usdc';
378
- const { version, offers } = this.parse402(body, tokenType);
455
+ const challenge = this.parse402(body, tokenType);
456
+ const { version, offers } = challenge;
379
457
  if (offers.length === 0) {
458
+ // Step 1 of the policy contract, and two different facts that used to be
459
+ // one error. A seller that sent offers none of which this build can read is
460
+ // asking for a scheme we do not implement; saying so names what it wanted,
461
+ // where "no usable payment options" sends the caller hunting a bug in its
462
+ // own code. A seller that sent none at all is the other fact, and keeps the
463
+ // error it always had.
464
+ const unreadableCount = challenge.unreadableCount ?? challenge.unreadable.length;
465
+ if (unreadableCount > 0) {
466
+ throw new PolicyRefusedError(noReadableOffer(challenge.unreadable));
467
+ }
380
468
  throw new X402Error(
381
469
  '402 response offered no usable payment options',
382
470
  'NO_ACCEPTABLE_PAYMENT'
@@ -412,6 +500,33 @@ export class X402Client {
412
500
  }
413
501
  }
414
502
 
503
+ // The policy, evaluated against THIS offer, before anything is signed. An
504
+ // offer that diverges from a listing but sits inside an authorised policy
505
+ // proceeds -- stopping to ask would turn every ordinary reprice into a halt --
506
+ // and one that does not is refused with a concrete cause. The policy is never
507
+ // widened to fit the offer, and there is no confirmation hook in this path.
508
+ //
509
+ // The whole challenge goes in, never just the offer: `validUntil` lives in
510
+ // the challenge's `extensions`, and a call that takes the parts is how it gets
511
+ // dropped.
512
+ const decision = decideOnChallenge(this.purchasePolicy, challenge, chosen, {
513
+ now: Math.floor(Date.now() / 1000),
514
+ quote: options.advertised,
515
+ });
516
+ if (!decision.ok) {
517
+ this.log('policy refused this offer', { cause: decision.refusal.code });
518
+ this.emit('paymentFailed', {
519
+ error: decision.refusal.message,
520
+ code: 'POLICY_REFUSED',
521
+ });
522
+ throw new PolicyRefusedError(decision.refusal);
523
+ }
524
+ const approval: PolicyApproval = decision.approval;
525
+ this.log('policy approved this offer', {
526
+ versusQuote: approval.versusQuote.code,
527
+ amount: String(approval.amount),
528
+ });
529
+
415
530
  if (!chosen.chainName) {
416
531
  throw new X402Error(
417
532
  `402 asked for payment on unknown network '${chosen.network}'`,
@@ -419,6 +534,38 @@ export class X402Client {
419
534
  );
420
535
  }
421
536
 
537
+ // The policy just approved the offer's OWN asset, but `createPayment` signs
538
+ // for whatever token `tokenType` resolves to on this chain -- and reads the
539
+ // price at that token's decimals. With USDC on both sides they coincide,
540
+ // which is why this never showed. They do not have to: a seller pricing in a
541
+ // token that is not the configured one would get an approval for asset A and
542
+ // a signature for asset B, at B's decimals, and the caller would believe a
543
+ // budget it never actually enforced.
544
+ //
545
+ // Refused rather than silently re-pointed: which token to pay with is the
546
+ // caller's decision, and guessing it from the seller's 402 is how a wallet
547
+ // ends up signing for a token nobody chose.
548
+ const configuredToken = getTokenConfig(chosen.chainName, tokenType);
549
+ if (
550
+ chosen.asset &&
551
+ configuredToken &&
552
+ canonicalRecipient(configuredToken.address) !== canonicalRecipient(chosen.asset)
553
+ ) {
554
+ throw new X402Error(
555
+ `402 wants ${chosen.asset} on ${chosen.chainName}, but this client signs ` +
556
+ `${configuredToken.address} (tokenType '${tokenType}'). Pass the tokenType ` +
557
+ 'that matches the offer; signing the configured token would pay a ' +
558
+ 'different asset than the one that was approved.',
559
+ 'NO_ACCEPTABLE_PAYMENT',
560
+ {
561
+ offered: chosen.asset,
562
+ wouldSign: configuredToken.address,
563
+ tokenType,
564
+ network: chosen.chainName,
565
+ }
566
+ );
567
+ }
568
+
422
569
  if (chosen.chainName !== this.currentChainName) {
423
570
  await this.switchChain(chosen.chainName);
424
571
  }
@@ -442,20 +589,43 @@ export class X402Client {
442
589
  paidHeaders['PAYMENT-SIGNATURE'] = payment.paymentHeader;
443
590
  }
444
591
 
445
- return doFetch(url, { ...options.init, method, headers: paidHeaders });
592
+ const paid = await doFetch(url, { ...options.init, method, headers: paidHeaders });
593
+
594
+ // Approving did not spend: signing can fail and a settlement can be refused,
595
+ // and a cumulative limit that counted attempts would lock a caller out of
596
+ // money it never spent. A retry that came back as anything but another 402 is
597
+ // the seller accepting the payment, which is the moment a caller records it --
598
+ // by its own hand, in `onPaid`, never automatically here.
599
+ if (paid.status !== 402) {
600
+ options.onPaid?.(approval);
601
+ }
602
+ return paid;
446
603
  }
447
604
 
448
605
  /**
449
- * Normalise a 402 body into its envelope version and its payment offers.
606
+ * Normalise a 402 body into its envelope version, its payment offers, the ones
607
+ * it could not read, and the challenge's `extensions`.
450
608
  *
451
609
  * Reads both spec shapes -- `{x402Version, accepts: [...]}` and the non-spec
452
610
  * one where a lone requirement sits at the top level -- and both dialects of
453
611
  * the price field (`maxAmountRequired` in v1, `amount` in v2).
612
+ *
613
+ * **`accepts` is a list, so one unreadable entry does not sink the list.** A
614
+ * seller offering `exact` alongside a scheme this build does not implement used
615
+ * to be unpayable in the Rust crate, and the buyer never learned there was a
616
+ * perfectly payable offer right there. The readable ones are kept and the
617
+ * others are counted by scheme name, so a refusal can say what the seller
618
+ * offered instead of "could not parse". Discovering the service keeps working
619
+ * even when buying it automatically does not.
620
+ *
621
+ * Returns the WHOLE challenge on purpose: handing `offers` alone downstream is
622
+ * what dropped the seller's `validUntil` on the floor in Rust for a full commit
623
+ * with every unit test green.
454
624
  */
455
625
  private parse402(
456
626
  body: unknown,
457
627
  tokenType: TokenType
458
- ): { version: X402Version; offers: X402PaymentOffer[] } {
628
+ ): ReadChallenge & { version: X402Version } {
459
629
  const doc = (body ?? {}) as Record<string, unknown>;
460
630
  const version: X402Version = Number(doc.x402Version) === 2 ? 2 : 1;
461
631
 
@@ -466,16 +636,64 @@ export class X402Client {
466
636
  : [];
467
637
 
468
638
  const offers: X402PaymentOffer[] = [];
639
+ const unreadable: string[] = [];
640
+ let unreadableCount = 0;
641
+
642
+ /** Count an entry this build cannot pay, by scheme name when it has one. */
643
+ const cannotRead = (accept: Record<string, unknown> | null) => {
644
+ unreadableCount += 1;
645
+ const scheme = accept?.scheme;
646
+ if (typeof scheme === 'string' && scheme) unreadable.push(scheme);
647
+ };
648
+
469
649
  for (const entry of rawAccepts) {
470
- if (!entry || typeof entry !== 'object') continue;
650
+ if (!entry || typeof entry !== 'object') {
651
+ cannotRead(null);
652
+ continue;
653
+ }
471
654
  const accept = entry as Record<string, unknown>;
472
655
 
656
+ // The scheme first, so a well-formed offer in a scheme we cannot pay is
657
+ // counted by the NAME the seller used -- the informative half of rule 7 --
658
+ // instead of being reported for whichever field it also happened to omit.
659
+ //
660
+ // An ABSENT scheme is unreadable, not `exact`. Rust requires the field, so a
661
+ // challenge without it fails to deserialise there, and a buyer that guessed
662
+ // `exact` would sign under a scheme the seller never actually named -- which
663
+ // is the same hole as signing a named one we cannot present, minus the
664
+ // evidence. It is counted (`unreadableCount`) without a name, because it has
665
+ // none to report.
666
+ //
667
+ // Note this is deliberately ASYMMETRIC with the seller side of this SDK,
668
+ // where a missing scheme reads as `exact` ("the default, not an override",
669
+ // `src/backend/index.ts`). A seller is lenient about what it accepts; a
670
+ // buyer is strict about what it signs.
671
+ const declaredScheme = accept.scheme;
672
+ if (
673
+ typeof declaredScheme !== 'string' ||
674
+ !declaredScheme ||
675
+ !KNOWN_SCHEMES.has(declaredScheme) ||
676
+ !CLIENT_PAYABLE_SCHEMES.has(declaredScheme)
677
+ ) {
678
+ cannotRead(accept);
679
+ continue;
680
+ }
681
+
473
682
  const amount = accept.amount ?? accept.maxAmountRequired;
474
683
  const payTo = accept.payTo;
475
684
  const network = accept.network;
476
- if (amount === undefined || amount === null) continue;
477
- if (typeof payTo !== 'string' || !payTo) continue;
478
- if (typeof network !== 'string' || !network) continue;
685
+ if (amount === undefined || amount === null) {
686
+ cannotRead(accept);
687
+ continue;
688
+ }
689
+ if (typeof payTo !== 'string' || !payTo) {
690
+ cannotRead(accept);
691
+ continue;
692
+ }
693
+ if (typeof network !== 'string' || !network) {
694
+ cannotRead(accept);
695
+ continue;
696
+ }
479
697
 
480
698
  const chainName = getChainByName(network) ? network : caip2ToChain(network);
481
699
 
@@ -490,7 +708,12 @@ export class X402Client {
490
708
  });
491
709
  }
492
710
 
493
- return { version, offers };
711
+ const extensions =
712
+ doc.extensions && typeof doc.extensions === 'object' && !Array.isArray(doc.extensions)
713
+ ? (doc.extensions as Record<string, unknown>)
714
+ : undefined;
715
+
716
+ return { version, offers, unreadable, unreadableCount, extensions };
494
717
  }
495
718
 
496
719
  /**
package/src/index.ts CHANGED
@@ -156,6 +156,34 @@ export type { AnchoredEvidence, EvidenceMode, RecipientRole, AnchorOptions } fro
156
156
 
157
157
  export { X402Client } from './client';
158
158
 
159
+ // ============================================================================
160
+ // Buyer policy - what this buyer is allowed to sign, decided before it signs
161
+ // ============================================================================
162
+
163
+ export {
164
+ PurchasePolicy,
165
+ PolicyRefusedError,
166
+ decideOnChallenge,
167
+ noReadableOffer,
168
+ offerValidUntil,
169
+ offerAsset,
170
+ canonicalRecipient,
171
+ assetKey,
172
+ OFFER_VALIDITY_EXTENSION,
173
+ } from './policy';
174
+ export type {
175
+ PolicyAsset,
176
+ PolicyRefusal,
177
+ PolicyRefusalCode,
178
+ PolicyApproval,
179
+ PolicyDecision,
180
+ QuoteComparison,
181
+ AdvertisedQuote,
182
+ EvaluateOptions,
183
+ DecideOptions,
184
+ ReadChallenge,
185
+ } from './policy';
186
+
159
187
  // Chain configuration
160
188
  export {
161
189
  SUPPORTED_CHAINS,