@obbyx/x402-obyte 0.2.0 → 0.2.2

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.
@@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto';
2
2
  import type {
3
3
  PaymentPayloadResult,
4
4
  PaymentRequirements,
5
+ SchemeClientHooks,
5
6
  SchemeNetworkClient,
6
7
  SettleResponse,
7
8
  } from '@x402/core/types';
@@ -18,8 +19,13 @@ import obyte from 'obyte';
18
19
  import type { ReservingComposer } from '../coins/index.js';
19
20
  import { hubCall } from '../hub/hub-call.js';
20
21
  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';
22
+ import type { ClientChannel, ClientChannelStore, PendingVoucher } from './client-store.js';
23
+ import {
24
+ BATCH_REASONS,
25
+ BATCH_SCHEME,
26
+ type ParsedBatchRequirements,
27
+ parseBatchRequirements,
28
+ } from './requirements.js';
23
29
 
24
30
  /**
25
31
  * Buyer side of `batch-settlement` on Obyte (party B of an AA channel).
@@ -159,6 +165,16 @@ export function confirmClose(
159
165
 
160
166
  export class BatchObyteClient implements SchemeNetworkClient {
161
167
  readonly scheme = BATCH_SCHEME;
168
+ /**
169
+ * The official x402 clients (`@x402/fetch`, `@x402/axios`) report the
170
+ * seller's PAYMENT-RESPONSE here, so the voucher is confirmed or released
171
+ * exactly as with `payingFetch`.
172
+ */
173
+ readonly schemeHooks: SchemeClientHooks = {
174
+ onPaymentResponse: async (ctx) => {
175
+ this.onSettled(ctx.requirements, ctx.settleResponse);
176
+ },
177
+ };
162
178
  private readonly log: Logger;
163
179
 
