@obbyx/sdk 0.1.0 → 0.2.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.
@@ -43,6 +43,17 @@ import {
43
43
  operationFingerprint,
44
44
  } from './quote.js';
45
45
  import type { ConsumedProofsStore, StoredResponse } from './consumed-proofs.js';
46
+ import {
47
+ type PaywallCorsOptions,
48
+ allowOrigin,
49
+ exposePaymentHeaders,
50
+ preflightHandler,
51
+ } from './cors.js';
52
+ import {
53
+ DEFAULT_MAX_TIMEOUT_SECONDS,
54
+ DEFAULT_QUOTE_TTL_SECONDS,
55
+ DEFAULT_SERVING_LEASE_SECONDS,
56
+ } from './defaults.js';
46
57
 
47
58
  /**
48
59
  * Express paywall for x402 `exact` payments on Obyte (seller side).
@@ -117,11 +128,15 @@ export interface ObytePaywallOptions {
117
128
  readonly paymentIdentifier?: { readonly required: boolean };
118
129
  /** Enables batch-settlement (AA payment channels) offers; requires a running seller watcher. */
119
130
  readonly batch?: { readonly minChannelTimeoutSeconds: number };
131
+ /**
132
+ * CORS for browser clients on another origin (x402 HTTP transport, "Browser
133
+ * clients"). Also mount {@link ObytePaywall.preflight} before the routes.
134
+ * Without it, same-origin pages and non-browser clients work as before.
135
+ */
136
+ readonly cors?: PaywallCorsOptions;
120
137
  readonly now?: () => number;
121
138
  }
122
139
 
