@obbyx/sdk 0.0.0-stage → 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 +21 -0
- package/README.md +89 -2
- package/dist/client/budget.d.ts +43 -0
- package/dist/client/budget.d.ts.map +1 -0
- package/dist/client/budget.js +56 -0
- package/dist/client/budget.js.map +1 -0
- package/dist/client/paying-fetch.d.ts +46 -0
- package/dist/client/paying-fetch.d.ts.map +1 -0
- package/dist/client/paying-fetch.js +186 -0
- package/dist/client/paying-fetch.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/seller/binding.d.ts +2 -0
- package/dist/seller/binding.d.ts.map +1 -0
- package/dist/seller/binding.js +3 -0
- package/dist/seller/binding.js.map +1 -0
- package/dist/seller/consumed-proofs.d.ts +72 -0
- package/dist/seller/consumed-proofs.d.ts.map +1 -0
- package/dist/seller/consumed-proofs.js +123 -0
- package/dist/seller/consumed-proofs.js.map +1 -0
- package/dist/seller/payment-identifier.d.ts +73 -0
- package/dist/seller/payment-identifier.d.ts.map +1 -0
- package/dist/seller/payment-identifier.js +75 -0
- package/dist/seller/payment-identifier.js.map +1 -0
- package/dist/seller/paywall.d.ts +99 -0
- package/dist/seller/paywall.d.ts.map +1 -0
- package/dist/seller/paywall.js +372 -0
- package/dist/seller/paywall.js.map +1 -0
- package/dist/seller/quote.d.ts +38 -0
- package/dist/seller/quote.d.ts.map +1 -0
- package/dist/seller/quote.js +39 -0
- package/dist/seller/quote.js.map +1 -0
- package/dist/seller/x402-server.d.ts +57 -0
- package/dist/seller/x402-server.d.ts.map +1 -0
- package/dist/seller/x402-server.js +152 -0
- package/dist/seller/x402-server.js.map +1 -0
- package/package.json +68 -3
- package/src/client/budget.ts +88 -0
- package/src/client/paying-fetch.ts +256 -0
- package/src/index.ts +35 -0
- package/src/seller/binding.ts +8 -0
- package/src/seller/consumed-proofs.ts +217 -0
- package/src/seller/payment-identifier.ts +104 -0
- package/src/seller/paywall.ts +566 -0
- package/src/seller/quote.ts +82 -0
- package/src/seller/x402-server.ts +224 -0
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
|
2
|
+
import {
|
|
3
|
+
type FacilitatorClient,
|
|
4
|
+
decodePaymentSignatureHeader,
|
|
5
|
+
encodePaymentRequiredHeader,
|
|
6
|
+
encodePaymentResponseHeader,
|
|
7
|
+
} from '@x402/core/http';
|
|
8
|
+
import type {
|
|
9
|
+
PaymentPayload,
|
|
10
|
+
PaymentRequired,
|
|
11
|
+
PaymentRequirements,
|
|
12
|
+
SettleResponse,
|
|
13
|
+
} from '@x402/core/types';
|
|
14
|
+
import {
|
|
15
|
+
CHANNEL_BASE_AA,
|
|
16
|
+
getNetwork,
|
|
17
|
+
isAddress,
|
|
18
|
+
isAssetId,
|
|
19
|
+
networkKey,
|
|
20
|
+
parseAmount,
|
|
21
|
+
} from '@obbyx/obyte';
|
|
22
|
+
import { type Logger, moduleLogger, withLogContext } from '@obbyx/obyte/logger';
|
|
23
|
+
import {
|
|
24
|
+
BATCH_SCHEME,
|
|
25
|
+
EXACT_SCHEME,
|
|
26
|
+
batchExtraSchema,
|
|
27
|
+
canonical,
|
|
28
|
+
exactExtraSchema,
|
|
29
|
+
} from '@obbyx/x402-obyte';
|
|
30
|
+
import { bindingsEqual, sha256Hex } from './binding.js';
|
|
31
|
+
import {
|
|
32
|
+
PAYMENT_IDENTIFIER,
|
|
33
|
+
decidePaymentId,
|
|
34
|
+
paymentIdOf,
|
|
35
|
+
paymentIdentifierDeclaration,
|
|
36
|
+
} from './payment-identifier.js';
|
|
37
|
+
import {
|
|
38
|
+
type PaymentTerms,
|
|
39
|
+
type RequestFacts,
|
|
40
|
+
bodyHashOf,
|
|
41
|
+
newQuote,
|
|
42
|
+
offerBinding,
|
|
43
|
+
operationFingerprint,
|
|
44
|
+
} from './quote.js';
|
|
45
|
+
import type { ConsumedProofsStore, StoredResponse } from './consumed-proofs.js';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Express paywall for x402 `exact` payments on Obyte (seller side).
|
|
49
|
+
*
|
|
50
|
+
* Flow `upfront` (phase 3): settle to the facilitator's finality BEFORE the
|
|
51
|
+
* handler runs; the handler runs at most once per payment proof; a repeat of
|
|
52
|
+
* the same operation with the same proof returns the stored response.
|
|
53
|
+
*
|
|
54
|
+
* Paid handlers return their response instead of writing to `res`, so the
|
|
55
|
+
* paywall can store it for idempotent replays.
|
|
56
|
+
*/
|
|
57
|
+
export interface PaidResult {
|
|
58
|
+
readonly status?: number;
|
|
59
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
60
|
+
/** Objects are sent as JSON; strings/bytes as-is. */
|
|
61
|
+
readonly body: unknown;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type PaidHandler = (req: Request) => Promise<PaidResult> | PaidResult;
|
|
65
|
+
|
|
66
|
+
export interface RoutePrice {
|
|
67
|
+
/** Atomic units as a decimal string. */
|
|
68
|
+
readonly amount: string;
|
|
69
|
+
/** `'base'` for bytes or an asset id. */
|
|
70
|
+
readonly asset?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface PaidRouteOptions {
|
|
74
|
+
readonly price: RoutePrice;
|
|
75
|
+
readonly description?: string;
|
|
76
|
+
readonly mimeType?: string;
|
|
77
|
+
/** What identifies "the same operation". Default: sha256 of the canonical JSON body. */
|
|
78
|
+
readonly fingerprint?: (req: Request) => string;
|
|
79
|
+
/** Offered schemes in order of preference. Default: batch-settlement (if enabled), exact. */
|
|
80
|
+
readonly schemes?: readonly PaywallScheme[];
|
|
81
|
+
/**
|
|
82
|
+
* `upfront` (default): settle, then run the handler — for anything expensive
|
|
83
|
+
* or irreversible. `authorization`: verify, run the handler, settle, then
|
|
84
|
+
* send — only for cheap, idempotent handlers: if the settlement then fails,
|
|
85
|
+
* the handler's side effects have happened but its response is withheld.
|
|
86
|
+
*/
|
|
87
|
+
readonly flow?: 'upfront' | 'authorization';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export type PaywallScheme = typeof EXACT_SCHEME | typeof BATCH_SCHEME;
|
|
91
|
+
|
|
92
|
+
export interface ObytePaywallOptions {
|
|
93
|
+
readonly facilitator: FacilitatorClient;
|
|
94
|
+
readonly network: string;
|
|
95
|
+
readonly payTo: string;
|
|
96
|
+
/** Stable identifier of this seller/API (part of the binding). */
|
|
97
|
+
readonly sellerId: string;
|
|
98
|
+
/** HMAC key for bindings; keep secret, ≥ 32 bytes. */
|
|
99
|
+
readonly secret: string | Uint8Array;
|
|
100
|
+
readonly store: ConsumedProofsStore;
|
|
101
|
+
readonly logger: Logger;
|
|
102
|
+
readonly quoteTtlSeconds?: number;
|
|
103
|
+
readonly maxTimeoutSeconds?: number;
|
|
104
|
+
/** How long one request waits for `settlement_pending` to resolve. */
|
|
105
|
+
readonly settleWaitMs?: number;
|
|
106
|
+
readonly settlePollMs?: number;
|
|
107
|
+
/**
|
|
108
|
+
* How long a request may hold a payment in `serving` (default 300 s). After
|
|
109
|
+
* that a retry with the same payment takes over (crash recovery); set it
|
|
110
|
+
* above the slowest handler.
|
|
111
|
+
*/
|
|
112
|
+
readonly servingLeaseSeconds?: number;
|
|
113
|
+
/**
|
|
114
|
+
* Declares the x402 `payment-identifier` extension: a client-chosen id makes a
|
|
115
|
+
* repeated operation return the stored response instead of charging again.
|
|
116
|
+
*/
|
|
117
|
+
readonly paymentIdentifier?: { readonly required: boolean };
|
|
118
|
+
/** Enables batch-settlement (AA payment channels) offers; requires a running seller watcher. */
|
|
119
|
+
readonly batch?: { readonly minChannelTimeoutSeconds: number };
|
|
120
|
+
readonly now?: () => number;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const EXPOSE = 'PAYMENT-REQUIRED, PAYMENT-RESPONSE';
|
|
124
|
+
|
|
125
|
+
export class ObytePaywall {
|
|
126
|
+
private readonly log: Logger;
|
|
127
|
+
private readonly now: () => number;
|
|
128
|
+
|
|
129
|
+
constructor(private readonly options: ObytePaywallOptions) {
|
|
130
|
+
getNetwork(options.network);
|
|
131
|
+
if (!isAddress(options.payTo)) throw new Error('payTo must be a valid Obyte address');
|
|
132
|
+
if (
|
|
133
|
+
(typeof options.secret === 'string'
|
|
134
|
+
? Buffer.byteLength(options.secret)
|
|
135
|
+
: options.secret.length) < 32
|
|
136
|
+
) {
|
|
137
|
+
throw new Error('secret must be at least 32 bytes');
|
|
138
|
+
}
|
|
139
|
+
this.log = moduleLogger(options.logger, 'sdk.paywall', { network: options.network });
|
|
140
|
+
this.now = options.now ?? (() => Math.floor(Date.now() / 1000));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Wraps a handler so it only runs after a settled payment. */
|
|
144
|
+
paid(route: PaidRouteOptions, handler: PaidHandler): RequestHandler {
|
|
145
|
+
const asset = route.price.asset ?? 'base';
|
|
146
|
+
if (!isAssetId(asset)) throw new Error(`Invalid asset ${asset}`);
|
|
147
|
+
const amount = parseAmount(route.price.amount).toString();
|
|
148
|
+
return (req: Request, res: Response, next: NextFunction) => {
|
|
149
|
+
const requestId = String(req.get('x-request-id') ?? cryptoId());
|
|
150
|
+
withLogContext({ requestId, network: this.options.network }, () =>
|
|
151
|
+
this.handle(req, res, route, { asset, amount }, handler).catch(next),
|
|
152
|
+
);
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private async handle(
|
|
157
|
+
req: Request,
|
|
158
|
+
res: Response,
|
|
159
|
+
route: PaidRouteOptions,
|
|
160
|
+
price: { asset: string; amount: string },
|
|
161
|
+
handler: PaidHandler,
|
|
162
|
+
): Promise<void> {
|
|
163
|
+
res.setHeader('Access-Control-Expose-Headers', EXPOSE);
|
|
164
|
+
res.setHeader('Cache-Control', 'private, no-store');
|
|
165
|
+
const header = req.get('payment-signature');
|
|
166
|
+
if (header === undefined) return this.paymentRequired(req, res, route, price);
|
|
167
|
+
|
|
168
|
+
let payload: PaymentPayload;
|
|
169
|
+
try {
|
|
170
|
+
payload = decodePaymentSignatureHeader(header);
|
|
171
|
+
} catch {
|
|
172
|
+
res.status(400).json({ error: 'invalid_payment_header' });
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const accepted = payload.accepted;
|
|
176
|
+
const scheme = accepted?.scheme as PaywallScheme | undefined;
|
|
177
|
+
const offered = this.schemesFor(route);
|
|
178
|
+
const extra =
|
|
179
|
+
scheme === EXACT_SCHEME
|
|
180
|
+
? exactExtraSchema.safeParse(accepted?.extra)
|
|
181
|
+
: scheme === BATCH_SCHEME
|
|
182
|
+
? batchExtraSchema.safeParse(accepted?.extra)
|
|
183
|
+
: undefined;
|
|
184
|
+
const proofId = scheme === undefined ? undefined : proofIdOf(scheme, payload);
|
|
185
|
+
if (
|
|
186
|
+
payload.x402Version !== 2 ||
|
|
187
|
+
scheme === undefined ||
|
|
188
|
+
!offered.includes(scheme) ||
|
|
189
|
+
accepted?.network !== this.options.network ||
|
|
190
|
+
accepted.asset !== price.asset ||
|
|
191
|
+
accepted.amount !== price.amount ||
|
|
192
|
+
accepted.payTo !== this.options.payTo ||
|
|
193
|
+
extra === undefined ||
|
|
194
|
+
!extra.success ||
|
|
195
|
+
proofId === undefined ||
|
|
196
|
+
(scheme === BATCH_SCHEME &&
|
|
197
|
+
(extra.data as { minTimeout?: number }).minTimeout !==
|
|
198
|
+
this.options.batch?.minChannelTimeoutSeconds)
|
|
199
|
+
) {
|
|
200
|
+
return this.paymentRequired(req, res, route, price, 'invalid_payment_requirements');
|
|
201
|
+
}
|
|
202
|
+
const facts = this.requestFacts(req, route);
|
|
203
|
+
const terms = this.terms(price);
|
|
204
|
+
const expected = offerBinding(this.options, scheme, extra.data, facts, terms);
|
|
205
|
+
if (!bindingsEqual(expected, extra.data.binding)) {
|
|
206
|
+
this.log.warn({ proofId }, 'payment is bound to another operation or a forged quote');
|
|
207
|
+
return this.paymentRequired(req, res, route, price, 'invalid_binding');
|
|
208
|
+
}
|
|
209
|
+
const binding = extra.data.binding;
|
|
210
|
+
const key = networkKey(
|
|
211
|
+
getNetwork(this.options.network),
|
|
212
|
+
scheme === EXACT_SCHEME ? 'unit' : 'voucher',
|
|
213
|
+
proofId,
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
await withLogContext({ binding, proofId }, async () => {
|
|
217
|
+
const store = this.options.store;
|
|
218
|
+
const begun = store.begin(key, binding);
|
|
219
|
+
const { created } = begun;
|
|
220
|
+
let { record } = begun;
|
|
221
|
+
// A proof left in `serving` by a crashed process is recovered after the lease.
|
|
222
|
+
if (
|
|
223
|
+
record.state === 'serving' &&
|
|
224
|
+
store.reclaimStale(key, this.options.servingLeaseSeconds ?? 300)
|
|
225
|
+
) {
|
|
226
|
+
this.log.warn('recovered a payment stuck in serving (lease expired)');
|
|
227
|
+
record = store.get(key) ?? record;
|
|
228
|
+
}
|
|
229
|
+
if (!bindingsEqual(record.binding, binding)) {
|
|
230
|
+
res.status(402).json({ error: 'invalid_payment_reused' });
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
// x402 payment-identifier: one client id = one operation. Decided AFTER
|
|
234
|
+
// the proof row exists, so a concurrent request always sees this proof
|
|
235
|
+
// as in progress (never as "absent") and cannot take the id over.
|
|
236
|
+
if (this.options.paymentIdentifier !== undefined) {
|
|
237
|
+
const decision = decidePaymentId(store, {
|
|
238
|
+
paymentId: paymentIdOf(payload),
|
|
239
|
+
required: this.options.paymentIdentifier.required,
|
|
240
|
+
scopeId: (id) => `${this.options.sellerId}:${id}`,
|
|
241
|
+
fingerprint: operationFingerprint(facts, terms),
|
|
242
|
+
proofKey: key,
|
|
243
|
+
});
|
|
244
|
+
if (decision.kind !== 'proceed' && created) store.forget(key, 'settling');
|
|
245
|
+
if (decision.kind === 'reject') {
|
|
246
|
+
res.status(decision.status).json({ error: decision.error });
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (decision.kind === 'replay') {
|
|
250
|
+
const original = store.get(decision.proofKey);
|
|
251
|
+
this.log.info('payment-identifier repeat: stored response, new payment NOT settled');
|
|
252
|
+
return this.send(res, original?.response ?? null, original?.paymentResponse ?? null);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (created && extra.data.validUntil < this.now()) {
|
|
256
|
+
store.forget(key, 'settling');
|
|
257
|
+
return this.paymentRequired(req, res, route, price, 'quote_expired');
|
|
258
|
+
}
|
|
259
|
+
switch (record.state) {
|
|
260
|
+
case 'served':
|
|
261
|
+
this.log.info('replaying stored response for a repeated operation');
|
|
262
|
+
return this.send(res, record.response, record.paymentResponse);
|
|
263
|
+
case 'serving':
|
|
264
|
+
if (record.paymentResponse !== null)
|
|
265
|
+
res.setHeader('PAYMENT-RESPONSE', record.paymentResponse);
|
|
266
|
+
res.status(409).json({ error: 'payment_in_progress' });
|
|
267
|
+
return;
|
|
268
|
+
case 'failed':
|
|
269
|
+
res.status(402).json({ error: 'payment_failed' });
|
|
270
|
+
return;
|
|
271
|
+
case 'paid_not_served':
|
|
272
|
+
return this.serve(req, res, key, record.paymentResponse, handler);
|
|
273
|
+
case 'awaiting_settle':
|
|
274
|
+
return this.settleServed(req, res, route, price, key, payload, accepted);
|
|
275
|
+
case 'settling':
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (route.flow === 'authorization') {
|
|
280
|
+
return this.authorize(req, res, route, price, key, payload, accepted, handler);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const settled = await this.settleWithWait(payload, accepted);
|
|
284
|
+
const paymentResponse = encodePaymentResponseHeader(settled);
|
|
285
|
+
if (settled.success) {
|
|
286
|
+
store.transition(key, 'settling', 'paid_not_served', { paymentResponse });
|
|
287
|
+
this.log.info({ transaction: settled.transaction }, 'payment settled');
|
|
288
|
+
return this.serve(req, res, key, paymentResponse, handler);
|
|
289
|
+
}
|
|
290
|
+
if (settled.errorReason === 'settlement_pending') {
|
|
291
|
+
this.log.info(
|
|
292
|
+
{ transaction: settled.transaction },
|
|
293
|
+
'settlement pending; client should retry with the same payment',
|
|
294
|
+
);
|
|
295
|
+
res.setHeader('PAYMENT-RESPONSE', paymentResponse);
|
|
296
|
+
res.setHeader('Retry-After', '5');
|
|
297
|
+
res.status(402).json({ error: 'settlement_pending', transaction: settled.transaction });
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (settled.transaction === '') {
|
|
301
|
+
// Rejected before reaching the network: the proof was not consumed.
|
|
302
|
+
store.forget(key, 'settling');
|
|
303
|
+
} else {
|
|
304
|
+
store.transition(key, 'settling', 'failed', { paymentResponse });
|
|
305
|
+
}
|
|
306
|
+
this.log.warn({ reason: settled.errorReason }, 'payment failed');
|
|
307
|
+
res.setHeader('PAYMENT-RESPONSE', paymentResponse);
|
|
308
|
+
return this.paymentRequired(req, res, route, price, settled.errorReason ?? 'payment_failed');
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** authorization flow: verify → handler (response stored) → settle → send. */
|
|
313
|
+
private async authorize(
|
|
314
|
+
req: Request,
|
|
315
|
+
res: Response,
|
|
316
|
+
route: PaidRouteOptions,
|
|
317
|
+
price: { asset: string; amount: string },
|
|
318
|
+
key: string,
|
|
319
|
+
payload: PaymentPayload,
|
|
320
|
+
accepted: PaymentRequirements,
|
|
321
|
+
handler: PaidHandler,
|
|
322
|
+
): Promise<void> {
|
|
323
|
+
const store = this.options.store;
|
|
324
|
+
let verified;
|
|
325
|
+
try {
|
|
326
|
+
verified = await this.options.facilitator.verify(payload, accepted);
|
|
327
|
+
} catch (err) {
|
|
328
|
+
this.log.warn({ err }, 'facilitator unreachable during verify');
|
|
329
|
+
store.forget(key, 'settling');
|
|
330
|
+
res.status(503).json({ error: 'facilitator_unavailable' });
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (!verified.isValid) {
|
|
334
|
+
store.forget(key, 'settling');
|
|
335
|
+
return this.paymentRequired(
|
|
336
|
+
req,
|
|
337
|
+
res,
|
|
338
|
+
route,
|
|
339
|
+
price,
|
|
340
|
+
verified.invalidReason ?? 'invalid_payment',
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
if (!store.transition(key, 'settling', 'serving')) {
|
|
344
|
+
res.status(409).json({ error: 'payment_in_progress' });
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
let result: PaidResult;
|
|
348
|
+
try {
|
|
349
|
+
result = await handler(req);
|
|
350
|
+
} catch (err) {
|
|
351
|
+
store.transition(key, 'serving', 'settling'); // nothing settled; the same payment may retry
|
|
352
|
+
this.log.error({ err }, 'paid handler failed before settlement');
|
|
353
|
+
res.status(500).json({ error: 'handler_failed', retry: 'same payment, same request' });
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
store.transition(key, 'serving', 'awaiting_settle', { response: toStored(result) });
|
|
357
|
+
return this.settleServed(req, res, route, price, key, payload, accepted);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
private async settleServed(
|
|
361
|
+
req: Request,
|
|
362
|
+
res: Response,
|
|
363
|
+
route: PaidRouteOptions,
|
|
364
|
+
price: { asset: string; amount: string },
|
|
365
|
+
key: string,
|
|
366
|
+
payload: PaymentPayload,
|
|
367
|
+
accepted: PaymentRequirements,
|
|
368
|
+
): Promise<void> {
|
|
369
|
+
const store = this.options.store;
|
|
370
|
+
const settled = await this.settleWithWait(payload, accepted);
|
|
371
|
+
const paymentResponse = encodePaymentResponseHeader(settled);
|
|
372
|
+
if (settled.success) {
|
|
373
|
+
store.transition(key, 'awaiting_settle', 'served', { paymentResponse });
|
|
374
|
+
const record = store.get(key);
|
|
375
|
+
this.log.info(
|
|
376
|
+
{ transaction: settled.transaction },
|
|
377
|
+
'payment settled after the handler; response sent',
|
|
378
|
+
);
|
|
379
|
+
return this.send(res, record?.response ?? null, paymentResponse);
|
|
380
|
+
}
|
|
381
|
+
res.setHeader('PAYMENT-RESPONSE', paymentResponse);
|
|
382
|
+
if (settled.errorReason === 'settlement_pending') {
|
|
383
|
+
res.setHeader('Retry-After', '5');
|
|
384
|
+
res.status(402).json({ error: 'settlement_pending', transaction: settled.transaction });
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
store.transition(key, 'awaiting_settle', 'failed', { paymentResponse });
|
|
388
|
+
this.log.warn(
|
|
389
|
+
{ reason: settled.errorReason },
|
|
390
|
+
'payment failed AFTER the handler ran (authorization flow); response withheld',
|
|
391
|
+
);
|
|
392
|
+
return this.paymentRequired(req, res, route, price, settled.errorReason ?? 'payment_failed');
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
private async serve(
|
|
396
|
+
req: Request,
|
|
397
|
+
res: Response,
|
|
398
|
+
key: string,
|
|
399
|
+
paymentResponse: string | null,
|
|
400
|
+
handler: PaidHandler,
|
|
401
|
+
): Promise<void> {
|
|
402
|
+
const store = this.options.store;
|
|
403
|
+
if (!store.transition(key, 'paid_not_served', 'serving')) {
|
|
404
|
+
res.status(409).json({ error: 'payment_in_progress' });
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
let result: PaidResult;
|
|
408
|
+
try {
|
|
409
|
+
result = await handler(req);
|
|
410
|
+
} catch (err) {
|
|
411
|
+
store.transition(key, 'serving', 'paid_not_served');
|
|
412
|
+
this.log.error(
|
|
413
|
+
{ err },
|
|
414
|
+
'paid handler failed; the payment can be retried for the same operation',
|
|
415
|
+
);
|
|
416
|
+
// The payment WAS settled: tell the client (its budget must count it).
|
|
417
|
+
if (paymentResponse !== null) res.setHeader('PAYMENT-RESPONSE', paymentResponse);
|
|
418
|
+
res.status(500).json({ error: 'handler_failed', retry: 'same payment, same request' });
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
const response = toStored(result);
|
|
422
|
+
if (!store.transition(key, 'serving', 'served', { response })) {
|
|
423
|
+
this.log.error('lost the serving state while storing the response');
|
|
424
|
+
}
|
|
425
|
+
this.log.info({ status: response.status }, 'paid response served');
|
|
426
|
+
this.send(res, response, paymentResponse);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
private send(
|
|
430
|
+
res: Response,
|
|
431
|
+
response: StoredResponse | null,
|
|
432
|
+
paymentResponse: string | null,
|
|
433
|
+
): void {
|
|
434
|
+
if (response === null) {
|
|
435
|
+
res.status(500).json({ error: 'missing_stored_response' });
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
if (paymentResponse !== null) res.setHeader('PAYMENT-RESPONSE', paymentResponse);
|
|
439
|
+
for (const [name, value] of Object.entries(response.headers)) res.setHeader(name, value);
|
|
440
|
+
res.status(response.status).end(Buffer.from(response.body));
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
private paymentRequired(
|
|
444
|
+
req: Request,
|
|
445
|
+
res: Response,
|
|
446
|
+
route: PaidRouteOptions,
|
|
447
|
+
price: { asset: string; amount: string },
|
|
448
|
+
error?: string,
|
|
449
|
+
): void {
|
|
450
|
+
const quote = newQuote(this.now(), this.options.quoteTtlSeconds ?? 300);
|
|
451
|
+
const facts = this.requestFacts(req, route);
|
|
452
|
+
const terms = this.terms(price);
|
|
453
|
+
const accepts: PaymentRequirements[] = this.schemesFor(route).map((scheme) => ({
|
|
454
|
+
scheme,
|
|
455
|
+
network: this.options.network as PaymentRequirements['network'],
|
|
456
|
+
asset: price.asset,
|
|
457
|
+
amount: price.amount,
|
|
458
|
+
payTo: this.options.payTo,
|
|
459
|
+
maxTimeoutSeconds: this.options.maxTimeoutSeconds ?? 300,
|
|
460
|
+
extra: {
|
|
461
|
+
binding: offerBinding(this.options, scheme, quote, facts, terms),
|
|
462
|
+
...quote,
|
|
463
|
+
// x402 §6.1: a non-default flow must be declared in the offer.
|
|
464
|
+
...(route.flow === 'authorization' ? {} : { paymentFlow: 'upfront' }),
|
|
465
|
+
...(scheme === BATCH_SCHEME
|
|
466
|
+
? {
|
|
467
|
+
baseAa: CHANNEL_BASE_AA,
|
|
468
|
+
minTimeout: this.options.batch?.minChannelTimeoutSeconds ?? 0,
|
|
469
|
+
}
|
|
470
|
+
: {}),
|
|
471
|
+
},
|
|
472
|
+
}));
|
|
473
|
+
const body: PaymentRequired = {
|
|
474
|
+
x402Version: 2,
|
|
475
|
+
...(error === undefined ? {} : { error }),
|
|
476
|
+
resource: {
|
|
477
|
+
url: `${req.protocol}://${req.get('host') ?? 'localhost'}${req.originalUrl}`,
|
|
478
|
+
...(route.description ? { description: route.description } : {}),
|
|
479
|
+
...(route.mimeType ? { mimeType: route.mimeType } : {}),
|
|
480
|
+
},
|
|
481
|
+
accepts,
|
|
482
|
+
...(this.options.paymentIdentifier
|
|
483
|
+
? {
|
|
484
|
+
extensions: {
|
|
485
|
+
[PAYMENT_IDENTIFIER]: paymentIdentifierDeclaration(
|
|
486
|
+
this.options.paymentIdentifier.required,
|
|
487
|
+
),
|
|
488
|
+
},
|
|
489
|
+
}
|
|
490
|
+
: {}),
|
|
491
|
+
};
|
|
492
|
+
res.setHeader('PAYMENT-REQUIRED', encodePaymentRequiredHeader(body));
|
|
493
|
+
res.status(402).json(body);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
private requestFacts(req: Request, route: PaidRouteOptions): RequestFacts {
|
|
497
|
+
return {
|
|
498
|
+
method: req.method,
|
|
499
|
+
url: req.originalUrl,
|
|
500
|
+
bodyHash: route.fingerprint ? route.fingerprint(req) : bodyHashOf(req.body),
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
private terms(price: { asset: string; amount: string }): PaymentTerms {
|
|
505
|
+
return { network: this.options.network, payTo: this.options.payTo, ...price };
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
private schemesFor(route: PaidRouteOptions): readonly PaywallScheme[] {
|
|
509
|
+
const all =
|
|
510
|
+
route.schemes ?? (this.options.batch ? [BATCH_SCHEME, EXACT_SCHEME] : [EXACT_SCHEME]);
|
|
511
|
+
return all.filter((s) => s === EXACT_SCHEME || this.options.batch !== undefined);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
private async settleWithWait(
|
|
515
|
+
payload: PaymentPayload,
|
|
516
|
+
req: PaymentRequirements,
|
|
517
|
+
): Promise<SettleResponse> {
|
|
518
|
+
const deadline = Date.now() + (this.options.settleWaitMs ?? 20_000);
|
|
519
|
+
const poll = this.options.settlePollMs ?? 2_000;
|
|
520
|
+
for (;;) {
|
|
521
|
+
let res: SettleResponse;
|
|
522
|
+
try {
|
|
523
|
+
res = await this.options.facilitator.settle(payload, req);
|
|
524
|
+
} catch (err) {
|
|
525
|
+
// Unknown outcome talking to the facilitator: keep the proof in `settling`.
|
|
526
|
+
this.log.warn({ err }, 'facilitator unreachable during settle');
|
|
527
|
+
return {
|
|
528
|
+
success: false,
|
|
529
|
+
errorReason: 'settlement_pending',
|
|
530
|
+
transaction: String(
|
|
531
|
+
(payload.payload['unit'] as { unit?: unknown } | undefined)?.unit ?? '',
|
|
532
|
+
),
|
|
533
|
+
network: req.network,
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
if (res.errorReason !== 'settlement_pending' || Date.now() + poll > deadline) return res;
|
|
537
|
+
await new Promise((r) => setTimeout(r, poll));
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** Identifies a payment proof: the unit hash (exact) or the voucher hash (batch). */
|
|
543
|
+
function proofIdOf(scheme: PaywallScheme, payload: PaymentPayload): string | undefined {
|
|
544
|
+
if (scheme === EXACT_SCHEME) {
|
|
545
|
+
const hash = (payload.payload?.['unit'] as { unit?: unknown } | undefined)?.unit;
|
|
546
|
+
return typeof hash === 'string' ? hash : undefined;
|
|
547
|
+
}
|
|
548
|
+
const pkg = payload.payload?.['signedPackage'];
|
|
549
|
+
return pkg !== null && typeof pkg === 'object' ? sha256Hex(canonical(pkg)) : undefined;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function toStored(result: PaidResult): StoredResponse {
|
|
553
|
+
const headers: Record<string, string> = { ...(result.headers ?? {}) };
|
|
554
|
+
let body: Uint8Array;
|
|
555
|
+
if (result.body instanceof Uint8Array) body = result.body;
|
|
556
|
+
else if (typeof result.body === 'string') body = Buffer.from(result.body);
|
|
557
|
+
else {
|
|
558
|
+
body = Buffer.from(JSON.stringify(result.body));
|
|
559
|
+
headers['content-type'] ??= 'application/json; charset=utf-8';
|
|
560
|
+
}
|
|
561
|
+
return { status: result.status ?? 200, headers, body };
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function cryptoId(): string {
|
|
565
|
+
return globalThis.crypto.randomUUID();
|
|
566
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { canonical, computeBinding, newQuoteId, sha256Hex } from '@obbyx/x402-obyte';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Offers ("quotes") and their binding to ONE request — shared by ObytePaywall
|
|
5
|
+
* and ObyteExactServer so both compute byte-identical bindings.
|
|
6
|
+
*
|
|
7
|
+
* binding = HMAC(secret, {sellerId, scheme, quoteId, validUntil, method, url,
|
|
8
|
+
* bodyHash, network, asset, amount, payTo})
|
|
9
|
+
*/
|
|
10
|
+
export interface RequestFacts {
|
|
11
|
+
readonly method: string;
|
|
12
|
+
/** Path and query string, as requested. */
|
|
13
|
+
readonly url: string;
|
|
14
|
+
/** sha256 hex of the canonical body, or a route-specific fingerprint. */
|
|
15
|
+
readonly bodyHash: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PaymentTerms {
|
|
19
|
+
readonly network: string;
|
|
20
|
+
readonly asset: string;
|
|
21
|
+
readonly amount: string;
|
|
22
|
+
readonly payTo: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface Quote {
|
|
26
|
+
readonly quoteId: string;
|
|
27
|
+
readonly validUntil: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SellerKey {
|
|
31
|
+
readonly sellerId: string;
|
|
32
|
+
readonly secret: string | Uint8Array;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** sha256 of the canonical JSON body ('' when there is none). */
|
|
36
|
+
export function bodyHashOf(body: unknown): string {
|
|
37
|
+
return sha256Hex(body === undefined ? '' : canonical(body));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function newQuote(now: number, ttlSeconds: number): Quote {
|
|
41
|
+
return { quoteId: newQuoteId(), validUntil: now + ttlSeconds };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function offerBinding(
|
|
45
|
+
seller: SellerKey,
|
|
46
|
+
scheme: string,
|
|
47
|
+
quote: Quote,
|
|
48
|
+
request: RequestFacts,
|
|
49
|
+
terms: PaymentTerms,
|
|
50
|
+
): string {
|
|
51
|
+
return computeBinding(seller.secret, {
|
|
52
|
+
sellerId: seller.sellerId,
|
|
53
|
+
scheme,
|
|
54
|
+
quoteId: quote.quoteId,
|
|
55
|
+
validUntil: quote.validUntil,
|
|
56
|
+
method: request.method,
|
|
57
|
+
url: request.url,
|
|
58
|
+
bodyHash: request.bodyHash,
|
|
59
|
+
network: terms.network,
|
|
60
|
+
asset: terms.asset,
|
|
61
|
+
amount: terms.amount,
|
|
62
|
+
payTo: terms.payTo,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The operation independent of the quote (method, URL, body, price, payee):
|
|
68
|
+
* what a payment-identifier is bound to.
|
|
69
|
+
*/
|
|
70
|
+
export function operationFingerprint(request: RequestFacts, terms: PaymentTerms): string {
|
|
71
|
+
return sha256Hex(
|
|
72
|
+
canonical({
|
|
73
|
+
method: request.method.toUpperCase(),
|
|
74
|
+
url: request.url,
|
|
75
|
+
body: request.bodyHash,
|
|
76
|
+
network: terms.network,
|
|
77
|
+
asset: terms.asset,
|
|
78
|
+
amount: terms.amount,
|
|
79
|
+
payTo: terms.payTo,
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
}
|