@obbyx/x402-obyte 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 (123) 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 +222 -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 +350 -0
  14. package/dist/batch/facilitator.js.map +1 -0
  15. package/dist/batch/gateway.d.ts +19 -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 +104 -0
  24. package/dist/batch/store.d.ts.map +1 -0
  25. package/dist/batch/store.js +192 -0
  26. package/dist/batch/store.js.map +1 -0
  27. package/dist/batch/watcher-protocol.d.ts +89 -0
  28. package/dist/batch/watcher-protocol.d.ts.map +1 -0
  29. package/dist/batch/watcher-protocol.js +80 -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 +77 -0
  60. package/dist/exact/facilitator.d.ts.map +1 -0
  61. package/dist/exact/facilitator.js +331 -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 +133 -0
  80. package/dist/exact/store.d.ts.map +1 -0
  81. package/dist/exact/store.js +212 -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 +18 -0
  96. package/dist/index.d.ts.map +1 -0
  97. package/dist/index.js +18 -0
  98. package/dist/index.js.map +1 -0
  99. package/package.json +51 -3
  100. package/src/batch/client-store.ts +167 -0
  101. package/src/batch/client.ts +314 -0
  102. package/src/batch/facilitator.ts +469 -0
  103. package/src/batch/gateway.ts +20 -0
  104. package/src/batch/requirements.ts +108 -0
  105. package/src/batch/store.ts +343 -0
  106. package/src/batch/watcher-protocol.ts +87 -0
  107. package/src/binding/binding.ts +50 -0
  108. package/src/coins/composer.ts +159 -0
  109. package/src/coins/index.ts +9 -0
  110. package/src/coins/reservations.ts +139 -0
  111. package/src/coins/selection.ts +93 -0
  112. package/src/exact/client.ts +144 -0
  113. package/src/exact/facilitator-v1.ts +137 -0
  114. package/src/exact/facilitator.ts +451 -0
  115. package/src/exact/finality.ts +56 -0
  116. package/src/exact/ledger-checks.ts +113 -0
  117. package/src/exact/reasons.ts +25 -0
  118. package/src/exact/requirements.ts +109 -0
  119. package/src/exact/store.ts +391 -0
  120. package/src/hub/heartbeat.ts +18 -0
  121. package/src/hub/hub-call.ts +46 -0
  122. package/src/hub/schemas.ts +89 -0
  123. package/src/index.ts +110 -0
