@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.
- package/NOTICE +2 -0
- package/README.md +38 -2
- package/dist/chunk-27V2ILSR.js +291 -0
- package/dist/chunk-365P52XQ.js +4121 -0
- package/dist/chunk-5GFED3GJ.js +955 -0
- package/dist/chunk-6JZIX5WW.js +1155 -0
- package/dist/chunk-7IH5SG2A.js +1038 -0
- package/dist/chunk-AT6V3SY7.js +102 -0
- package/dist/chunk-DCNT4PJS.js +733 -0
- package/dist/chunk-DMNLFNTW.js +135 -0
- package/dist/chunk-FROTD5XQ.js +70 -0
- package/dist/chunk-H25M54MI.js +149 -0
- package/dist/chunk-KQAJVVZT.js +712 -0
- package/dist/chunk-KXWROQGK.js +74 -0
- package/dist/chunk-L4OYF4DQ.js +67 -0
- package/dist/chunk-OJ5WFIB2.js +1266 -0
- package/dist/chunk-S3XAHZQY.js +63 -0
- package/dist/chunk-YG7G4DPZ.js +25 -0
- package/dist/credit-ledger-RO4FGSHG.js +28 -0
- package/dist/index.d.ts +144 -0
- package/dist/index.js +303 -0
- package/dist/job-store-6gR4pZRP.d.ts +5350 -0
- package/dist/memory-credit-ledger-I2G64DDK.js +9 -0
- package/dist/mpp-secret-state-WNAQQ6K4.js +127 -0
- package/dist/mpp-setup-MOBWGTWJ.js +30 -0
- package/dist/payout-reporter-4TNWRS5F.js +753 -0
- package/dist/postgres-consumed-credential-store-VHBT4KEA.js +72 -0
- package/dist/postgres-job-store-J5F4GUWU.js +7 -0
- package/dist/postgres-kv-store-JFBDP5IP.js +7 -0
- package/dist/postgres-replay-store-UJXRT6VO.js +7 -0
- package/dist/pricing-4CEB34RM.js +48 -0
- package/dist/processed-payment-store-HAA4SFNK.js +11 -0
- package/dist/revenue-reporter-GB4WKLDC.js +510 -0
- package/dist/server/index.d.ts +4168 -0
- package/dist/server/index.js +22716 -0
- package/dist/ssrf-DZi-xJyn.d.ts +325 -0
- package/dist/tempo-charge-store-6GJEMNUU.js +130 -0
- package/dist/tempo-session-store-FTEEGZXA.js +467 -0
- package/dist/testing/index.d.ts +135 -0
- package/dist/testing/index.js +151 -0
- package/dist/x402-35VLYFKZ.js +1272 -0
- 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
|
+
};
|