@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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +89 -2
  3. package/dist/client/budget.d.ts +43 -0
  4. package/dist/client/budget.d.ts.map +1 -0
  5. package/dist/client/budget.js +56 -0
  6. package/dist/client/budget.js.map +1 -0
  7. package/dist/client/paying-fetch.d.ts +46 -0
  8. package/dist/client/paying-fetch.d.ts.map +1 -0
  9. package/dist/client/paying-fetch.js +186 -0
  10. package/dist/client/paying-fetch.js.map +1 -0
  11. package/dist/index.d.ts +7 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +7 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/seller/binding.d.ts +2 -0
  16. package/dist/seller/binding.d.ts.map +1 -0
  17. package/dist/seller/binding.js +3 -0
  18. package/dist/seller/binding.js.map +1 -0
  19. package/dist/seller/consumed-proofs.d.ts +72 -0
  20. package/dist/seller/consumed-proofs.d.ts.map +1 -0
  21. package/dist/seller/consumed-proofs.js +123 -0
  22. package/dist/seller/consumed-proofs.js.map +1 -0
  23. package/dist/seller/payment-identifier.d.ts +73 -0
  24. package/dist/seller/payment-identifier.d.ts.map +1 -0
  25. package/dist/seller/payment-identifier.js +75 -0
  26. package/dist/seller/payment-identifier.js.map +1 -0
  27. package/dist/seller/paywall.d.ts +99 -0
  28. package/dist/seller/paywall.d.ts.map +1 -0
  29. package/dist/seller/paywall.js +372 -0
  30. package/dist/seller/paywall.js.map +1 -0
  31. package/dist/seller/quote.d.ts +38 -0
  32. package/dist/seller/quote.d.ts.map +1 -0
  33. package/dist/seller/quote.js +39 -0
  34. package/dist/seller/quote.js.map +1 -0
  35. package/dist/seller/x402-server.d.ts +57 -0
  36. package/dist/seller/x402-server.d.ts.map +1 -0
  37. package/dist/seller/x402-server.js +152 -0
  38. package/dist/seller/x402-server.js.map +1 -0
  39. package/package.json +68 -3
  40. package/src/client/budget.ts +88 -0
  41. package/src/client/paying-fetch.ts +256 -0
  42. package/src/index.ts +35 -0
  43. package/src/seller/binding.ts +8 -0
  44. package/src/seller/consumed-proofs.ts +217 -0
  45. package/src/seller/payment-identifier.ts +104 -0
  46. package/src/seller/paywall.ts +566 -0
  47. package/src/seller/quote.ts +82 -0
  48. package/src/seller/x402-server.ts +224 -0