@@ -0,0 +1,314 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import type {
3
+ PaymentPayloadResult,
4
+ PaymentRequirements,
5
+ SchemeNetworkClient,
6
+ SettleResponse,
7
+ } from '@x402/core/types';
8
+ import {
9
+ type ChannelParams,
10
+ ObbyxError,
11
+ type ObyteNetwork,
12
+ channelAddress,
13
+ channelDefinition,
14
+ toSafeNumber,
15
+ } from '@obbyx/obyte';
16
+ import { type Logger, moduleLogger } from '@obbyx/obyte/logger';
17
+ import obyte from 'obyte';
18
+ import type { ReservingComposer } from '../coins/index.js';
19
+ import { hubCall } from '../hub/hub-call.js';
20
+ import { parseStateVars } from '../hub/schemas.js';
21
+ import type { ClientChannelStore, PendingVoucher } from './client-store.js';
22
+ import { BATCH_REASONS, BATCH_SCHEME, parseBatchRequirements } from './requirements.js';
23
+
24
+ /**
25
+ * Buyer side of `batch-settlement` on Obyte (party B of an AA channel).
26
+ * obyte.js is used directly: `compose.message` + `api.postJoint` for on-chain
27
+ * actions, `utils.signMessage` for vouchers, `api.getAaStateVars` for state.
28
+ */
29
+ export const MIN_DEPOSIT = 10_001n; // the AA treats ≤ 10000 bytes as a bounce-fee-only trigger
30
+ export const TRIGGER_FEE = 10_000;
31
+
32
+ export interface BatchClientOptions {
33
+ readonly client: obyte.Client;
34
+ readonly network: ObyteNetwork;
35
+ readonly privateKey: Uint8Array;
36
+ readonly store: ClientChannelStore;
37
+ readonly logger: Logger;
38
+ readonly hubTimeoutMs?: number;
39
+ /** Refuse prices above this per call (atomic units per asset). */
40
+ readonly maxAmount?: ReadonlyMap<string, bigint>;
41
+ /** Reserves inputs for on-chain actions (open, deposit, close). */
42
+ readonly composer?: ReservingComposer;
43
+ }
44
+
45
+ /** My address (party B) from the key, via obyte.js public API. */
46
+ export function addressOfKey(privateKey: Uint8Array, network: ObyteNetwork): string {
47
+ const signed = obyte.utils.signMessage('obbyx', {
48
+ privateKey,
49
+ testnet: network.testnet,
50
+ }) as unknown as {
51
+ authors: { address: string }[];
52
+ };
53
+ const address = signed.authors[0]?.address;
54
+ if (address === undefined) throw new ObbyxError('bad_key', 'Cannot derive address');
55
+ return address;
56
+ }
57
+
58
+ async function broadcast(
59
+ options: BatchClientOptions,
60
+ messages: { app: string; payload: unknown }[],
61
+ ): Promise<string> {
62
+ const compose = () =>
63
+ options.client.compose.message('multi', messages as never, {
64
+ privateKey: options.privateKey,
65
+ testnet: options.network.testnet,
66
+ }) as unknown as Promise<Record<string, unknown>>;
67
+ const unit = await (options.composer ? options.composer.compose(compose) : compose());
68
+ const res = await hubCall(
69
+ () => options.client.api.postJoint({ unit } as never),
70
+ options.hubTimeoutMs ?? 15_000,
71
+ );
72
+ if (res.kind === 'hub_error')
73
+ throw new ObbyxError('broadcast_rejected', `Hub rejected the unit: ${res.error}`);
74
+ // ok or unknown: the unit hash identifies it either way; callers can check it later.
75
+ return String(unit['unit']);
76
+ }
77
+
78
+ /**
79
+ * Opens a channel to `payTo`: publishes the parameterized AA definition and the
80
+ * first deposit in ONE unit (as aa-channels-lib does). The deposit is credited
81
+ * by the AA once the unit is stable.
82
+ */
83
+ export async function openChannel(
84
+ options: BatchClientOptions,
85
+ input: { payTo: string; deposit: bigint; timeout: number; asset?: string },
86
+ ): Promise<{ aa: string; unit: string; params: ChannelParams }> {
87
+ const asset = input.asset ?? 'base';
88
+ if (asset !== 'base')
89
+ throw new ObbyxError('unsupported', 'Only bytes channels are supported by the CLI for now');
90
+ if (input.deposit < MIN_DEPOSIT)
91
+ throw new ObbyxError('deposit_too_small', `Deposit must be at least ${MIN_DEPOSIT} bytes`);
92
+ const params: ChannelParams = {
93
+ addressA: input.payTo,
94
+ addressB: addressOfKey(options.privateKey, options.network),
95
+ asset,
96
+ salt: randomBytes(12).toString('hex'),
97
+ timeout: input.timeout,
98
+ };
99
+ const aa = channelAddress(params);
100
+ const unit = await broadcast(options, [
101
+ { app: 'definition', payload: { address: aa, definition: channelDefinition(params) } },
102
+ {
103
+ app: 'payment',
104
+ payload: { outputs: [{ address: aa, amount: toSafeNumber(input.deposit) }] },
105
+ },
106
+ ]);
107
+ options.store.add({
108
+ network: options.network.caip2,
109
+ aa,
110
+ payTo: input.payTo,
111
+ asset,
112
+ timeout: input.timeout,
113
+ salt: params.salt ?? '',
114
+ openedUnit: unit,
115
+ });
116
+ moduleLogger(options.logger, 'x402.batch.client').info(
117
+ { aa, unit, deposit: input.deposit.toString() },
118
+ 'channel opened',
119
+ );
120
+ return { aa, unit, params };
121
+ }
122
+
123
+ export function deposit(options: BatchClientOptions, aa: string, amount: bigint): Promise<string> {
124
+ if (amount < MIN_DEPOSIT)
125
+ throw new ObbyxError('deposit_too_small', `Deposit must be at least ${MIN_DEPOSIT} bytes`);
126
+ return broadcast(options, [
127
+ { app: 'payment', payload: { outputs: [{ address: aa, amount: toSafeNumber(amount) }] } },
128
+ ]);
129
+ }
130
+
131
+ /**
132
+ * The client (B) starts closing: declares what it spent (the last confirmed
133
+ * cumulative amount). The seller's watcher confirms at once if honest, or
134
+ * answers with a fraud proof if B under-declares.
135
+ */
136
+ export function closeChannel(
137
+ options: BatchClientOptions,
138
+ aa: string,
139
+ period: number,
140
+ spent: bigint,
141
+ ): Promise<string> {
142
+ return broadcast(options, [
143
+ { app: 'payment', payload: { outputs: [{ address: aa, amount: TRIGGER_FEE }] } },
144
+ { app: 'data', payload: { close: 1, period, transferredFromMe: toSafeNumber(spent) } },
145
+ ]);
146
+ }
147
+
148
+ /** Confirms our own close after the dispute window. */
149
+ export function confirmClose(
150
+ options: BatchClientOptions,
151
+ aa: string,
152
+ period: number,
153
+ ): Promise<string> {
154
+ return broadcast(options, [
155
+ { app: 'payment', payload: { outputs: [{ address: aa, amount: TRIGGER_FEE }] } },
156
+ { app: 'data', payload: { confirm: 1, period } },
157
+ ]);
158
+ }
159
+
160
+ export class BatchObyteClient implements SchemeNetworkClient {
161
+ readonly scheme = BATCH_SCHEME;
162
+ private readonly log: Logger;
163
+
164
+ constructor(private readonly options: BatchClientOptions) {
165
+ this.log = moduleLogger(options.logger, 'x402.batch.client');
166
+ }
167
+
168
+ /** Is there a usable channel for these requirements? */
169
+ hasChannel(req: PaymentRequirements): boolean {
170
+ return this.options.store.find(req.network, req.payTo, req.asset) !== undefined;
171
+ }
172
+
173
+ async createPaymentPayload(
174
+ x402Version: number,
175
+ requirements: PaymentRequirements,
176
+ ): Promise<PaymentPayloadResult> {
177
+ if (x402Version !== 2)
178
+ throw new ObbyxError('invalid_x402_version', 'Only x402 v2 is supported');
179
+ const parsed = parseBatchRequirements(requirements);
180
+ if (!parsed.ok) throw new ObbyxError(parsed.reason, parsed.message);
181
+ const req = parsed.value;
182
+ const cap = this.options.maxAmount?.get(req.asset);
183
+ if (cap !== undefined && req.amount > cap) {
184
+ throw new ObbyxError('amount_over_limit', `Price ${req.amount} exceeds the limit ${cap}`);
185
+ }
186
+ const store = this.options.store;
187
+ const channel = store.find(req.network.caip2, req.payTo, req.asset);
188
+ if (channel === undefined) {
189
+ throw new ObbyxError(
190
+ 'no_channel',
191
+ `No channel to ${req.payTo}; open one with: obbyx channel open ${req.payTo}`,
192
+ );
193
+ }
194
+ if (channel.timeout < req.extra.minTimeout) {
195
+ throw new ObbyxError(
196
+ 'channel_timeout',
197
+ `The seller requires a channel timeout ≥ ${req.extra.minTimeout} s`,
198
+ );
199
+ }
200
+
201
+ // Same operation retried: resend the same voucher (never sign a second one).
202
+ if (channel.pending !== null) {
203
+ if (channel.pending.binding === req.extra.binding)
204
+ return { x402Version: 2, payload: { signedPackage: channel.pending.signedPackage } };
205
+ throw new ObbyxError(
206
+ 'payment_pending',
207
+ `A previous voucher on channel ${channel.aa} has an unknown outcome; retry that request first`,
208
+ );
209
+ }
210
+
211
+ const vars = await hubCall(
212
+ () => this.options.client.api.getAaStateVars({ address: channel.aa }),
213
+ this.options.hubTimeoutMs ?? 15_000,
214
+ );
215
+ if (vars.kind !== 'ok')
216
+ throw new ObbyxError('ledger_unavailable', `Cannot read channel state: ${vars.error}`);
217
+ const state = parseStateVars(vars.value);
218
+ if (state === undefined)
219
+ throw new ObbyxError('ledger_unavailable', 'Cannot read channel state: malformed hub answer');
220
+ const period = typeof state['period'] === 'number' ? state['period'] : undefined;
221
+ if (period === undefined)
222
+ throw new ObbyxError(
223
+ 'channel_not_funded',
224
+ 'The channel has no confirmed deposit yet (wait until stable)',
225
+ );
226
+ if (state['close_initiated_by'] !== undefined)
227
+ throw new ObbyxError('channel_closing', 'The channel is being closed');
228
+ if (period > channel.period) store.newPeriod(channel.aa, period);
229
+ const committed = period > channel.period ? 0n : channel.committed;
230
+ const amountSpent = committed + req.amount;
231
+ const balance = BigInt(typeof state['balanceB'] === 'number' ? state['balanceB'] : 0);
232
+ if (amountSpent > balance) {
233
+ throw new ObbyxError(
234
+ 'channel_exhausted',
235
+ `Channel balance ${balance} is not enough; deposit more with: obbyx channel deposit ${channel.aa}`,
236
+ );
237
+ }
238
+
239
+ const message = {
240
+ aa_address: channel.aa,
241
+ period,
242
+ amount_spent: toSafeNumber(amountSpent),
243
+ payment_amount: toSafeNumber(req.amount),
244
+ binding: req.extra.binding,
245
+ };
246
+ const signedPackage = obyte.utils.signMessage(message, {
247
+ privateKey: this.options.privateKey,
248
+ testnet: this.options.network.testnet,
249
+ }) as unknown as Record<string, unknown>;
250
+ const pending: PendingVoucher = {
251
+ binding: req.extra.binding,
252
+ period,
253
+ amountSpent: amountSpent.toString(),
254
+ signedPackage,
255
+ };
256
+ if (!store.setPending(channel.aa, pending)) {
257
+ throw new ObbyxError('payment_pending', 'Another voucher on this channel is in flight');
258
+ }
259
+ this.log.info(
260
+ { aa: channel.aa, period, amountSpent: amountSpent.toString(), binding: req.extra.binding },
261
+ 'voucher signed',
262
+ );
263
+ return { x402Version: 2, payload: { signedPackage } };
264
+ }
265
+
266
+ /** Updates local state from the seller's PAYMENT-RESPONSE (settlement result). */
267
+ onSettled(requirements: PaymentRequirements, settle: SettleResponse | undefined): void {
268
+ const channel = this.options.store.find(
269
+ requirements.network,
270
+ requirements.payTo,
271
+ requirements.asset,
272
+ );
273
+ const pending = channel?.pending;
274
+ if (channel === undefined || pending == null) return;
275
+ if (settle?.success === true) {
276
+ this.options.store.confirm(channel.aa, pending.period, BigInt(pending.amountSpent));
277
+ return;
278
+ }
279
+ const reason = settle?.errorReason;
280
+ const charged = (settle?.extra as { chargedCumulativeAmount?: unknown } | undefined)
281
+ ?.chargedCumulativeAmount;
282
+ if (
283
+ reason === BATCH_REASONS.cumulative &&
284
+ typeof charged === 'string' &&
285
+ /^\d+$/.test(charged)
286
+ ) {
287
+ // PAYMENT-RESPONSE is NOT authenticated: never raise our committed amount
288
+ // to an arbitrary value (a malicious seller could make us sign a voucher
289
+ // for the whole deposit). Accept only:
290
+ // - exactly the amount of the voucher WE signed (it was committed earlier), or
291
+ // - a lower amount (signing less than before cannot hurt us).
292
+ const claimed = BigInt(charged);
293
+ if (claimed === BigInt(pending.amountSpent)) {
294
+ this.options.store.confirm(channel.aa, pending.period, claimed);
295
+ } else if (pending.period === channel.period && claimed <= channel.committed) {
296
+ this.options.store.dropPending(channel.aa, { period: pending.period, committed: claimed });
297
+ } else {
298
+ this.options.store.dropPending(channel.aa);
299
+ this.log.error(
300
+ { aa: channel.aa, claimed: charged, local: channel.committed.toString() },
301
+ 'seller claimed a committed amount we never signed; refusing to resync',
302
+ );
303
+ }
304
+ } else if (
305
+ reason !== undefined &&
306
+ reason !== 'settlement_pending' &&
307
+ reason !== BATCH_REASONS.busy
308
+ ) {
309
+ // Definitely refused (incl. watcher_unavailable at verification): nothing was stored.
310
+ this.options.store.dropPending(channel.aa);
311
+ }
312
+ // settlement_pending / busy / no answer: keep the voucher pending; retry the same operation.
313
+ }
314
+ }