123
- const EXPOSE = 'PAYMENT-REQUIRED, PAYMENT-RESPONSE';
124
-
125
140
  export class ObytePaywall {
126
141
  private readonly log: Logger;
127
142
  private readonly now: () => number;
@@ -140,6 +155,15 @@ export class ObytePaywall {
140
155
  this.now = options.now ?? (() => Math.floor(Date.now() / 1000));
141
156
  }
142
157
 
158
+ /**
159
+ * Answers CORS preflight requests (`app.use(paywall.preflight())`); a no-op
160
+ * without the `cors` option. Preflights never require payment.
161
+ */
162
+ preflight(): RequestHandler {
163
+ const cors = this.options.cors;
164
+ return cors === undefined ? (_req, _res, next) => next() : preflightHandler(cors);
165
+ }
166
+
143
167
  /** Wraps a handler so it only runs after a settled payment. */
144
168
  paid(route: PaidRouteOptions, handler: PaidHandler): RequestHandler {
145
169
  const asset = route.price.asset ?? 'base';
@@ -160,7 +184,8 @@ export class ObytePaywall {
160
184
  price: { asset: string; amount: string },
161
185
  handler: PaidHandler,
162
186
  ): Promise<void> {
163
- res.setHeader('Access-Control-Expose-Headers', EXPOSE);
187
+ exposePaymentHeaders(res);
188
+ if (this.options.cors !== undefined) allowOrigin(req, res, this.options.cors);
164
189
  res.setHeader('Cache-Control', 'private, no-store');
165
190
  const header = req.get('payment-signature');
166
191
  if (header === undefined) return this.paymentRequired(req, res, route, price);
@@ -221,12 +246,13 @@ export class ObytePaywall {
221
246
  // A proof left in `serving` by a crashed process is recovered after the lease.
222
247
  if (
223
248
  record.state === 'serving' &&
224
- store.reclaimStale(key, this.options.servingLeaseSeconds ?? 300)
249
+ store.reclaimStale(key, this.options.servingLeaseSeconds ?? DEFAULT_SERVING_LEASE_SECONDS)
225
250
  ) {
226
251
  this.log.warn('recovered a payment stuck in serving (lease expired)');
227
252
  record = store.get(key) ?? record;
228
253
  }
229
254
  if (!bindingsEqual(record.binding, binding)) {
255
+ res.setHeader('PAYMENT-RESPONSE', this.refusal('invalid_payment_reused'));
230
256
  res.status(402).json({ error: 'invalid_payment_reused' });
231
257
  return;
232
258
  }
@@ -283,8 +309,12 @@ export class ObytePaywall {
283
309
  const settled = await this.settleWithWait(payload, accepted);
284
310
  const paymentResponse = encodePaymentResponseHeader(settled);
285
311
  if (settled.success) {
286
- store.transition(key, 'settling', 'paid_not_served', { paymentResponse });
287
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
+ }
288
318
  return this.serve(req, res, key, paymentResponse, handler);
289
319
  }
290
320
  if (settled.errorReason === 'settlement_pending') {
@@ -299,7 +329,9 @@ export class ObytePaywall {
299
329
  }
300
330
  if (settled.transaction === '') {
301
331
  // Rejected before reaching the network: the proof was not consumed.
302
- store.forget(key, 'settling');
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');
303
335
  } else {
304
336
  store.transition(key, 'settling', 'failed', { paymentResponse });
305
337
  }
@@ -447,7 +479,14 @@ export class ObytePaywall {
447
479
  price: { asset: string; amount: string },
448
480
  error?: string,
449
481
  ): void {
450
- const quote = newQuote(this.now(), this.options.quoteTtlSeconds ?? 300);
482
+ // A refused payment that was never settled: say so explicitly (success:
483
+ // false, empty transaction) so the client knows it was NOT used and can
484
+ // release its budget and its channel voucher. A settle failure already
485
+ // set its own PAYMENT-RESPONSE.
486
+ if (error !== undefined && !res.hasHeader('PAYMENT-RESPONSE')) {
487
+ res.setHeader('PAYMENT-RESPONSE', this.refusal(error));
488
+ }
489
+ const quote = newQuote(this.now(), this.options.quoteTtlSeconds ?? DEFAULT_QUOTE_TTL_SECONDS);
451
490
  const facts = this.requestFacts(req, route);
452
491
  const terms = this.terms(price);
453
492
  const accepts: PaymentRequirements[] = this.schemesFor(route).map((scheme) => ({
@@ -456,7 +495,7 @@ export class ObytePaywall {
456
495
  asset: price.asset,
457
496
  amount: price.amount,
458
497
  payTo: this.options.payTo,
459
- maxTimeoutSeconds: this.options.maxTimeoutSeconds ?? 300,
498
+ maxTimeoutSeconds: this.options.maxTimeoutSeconds ?? DEFAULT_MAX_TIMEOUT_SECONDS,
460
499
  extra: {
461
500
  binding: offerBinding(this.options, scheme, quote, facts, terms),
462
501
  ...quote,
@@ -493,6 +532,16 @@ export class ObytePaywall {
493
532
  res.status(402).json(body);
494
533
  }
495
534
 
535
+ /** PAYMENT-RESPONSE of a payment refused before settlement (not used, nothing charged). */
536
+ private refusal(errorReason: string): string {
537
+ return encodePaymentResponseHeader({
538
+ success: false,
539
+ errorReason,
540
+ transaction: '',
541
+ network: this.options.network as PaymentRequirements['network'],
542
+ });
543
+ }
544
+
496
545
  private requestFacts(req: Request, route: PaidRouteOptions): RequestFacts {
497
546
  return {
498
547
  method: req.method,
@@ -9,6 +9,7 @@ import type {
9
9
  SchemeServerHooks,
10
10
  } from '@x402/core/types';
11
11
  import {
12
+ CHANNEL_BASE_AA,
12
13
  ObbyxError,
13
14
  fromDisplay,
14
15
  getNetwork,
@@ -17,13 +18,24 @@ import {
17
18
  parseAmount,
18
19
  } from '@obbyx/obyte';
19
20
  import { type Logger, moduleLogger } from '@obbyx/obyte/logger';
20
- import { EXACT_SCHEME, bindingsEqual, exactExtraSchema } from '@obbyx/x402-obyte';
21
+ import {
22
+ BATCH_SCHEME,
23
+ EXACT_SCHEME,
24
+ batchExtraSchema,
25
+ bindingsEqual,
26
+ canonical,
27
+ exactExtraSchema,
28
+ sha256Hex,
29
+ } from '@obbyx/x402-obyte';
21
30
  import type { ConsumedProofsStore } from './consumed-proofs.js';
31
+ import { DEFAULT_QUOTE_TTL_SECONDS, DEFAULT_SERVING_LEASE_SECONDS } from './defaults.js';
22
32
  import { type Quote, bodyHashOf, newQuote, offerBinding } from './quote.js';
23
33
 
24
34
  /**
25
- * Obyte `exact` for the OFFICIAL x402 resource server (`@x402/core/server`,
26
- * `@x402/express`, `@x402/hono`, …): a SchemeNetworkServer.
35
+ * Obyte schemes for the OFFICIAL x402 resource server (`@x402/core/server`,
36
+ * `@x402/express`, `@x402/hono`, …): SchemeNetworkServer implementations for
37
+ * `exact` ({@link ObyteExactServer}) and `batch-settlement`
38
+ * ({@link ObyteBatchServer}), sharing {@link ObyteSchemeServer}.
27
39
  *
28
40
  * - Binding (I1/I2): `enrichPaymentRequiredResponse` adds a per-request HMAC
29
41
  * binding + quote to every offer. On a paid request it recomputes the HMAC
@@ -37,7 +49,9 @@ import { type Quote, bodyHashOf, newQuote, offerBinding } from './quote.js';
37
49
  * Unlike `@obbyx/sdk`'s ObytePaywall, a repeated request gets a 402 instead
38
50
  * of the stored response (the official middleware cannot replay responses).
39
51
  */
40
- export interface ObyteExactServerOptions {
52
+ export interface ObyteSchemeServerOptions {
53
+ /** How long a request may hold a payment in `serving` before a retry takes over (default 300 s). */
54
+ readonly servingLeaseSeconds?: number;
41
55
  readonly sellerId: string;
42
56
  /** HMAC key for bindings (≥ 32 bytes). */
43
57
  readonly secret: string | Uint8Array;
@@ -69,8 +83,32 @@ function requestOf(transportContext: unknown): RequestLike | undefined {
69
83
  };
70
84
  }
71
85
 
72
- export class ObyteExactServer implements SchemeNetworkServer {
73
- readonly scheme = EXACT_SCHEME;
86
+ /** @deprecated Use {@link ObyteSchemeServerOptions}. */
87
+ export type ObyteExactServerOptions = ObyteSchemeServerOptions;
88
+
89
+ /** Options of {@link ObyteBatchServer}. */
90
+ export interface ObyteBatchServerOptions extends ObyteSchemeServerOptions {
91
+ /** Minimum channel dispute window the seller accepts (seconds); must match the facilitator's. */
92
+ readonly minChannelTimeoutSeconds: number;
93
+ }
94
+
95
+ /** Per-scheme parts of an Obyte resource-server scheme. */
96
+ export interface ObyteSchemeSpec {
97
+ readonly scheme: typeof EXACT_SCHEME | typeof BATCH_SCHEME;
98
+ /** `extra` fields the scheme adds to every offer besides binding and quote. */
99
+ readonly staticExtra: Record<string, unknown>;
100
+ /** Parses the quote fields of the accepted offer. */
101
+ readonly acceptedExtra: (extra: unknown) => {
102
+ readonly binding: string;
103
+ readonly quoteId: string;
104
+ readonly validUntil: number;
105
+ };
106
+ /** Proof id and key kind of a payment payload (undefined when malformed). */
107
+ readonly proofOf: (payload: Record<string, unknown>) => { kind: string; id: string } | undefined;
108
+ }
109
+
110
+ export abstract class ObyteSchemeServer implements SchemeNetworkServer {
111
+ readonly scheme: string;
74
112
  readonly defaultAssetTransferMethod = 'default';
75
113
  readonly paymentFlows = {
76
114
  default: { supported: ['upfront'] as const, default: 'upfront' as const },
@@ -80,24 +118,25 @@ export class ObyteExactServer implements SchemeNetworkServer {
80
118
  private readonly log: Logger;
81
119
  private readonly now: () => number;
82
120
 
83
- constructor(private readonly options: ObyteExactServerOptions) {
84
- this.log = moduleLogger(options.logger, 'sdk.x402-server', { scheme: EXACT_SCHEME });
121
+ protected constructor(
122
+ private readonly options: ObyteSchemeServerOptions,
123
+ private readonly spec: ObyteSchemeSpec,
124
+ ) {
125
+ this.scheme = spec.scheme;
126
+ this.log = moduleLogger(options.logger, 'sdk.x402-server', { scheme: spec.scheme });
85
127
  this.now = options.now ?? (() => Math.floor(Date.now() / 1000));
86
128
  this.schemeHooks = {
87
129
  onBeforeVerify: async (ctx) => {
88
- const unit = (ctx.paymentPayload.payload as { unit?: { unit?: unknown } }).unit?.unit;
130
+ const key = this.keyOf(ctx);
89
131
  const binding = (ctx.requirements.extra as { binding?: unknown }).binding;
90
- if (typeof unit !== 'string' || typeof binding !== 'string') {
132
+ if (key === undefined || typeof binding !== 'string') {
91
133
  return { abort: true, reason: 'invalid_payload' };
92
134
  }
93
- const { record } = this.options.proofs.begin(
94
- this.key(ctx.requirements.network, unit),
95
- binding,
96
- );
135
+ const { record } = this.options.proofs.begin(key, binding);
97
136
  if (!bindingsEqual(record.binding, binding))
98
137
  return { abort: true, reason: 'invalid_payment_reused' };
99
138
  if (record.state === 'served') {
100
- this.log.warn({ proofId: unit }, 'payment already served a request; refused');
139
+ this.log.warn({ proofId: key }, 'payment already served a request; refused');
101
140
  return { abort: true, reason: 'payment_already_used' };
102
141
  }
103
142
  return undefined;
@@ -109,7 +148,16 @@ export class ObyteExactServer implements SchemeNetworkServer {
109
148
  onBeforeSettle: async (ctx) => {
110
149
  const key = this.keyOf(ctx);
111
150
  if (key === undefined) return { abort: true, reason: 'invalid_payload' };
112
- if (this.options.proofs.transition(key, 'settling', 'serving')) return undefined;
151
+ const proofs = this.options.proofs;
152
+ // A claim left behind by a crashed process is taken over after the lease.
153
+ proofs.reclaimStale(key, this.options.servingLeaseSeconds ?? DEFAULT_SERVING_LEASE_SECONDS);
154
+ if (
155
+ proofs.transition(key, 'settling', 'serving') ||
156
+ // settled earlier, but the handler failed: serve it now (settle is idempotent)
157
+ proofs.transition(key, 'paid_not_served', 'serving')
158
+ ) {
159
+ return undefined;
160
+ }
113
161
  const state = this.options.proofs.get(key)?.state;
114
162
  this.log.warn({ state }, 'payment already in use by another request; refused');
115
163
  return {
@@ -125,6 +173,21 @@ export class ObyteExactServer implements SchemeNetworkServer {
125
173
  if (ctx.result.success) this.options.proofs.transition(key, 'serving', 'served');
126
174
  else this.options.proofs.transition(key, 'serving', 'settling');
127
175
  },
176
+ // The handler failed or threw after the payment was settled (upfront):
177
+ // the payment stays usable for the SAME operation instead of "already used".
178
+ onVerifiedPaymentCanceled: async (ctx) => {
179
+ const key = this.keyOf(ctx);
180
+ if (key === undefined) return;
181
+ if (ctx.settledPhases.includes('before-handler')) {
182
+ this.options.proofs.transition(key, 'served', 'paid_not_served');
183
+ } else {
184
+ this.options.proofs.transition(key, 'serving', 'settling');
185
+ }
186
+ this.log.warn(
187
+ { reason: ctx.reason },
188
+ 'paid request not served; the same payment can retry',
189
+ );
190
+ },
128
191
  onSettleFailure: async (ctx) => {
129
192
  const key = this.keyOf(ctx);
130
193
  if (key !== undefined) this.options.proofs.transition(key, 'serving', 'settling');
@@ -156,7 +219,7 @@ export class ObyteExactServer implements SchemeNetworkServer {
156
219
  return asset === 'base' ? 9 : undefined;
157
220
  }
158
221
 
159
- /** Nothing to add from /supported for exact; the per-request fields come from the enrich hook. */
222
+ /** Nothing to add from /supported; the per-request fields come from the enrich hook. */
160
223
  async enhancePaymentRequirements(
161
224
  requirements: PaymentRequirements,
162
225
  ): Promise<PaymentRequirements> {
@@ -169,20 +232,21 @@ export class ObyteExactServer implements SchemeNetworkServer {
169
232
  ): Promise<PaymentRequirements[]> => {
170
233
  const request = requestOf(ctx.transportContext);
171
234
  if (request === undefined)
172
- throw new ObbyxError('no_request', 'Obyte exact needs the HTTP request context');
235
+ throw new ObbyxError('no_request', `Obyte ${this.scheme} needs the HTTP request context`);
173
236
  const accepted = this.acceptedQuote(request);
174
237
  return ctx.requirements.map((req) => {
175
- if (req.scheme !== EXACT_SCHEME) return req;
238
+ if (req.scheme !== this.spec.scheme) return req;
176
239
  const quote =
177
240
  accepted !== undefined &&
178
241
  bindingsEqual(this.bindingFor(req, request, accepted), accepted.binding)
179
242
  ? accepted
180
243
  : undefined;
181
- if (quote !== undefined) return { ...req, extra: { ...req.extra, ...quote } };
182
- const fresh = newQuote(this.now(), this.options.quoteTtlSeconds ?? 300);
244
+ const extra = { ...req.extra, ...this.spec.staticExtra };
245
+ if (quote !== undefined) return { ...req, extra: { ...extra, ...quote } };
246
+ const fresh = newQuote(this.now(), this.options.quoteTtlSeconds ?? DEFAULT_QUOTE_TTL_SECONDS);
183
247
  return {
184
248
  ...req,
185
- extra: { ...req.extra, binding: this.bindingFor(req, request, fresh), ...fresh },
249
+ extra: { ...extra, binding: this.bindingFor(req, request, fresh), ...fresh },
186
250
  };
187
251
  });
188
252
  };
@@ -190,10 +254,10 @@ export class ObyteExactServer implements SchemeNetworkServer {
190
254
  private acceptedQuote(request: RequestLike) {
191
255
  if (request.paymentHeader === undefined) return undefined;
192
256
  try {
193
- const extra = exactExtraSchema.safeParse(
194
- decodePaymentSignatureHeader(request.paymentHeader).accepted?.extra,
195
- );
196
- return extra.success ? extra.data : undefined;
257
+ const accepted = decodePaymentSignatureHeader(request.paymentHeader).accepted;
258
+ if (accepted?.scheme !== this.spec.scheme) return undefined;
259
+ const extra = this.spec.acceptedExtra(accepted.extra);
260
+ return { quoteId: extra.quoteId, validUntil: extra.validUntil, binding: extra.binding };
197
261
  } catch {
198
262
  return undefined;
199
263
  }
@@ -202,7 +266,7 @@ export class ObyteExactServer implements SchemeNetworkServer {
202
266
  private bindingFor(req: PaymentRequirements, request: RequestLike, quote: Quote): string {
203
267
  return offerBinding(
204
268
  this.options,
205
- EXACT_SCHEME,
269
+ this.spec.scheme,
206
270
  quote,
207
271
  { method: request.method, url: request.url, bodyHash: bodyHashOf(request.body) },
208
272
  { network: req.network, asset: req.asset, amount: req.amount, payTo: req.payTo },
@@ -214,11 +278,46 @@ export class ObyteExactServer implements SchemeNetworkServer {
214
278
  paymentPayload: { payload: Record<string, unknown> };
215
279
  requirements: { network: string };
216
280
  }): string | undefined {
217
- const unit = (ctx.paymentPayload.payload as { unit?: { unit?: unknown } }).unit?.unit;
218
- return typeof unit === 'string' ? this.key(ctx.requirements.network, unit) : undefined;
281
+ const proof = this.spec.proofOf(ctx.paymentPayload.payload);
282
+ return proof === undefined
283
+ ? undefined
284
+ : networkKey(getNetwork(ctx.requirements.network), proof.kind, proof.id);
219
285
  }
286
+ }
220
287
 
221
- private key(network: string, unit: string): string {
222
- return networkKey(getNetwork(network), 'unit', unit);
288
+ /** `exact`: the proof is the unit (key `<network>:unit:<hash>`, as ObytePaywall). */
289
+ export class ObyteExactServer extends ObyteSchemeServer {
290
+ constructor(options: ObyteSchemeServerOptions) {
291
+ super(options, {
292
+ scheme: EXACT_SCHEME,
293
+ staticExtra: {},
294
+ acceptedExtra: (extra) => exactExtraSchema.parse(extra),
295
+ proofOf: (payload) => {
296
+ const unit = (payload as { unit?: { unit?: unknown } }).unit?.unit;
297
+ return typeof unit === 'string' ? { kind: 'unit', id: unit } : undefined;
298
+ },
299
+ });
300
+ }
301
+ }
302
+
303
+ /**
304
+ * `batch-settlement` (AA payment channels): the proof is the voucher (key
305
+ * `<network>:voucher:<sha256 of the canonical signed package>`, as
306
+ * ObytePaywall). Offers carry the channel base AA and the minimum dispute
307
+ * window. Needs a facilitator with batch-settlement and the seller's watcher.
308
+ */
309
+ export class ObyteBatchServer extends ObyteSchemeServer {
310
+ constructor(options: ObyteBatchServerOptions) {
311
+ super(options, {
312
+ scheme: BATCH_SCHEME,
313
+ staticExtra: { baseAa: CHANNEL_BASE_AA, minTimeout: options.minChannelTimeoutSeconds },
314
+ acceptedExtra: (extra) => batchExtraSchema.parse(extra),
315
+ proofOf: (payload) => {
316
+ const pkg = payload['signedPackage'];
317
+ return pkg !== null && typeof pkg === 'object'
318
+ ? { kind: 'voucher', id: sha256Hex(canonical(pkg)) }
319
+ : undefined;
320
+ },
321
+ });
223
322
  }
224
323
  }