@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.
@@ -74,6 +74,32 @@ export interface PaidResult {
74
74
 
75
75
  export type PaidHandler = (req: Request) => Promise<PaidResult> | PaidResult;
76
76
 
77
+ /** A route's price, resolved: asset and atomic amount. */
78
+ type Price = { readonly asset: string; readonly amount: string };
79
+
80
+ /** One call of a paid route. */
81
+ interface PaidCall {
82
+ readonly req: Request;
83
+ readonly res: Response;
84
+ readonly route: PaidRouteOptions;
85
+ readonly price: Price;
86
+ readonly handler: PaidHandler;
87
+ }
88
+
89
+ /** A paid call whose payment matches this offer and was bound to this request. */
90
+ interface Payment extends PaidCall {
91
+ readonly payload: PaymentPayload;
92
+ readonly accepted: PaymentRequirements;
93
+ /** Unit hash (exact) or voucher hash (batch). */
94
+ readonly proofId: string;
95
+ /** Proof key in the consumed-proofs store (network-scoped). */
96
+ readonly key: string;
97
+ readonly binding: string;
98
+ readonly validUntil: number;
99
+ readonly facts: RequestFacts;
100
+ readonly terms: PaymentTerms;
101
+ }
102
+
77
103
  export interface RoutePrice {
78
104
  /** Atomic units as a decimal string. */
79
105
  readonly amount: string;
@@ -168,38 +194,53 @@ export class ObytePaywall {
168
194
  paid(route: PaidRouteOptions, handler: PaidHandler): RequestHandler {
169
195
  const asset = route.price.asset ?? 'base';
170
196
  if (!isAssetId(asset)) throw new Error(`Invalid asset ${asset}`);
171
- const amount = parseAmount(route.price.amount).toString();
197
+ const price = { asset, amount: parseAmount(route.price.amount).toString() };
172
198
  return (req: Request, res: Response, next: NextFunction) => {
173
199
  const requestId = String(req.get('x-request-id') ?? cryptoId());
174
200
  withLogContext({ requestId, network: this.options.network }, () =>
175
- this.handle(req, res, route, { asset, amount }, handler).catch(next),
201
+ this.handle({ req, res, route, price, handler }).catch(next),
176
202
  );
177
203
  };
178
204
  }
179
205
 
180
- private async handle(
181
- req: Request,
182
- res: Response,
183
- route: PaidRouteOptions,
184
- price: { asset: string; amount: string },
185
- handler: PaidHandler,
186
- ): Promise<void> {
206
+ private async handle(call: PaidCall): Promise<void> {
207
+ const { req, res } = call;
187
208
  exposePaymentHeaders(res);
188
209
  if (this.options.cors !== undefined) allowOrigin(req, res, this.options.cors);
189
210
  res.setHeader('Cache-Control', 'private, no-store');
190
211
  const header = req.get('payment-signature');
191
- if (header === undefined) return this.paymentRequired(req, res, route, price);
212
+ if (header === undefined) return this.paymentRequired(call);
192
213
 
214
+ const parsed = this.parsePayment(call, header);
215
+ if (parsed.kind === 'bad_header') {
216
+ res.status(400).json({ error: 'invalid_payment_header' });
217
+ return;
218
+ }
219
+ if (parsed.kind === 'refused') return this.paymentRequired(call, parsed.error);
220
+ const payment = parsed.payment;
221
+ await withLogContext({ binding: payment.binding, proofId: payment.proofId }, () =>
222
+ this.process(payment),
223
+ );
224
+ }
225
+
226
+ /**
227
+ * Decodes the payment and checks it against THIS offer: our scheme, network,
228
+ * price and payee, and a binding we issued for this very request.
229
+ */
230
+ private parsePayment(
231
+ call: PaidCall,
232
+ header: string,
233
+ ):
234
+ { kind: 'ok'; payment: Payment } | { kind: 'bad_header' } | { kind: 'refused'; error: string } {
193
235
  let payload: PaymentPayload;
194
236
  try {
195
237
  payload = decodePaymentSignatureHeader(header);
196
238
  } catch {
197
- res.status(400).json({ error: 'invalid_payment_header' });
198
- return;
239
+ return { kind: 'bad_header' };
199
240
  }
241
+ const { route, price } = call;
200
242
  const accepted = payload.accepted;
201
243
  const scheme = accepted?.scheme as PaywallScheme | undefined;
202
- const offered = this.schemesFor(route);
203
244
  const extra =
204
245
  scheme === EXACT_SCHEME
205
246
  ? exactExtraSchema.safeParse(accepted?.extra)
@@ -210,152 +251,169 @@ export class ObytePaywall {
210
251
  if (
211
252
  payload.x402Version !== 2 ||
212
253
  scheme === undefined ||
213
- !offered.includes(scheme) ||
254
+ !this.schemesFor(route).includes(scheme) ||
214
255
  accepted?.network !== this.options.network ||
215
256
  accepted.asset !== price.asset ||
216
257
  accepted.amount !== price.amount ||
217
258
  accepted.payTo !== this.options.payTo ||
218
- extra === undefined ||
219
- !extra.success ||
259
+ extra?.success !== true ||
220
260
  proofId === undefined ||
221
261
  (scheme === BATCH_SCHEME &&
222
262
  (extra.data as { minTimeout?: number }).minTimeout !==
223
263
  this.options.batch?.minChannelTimeoutSeconds)
224
264
  ) {
225
- return this.paymentRequired(req, res, route, price, 'invalid_payment_requirements');
265
+ return { kind: 'refused', error: 'invalid_payment_requirements' };
226
266
  }
227
- const facts = this.requestFacts(req, route);
267
+ const facts = this.requestFacts(call.req, route);
228
268
  const terms = this.terms(price);
229
- const expected = offerBinding(this.options, scheme, extra.data, facts, terms);
230
- if (!bindingsEqual(expected, extra.data.binding)) {
269
+ if (
270
+ !bindingsEqual(
271
+ offerBinding(this.options, scheme, extra.data, facts, terms),
272
+ extra.data.binding,
273
+ )
274
+ ) {
231
275
  this.log.warn({ proofId }, 'payment is bound to another operation or a forged quote');
232
- return this.paymentRequired(req, res, route, price, 'invalid_binding');
276
+ return { kind: 'refused', error: 'invalid_binding' };
233
277
  }
234
- const binding = extra.data.binding;
235
- const key = networkKey(
236
- getNetwork(this.options.network),
237
- scheme === EXACT_SCHEME ? 'unit' : 'voucher',
238
- proofId,
239
- );
278
+ return {
279
+ kind: 'ok',
280
+ payment: {
281
+ ...call,
282
+ payload,
283
+ accepted,
284
+ proofId,
285
+ binding: extra.data.binding,
286
+ validUntil: extra.data.validUntil,
287
+ facts,
288
+ terms,
289
+ key: networkKey(
290
+ getNetwork(this.options.network),
291
+ scheme === EXACT_SCHEME ? 'unit' : 'voucher',
292
+ proofId,
293
+ ),
294
+ },
295
+ };
296
+ }
240
297
 
241
- await withLogContext({ binding, proofId }, async () => {
242
- const store = this.options.store;
243
- const begun = store.begin(key, binding);
244
- const { created } = begun;
245
- let { record } = begun;
246
- // A proof left in `serving` by a crashed process is recovered after the lease.
247
- if (
248
- record.state === 'serving' &&
249
- store.reclaimStale(key, this.options.servingLeaseSeconds ?? DEFAULT_SERVING_LEASE_SECONDS)
250
- ) {
251
- this.log.warn('recovered a payment stuck in serving (lease expired)');
252
- record = store.get(key) ?? record;
253
- }
254
- if (!bindingsEqual(record.binding, binding)) {
255
- res.setHeader('PAYMENT-RESPONSE', this.refusal('invalid_payment_reused'));
256
- res.status(402).json({ error: 'invalid_payment_reused' });
298
+ /** Registers the proof, then continues from its state: one payment, one operation. */
299
+ private async process(payment: Payment): Promise<void> {
300
+ const { res, key, binding } = payment;
301
+ const store = this.options.store;
302
+ const begun = store.begin(key, binding);
303
+ const { created } = begun;
304
+ let { record } = begun;
305
+ // A proof left in `serving` by a crashed process is recovered after the lease.
306
+ if (
307
+ record.state === 'serving' &&
308
+ store.reclaimStale(key, this.options.servingLeaseSeconds ?? DEFAULT_SERVING_LEASE_SECONDS)
309
+ ) {
310
+ this.log.warn('recovered a payment stuck in serving (lease expired)');
311
+ record = store.get(key) ?? record;
312
+ }
313
+ if (!bindingsEqual(record.binding, binding)) {
314
+ res.setHeader('PAYMENT-RESPONSE', this.refusal('invalid_payment_reused'));
315
+ res.status(402).json({ error: 'invalid_payment_reused' });
316
+ return;
317
+ }
318
+ if (this.paymentIdentifierAnswered(payment, created)) return;
319
+ if (created && payment.validUntil < this.now()) {
320
+ store.forget(key, 'settling');
321
+ return this.paymentRequired(payment, 'quote_expired');
322
+ }
323
+ switch (record.state) {
324
+ case 'served':
325
+ this.log.info('replaying stored response for a repeated operation');
326
+ return this.send(res, record.response, record.paymentResponse);
327
+ case 'serving':
328
+ if (record.paymentResponse !== null)
329
+ res.setHeader('PAYMENT-RESPONSE', record.paymentResponse);
330
+ res.status(409).json({ error: 'payment_in_progress' });
257
331
  return;
258
- }
259
- // x402 payment-identifier: one client id = one operation. Decided AFTER
260
- // the proof row exists, so a concurrent request always sees this proof
261
- // as in progress (never as "absent") and cannot take the id over.
262
- if (this.options.paymentIdentifier !== undefined) {
263
- const decision = decidePaymentId(store, {
264
- paymentId: paymentIdOf(payload),
265
- required: this.options.paymentIdentifier.required,
266
- scopeId: (id) => `${this.options.sellerId}:${id}`,
267
- fingerprint: operationFingerprint(facts, terms),
268
- proofKey: key,
269
- });
270
- if (decision.kind !== 'proceed' && created) store.forget(key, 'settling');
271
- if (decision.kind === 'reject') {
272
- res.status(decision.status).json({ error: decision.error });
273
- return;
274
- }
275
- if (decision.kind === 'replay') {
276
- const original = store.get(decision.proofKey);
277
- this.log.info('payment-identifier repeat: stored response, new payment NOT settled');
278
- return this.send(res, original?.response ?? null, original?.paymentResponse ?? null);
279
- }
280
- }
281
- if (created && extra.data.validUntil < this.now()) {
282
- store.forget(key, 'settling');
283
- return this.paymentRequired(req, res, route, price, 'quote_expired');
284
- }
285
- switch (record.state) {
286
- case 'served':
287
- this.log.info('replaying stored response for a repeated operation');
288
- return this.send(res, record.response, record.paymentResponse);
289
- case 'serving':
290
- if (record.paymentResponse !== null)
291
- res.setHeader('PAYMENT-RESPONSE', record.paymentResponse);
292
- res.status(409).json({ error: 'payment_in_progress' });
293
- return;
294
- case 'failed':
295
- res.status(402).json({ error: 'payment_failed' });
296
- return;
297
- case 'paid_not_served':
298
- return this.serve(req, res, key, record.paymentResponse, handler);
299
- case 'awaiting_settle':
300
- return this.settleServed(req, res, route, price, key, payload, accepted);
301
- case 'settling':
302
- break;
303
- }
332
+ case 'failed':
333
+ res.status(402).json({ error: 'payment_failed' });
334
+ return;
335
+ case 'paid_not_served':
336
+ return this.serve(payment, record.paymentResponse);
337
+ case 'awaiting_settle':
338
+ return this.settleServed(payment);
339
+ case 'settling':
340
+ return payment.route.flow === 'authorization'
341
+ ? this.authorize(payment)
342
+ : this.settleUpfront(payment, created);
343
+ }
344
+ }
304
345
 
305
- if (route.flow === 'authorization') {
306
- return this.authorize(req, res, route, price, key, payload, accepted, handler);
307
- }
346
+ /**
347
+ * x402 payment-identifier: one client id = one operation. Decided AFTER the
348
+ * proof row exists, so a concurrent request always sees this proof as in
349
+ * progress (never as "absent") and cannot take the id over. True when the
350
+ * response was already sent (refused, or the stored response replayed).
351
+ */
352
+ private paymentIdentifierAnswered(payment: Payment, created: boolean): boolean {
353
+ const declared = this.options.paymentIdentifier;
354
+ if (declared === undefined) return false;
355
+ const store = this.options.store;
356
+ const decision = decidePaymentId(store, {
357
+ paymentId: paymentIdOf(payment.payload),
358
+ required: declared.required,
359
+ scopeId: (id) => `${this.options.sellerId}:${id}`,
360
+ fingerprint: operationFingerprint(payment.facts, payment.terms),
361
+ proofKey: payment.key,
362
+ });
363
+ if (decision.kind === 'proceed') return false;
364
+ if (created) store.forget(payment.key, 'settling');
365
+ if (decision.kind === 'reject') {
366
+ payment.res.status(decision.status).json({ error: decision.error });
367
+ return true;
368
+ }
369
+ const original = store.get(decision.proofKey);
370
+ this.log.info('payment-identifier repeat: stored response, new payment NOT settled');
371
+ this.send(payment.res, original?.response ?? null, original?.paymentResponse ?? null);
372
+ return true;
373
+ }
308
374
 
309
- const settled = await this.settleWithWait(payload, accepted);
310
- const paymentResponse = encodePaymentResponseHeader(settled);
311
- if (settled.success) {
312
- this.log.info({ transaction: settled.transaction }, 'payment settled');
313
- if (store.markSettled(key, binding, paymentResponse) === 'served') {
314
- // A concurrent request with the same payment already served it.
315
- const served = store.get(key);
316
- return this.send(res, served?.response ?? null, served?.paymentResponse ?? null);
317
- }
318
- return this.serve(req, res, key, paymentResponse, handler);
319
- }
320
- if (settled.errorReason === 'settlement_pending') {
321
- this.log.info(
322
- { transaction: settled.transaction },
323
- 'settlement pending; client should retry with the same payment',
324
- );
325
- res.setHeader('PAYMENT-RESPONSE', paymentResponse);
326
- res.setHeader('Retry-After', '5');
327
- res.status(402).json({ error: 'settlement_pending', transaction: settled.transaction });
328
- return;
329
- }
330
- if (settled.transaction === '') {
331
- // Rejected before reaching the network: the proof was not consumed.
332
- // Only the request that created the row removes it: a concurrent
333
- // request with the same payment may be settling it right now.
334
- if (created) store.forget(key, 'settling');
335
- } else {
336
- store.transition(key, 'settling', 'failed', { paymentResponse });
375
+ /** upfront flow: settle to finality, then run the handler once. */
376
+ private async settleUpfront(payment: Payment, created: boolean): Promise<void> {
377
+ const { res, key } = payment;
378
+ const store = this.options.store;
379
+ const settled = await this.settleWithWait(payment.payload, payment.accepted);
380
+ const paymentResponse = encodePaymentResponseHeader(settled);
381
+ if (settled.success) {
382
+ this.log.info({ transaction: settled.transaction }, 'payment settled');
383
+ if (store.markSettled(key, payment.binding, paymentResponse) === 'served') {
384
+ // A concurrent request with the same payment already served it.
385
+ const served = store.get(key);
386
+ return this.send(res, served?.response ?? null, served?.paymentResponse ?? null);
337
387
  }
338
- this.log.warn({ reason: settled.errorReason }, 'payment failed');
339
- res.setHeader('PAYMENT-RESPONSE', paymentResponse);
340
- return this.paymentRequired(req, res, route, price, settled.errorReason ?? 'payment_failed');
341
- });
388
+ return this.serve(payment, paymentResponse);
389
+ }
390
+ res.setHeader('PAYMENT-RESPONSE', paymentResponse);
391
+ if (settled.errorReason === 'settlement_pending') {
392
+ this.log.info(
393
+ { transaction: settled.transaction },
394
+ 'settlement pending; client should retry with the same payment',
395
+ );
396
+ return this.answerPending(res, settled.transaction);
397
+ }
398
+ if (settled.transaction === '') {
399
+ // Rejected before reaching the network: the proof was not consumed.
400
+ // Only the request that created the row removes it: a concurrent
401
+ // request with the same payment may be settling it right now.
402
+ if (created) store.forget(key, 'settling');
403
+ } else {
404
+ store.transition(key, 'settling', 'failed', { paymentResponse });
405
+ }
406
+ this.log.warn({ reason: settled.errorReason }, 'payment failed');
407
+ return this.paymentRequired(payment, settled.errorReason ?? 'payment_failed');
342
408
  }
343
409
 
344
410
  /** authorization flow: verify → handler (response stored) → settle → send. */
345
- private async authorize(
346
- req: Request,
347
- res: Response,
348
- route: PaidRouteOptions,
349
- price: { asset: string; amount: string },
350
- key: string,
351
- payload: PaymentPayload,
352
- accepted: PaymentRequirements,
353
- handler: PaidHandler,
354
- ): Promise<void> {
411
+ private async authorize(payment: Payment): Promise<void> {
412
+ const { res, key } = payment;
355
413
  const store = this.options.store;
356
414
  let verified;
357
415
  try {
358
- verified = await this.options.facilitator.verify(payload, accepted);
416
+ verified = await this.options.facilitator.verify(payment.payload, payment.accepted);
359
417
  } catch (err) {
360
418
  this.log.warn({ err }, 'facilitator unreachable during verify');
361
419
  store.forget(key, 'settling');
@@ -364,13 +422,7 @@ export class ObytePaywall {
364
422
  }
365
423
  if (!verified.isValid) {
366
424
  store.forget(key, 'settling');
367
- return this.paymentRequired(
368
- req,
369
- res,
370
- route,
371
- price,
372
- verified.invalidReason ?? 'invalid_payment',
373
- );
425
+ return this.paymentRequired(payment, verified.invalidReason ?? 'invalid_payment');
374
426
  }
375
427
  if (!store.transition(key, 'settling', 'serving')) {
376
428
  res.status(409).json({ error: 'payment_in_progress' });
@@ -378,7 +430,7 @@ export class ObytePaywall {
378
430
  }
379
431
  let result: PaidResult;
380
432
  try {
381
- result = await handler(req);
433
+ result = await payment.handler(payment.req);
382
434
  } catch (err) {
383
435
  store.transition(key, 'serving', 'settling'); // nothing settled; the same payment may retry
384
436
  this.log.error({ err }, 'paid handler failed before settlement');
@@ -386,20 +438,14 @@ export class ObytePaywall {
386
438
  return;
387
439
  }
388
440
  store.transition(key, 'serving', 'awaiting_settle', { response: toStored(result) });
389
- return this.settleServed(req, res, route, price, key, payload, accepted);
441
+ return this.settleServed(payment);
390
442
  }
391
443
 
392
- private async settleServed(
393
- req: Request,
394
- res: Response,
395
- route: PaidRouteOptions,
396
- price: { asset: string; amount: string },
397
- key: string,
398
- payload: PaymentPayload,
399
- accepted: PaymentRequirements,
400
- ): Promise<void> {
444
+ /** authorization flow, after the handler: settle, then send the stored response. */
445
+ private async settleServed(payment: Payment): Promise<void> {
446
+ const { res, key } = payment;
401
447
  const store = this.options.store;
402
- const settled = await this.settleWithWait(payload, accepted);
448
+ const settled = await this.settleWithWait(payment.payload, payment.accepted);
403
449
  const paymentResponse = encodePaymentResponseHeader(settled);
404
450
  if (settled.success) {
405
451
  store.transition(key, 'awaiting_settle', 'served', { paymentResponse });
@@ -412,25 +458,25 @@ export class ObytePaywall {
412
458
  }
413
459
  res.setHeader('PAYMENT-RESPONSE', paymentResponse);
414
460
  if (settled.errorReason === 'settlement_pending') {
415
- res.setHeader('Retry-After', '5');
416
- res.status(402).json({ error: 'settlement_pending', transaction: settled.transaction });
417
- return;
461
+ return this.answerPending(res, settled.transaction);
418
462
  }
419
463
  store.transition(key, 'awaiting_settle', 'failed', { paymentResponse });
420
464
  this.log.warn(
421
465
  { reason: settled.errorReason },
422
466
  'payment failed AFTER the handler ran (authorization flow); response withheld',
423
467
  );
424
- return this.paymentRequired(req, res, route, price, settled.errorReason ?? 'payment_failed');
468
+ return this.paymentRequired(payment, settled.errorReason ?? 'payment_failed');
425
469
  }
426
470
 
427
- private async serve(
428
- req: Request,
429
- res: Response,
430
- key: string,
431
- paymentResponse: string | null,
432
- handler: PaidHandler,
433
- ): Promise<void> {
471
+ /** Not final yet: the client retries with the same payment. */
472
+ private answerPending(res: Response, transaction: string): void {
473
+ res.setHeader('Retry-After', '5');
474
+ res.status(402).json({ error: 'settlement_pending', transaction });
475
+ }
476
+
477
+ /** Runs the handler once for a settled payment and stores its response. */
478
+ private async serve(payment: Payment, paymentResponse: string | null): Promise<void> {
479
+ const { res, key } = payment;
434
480
  const store = this.options.store;
435
481
  if (!store.transition(key, 'paid_not_served', 'serving')) {
436
482
  res.status(409).json({ error: 'payment_in_progress' });
@@ -438,7 +484,7 @@ export class ObytePaywall {
438
484
  }
439
485
  let result: PaidResult;
440
486
  try {
441
- result = await handler(req);
487
+ result = await payment.handler(payment.req);
442
488
  } catch (err) {
443
489
  store.transition(key, 'serving', 'paid_not_served');
444
490
  this.log.error(
@@ -472,13 +518,7 @@ export class ObytePaywall {
472
518
  res.status(response.status).end(Buffer.from(response.body));
473
519
  }
474
520
 
475
- private paymentRequired(
476
- req: Request,
477
- res: Response,
478
- route: PaidRouteOptions,
479
- price: { asset: string; amount: string },
480
- error?: string,
481
- ): void {
521
+ private paymentRequired({ req, res, route, price }: PaidCall, error?: string): void {
482
522
  // A refused payment that was never settled: say so explicitly (success:
483
523
  // false, empty transaction) so the client knows it was NOT used and can
484
524
  // release its budget and its channel voucher. A settle failure already
@@ -550,7 +590,7 @@ export class ObytePaywall {
550
590
  };
551
591
  }
552
592
 
553
- private terms(price: { asset: string; amount: string }): PaymentTerms {
593
+ private terms(price: Price): PaymentTerms {
554
594
  return { network: this.options.network, payTo: this.options.payTo, ...price };
555
595
  }
556
596