@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.
- package/README.md +4 -0
- package/dist/batch/client.d.ts +7 -1
- package/dist/batch/client.d.ts.map +1 -1
- package/dist/batch/client.js +14 -0
- package/dist/batch/client.js.map +1 -1
- package/dist/batch/facilitator.d.ts +2 -2
- package/dist/batch/facilitator.d.ts.map +1 -1
- package/dist/batch/facilitator.js +7 -16
- package/dist/batch/facilitator.js.map +1 -1
- package/dist/batch/gateway.d.ts +3 -1
- package/dist/batch/gateway.d.ts.map +1 -1
- package/dist/batch/store.d.ts +8 -0
- package/dist/batch/store.d.ts.map +1 -1
- package/dist/batch/store.js +6 -0
- package/dist/batch/store.js.map +1 -1
- package/dist/batch/watcher-protocol.d.ts +3 -1
- package/dist/batch/watcher-protocol.d.ts.map +1 -1
- package/dist/batch/watcher-protocol.js +3 -1
- package/dist/batch/watcher-protocol.js.map +1 -1
- package/dist/exact/facilitator.d.ts +17 -0
- package/dist/exact/facilitator.d.ts.map +1 -1
- package/dist/exact/facilitator.js +105 -75
- package/dist/exact/facilitator.js.map +1 -1
- package/dist/exact/store.d.ts +3 -0
- package/dist/exact/store.d.ts.map +1 -1
- package/dist/exact/store.js +5 -0
- package/dist/exact/store.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/util/keyed-queue.d.ts +13 -0
- package/dist/util/keyed-queue.d.ts.map +1 -0
- package/dist/util/keyed-queue.js +25 -0
- package/dist/util/keyed-queue.js.map +1 -0
- package/package.json +1 -1
- package/src/batch/client.ts +14 -0
- package/src/batch/facilitator.ts +7 -15
- package/src/batch/gateway.ts +3 -1
- package/src/batch/store.ts +16 -0
- package/src/batch/watcher-protocol.ts +3 -1
- package/src/exact/facilitator.ts +122 -77
- package/src/exact/store.ts +8 -0
- package/src/index.ts +1 -0
- package/src/util/keyed-queue.ts +25 -0
package/src/exact/facilitator.ts
CHANGED
|
@@ -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
|
-
|
|
312
|
-
|
|
329
|
+
let problem: string | undefined;
|
|
313
330
|
if (record.state === 'claimed' || record.state === 'broadcasting') {
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
434
|
+
await this.options.store.touch(record.key);
|
|
435
|
+
return record;
|
|
391
436
|
}
|
|
392
437
|
|
|
393
438
|
private async move(
|
package/src/exact/store.ts
CHANGED
|
@@ -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
|
@@ -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
|
+
}
|