@dvmkit/sdk 0.0.0 → 0.1.0-rc.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.
Files changed (42) hide show
  1. package/NOTICE +2 -0
  2. package/README.md +38 -2
  3. package/dist/chunk-27V2ILSR.js +291 -0
  4. package/dist/chunk-365P52XQ.js +4121 -0
  5. package/dist/chunk-5GFED3GJ.js +955 -0
  6. package/dist/chunk-6JZIX5WW.js +1155 -0
  7. package/dist/chunk-7IH5SG2A.js +1038 -0
  8. package/dist/chunk-AT6V3SY7.js +102 -0
  9. package/dist/chunk-DCNT4PJS.js +733 -0
  10. package/dist/chunk-DMNLFNTW.js +135 -0
  11. package/dist/chunk-FROTD5XQ.js +70 -0
  12. package/dist/chunk-H25M54MI.js +149 -0
  13. package/dist/chunk-KQAJVVZT.js +712 -0
  14. package/dist/chunk-KXWROQGK.js +74 -0
  15. package/dist/chunk-L4OYF4DQ.js +67 -0
  16. package/dist/chunk-OJ5WFIB2.js +1266 -0
  17. package/dist/chunk-S3XAHZQY.js +63 -0
  18. package/dist/chunk-YG7G4DPZ.js +25 -0
  19. package/dist/credit-ledger-RO4FGSHG.js +28 -0
  20. package/dist/index.d.ts +144 -0
  21. package/dist/index.js +303 -0
  22. package/dist/job-store-6gR4pZRP.d.ts +5350 -0
  23. package/dist/memory-credit-ledger-I2G64DDK.js +9 -0
  24. package/dist/mpp-secret-state-WNAQQ6K4.js +127 -0
  25. package/dist/mpp-setup-MOBWGTWJ.js +30 -0
  26. package/dist/payout-reporter-4TNWRS5F.js +753 -0
  27. package/dist/postgres-consumed-credential-store-VHBT4KEA.js +72 -0
  28. package/dist/postgres-job-store-J5F4GUWU.js +7 -0
  29. package/dist/postgres-kv-store-JFBDP5IP.js +7 -0
  30. package/dist/postgres-replay-store-UJXRT6VO.js +7 -0
  31. package/dist/pricing-4CEB34RM.js +48 -0
  32. package/dist/processed-payment-store-HAA4SFNK.js +11 -0
  33. package/dist/revenue-reporter-GB4WKLDC.js +510 -0
  34. package/dist/server/index.d.ts +4168 -0
  35. package/dist/server/index.js +22716 -0
  36. package/dist/ssrf-DZi-xJyn.d.ts +325 -0
  37. package/dist/tempo-charge-store-6GJEMNUU.js +130 -0
  38. package/dist/tempo-session-store-FTEEGZXA.js +467 -0
  39. package/dist/testing/index.d.ts +135 -0
  40. package/dist/testing/index.js +151 -0
  41. package/dist/x402-35VLYFKZ.js +1272 -0
  42. package/package.json +89 -6
