@obbyx/x402-obyte 0.1.0 → 0.2.1

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 (45) hide show
  1. package/README.md +4 -0
  2. package/dist/batch/client.d.ts +7 -1
  3. package/dist/batch/client.d.ts.map +1 -1
  4. package/dist/batch/client.js +14 -0
  5. package/dist/batch/client.js.map +1 -1
  6. package/dist/batch/facilitator.d.ts +2 -2
  7. package/dist/batch/facilitator.d.ts.map +1 -1
  8. package/dist/batch/facilitator.js +7 -16
  9. package/dist/batch/facilitator.js.map +1 -1
  10. package/dist/batch/gateway.d.ts +3 -1
  11. package/dist/batch/gateway.d.ts.map +1 -1
  12. package/dist/batch/store.d.ts +8 -0
  13. package/dist/batch/store.d.ts.map +1 -1
  14. package/dist/batch/store.js +6 -0
  15. package/dist/batch/store.js.map +1 -1
  16. package/dist/batch/watcher-protocol.d.ts +3 -1
  17. package/dist/batch/watcher-protocol.d.ts.map +1 -1
  18. package/dist/batch/watcher-protocol.js +3 -1
  19. package/dist/batch/watcher-protocol.js.map +1 -1
  20. package/dist/exact/facilitator.d.ts +17 -0
  21. package/dist/exact/facilitator.d.ts.map +1 -1
  22. package/dist/exact/facilitator.js +105 -75
  23. package/dist/exact/facilitator.js.map +1 -1
  24. package/dist/exact/store.d.ts +3 -0
  25. package/dist/exact/store.d.ts.map +1 -1
  26. package/dist/exact/store.js +5 -0
  27. package/dist/exact/store.js.map +1 -1
  28. package/dist/index.d.ts +1 -0
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +1 -0
  31. package/dist/index.js.map +1 -1
  32. package/dist/util/keyed-queue.d.ts +13 -0
  33. package/dist/util/keyed-queue.d.ts.map +1 -0
  34. package/dist/util/keyed-queue.js +25 -0
  35. package/dist/util/keyed-queue.js.map +1 -0
  36. package/package.json +1 -1
  37. package/src/batch/client.ts +14 -0
  38. package/src/batch/facilitator.ts +7 -15
  39. package/src/batch/gateway.ts +3 -1
  40. package/src/batch/store.ts +16 -0
  41. package/src/batch/watcher-protocol.ts +3 -1
  42. package/src/exact/facilitator.ts +122 -77
  43. package/src/exact/store.ts +8 -0
  44. package/src/index.ts +1 -0
  45. package/src/util/keyed-queue.ts +25 -0
@@ -44,6 +44,9 @@ import { type SettlementRecord, type SettlementStore, TERMINAL_STATES } from './
44
44
  * {@link hubCall} for timeouts and unknown-outcome classification (I7).
45
45
  */
46
46
  export type FinalityPolicy = 'stable' | 'accepted';
47
+
48
+ /** How long a unit we posted is still looked for after the publication window (s). */
49
+ const POSTED_UNIT_GRACE_SECONDS = 86_400;
47
50
  export { EXACT_SETTLE_REASONS } from './reasons.js';
48
51
  export { HISTORY_PROBE_ADDRESS, isTransientPostError } from './finality.js';
49
52
 
@@ -145,6 +148,17 @@ export class ExactObyteFacilitator implements SchemeNetworkFacilitator {
145
148
  { proofId: unitHash, reason: checked.reason },
146
149
  'settle rejected at verification',
147
150
  );
151
+ // The hub did not answer: outcome unknown, nothing claimed yet. Pending
152
+ // (non-empty transaction) so callers keep the payment and retry.
153
+ if (checked.reason === EXACT_SETTLE_REASONS.ledger) {
154
+ return {
155
+ success: false,
156
+ errorReason: 'settlement_pending',
157
+ errorMessage: checked.message,
158
+ transaction: unitHash,
159
+ network,
160
+ };
161
+ }
148
162
  return failure(network, checked.reason, checked.message);
149
163
  }