164
180
  constructor(private readonly options: BatchClientOptions) {
@@ -183,8 +199,33 @@ export class BatchObyteClient implements SchemeNetworkClient {
183
199
  if (cap !== undefined && req.amount > cap) {
184
200
  throw new ObbyxError('amount_over_limit', `Price ${req.amount} exceeds the limit ${cap}`);
185
201
  }
186
- const store = this.options.store;
187
- const channel = store.find(req.network.caip2, req.payTo, req.asset);
202
+ const channel = this.channelFor(req);
203
+
204
+ // Same operation retried: resend the same voucher (never sign a second one).
205
+ if (channel.pending !== null) {
206
+ if (channel.pending.binding === req.extra.binding)
207
+ return { x402Version: 2, payload: { signedPackage: channel.pending.signedPackage } };
208
+ throw new ObbyxError(
209
+ 'payment_pending',
210
+ `A previous voucher on channel ${channel.aa} has an unknown outcome; retry that request first`,
211
+ );
212
+ }
213
+
214
+ const { period, balance } = await this.ledgerState(channel);
215
+ const committed = period > channel.period ? 0n : channel.committed;
216
+ const amountSpent = committed + req.amount;
217
+ if (amountSpent > balance) {
218
+ throw new ObbyxError(
219
+ 'channel_exhausted',
220
+ `Channel balance ${balance} is not enough; deposit more with: obbyx channel deposit ${channel.aa}`,
221
+ );
222
+ }
223
+ return this.signVoucher(channel.aa, period, amountSpent, req);
224
+ }
225
+
226
+ /** Our open channel to this seller, with a dispute window the seller accepts. */
227
+ private channelFor(req: ParsedBatchRequirements): ClientChannel {
228
+ const channel = this.options.store.find(req.network.caip2, req.payTo, req.asset);
188
229
  if (channel === undefined) {
189
230
  throw new ObbyxError(
190
231
  'no_channel',
@@ -197,17 +238,14 @@ export class BatchObyteClient implements SchemeNetworkClient {
197
238
  `The seller requires a channel timeout ≥ ${req.extra.minTimeout} s`,
198
239
  );
199
240
  }
241
+ return channel;
242
+ }
200
243
 
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
-
244
+ /**
245
+ * The channel's current period and confirmed deposit, from the AA state.
246
+ * A new period (the channel was closed and reopened) resets our local amount.
247
+ */
248
+ private async ledgerState(channel: ClientChannel): Promise<{ period: number; balance: bigint }> {
211
249
  const vars = await hubCall(
212
250
  () => this.options.client.api.getAaStateVars({ address: channel.aa }),
213
251
  this.options.hubTimeoutMs ?? 15_000,
@@ -225,19 +263,20 @@ export class BatchObyteClient implements SchemeNetworkClient {
225
263
  );
226
264
  if (state['close_initiated_by'] !== undefined)
227
265
  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;
266
+ if (period > channel.period) this.options.store.newPeriod(channel.aa, period);
231
267
  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
- }
268
+ return { period, balance };
269
+ }
238
270
 
271
+ /** Signs the voucher and records it as THE pending one of the channel (one at a time). */
272
+ private signVoucher(
273
+ aa: string,
274
+ period: number,
275
+ amountSpent: bigint,
276
+ req: ParsedBatchRequirements,
277
+ ): PaymentPayloadResult {
239
278
  const message = {
240
- aa_address: channel.aa,
279
+ aa_address: aa,
241
280
  period,
242
281
  amount_spent: toSafeNumber(amountSpent),
243
282
  payment_amount: toSafeNumber(req.amount),
@@ -253,11 +292,11 @@ export class BatchObyteClient implements SchemeNetworkClient {
253
292
  amountSpent: amountSpent.toString(),
254
293
  signedPackage,
255
294
  };
256
- if (!store.setPending(channel.aa, pending)) {
295
+ if (!this.options.store.setPending(aa, pending)) {
257
296
  throw new ObbyxError('payment_pending', 'Another voucher on this channel is in flight');
258
297
  }
259
298
  this.log.info(
260
- { aa: channel.aa, period, amountSpent: amountSpent.toString(), binding: req.extra.binding },
299
+ { aa, period, amountSpent: amountSpent.toString(), binding: req.extra.binding },
261
300
  'voucher signed',
262
301
  );
263
302
  return { x402Version: 2, payload: { signedPackage } };
@@ -287,23 +326,7 @@ export class BatchObyteClient implements SchemeNetworkClient {
287
326
  typeof charged === 'string' &&
288
327
  /^\d+$/.test(charged)
289
328
  ) {
290
- // PAYMENT-RESPONSE is NOT authenticated: never raise our committed amount
291
- // to an arbitrary value (a malicious seller could make us sign a voucher
292
- // for the whole deposit). Accept only:
293
- // - exactly the amount of the voucher WE signed (it was committed earlier), or
294
- // - a lower amount (signing less than before cannot hurt us).
295
- const claimed = BigInt(charged);
296
- if (claimed === BigInt(pending.amountSpent)) {
297
- this.options.store.confirm(channel.aa, pending.period, claimed);
298
- } else if (pending.period === channel.period && claimed <= channel.committed) {
299
- this.options.store.dropPending(channel.aa, { period: pending.period, committed: claimed });
300
- } else {
301
- this.options.store.dropPending(channel.aa);
302
- this.log.error(
303
- { aa: channel.aa, claimed: charged, local: channel.committed.toString() },
304
- 'seller claimed a committed amount we never signed; refusing to resync',
305
- );
306
- }
329
+ this.resync(channel, pending, BigInt(charged));
307
330
  } else if (
308
331
  reason !== undefined &&
309
332
  reason !== 'settlement_pending' &&
@@ -314,4 +337,25 @@ export class BatchObyteClient implements SchemeNetworkClient {
314
337
  }
315
338
  // settlement_pending / busy / no answer: keep the voucher pending; retry the same operation.
316
339
  }
340
+
341
+ /**
342
+ * The seller says our committed amount differs. PAYMENT-RESPONSE is NOT
343
+ * authenticated: never raise our committed amount to an arbitrary value (a
344
+ * malicious seller could make us sign a voucher for the whole deposit).
345
+ * Accept only exactly the voucher WE signed (it was committed earlier), or a
346
+ * lower amount (signing less than before cannot hurt us).
347
+ */
348
+ private resync(channel: ClientChannel, pending: PendingVoucher, claimed: bigint): void {
349
+ if (claimed === BigInt(pending.amountSpent)) {
350
+ this.options.store.confirm(channel.aa, pending.period, claimed);
351
+ } else if (pending.period === channel.period && claimed <= channel.committed) {
352
+ this.options.store.dropPending(channel.aa, { period: pending.period, committed: claimed });
353
+ } else {
354
+ this.options.store.dropPending(channel.aa);
355
+ this.log.error(
356
+ { aa: channel.aa, claimed: claimed.toString(), local: channel.committed.toString() },
357
+ 'seller claimed a committed amount we never signed; refusing to resync',
358
+ );
359
+ }
360
+ }
317
361
  }
@@ -186,8 +186,7 @@ export class BatchObyteFacilitator implements SchemeNetworkFacilitator {
186
186
  channelKey: string,
187
187
  network: Network,
188
188
  ): Promise<SettleResponse> {
189
- const store = this.options.store;
190
- let commitment = await store.commitmentByHash(c.packageHash);
189
+ let commitment = await this.options.store.commitmentByHash(c.packageHash);
191
190
  if (commitment !== undefined && commitment.binding !== c.voucher.binding) {
192
191
  return failure(
193
192
  network,
@@ -196,96 +195,123 @@ export class BatchObyteFacilitator implements SchemeNetworkFacilitator {
196
195
  );
197
196
  }
198
197
  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
- );
198
+ const appended = await this.append(c, channelKey, network);
199
+ if ('success' in appended) return appended;
200
+ commitment = appended;
248
201
  }
249
-
250
202
  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)');
203
+ const committed = await this.handToWatcher(c, commitment, channelKey, network);
204
+ if ('success' in committed) return committed;
205
+ commitment = committed;
206
+ }
207
+ return this.success(network, c, commitment);
208
+ }
209
+
210
+ /**
211
+ * Appends the voucher to the channel journal as pending. The store enforces
212
+ * committed + price, the period and one voucher in flight per channel; a
213
+ * refusal reports the committed amount so an honest client can resync.
214
+ */
215
+ private async append(
216
+ c: Authenticated,
217
+ channelKey: string,
218
+ network: Network,
219
+ ): Promise<CommitmentRow | SettleResponse> {
220
+ const appended = await this.options.store.appendPending({
221
+ channelKey,
222
+ network: c.req.network.caip2,
223
+ aa: c.voucher.aa_address,
224
+ payTo: c.req.payTo,
225
+ payer: c.payer,
226
+ asset: c.req.asset,
227
+ period: c.voucher.period,
228
+ amountSpent: BigInt(c.voucher.amount_spent),
229
+ paymentAmount: BigInt(c.voucher.payment_amount),
230
+ binding: c.voucher.binding,
231
+ packageHash: c.packageHash,
232
+ packageJson: c.packageJson,
233
+ });
234
+ if (!appended.ok) {
235
+ const reason =
236
+ appended.reason === 'channel_busy'
237
+ ? R.busy
238
+ : appended.reason === 'period_mismatch'
239
+ ? R.period
240
+ : R.cumulative;
278
241
  await this.emit({
279
242
  key: channelKey,
280
- kind: 'voucher_committed',
243
+ kind: 'voucher_refused',
281
244
  data: {
282
- seq: commitment.seq,
283
- period: commitment.period,
284
- amountSpent: commitment.amountSpent.toString(),
245
+ reason,
246
+ amountSpent: c.voucher.amount_spent,
247
+ committed: appended.committed.toString(),
285
248
  },
286
249
  });
250
+ return {
251
+ ...failure(network, reason, `Voucher rejected: ${appended.reason}`),
252
+ extra: { chargedCumulativeAmount: appended.committed.toString() },
253
+ };
287
254
  }
288
- return this.success(network, c, commitment);
255
+ const commitment = appended.commitment;
256
+ await this.emit({
257
+ key: channelKey,
258
+ kind: 'voucher_pending',
259
+ data: {
260
+ seq: commitment.seq,
261
+ period: commitment.period,
262
+ amountSpent: commitment.amountSpent.toString(),
263
+ },
264
+ });
265
+ this.log.info(
266
+ { seq: commitment.seq, amountSpent: commitment.amountSpent.toString() },
267
+ 'voucher pending',
268
+ );
269
+ return commitment;
270
+ }
271
+
272
+ /**
273
+ * Delivers a pending voucher to the seller's watcher and commits it on its
274
+ * ack. Without the ack the voucher is stored here but not confirmed by the
275
+ * watcher: the outcome is unknown to the client → settlement_pending (x402:
276
+ * non-empty transaction), retried with the SAME voucher. Distinct from the
277
+ * definite refusal `…_watcher_unavailable` at verification.
278
+ */
279
+ private async handToWatcher(
280
+ c: Authenticated,
281
+ pending: CommitmentRow,
282
+ channelKey: string,
283
+ network: Network,
284
+ ): Promise<CommitmentRow | SettleResponse> {
285
+ const delivered = await this.options.gateway.deliver(pending, this.ackTimeoutMs);
286
+ if (delivered !== 'acked') {
287
+ this.log.warn({ seq: pending.seq }, 'watcher did not ack in time; voucher stays pending');
288
+ await this.emit({ key: channelKey, kind: 'ack_timeout', data: { seq: pending.seq } });
289
+ return {
290
+ success: false,
291
+ errorReason: 'settlement_pending',
292
+ errorMessage:
293
+ "Waiting for the seller's watcher to store this voucher; retry with the same voucher",
294
+ transaction: commitmentIdOf(network, pending),
295
+ network,
296
+ payer: c.payer,
297
+ };
298
+ }
299
+ const commitment =
300
+ (await this.options.store.commit(pending.seq, {
301
+ payTo: pending.payTo,
302
+ network: pending.network,
303
+ })) ?? pending;
304
+ this.log.info({ seq: commitment.seq }, 'voucher committed (watcher ack)');
305
+ await this.emit({
306
+ key: channelKey,
307
+ kind: 'voucher_committed',
308
+ data: {
309
+ seq: commitment.seq,
310
+ period: commitment.period,
311
+ amountSpent: commitment.amountSpent.toString(),
312
+ },
313
+ });
314
+ return commitment;
289
315
  }
290
316
 
291
317
  private success(
@@ -9,7 +9,11 @@ import { type Logger, moduleLogger } from '@obbyx/obyte/logger';
9
9
  import type obyte from 'obyte';
10
10
  import type { ReservingComposer } from '../coins/index.js';
11
11
  import { type PaymentRequirementsV1, requirementsV1ToV2 } from './facilitator-v1.js';
12
- import { EXACT_SCHEME, parseExactRequirements } from './requirements.js';
12
+ import {
13
+ EXACT_SCHEME,
14
+ type ParsedExactRequirements,
15
+ parseExactRequirements,
16
+ } from './requirements.js';
13
17
 
14
18
  /**
15
19
  * Client side of `exact` on Obyte: composes and signs (but does NOT
@@ -55,6 +59,36 @@ export class ExactObyteClient implements SchemeNetworkClient {
55
59
  requirements: PaymentRequirements,
56
60
  context?: PaymentPayloadContext,
57
61
  ): Promise<PaymentPayloadResult> {
62
+ const req = this.acceptable(x402Version, requirements, context);
63
+ const unit = await this.signUnit(req);
64
+ this.log.info(
65
+ {
66
+ proofId: unit['unit'],
67
+ payTo: req.payTo,
68
+ asset: req.asset,
69
+ amount: req.amount.toString(),
70
+ binding: req.extra.binding,
71
+ },
72
+ 'payment unit signed (not broadcast)',
73
+ );
74
+ // v1: the mechanism returns the complete payload (core passes it through as-is).
75
+ if (x402Version === 1) {
76
+ return {
77
+ x402Version: 1,
78
+ scheme: EXACT_SCHEME,
79
+ network: requirements.network,
80
+ payload: { unit },
81
+ } as unknown as PaymentPayloadResult;
82
+ }
83
+ return { x402Version, payload: { unit } };
84
+ }
85
+
86
+ /** The requirements, if this client may pay them (network, limits, offer still valid). */
87
+ private acceptable(
88
+ x402Version: number,
89
+ requirements: PaymentRequirements,
90
+ context: PaymentPayloadContext | undefined,
91
+ ): ParsedExactRequirements {
58
92
  if (x402Version !== 1 && x402Version !== 2) {
59
93
  throw new ObbyxError('invalid_x402_version', 'Only x402 v1 and v2 are supported');
60
94
  }
@@ -85,7 +119,14 @@ export class ExactObyteClient implements SchemeNetworkClient {
85
119
  if (req.extra.validUntil < Math.floor(Date.now() / 1000)) {
86
120
  throw new ObbyxError('quote_expired', 'The payment offer has expired');
87
121
  }
122
+ return req;
123
+ }
88
124
 
125
+ /**
126
+ * Composes and signs (never broadcasts) the unit: one output to payTo and the
127
+ * binding in a data message. Refuses it when the network fee is above the limit.
128
+ */
129
+ private async signUnit(req: ParsedExactRequirements): Promise<Record<string, unknown>> {
89
130
  const output = { address: req.payTo, amount: toSafeNumber(req.amount) };
90
131
  const payment =
91
132
  req.asset === 'base'
@@ -95,7 +136,6 @@ export class ExactObyteClient implements SchemeNetworkClient {
95
136
  app: 'data',
96
137
  payload: { x402: { binding: req.extra.binding, quoteId: req.extra.quoteId } },
97
138
  };
98
-
99
139
  const compose = () =>
100
140
  this.options.client.compose.message('multi', [payment, data] as never, {
101
141
  privateKey: this.options.privateKey,
@@ -120,25 +160,6 @@ export class ExactObyteClient implements SchemeNetworkClient {
120
160
  `Network fee ${fee} exceeds the limit ${this.options.maxFee} bytes`,
121
161
  );
122
162
  }
123
- this.log.info(
124
- {
125
- proofId: unit['unit'],
126
- payTo: req.payTo,
127
- asset: req.asset,
128
- amount: req.amount.toString(),
129
- binding: req.extra.binding,
130
- },
131
- 'payment unit signed (not broadcast)',
132
- );
133
- // v1: the mechanism returns the complete payload (core passes it through as-is).
134
- if (x402Version === 1) {
135
- return {
136
- x402Version: 1,
137
- scheme: EXACT_SCHEME,
138
- network: requirements.network,
139
- payload: { unit },
140
- } as unknown as PaymentPayloadResult;
141
- }
142
- return { x402Version, payload: { unit } };
163
+ return unit;
143
164
  }
144
165
  }
@@ -90,6 +90,8 @@ export class ExactObyteFacilitator implements SchemeNetworkFacilitator {
90
90
  private readonly now: () => number;
91
91
  /** Serializes settle/resume per settlement key inside this process. */
92
92
  private readonly inFlight = new Map<string, Promise<SettleResponse>>();
93
+ /** Known plain (non-AA) recipients; an address can never turn into an AA. */
94
+ private readonly plainRecipients = new Set<string>();
93
95
 
94
96
  constructor(private readonly options: ExactObyteFacilitatorOptions) {
95
97
  this.log = moduleLogger(options.logger, 'x402.exact', { scheme: EXACT_SCHEME });
@@ -264,7 +266,6 @@ export class ExactObyteFacilitator implements SchemeNetworkFacilitator {
264
266
  now: this.now,
265
267
  },
266
268
  client,
267
- req,
268
269
  facts,
269
270
  unit as Record<string, unknown>,
270
271
  );
@@ -272,9 +273,6 @@ export class ExactObyteFacilitator implements SchemeNetworkFacilitator {
272
273
  return { ok: true, value: { facts, req } };
273
274
  }
274
275
 
275
- /** Known plain (non-AA) recipients; an address can never turn into an AA. */
276
- private readonly plainRecipients = new Set<string>();
277
-
278
276
  private async recipientIsNotAa(client: obyte.Client, payTo: string): Promise<Check<null>> {
279
277
  if (this.plainRecipients.has(payTo)) return { ok: true, value: null };
280
278
  const res = await hubCall(() => client.api.getDefinition(payTo), this.hubTimeoutMs);
@@ -3,7 +3,6 @@ import type { ExactUnitFacts } from '@obbyx/obyte';
3
3
  import { hubCall } from '../hub/hub-call.js';
4
4
  import { type LedgerUnit, parseHistory, parseLedgerUnit } from '../hub/schemas.js';
5
5
  import { type Check, EXACT_SETTLE_REASONS, fail } from './reasons.js';
6
- import type { ParsedExactRequirements } from './requirements.js';
7
6
 
8
7
  export interface LedgerCheckContext {
9
8
  readonly hubTimeoutMs: number;
@@ -18,12 +17,25 @@ export interface LedgerCheckContext {
18
17
  export async function checkLedger(
19
18
  ctx: LedgerCheckContext,
20
19
  client: obyte.Client,
21
- req: ParsedExactRequirements,
22
20
  facts: ExactUnitFacts,
23
21
  unit: Record<string, unknown>,
24
22
  ): Promise<Check<null>> {
25
- const t = ctx.hubTimeoutMs;
26
- const lastBall = await hubCall(() => client.api.getJoint(facts.lastBallUnit), t);
23
+ const recent = await lastBallIsRecent(ctx, client, facts);
24
+ if (!recent.ok) return recent;
25
+ const totals = await inputTotals(ctx, client, facts);
26
+ if (!totals.ok) return totals;
27
+ const balanced = inputsBalance(totals.value, facts, unit);
28
+ if (!balanced.ok) return balanced;
29
+ return inputsUnspent(ctx, client, facts);
30
+ }
31
+
32
+ /** The unit's last ball exists and is not older than the configured limit. */
33
+ async function lastBallIsRecent(
34
+ ctx: LedgerCheckContext,
35
+ client: obyte.Client,
36
+ facts: ExactUnitFacts,
37
+ ): Promise<Check<null>> {
38
+ const lastBall = await hubCall(() => client.api.getJoint(facts.lastBallUnit), ctx.hubTimeoutMs);
27
39
  if (lastBall.kind !== 'ok')
28
40
  return fail(EXACT_SETTLE_REASONS.ledger, `last ball: ${lastBall.error}`);
29
41
  const lastBallTs = lastBall.value.joint?.unit.timestamp;
@@ -36,14 +48,21 @@ export async function checkLedger(
36
48
  `Last ball is ${lastBallAge} s old; the limit is ${ctx.maxLastBallAgeSeconds} s (MAX_LAST_BALL_AGE_SECONDS)`,
37
49
  );
38
50
  }
51
+ return { ok: true, value: null };
52
+ }
39
53
 
40
- // Inputs: each referenced output exists, belongs to the payer, has the right asset.
54
+ /** Each input refers to an existing output of the payer in its asset; sums per asset. */
55
+ async function inputTotals(
56
+ ctx: LedgerCheckContext,
57
+ client: obyte.Client,
58
+ facts: ExactUnitFacts,
59
+ ): Promise<Check<Map<string, bigint>>> {
41
60
  const totals = new Map<string, bigint>();
42
61
  const sources = new Map<string, LedgerUnit>();
43
62
  for (const input of facts.inputs) {
44
63
  let source = sources.get(input.unit);
45
64
  if (source === undefined) {
46
- const res = await hubCall(() => client.api.getJoint(input.unit), t);
65
+ const res = await hubCall(() => client.api.getJoint(input.unit), ctx.hubTimeoutMs);
47
66
  if (res.kind !== 'ok') return fail(EXACT_SETTLE_REASONS.ledger, `input source: ${res.error}`);
48
67
  if (res.value.joint === undefined)
49
68
  return fail(EXACT_SETTLE_REASONS.inputs, 'Input source unit not found');
@@ -71,6 +90,15 @@ export async function checkLedger(
71
90
  }
72
91
  totals.set(asset, (totals.get(asset) ?? 0n) + BigInt(output.amount));
73
92
  }
93
+ return { ok: true, value: totals };
94
+ }
95
+
96
+ /** Inputs equal outputs per asset; bytes also cover every fee of the unit. */
97
+ function inputsBalance(
98
+ totals: ReadonlyMap<string, bigint>,
99
+ facts: ExactUnitFacts,
100
+ unit: Record<string, unknown>,
101
+ ): Check<null> {
74
102
  const fees =
75
103
  BigInt(facts.commissions.headers + facts.commissions.payload) +
76
104
  BigInt((unit['tps_fee'] as number | undefined) ?? 0) +
@@ -81,9 +109,18 @@ export async function checkLedger(
81
109
  return fail(EXACT_SETTLE_REASONS.inputs, `Inputs and outputs do not balance in ${asset}`);
82
110
  }
83
111
  }
112
+ return { ok: true, value: null };
113
+ }
84
114
 
85
- // Unspent: no other unit of the payer spends these inputs (best effort
86
- // until stability; the network decides double-spends definitively).
115
+ /**
116
+ * No other unit of the payer spends these inputs (best effort until
117
+ * stability; the network decides double spends definitively).
118
+ */
119
+ async function inputsUnspent(
120
+ ctx: LedgerCheckContext,
121
+ client: obyte.Client,
122
+ facts: ExactUnitFacts,
123
+ ): Promise<Check<null>> {
87
124
  const history = await hubCall(
88
125
  () =>
89
126
  client.api
@@ -91,7 +128,7 @@ export async function checkLedger(
91
128
  .then((witnesses) =>
92
129
  client.api.getHistory({ witnesses, addresses: [facts.payer] } as never),
93
130
  ),
94
- t,
131
+ ctx.hubTimeoutMs,
95
132
  );
96
133
  if (history.kind !== 'ok') return fail(EXACT_SETTLE_REASONS.ledger, `history: ${history.error}`);
97
134
  const parsedHistory = parseHistory(history.value);
@@ -87,3 +87,32 @@ export function parseStateVars(raw: unknown): Record<string, unknown> | undefine
87
87
  const parsed = z.record(z.string(), z.unknown()).safeParse(raw);
88
88
  return parsed.success ? parsed.data : undefined;
89
89
  }
90
+
91
+ /** Balance of one asset: confirmed (stable) and not yet confirmed (pending), atomic units. */
92
+ export interface AssetBalance {
93
+ readonly stable: bigint;
94
+ readonly pending: bigint;
95
+ }
96
+
97
+ const balancesSchema = z.record(
98
+ z.string(),
99
+ z.record(z.string(), z.object({ stable: safeAmount, pending: safeAmount }).loose()),
100
+ );
101
+
102
+ /**
103
+ * `light/get_balances` for one address: balances by asset ('base' = bytes);
104
+ * an address without funds has none. Undefined when the answer is malformed.
105
+ */
106
+ export function parseBalances(
107
+ raw: unknown,
108
+ address: string,
109
+ ): Record<string, AssetBalance> | undefined {
110
+ const parsed = balancesSchema.safeParse(raw);
111
+ if (!parsed.success) return undefined;
112
+ return Object.fromEntries(
113
+ Object.entries(parsed.data[address] ?? {}).map(([asset, b]) => [
114
+ asset,
115
+ { stable: BigInt(b.stable), pending: BigInt(b.pending) },
116
+ ]),
117
+ );
118
+ }