@@ -0,0 +1,753 @@
1
+ import {
2
+ withSdkInitLock
3
+ } from "./chunk-S3XAHZQY.js";
4
+
5
+ // src/sdk/server/payout-reporter.ts
6
+ import { createHash, randomUUID } from "crypto";
7
+ var MemoryX402BatchStore = class {
8
+ batches = /* @__PURE__ */ new Map();
9
+ get(scope) {
10
+ return Promise.resolve(this.batches.get(scope));
11
+ }
12
+ put(batch) {
13
+ this.batches.set(batch.scope, { ...batch });
14
+ return Promise.resolve();
15
+ }
16
+ delete(scope) {
17
+ this.batches.delete(scope);
18
+ return Promise.resolve();
19
+ }
20
+ async settle(scope, batchId, enqueue) {
21
+ const open = this.batches.get(scope);
22
+ if (open?.batchId !== batchId) return false;
23
+ this.batches.delete(scope);
24
+ try {
25
+ await enqueue();
26
+ } catch (err) {
27
+ this.batches.set(scope, open);
28
+ throw err;
29
+ }
30
+ return true;
31
+ }
32
+ };
33
+ var PostgresX402BatchStore = class {
34
+ constructor(db) {
35
+ this.db = db;
36
+ }
37
+ db;
38
+ /** Create the table (idempotent), under the SDK init lock like every other boot DDL. */
39
+ async init() {
40
+ await withSdkInitLock(this.db, async () => {
41
+ await this.db.query(`
42
+ CREATE TABLE IF NOT EXISTS payout_x402_batches (
43
+ scope TEXT PRIMARY KEY,
44
+ batch_id TEXT NOT NULL,
45
+ claimed_native NUMERIC(78, 0) NOT NULL DEFAULT 0,
46
+ voucher_count INTEGER NOT NULL DEFAULT 0,
47
+ claims INTEGER NOT NULL DEFAULT 0,
48
+ opened_at BIGINT NOT NULL,
49
+ attempts INTEGER NOT NULL DEFAULT 0,
50
+ first_failed_at BIGINT,
51
+ last_error TEXT,
52
+ next_retry_at BIGINT,
53
+ updated_at BIGINT NOT NULL
54
+ )
55
+ `);
56
+ });
57
+ }
58
+ async get(scope) {
59
+ const { rows } = await this.db.query("SELECT * FROM payout_x402_batches WHERE scope = $1", [scope]);
60
+ const row = rows.at(0);
61
+ if (!row) return void 0;
62
+ return {
63
+ scope: row.scope,
64
+ batchId: row.batch_id,
65
+ claimedNative: BigInt(row.claimed_native),
66
+ voucherCount: row.voucher_count,
67
+ claims: row.claims,
68
+ openedAt: Number(row.opened_at),
69
+ attempts: row.attempts,
70
+ firstFailedAt: row.first_failed_at === null ? null : Number(row.first_failed_at),
71
+ lastError: row.last_error,
72
+ nextRetryAt: row.next_retry_at === null ? null : Number(row.next_retry_at)
73
+ };
74
+ }
75
+ async put(batch) {
76
+ await this.db.query(
77
+ `INSERT INTO payout_x402_batches (
78
+ scope, batch_id, claimed_native, voucher_count, claims, opened_at,
79
+ attempts, first_failed_at, last_error, next_retry_at, updated_at
80
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
81
+ ON CONFLICT (scope) DO UPDATE SET
82
+ batch_id = EXCLUDED.batch_id,
83
+ claimed_native = EXCLUDED.claimed_native,
84
+ voucher_count = EXCLUDED.voucher_count,
85
+ claims = EXCLUDED.claims,
86
+ opened_at = EXCLUDED.opened_at,
87
+ attempts = EXCLUDED.attempts,
88
+ first_failed_at = EXCLUDED.first_failed_at,
89
+ last_error = EXCLUDED.last_error,
90
+ next_retry_at = EXCLUDED.next_retry_at,
91
+ updated_at = EXCLUDED.updated_at`,
92
+ [
93
+ batch.scope,
94
+ batch.batchId,
95
+ batch.claimedNative.toString(),
96
+ batch.voucherCount,
97
+ batch.claims,
98
+ batch.openedAt,
99
+ batch.attempts,
100
+ batch.firstFailedAt,
101
+ batch.lastError,
102
+ batch.nextRetryAt,
103
+ Date.now()
104
+ ]
105
+ );
106
+ }
107
+ async delete(scope) {
108
+ await this.db.query("DELETE FROM payout_x402_batches WHERE scope = $1", [scope]);
109
+ }
110
+ async settle(scope, batchId, enqueue) {
111
+ const client = await this.db.connect();
112
+ try {
113
+ await client.query("BEGIN");
114
+ const { rowCount } = await client.query(
115
+ "DELETE FROM payout_x402_batches WHERE scope = $1 AND batch_id = $2",
116
+ [scope, batchId]
117
+ );
118
+ if (!rowCount) {
119
+ await client.query("ROLLBACK");
120
+ return false;
121
+ }
122
+ await enqueue(client);
123
+ await client.query("COMMIT");
124
+ return true;
125
+ } catch (err) {
126
+ await client.query("ROLLBACK").catch(() => void 0);
127
+ throw err;
128
+ } finally {
129
+ client.release();
130
+ }
131
+ }
132
+ };
133
+ var PAYOUT_SNAPSHOT_INTERVAL_MS = 5 * 6e4;
134
+ var PayoutReporter = class {
135
+ dvmId;
136
+ transport;
137
+ db;
138
+ cashu;
139
+ batches;
140
+ ownsBatchStore;
141
+ intervalMs;
142
+ now;
143
+ x402;
144
+ tempo;
145
+ timer = null;
146
+ warmup = null;
147
+ constructor(opts) {
148
+ this.dvmId = opts.dvmId;
149
+ this.transport = opts.transport;
150
+ this.db = opts.db;
151
+ this.cashu = opts.cashu ?? false;
152
+ this.ownsBatchStore = !opts.batchStore;
153
+ this.batches = opts.batchStore ?? (opts.db ? new PostgresX402BatchStore(opts.db) : new MemoryX402BatchStore());
154
+ this.intervalMs = opts.snapshotIntervalMs ?? PAYOUT_SNAPSHOT_INTERVAL_MS;
155
+ this.now = opts.now ?? Date.now;
156
+ }
157
+ /** Boot DDL for the durable batch store, when this reporter owns one. */
158
+ async init() {
159
+ if (this.ownsBatchStore && this.batches instanceof PostgresX402BatchStore) {
160
+ await this.batches.init();
161
+ }
162
+ }
163
+ /**
164
+ * Start the snapshot loop. Unref'd, so it never keeps a scale-to-zero
165
+ * machine awake; a first snapshot runs shortly after boot so a fresh process
166
+ * reports without waiting a whole interval.
167
+ */
168
+ start() {
169
+ if (this.timer) return;
170
+ this.warmup = setTimeout(() => {
171
+ void this.snapshot();
172
+ }, WARMUP_MS);
173
+ this.warmup.unref();
174
+ this.timer = setInterval(() => {
175
+ void this.snapshot();
176
+ }, this.intervalMs);
177
+ this.timer.unref();
178
+ }
179
+ stop() {
180
+ if (this.warmup) clearTimeout(this.warmup);
181
+ this.warmup = null;
182
+ if (this.timer) clearInterval(this.timer);
183
+ this.timer = null;
184
+ }
185
+ enqueuePayout(payload, tx) {
186
+ return this.transport.enqueuePayout(payload, tx);
187
+ }
188
+ reportPayout(payload) {
189
+ return this.transport.reportPayout(payload);
190
+ }
191
+ reportPayoutPending(payload) {
192
+ return this.transport.reportPayoutPending(payload);
193
+ }
194
+ /** Deliver what is queued now; a transport without the seam waits for its retry loop. */
195
+ async flush() {
196
+ try {
197
+ await this.transport.drainPending?.();
198
+ } catch (err) {
199
+ warn("payout_flush_failed", { error: message(err) });
200
+ }
201
+ }
202
+ // ── cashu ─────────────────────────────────────────────────────────────
203
+ /** The hook `mark-melted` runs inside its transaction: the mark and its payout commit together. */
204
+ meltHook() {
205
+ return {
206
+ enqueue: (event, tx) => this.cashuMelted(event, tx),
207
+ committed: () => {
208
+ void this.flush();
209
+ void this.snapshotCashu();
210
+ }
211
+ };
212
+ }
213
+ /**
214
+ * A melt completed: the accumulator rows are SPENT at the mint and the
215
+ * Lightning payment reached the builder's destination. One payout per mint
216
+ * in the call — a `mark-melted` normally names one, since a melt quote
217
+ * belongs to one mint. The amount is the face value melted; the mint's fee
218
+ * and the Lightning amount received are builder-machine facts this DVM does
219
+ * not see.
220
+ *
221
+ * Queued through `tx` when the caller is inside the transaction that marks
222
+ * the rows, and a failure is thrown rather than swallowed so that mark rolls
223
+ * back with it — see {@link TransactionalPayoutHook}. Without `tx` the rows
224
+ * are queued on their own and delivered at once.
225
+ */
226
+ async cashuMelted(event, tx) {
227
+ const byMint = /* @__PURE__ */ new Map();
228
+ for (const row of event.rows) {
229
+ const group = byMint.get(row.mintUrl) ?? { sats: 0, proofs: 0 };
230
+ group.sats += row.proofAmount;
231
+ group.proofs += 1;
232
+ byMint.set(row.mintUrl, group);
233
+ }
234
+ const preimageHash = createHash("sha256").update(event.paymentPreimage).digest("hex");
235
+ const landedAt = this.now();
236
+ const mints = [...byMint.entries()];
237
+ for (const [index, [mint, group]] of mints.entries()) {
238
+ if (group.sats <= 0) continue;
239
+ await this.enqueuePayout(
240
+ {
241
+ dvmId: this.dvmId,
242
+ payoutId: mints.length === 1 ? `cashu:melt:${event.meltQuoteId}` : `cashu:melt:${event.meltQuoteId}:${index}`,
243
+ rail: "cashu",
244
+ kind: "melt",
245
+ nativeAmount: group.sats,
246
+ nativeAsset: "sats",
247
+ recipient: null,
248
+ refs: {
249
+ mint,
250
+ meltQuoteId: event.meltQuoteId,
251
+ preimageHash,
252
+ proofCount: group.proofs
253
+ },
254
+ landedAt
255
+ },
256
+ tx
257
+ );
258
+ }
259
+ if (!tx) {
260
+ void this.flush();
261
+ void this.snapshotCashu();
262
+ }
263
+ }
264
+ // ── tempo ─────────────────────────────────────────────────────────────
265
+ /** The host attaches the session store and ledger readers once they exist. */
266
+ attachTempo(reader) {
267
+ this.tempo = reader;
268
+ }
269
+ /**
270
+ * A Tempo channel settled or closed on chain. `delta` is what this
271
+ * transaction newly paid the recipient; a transaction that paid nothing new
272
+ * (a close of a fully settled channel) is no payout.
273
+ */
274
+ async tempoSettled(event) {
275
+ try {
276
+ if (event.delta <= 0n) return;
277
+ await this.reportPayout({
278
+ dvmId: this.dvmId,
279
+ payoutId: `tempo:${event.txHash}`,
280
+ rail: "tempo",
281
+ kind: event.trigger === "close" ? "close" : "settle",
282
+ nativeAmount: Number(event.delta),
283
+ nativeAsset: "usdc",
284
+ recipient: this.tempo?.recipient ?? null,
285
+ refs: {
286
+ channelId: event.channelId,
287
+ txHash: event.txHash,
288
+ trigger: event.trigger,
289
+ ...this.tempo?.network ? { network: this.tempo.network } : {}
290
+ },
291
+ landedAt: this.now()
292
+ });
293
+ } catch (err) {
294
+ warn("payout_report_failed", { rail: "tempo", kind: event.trigger, error: message(err) });
295
+ }
296
+ void this.snapshotTempo();
297
+ }
298
+ /** The hook `reconcile-tempo-drain` runs inside its transaction: the booking and its payout commit together. */
299
+ tempoRepairHook() {
300
+ return {
301
+ enqueue: (event, tx) => this.tempoCloseReconciled(event, tx),
302
+ committed: () => {
303
+ void this.flush();
304
+ void this.snapshotTempo();
305
+ }
306
+ };
307
+ }
308
+ /**
309
+ * The operator repaired a wedged cooperative close (internal-review). The close's
310
+ * payee side landed on chain when the close did; if the live hook reported
311
+ * it, this upgrades that row to `repair`, otherwise it is the row. The
312
+ * figure is the receipt's payee total for the channel.
313
+ *
314
+ * Queued through `tx` when the caller is inside the transaction that books
315
+ * the drain, and thrown rather than swallowed so that booking rolls back
316
+ * with it — see {@link TransactionalPayoutHook}.
317
+ */
318
+ async tempoCloseReconciled(event, tx) {
319
+ const settled = BigInt(event.settledToPayee);
320
+ if (settled <= 0n) return;
321
+ await this.enqueuePayout(
322
+ {
323
+ dvmId: this.dvmId,
324
+ payoutId: `tempo:${event.txHash}`,
325
+ rail: "tempo",
326
+ kind: "close",
327
+ nativeAmount: Number(settled),
328
+ nativeAsset: "usdc",
329
+ recipient: this.tempo?.recipient ?? null,
330
+ refs: {
331
+ channelId: event.channelId,
332
+ txHash: event.txHash,
333
+ trigger: "close",
334
+ ...this.tempo?.network ? { network: this.tempo.network } : {}
335
+ },
336
+ landedAt: this.now(),
337
+ repaired: true,
338
+ repairKind: "tempo_close_reconciled"
339
+ },
340
+ tx
341
+ );
342
+ if (!tx) {
343
+ void this.flush();
344
+ void this.snapshotTempo();
345
+ }
346
+ }
347
+ // ── x402 ──────────────────────────────────────────────────────────────
348
+ /** The observer the batch-settlement server attaches to and routes its manager through. */
349
+ x402Observer() {
350
+ return {
351
+ attach: (ctx) => {
352
+ this.x402 = ctx;
353
+ },
354
+ trackManager: (manager) => this.trackX402Manager(manager),
355
+ recordSettle: async (transaction) => {
356
+ const ctx = this.x402;
357
+ if (ctx) await this.recordX402Settle(ctx, transaction);
358
+ void this.snapshotX402();
359
+ },
360
+ recordSettleFailure: async (error) => {
361
+ const ctx = this.x402;
362
+ if (ctx) await this.recordX402SettleFailure(ctx, error);
363
+ void this.snapshotX402();
364
+ }
365
+ };
366
+ }
367
+ /**
368
+ * Wrap the upstream channel manager so every claim grows the open batch and
369
+ * every settle closes it. The claim delta is read off storage rather than
370
+ * off upstream's result, which carries only a voucher count; under the
371
+ * fleet lock the before/after read is consistent. A channel the claim
372
+ * removed on its way through — a refund's claim-then-delete (internal-review) —
373
+ * moved whatever it still owed before it went, and that value reaches
374
+ * `pay_to` in this batch too, so it counts at its pre-claim figure.
375
+ */
376
+ trackX402Manager(manager) {
377
+ const claim = async (...args) => {
378
+ const ctx = this.x402;
379
+ const before = ctx ? await claimedByChannel(ctx.storage) : /* @__PURE__ */ new Map();
380
+ const results = await manager.claim(...args);
381
+ if (ctx && results.length > 0) {
382
+ const after = await claimedByChannel(ctx.storage);
383
+ let delta = 0n;
384
+ for (const [channelId, now] of after) {
385
+ const prior = before.get(channelId)?.claimed ?? 0n;
386
+ if (now.claimed > prior) delta += now.claimed - prior;
387
+ }
388
+ for (const [channelId, prior] of before) {
389
+ if (!after.has(channelId) && prior.charged > prior.claimed) {
390
+ delta += prior.charged - prior.claimed;
391
+ }
392
+ }
393
+ await this.recordX402Claim(ctx, {
394
+ delta,
395
+ vouchers: results.reduce((sum, r) => sum + r.vouchers, 0)
396
+ });
397
+ void this.snapshotX402();
398
+ }
399
+ return results;
400
+ };
401
+ const settle = async () => {
402
+ const ctx = this.x402;
403
+ try {
404
+ const result = await manager.settle();
405
+ if (ctx) await this.recordX402Settle(ctx, result.transaction);
406
+ void this.snapshotX402();
407
+ return result;
408
+ } catch (err) {
409
+ if (ctx) await this.recordX402SettleFailure(ctx, err);
410
+ void this.snapshotX402();
411
+ throw err;
412
+ }
413
+ };
414
+ return new Proxy(manager, {
415
+ get(target, prop, receiver) {
416
+ if (prop === "claim") return claim;
417
+ if (prop === "settle") return settle;
418
+ const value = Reflect.get(target, prop, receiver);
419
+ return typeof value === "function" ? value.bind(target) : value;
420
+ }
421
+ });
422
+ }
423
+ async recordX402Claim(ctx, claim) {
424
+ try {
425
+ const now = this.now();
426
+ const open = await this.batches.get(ctx.scope) ?? {
427
+ scope: ctx.scope,
428
+ batchId: randomUUID(),
429
+ claimedNative: 0n,
430
+ voucherCount: 0,
431
+ claims: 0,
432
+ openedAt: now,
433
+ attempts: 0,
434
+ firstFailedAt: null,
435
+ lastError: null,
436
+ nextRetryAt: null
437
+ };
438
+ open.claimedNative += claim.delta;
439
+ open.voucherCount += claim.vouchers;
440
+ open.claims += 1;
441
+ await this.batches.put(open);
442
+ } catch (err) {
443
+ warn("payout_batch_track_failed", { scope: ctx.scope, error: message(err) });
444
+ }
445
+ }
446
+ async recordX402Settle(ctx, transaction) {
447
+ try {
448
+ const open = await this.batches.get(ctx.scope);
449
+ if (!open) {
450
+ warn("payout_batch_untracked", { scope: ctx.scope, transaction });
451
+ return;
452
+ }
453
+ if (open.claimedNative <= 0n) {
454
+ await this.batches.delete(ctx.scope);
455
+ warn("payout_batch_empty", { scope: ctx.scope, transaction, batchId: open.batchId });
456
+ return;
457
+ }
458
+ const payload = {
459
+ dvmId: this.dvmId,
460
+ payoutId: `x402:batch:${open.batchId}`,
461
+ rail: "x402",
462
+ kind: "batch",
463
+ nativeAmount: Number(open.claimedNative),
464
+ nativeAsset: "usdc",
465
+ recipient: ctx.payTo,
466
+ refs: {
467
+ batchId: open.batchId,
468
+ txHash: transaction,
469
+ voucherCount: open.voucherCount,
470
+ claims: open.claims,
471
+ network: ctx.network
472
+ },
473
+ landedAt: this.now()
474
+ };
475
+ const closed = await this.batches.settle(
476
+ ctx.scope,
477
+ open.batchId,
478
+ (tx) => this.enqueuePayout(payload, tx)
479
+ );
480
+ if (!closed) {
481
+ warn("payout_batch_raced", { scope: ctx.scope, transaction, batchId: open.batchId });
482
+ return;
483
+ }
484
+ } catch (err) {
485
+ warn("payout_report_failed", { rail: "x402", kind: "batch", error: message(err) });
486
+ return;
487
+ }
488
+ void this.flush();
489
+ }
490
+ async recordX402SettleFailure(ctx, err) {
491
+ try {
492
+ const open = await this.batches.get(ctx.scope);
493
+ if (!open) return;
494
+ const now = this.now();
495
+ open.attempts += 1;
496
+ open.firstFailedAt ??= now;
497
+ open.lastError = settleErrorReason(err);
498
+ open.nextRetryAt = now + ctx.settleIntervalMs;
499
+ await this.batches.put(open);
500
+ } catch (trackErr) {
501
+ warn("payout_batch_track_failed", { scope: ctx.scope, error: message(trackErr) });
502
+ }
503
+ }
504
+ // ── snapshots ─────────────────────────────────────────────────────────
505
+ /** Every rail this reporter can read, each on its own failure boundary. */
506
+ async snapshot() {
507
+ await this.snapshotCashu();
508
+ await this.snapshotX402();
509
+ await this.snapshotTempo();
510
+ }
511
+ /**
512
+ * Cashu: every unmelted proof in the accumulator, per mint. Rows whose
513
+ * builder-side melt failed are still in the pool (they are still at the
514
+ * mint) and listed as wedged until `restart-failed` clears them.
515
+ */
516
+ async snapshotCashu() {
517
+ if (!this.db || !this.cashu) return;
518
+ try {
519
+ const { rows } = await this.db.query(
520
+ `SELECT mint_url, melt_status, SUM(proof_amount)::text AS sats,
521
+ COUNT(*)::int AS proofs, MIN(received_at)::text AS since
522
+ FROM wallet_accumulator
523
+ WHERE dvm_id = $1 AND melted_at IS NULL
524
+ GROUP BY mint_url, melt_status`,
525
+ [this.dvmId]
526
+ );
527
+ const byMint = /* @__PURE__ */ new Map();
528
+ const wedged = [];
529
+ let poolNative = 0;
530
+ for (const row of rows) {
531
+ const sats = Number(row.sats);
532
+ poolNative += sats;
533
+ const pool = byMint.get(row.mint_url) ?? { native: 0, proofs: 0 };
534
+ pool.native += sats;
535
+ pool.proofs += row.proofs;
536
+ byMint.set(row.mint_url, pool);
537
+ if (row.melt_status === "failed") {
538
+ wedged.push({
539
+ id: `cashu:melt-failed:${row.mint_url}`,
540
+ kind: "melt",
541
+ since: Number(row.since),
542
+ attempts: null,
543
+ nextRetry: null,
544
+ lastError: "melt_failed",
545
+ native: sats
546
+ });
547
+ }
548
+ }
549
+ await this.reportPayoutPending({
550
+ dvmId: this.dvmId,
551
+ rail: "cashu",
552
+ poolNative,
553
+ nativeAsset: "sats",
554
+ items: byMint.size,
555
+ pools: [...byMint.entries()].map(([mint, pool]) => ({
556
+ id: mint,
557
+ native: pool.native,
558
+ label: `${pool.proofs} proofs`
559
+ })),
560
+ wedged,
561
+ snapshotAt: this.now()
562
+ });
563
+ } catch (err) {
564
+ warn("payout_pending_snapshot_failed", { rail: "cashu", error: message(err) });
565
+ }
566
+ }
567
+ /**
568
+ * x402: the open batch (claimed, not yet transferred) plus every channel's
569
+ * unclaimed voucher value; wedged when the settle keeps failing, and the
570
+ * refund settlements stuck between chain and ledger beside it.
571
+ */
572
+ async snapshotX402() {
573
+ const ctx = this.x402;
574
+ if (!ctx) return;
575
+ try {
576
+ const pools = [];
577
+ const wedged = [];
578
+ const open = await this.batches.get(ctx.scope);
579
+ if (open && open.claimedNative > 0n) {
580
+ pools.push({
581
+ id: open.batchId,
582
+ native: Number(open.claimedNative),
583
+ label: "claimed, awaiting settle"
584
+ });
585
+ if (open.attempts > 0) {
586
+ wedged.push({
587
+ id: open.batchId,
588
+ kind: "settle",
589
+ since: open.firstFailedAt ?? open.openedAt,
590
+ attempts: open.attempts,
591
+ nextRetry: open.nextRetryAt,
592
+ lastError: open.lastError,
593
+ native: Number(open.claimedNative)
594
+ });
595
+ }
596
+ }
597
+ const unclaimed = [];
598
+ for (const channel of await ctx.storage.list()) {
599
+ const owed = BigInt(channel.chargedCumulativeAmount) - BigInt(channel.totalClaimed);
600
+ if (owed > 0n) {
601
+ unclaimed.push({
602
+ id: channel.channelId,
603
+ native: Number(owed),
604
+ label: "unclaimed vouchers"
605
+ });
606
+ }
607
+ }
608
+ pools.push(...capPools(unclaimed, MAX_POOLS - pools.length));
609
+ if (ctx.listWedgedRefunds) {
610
+ for (const refund of await ctx.listWedgedRefunds()) {
611
+ wedged.push({
612
+ id: refund.settlementId,
613
+ kind: "refund",
614
+ since: refund.createdAt,
615
+ attempts: null,
616
+ nextRetry: null,
617
+ lastError: null,
618
+ ...refund.native !== void 0 ? { native: refund.native } : {}
619
+ });
620
+ }
621
+ }
622
+ await this.reportPayoutPending({
623
+ dvmId: this.dvmId,
624
+ rail: "x402",
625
+ poolNative: pools.reduce((sum, pool) => sum + pool.native, 0),
626
+ nativeAsset: "usdc",
627
+ items: pools.length,
628
+ pools,
629
+ wedged,
630
+ snapshotAt: this.now()
631
+ });
632
+ } catch (err) {
633
+ warn("payout_pending_snapshot_failed", { rail: "x402", error: message(err) });
634
+ }
635
+ }
636
+ /**
637
+ * Tempo: each active channel's spent-but-unsettled balance — earned, not
638
+ * yet paid to the recipient — and the cooperative closes wedged between
639
+ * chain and ledger (internal-review), aged past the same floor the repair queue
640
+ * uses so an in-flight close is not reported as stuck.
641
+ */
642
+ async snapshotTempo() {
643
+ const reader = this.tempo;
644
+ if (!reader?.listActive) return;
645
+ try {
646
+ const pools = [];
647
+ let cursor;
648
+ for (let page = 0; page < MAX_TEMPO_PAGES; page++) {
649
+ const rows = await reader.listActive(100, cursor);
650
+ for (const row of rows) {
651
+ const state = row.state;
652
+ if (state.finalized === true) continue;
653
+ const spent = asBigInt(state.spent);
654
+ const settled = asBigInt(state.settledOnChain);
655
+ if (spent === void 0 || settled === void 0 || spent <= settled) continue;
656
+ pools.push({
657
+ id: typeof state.channelId === "string" ? state.channelId : row.key,
658
+ native: Number(spent - settled),
659
+ label: "earned, awaiting settlement"
660
+ });
661
+ }
662
+ if (rows.length < 100) break;
663
+ const last = rows[rows.length - 1];
664
+ cursor = { updatedAt: last.updatedAt, key: last.key };
665
+ }
666
+ const wedged = [];
667
+ if (reader.listChannelDrains) {
668
+ const drains = await reader.listChannelDrains({
669
+ limit: 50,
670
+ createdBeforeMs: this.now() - WEDGE_MIN_AGE_MS,
671
+ rail: "tempo"
672
+ });
673
+ for (const drain of drains) {
674
+ wedged.push({
675
+ id: `${drain.creditId}:${drain.drainId}`,
676
+ kind: "close",
677
+ since: drain.createdAt,
678
+ attempts: null,
679
+ nextRetry: null,
680
+ lastError: null,
681
+ ...drain.drainedNative !== null ? { native: drain.drainedNative } : {}
682
+ });
683
+ }
684
+ }
685
+ const capped = capPools(pools);
686
+ await this.reportPayoutPending({
687
+ dvmId: this.dvmId,
688
+ rail: "tempo",
689
+ poolNative: capped.reduce((sum, pool) => sum + pool.native, 0),
690
+ nativeAsset: "usdc",
691
+ items: capped.length,
692
+ pools: capped,
693
+ wedged,
694
+ snapshotAt: this.now()
695
+ });
696
+ } catch (err) {
697
+ warn("payout_pending_snapshot_failed", { rail: "tempo", error: message(err) });
698
+ }
699
+ }
700
+ };
701
+ var WARMUP_MS = 5e3;
702
+ var WEDGE_MIN_AGE_MS = 5 * 6e4;
703
+ var MAX_POOLS = 50;
704
+ var MAX_TEMPO_PAGES = 20;
705
+ async function claimedByChannel(storage) {
706
+ const view = /* @__PURE__ */ new Map();
707
+ for (const channel of await storage.list()) {
708
+ view.set(channel.channelId.toLowerCase(), {
709
+ claimed: BigInt(channel.totalClaimed),
710
+ charged: BigInt(channel.chargedCumulativeAmount)
711
+ });
712
+ }
713
+ return view;
714
+ }
715
+ function capPools(pools, limit = MAX_POOLS) {
716
+ const keep = Math.max(1, limit);
717
+ if (pools.length <= keep) return pools;
718
+ const sorted = [...pools].sort((a, b) => b.native - a.native);
719
+ const head = sorted.slice(0, keep - 1);
720
+ const tail = sorted.slice(keep - 1);
721
+ head.push({
722
+ id: "other",
723
+ native: tail.reduce((sum, pool) => sum + pool.native, 0),
724
+ label: `${tail.length} more`
725
+ });
726
+ return head;
727
+ }
728
+ function asBigInt(value) {
729
+ if (typeof value === "bigint") return value;
730
+ if (typeof value === "number" && Number.isSafeInteger(value)) return BigInt(value);
731
+ if (typeof value === "string" && /^\d+$/.test(value)) return BigInt(value);
732
+ return void 0;
733
+ }
734
+ function settleErrorReason(err) {
735
+ if (err && typeof err === "object" && typeof err.errorReason === "string") {
736
+ return err.errorReason;
737
+ }
738
+ const name = err instanceof Error ? err.name : "Error";
739
+ const text = message(err).replace(/https?:\/\/\S+/g, "<url>").slice(0, 160);
740
+ return text ? `${name}: ${text}` : name;
741
+ }
742
+ function message(err) {
743
+ return err instanceof Error ? err.message : String(err);
744
+ }
745
+ function warn(level, fields) {
746
+ console.warn(JSON.stringify({ level, ...fields }));
747
+ }
748
+ export {
749
+ MemoryX402BatchStore,
750
+ PAYOUT_SNAPSHOT_INTERVAL_MS,
751
+ PayoutReporter,
752
+ PostgresX402BatchStore
753
+ };