@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,461 @@
1
+ import { createHash } from 'node:crypto';
2
+ import type {
3
+ Network,
4
+ PaymentPayload,
5
+ PaymentRequirements,
6
+ SchemeNetworkFacilitator,
7
+ SettleResponse,
8
+ VerifyResponse,
9
+ } from '@x402/core/types';
10
+ import {
11
+ type ChannelParams,
12
+ OBYTE_CAIP_FAMILY,
13
+ getNetwork,
14
+ networkKey,
15
+ parseChannelDefinition,
16
+ verifySignedPackage,
17
+ } from '@obbyx/obyte';
18
+ import { type Logger, moduleLogger, withLogContext } from '@obbyx/obyte/logger';
19
+ import type obyte from 'obyte';
20
+ import { acceptedMatches, canonical } from '../exact/requirements.js';
21
+ import { hubCall } from '../hub/hub-call.js';
22
+ import { KeyedQueue } from '../util/keyed-queue.js';
23
+ import { parseStateVars } from '../hub/schemas.js';
24
+ import type { WatcherGateway } from './gateway.js';
25
+ import {
26
+ BATCH_REASONS as R,
27
+ BATCH_SCHEME,
28
+ type ParsedBatchRequirements,
29
+ type VoucherMessage,
30
+ parseBatchRequirements,
31
+ voucherMessageSchema,
32
+ } from './requirements.js';
33
+ import type { SettlementEvent } from '../exact/store.js';
34
+ import type { BatchStore, CommitmentRow } from './store.js';
35
+
36
+ /**
37
+ * x402 `batch-settlement` facilitator for Obyte AA payment channels
38
+ * (ADR-0003, facilitator-managed vouchers).
39
+ *
40
+ * The client (party B) signs cumulative vouchers `amount_spent` for the
41
+ * channel AA with the seller (party A = payTo). `/settle` stores the voucher;
42
+ * it succeeds only after the SELLER'S WATCHER has durably stored it (sync ack),
43
+ * so the component that defends the channel always holds the latest proof.
44
+ * No value moves on-chain here; the watcher closes channels later.
45
+ *
46
+ * MVP rules: unidirectional channel, one in-flight voucher per channel,
47
+ * `amount_spent == committed + price`, `payment_amount == price`.
48
+ */
49
+ export interface BatchObyteFacilitatorOptions {
50
+ readonly clients: ReadonlyMap<string, obyte.Client>;
51
+ readonly store: BatchStore;
52
+ readonly gateway: WatcherGateway;
53
+ readonly logger: Logger;
54
+ readonly allowedAssets: ReadonlyMap<string, ReadonlySet<string>>;
55
+ /** Lowest channel timeout accepted (also advertised in /supported). */
56
+ readonly minChannelTimeoutSeconds: number;
57
+ readonly hubTimeoutMs?: number;
58
+ readonly ackTimeoutMs?: number;
59
+ /** Payment event journal (logging stream 2); e.g. the facilitator's `events` table. */
60
+ readonly events?: (event: SettlementEvent) => Promise<void>;
61
+ readonly now?: () => number;
62
+ }
63
+
64
+ /** A voucher whose signature, binding, price and channel parameters are verified. */
65
+ interface Authenticated {
66
+ readonly req: ParsedBatchRequirements;
67
+ readonly voucher: VoucherMessage;
68
+ readonly payer: string;
69
+ readonly channel: ChannelParams;
70
+ readonly packageJson: string;
71
+ readonly packageHash: string;
72
+ }
73
+
74
+ /** A new voucher that also passed the live-state checks. */
75
+ interface Checked extends Authenticated {
76
+ readonly balance: bigint;
77
+ }
78
+
79
+ type Check<T> = { ok: true; value: T } | { ok: false; reason: string; message: string };
80
+ const fail = (reason: string, message: string) => ({ ok: false as const, reason, message });
81
+
82
+ export class BatchObyteFacilitator implements SchemeNetworkFacilitator {
83
+ readonly scheme = BATCH_SCHEME;
84
+ readonly caipFamily = OBYTE_CAIP_FAMILY;
85
+ private readonly log: Logger;
86
+ private readonly hubTimeoutMs: number;
87
+ private readonly ackTimeoutMs: number;
88
+ private readonly now: () => number;
89
+ /** Channel definitions are immutable: cache them. */
90
+ private readonly definitions = new Map<string, ChannelParams>();
91
+ /** One settle at a time per channel inside this process. */
92
+ /** One voucher at a time per channel inside this process (the DB enforces it across processes). */
93
+ private readonly channels = new KeyedQueue();
94
+
95
+ constructor(private readonly options: BatchObyteFacilitatorOptions) {
96
+ this.log = moduleLogger(options.logger, 'x402.batch', { scheme: BATCH_SCHEME });
97
+ this.hubTimeoutMs = options.hubTimeoutMs ?? 15_000;
98
+ this.ackTimeoutMs = options.ackTimeoutMs ?? 5_000;
99
+ this.now = options.now ?? (() => Math.floor(Date.now() / 1000));
100
+ }
101
+
102
+ getExtra(network: Network): Record<string, unknown> {
103
+ return {
104
+ voucherManager: 'facilitator',
105
+ minTimeout: this.options.minChannelTimeoutSeconds,
106
+ assets: [...(this.options.allowedAssets.get(network) ?? [])],
107
+ };
108
+ }
109
+
110
+ /** The facilitator never signs channel triggers (ADR-0001). */
111
+ getSigners(): string[] {
112
+ return [];
113
+ }
114
+
115
+ async verify(
116
+ payload: PaymentPayload,
117
+ requirements: PaymentRequirements,
118
+ ): Promise<VerifyResponse> {
119
+ return withLogContext({ scheme: BATCH_SCHEME, network: requirements.network }, async () => {
120
+ const checked = await this.check(payload, requirements);
121
+ if (!checked.ok) {
122
+ this.log.info({ reason: checked.reason, detail: checked.message }, 'verify rejected');
123
+ return { isValid: false, invalidReason: checked.reason, invalidMessage: checked.message };
124
+ }
125
+ const c = checked.value;
126
+ const channelKey = this.channelKey(c.req.network.caip2, c.voucher.aa_address);
127
+ const existing = await this.options.store.commitmentByHash(c.packageHash);
128
+ if (existing === undefined) {
129
+ const channel = await this.options.store.channel(channelKey);
130
+ const committed = channel?.period === c.voucher.period ? channel.committed : 0n;
131
+ if (channel?.period === c.voucher.period && channel.pendingSeq !== null) {
132
+ return {
133
+ isValid: false,
134
+ invalidReason: R.busy,
135
+ invalidMessage: 'Another voucher is in flight',
136
+ };
137
+ }
138
+ if (BigInt(c.voucher.amount_spent) !== committed + c.req.amount) {
139
+ return {
140
+ isValid: false,
141
+ invalidReason: R.cumulative,
142
+ invalidMessage: `Expected amount_spent ${committed + c.req.amount}`,
143
+ extra: { chargedCumulativeAmount: committed.toString() },
144
+ };
145
+ }
146
+ }
147
+ return { isValid: true, payer: c.payer, extra: { channel: c.voucher.aa_address } };
148
+ });
149
+ }
150
+
151
+ async settle(
152
+ payload: PaymentPayload,
153
+ requirements: PaymentRequirements,
154
+ ): Promise<SettleResponse> {
155
+ return withLogContext({ scheme: BATCH_SCHEME, network: requirements.network }, async () => {
156
+ const network = requirements.network as Network;
157
+ const auth = await this.authenticate(payload, requirements);
158
+ const stored = auth.ok
159
+ ? await this.options.store.commitmentByHash(auth.value.packageHash)
160
+ : undefined;
161
+ const checked: Check<Authenticated & { readonly balance?: bigint }> =
162
+ auth.ok && stored === undefined ? await this.newVoucherChecks(auth.value) : auth;
163
+ if (!checked.ok) {
164
+ await this.emit({
165
+ key: `${requirements.network}:batch:${requirements.payTo}`,
166
+ kind: 'voucher_refused',
167
+ data: { reason: checked.reason, stage: 'verify' },
168
+ });
169
+ return failure(network, checked.reason, checked.message);
170
+ }
171
+ const c = checked.value;
172
+ const channelKey = this.channelKey(c.req.network.caip2, c.voucher.aa_address);
173
+ return withLogContext({ aa: c.voucher.aa_address, proofId: c.packageHash }, () =>
174
+ this.channels.run(channelKey, () => this.settleLocked(c, channelKey, network)),
175
+ );
176
+ });
177
+ }
178
+
179
+ private async newVoucherChecks(a: Authenticated): Promise<Check<Checked>> {
180
+ const live = await this.liveChecks(a);
181
+ return live.ok ? { ok: true, value: { ...a, balance: live.value.balance } } : live;
182
+ }
183
+
184
+ private async settleLocked(
185
+ c: Authenticated & { readonly balance?: bigint },
186
+ channelKey: string,
187
+ network: Network,
188
+ ): Promise<SettleResponse> {
189
+ const store = this.options.store;
190
+ let commitment = await store.commitmentByHash(c.packageHash);
191
+ if (commitment !== undefined && commitment.binding !== c.voucher.binding) {
192
+ return failure(
193
+ network,
194
+ 'invalid_payment_reused',
195
+ 'This voucher is bound to another operation',
196
+ );
197
+ }
198
+ if (commitment === undefined) {
199
+ const appended = await store.appendPending({
200
+ channelKey,
201
+ network: c.req.network.caip2,
202
+ aa: c.voucher.aa_address,
203
+ payTo: c.req.payTo,
204
+ payer: c.payer,
205
+ asset: c.req.asset,
206
+ period: c.voucher.period,
207
+ amountSpent: BigInt(c.voucher.amount_spent),
208
+ paymentAmount: BigInt(c.voucher.payment_amount),
209
+ binding: c.voucher.binding,
210
+ packageHash: c.packageHash,
211
+ packageJson: c.packageJson,
212
+ });
213
+ if (!appended.ok) {
214
+ const reason =
215
+ appended.reason === 'channel_busy'
216
+ ? R.busy
217
+ : appended.reason === 'period_mismatch'
218
+ ? R.period
219
+ : R.cumulative;
220
+ await this.emit({
221
+ key: channelKey,
222
+ kind: 'voucher_refused',
223
+ data: {
224
+ reason,
225
+ amountSpent: c.voucher.amount_spent,
226
+ committed: appended.committed.toString(),
227
+ },
228
+ });
229
+ return {
230
+ ...failure(network, reason, `Voucher rejected: ${appended.reason}`),
231
+ extra: { chargedCumulativeAmount: appended.committed.toString() },
232
+ };
233
+ }
234
+ commitment = appended.commitment;
235
+ await this.emit({
236
+ key: channelKey,
237
+ kind: 'voucher_pending',
238
+ data: {
239
+ seq: commitment.seq,
240
+ period: commitment.period,
241
+ amountSpent: commitment.amountSpent.toString(),
242
+ },
243
+ });
244
+ this.log.info(
245
+ { seq: commitment.seq, amountSpent: commitment.amountSpent.toString() },
246
+ 'voucher pending',
247
+ );
248
+ }
249
+
250
+ if (commitment.state === 'pending') {
251
+ const delivered = await this.options.gateway.deliver(commitment, this.ackTimeoutMs);
252
+ if (delivered !== 'acked') {
253
+ // Stored here but not yet confirmed by the seller's watcher: the outcome
254
+ // is unknown to the client → settlement_pending (x402: non-empty
255
+ // transaction), retried with the SAME voucher. Distinct from the
256
+ // definite refusal `…_watcher_unavailable` at verification.
257
+ this.log.warn(
258
+ { seq: commitment.seq },
259
+ 'watcher did not ack in time; voucher stays pending',
260
+ );
261
+ await this.emit({ key: channelKey, kind: 'ack_timeout', data: { seq: commitment.seq } });
262
+ return {
263
+ success: false,
264
+ errorReason: 'settlement_pending',
265
+ errorMessage:
266
+ "Waiting for the seller's watcher to store this voucher; retry with the same voucher",
267
+ transaction: commitmentIdOf(network, commitment),
268
+ network,
269
+ payer: c.payer,
270
+ };
271
+ }
272
+ commitment =
273
+ (await store.commit(commitment.seq, {
274
+ payTo: commitment.payTo,
275
+ network: commitment.network,
276
+ })) ?? commitment;
277
+ this.log.info({ seq: commitment.seq }, 'voucher committed (watcher ack)');
278
+ await this.emit({
279
+ key: channelKey,
280
+ kind: 'voucher_committed',
281
+ data: {
282
+ seq: commitment.seq,
283
+ period: commitment.period,
284
+ amountSpent: commitment.amountSpent.toString(),
285
+ },
286
+ });
287
+ }
288
+ return this.success(network, c, commitment);
289
+ }
290
+
291
+ private success(
292
+ network: Network,
293
+ c: Authenticated & { readonly balance?: bigint },
294
+ commitment: CommitmentRow,
295
+ ): SettleResponse {
296
+ return {
297
+ success: true,
298
+ payer: c.payer,
299
+ network,
300
+ transaction: '',
301
+ amount: '',
302
+ extra: {
303
+ chargedAmount: commitment.paymentAmount.toString(),
304
+ commitmentId: commitmentIdOf(network, commitment),
305
+ channelState: {
306
+ channelId: commitment.aa,
307
+ period: commitment.period,
308
+ ...(c.balance === undefined ? {} : { balance: c.balance.toString() }),
309
+ chargedCumulativeAmount: commitment.amountSpent.toString(),
310
+ },
311
+ },
312
+ };
313
+ }
314
+
315
+ // --- verification -----------------------------------------------------------
316
+
317
+ /** Stateless checks: shape, signature, binding, price, the channel's on-chain parameters. */
318
+ private async authenticate(
319
+ payload: PaymentPayload,
320
+ requirements: PaymentRequirements,
321
+ ): Promise<Check<Authenticated>> {
322
+ if (payload.x402Version !== 2)
323
+ return fail('invalid_x402_version', 'Only x402 v2 is supported here');
324
+ if (!acceptedMatches(payload, requirements)) {
325
+ return fail(
326
+ 'invalid_payment_requirements',
327
+ 'Accepted requirements differ from the server requirements',
328
+ );
329
+ }
330
+ const parsed = parseBatchRequirements(requirements);
331
+ if (!parsed.ok) return fail(parsed.reason, parsed.message);
332
+ const req = parsed.value;
333
+ if (!this.options.allowedAssets.get(req.network.caip2)?.has(req.asset)) {
334
+ return fail('invalid_batch_settlement_obyte_asset', 'Asset is not accepted');
335
+ }
336
+ const client = this.options.clients.get(req.network.caip2);
337
+ if (client === undefined) return fail('invalid_network', 'Network is not served');
338
+
339
+ // Voucher signature (bound to the network) and content.
340
+ const pkg = payload.payload['signedPackage'];
341
+ const verified = verifySignedPackage<unknown>(pkg, { network: req.network });
342
+ if (!verified.ok) return fail(R.signature, verified.message);
343
+ const voucher = voucherMessageSchema.safeParse(verified.value.message);
344
+ if (!voucher.success)
345
+ return fail(R.payload, `Invalid voucher: ${voucher.error.issues[0]?.message}`);
346
+ const v = voucher.data;
347
+ if (v.binding !== req.extra.binding)
348
+ return fail(R.binding, 'Voucher is bound to another operation');
349
+ if (BigInt(v.payment_amount) !== req.amount)
350
+ return fail(R.amount, 'payment_amount must equal the price');
351
+
352
+ // Channel binding: on-chain definition → payTo is party A, signer is party B.
353
+ const params = await this.channelParams(client, v.aa_address);
354
+ if (!params.ok) return params;
355
+ const p = params.value;
356
+ if (p.addressA !== req.payTo || p.addressB !== verified.value.signer || p.asset !== req.asset) {
357
+ return fail(R.channelParams, 'Channel does not connect this payer to payTo in this asset');
358
+ }
359
+ const minTimeout = Math.max(this.options.minChannelTimeoutSeconds, req.extra.minTimeout);
360
+ if (p.timeout < minTimeout)
361
+ return fail(R.timeout, `Channel timeout must be at least ${minTimeout} s`);
362
+
363
+ const packageJson = canonical(pkg);
364
+ return {
365
+ ok: true,
366
+ value: {
367
+ req,
368
+ voucher: v,
369
+ payer: verified.value.signer,
370
+ channel: p,
371
+ packageJson,
372
+ packageHash: createHash('sha256').update(packageJson).digest('hex'),
373
+ },
374
+ };
375
+ }
376
+
377
+ /**
378
+ * Checks that depend on time and live state: offer validity, channel period,
379
+ * closing flag, confirmed deposit, the seller's watcher. Applied only to a
380
+ * NEW voucher: one already stored is re-delivered regardless, because its
381
+ * outcome is decided by the watcher's ack (refusing it now would report a
382
+ * definite failure for a voucher the seller may still redeem).
383
+ */
384
+ private async liveChecks(c: Authenticated): Promise<Check<{ balance: bigint }>> {
385
+ const { req, voucher: v } = c;
386
+ if (req.extra.validUntil < this.now())
387
+ return fail(R.quoteExpired, 'The payment offer has expired');
388
+ const client = this.options.clients.get(req.network.caip2);
389
+ if (client === undefined) return fail('invalid_network', 'Network is not served');
390
+ // Channel state: same period, not closing, enough confirmed deposit.
391
+ const vars = await hubCall(
392
+ () => client.api.getAaStateVars({ address: v.aa_address }),
393
+ this.hubTimeoutMs,
394
+ );
395
+ if (vars.kind !== 'ok') return fail(R.ledger, `state vars: ${vars.error}`);
396
+ const state = parseStateVars(vars.value);
397
+ if (state === undefined) return fail(R.ledger, 'state vars: malformed hub answer');
398
+ if (state['period'] !== v.period)
399
+ return fail(R.period, `Channel period is ${String(state['period'])}`);
400
+ if (state['close_initiated_by'] !== undefined)
401
+ return fail(R.closing, 'Channel is being closed');
402
+ const balance = BigInt(typeof state['balanceB'] === 'number' ? state['balanceB'] : 0);
403
+ if (BigInt(v.amount_spent) > balance)
404
+ return fail(R.balance, 'amount_spent exceeds the confirmed deposit');
405
+
406
+ // The seller's watcher must be able to defend the channel (fail-closed).
407
+ const health = this.options.gateway.health(req.payTo, req.network.caip2, v.aa_address);
408
+ if (!health.ok && health.closing === true)
409
+ return fail(R.closing, health.reason ?? 'The seller is closing this channel');
410
+ if (!health.ok)
411
+ return fail(R.watcher, health.reason ?? "The seller's watcher is not available");
412
+
413
+ return { ok: true, value: { balance } };
414
+ }
415
+
416
+ /** Full verification of a new voucher. */
417
+ private async check(
418
+ payload: PaymentPayload,
419
+ requirements: PaymentRequirements,
420
+ ): Promise<Check<Checked>> {
421
+ const auth = await this.authenticate(payload, requirements);
422
+ if (!auth.ok) return auth;
423
+ const live = await this.liveChecks(auth.value);
424
+ if (!live.ok) return live;
425
+ return { ok: true, value: { ...auth.value, balance: live.value.balance } };
426
+ }
427
+
428
+ private async channelParams(client: obyte.Client, aa: string): Promise<Check<ChannelParams>> {
429
+ const cached = this.definitions.get(aa);
430
+ if (cached !== undefined) return { ok: true, value: cached };
431
+ const res = await hubCall(() => client.api.getDefinition(aa), this.hubTimeoutMs);
432
+ if (res.kind !== 'ok') return fail(R.ledger, `definition: ${res.error}`);
433
+ if (res.value === null || res.value === undefined)
434
+ return fail(R.channel, 'Channel AA is not defined on the ledger');
435
+ const parsed = parseChannelDefinition(aa, res.value);
436
+ if (!parsed.ok) return fail(R.channel, parsed.message);
437
+ this.definitions.set(aa, parsed.value);
438
+ return { ok: true, value: parsed.value };
439
+ }
440
+
441
+ /** The journal must never break a payment: failures are logged, not thrown. */
442
+ private async emit(event: SettlementEvent): Promise<void> {
443
+ try {
444
+ await this.options.events?.(event);
445
+ } catch (err) {
446
+ this.log.error({ err, kind: event.kind }, 'cannot write payment event');
447
+ }
448
+ }
449
+
450
+ private channelKey(network: string, aa: string): string {
451
+ return networkKey(getNetwork(network), 'aa', aa);
452
+ }
453
+ }
454
+
455
+ function commitmentIdOf(network: Network, c: CommitmentRow): string {
456
+ return `${network}:${c.aa}:${c.period}:${c.amountSpent}`;
457
+ }
458
+
459
+ function failure(network: Network, reason: string, message: string): SettleResponse {
460
+ return { success: false, errorReason: reason, errorMessage: message, transaction: '', network };
461
+ }
@@ -0,0 +1,22 @@
1
+ import type { CommitmentRow } from './store.js';
2
+
3
+ /**
4
+ * Port to the sellers' watchers (ADR-0003, synchronous durable ack).
5
+ *
6
+ * `deliver` resolves `acked` only after the seller's watcher confirmed it has
7
+ * durably stored the commitment; anything else is not an ack. The facilitator
8
+ * reports success to the resource server only after `acked` (fail-closed).
9
+ */
10
+ export interface WatcherHealth {
11
+ readonly ok: boolean;
12
+ readonly reason?: string;
13
+ /** The seller announced it is closing this channel: refuse new vouchers for it. */
14
+ readonly closing?: boolean;
15
+ }
16
+
17
+ export interface WatcherGateway {
18
+ /** Is a watcher for this seller connected, recent and funded for defensive triggers? */
19
+ health(payTo: string, network: string, aa?: string): WatcherHealth;
20
+ /** Pushes a commitment and waits for the durable ack (or times out). */
21
+ deliver(commitment: CommitmentRow, timeoutMs: number): Promise<'acked' | 'timeout'>;
22
+ }
@@ -0,0 +1,108 @@
1
+ import type { PaymentRequirements } from '@x402/core/types';
2
+ import {
3
+ CHANNEL_BASE_AA,
4
+ type ObyteNetwork,
5
+ type Verdict,
6
+ accept,
7
+ getNetwork,
8
+ isAddress,
9
+ isAssetId,
10
+ isObyteNetwork,
11
+ parseAmount,
12
+ reject,
13
+ } from '@obbyx/obyte';
14
+ import { z } from 'zod';
15
+
16
+ export const BATCH_SCHEME = 'batch-settlement';
17
+
18
+ /** Error codes of the Obyte binding (`invalid_batch_settlement_obyte_*`). */
19
+ export const BATCH_REASONS = {
20
+ payload: 'invalid_batch_settlement_obyte_payload',
21
+ signature: 'invalid_batch_settlement_obyte_voucher_signature',
22
+ channel: 'invalid_batch_settlement_obyte_channel_not_found',
23
+ channelParams: 'invalid_batch_settlement_obyte_channel_params',
24
+ timeout: 'invalid_batch_settlement_obyte_channel_timeout',
25
+ period: 'invalid_batch_settlement_obyte_period_mismatch',
26
+ closing: 'invalid_batch_settlement_obyte_channel_closing',
27
+ balance: 'invalid_batch_settlement_obyte_cumulative_exceeds_balance',
28
+ cumulative: 'invalid_batch_settlement_obyte_cumulative_amount_mismatch',
29
+ amount: 'invalid_batch_settlement_obyte_payment_amount_mismatch',
30
+ binding: 'invalid_batch_settlement_obyte_binding_mismatch',
31
+ busy: 'invalid_batch_settlement_obyte_channel_busy',
32
+ watcher: 'invalid_batch_settlement_obyte_watcher_unavailable',
33
+ ledger: 'invalid_batch_settlement_obyte_ledger_unavailable',
34
+ quoteExpired: 'invalid_batch_settlement_obyte_quote_expired',
35
+ } as const;
36
+
37
+ export const batchExtraSchema = z
38
+ .object({
39
+ binding: z.string().regex(/^[0-9a-f]{64}$/),
40
+ quoteId: z
41
+ .string()
42
+ .min(8)
43
+ .max(64)
44
+ .regex(/^[A-Za-z0-9_-]+$/),
45
+ validUntil: z.number().int().positive(),
46
+ baseAa: z.literal(CHANNEL_BASE_AA),
47
+ /** Minimum channel dispute window the seller accepts (seconds). */
48
+ minTimeout: z.number().int().positive(),
49
+ // x402 §6.1: Obyte supports the `upfront` flow (settle, then serve) and the
50
+ // default `authorization` flow; `escrow` and any asset transfer method
51
+ // (there is none on Obyte: the payer signs the transfer itself) are refused.
52
+ paymentFlow: z.enum(['authorization', 'upfront']).optional(),
53
+ })
54
+ .strict();
55
+
56
+ export type BatchExtra = z.infer<typeof batchExtraSchema>;
57
+
58
+ /** The voucher: a signed package as used by aa-channels-lib, plus our binding. */
59
+ export const voucherMessageSchema = z
60
+ .object({
61
+ aa_address: z.string(),
62
+ period: z.number().int().positive(),
63
+ // Safe integers only: a larger JSON number cannot be read back exactly.
64
+ amount_spent: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
65
+ payment_amount: z.number().int().positive().max(Number.MAX_SAFE_INTEGER),
66
+ binding: z.string().regex(/^[0-9a-f]{64}$/),
67
+ })
68
+ .strict();
69
+
70
+ export type VoucherMessage = z.infer<typeof voucherMessageSchema>;
71
+
72
+ export interface ParsedBatchRequirements {
73
+ readonly network: ObyteNetwork;
74
+ readonly asset: string;
75
+ readonly amount: bigint;
76
+ readonly payTo: string;
77
+ readonly extra: BatchExtra;
78
+ }
79
+
80
+ export function parseBatchRequirements(req: PaymentRequirements): Verdict<ParsedBatchRequirements> {
81
+ if (req.scheme !== BATCH_SCHEME)
82
+ return reject('unsupported_scheme', 'Scheme is not batch-settlement');
83
+ if (!isObyteNetwork(req.network)) return reject('invalid_network', 'Not an Obyte network');
84
+ if (!isAssetId(req.asset) || !isAddress(req.payTo)) {
85
+ return reject('invalid_payment_requirements', 'Invalid asset or payTo');
86
+ }
87
+ let amount: bigint;
88
+ try {
89
+ amount = parseAmount(req.amount);
90
+ } catch {
91
+ return reject('invalid_payment_requirements', 'Invalid amount');
92
+ }
93
+ if (amount <= 0n) return reject('invalid_payment_requirements', 'Amount must be positive');
94
+ const extra = batchExtraSchema.safeParse(req.extra);
95
+ if (!extra.success) {
96
+ return reject(
97
+ 'invalid_payment_requirements',
98
+ `Invalid extra: ${extra.error.issues[0]?.message}`,
99
+ );
100
+ }
101
+ return accept({
102
+ network: getNetwork(req.network),
103
+ asset: req.asset,
104
+ amount,
105
+ payTo: req.payTo,
106
+ extra: extra.data,
107
+ });
108
+ }