@obbyx/sdk 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,7 +5,13 @@ import {
5
5
  decodePaymentSignatureHeader,
6
6
  encodePaymentSignatureHeader,
7
7
  } from '@x402/core/http';
8
- import type { PaymentPayload, PaymentRequirements, SettleResponse } from '@x402/core/types';
8
+ import type {
9
+ PaymentPayload,
10
+ PaymentPayloadResult,
11
+ PaymentRequired,
12
+ PaymentRequirements,
13
+ SettleResponse,
14
+ } from '@x402/core/types';
9
15
  import { ObbyxError } from '@obbyx/obyte';
10
16
  import { type Logger, moduleLogger } from '@obbyx/obyte/logger';
11
17
  import type { BudgetReservation, SpendingBudget } from './budget.js';
@@ -84,59 +90,13 @@ export async function payingFetch(
84
90
  const required = response.headers.get('PAYMENT-REQUIRED');
85
91
  if (required === null)
86
92
  throw new ObbyxError('invalid_402', '402 without PAYMENT-REQUIRED header');
87
- const offer = decodePaymentRequiredHeader(required);
88
- // Offers in preference order (channel first). A channel that turns out to
89
- // be unusable before anything is signed (not funded, closing, exhausted,
90
- // busy) falls through to the next offer, normally `exact`.
91
- let accepted: PaymentRequirements | undefined;
92
- let result: Awaited<ReturnType<ExactObyteClient['createPaymentPayload']>> | undefined;
93
- let lastChannelError: ObbyxError | undefined;
94
- for (const candidate of chooseOffers(offer.accepts, options)) {
95
- const mechanism =
96
- candidate.scheme === BATCH_SCHEME ? options.mechanisms.batch : options.mechanisms.exact;
97
- if (mechanism === undefined) continue;
98
- log.info(
99
- {
100
- url,
101
- scheme: candidate.scheme,
102
- payTo: candidate.payTo,
103
- asset: candidate.asset,
104
- amount: candidate.amount,
105
- },
106
- 'paying for request',
107
- );
108
- // Reserve the price BEFORE signing: concurrent calls cannot overshoot the budget.
109
- reservation = options.budget?.reserve(candidate.asset, BigInt(candidate.amount));
110
- try {
111
- result = await mechanism.createPaymentPayload(2, candidate);
112
- accepted = candidate;
113
- break;
114
- } catch (err) {
115
- if (reservation) options.budget?.release(reservation); // nothing was signed or sent
116
- reservation = undefined;
117
- if (err instanceof ObbyxError && CHANNEL_UNUSABLE.has(err.code)) {
118
- lastChannelError = err;
119
- log.info(
120
- { reason: err.code, detail: err.message },
121
- 'channel unusable; trying the next offer',
122
- );
123
- continue;
124
- }
125
- throw err;
126
- }
127
- }
128
- if (accepted === undefined || result === undefined) {
129
- // Nothing else to pay with: report why the channel could not (e.g. payment_pending).
130
- throw lastChannelError ?? new ObbyxError('no_acceptable_offer', 'No offer could be paid');
131
- }
132
- const payload: PaymentPayload = {
133
- x402Version: 2,
134
- resource: offer.resource,
135
- accepted,
136
- payload: result.payload,
137
- ...(offer.extensions ? { extensions: withPaymentId(offer.extensions, init.paymentId) } : {}),
138
- };
139
- header = encodePaymentSignatureHeader(payload);
93
+ ({ header, reservation } = await signPayment(
94
+ decodePaymentRequiredHeader(required),
95
+ init.paymentId,
96
+ url,
97
+ options,
98
+ log,
99
+ ));
140
100
  response = await request(header);
141
101
  }
142
102
 
