@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.
- package/dist/client/paying-fetch.d.ts.map +1 -1
- package/dist/client/paying-fetch.js +68 -61
- package/dist/client/paying-fetch.js.map +1 -1
- package/dist/seller/paywall.d.ts +20 -0
- package/dist/seller/paywall.d.ts.map +1 -1
- package/dist/seller/paywall.js +171 -125
- package/dist/seller/paywall.js.map +1 -1
- package/package.json +3 -3
- package/src/client/paying-fetch.ts +96 -64
- package/src/seller/paywall.ts +214 -174
|
@@ -5,7 +5,13 @@ import {
|
|
|
5
5
|
decodePaymentSignatureHeader,
|
|
6
6
|
encodePaymentSignatureHeader,
|
|
7
7
|
} from '@x402/core/http';
|
|
8
|
-
import type {
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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({
|