@obbyx/x402-obyte 0.0.0-stage → 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.
Files changed (128) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +65 -2
  3. package/dist/batch/client-store.d.ts +50 -0
  4. package/dist/batch/client-store.d.ts.map +1 -0
  5. package/dist/batch/client-store.js +93 -0
  6. package/dist/batch/client-store.js.map +1 -0
  7. package/dist/batch/client.d.ts +63 -0
  8. package/dist/batch/client.d.ts.map +1 -0
  9. package/dist/batch/client.js +226 -0
  10. package/dist/batch/client.js.map +1 -0
  11. package/dist/batch/facilitator.d.ts +73 -0
  12. package/dist/batch/facilitator.d.ts.map +1 -0
  13. package/dist/batch/facilitator.js +341 -0
  14. package/dist/batch/facilitator.js.map +1 -0
  15. package/dist/batch/gateway.d.ts +21 -0
  16. package/dist/batch/gateway.d.ts.map +1 -0
  17. package/dist/batch/gateway.js +2 -0
  18. package/dist/batch/gateway.js.map +1 -0
  19. package/dist/batch/requirements.d.ts +52 -0
  20. package/dist/batch/requirements.d.ts.map +1 -0
  21. package/dist/batch/requirements.js +80 -0
  22. package/dist/batch/requirements.js.map +1 -0
  23. package/dist/batch/store.d.ts +112 -0
  24. package/dist/batch/store.d.ts.map +1 -0
  25. package/dist/batch/store.js +198 -0
  26. package/dist/batch/store.js.map +1 -0
  27. package/dist/batch/watcher-protocol.d.ts +91 -0
  28. package/dist/batch/watcher-protocol.d.ts.map +1 -0
  29. package/dist/batch/watcher-protocol.js +82 -0
  30. package/dist/batch/watcher-protocol.js.map +1 -0
  31. package/dist/binding/binding.d.ts +32 -0
  32. package/dist/binding/binding.d.ts.map +1 -0
  33. package/dist/binding/binding.js +19 -0
  34. package/dist/binding/binding.js.map +1 -0
  35. package/dist/coins/composer.d.ts +58 -0
  36. package/dist/coins/composer.d.ts.map +1 -0
  37. package/dist/coins/composer.js +102 -0
  38. package/dist/coins/composer.js.map +1 -0
  39. package/dist/coins/index.d.ts +4 -0
  40. package/dist/coins/index.d.ts.map +1 -0
  41. package/dist/coins/index.js +4 -0
  42. package/dist/coins/index.js.map +1 -0
  43. package/dist/coins/reservations.d.ts +37 -0
  44. package/dist/coins/reservations.d.ts.map +1 -0
  45. package/dist/coins/reservations.js +102 -0
  46. package/dist/coins/reservations.js.map +1 -0
  47. package/dist/coins/selection.d.ts +42 -0
  48. package/dist/coins/selection.d.ts.map +1 -0
  49. package/dist/coins/selection.js +60 -0
  50. package/dist/coins/selection.js.map +1 -0
  51. package/dist/exact/client.d.ts +33 -0
  52. package/dist/exact/client.d.ts.map +1 -0
  53. package/dist/exact/client.js +93 -0
  54. package/dist/exact/client.js.map +1 -0
  55. package/dist/exact/facilitator-v1.d.ts +48 -0
  56. package/dist/exact/facilitator-v1.d.ts.map +1 -0
  57. package/dist/exact/facilitator-v1.js +81 -0
  58. package/dist/exact/facilitator-v1.js.map +1 -0
  59. package/dist/exact/facilitator.d.ts +94 -0
  60. package/dist/exact/facilitator.d.ts.map +1 -0
  61. package/dist/exact/facilitator.js +361 -0
  62. package/dist/exact/facilitator.js.map +1 -0
  63. package/dist/exact/finality.d.ts +18 -0
  64. package/dist/exact/finality.d.ts.map +1 -0
  65. package/dist/exact/finality.js +45 -0
  66. package/dist/exact/finality.js.map +1 -0
  67. package/dist/exact/ledger-checks.d.ts +15 -0
  68. package/dist/exact/ledger-checks.d.ts.map +1 -0
  69. package/dist/exact/ledger-checks.js +85 -0
  70. package/dist/exact/ledger-checks.js.map +1 -0
  71. package/dist/exact/reasons.d.ts +29 -0
  72. package/dist/exact/reasons.d.ts.map +1 -0
  73. package/dist/exact/reasons.js +21 -0
  74. package/dist/exact/reasons.js.map +1 -0
  75. package/dist/exact/requirements.d.ts +37 -0
  76. package/dist/exact/requirements.d.ts.map +1 -0
  77. package/dist/exact/requirements.js +80 -0
  78. package/dist/exact/requirements.js.map +1 -0
  79. package/dist/exact/store.d.ts +136 -0
  80. package/dist/exact/store.d.ts.map +1 -0
  81. package/dist/exact/store.js +217 -0
  82. package/dist/exact/store.js.map +1 -0
  83. package/dist/hub/heartbeat.d.ts +12 -0
  84. package/dist/hub/heartbeat.d.ts.map +1 -0
  85. package/dist/hub/heartbeat.js +17 -0
  86. package/dist/hub/heartbeat.js.map +1 -0
  87. package/dist/hub/hub-call.d.ts +20 -0
  88. package/dist/hub/hub-call.d.ts.map +1 -0
  89. package/dist/hub/hub-call.js +30 -0
  90. package/dist/hub/hub-call.js.map +1 -0
  91. package/dist/hub/schemas.d.ts +41 -0
  92. package/dist/hub/schemas.d.ts.map +1 -0
  93. package/dist/hub/schemas.js +71 -0
  94. package/dist/hub/schemas.js.map +1 -0
  95. package/dist/index.d.ts +19 -0
  96. package/dist/index.d.ts.map +1 -0
  97. package/dist/index.js +19 -0
  98. package/dist/index.js.map +1 -0
  99. package/dist/util/keyed-queue.d.ts +13 -0
  100. package/dist/util/keyed-queue.d.ts.map +1 -0
  101. package/dist/util/keyed-queue.js +25 -0
  102. package/dist/util/keyed-queue.js.map +1 -0
  103. package/package.json +51 -3
  104. package/src/batch/client-store.ts +167 -0
  105. package/src/batch/client.ts +317 -0
  106. package/src/batch/facilitator.ts +461 -0
  107. package/src/batch/gateway.ts +22 -0
  108. package/src/batch/requirements.ts +108 -0
  109. package/src/batch/store.ts +359 -0
  110. package/src/batch/watcher-protocol.ts +89 -0
  111. package/src/binding/binding.ts +50 -0
  112. package/src/coins/composer.ts +159 -0
  113. package/src/coins/index.ts +9 -0
  114. package/src/coins/reservations.ts +139 -0
  115. package/src/coins/selection.ts +93 -0
  116. package/src/exact/client.ts +144 -0
  117. package/src/exact/facilitator-v1.ts +137 -0
  118. package/src/exact/facilitator.ts +496 -0
  119. package/src/exact/finality.ts +56 -0
  120. package/src/exact/ledger-checks.ts +113 -0
  121. package/src/exact/reasons.ts +25 -0
  122. package/src/exact/requirements.ts +109 -0
  123. package/src/exact/store.ts +399 -0
  124. package/src/hub/heartbeat.ts +18 -0
  125. package/src/hub/hub-call.ts +46 -0
  126. package/src/hub/schemas.ts +89 -0
  127. package/src/index.ts +111 -0
  128. package/src/util/keyed-queue.ts +25 -0
