@obbyx/x402-obyte 0.2.1 → 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.
@@ -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
+ }
package/src/index.ts CHANGED
@@ -107,5 +107,7 @@ export {
107
107
  parseHistory,
108
108
  parseLedgerUnit,
109
109
  parseStateVars,
110
+ type AssetBalance,
111
+ parseBalances,
110
112
  } from './hub/schemas.js';
111
113
  export { KeyedQueue } from './util/keyed-queue.js';