@obbyx/sdk 0.0.0-stage → 0.1.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.
Files changed (53) 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 +78 -0
  20. package/dist/seller/consumed-proofs.d.ts.map +1 -0
  21. package/dist/seller/consumed-proofs.js +150 -0
  22. package/dist/seller/consumed-proofs.js.map +1 -0
  23. package/dist/seller/defaults.d.ts +7 -0
  24. package/dist/seller/defaults.d.ts.map +1 -0
  25. package/dist/seller/defaults.js +7 -0
  26. package/dist/seller/defaults.js.map +1 -0
  27. package/dist/seller/payment-identifier.d.ts +73 -0
  28. package/dist/seller/payment-identifier.d.ts.map +1 -0
  29. package/dist/seller/payment-identifier.js +75 -0
  30. package/dist/seller/payment-identifier.js.map +1 -0
  31. package/dist/seller/paywall.d.ts +101 -0
  32. package/dist/seller/paywall.d.ts.map +1 -0
  33. package/dist/seller/paywall.js +397 -0
  34. package/dist/seller/paywall.js.map +1 -0
  35. package/dist/seller/quote.d.ts +38 -0
  36. package/dist/seller/quote.d.ts.map +1 -0
  37. package/dist/seller/quote.js +39 -0
  38. package/dist/seller/quote.js.map +1 -0
  39. package/dist/seller/x402-server.d.ts +59 -0
  40. package/dist/seller/x402-server.d.ts.map +1 -0
  41. package/dist/seller/x402-server.js +173 -0
  42. package/dist/seller/x402-server.js.map +1 -0
  43. package/package.json +68 -3
  44. package/src/client/budget.ts +88 -0
  45. package/src/client/paying-fetch.ts +256 -0
  46. package/src/index.ts +35 -0
  47. package/src/seller/binding.ts +8 -0
  48. package/src/seller/consumed-proofs.ts +245 -0
  49. package/src/seller/defaults.ts +6 -0
  50. package/src/seller/payment-identifier.ts +104 -0
  51. package/src/seller/paywall.ts +595 -0
  52. package/src/seller/quote.ts +82 -0
  53. package/src/seller/x402-server.ts +251 -0