150
164
  const { facts, req } = checked.value;
@@ -303,91 +317,122 @@ export class ExactObyteFacilitator implements SchemeNetworkFacilitator {
303
317
  return promise;
304
318
  }
305
319
 
320
+ /**
321
+ * One pass of the state machine: publish (or find) the unit, then follow it
322
+ * to finality. Every hop is a compare-and-set; anything unknown leaves the
323
+ * record where it is, to be resumed by a retry or the recovery worker.
324
+ */
306
325
  private async step(initial: SettlementRecord): Promise<SettleResponse> {
307
- const store = this.options.store;
308
326
  const client = this.options.clients.get(initial.network);
309
- let record = (await store.get(initial.key)) ?? initial;
327
+ let record = (await this.options.store.get(initial.key)) ?? initial;
310
328
  if (client === undefined) return this.respond(record, 'Network is not served');
311
- const t = this.hubTimeoutMs;
312
-
329
+ let problem: string | undefined;
313
330
  if (record.state === 'claimed' || record.state === 'broadcasting') {
314
- // Is it already on the ledger (we or someone else published it)?
315
- const known = await hubCall(() => client.api.getJoint(record.unitHash), t);
316
- if (known.kind === 'ok' && known.value.joint !== undefined) {
317
- record = await this.move(record, 'accepted', { data: { via: 'get_joint' } });
318
- } else if (known.kind !== 'ok') {
319
- await store.event({
320
- key: record.key,
321
- kind: 'hub_unavailable',
322
- data: { op: 'get_joint', error: known.error },
323
- });
324
- return this.respond(record);
325
- } else if (this.now() > record.publishDeadline) {
326
- record = await this.move(record, 'expired', { lastError: 'publication window closed' });
327
- return this.respond(record);
328
- } else {
329
- if (record.state === 'claimed') record = await this.move(record, 'broadcasting');
330
- if (record.unitJson === null) return this.respond(record, 'Unit body is no longer stored');
331
- const unit = JSON.parse(record.unitJson) as obyte.Unit;
332
- const posted = await hubCall(() => client.api.postJoint({ unit } as never), t);
333
- await store.event({ key: record.key, kind: 'post_joint', data: { outcome: posted.kind } });
334
- if (posted.kind === 'ok') {
335
- record = await this.move(record, 'accepted', {
336
- incrementAttempts: true,
337
- data: { response: posted.value },
338
- });
339
- } else if (posted.kind === 'hub_error' && isTransientPostError(posted.error)) {
340
- // The hub is still working on it or lacks context: not a rejection.
341
- // Stay in broadcasting; the next attempt resolves it via get_joint.
342
- this.log.info(
343
- { proofId: record.unitHash, hubError: posted.error },
344
- 'broadcast not final yet (transient hub answer)',
345
- );
346
- return this.respond(record);
347
- } else if (posted.kind === 'hub_error') {
348
- // Definite rejection — unless the unit is known after all (e.g. posted earlier).
349
- const recheck = await hubCall(() => client.api.getJoint(record.unitHash), t);
350
- if (recheck.kind === 'ok' && recheck.value.joint !== undefined) {
351
- record = await this.move(record, 'accepted', {
352
- data: { via: 'get_joint after hub error' },
353
- });
354
- } else if (recheck.kind === 'ok') {
355
- record = await this.move(record, 'rejected', {
356
- lastError: posted.error,
357
- incrementAttempts: true,
358
- });
359
- this.log.warn(
360
- { proofId: record.unitHash, hubError: posted.error },
361
- 'unit rejected by the network',
362
- );
363
- return this.respond(record);
364
- } else {
365
- return this.respond(record);
366
- }
367
- } else {
368
- // Unknown outcome: stay in broadcasting; the next attempt resolves it via get_joint.
369
- this.log.warn(
370
- { proofId: record.unitHash, error: posted.error },
371
- 'broadcast outcome unknown',
372
- );
373
- return this.respond(record);
374
- }
375
- }
331
+ ({ record, problem } = await this.publish(client, record));
332
+ }
333
+ if (record.state === 'accepted') record = await this.awaitFinality(client, record);
334
+ return this.respond(record, problem);
335
+ }
336
+
337
+ /** Before acceptance: find the unit on the ledger, otherwise post it while the window is open. */
338
+ private async publish(
339
+ client: obyte.Client,
340
+ record: SettlementRecord,
341
+ ): Promise<{ record: SettlementRecord; problem?: string }> {
342
+ // Is it already on the ledger (we or someone else published it)?
343
+ const known = await hubCall(() => client.api.getJoint(record.unitHash), this.hubTimeoutMs);
344
+ if (known.kind !== 'ok') {
345
+ await this.options.store.event({
346
+ key: record.key,
347
+ kind: 'hub_unavailable',
348
+ data: { op: 'get_joint', error: known.error },
349
+ });
350
+ return { record };
351
+ }
352
+ if (known.value.joint !== undefined) {
353
+ return { record: await this.move(record, 'accepted', { data: { via: 'get_joint' } }) };
376
354
  }
355
+ if (this.now() > record.publishDeadline)
356
+ return { record: await this.afterPublishWindow(record) };
357
+ if (record.unitJson === null) return { record, problem: 'Unit body is no longer stored' };
358
+ return { record: await this.broadcast(client, record, record.unitJson) };
359
+ }
377
360
 
