@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.
- package/NOTICE +2 -0
- package/README.md +38 -2
- package/dist/chunk-27V2ILSR.js +291 -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-NTK5DJ6R.js +1256 -0
- package/dist/chunk-RPXHKMYE.js +3808 -0
- package/dist/chunk-S3XAHZQY.js +63 -0
- package/dist/chunk-YG7G4DPZ.js +25 -0
- package/dist/credit-ledger-EDMEZSA2.js +28 -0
- package/dist/index.d.ts +144 -0
- package/dist/index.js +303 -0
- package/dist/job-store-C5n6bhap.d.ts +5090 -0
- package/dist/memory-credit-ledger-7TTZDSRS.js +9 -0
- package/dist/mpp-secret-state-WNAQQ6K4.js +127 -0
- package/dist/mpp-setup-MOBWGTWJ.js +30 -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-M35KP6V7.js +435 -0
- package/dist/server/index.d.ts +4108 -0
- package/dist/server/index.js +22538 -0
- package/dist/ssrf-BdHsrrIb.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,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
|
+
};
|