@@ -144,16 +104,7 @@ export async function payingFetch(
144
104
  for (;;) {
145
105
  const settle = readSettle(response);
146
106
  if (response.status !== 402 || settle?.errorReason !== 'settlement_pending') {
147
- const accepted = acceptedOf(header);
148
- if (accepted?.scheme === BATCH_SCHEME) options.mechanisms.batch?.onSettled(accepted, settle);
149
- if (reservation !== undefined && options.budget !== undefined) {
150
- // I7: spend on success; release ONLY on a definite refusal (402: the
151
- // payment was not used). Anything else (5xx, 409, no settlement info)
152
- // is an unknown outcome: the reservation stays counted.
153
- if (settle?.success === true || (response.ok && settle === undefined))
154
- options.budget.spend(reservation);
155
- else if (response.status === 402) options.budget.release(reservation);
156
- }
107
+ recordOutcome(response, settle, header, reservation, options);
157
108
  return { response, ...(settle ? { payment: settle } : {}), paymentHeader: header };
158
109
  }
159
110
  if (Date.now() > deadline) throw new PaymentPendingError(header, settle.transaction);
@@ -169,6 +120,87 @@ export async function payingFetch(
169
120
  }
170
121
  }
171
122
 
123
+ /**
124
+ * Signs a payment for the first usable offer, in preference order (channel
125
+ * first). A channel that turns out to be unusable before anything is signed
126
+ * (not funded, closing, exhausted, busy) falls through to the next offer,
127
+ * normally `exact`. The price is reserved in the budget BEFORE signing, so
128
+ * concurrent calls cannot overshoot it.
129
+ */
130
+ async function signPayment(
131
+ offer: PaymentRequired,
132
+ paymentId: string | undefined,
133
+ url: string,
134
+ options: PayingFetchOptions,
135
+ log: Logger,
136
+ ): Promise<{ header: string; reservation: BudgetReservation | undefined }> {
137
+ let lastChannelError: ObbyxError | undefined;
138
+ for (const candidate of chooseOffers(offer.accepts, options)) {
139
+ const mechanism =
140
+ candidate.scheme === BATCH_SCHEME ? options.mechanisms.batch : options.mechanisms.exact;
141
+ if (mechanism === undefined) continue;
142
+ log.info(
143
+ {
144
+ url,
145
+ scheme: candidate.scheme,
146
+ payTo: candidate.payTo,
147
+ asset: candidate.asset,
148
+ amount: candidate.amount,
149
+ },
150
+ 'paying for request',
151
+ );
152
+ const reservation = options.budget?.reserve(candidate.asset, BigInt(candidate.amount));
153
+ let result: PaymentPayloadResult;
154
+ try {
155
+ result = await mechanism.createPaymentPayload(2, candidate);
156
+ } catch (err) {
157
+ if (reservation) options.budget?.release(reservation); // nothing was signed or sent
158
+ if (err instanceof ObbyxError && CHANNEL_UNUSABLE.has(err.code)) {
159
+ lastChannelError = err;
160
+ log.info(
161
+ { reason: err.code, detail: err.message },
162
+ 'channel unusable; trying the next offer',
163
+ );
164
+ continue;
165
+ }
166
+ throw err;
167
+ }
168
+ const payload: PaymentPayload = {
169
+ x402Version: 2,
170
+ resource: offer.resource,
171
+ accepted: candidate,
172
+ payload: result.payload,
173
+ ...(offer.extensions ? { extensions: withPaymentId(offer.extensions, paymentId) } : {}),
174
+ };
175
+ return { header: encodePaymentSignatureHeader(payload), reservation };
176
+ }
177
+ // Nothing else to pay with: report why the channel could not (e.g. payment_pending).
178
+ throw lastChannelError ?? new ObbyxError('no_acceptable_offer', 'No offer could be paid');
179
+ }
180
+
181
+ /**
182
+ * Applies the final answer: the channel voucher is confirmed or released, and
183
+ * the budget reservation is spent on success and released ONLY on a definite
184
+ * refusal (402: the payment was not used). Anything else (5xx, 409, no
185
+ * settlement info) is an unknown outcome: the reservation stays counted (I7).
186
+ */
187
+ function recordOutcome(
188
+ response: Response,
189
+ settle: SettleResponse | undefined,
190
+ header: string,
191
+ reservation: BudgetReservation | undefined,
192
+ options: PayingFetchOptions,
193
+ ): void {
194
+ const accepted = acceptedOf(header);
195
+ if (accepted?.scheme === BATCH_SCHEME) options.mechanisms.batch?.onSettled(accepted, settle);
196
+ if (reservation === undefined || options.budget === undefined) return;
197
+ if (settle?.success === true || (response.ok && settle === undefined)) {
198
+ options.budget.spend(reservation);
199
+ } else if (response.status === 402) {
200
+ options.budget.release(reservation);
201
+ }
202
+ }
203
+
172
204
  /** An offer from an untrusted seller: integer amount in smallest units, strings elsewhere. */
173
205
  const offerShape = z
174
206
  .object({