@dvmkit/sdk 0.0.0 → 0.1.0-rc.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 (41) 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-5GFED3GJ.js +955 -0
  5. package/dist/chunk-6JZIX5WW.js +1155 -0
  6. package/dist/chunk-7IH5SG2A.js +1038 -0
  7. package/dist/chunk-AT6V3SY7.js +102 -0
  8. package/dist/chunk-DCNT4PJS.js +733 -0
  9. package/dist/chunk-DMNLFNTW.js +135 -0
  10. package/dist/chunk-FROTD5XQ.js +70 -0
  11. package/dist/chunk-H25M54MI.js +149 -0
  12. package/dist/chunk-KQAJVVZT.js +712 -0
  13. package/dist/chunk-KXWROQGK.js +74 -0
  14. package/dist/chunk-L4OYF4DQ.js +67 -0
  15. package/dist/chunk-NTK5DJ6R.js +1256 -0
  16. package/dist/chunk-RPXHKMYE.js +3808 -0
  17. package/dist/chunk-S3XAHZQY.js +63 -0
  18. package/dist/chunk-YG7G4DPZ.js +25 -0
  19. package/dist/credit-ledger-EDMEZSA2.js +28 -0
  20. package/dist/index.d.ts +144 -0
  21. package/dist/index.js +303 -0
  22. package/dist/job-store-C5n6bhap.d.ts +5090 -0
  23. package/dist/memory-credit-ledger-7TTZDSRS.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/postgres-consumed-credential-store-VHBT4KEA.js +72 -0
  27. package/dist/postgres-job-store-J5F4GUWU.js +7 -0
  28. package/dist/postgres-kv-store-JFBDP5IP.js +7 -0
  29. package/dist/postgres-replay-store-UJXRT6VO.js +7 -0
  30. package/dist/pricing-4CEB34RM.js +48 -0
  31. package/dist/processed-payment-store-HAA4SFNK.js +11 -0
  32. package/dist/revenue-reporter-M35KP6V7.js +435 -0
  33. package/dist/server/index.d.ts +4108 -0
  34. package/dist/server/index.js +22538 -0
  35. package/dist/ssrf-BdHsrrIb.d.ts +325 -0
  36. package/dist/tempo-charge-store-6GJEMNUU.js +130 -0
  37. package/dist/tempo-session-store-FTEEGZXA.js +467 -0
  38. package/dist/testing/index.d.ts +135 -0
  39. package/dist/testing/index.js +151 -0
  40. package/dist/x402-35VLYFKZ.js +1272 -0
  41. package/package.json +89 -6