@@ -0,0 +1,245 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+
3
+ /**
4
+ * Seller-side registry of consumed payment proofs (invariant I3).
5
+ *
6
+ * Keyed by `<caip2>:unit:<hash>`, so it holds even when several APIs or
7
+ * facilitators serve the same payTo. Each proof serves exactly one operation
8
+ * (its binding); a repeat of that operation gets the stored response.
9
+ *
10
+ * upfront: settling → paid_not_served → serving → served
11
+ * └────→ failed └──→ paid_not_served (handler failed)
12
+ * authorization: settling → serving → awaiting_settle → served
13
+ * │ └──────→ failed (handler already ran)
14
+ * └──→ settling (handler failed; nothing settled)
15
+ *
16
+ * serving older than the lease (crash) → paid_not_served | settling (reclaimStale)
17
+ *
18
+ * Transitions are compare-and-set, so concurrent requests with the same proof
19
+ * never run the handler twice.
20
+ */
21
+ export type ProofState =
22
+ | 'settling'
23
+ | 'paid_not_served'
24
+ | 'serving'
25
+ | 'served'
26
+ | 'failed'
27
+ /** authorization flow: the handler ran, its response waits for the settlement. */
28
+ | 'awaiting_settle';
29
+
30
+ export interface StoredResponse {
31
+ readonly status: number;
32
+ readonly headers: Readonly<Record<string, string>>;
33
+ readonly body: Uint8Array;
34
+ }
35
+
36
+ export interface ProofRecord {
37
+ readonly key: string;
38
+ readonly binding: string;
39
+ readonly state: ProofState;
40
+ readonly paymentResponse: string | null;
41
+ readonly response: StoredResponse | null;
42
+ readonly updatedAt: number;
43
+ }
44
+
45
+ interface Row {
46
+ key: string;
47
+ binding: string;
48
+ state: ProofState;
49
+ payment_response: string | null;
50
+ response_status: number | null;
51
+ response_headers: string | null;
52
+ response_body: Uint8Array | null;
53
+ updated_at: number;
54
+ }
55
+
56
+ export class ConsumedProofsStore {
57
+ private readonly db: DatabaseSync;
58
+
59
+ constructor(
60
+ path: string,
61
+ private readonly now: () => number = () => Math.floor(Date.now() / 1000),
62
+ ) {
63
+ this.db = new DatabaseSync(path);
64
+ this.db.exec(`
65
+ PRAGMA journal_mode = WAL;
66
+ PRAGMA synchronous = FULL;
67
+ PRAGMA busy_timeout = 5000;
68
+ CREATE TABLE IF NOT EXISTS consumed_proofs (
69
+ key TEXT PRIMARY KEY,
70
+ binding TEXT NOT NULL,
71
+ state TEXT NOT NULL,
72
+ payment_response TEXT,
73
+ response_status INTEGER,
74
+ response_headers TEXT,
75
+ response_body BLOB,
76
+ created_at INTEGER NOT NULL,
77
+ updated_at INTEGER NOT NULL
78
+ ) STRICT;
79
+ CREATE TABLE IF NOT EXISTS payment_ids (
80
+ scope_id TEXT PRIMARY KEY,
81
+ fingerprint TEXT NOT NULL,
82
+ proof_key TEXT NOT NULL,
83
+ created_at INTEGER NOT NULL
84
+ ) STRICT;
85
+ `);
86
+ }
87
+
88
+ /**
89
+ * x402 `payment-identifier`: remembers which proof served a client-chosen id
90
+ * for one operation fingerprint. Returns the stored mapping (existing or new).
91
+ */
92
+ bindPaymentId(
93
+ scopeId: string,
94
+ fingerprint: string,
95
+ proofKey: string,
96
+ ): { fingerprint: string; proofKey: string; created: boolean } {
97
+ const res = this.db
98
+ .prepare(
99
+ 'INSERT INTO payment_ids (scope_id, fingerprint, proof_key, created_at) VALUES (?, ?, ?, ?) ON CONFLICT(scope_id) DO NOTHING',
100
+ )
101
+ .run(scopeId, fingerprint, proofKey, this.now());
102
+ const row = this.db
103
+ .prepare('SELECT fingerprint, proof_key FROM payment_ids WHERE scope_id = ?')
104
+ .get(scopeId) as {
105
+ fingerprint: string;
106
+ proof_key: string;
107
+ };
108
+ return {
109
+ fingerprint: row.fingerprint,
110
+ proofKey: row.proof_key,
111
+ created: Number(res.changes) === 1,
112
+ };
113
+ }
114
+
115
+ /** Compare-and-set: moves the id only if it still points at `previous` (one winner). */
116
+ rebindPaymentId(scopeId: string, previous: string, proofKey: string): boolean {
117
+ const res = this.db
118
+ .prepare('UPDATE payment_ids SET proof_key = ? WHERE scope_id = ? AND proof_key = ?')
119
+ .run(proofKey, scopeId, previous);
120
+ return Number(res.changes) === 1;
121
+ }
122
+
123
+ close(): void {
124
+ this.db.close();
125
+ }
126
+
127
+ get(key: string): ProofRecord | undefined {
128
+ const row = this.db.prepare('SELECT * FROM consumed_proofs WHERE key = ?').get(key) as
129
+ Row | undefined;
130
+ return row === undefined ? undefined : toRecord(row);
131
+ }
132
+
133
+ /** Inserts the proof in `settling` unless it exists; returns the stored record. */
134
+ begin(key: string, binding: string): { created: boolean; record: ProofRecord } {
135
+ const ts = this.now();
136
+ const res = this.db
137
+ .prepare(
138
+ `INSERT INTO consumed_proofs (key, binding, state, created_at, updated_at)
139
+ VALUES (?, ?, 'settling', ?, ?) ON CONFLICT(key) DO NOTHING`,
140
+ )
141
+ .run(key, binding, ts, ts);
142
+ const record = this.get(key);
143
+ if (record === undefined) throw new Error(`consumed proof ${key} vanished`);
144
+ return { created: Number(res.changes) === 1, record };
145
+ }
146
+
147
+ /** Compare-and-set; returns false if the proof is not in `from`. */
148
+ transition(
149
+ key: string,
150
+ from: ProofState,
151
+ to: ProofState,
152
+ patch: { paymentResponse?: string; response?: StoredResponse } = {},
153
+ ): boolean {
154
+ const res = this.db
155
+ .prepare(
156
+ `UPDATE consumed_proofs
157
+ SET state = ?, updated_at = ?,
158
+ payment_response = COALESCE(?, payment_response),
159
+ response_status = COALESCE(?, response_status),
160
+ response_headers = COALESCE(?, response_headers),
161
+ response_body = COALESCE(?, response_body)
162
+ WHERE key = ? AND state = ?`,
163
+ )
164
+ .run(
165
+ to,
166
+ this.now(),
167
+ patch.paymentResponse ?? null,
168
+ patch.response?.status ?? null,
169
+ patch.response === undefined ? null : JSON.stringify(patch.response.headers),
170
+ patch.response?.body ?? null,
171
+ key,
172
+ from,
173
+ );
174
+ return Number(res.changes) === 1;
175
+ }
176
+
177
+ /**
178
+ * Records a SETTLED payment as paid (`settling → paid_not_served`), also when
179
+ * a concurrent request removed or moved its row meanwhile: the payment did
180
+ * settle, so it must stay servable once. Atomic; returns the stored state.
181
+ */
182
+ markSettled(key: string, binding: string, paymentResponse: string): ProofState {
183
+ const ts = this.now();
184
+ this.db.exec('BEGIN IMMEDIATE');
185
+ try {
186
+ this.db
187
+ .prepare(
188
+ `INSERT INTO consumed_proofs (key, binding, state, payment_response, created_at, updated_at)
189
+ VALUES (?, ?, 'paid_not_served', ?, ?, ?)
190
+ ON CONFLICT(key) DO UPDATE SET state = 'paid_not_served', payment_response = excluded.payment_response,
191
+ updated_at = excluded.updated_at
192
+ WHERE consumed_proofs.state = 'settling'`,
193
+ )
194
+ .run(key, binding, paymentResponse, ts, ts);
195
+ this.db.exec('COMMIT');
196
+ } catch (err) {
197
+ this.db.exec('ROLLBACK');
198
+ throw err;
199
+ }
200
+ const record = this.get(key);
201
+ if (record === undefined) throw new Error(`consumed proof ${key} vanished`);
202
+ return record.state;
203
+ }
204
+
205
+ /**
206
+ * Recovers a proof stuck in `serving` (the process died while the handler
207
+ * ran): after `leaseSeconds` it goes back to `paid_not_served` (already
208
+ * settled) or `settling` (authorization flow, not settled yet), so the SAME
209
+ * payment can complete the operation. Compare-and-set: one winner.
210
+ */
211
+ reclaimStale(key: string, leaseSeconds: number): boolean {
212
+ const res = this.db
213
+ .prepare(
214
+ `UPDATE consumed_proofs
215
+ SET state = CASE WHEN payment_response IS NULL THEN 'settling' ELSE 'paid_not_served' END,
216
+ updated_at = ?
217
+ WHERE key = ? AND state = 'serving' AND updated_at <= ?`,
218
+ )
219
+ .run(this.now(), key, this.now() - leaseSeconds);
220
+ return Number(res.changes) === 1;
221
+ }
222
+
223
+ /** Removes a proof that never reached the network (e.g. rejected at verification). */
224
+ forget(key: string, state: ProofState): void {
225
+ this.db.prepare('DELETE FROM consumed_proofs WHERE key = ? AND state = ?').run(key, state);
226
+ }
227
+ }
228
+
229
+ function toRecord(row: Row): ProofRecord {
230
+ return {
231
+ key: row.key,
232
+ binding: row.binding,
233
+ state: row.state,
234
+ paymentResponse: row.payment_response,
235
+ response:
236
+ row.response_status === null
237
+ ? null
238
+ : {
239
+ status: row.response_status,
240
+ headers: JSON.parse(row.response_headers ?? '{}') as Record<string, string>,
241
+ body: row.response_body ?? new Uint8Array(),
242
+ },
243
+ updatedAt: row.updated_at,
244
+ };
245
+ }
@@ -0,0 +1,6 @@
1
+ /** Seller-side defaults shared by ObytePaywall and ObyteExactServer (seconds). */
2
+ export const DEFAULT_QUOTE_TTL_SECONDS = 300;
3
+ /** Freshness window of the payment unit's timestamp offered in `maxTimeoutSeconds`. */
4
+ export const DEFAULT_MAX_TIMEOUT_SECONDS = 300;
5
+ /** How long a request may hold a payment in `serving` before a retry takes it over. */
6
+ export const DEFAULT_SERVING_LEASE_SECONDS = 300;
@@ -0,0 +1,104 @@
1
+ import type { PaymentPayload } from '@x402/core/types';
2
+ import { z } from 'zod';
3
+
4
+ /**
5
+ * x402 `payment-identifier` extension (seller side): a client-chosen id makes
6
+ * a repeated operation return the stored response instead of charging again.
7
+ */
8
+ export const PAYMENT_IDENTIFIER = 'payment-identifier';
9
+
10
+ /** Same constraints as @x402/extensions (16–128 chars of [A-Za-z0-9_-]). */
11
+ export const paymentIdSchema = z.string().regex(/^[a-zA-Z0-9_-]{16,128}$/);
12
+
13
+ const extensionSchema = z
14
+ .object({ info: z.object({ id: z.unknown().optional() }).loose() })
15
+ .loose();
16
+
17
+ /** Same declaration as @x402/extensions `declarePaymentIdentifierExtension`. */
18
+ export function paymentIdentifierDeclaration(required: boolean) {
19
+ return {
20
+ info: { required },
21
+ schema: {
22
+ $schema: 'https://json-schema.org/draft/2020-12/schema',
23
+ type: 'object',
24
+ properties: {
25
+ required: { type: 'boolean' },
26
+ id: { type: 'string', minLength: 16, maxLength: 128, pattern: '^[a-zA-Z0-9_-]+$' },
27
+ },
28
+ required: ['required'],
29
+ },
30
+ };
31
+ }
32
+
33
+ /** The client's id: `undefined` when absent, `'invalid'` when malformed. */
34
+ export function paymentIdOf(payload: PaymentPayload): string | undefined | 'invalid' {
35
+ const raw = payload.extensions?.[PAYMENT_IDENTIFIER];
36
+ if (raw === undefined) return undefined;
37
+ const ext = extensionSchema.safeParse(raw);
38
+ if (!ext.success) return 'invalid';
39
+ const id = ext.data.info.id;
40
+ if (id === undefined) return undefined;
41
+ const parsed = paymentIdSchema.safeParse(id);
42
+ return parsed.success ? parsed.data : 'invalid';
43
+ }
44
+
45
+ /** What the store needs for payment-identifier decisions. */
46
+ export interface PaymentIdStore {
47
+ bindPaymentId(
48
+ scopeId: string,
49
+ fingerprint: string,
50
+ proofKey: string,
51
+ ): { fingerprint: string; proofKey: string; created: boolean };
52
+ /** Compare-and-set: moves the id to `proofKey` only if it still points at `previous`. */
53
+ rebindPaymentId(scopeId: string, previous: string, proofKey: string): boolean;
54
+ get(
55
+ key: string,
56
+ ): { state: string; response: unknown; paymentResponse: string | null } | undefined;
57
+ }
58
+
59
+ export type PaymentIdDecision =
60
+ | { kind: 'proceed' }
61
+ | { kind: 'replay'; proofKey: string }
62
+ | { kind: 'reject'; status: 400 | 409; error: string };
63
+
64
+ /**
65
+ * One client id = one operation. Contract: the caller has already created the
66
+ * proof row for `proofKey` (so other requests see it as in progress).
67
+ * Concurrent retries with DIFFERENT payments
68
+ * for the same id: exactly one wins the compare-and-set; the others get 409,
69
+ * so an id can never be charged twice.
70
+ */
71
+ export function decidePaymentId(
72
+ store: PaymentIdStore,
73
+ input: {
74
+ paymentId: string | undefined | 'invalid';
75
+ required: boolean;
76
+ scopeId: (id: string) => string;
77
+ fingerprint: string;
78
+ proofKey: string;
79
+ },
80
+ ): PaymentIdDecision {
81
+ const { paymentId } = input;
82
+ if (paymentId === 'invalid')
83
+ return { kind: 'reject', status: 400, error: 'invalid_payment_identifier' };
84
+ if (paymentId === undefined) {
85
+ return input.required
86
+ ? { kind: 'reject', status: 400, error: 'payment_identifier_required' }
87
+ : { kind: 'proceed' };
88
+ }
89
+ const scopeId = input.scopeId(paymentId);
90
+ const mapping = store.bindPaymentId(scopeId, input.fingerprint, input.proofKey);
91
+ if (mapping.fingerprint !== input.fingerprint) {
92
+ return { kind: 'reject', status: 409, error: 'payment_identifier_conflict' };
93
+ }
94
+ if (mapping.proofKey === input.proofKey) return { kind: 'proceed' };
95
+ const original = store.get(mapping.proofKey);
96
+ if (original?.state === 'served') return { kind: 'replay', proofKey: mapping.proofKey };
97
+ if (original !== undefined && original.state !== 'failed') {
98
+ return { kind: 'reject', status: 409, error: 'payment_in_progress' };
99
+ }
100
+ // The earlier attempt definitely failed (or never consumed its proof): move the id.
101
+ return store.rebindPaymentId(scopeId, mapping.proofKey, input.proofKey)
102
+ ? { kind: 'proceed' }
103
+ : { kind: 'reject', status: 409, error: 'payment_in_progress' };
104
+ }