378
- if (record.state === 'accepted') {
379
- const finality = await finalityOf(
380
- { hubTimeoutMs: this.hubTimeoutMs, historyProbeAddress: this.options.historyProbeAddress },
381
- client,
382
- record.unitHash,
361
+ /**
362
+ * Past the publication window we never post. A unit we never posted expires.
363
+ * One we posted (outcome unknown or "not yet") may still land, so it is
364
+ * looked for during a grace period before it expires.
365
+ */
366
+ private async afterPublishWindow(record: SettlementRecord): Promise<SettlementRecord> {
367
+ if (record.attempts === 0) {
368
+ return this.move(record, 'expired', { lastError: 'publication window closed' });
369
+ }
370
+ if (this.now() > record.publishDeadline + POSTED_UNIT_GRACE_SECONDS) {
371
+ return this.move(record, 'expired', { lastError: 'posted, but never seen on the ledger' });
372
+ }
373
+ await this.options.store.touch(record.key);
374
+ return record;
375
+ }
376
+
377
+ /** Posts the unit once and classifies the hub's answer. */
378
+ private async broadcast(
379
+ client: obyte.Client,
380
+ initial: SettlementRecord,
381
+ unitJson: string,
382
+ ): Promise<SettlementRecord> {
383
+ // Count the attempt BEFORE posting: a crash mid-post still counts as "maybe sent".
384
+ const record = await this.move(initial, 'broadcasting', { incrementAttempts: true });
385
+ const unit = JSON.parse(unitJson) as obyte.Unit;
386
+ const posted = await hubCall(() => client.api.postJoint({ unit } as never), this.hubTimeoutMs);
387
+ await this.options.store.event({
388
+ key: record.key,
389
+ kind: 'post_joint',
390
+ data: { outcome: posted.kind },
391
+ });
392
+ const proofId = record.unitHash;
393
+
394
+ if (posted.kind === 'ok') {
395
+ return this.move(record, 'accepted', { data: { response: posted.value } });
396
+ }
397
+ if (posted.kind === 'unknown') {
398
+ this.log.warn({ proofId, error: posted.error }, 'broadcast outcome unknown');
399
+ return record; // resolved via get_joint on the next attempt
400
+ }
401
+ if (isTransientPostError(posted.error)) {
402
+ // The hub is still working on it or lacks context: not a rejection.
403
+ this.log.info(
404
+ { proofId, hubError: posted.error },
405
+ 'broadcast not final yet (transient hub answer)',
383
406
  );
384
- if (finality === 'good') record = await this.move(record, 'stable_good');
385
- else if (finality === 'final_bad') {
386
- record = await this.move(record, 'final_bad', { lastError: 'double spend (final-bad)' });
387
- this.log.error({ proofId: record.unitHash }, 'settled unit became final-bad');
388
- }
407
+ return record;
408
+ }
409
+ // Definite rejection — unless the unit is known after all (e.g. posted earlier).
410
+ const recheck = await hubCall(() => client.api.getJoint(proofId), this.hubTimeoutMs);
411
+ if (recheck.kind !== 'ok') return record;
412
+ if (recheck.value.joint !== undefined) {
413
+ return this.move(record, 'accepted', { data: { via: 'get_joint after hub error' } });
414
+ }
415
+ this.log.warn({ proofId, hubError: posted.error }, 'unit rejected by the network');
416
+ return this.move(record, 'rejected', { lastError: posted.error });
417
+ }
418
+
419
+ /** After acceptance: stable and good, final-bad, or still waiting (rotated for recovery). */
420
+ private async awaitFinality(
421
+ client: obyte.Client,
422
+ record: SettlementRecord,
423
+ ): Promise<SettlementRecord> {
424
+ const finality = await finalityOf(
425
+ { hubTimeoutMs: this.hubTimeoutMs, historyProbeAddress: this.options.historyProbeAddress },
426
+ client,
427
+ record.unitHash,
428
+ );
429
+ if (finality === 'good') return this.move(record, 'stable_good');
430
+ if (finality === 'final_bad') {
431
+ this.log.error({ proofId: record.unitHash }, 'settled unit became final-bad');
432
+ return this.move(record, 'final_bad', { lastError: 'double spend (final-bad)' });
389
433
  }
390
- return this.respond(record);
434
+ await this.options.store.touch(record.key);
435
+ return record;
391
436
  }
392
437
 
393
438
  private async move(
@@ -85,6 +85,8 @@ export interface SettlementStore {
85
85
  ): Promise<SettlementRecord | undefined>;
86
86
  event(event: SettlementEvent): Promise<void>;
87
87
  listActive(limit?: number): Promise<SettlementRecord[]>;
88
+ /** Marks a non-terminal record as just looked at (fair rotation in the recovery worker). */
89
+ touch(key: string): Promise<void>;
88
90
  events(
89
91
  key: string,
90
92
  ): Promise<{ kind: string; from: string | null; to: string | null; data: unknown }[]>;
@@ -288,6 +290,12 @@ export class SqliteSettlementStore implements SettlementStore {
288
290
  }
289
291
 
290
292
  /** Non-terminal settlements, oldest first — resumed by the recovery worker. */
293
+ async touch(key: string): Promise<void> {
294
+ this.db
295
+ .prepare('UPDATE exact_settlements SET updated_at = ? WHERE key = ?')
296
+ .run(this.now(), key);
297
+ }
298
+
291
299
  async listActive(limit = 100): Promise<SettlementRecord[]> {
292
300
  const rows = this.db
293
301
  .prepare(
package/src/index.ts CHANGED
@@ -108,3 +108,4 @@ export {
108
108
  parseLedgerUnit,
109
109
  parseStateVars,
110
110
  } from './hub/schemas.js';
111
+ export { KeyedQueue } from './util/keyed-queue.js';
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Runs tasks one at a time per key, in arrival order; different keys run in
3
+ * parallel. A failing task never blocks the ones queued after it. Used where
4
+ * check-then-act must not interleave within one process: per channel in the
5
+ * batch facilitator and in the seller watcher.
6
+ */
7
+ export class KeyedQueue {
8
+ private readonly tails = new Map<string, Promise<unknown>>();
9
+
10
+ run<T>(key: string, task: () => Promise<T>): Promise<T> {
11
+ const previous = this.tails.get(key) ?? Promise.resolve();
12
+ const result = previous.then(task, task);
13
+ const tail = result.catch(() => undefined);
14
+ this.tails.set(key, tail);
15
+ void tail.then(() => {
16
+ if (this.tails.get(key) === tail) this.tails.delete(key);
17
+ });
18
+ return result;
19
+ }
20
+
21
+ /** Keys with queued or running tasks (for tests and diagnostics). */
22
+ get size(): number {
23
+ return this.tails.size;
24
+ }
25
+ }