@@ -0,0 +1,1256 @@
1
+ import {
2
+ CreditLedgerError,
3
+ DRAIN_DELIVERY_RESERVE_SATS,
4
+ X402_SPEND_BLOCKING_SETTLEMENT_STATUSES,
5
+ X402_WEDGED_SETTLEMENT_STATUSES,
6
+ allocateDrawValue,
7
+ assertFundingBasis,
8
+ clipEarmark,
9
+ depleteLots,
10
+ fifoOrder,
11
+ growthRailValue,
12
+ inKindDrawMsats,
13
+ isInKindDepletion,
14
+ isNonChannelBitcoinRail,
15
+ lotOwedSats,
16
+ netOwedSats,
17
+ x402SettlementPending
18
+ } from "./chunk-RPXHKMYE.js";
19
+
20
+ // src/sdk/server/memory-credit-ledger.ts
21
+ import { randomUUID } from "crypto";
22
+ var MemoryCreditLedger = class {
23
+ /**
24
+ * Per-process and lost on restart, so a DVM backed by this ledger never
25
+ * advertises the funding menu outside devMode (internal-review) — funding on one
26
+ * machine and drawing on another would `credit_not_found`.
27
+ */
28
+ durable = false;
29
+ credits = /* @__PURE__ */ new Map();
30
+ fundings = /* @__PURE__ */ new Map();
31
+ invoices = /* @__PURE__ */ new Map();
32
+ tempoLosses = /* @__PURE__ */ new Map();
33
+ x402Losses = /* @__PURE__ */ new Map();
34
+ x402Settlements;
35
+ /** See {@link CreditLedger.useX402SettlementGate} — same contract (internal-review). */
36
+ useX402SettlementGate(gate) {
37
+ this.x402Settlements = gate;
38
+ }
39
+ async fund(args) {
40
+ const basis = args.basis;
41
+ const wedged = await this.blockingX402Refund(
42
+ basis?.x402Channel?.channelId,
43
+ X402_SPEND_BLOCKING_SETTLEMENT_STATUSES
44
+ );
45
+ if (wedged) throw x402SettlementPending(args.creditId ?? randomUUID(), wedged);
46
+ return run(() => this.fundSync(args));
47
+ }
48
+ async draw(args) {
49
+ const blocking = await this.blockingRefundForCredit(
50
+ args.creditId,
51
+ X402_SPEND_BLOCKING_SETTLEMENT_STATUSES
52
+ );
53
+ return run(() => this.drawSync(args, blocking));
54
+ }
55
+ /** See {@link CreditLedger.blockingX402Refund} — same contract (internal-review). */
56
+ async blockingX402Refund(channelId, statuses) {
57
+ if (!channelId || !this.x402Settlements) return void 0;
58
+ return this.x402Settlements.pendingRefundSettlement(channelId, statuses);
59
+ }
60
+ /** The unresolved x402 refund that must stop an effect on `creditId` (internal-review). */
61
+ blockingRefundForCredit(creditId, statuses) {
62
+ return this.blockingX402Refund(this.credits.get(creditId)?.x402ChannelId, statuses);
63
+ }
64
+ growDraw(args) {
65
+ return run(() => this.growDrawSync(args));
66
+ }
67
+ settle(args) {
68
+ return run(() => {
69
+ const credit = this.requireCredit(args.creditId);
70
+ const draw = credit.draws.get(args.drawId);
71
+ const to = draw?.status === "settled" ? "settled" : credit.status === "unbacked" ? "released" : "settled";
72
+ return this.resolve({ ...args, to });
73
+ });
74
+ }
75
+ release(args) {
76
+ return run(() => this.resolve({ ...args, to: "released" }));
77
+ }
78
+ recordFunding(args) {
79
+ return run(() => {
80
+ assertAmount(args.amountMicro, { min: 1 });
81
+ const key = fundingKey(args.creditId, args.fundId);
82
+ if (this.fundings.has(key)) {
83
+ throw new CreditLedgerError(
84
+ "funding_replayed",
85
+ `funding ${args.fundId} on credit ${args.creditId} was already recorded`
86
+ );
87
+ }
88
+ const record = {
89
+ creditId: args.creditId,
90
+ fundId: args.fundId,
91
+ amountMicro: args.amountMicro,
92
+ rail: args.rail ?? null,
93
+ createdAt: args.nowMs ?? Date.now(),
94
+ callerPubkey: null,
95
+ balanceAfterMicro: null,
96
+ ledgerSeq: null,
97
+ receipt: null
98
+ };
99
+ this.fundings.set(key, record);
100
+ return record;
101
+ });
102
+ }
103
+ getFunding(args) {
104
+ return run(() => this.fundings.get(fundingKey(args.creditId, args.fundId)));
105
+ }
106
+ completeFunding(args) {
107
+ return run(() => {
108
+ const key = fundingKey(args.creditId, args.fundId);
109
+ const record = this.fundings.get(key);
110
+ if (!record) {
111
+ throw new CreditLedgerError(
112
+ "credit_not_found",
113
+ `funding ${args.fundId} on credit ${args.creditId} was not recorded`
114
+ );
115
+ }
116
+ record.callerPubkey ??= args.callerPubkey;
117
+ record.balanceAfterMicro ??= args.balanceAfterMicro;
118
+ record.ledgerSeq ??= args.ledgerSeq;
119
+ return record;
120
+ });
121
+ }
122
+ saveFundingReceipt(args) {
123
+ return run(() => {
124
+ const record = this.fundings.get(fundingKey(args.creditId, args.fundId));
125
+ if (!record) {
126
+ throw new CreditLedgerError(
127
+ "credit_not_found",
128
+ `funding ${args.fundId} on credit ${args.creditId} was not recorded`
129
+ );
130
+ }
131
+ record.receipt ??= args.receipt;
132
+ return record.receipt;
133
+ });
134
+ }
135
+ recordInvoice(args) {
136
+ return run(() => {
137
+ assertAmount(args.amountMicro, { min: 1 });
138
+ const key = fundingKey(args.creditId, args.fundId);
139
+ const existing = this.invoices.get(key);
140
+ if (existing) return existing;
141
+ for (const invoice of this.invoices.values()) {
142
+ if (invoice.paymentHash === args.paymentHash) {
143
+ throw new CreditLedgerError(
144
+ "invoice_conflict",
145
+ `payment hash ${args.paymentHash} is already bound to another funding`
146
+ );
147
+ }
148
+ }
149
+ const record = {
150
+ creditId: args.creditId,
151
+ fundId: args.fundId,
152
+ callerPubkey: args.callerPubkey,
153
+ currency: args.currency,
154
+ amountMicro: args.amountMicro,
155
+ amountMsats: args.amountMsats,
156
+ paymentHash: args.paymentHash,
157
+ bolt11: args.bolt11,
158
+ status: "pending",
159
+ blockedReason: null,
160
+ reconciledCreditId: null,
161
+ reconciledFundId: null,
162
+ resolvedAt: null,
163
+ writeOffNote: null,
164
+ expiresAt: args.expiresAt,
165
+ createdAt: args.nowMs ?? Date.now(),
166
+ settledAt: null
167
+ };
168
+ this.invoices.set(key, record);
169
+ return record;
170
+ });
171
+ }
172
+ getInvoice(args) {
173
+ return run(() => this.invoices.get(fundingKey(args.creditId, args.fundId)));
174
+ }
175
+ listPendingInvoices(args) {
176
+ return run(
177
+ () => [...this.invoices.values()].filter(
178
+ (invoice) => invoice.status === "pending" && invoice.callerPubkey === args.callerPubkey && (args.creditId === void 0 || invoice.creditId === args.creditId)
179
+ ).sort((a, b) => a.createdAt - b.createdAt || (a.fundId < b.fundId ? -1 : 1)).slice(0, args.limit)
180
+ );
181
+ }
182
+ settleInvoice(args) {
183
+ return run(() => {
184
+ assertFundingBasis(args.basis);
185
+ const key = fundingKey(args.creditId, args.fundId);
186
+ const invoice = this.invoices.get(key);
187
+ if (!invoice) {
188
+ throw new CreditLedgerError(
189
+ "invoice_not_found",
190
+ `no invoice for funding ${args.fundId} on credit ${args.creditId}`
191
+ );
192
+ }
193
+ const nowMs = args.nowMs ?? Date.now();
194
+ if (invoice.status === "settled") {
195
+ const existing = this.credits.get(args.creditId);
196
+ return {
197
+ invoice,
198
+ funding: this.fundings.get(key),
199
+ credit: existing ? this.snapshot(existing, nowMs) : void 0,
200
+ replayed: true
201
+ };
202
+ }
203
+ if (this.fundings.has(key)) {
204
+ throw new CreditLedgerError(
205
+ "funding_replayed",
206
+ `funding ${args.fundId} on credit ${args.creditId} was already recorded`
207
+ );
208
+ }
209
+ const funding = {
210
+ creditId: invoice.creditId,
211
+ fundId: invoice.fundId,
212
+ amountMicro: invoice.amountMicro,
213
+ rail: "lightning",
214
+ createdAt: nowMs,
215
+ callerPubkey: null,
216
+ balanceAfterMicro: null,
217
+ ledgerSeq: null,
218
+ receipt: null
219
+ };
220
+ this.fundings.set(key, funding);
221
+ const credit = this.fundSync({
222
+ creditId: invoice.creditId,
223
+ callerPubkey: invoice.callerPubkey,
224
+ currency: invoice.currency,
225
+ amountMicro: invoice.amountMicro,
226
+ expiryMs: args.expiryMs,
227
+ basis: args.basis,
228
+ nowMs
229
+ });
230
+ funding.callerPubkey = invoice.callerPubkey;
231
+ funding.balanceAfterMicro = credit.availableMicro;
232
+ funding.ledgerSeq = credit.lastLedgerSeq;
233
+ const settled = {
234
+ ...invoice,
235
+ status: "settled",
236
+ settledAt: args.settledAtMs ?? nowMs
237
+ };
238
+ this.invoices.set(key, settled);
239
+ return { invoice: settled, funding, credit, replayed: false };
240
+ });
241
+ }
242
+ markInvoiceExpired(args) {
243
+ return run(() => {
244
+ const key = fundingKey(args.creditId, args.fundId);
245
+ const invoice = this.invoices.get(key);
246
+ if (invoice?.status !== "pending") return invoice;
247
+ const expired = { ...invoice, status: "expired" };
248
+ this.invoices.set(key, expired);
249
+ return expired;
250
+ });
251
+ }
252
+ markInvoiceBlocked(args) {
253
+ return run(() => {
254
+ const key = fundingKey(args.creditId, args.fundId);
255
+ const invoice = this.invoices.get(key);
256
+ if (invoice?.status !== "pending") return invoice;
257
+ const blocked = {
258
+ ...invoice,
259
+ status: "blocked",
260
+ blockedReason: args.reason
261
+ };
262
+ this.invoices.set(key, blocked);
263
+ return blocked;
264
+ });
265
+ }
266
+ getInvoiceByPaymentHash(paymentHash) {
267
+ return run(() => this.findByPaymentHash(paymentHash)?.invoice);
268
+ }
269
+ listBlockedInvoices(args) {
270
+ const statuses = args.includeResolved ? ["blocked", "reconciled", "written_off"] : ["blocked"];
271
+ return run(
272
+ () => [...this.invoices.values()].filter((invoice) => statuses.includes(invoice.status)).sort(compareInvoiceKeyset).filter((invoice) => args.after === void 0 || compareToCursor(invoice, args.after) > 0).slice(0, args.limit)
273
+ );
274
+ }
275
+ reconcileBlockedInvoice(args) {
276
+ return run(() => {
277
+ assertFundingBasis(args.basis);
278
+ const found = this.findByPaymentHash(args.paymentHash);
279
+ if (!found) {
280
+ throw new CreditLedgerError(
281
+ "invoice_not_found",
282
+ `no invoice for payment hash ${args.paymentHash}`
283
+ );
284
+ }
285
+ const { key, invoice } = found;
286
+ const nowMs = args.nowMs ?? Date.now();
287
+ if (invoice.status === "reconciled") {
288
+ const targetKey2 = invoice.reconciledCreditId && invoice.reconciledFundId ? fundingKey(invoice.reconciledCreditId, invoice.reconciledFundId) : void 0;
289
+ const existing = invoice.reconciledCreditId ? this.credits.get(invoice.reconciledCreditId) : void 0;
290
+ return {
291
+ invoice,
292
+ funding: targetKey2 ? this.fundings.get(targetKey2) : void 0,
293
+ credit: existing ? this.snapshot(existing, nowMs) : void 0,
294
+ replayed: true
295
+ };
296
+ }
297
+ if (invoice.status !== "blocked" && invoice.status !== "written_off") {
298
+ throw new CreditLedgerError(
299
+ "invoice_not_blocked",
300
+ `invoice ${args.paymentHash} is ${invoice.status}; only a blocked or written-off invoice can be reconciled`
301
+ );
302
+ }
303
+ const targetKey = fundingKey(args.targetCreditId, invoice.paymentHash);
304
+ if (this.fundings.has(targetKey)) {
305
+ throw new CreditLedgerError(
306
+ "funding_replayed",
307
+ `funding ${invoice.paymentHash} on credit ${args.targetCreditId} was already recorded`
308
+ );
309
+ }
310
+ const funding = {
311
+ creditId: args.targetCreditId,
312
+ fundId: invoice.paymentHash,
313
+ amountMicro: invoice.amountMicro,
314
+ rail: "lightning",
315
+ createdAt: nowMs,
316
+ callerPubkey: null,
317
+ balanceAfterMicro: null,
318
+ ledgerSeq: null,
319
+ receipt: null
320
+ };
321
+ this.fundings.set(targetKey, funding);
322
+ const credit = this.fundSync({
323
+ creditId: args.targetCreditId,
324
+ callerPubkey: invoice.callerPubkey,
325
+ currency: invoice.currency,
326
+ amountMicro: invoice.amountMicro,
327
+ expiryMs: args.expiryMs,
328
+ basis: args.basis,
329
+ nowMs
330
+ });
331
+ funding.callerPubkey = invoice.callerPubkey;
332
+ funding.balanceAfterMicro = credit.availableMicro;
333
+ funding.ledgerSeq = credit.lastLedgerSeq;
334
+ const reconciled = {
335
+ ...invoice,
336
+ status: "reconciled",
337
+ reconciledCreditId: args.targetCreditId,
338
+ reconciledFundId: invoice.paymentHash,
339
+ resolvedAt: nowMs,
340
+ settledAt: args.settledAtMs ?? invoice.settledAt
341
+ };
342
+ this.invoices.set(key, reconciled);
343
+ return { invoice: reconciled, funding, credit, replayed: false };
344
+ });
345
+ }
346
+ writeOffBlockedInvoice(args) {
347
+ return run(() => {
348
+ const found = this.findByPaymentHash(args.paymentHash);
349
+ if (!found) return void 0;
350
+ if (found.invoice.status !== "blocked") return { invoice: found.invoice, written: false };
351
+ const written = {
352
+ ...found.invoice,
353
+ status: "written_off",
354
+ writeOffNote: args.note ?? null,
355
+ resolvedAt: args.nowMs ?? Date.now()
356
+ };
357
+ this.invoices.set(found.key, written);
358
+ return { invoice: written, written: true };
359
+ });
360
+ }
361
+ findByPaymentHash(paymentHash) {
362
+ for (const [key, invoice] of this.invoices) {
363
+ if (invoice.paymentHash === paymentHash) return { key, invoice };
364
+ }
365
+ return void 0;
366
+ }
367
+ fundSync(args) {
368
+ assertAmount(args.amountMicro, { min: 1 });
369
+ if (!Number.isSafeInteger(args.expiryMs) || args.expiryMs <= 0) {
370
+ throw new CreditLedgerError(
371
+ "invalid_expiry",
372
+ `expiryMs must be a positive safe integer (got ${args.expiryMs})`
373
+ );
374
+ }
375
+ const b = assertFundingBasis(args.basis);
376
+ const nowMs = args.nowMs ?? Date.now();
377
+ const creditId = args.creditId ?? randomUUID();
378
+ const existing = this.credits.get(creditId);
379
+ if (existing) {
380
+ if (existing.callerPubkey !== args.callerPubkey) {
381
+ throw new CreditLedgerError(
382
+ "caller_mismatch",
383
+ `credit ${creditId} belongs to a different caller pubkey`
384
+ );
385
+ }
386
+ if (existing.currency !== args.currency) {
387
+ throw new CreditLedgerError(
388
+ "currency_mismatch",
389
+ `credit ${creditId} is denominated in ${existing.currency}, not ${args.currency}`
390
+ );
391
+ }
392
+ if (existing.status !== "active") throw terminalCreditError(creditId);
393
+ if (existing.rail !== null && existing.rail !== b.rail) {
394
+ throw new CreditLedgerError(
395
+ "rail_mismatch",
396
+ `credit ${creditId} was funded on the ${existing.rail} rail, not ${b.rail}`,
397
+ { expectedRail: existing.rail }
398
+ );
399
+ }
400
+ const incomingTempoChannel = b.tempoSession?.channelId.toLowerCase() ?? null;
401
+ if (existing.tempoChannelId !== incomingTempoChannel) {
402
+ throw new CreditLedgerError(
403
+ "rail_mismatch",
404
+ existing.tempoChannelId ? `credit ${creditId} belongs to Tempo session channel ${existing.tempoChannelId}` : existing.rail === "tempo" ? `credit ${creditId} was funded by a one-shot Tempo charge and cannot accept a Tempo session voucher` : `credit ${creditId} is not bound to a Tempo session channel and cannot adopt one`,
405
+ {
406
+ expectedRail: existing.rail,
407
+ ...existing.tempoChannelId || existing.rail === "tempo" ? { expectedInstrument: existing.tempoChannelId ? "tempo_session" : "tempo_charge" } : {},
408
+ expectedChannelId: existing.tempoChannelId
409
+ }
410
+ );
411
+ }
412
+ if (existing.x402ChannelId !== (b.x402Channel?.channelId ?? null)) {
413
+ throw new CreditLedgerError(
414
+ "rail_mismatch",
415
+ existing.x402ChannelId ? `credit ${creditId} is bound to x402 settlement channel ${existing.x402ChannelId}` : existing.rail === "x402" ? `credit ${creditId} was funded by a one-shot x402 exact payment and cannot accept an x402 batch-settlement channel deposit` : `credit ${creditId} is not bound to an x402 batch-settlement channel and cannot adopt one`,
416
+ {
417
+ expectedRail: existing.rail,
418
+ ...existing.x402ChannelId || existing.rail === "x402" ? { expectedInstrument: existing.x402ChannelId ? "x402_channel" : "x402_exact" } : {},
419
+ expectedChannelId: existing.x402ChannelId
420
+ }
421
+ );
422
+ }
423
+ existing.balanceMicro += args.amountMicro;
424
+ existing.expiryMs = args.expiryMs;
425
+ applyBasis(existing, b);
426
+ recordLot(existing, args.amountMicro, b, nowMs);
427
+ return this.snapshot(existing, nowMs);
428
+ }
429
+ if (b.x402Channel) {
430
+ for (const candidate of this.credits.values()) {
431
+ if (candidate.x402ChannelId === b.x402Channel.channelId) {
432
+ throw new CreditLedgerError(
433
+ "rail_mismatch",
434
+ `x402 channel ${b.x402Channel.channelId} is already bound to credit ${candidate.creditId}`,
435
+ {
436
+ expectedRail: candidate.rail,
437
+ expectedInstrument: "x402_channel",
438
+ expectedChannelId: b.x402Channel.channelId,
439
+ boundCreditId: candidate.creditId
440
+ }
441
+ );
442
+ }
443
+ }
444
+ }
445
+ const credit = {
446
+ creditId,
447
+ callerPubkey: args.callerPubkey,
448
+ currency: args.currency,
449
+ balanceMicro: args.amountMicro,
450
+ expiryMs: args.expiryMs,
451
+ status: "active",
452
+ lastLedgerSeq: 0,
453
+ createdAt: nowMs,
454
+ draws: /* @__PURE__ */ new Map(),
455
+ rail: null,
456
+ nativeAsset: null,
457
+ mint: null,
458
+ fundingRef: null,
459
+ tempoChannelId: null,
460
+ msatsRemaining: 0,
461
+ nativeRemaining: null,
462
+ x402ChannelId: null,
463
+ drains: /* @__PURE__ */ new Map(),
464
+ lots: []
465
+ };
466
+ applyBasis(credit, b);
467
+ recordLot(credit, args.amountMicro, b, nowMs);
468
+ this.credits.set(creditId, credit);
469
+ return this.snapshot(credit, nowMs);
470
+ }
471
+ drawSync(args, x402Refund) {
472
+ assertAmount(args.amountMicro, { min: 0 });
473
+ const nowMs = args.nowMs ?? Date.now();
474
+ const jobId = args.jobId ?? null;
475
+ const credit = this.requireCredit(args.creditId);
476
+ const existing = credit.draws.get(args.drawId);
477
+ if (existing) {
478
+ if (existing.amountMicro !== args.amountMicro || existing.jobId !== jobId) {
479
+ throw new CreditLedgerError(
480
+ "draw_conflict",
481
+ `draw ${args.drawId} on credit ${args.creditId} was recorded with different parameters`,
482
+ { expectedAmountMicro: existing.amountMicro, expectedJobId: existing.jobId }
483
+ );
484
+ }
485
+ return { ...drawResult(existing, credit), replayed: true };
486
+ }
487
+ if (credit.status !== "active") throw terminalCreditError(args.creditId);
488
+ if (x402Refund) throw x402SettlementPending(args.creditId, x402Refund);
489
+ if (credit.expiryMs <= nowMs) {
490
+ throw new CreditLedgerError("credit_expired", `credit ${args.creditId} expired`, {
491
+ expiryMs: credit.expiryMs,
492
+ balanceMicro: credit.balanceMicro
493
+ });
494
+ }
495
+ const pending = pendingSums(credit);
496
+ const available = credit.balanceMicro - pending.micro;
497
+ if (args.amountMicro > available) {
498
+ throw new CreditLedgerError(
499
+ "insufficient_credit",
500
+ `draw of ${args.amountMicro} exceeds available balance ${available}`,
501
+ {
502
+ availableMicro: available,
503
+ balanceMicro: credit.balanceMicro,
504
+ requestedMicro: args.amountMicro
505
+ }
506
+ );
507
+ }
508
+ const alloc = allocateDrawValue({
509
+ amountMicro: args.amountMicro,
510
+ availableMicro: available,
511
+ availableMsats: Math.max(0, credit.msatsRemaining - pending.msats),
512
+ availableNative: credit.nativeRemaining === null ? null : Math.max(0, credit.nativeRemaining - pending.native)
513
+ });
514
+ const ledgerSeq = credit.lastLedgerSeq + 1;
515
+ credit.lastLedgerSeq = ledgerSeq;
516
+ const draw = {
517
+ creditId: args.creditId,
518
+ drawId: args.drawId,
519
+ amountMicro: args.amountMicro,
520
+ jobId,
521
+ ledgerSeq,
522
+ status: "pending",
523
+ balanceAfterMicro: available - args.amountMicro,
524
+ createdAt: nowMs,
525
+ resolvedAt: null,
526
+ drawMsats: alloc.drawMsats,
527
+ drawNative: alloc.drawNative,
528
+ grownMicro: 0
529
+ };
530
+ credit.draws.set(args.drawId, draw);
531
+ return { ...drawResult(draw, credit), replayed: false };
532
+ }
533
+ /** Mid-job top-up growth (internal-review) — see `CreditLedger.growDraw` for the semantics. */
534
+ growDrawSync(args) {
535
+ assertAmount(args.addAmountMicro, { min: 1 });
536
+ const credit = this.requireCredit(args.creditId);
537
+ const draw = credit.draws.get(args.drawId);
538
+ if (!draw) {
539
+ throw new CreditLedgerError(
540
+ "draw_not_found",
541
+ `draw ${args.drawId} not found on credit ${args.creditId}`
542
+ );
543
+ }
544
+ if (draw.status !== "pending") {
545
+ throw new CreditLedgerError(
546
+ "invalid_draw_state",
547
+ `cannot grow draw ${args.drawId}: status is ${draw.status}`,
548
+ { currentStatus: draw.status }
549
+ );
550
+ }
551
+ if (credit.status !== "active") throw terminalCreditError(args.creditId);
552
+ if (args.jobId !== void 0 && draw.jobId !== args.jobId) {
553
+ throw new CreditLedgerError(
554
+ "draw_conflict",
555
+ `draw ${args.drawId} on credit ${args.creditId} belongs to a different job`,
556
+ { expectedJobId: draw.jobId }
557
+ );
558
+ }
559
+ const pending = pendingSums(credit);
560
+ const available = credit.balanceMicro - pending.micro;
561
+ const headroom = args.capMicro === void 0 ? args.addAmountMicro : Math.max(0, args.capMicro - draw.grownMicro);
562
+ const grantMicro = Math.min(args.addAmountMicro, headroom);
563
+ if (grantMicro <= 0) {
564
+ return {
565
+ ...drawResult(draw, credit),
566
+ replayed: false,
567
+ addedRailValue: { drawMsats: 0, drawNative: null },
568
+ addedAmountMicro: 0,
569
+ cappedMicro: args.addAmountMicro
570
+ };
571
+ }
572
+ if (grantMicro > available) {
573
+ throw new CreditLedgerError(
574
+ "insufficient_credit",
575
+ `top-up draw of ${grantMicro} exceeds available balance ${available}`,
576
+ {
577
+ availableMicro: available,
578
+ balanceMicro: credit.balanceMicro,
579
+ requestedMicro: grantMicro
580
+ }
581
+ );
582
+ }
583
+ const added = growthRailValue({
584
+ earmark: clipEarmark(args.addRailValue, grantMicro, args.addAmountMicro),
585
+ amountMicro: grantMicro,
586
+ availableMicro: available,
587
+ availableMsats: Math.max(0, credit.msatsRemaining - pending.msats),
588
+ availableNative: credit.nativeRemaining === null ? null : Math.max(0, credit.nativeRemaining - pending.native)
589
+ });
590
+ draw.amountMicro += grantMicro;
591
+ draw.grownMicro += grantMicro;
592
+ draw.balanceAfterMicro = available - grantMicro;
593
+ draw.drawMsats += added.drawMsats;
594
+ if (added.drawNative !== null) draw.drawNative = (draw.drawNative ?? 0) + added.drawNative;
595
+ return {
596
+ ...drawResult(draw, credit),
597
+ replayed: false,
598
+ addedRailValue: added,
599
+ addedAmountMicro: grantMicro,
600
+ cappedMicro: args.addAmountMicro - grantMicro
601
+ };
602
+ }
603
+ get(creditId, opts) {
604
+ const credit = this.credits.get(creditId);
605
+ return Promise.resolve(credit ? this.snapshot(credit, opts?.nowMs ?? Date.now()) : void 0);
606
+ }
607
+ getForCaller(callerPubkey, opts) {
608
+ const nowMs = opts?.nowMs ?? Date.now();
609
+ return Promise.resolve(
610
+ [...this.credits.values()].filter((c) => c.callerPubkey === callerPubkey).sort((a, b) => a.createdAt - b.createdAt || a.creditId.localeCompare(b.creditId)).map((c) => this.snapshot(c, nowMs))
611
+ );
612
+ }
613
+ getDraw(args) {
614
+ const credit = this.credits.get(args.creditId);
615
+ const draw = credit?.draws.get(args.drawId);
616
+ return Promise.resolve(draw && credit ? drawRecord(draw, credit) : void 0);
617
+ }
618
+ getDrawByJobId(jobId) {
619
+ const matches = [];
620
+ for (const credit of this.credits.values()) {
621
+ for (const draw of credit.draws.values()) {
622
+ if (draw.jobId === jobId) matches.push(drawRecord(draw, credit));
623
+ }
624
+ }
625
+ return Promise.resolve(matches.length === 1 ? matches[0] : void 0);
626
+ }
627
+ listPendingDraws(creditId) {
628
+ const credit = this.credits.get(creditId);
629
+ if (!credit) return Promise.resolve([]);
630
+ return Promise.resolve(
631
+ [...credit.draws.values()].filter((d) => d.status === "pending").sort((a, b) => a.ledgerSeq - b.ledgerSeq).map((d) => drawRecord(d, credit))
632
+ );
633
+ }
634
+ listStalePendingDraws(args) {
635
+ const out = [];
636
+ for (const credit of this.credits.values()) {
637
+ for (const draw of credit.draws.values()) {
638
+ if (draw.status !== "pending") continue;
639
+ if (draw.createdAt >= args.createdBeforeMs) continue;
640
+ out.push(drawRecord(draw, credit));
641
+ }
642
+ }
643
+ out.sort(compareStaleDrawKey);
644
+ const after = args.after;
645
+ const page = after ? out.filter((d) => compareStaleDrawKey(d, after) > 0) : out;
646
+ return Promise.resolve(page.slice(0, args.limit));
647
+ }
648
+ /** Settled draws' native value for the credit an x402 channel funded (internal-review). */
649
+ earnedNativeForX402Channel(channelId) {
650
+ for (const credit of this.credits.values()) {
651
+ if (credit.x402ChannelId !== channelId) continue;
652
+ let earned = 0;
653
+ for (const draw of credit.draws.values()) {
654
+ if (draw.status !== "settled" || draw.drawNative === null) continue;
655
+ earned += draw.drawNative;
656
+ }
657
+ return Promise.resolve(earned);
658
+ }
659
+ return Promise.resolve(void 0);
660
+ }
661
+ getCreditByX402Channel(channelId) {
662
+ for (const credit of this.credits.values()) {
663
+ if (credit.x402ChannelId === channelId) {
664
+ return Promise.resolve(this.snapshot(credit, Date.now()));
665
+ }
666
+ }
667
+ return Promise.resolve(void 0);
668
+ }
669
+ terminalizeTempoCredits(evidence, _tx) {
670
+ return run(() => {
671
+ const channelId = evidence.channelId.toLowerCase();
672
+ const observedAt = evidence.observedAt ?? Date.now();
673
+ for (const credit of this.credits.values()) {
674
+ if (credit.tempoChannelId !== channelId) continue;
675
+ if (credit.status === "unbacked") continue;
676
+ const consumedServiceMicro = [...credit.draws.values()].filter((draw) => draw.status === "settled").reduce((sum, draw) => sum + draw.amountMicro, 0);
677
+ this.tempoLosses.set(credit.creditId, {
678
+ creditId: credit.creditId,
679
+ channelId,
680
+ callerPubkey: credit.callerPubkey,
681
+ currency: credit.currency,
682
+ formerBalanceMicro: credit.balanceMicro,
683
+ settledOnChainNative: evidence.settledOnChainNative,
684
+ highestVoucherNative: evidence.highestVoucherNative,
685
+ consumedServiceMicro,
686
+ consumedNative: evidence.consumedNative,
687
+ observedAt
688
+ });
689
+ credit.status = "unbacked";
690
+ }
691
+ return [...this.tempoLosses.values()].filter((loss) => loss.channelId === channelId);
692
+ });
693
+ }
694
+ listTempoCreditLosses(args) {
695
+ return Promise.resolve(
696
+ [...this.tempoLosses.values()].sort((a, b) => b.observedAt - a.observedAt || a.creditId.localeCompare(b.creditId)).slice(0, args?.limit ?? 50)
697
+ );
698
+ }
699
+ terminalizeX402Credit(evidence, _tx) {
700
+ return run(() => {
701
+ const channelId = evidence.channelId.toLowerCase();
702
+ const observedAt = evidence.observedAt ?? Date.now();
703
+ for (const credit of this.credits.values()) {
704
+ if (credit.x402ChannelId !== channelId) continue;
705
+ if (credit.status === "unbacked") continue;
706
+ const consumedServiceMicro = [...credit.draws.values()].filter((draw) => draw.status === "settled").reduce((sum, draw) => sum + draw.amountMicro, 0);
707
+ this.x402Losses.set(credit.creditId, {
708
+ creditId: credit.creditId,
709
+ channelId,
710
+ callerPubkey: credit.callerPubkey,
711
+ currency: credit.currency,
712
+ formerBalanceMicro: credit.balanceMicro,
713
+ channelBalanceNative: evidence.channelBalanceNative,
714
+ totalClaimedNative: evidence.totalClaimedNative,
715
+ consumedServiceMicro,
716
+ observedAt
717
+ });
718
+ credit.status = "unbacked";
719
+ }
720
+ return [...this.x402Losses.values()].filter((loss) => loss.channelId === channelId);
721
+ });
722
+ }
723
+ listX402CreditLosses(args) {
724
+ return Promise.resolve(
725
+ [...this.x402Losses.values()].sort((a, b) => b.observedAt - a.observedAt || a.creditId.localeCompare(b.creditId)).slice(0, args?.limit ?? 50)
726
+ );
727
+ }
728
+ // ── Drains (internal-review) — semantics mirror the Postgres ledger ─────────
729
+ async requestDrain(args) {
730
+ const wedged = await this.blockingRefundForCredit(
731
+ args.creditId,
732
+ X402_WEDGED_SETTLEMENT_STATUSES
733
+ );
734
+ const x402Refund = wedged?.effectId === `${args.creditId}:${args.drainId}` ? void 0 : wedged;
735
+ return run(() => {
736
+ const nowMs = args.nowMs ?? Date.now();
737
+ const credit = this.requireCredit(args.creditId);
738
+ if (credit.callerPubkey !== args.callerPubkey) {
739
+ throw new CreditLedgerError(
740
+ "caller_mismatch",
741
+ `credit ${args.creditId} belongs to a different caller pubkey`
742
+ );
743
+ }
744
+ const existing = credit.drains.get(args.drainId);
745
+ if (existing) {
746
+ if (existing.method !== args.method || JSON.stringify(existing.payout) !== JSON.stringify(args.payout)) {
747
+ throw new CreditLedgerError(
748
+ "drain_conflict",
749
+ `drain ${args.drainId} on credit ${args.creditId} was recorded with a different method or payout`
750
+ );
751
+ }
752
+ return {
753
+ drain: drainRecord(existing, credit),
754
+ credit: this.snapshot(credit, nowMs),
755
+ replayed: true
756
+ };
757
+ }
758
+ if (credit.status !== "active") throw terminalCreditError(args.creditId);
759
+ if (x402Refund) throw x402SettlementPending(args.creditId, x402Refund);
760
+ const pending = pendingSums(credit);
761
+ const availableMicro = credit.balanceMicro - pending.micro;
762
+ if (args.requireNoPending && pending.micro > 0) {
763
+ throw new CreditLedgerError(
764
+ "drain_conflict",
765
+ `credit ${args.creditId} has pending draws that must finish before it can drain`,
766
+ { drainConflict: "pending_draws" }
767
+ );
768
+ }
769
+ if (availableMicro <= 0) {
770
+ throw new CreditLedgerError(
771
+ "nothing_to_drain",
772
+ `credit ${args.creditId} has no available balance to drain`,
773
+ { availableMicro, balanceMicro: credit.balanceMicro }
774
+ );
775
+ }
776
+ const depletion = isNonChannelBitcoinRail(credit.rail) ? depleteLots(openLots(credit), availableMicro) : void 0;
777
+ const inKind = depletion !== void 0 && isInKindDepletion(depletion, availableMicro);
778
+ const grossSats = inKind ? depletion.satsOwed : null;
779
+ const owedSats = grossSats === null ? null : netOwedSats(grossSats);
780
+ if (owedSats === 0) {
781
+ throw new CreditLedgerError(
782
+ "drain_below_dust",
783
+ `credit ${args.creditId} holds ${availableMicro} micro, worth ${grossSats ?? 0} sats at the rate it was funded \u2014 no more than the ${DRAIN_DELIVERY_RESERVE_SATS} sats it costs to hand back`,
784
+ { availableMicro, balanceMicro: credit.balanceMicro }
785
+ );
786
+ }
787
+ const ledgerSeq = credit.lastLedgerSeq + 1;
788
+ const drainedMsats = Math.max(0, credit.msatsRemaining - pending.msats);
789
+ const drainedNative = credit.nativeRemaining === null ? null : Math.max(0, credit.nativeRemaining - pending.native);
790
+ credit.lastLedgerSeq = ledgerSeq;
791
+ credit.balanceMicro -= availableMicro;
792
+ credit.msatsRemaining = Math.min(credit.msatsRemaining, pending.msats);
793
+ if (credit.nativeRemaining !== null) {
794
+ credit.nativeRemaining = Math.min(credit.nativeRemaining, pending.native);
795
+ }
796
+ const drain = {
797
+ drainId: args.drainId,
798
+ method: args.method,
799
+ payout: { ...args.payout },
800
+ amountMicro: availableMicro,
801
+ balanceAfterMicro: credit.balanceMicro,
802
+ drainedMsats,
803
+ drainedNative,
804
+ owedSats,
805
+ lotDebits: depletion?.debits ?? [],
806
+ ledgerSeq,
807
+ status: "pending",
808
+ token: null,
809
+ sentRef: null,
810
+ fulfilment: null,
811
+ receipts: [],
812
+ createdAt: nowMs,
813
+ parkedAt: null,
814
+ pickedUpAt: null,
815
+ sentAt: null,
816
+ releasedAt: null,
817
+ writeOffNote: null,
818
+ writtenOffAt: null
819
+ };
820
+ applyLotDebits(credit, drain.lotDebits, -1);
821
+ credit.drains.set(args.drainId, drain);
822
+ return {
823
+ drain: drainRecord(drain, credit),
824
+ credit: this.snapshot(credit, nowMs),
825
+ replayed: false
826
+ };
827
+ });
828
+ }
829
+ bitcoinDepositLiability() {
830
+ const byRail = /* @__PURE__ */ new Map();
831
+ const byCurrency = /* @__PURE__ */ new Map();
832
+ const liability = {
833
+ depositSats: 0,
834
+ credits: 0,
835
+ byRail: [],
836
+ byCurrency: [],
837
+ uncoveredCredits: 0,
838
+ uncoveredMicro: 0,
839
+ unbackedCredits: 0,
840
+ unbackedMicro: 0
841
+ };
842
+ for (const credit of this.credits.values()) {
843
+ if (!isNonChannelBitcoinRail(credit.rail)) continue;
844
+ if (credit.tempoChannelId !== null || credit.x402ChannelId !== null) continue;
845
+ if (credit.balanceMicro <= 0) continue;
846
+ const lots = openLots(credit);
847
+ const covered = lots.reduce((sum, lot) => sum + lot.remainingMicro, 0);
848
+ const basisless = lots.reduce(
849
+ (sum, lot) => sum + (lot.satsFunded === 0 ? lot.remainingMicro : 0),
850
+ 0
851
+ );
852
+ const inKind = covered >= credit.balanceMicro && basisless === 0;
853
+ const owed = inKind ? lotOwedSats(lots) : 0;
854
+ liability.depositSats += owed;
855
+ liability.credits++;
856
+ if (covered < credit.balanceMicro) {
857
+ liability.uncoveredCredits++;
858
+ liability.uncoveredMicro += credit.balanceMicro;
859
+ } else if (basisless > 0) {
860
+ liability.unbackedCredits++;
861
+ liability.unbackedMicro += credit.balanceMicro;
862
+ }
863
+ const rail = byRail.get(credit.rail) ?? { rail: credit.rail, sats: 0, credits: 0 };
864
+ rail.sats += owed;
865
+ rail.credits++;
866
+ byRail.set(credit.rail, rail);
867
+ const currency = byCurrency.get(credit.currency) ?? {
868
+ currency: credit.currency,
869
+ micro: 0,
870
+ credits: 0
871
+ };
872
+ currency.micro += credit.balanceMicro;
873
+ currency.credits++;
874
+ byCurrency.set(credit.currency, currency);
875
+ }
876
+ liability.byRail = [...byRail.values()];
877
+ liability.byCurrency = [...byCurrency.values()];
878
+ return Promise.resolve(liability);
879
+ }
880
+ listFundingLots(creditId) {
881
+ const credit = this.credits.get(creditId);
882
+ return Promise.resolve(credit ? openLots(credit).map((lot) => ({ ...lot })) : []);
883
+ }
884
+ getDrain(args) {
885
+ const credit = this.credits.get(args.creditId);
886
+ const drain = credit?.drains.get(args.drainId);
887
+ return Promise.resolve(drain && credit ? drainRecord(drain, credit) : void 0);
888
+ }
889
+ listPendingDrains() {
890
+ const out = [];
891
+ for (const credit of this.credits.values()) {
892
+ for (const drain of credit.drains.values()) {
893
+ if (drain.status === "pending") out.push(drainRecord(drain, credit));
894
+ }
895
+ }
896
+ out.sort((a, b) => a.createdAt - b.createdAt || a.creditId.localeCompare(b.creditId));
897
+ return Promise.resolve(out);
898
+ }
899
+ listChannelDrains(args) {
900
+ const out = [];
901
+ for (const credit of this.credits.values()) {
902
+ if (credit.tempoChannelId === null) continue;
903
+ for (const drain of credit.drains.values()) {
904
+ if (drain.status !== "pending" || drain.method !== "tempo") continue;
905
+ if (drain.createdAt > args.createdBeforeMs) continue;
906
+ if (!args.includeResolved && drain.writtenOffAt !== null) continue;
907
+ out.push(drainRecord(drain, credit));
908
+ }
909
+ }
910
+ out.sort(
911
+ (a, b) => a.createdAt - b.createdAt || a.creditId.localeCompare(b.creditId) || a.drainId.localeCompare(b.drainId)
912
+ );
913
+ const after = args.after;
914
+ const page = after ? out.filter(
915
+ (row) => row.createdAt > after.createdAt || row.createdAt === after.createdAt && (row.creditId > after.creditId || row.creditId === after.creditId && row.drainId > after.drainId)
916
+ ) : out;
917
+ return Promise.resolve(page.slice(0, args.limit));
918
+ }
919
+ writeOffDrain(args) {
920
+ const credit = this.credits.get(args.creditId);
921
+ const drain = credit?.drains.get(args.drainId);
922
+ if (!credit || !drain) return Promise.resolve(void 0);
923
+ if (drain.status !== "pending" || drain.writtenOffAt !== null) {
924
+ return Promise.resolve({ drain: drainRecord(drain, credit), written: false });
925
+ }
926
+ drain.writeOffNote = args.note ?? null;
927
+ drain.writtenOffAt = args.nowMs ?? Date.now();
928
+ return Promise.resolve({ drain: drainRecord(drain, credit), written: true });
929
+ }
930
+ clearDrainWriteOff(args) {
931
+ const drain = this.credits.get(args.creditId)?.drains.get(args.drainId);
932
+ if (drain) {
933
+ drain.writeOffNote = null;
934
+ drain.writtenOffAt = null;
935
+ }
936
+ return Promise.resolve();
937
+ }
938
+ releaseDrain(args) {
939
+ return run(() => {
940
+ const { credit, drain } = this.requireDrain(args.creditId, args.drainId);
941
+ if (drain.status === "released") {
942
+ return {
943
+ drain: drainRecord(drain, credit),
944
+ credit: this.snapshot(credit, args.nowMs ?? Date.now()),
945
+ replayed: true
946
+ };
947
+ }
948
+ if (drain.status !== "pending") {
949
+ throw new CreditLedgerError(
950
+ "invalid_drain_state",
951
+ `cannot release ${drain.method} drain ${args.drainId} from status ${drain.status}`
952
+ );
953
+ }
954
+ drain.status = "released";
955
+ drain.releasedAt = args.nowMs ?? Date.now();
956
+ credit.balanceMicro += drain.amountMicro;
957
+ credit.msatsRemaining += drain.drainedMsats;
958
+ if (credit.nativeRemaining !== null && drain.drainedNative !== null) {
959
+ credit.nativeRemaining += drain.drainedNative;
960
+ }
961
+ applyLotDebits(credit, drain.lotDebits, 1);
962
+ return {
963
+ drain: drainRecord(drain, credit),
964
+ credit: this.snapshot(credit, args.nowMs ?? Date.now()),
965
+ replayed: false
966
+ };
967
+ });
968
+ }
969
+ parkDrain(args) {
970
+ return run(() => {
971
+ const { credit, drain } = this.requireDrain(args.creditId, args.drainId);
972
+ if (drain.status === "pending" && drain.method === "cashu") {
973
+ drain.status = "parked";
974
+ drain.token = args.token;
975
+ if (args.fulfilment) drain.fulfilment = args.fulfilment;
976
+ drain.parkedAt = args.nowMs ?? Date.now();
977
+ return drainRecord(drain, credit);
978
+ }
979
+ if ((drain.status === "parked" || drain.status === "picked_up") && drain.token === args.token) {
980
+ return drainRecord(drain, credit);
981
+ }
982
+ throw this.transitionRefusal(drain, "park");
983
+ });
984
+ }
985
+ markDrainPickedUp(args) {
986
+ return run(() => {
987
+ const { credit, drain } = this.requireDrain(args.creditId, args.drainId);
988
+ if (drain.status === "parked") {
989
+ drain.status = "picked_up";
990
+ drain.pickedUpAt = args.nowMs ?? Date.now();
991
+ return { drain: drainRecord(drain, credit), replayed: false };
992
+ }
993
+ if (drain.status === "picked_up")
994
+ return { drain: drainRecord(drain, credit), replayed: true };
995
+ throw this.transitionRefusal(drain, "mark picked up");
996
+ });
997
+ }
998
+ markDrainSent(args) {
999
+ return run(() => {
1000
+ const { credit, drain } = this.requireDrain(args.creditId, args.drainId);
1001
+ if (drain.status === "pending" && drain.method !== "cashu") {
1002
+ drain.status = "sent";
1003
+ drain.sentRef = { ...args.sentRef };
1004
+ drain.sentAt = args.nowMs ?? Date.now();
1005
+ return { drain: drainRecord(drain, credit), replayed: false };
1006
+ }
1007
+ if (drain.status === "sent") return { drain: drainRecord(drain, credit), replayed: true };
1008
+ throw this.transitionRefusal(drain, "mark sent");
1009
+ });
1010
+ }
1011
+ appendDrainReceipt(args) {
1012
+ return run(() => {
1013
+ const drain = this.credits.get(args.creditId)?.drains.get(args.drainId);
1014
+ if (drain) drain.receipts.push(args.receipt);
1015
+ });
1016
+ }
1017
+ // ── Internals ─────────────────────────────────────────────────────────
1018
+ requireDrain(creditId, drainId) {
1019
+ const credit = this.credits.get(creditId);
1020
+ const drain = credit?.drains.get(drainId);
1021
+ if (!credit || !drain) {
1022
+ throw new CreditLedgerError(
1023
+ "drain_not_found",
1024
+ `drain ${drainId} not found on credit ${creditId}`
1025
+ );
1026
+ }
1027
+ return { credit, drain };
1028
+ }
1029
+ transitionRefusal(drain, verb) {
1030
+ return new CreditLedgerError(
1031
+ drain.status === "parked" || drain.status === "picked_up" || drain.status === "sent" ? "drain_conflict" : "invalid_drain_state",
1032
+ `cannot ${verb} drain ${drain.drainId}: status is ${drain.status} (method ${drain.method})`
1033
+ );
1034
+ }
1035
+ resolve(args) {
1036
+ const nowMs = args.nowMs ?? Date.now();
1037
+ const credit = this.requireCredit(args.creditId);
1038
+ const draw = credit.draws.get(args.drawId);
1039
+ if (!draw) {
1040
+ throw new CreditLedgerError(
1041
+ "draw_not_found",
1042
+ `draw ${args.drawId} not found on credit ${args.creditId}`
1043
+ );
1044
+ }
1045
+ let replayed = false;
1046
+ if (draw.status === args.to) {
1047
+ replayed = true;
1048
+ } else if (draw.status !== "pending") {
1049
+ throw new CreditLedgerError(
1050
+ "invalid_draw_state",
1051
+ `cannot ${args.to === "settled" ? "settle" : "release"} draw ${args.drawId}: status is ${draw.status}`,
1052
+ { currentStatus: draw.status }
1053
+ );
1054
+ } else {
1055
+ draw.status = args.to;
1056
+ draw.resolvedAt = nowMs;
1057
+ if (args.to === "settled") {
1058
+ credit.balanceMicro -= draw.amountMicro;
1059
+ if (isNonChannelBitcoinRail(credit.rail)) {
1060
+ const depletion = depleteLots(openLots(credit), draw.amountMicro);
1061
+ const settledMsats = inKindDrawMsats(depletion, draw.amountMicro);
1062
+ if (settledMsats !== null && settledMsats !== draw.drawMsats) {
1063
+ draw.allocatedMsats = draw.drawMsats;
1064
+ draw.drawMsats = settledMsats;
1065
+ }
1066
+ applyLotDebits(credit, depletion.debits, -1);
1067
+ }
1068
+ credit.msatsRemaining = Math.max(0, credit.msatsRemaining - draw.drawMsats);
1069
+ if (credit.nativeRemaining !== null) {
1070
+ credit.nativeRemaining = Math.max(0, credit.nativeRemaining - (draw.drawNative ?? 0));
1071
+ }
1072
+ }
1073
+ }
1074
+ return {
1075
+ ...drawResult(draw, credit),
1076
+ status: args.to,
1077
+ replayed,
1078
+ availableMicro: credit.status === "unbacked" ? 0 : credit.balanceMicro - pendingSums(credit).micro,
1079
+ resolvedAt: draw.resolvedAt ?? nowMs
1080
+ };
1081
+ }
1082
+ requireCredit(creditId) {
1083
+ const credit = this.credits.get(creditId);
1084
+ if (!credit) {
1085
+ throw new CreditLedgerError("credit_not_found", `credit ${creditId} not found`);
1086
+ }
1087
+ return credit;
1088
+ }
1089
+ snapshot(credit, nowMs) {
1090
+ const pending = pendingSums(credit);
1091
+ const live = credit.status === "active";
1092
+ return {
1093
+ creditId: credit.creditId,
1094
+ callerPubkey: credit.callerPubkey,
1095
+ currency: credit.currency,
1096
+ balanceMicro: credit.balanceMicro,
1097
+ availableMicro: live ? credit.balanceMicro - pending.micro : 0,
1098
+ nativeAvailable: credit.nativeRemaining === null ? null : live ? Math.max(0, credit.nativeRemaining - pending.native) : 0,
1099
+ expiryMs: credit.expiryMs,
1100
+ expired: credit.expiryMs <= nowMs,
1101
+ status: credit.status,
1102
+ lastLedgerSeq: credit.lastLedgerSeq,
1103
+ createdAt: credit.createdAt,
1104
+ rail: credit.rail,
1105
+ tempoChannelId: credit.tempoChannelId,
1106
+ x402ChannelId: credit.x402ChannelId
1107
+ };
1108
+ }
1109
+ };
1110
+ function terminalCreditError(creditId) {
1111
+ return new CreditLedgerError(
1112
+ "credit_unbacked",
1113
+ `credit ${creditId} is terminal; its Tempo backing finalized before the DVM was protected`
1114
+ );
1115
+ }
1116
+ function applyBasis(credit, basis) {
1117
+ credit.rail ??= basis.rail;
1118
+ if (basis.nativeAsset !== void 0) credit.nativeAsset = basis.nativeAsset;
1119
+ if (basis.mint !== void 0) credit.mint = basis.mint;
1120
+ if (basis.fundingRef !== void 0) credit.fundingRef = basis.fundingRef;
1121
+ if (basis.tempoSession) credit.tempoChannelId ??= basis.tempoSession.channelId.toLowerCase();
1122
+ credit.x402ChannelId ??= basis.x402Channel?.channelId ?? null;
1123
+ credit.msatsRemaining += basis.paidMsats;
1124
+ if (basis.nativeAmount !== void 0) {
1125
+ credit.nativeRemaining = (credit.nativeRemaining ?? 0) + basis.nativeAmount;
1126
+ }
1127
+ }
1128
+ function recordLot(credit, amountMicro, basis, nowMs) {
1129
+ if (!isNonChannelBitcoinRail(basis.rail)) return;
1130
+ if (basis.tempoSession || basis.x402Channel) return;
1131
+ credit.lots.push({
1132
+ lotId: `lot:${randomUUID()}`,
1133
+ creditId: credit.creditId,
1134
+ rail: basis.rail,
1135
+ satsFunded: Math.floor(basis.paidMsats / 1e3),
1136
+ creditedMicro: amountMicro,
1137
+ remainingMicro: amountMicro,
1138
+ fundingRef: basis.fundingRef ?? null,
1139
+ createdAt: nowMs
1140
+ });
1141
+ }
1142
+ function applyLotDebits(credit, debits, sign) {
1143
+ for (const debit of debits) {
1144
+ const lot = credit.lots.find((candidate) => candidate.lotId === debit.lotId);
1145
+ if (!lot) continue;
1146
+ lot.remainingMicro = Math.max(
1147
+ 0,
1148
+ Math.min(lot.remainingMicro + sign * debit.micro, lot.creditedMicro)
1149
+ );
1150
+ }
1151
+ }
1152
+ function openLots(credit) {
1153
+ return credit.lots.filter((lot) => lot.remainingMicro > 0).sort(fifoOrder);
1154
+ }
1155
+ function run(fn) {
1156
+ try {
1157
+ return Promise.resolve(fn());
1158
+ } catch (err) {
1159
+ return Promise.reject(err instanceof Error ? err : new Error(String(err)));
1160
+ }
1161
+ }
1162
+ function fundingKey(creditId, fundId) {
1163
+ return JSON.stringify([creditId, fundId]);
1164
+ }
1165
+ function pendingSums(credit) {
1166
+ const sums = { micro: 0, msats: 0, native: 0 };
1167
+ for (const d of credit.draws.values()) {
1168
+ if (d.status !== "pending") continue;
1169
+ sums.micro += d.amountMicro;
1170
+ sums.msats += d.drawMsats;
1171
+ sums.native += d.drawNative ?? 0;
1172
+ }
1173
+ return sums;
1174
+ }
1175
+ function railValue(draw, credit) {
1176
+ return {
1177
+ drawMsats: draw.drawMsats,
1178
+ drawNative: draw.drawNative,
1179
+ rail: credit.rail,
1180
+ nativeAsset: credit.nativeAsset,
1181
+ mint: credit.mint,
1182
+ fundingRef: credit.fundingRef,
1183
+ creditCurrency: credit.currency
1184
+ };
1185
+ }
1186
+ function drawResult(draw, credit) {
1187
+ return {
1188
+ creditId: draw.creditId,
1189
+ drawId: draw.drawId,
1190
+ amountMicro: draw.amountMicro,
1191
+ balanceAfterMicro: draw.balanceAfterMicro,
1192
+ ledgerSeq: draw.ledgerSeq,
1193
+ status: draw.status,
1194
+ replayed: false,
1195
+ railValue: railValue(draw, credit),
1196
+ resolvedAt: draw.resolvedAt
1197
+ };
1198
+ }
1199
+ function drawRecord(draw, credit) {
1200
+ const { replayed: _replayed, ...rest } = drawResult(draw, credit);
1201
+ return {
1202
+ ...rest,
1203
+ jobId: draw.jobId,
1204
+ createdAt: draw.createdAt,
1205
+ allocatedMsats: draw.allocatedMsats ?? draw.drawMsats
1206
+ };
1207
+ }
1208
+ function compareStaleDrawKey(a, b) {
1209
+ return a.createdAt - b.createdAt || a.creditId.localeCompare(b.creditId) || a.ledgerSeq - b.ledgerSeq;
1210
+ }
1211
+ function compareInvoiceKeyset(a, b) {
1212
+ return a.createdAt - b.createdAt || a.creditId.localeCompare(b.creditId) || a.fundId.localeCompare(b.fundId);
1213
+ }
1214
+ function compareToCursor(invoice, cursor) {
1215
+ return invoice.createdAt - cursor.createdAt || invoice.creditId.localeCompare(cursor.creditId) || invoice.fundId.localeCompare(cursor.fundId);
1216
+ }
1217
+ function drainRecord(drain, credit) {
1218
+ return {
1219
+ creditId: credit.creditId,
1220
+ drainId: drain.drainId,
1221
+ callerPubkey: credit.callerPubkey,
1222
+ currency: credit.currency,
1223
+ method: drain.method,
1224
+ channelId: credit.tempoChannelId ?? credit.x402ChannelId,
1225
+ payout: { ...drain.payout },
1226
+ amountMicro: drain.amountMicro,
1227
+ drainedNative: drain.drainedNative,
1228
+ owedSats: drain.owedSats,
1229
+ balanceAfterMicro: drain.balanceAfterMicro,
1230
+ ledgerSeq: drain.ledgerSeq,
1231
+ status: drain.status,
1232
+ token: drain.token,
1233
+ sentRef: drain.sentRef ? { ...drain.sentRef } : null,
1234
+ fulfilment: drain.fulfilment ? { ...drain.fulfilment } : null,
1235
+ receipts: [...drain.receipts],
1236
+ createdAt: drain.createdAt,
1237
+ parkedAt: drain.parkedAt,
1238
+ pickedUpAt: drain.pickedUpAt,
1239
+ sentAt: drain.sentAt,
1240
+ releasedAt: drain.releasedAt,
1241
+ writeOffNote: drain.writeOffNote,
1242
+ writtenOffAt: drain.writtenOffAt
1243
+ };
1244
+ }
1245
+ function assertAmount(amountMicro, opts) {
1246
+ if (!Number.isSafeInteger(amountMicro) || amountMicro < opts.min) {
1247
+ throw new CreditLedgerError(
1248
+ "invalid_amount",
1249
+ `amountMicro must be a safe integer \u2265 ${opts.min} (got ${amountMicro})`
1250
+ );
1251
+ }
1252
+ }
1253
+
1254
+ export {
1255
+ MemoryCreditLedger
1256
+ };