@@ -0,0 +1,224 @@
1
+ import { decodePaymentSignatureHeader } from '@x402/core/http';
2
+ import type {
3
+ AssetAmount,
4
+ Network,
5
+ PaymentRequirements,
6
+ Price,
7
+ SchemeNetworkServer,
8
+ SchemePaymentRequiredContext,
9
+ SchemeServerHooks,
10
+ } from '@x402/core/types';
11
+ import {
12
+ ObbyxError,
13
+ fromDisplay,
14
+ getNetwork,
15
+ isAssetId,
16
+ networkKey,
17
+ parseAmount,
18
+ } from '@obbyx/obyte';
19
+ import { type Logger, moduleLogger } from '@obbyx/obyte/logger';
20
+ import { EXACT_SCHEME, bindingsEqual, exactExtraSchema } from '@obbyx/x402-obyte';
21
+ import type { ConsumedProofsStore } from './consumed-proofs.js';
22
+ import { type Quote, bodyHashOf, newQuote, offerBinding } from './quote.js';
23
+
24
+ /**
25
+ * Obyte `exact` for the OFFICIAL x402 resource server (`@x402/core/server`,
26
+ * `@x402/express`, `@x402/hono`, …): a SchemeNetworkServer.
27
+ *
28
+ * - Binding (I1/I2): `enrichPaymentRequiredResponse` adds a per-request HMAC
29
+ * binding + quote to every offer. On a paid request it recomputes the HMAC
30
+ * from the request and the quote the client accepted; only if it matches are
31
+ * those fields reused (declared in `dynamicExtraFields`), so the facilitator
32
+ * then sees exactly the requirements the client signed. Otherwise a fresh
33
+ * binding is issued and the facilitator refuses the payment.
34
+ * - One payment, one operation (I3): the official middleware keeps no
35
+ * consumed-proof registry, so the scheme hooks do — `onBeforeVerify` refuses
36
+ * a proof that already served a request, `onAfterSettle` marks it served.
37
+ * Unlike `@obbyx/sdk`'s ObytePaywall, a repeated request gets a 402 instead
38
+ * of the stored response (the official middleware cannot replay responses).
39
+ */
40
+ export interface ObyteExactServerOptions {
41
+ readonly sellerId: string;
42
+ /** HMAC key for bindings (≥ 32 bytes). */
43
+ readonly secret: string | Uint8Array;
44
+ readonly proofs: ConsumedProofsStore;
45
+ readonly logger: Logger;
46
+ readonly quoteTtlSeconds?: number;
47
+ readonly now?: () => number;
48
+ }
49
+
50
+ interface RequestLike {
51
+ readonly method: string;
52
+ readonly url: string;
53
+ readonly body: unknown;
54
+ readonly paymentHeader: string | undefined;
55
+ }
56
+
57
+ function requestOf(transportContext: unknown): RequestLike | undefined {
58
+ const request = (transportContext as { request?: Record<string, unknown> } | undefined)?.request;
59
+ const adapter = request?.['adapter'] as
60
+ { getMethod(): string; getUrl(): string; getBody?(): unknown } | undefined;
61
+ if (adapter === undefined) return undefined;
62
+ const url = new URL(adapter.getUrl(), 'http://localhost');
63
+ return {
64
+ method: adapter.getMethod(),
65
+ url: url.pathname + url.search,
66
+ body: adapter.getBody?.(),
67
+ paymentHeader:
68
+ typeof request?.['paymentHeader'] === 'string' ? request['paymentHeader'] : undefined,
69
+ };
70
+ }
71
+
72
+ export class ObyteExactServer implements SchemeNetworkServer {
73
+ readonly scheme = EXACT_SCHEME;
74
+ readonly defaultAssetTransferMethod = 'default';
75
+ readonly paymentFlows = {
76
+ default: { supported: ['upfront'] as const, default: 'upfront' as const },
77
+ };
78
+ readonly dynamicExtraFields = ['binding', 'quoteId', 'validUntil'];
79
+ readonly schemeHooks: SchemeServerHooks;
80
+ private readonly log: Logger;
81
+ private readonly now: () => number;
82
+
83
+ constructor(private readonly options: ObyteExactServerOptions) {
84
+ this.log = moduleLogger(options.logger, 'sdk.x402-server', { scheme: EXACT_SCHEME });
85
+ this.now = options.now ?? (() => Math.floor(Date.now() / 1000));
86
+ this.schemeHooks = {
87
+ onBeforeVerify: async (ctx) => {
88
+ const unit = (ctx.paymentPayload.payload as { unit?: { unit?: unknown } }).unit?.unit;
89
+ const binding = (ctx.requirements.extra as { binding?: unknown }).binding;
90
+ if (typeof unit !== 'string' || typeof binding !== 'string') {
91
+ return { abort: true, reason: 'invalid_payload' };
92
+ }
93
+ const { record } = this.options.proofs.begin(
94
+ this.key(ctx.requirements.network, unit),
95
+ binding,
96
+ );
97
+ if (!bindingsEqual(record.binding, binding))
98
+ return { abort: true, reason: 'invalid_payment_reused' };
99
+ if (record.state === 'served') {
100
+ this.log.warn({ proofId: unit }, 'payment already served a request; refused');
101
+ return { abort: true, reason: 'payment_already_used' };
102
+ }
103
+ return undefined;
104
+ },
105
+ // Claim the proof atomically (settling → serving) before settling, so two
106
+ // concurrent requests with the same payment can never both reach the
107
+ // handler: the official middleware runs the handler after a successful
108
+ // settle, and the facilitator answers success for both (idempotent).
109
+ onBeforeSettle: async (ctx) => {
110
+ const key = this.keyOf(ctx);
111
+ if (key === undefined) return { abort: true, reason: 'invalid_payload' };
112
+ if (this.options.proofs.transition(key, 'settling', 'serving')) return undefined;
113
+ const state = this.options.proofs.get(key)?.state;
114
+ this.log.warn({ state }, 'payment already in use by another request; refused');
115
+ return {
116
+ abort: true,
117
+ reason: state === 'served' ? 'payment_already_used' : 'payment_in_progress',
118
+ };
119
+ },
120
+ onAfterSettle: async (ctx) => {
121
+ const key = this.keyOf(ctx);
122
+ if (key === undefined) return;
123
+ // Success: one operation consumed the proof. Otherwise (pending, failed):
124
+ // release the claim so the SAME payment can be retried.
125
+ if (ctx.result.success) this.options.proofs.transition(key, 'serving', 'served');
126
+ else this.options.proofs.transition(key, 'serving', 'settling');
127
+ },
128
+ onSettleFailure: async (ctx) => {
129
+ const key = this.keyOf(ctx);
130
+ if (key !== undefined) this.options.proofs.transition(key, 'serving', 'settling');
131
+ },
132
+ };
133
+ }
134
+
135
+ /** `{asset, amount}` in atomic units, or "20000 bytes" / "0.00002 GBYTE". */
136
+ async parsePrice(price: Price, network: Network): Promise<AssetAmount> {
137
+ getNetwork(network);
138
+ if (typeof price === 'object') {
139
+ if (!isAssetId(price.asset))
140
+ throw new ObbyxError('invalid_price', `Invalid asset ${price.asset}`);
141
+ return { asset: price.asset, amount: parseAmount(price.amount).toString() };
142
+ }
143
+ const [, value, unit] =
144
+ /^\s*([0-9]+(?:\.[0-9]+)?)\s*(bytes|gbyte)\s*$/i.exec(String(price)) ?? [];
145
+ if (value === undefined || unit === undefined) {
146
+ throw new ObbyxError(
147
+ 'invalid_price',
148
+ 'Use {asset, amount} or "<n> bytes" / "<x> GBYTE" (no USD default asset on Obyte yet)',
149
+ );
150
+ }
151
+ const amount = unit.toLowerCase() === 'bytes' ? parseAmount(value) : fromDisplay(value, 9);
152
+ return { asset: 'base', amount: amount.toString() };
153
+ }
154
+
155
+ getAssetDecimals(asset: string): number | undefined {
156
+ return asset === 'base' ? 9 : undefined;
157
+ }
158
+
159
+ /** Nothing to add from /supported for exact; the per-request fields come from the enrich hook. */
160
+ async enhancePaymentRequirements(
161
+ requirements: PaymentRequirements,
162
+ ): Promise<PaymentRequirements> {
163
+ return requirements;
164
+ }
165
+
166
+ /** Adds (or, for a valid paid request, re-confirms) the per-request binding and quote. */
167
+ enrichPaymentRequiredResponse = async (
168
+ ctx: SchemePaymentRequiredContext,
169
+ ): Promise<PaymentRequirements[]> => {
170
+ const request = requestOf(ctx.transportContext);
171
+ if (request === undefined)
172
+ throw new ObbyxError('no_request', 'Obyte exact needs the HTTP request context');
173
+ const accepted = this.acceptedQuote(request);
174
+ return ctx.requirements.map((req) => {
175
+ if (req.scheme !== EXACT_SCHEME) return req;
176
+ const quote =
177
+ accepted !== undefined &&
178
+ bindingsEqual(this.bindingFor(req, request, accepted), accepted.binding)
179
+ ? accepted
180
+ : undefined;
181
+ if (quote !== undefined) return { ...req, extra: { ...req.extra, ...quote } };
182
+ const fresh = newQuote(this.now(), this.options.quoteTtlSeconds ?? 300);
183
+ return {
184
+ ...req,
185
+ extra: { ...req.extra, binding: this.bindingFor(req, request, fresh), ...fresh },
186
+ };
187
+ });
188
+ };
189
+
190
+ private acceptedQuote(request: RequestLike) {
191
+ if (request.paymentHeader === undefined) return undefined;
192
+ try {
193
+ const extra = exactExtraSchema.safeParse(
194
+ decodePaymentSignatureHeader(request.paymentHeader).accepted?.extra,
195
+ );
196
+ return extra.success ? extra.data : undefined;
197
+ } catch {
198
+ return undefined;
199
+ }
200
+ }
201
+
202
+ private bindingFor(req: PaymentRequirements, request: RequestLike, quote: Quote): string {
203
+ return offerBinding(
204
+ this.options,
205
+ EXACT_SCHEME,
206
+ quote,
207
+ { method: request.method, url: request.url, bodyHash: bodyHashOf(request.body) },
208
+ { network: req.network, asset: req.asset, amount: req.amount, payTo: req.payTo },
209
+ );
210
+ }
211
+
212
+ /** Proof key of the payment in a hook context (undefined when malformed). */
213
+ private keyOf(ctx: {
214
+ paymentPayload: { payload: Record<string, unknown> };
215
+ requirements: { network: string };
216
+ }): 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;
219
+ }
220
+
221
+ private key(network: string, unit: string): string {
222
+ return networkKey(getNetwork(network), 'unit', unit);
223
+ }
224
+ }