@@ -0,0 +1,359 @@
1
+ import type { DatabaseSync } from 'node:sqlite';
2
+ import { ObbyxError } from '@obbyx/obyte';
3
+
4
+ /**
5
+ * Facilitator-side storage for `batch-settlement` (ADR-0003), sharing the
6
+ * facilitator's SQLite connection (one process per database file).
7
+ *
8
+ * - `batch_channels`: per (network, aa) the committed cumulative amount for the
9
+ * current period and at most ONE pending commitment (serialization, MVP).
10
+ * - `batch_commitments`: the journal. `seq` is global and monotonic; the seller's
11
+ * watcher reads it by cursor. An entry is `pending` until the watcher has
12
+ * durably stored it (ack), then `committed`.
13
+ *
14
+ * Amounts are decimal strings (BigInt in code).
15
+ */
16
+ export interface ChannelRow {
17
+ readonly key: string;
18
+ readonly network: string;
19
+ readonly aa: string;
20
+ readonly payTo: string;
21
+ readonly payer: string;
22
+ readonly asset: string;
23
+ readonly period: number;
24
+ readonly committed: bigint;
25
+ readonly pendingSeq: number | null;
26
+ }
27
+
28
+ export type CommitmentState = 'pending' | 'committed';
29
+
30
+ export interface CommitmentRow {
31
+ readonly seq: number;
32
+ readonly channelKey: string;
33
+ readonly network: string;
34
+ readonly aa: string;
35
+ readonly payTo: string;
36
+ readonly period: number;
37
+ readonly amountSpent: bigint;
38
+ readonly paymentAmount: bigint;
39
+ readonly binding: string;
40
+ readonly packageHash: string;
41
+ readonly packageJson: string;
42
+ readonly state: CommitmentState;
43
+ }
44
+
45
+ export type AppendResult =
46
+ | { ok: true; commitment: CommitmentRow }
47
+ | {
48
+ ok: false;
49
+ reason: 'channel_busy' | 'cumulative_amount_mismatch' | 'period_mismatch';
50
+ committed: bigint;
51
+ };
52
+
53
+ export interface AppendInput {
54
+ channelKey: string;
55
+ network: string;
56
+ aa: string;
57
+ payTo: string;
58
+ payer: string;
59
+ asset: string;
60
+ period: number;
61
+ amountSpent: bigint;
62
+ paymentAmount: bigint;
63
+ binding: string;
64
+ packageHash: string;
65
+ packageJson: string;
66
+ }
67
+
68
+ /** Port for batch-settlement storage (node:sqlite here, Prisma in apps/facilitator). */
69
+ export interface BatchStore {
70
+ channel(key: string): Promise<ChannelRow | undefined>;
71
+ commitmentByHash(packageHash: string): Promise<CommitmentRow | undefined>;
72
+ commitment(seq: number): Promise<CommitmentRow | undefined>;
73
+ journal(payTo: string, cursor: number, limit?: number): Promise<CommitmentRow[]>;
74
+ /**
75
+ * Every still-pending entry of a seller, whatever its seq: on reconnect they
76
+ * are re-delivered even if the watcher's cursor is past them (stored but the
77
+ * ack was lost, or stored out of order) — otherwise they would stay pending
78
+ * forever and block their channels.
79
+ */
80
+ pending(payTo: string): Promise<CommitmentRow[]>;
81
+ /** Atomically: check the channel is free and the cumulative rule, append a pending entry. */
82
+ appendPending(input: AppendInput): Promise<AppendResult>;
83
+ /**
84
+ * pending → committed (idempotent); releases the channel. Only commits a
85
+ * voucher of `owner` (seller + network): a watcher can never ack — and thus
86
+ * commit — another seller's voucher. Returns undefined if not owned.
87
+ */
88
+ commit(
89
+ seq: number,
90
+ owner: { payTo: string; network: string },
91
+ ): Promise<CommitmentRow | undefined>;
92
+ }
93
+
94
+ const SCHEMA = `
95
+ CREATE TABLE IF NOT EXISTS batch_channels (
96
+ key TEXT PRIMARY KEY,
97
+ network TEXT NOT NULL,
98
+ aa TEXT NOT NULL,
99
+ pay_to TEXT NOT NULL,
100
+ payer TEXT NOT NULL,
101
+ asset TEXT NOT NULL,
102
+ period INTEGER NOT NULL,
103
+ committed TEXT NOT NULL,
104
+ pending_seq INTEGER,
105
+ updated_at INTEGER NOT NULL
106
+ ) STRICT;
107
+ CREATE TABLE IF NOT EXISTS batch_commitments (
108
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
109
+ channel_key TEXT NOT NULL,
110
+ network TEXT NOT NULL,
111
+ aa TEXT NOT NULL,
112
+ pay_to TEXT NOT NULL,
113
+ period INTEGER NOT NULL,
114
+ amount_spent TEXT NOT NULL,
115
+ payment_amount TEXT NOT NULL,
116
+ binding TEXT NOT NULL,
117
+ package_hash TEXT NOT NULL UNIQUE,
118
+ package_json TEXT NOT NULL,
119
+ state TEXT NOT NULL,
120
+ created_at INTEGER NOT NULL,
121
+ committed_at INTEGER
122
+ ) STRICT;
123
+ CREATE INDEX IF NOT EXISTS batch_commitments_seller ON batch_commitments(pay_to, seq);
124
+ `;
125
+
126
+ interface ChannelDbRow {
127
+ key: string;
128
+ network: string;
129
+ aa: string;
130
+ pay_to: string;
131
+ payer: string;
132
+ asset: string;
133
+ period: number;
134
+ committed: string;
135
+ pending_seq: number | null;
136
+ }
137
+
138
+ interface CommitmentDbRow {
139
+ seq: number;
140
+ channel_key: string;
141
+ network: string;
142
+ aa: string;
143
+ pay_to: string;
144
+ period: number;
145
+ amount_spent: string;
146
+ payment_amount: string;
147
+ binding: string;
148
+ package_hash: string;
149
+ package_json: string;
150
+ state: CommitmentState;
151
+ }
152
+
153
+ export class SqliteBatchStore implements BatchStore {
154
+ constructor(
155
+ private readonly db: DatabaseSync,
156
+ private readonly now: () => number = () => Math.floor(Date.now() / 1000),
157
+ ) {
158
+ db.exec(SCHEMA);
159
+ }
160
+
161
+ async channel(key: string): Promise<ChannelRow | undefined> {
162
+ return this.channelSync(key);
163
+ }
164
+
165
+ private channelSync(key: string): ChannelRow | undefined {
166
+ const row = this.db.prepare('SELECT * FROM batch_channels WHERE key = ?').get(key) as
167
+ ChannelDbRow | undefined;
168
+ return row === undefined ? undefined : toChannel(row);
169
+ }
170
+
171
+ async commitmentByHash(packageHash: string): Promise<CommitmentRow | undefined> {
172
+ const row = this.db
173
+ .prepare('SELECT * FROM batch_commitments WHERE package_hash = ?')
174
+ .get(packageHash) as CommitmentDbRow | undefined;
175
+ return row === undefined ? undefined : toCommitment(row);
176
+ }
177
+
178
+ async commitment(seq: number): Promise<CommitmentRow | undefined> {
179
+ return this.commitmentSync(seq);
180
+ }
181
+
182
+ private commitmentSync(seq: number): CommitmentRow | undefined {
183
+ const row = this.db.prepare('SELECT * FROM batch_commitments WHERE seq = ?').get(seq) as
184
+ CommitmentDbRow | undefined;
185
+ return row === undefined ? undefined : toCommitment(row);
186
+ }
187
+
188
+ /** Journal for one seller after `cursor` (both pending and committed). */
189
+ async pending(payTo: string): Promise<CommitmentRow[]> {
190
+ const rows = this.db
191
+ .prepare(
192
+ "SELECT * FROM batch_commitments WHERE pay_to = ? AND state = 'pending' ORDER BY seq",
193
+ )
194
+ .all(payTo) as unknown as CommitmentDbRow[];
195
+ return rows.map(toCommitment);
196
+ }
197
+
198
+ async journal(payTo: string, cursor: number, limit = 500): Promise<CommitmentRow[]> {
199
+ const rows = this.db
200
+ .prepare('SELECT * FROM batch_commitments WHERE pay_to = ? AND seq > ? ORDER BY seq LIMIT ?')
201
+ .all(payTo, cursor, limit) as unknown as CommitmentDbRow[];
202
+ return rows.map(toCommitment);
203
+ }
204
+
205
+ /**
206
+ * Appends a pending commitment if the channel is free and the cumulative
207
+ * rule holds against the stored state; all in one IMMEDIATE transaction.
208
+ * A new period (the channel was closed and reopened) resets `committed`.
209
+ */
210
+ async appendPending(input: AppendInput): Promise<AppendResult> {
211
+ return this.tx(() => {
212
+ let channel = this.channelSync(input.channelKey);
213
+ const ts = this.now();
214
+ if (channel === undefined || channel.period < input.period) {
215
+ if (channel?.pendingSeq != null) {
216
+ return {
217
+ ok: false as const,
218
+ reason: 'channel_busy' as const,
219
+ committed: channel.committed,
220
+ };
221
+ }
222
+ this.db
223
+ .prepare(
224
+ `INSERT INTO batch_channels (key, network, aa, pay_to, payer, asset, period, committed, pending_seq, updated_at)
225
+ VALUES (?, ?, ?, ?, ?, ?, ?, '0', NULL, ?)
226
+ ON CONFLICT(key) DO UPDATE SET period = excluded.period, committed = '0', updated_at = excluded.updated_at`,
227
+ )
228
+ .run(
229
+ input.channelKey,
230
+ input.network,
231
+ input.aa,
232
+ input.payTo,
233
+ input.payer,
234
+ input.asset,
235
+ input.period,
236
+ ts,
237
+ );
238
+ channel = this.channelSync(input.channelKey);
239
+ }
240
+ if (channel === undefined) throw new ObbyxError('store_inconsistent', 'channel row missing');
241
+ if (channel.period !== input.period) {
242
+ return {
243
+ ok: false as const,
244
+ reason: 'period_mismatch' as const,
245
+ committed: channel.committed,
246
+ };
247
+ }
248
+ if (channel.pendingSeq !== null) {
249
+ return {
250
+ ok: false as const,
251
+ reason: 'channel_busy' as const,
252
+ committed: channel.committed,
253
+ };
254
+ }
255
+ if (input.amountSpent !== channel.committed + input.paymentAmount) {
256
+ return {
257
+ ok: false as const,
258
+ reason: 'cumulative_amount_mismatch' as const,
259
+ committed: channel.committed,
260
+ };
261
+ }
262
+ const res = this.db
263
+ .prepare(
264
+ `INSERT INTO batch_commitments
265
+ (channel_key, network, aa, pay_to, period, amount_spent, payment_amount, binding, package_hash,
266
+ package_json, state, created_at)
267
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?)`,
268
+ )
269
+ .run(
270
+ input.channelKey,
271
+ input.network,
272
+ input.aa,
273
+ input.payTo,
274
+ input.period,
275
+ input.amountSpent.toString(),
276
+ input.paymentAmount.toString(),
277
+ input.binding,
278
+ input.packageHash,
279
+ input.packageJson,
280
+ ts,
281
+ );
282
+ const seq = Number(res.lastInsertRowid);
283
+ this.db
284
+ .prepare('UPDATE batch_channels SET pending_seq = ?, updated_at = ? WHERE key = ?')
285
+ .run(seq, ts, input.channelKey);
286
+ const commitment = this.commitmentSync(seq);
287
+ if (commitment === undefined)
288
+ throw new ObbyxError('store_inconsistent', 'commitment missing');
289
+ return { ok: true as const, commitment };
290
+ });
291
+ }
292
+
293
+ /** Watcher acked `seq` (durably stored): pending → committed. Idempotent. */
294
+ async commit(
295
+ seq: number,
296
+ owner: { payTo: string; network: string },
297
+ ): Promise<CommitmentRow | undefined> {
298
+ return this.tx(() => {
299
+ const c = this.commitmentSync(seq);
300
+ if (c === undefined || c.payTo !== owner.payTo || c.network !== owner.network)
301
+ return undefined;
302
+ if (c.state === 'committed') return c;
303
+ const ts = this.now();
304
+ this.db
305
+ .prepare("UPDATE batch_commitments SET state = 'committed', committed_at = ? WHERE seq = ?")
306
+ .run(ts, seq);
307
+ this.db
308
+ .prepare(
309
+ `UPDATE batch_channels SET committed = ?, pending_seq = NULL, updated_at = ?
310
+ WHERE key = ? AND pending_seq = ? AND period = ?`,
311
+ )
312
+ .run(c.amountSpent.toString(), ts, c.channelKey, seq, c.period);
313
+ return this.commitmentSync(seq);
314
+ });
315
+ }
316
+
317
+ private tx<T>(fn: () => T): T {
318
+ this.db.exec('BEGIN IMMEDIATE');
319
+ try {
320
+ const result = fn();
321
+ this.db.exec('COMMIT');
322
+ return result;
323
+ } catch (err) {
324
+ this.db.exec('ROLLBACK');
325
+ throw err;
326
+ }
327
+ }
328
+ }
329
+
330
+ function toChannel(r: ChannelDbRow): ChannelRow {
331
+ return {
332
+ key: r.key,
333
+ network: r.network,
334
+ aa: r.aa,
335
+ payTo: r.pay_to,
336
+ payer: r.payer,
337
+ asset: r.asset,
338
+ period: r.period,
339
+ committed: BigInt(r.committed),
340
+ pendingSeq: r.pending_seq,
341
+ };
342
+ }
343
+
344
+ function toCommitment(r: CommitmentDbRow): CommitmentRow {
345
+ return {
346
+ seq: r.seq,
347
+ channelKey: r.channel_key,
348
+ network: r.network,
349
+ aa: r.aa,
350
+ payTo: r.pay_to,
351
+ period: r.period,
352
+ amountSpent: BigInt(r.amount_spent),
353
+ paymentAmount: BigInt(r.payment_amount),
354
+ binding: r.binding,
355
+ packageHash: r.package_hash,
356
+ packageJson: r.package_json,
357
+ state: r.state,
358
+ };
359
+ }
@@ -0,0 +1,89 @@
1
+ import { type ObyteNetwork, type Verdict, accept, reject, verifySignedPackage } from '@obbyx/obyte';
2
+ import { z } from 'zod';
3
+
4
+ /**
5
+ * Facilitator ↔ seller-watcher protocol (ADR-0003), JSON over WebSocket at
6
+ * `/watcher`.
7
+ *
8
+ * F → W challenge {challenge}
9
+ * W → F hello {network, login: signed {purpose, challenge, network, expires}, cursor, bytesBalance}
10
+ * F → W welcome {seller} then replays the journal after `cursor`
11
+ * F → W commitment {seq, ...voucher} (pending or committed)
12
+ * W → F ack {seq} ONLY after the voucher is durably stored
13
+ * W → F heartbeat {cursor, bytesBalance, closing?} closing: channels the seller is closing (no new vouchers)
14
+ * F → W error {message} then close
15
+ *
16
+ * The login is an Obyte signed message by the seller's address (payTo), so
17
+ * only the seller's watcher receives that seller's vouchers. Vouchers are
18
+ * bearer-sensitive (ADR-0001): never log `signedPackage`.
19
+ */
20
+ export const WATCHER_LOGIN_PURPOSE = 'obbyx-watcher-login';
21
+
22
+ export const commitmentMessageSchema = z.object({
23
+ type: z.literal('commitment'),
24
+ seq: z.number().int().positive(),
25
+ network: z.string(),
26
+ aa: z.string(),
27
+ period: z.number().int().positive(),
28
+ amountSpent: z.string().regex(/^\d+$/),
29
+ paymentAmount: z.string().regex(/^\d+$/),
30
+ binding: z.string(),
31
+ signedPackage: z.record(z.string(), z.unknown()),
32
+ state: z.enum(['pending', 'committed']),
33
+ });
34
+ export type CommitmentMessage = z.infer<typeof commitmentMessageSchema>;
35
+
36
+ export const serverMessageSchema = z.discriminatedUnion('type', [
37
+ z.object({ type: z.literal('challenge'), challenge: z.string() }),
38
+ z.object({ type: z.literal('welcome'), seller: z.string(), network: z.string() }),
39
+ commitmentMessageSchema,
40
+ z.object({ type: z.literal('error'), message: z.string() }),
41
+ ]);
42
+ export type ServerMessage = z.infer<typeof serverMessageSchema>;
43
+
44
+ export const clientMessageSchema = z.discriminatedUnion('type', [
45
+ z.object({
46
+ type: z.literal('hello'),
47
+ network: z.string(),
48
+ login: z.record(z.string(), z.unknown()),
49
+ cursor: z.number().int().nonnegative(),
50
+ bytesBalance: z.string().regex(/^\d+$/),
51
+ closing: z.array(z.string()).max(10_000).optional(),
52
+ }),
53
+ z.object({ type: z.literal('ack'), seq: z.number().int().positive() }),
54
+ z.object({
55
+ type: z.literal('heartbeat'),
56
+ cursor: z.number().int().nonnegative(),
57
+ bytesBalance: z.string().regex(/^\d+$/),
58
+ closing: z.array(z.string()).max(10_000).optional(),
59
+ }),
60
+ ]);
61
+ export type ClientMessage = z.infer<typeof clientMessageSchema>;
62
+
63
+ const loginSchema = z
64
+ .object({
65
+ purpose: z.literal(WATCHER_LOGIN_PURPOSE),
66
+ challenge: z.string(),
67
+ network: z.string(),
68
+ expires: z.number().int(),
69
+ })
70
+ .strict();
71
+
72
+ export function loginMessage(challenge: string, network: string, now: number) {
73
+ return { purpose: WATCHER_LOGIN_PURPOSE, challenge, network, expires: now + 60 };
74
+ }
75
+
76
+ /** Verifies a watcher login; returns the seller address that signed it. */
77
+ export function verifyWatcherLogin(
78
+ login: unknown,
79
+ expected: { challenge: string; network: ObyteNetwork; now: number },
80
+ ): Verdict<string> {
81
+ const verified = verifySignedPackage<unknown>(login, { network: expected.network });
82
+ if (!verified.ok) return reject('watcher_login', verified.message);
83
+ const body = loginSchema.safeParse(verified.value.message);
84
+ if (!body.success) return reject('watcher_login', 'Malformed login message');
85
+ if (body.data.challenge !== expected.challenge) return reject('watcher_login', 'Wrong challenge');
86
+ if (body.data.network !== expected.network.caip2) return reject('watcher_login', 'Wrong network');
87
+ if (body.data.expires < expected.now) return reject('watcher_login', 'Login expired');
88
+ return accept(verified.value.signer);
89
+ }
@@ -0,0 +1,50 @@
1
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
2
+ import { canonical } from '../exact/requirements.js';
3
+
4
+ /**
5
+ * Binding of a payment to ONE operation (invariant I1/I2).
6
+ *
7
+ * binding = HMAC-SHA256(sellerSecret, canonical{v, sellerId, scheme, quoteId,
8
+ * validUntil, method, url, bodyHash, network, asset, amount, payTo})
9
+ *
10
+ * - The client signs the binding into its payment (unit data message), so a
11
+ * payment cannot be moved to another operation.
12
+ * - The HMAC makes quotes unforgeable without storing them: on the paid
13
+ * request the seller recomputes it from the request and the quote fields.
14
+ */
15
+ export interface BindingInput {
16
+ readonly sellerId: string;
17
+ /** x402 scheme of the offer (a payment of one scheme never counts for another). */
18
+ readonly scheme: string;
19
+ readonly quoteId: string;
20
+ readonly validUntil: number;
21
+ readonly method: string;
22
+ /** Path + query string, as requested. */
23
+ readonly url: string;
24
+ /** sha256 hex of the request body ('' for none), or a custom fingerprint. */
25
+ readonly bodyHash: string;
26
+ readonly network: string;
27
+ readonly asset: string;
28
+ readonly amount: string;
29
+ readonly payTo: string;
30
+ }
31
+
32
+ export function computeBinding(secret: Uint8Array | string, input: BindingInput): string {
33
+ return createHmac('sha256', secret)
34
+ .update(canonical({ v: 1, ...input, method: input.method.toUpperCase() }))
35
+ .digest('hex');
36
+ }
37
+
38
+ export function bindingsEqual(a: string, b: string): boolean {
39
+ const x = Buffer.from(a, 'utf8');
40
+ const y = Buffer.from(b, 'utf8');
41
+ return x.length === y.length && timingSafeEqual(x, y);
42
+ }
43
+
44
+ export function newQuoteId(): string {
45
+ return randomBytes(16).toString('base64url');
46
+ }
47
+
48
+ export function sha256Hex(data: string | Uint8Array): string {
49
+ return createHash('sha256').update(data).digest('hex');
50
+ }
@@ -0,0 +1,159 @@
1
+ import type obyte from 'obyte';
2
+ import { ObbyxError } from '@obbyx/obyte';
3
+ import { type Logger, moduleLogger } from '@obbyx/obyte/logger';
4
+ import { hubCall } from '../hub/hub-call.js';
5
+ import { parseHistory } from '../hub/schemas.js';
6
+ import type { CoinReservations } from './reservations.js';
7
+ import { type Utxo, inputKey, selectCoins, unspentOutputs } from './selection.js';
8
+
9
+ /**
10
+ * Input reservation for client-composed units (ADR-0002 "publication").
11
+ *
12
+ * Problem: an `exact` unit is signed by the client but broadcast later by the
13
+ * facilitator. Until the hub knows it, the hub would pick the SAME coins for
14
+ * the next unit → double spend, one unit becomes bad.
15
+ *
16
+ * Solution: our own coin picker, installed on obyte.js's extension point
17
+ * `client.api.pickDivisibleCoinsForAmount`. It computes the wallet's unspent
18
+ * outputs from `light/get_history`, skips reserved ones, reserves what it
19
+ * picks (SQLite, durable) and binds the reservation to the unit hash. Composes
20
+ * per wallet are serialized. A reservation ends when the unit is seen on the
21
+ * ledger (its inputs are then spent for the hub) or, if it never appears, only
22
+ * after `releaseAfterSeconds` (≥ quote validity + the facilitator's publication
23
+ * window) — never earlier, because a signed unit can still be published.
24
+ *
25
+ * Coin rules mirror an ocore light wallet with `spend_unconfirmed: 'own'`:
26
+ * outputs of OTHER authors must be stable and at or before `last_ball_mci`;
27
+ * own unstable outputs (change) may be spent.
28
+ */
29
+ export interface ReservingComposerOptions {
30
+ readonly client: obyte.Client;
31
+ readonly network: string;
32
+ readonly address: string;
33
+ readonly reservations: CoinReservations;
34
+ readonly logger: Logger;
35
+ /** Minimum age before an unpublished unit's inputs are released (s). */
36
+ readonly releaseAfterSeconds?: number;
37
+ readonly hubTimeoutMs?: number;
38
+ readonly now?: () => number;
39
+ }
40
+
41
+ /**
42
+ * Installs the reserving coin picker on an obyte.js client and serializes
43
+ * composes for the wallet. Use `compose(fn)` around every `client.compose.*`.
44
+ */
45
+ export class ReservingComposer {
46
+ private queue: Promise<unknown> = Promise.resolve();
47
+ private lastPick: Utxo[] = [];
48
+ private readonly log: Logger;
49
+ private readonly now: () => number;
50
+
51
+ constructor(private readonly options: ReservingComposerOptions) {
52
+ this.log = moduleLogger(options.logger, 'x402.coins', { address: options.address });
53
+ this.now = options.now ?? (() => Math.floor(Date.now() / 1000));
54
+ const api = options.client.api as unknown as Record<string, unknown>;
55
+ api['pickDivisibleCoinsForAmount'] = (params: {
56
+ addresses: string[];
57
+ last_ball_mci: number;
58
+ amount: number;
59
+ asset?: string | null;
60
+ }) => this.pick(params);
61
+ }
62
+
63
+ /** Runs `fn` (which composes exactly one unit) with reservations; returns its unit. */
64
+ compose<T extends { unit?: unknown }>(
65
+ fn: () => Promise<T>,
66
+ opts: { holdUntil?: number } = {},
67
+ ): Promise<T> {
68
+ const run = this.queue.then(async () => {
69
+ await this.sweep();
70
+ this.lastPick = [];
71
+ try {
72
+ const unit = await fn();
73
+ const hash = typeof unit.unit === 'string' ? unit.unit : undefined;
74
+ if (hash === undefined) throw new ObbyxError('compose_failed', 'Composed unit has no hash');
75
+ this.options.reservations.bind(this.options.network, this.lastPick, hash, opts.holdUntil);
76
+ this.log.debug({ unit: hash, inputs: this.lastPick.length }, 'inputs reserved for unit');
77
+ return unit;
78
+ } catch (err) {
79
+ this.options.reservations.releaseUnbound(this.options.network, this.lastPick);
80
+ throw err;
81
+ } finally {
82
+ this.lastPick = [];
83
+ }
84
+ });
85
+ this.queue = run.catch(() => undefined);
86
+ return run;
87
+ }
88
+
89
+ /** Releases the inputs of a unit that was composed but provably never sent anywhere. */
90
+ discard(unit: string): void {
91
+ this.options.reservations.endUnit(this.options.network, unit);
92
+ }
93
+
94
+ /** Ends reservations of units now known to the hub, or provably expired. */
95
+ async sweep(): Promise<void> {
96
+ const { reservations, network, client } = this.options;
97
+ const releaseAfter = this.options.releaseAfterSeconds ?? 1200;
98
+ reservations.dropStaleUnbound(network, this.now() - 600);
99
+ for (const { unit, reservedAt, holdUntil } of reservations.boundUnits(network)) {
100
+ const res = await hubCall(
101
+ () => client.api.getJoint(unit),
102
+ this.options.hubTimeoutMs ?? 15_000,
103
+ );
104
+ if (res.kind !== 'ok') return; // unknown: keep everything reserved (fail-safe)
105
+ if (res.value.joint !== undefined) {
106
+ reservations.endUnit(network, unit); // the hub knows the spend now
107
+ } else if (this.now() - reservedAt > releaseAfter && this.now() > (holdUntil ?? 0)) {
108
+ reservations.endUnit(network, unit);
109
+ this.log.info({ unit }, 'unit was never published; inputs released');
110
+ }
111
+ }
112
+ }
113
+
114
+ private async pick(params: {
115
+ addresses: string[];
116
+ last_ball_mci: number;
117
+ amount: number;
118
+ asset?: string | null;
119
+ }) {
120
+ const { client, network, address, reservations } = this.options;
121
+ if (params.addresses.length !== 1 || params.addresses[0] !== address) {
122
+ throw new ObbyxError('coins_wrong_address', 'The reserving picker serves a single address');
123
+ }
124
+ const asset = params.asset ?? 'base';
125
+ const t = this.options.hubTimeoutMs ?? 15_000;
126
+ const history = await hubCall(
127
+ () =>
128
+ client.api
129
+ .getWitnesses()
130
+ .then((witnesses) => client.api.getHistory({ witnesses, addresses: [address] } as never)),
131
+ t,
132
+ );
133
+ if (history.kind !== 'ok')
134
+ throw new ObbyxError('ledger_unavailable', `history: ${history.error}`);
135
+ const parsedHistory = parseHistory(history.value);
136
+ if (parsedHistory === undefined)
137
+ throw new ObbyxError('ledger_unavailable', 'history: malformed hub answer');
138
+ const joints = parsedHistory.joints;
139
+ const reserved = reservations.reservedKeys(network);
140
+ const free = unspentOutputs(joints, address, params.last_ball_mci).filter(
141
+ (u) => !reserved.has(inputKey(network, u)),
142
+ );
143
+ const picked = selectCoins(free, asset, BigInt(params.amount));
144
+ if (picked === undefined) {
145
+ throw new ObbyxError(
146
+ 'insufficient_funds',
147
+ `Not enough unreserved ${asset} (some coins may be reserved by pending payments)`,
148
+ );
149
+ }
150
+ reservations.reserve(network, address, picked);
151
+ this.lastPick.push(...picked);
152
+ return {
153
+ inputs_with_proofs: picked.map((u) => ({
154
+ input: { unit: u.unit, message_index: u.messageIndex, output_index: u.outputIndex },
155
+ })),
156
+ total_amount: Number(picked.reduce((s, u) => s + u.amount, 0n)),
157
+ };
158
+ }
159
+ }
@@ -0,0 +1,9 @@
1
+ export { type ReservationRow, CoinReservations } from './reservations.js';
2
+ export {
3
+ type HistoryJoint,
4
+ type Utxo,
5
+ inputKey,
6
+ selectCoins,
7
+ unspentOutputs,
8
+ } from './selection.js';
9
+ export { ReservingComposer, type ReservingComposerOptions } from './composer.js';