@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.
- package/README.md +4 -0
- package/dist/batch/client.d.ts +24 -1
- package/dist/batch/client.d.ts.map +1 -1
- package/dist/batch/client.js +64 -34
- package/dist/batch/client.js.map +1 -1
- package/dist/batch/facilitator.d.ts +14 -0
- package/dist/batch/facilitator.d.ts.map +1 -1
- package/dist/batch/facilitator.js +95 -75
- package/dist/batch/facilitator.js.map +1 -1
- package/dist/exact/client.d.ts +7 -0
- package/dist/exact/client.d.ts.map +1 -1
- package/dist/exact/client.js +31 -18
- package/dist/exact/client.js.map +1 -1
- package/dist/exact/facilitator.d.ts +2 -2
- package/dist/exact/facilitator.d.ts.map +1 -1
- package/dist/exact/facilitator.js +3 -3
- package/dist/exact/facilitator.js.map +1 -1
- package/dist/exact/ledger-checks.d.ts +1 -2
- package/dist/exact/ledger-checks.d.ts.map +1 -1
- package/dist/exact/ledger-checks.js +32 -8
- package/dist/exact/ledger-checks.js.map +1 -1
- package/dist/hub/schemas.d.ts +10 -0
- package/dist/hub/schemas.d.ts.map +1 -1
- package/dist/hub/schemas.js +14 -0
- package/dist/hub/schemas.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/batch/client.ts +87 -43
- package/src/batch/facilitator.ts +110 -84
- package/src/exact/client.ts +43 -22
- package/src/exact/facilitator.ts +2 -4
- package/src/exact/ledger-checks.ts +46 -9
- package/src/hub/schemas.ts +29 -0
- package/src/index.ts +2 -0
package/src/batch/client.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
187
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
233
|
-
|
|
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:
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/batch/facilitator.ts
CHANGED
|
@@ -186,8 +186,7 @@ export class BatchObyteFacilitator implements SchemeNetworkFacilitator {
|
|
|
186
186
|
channelKey: string,
|
|
187
187
|
network: Network,
|
|
188
188
|
): Promise<SettleResponse> {
|
|
189
|
-
|
|
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
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|
252
|
-
if (
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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: '
|
|
243
|
+
kind: 'voucher_refused',
|
|
281
244
|
data: {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
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(
|
package/src/exact/client.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
}
|
package/src/exact/facilitator.ts
CHANGED
|
@@ -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
|
|
26
|
-
|
|
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
|
-
|
|
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),
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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);
|
package/src/hub/schemas.ts
CHANGED
|
@@ -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
|
+
}
|