@dvmkit/sdk 0.0.0 → 0.1.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/NOTICE +2 -0
  2. package/README.md +38 -2
  3. package/dist/chunk-27V2ILSR.js +291 -0
  4. package/dist/chunk-365P52XQ.js +4121 -0
  5. package/dist/chunk-5GFED3GJ.js +955 -0
  6. package/dist/chunk-6JZIX5WW.js +1155 -0
  7. package/dist/chunk-7IH5SG2A.js +1038 -0
  8. package/dist/chunk-AT6V3SY7.js +102 -0
  9. package/dist/chunk-DCNT4PJS.js +733 -0
  10. package/dist/chunk-DMNLFNTW.js +135 -0
  11. package/dist/chunk-FROTD5XQ.js +70 -0
  12. package/dist/chunk-H25M54MI.js +149 -0
  13. package/dist/chunk-KQAJVVZT.js +712 -0
  14. package/dist/chunk-KXWROQGK.js +74 -0
  15. package/dist/chunk-L4OYF4DQ.js +67 -0
  16. package/dist/chunk-OJ5WFIB2.js +1266 -0
  17. package/dist/chunk-S3XAHZQY.js +63 -0
  18. package/dist/chunk-YG7G4DPZ.js +25 -0
  19. package/dist/credit-ledger-RO4FGSHG.js +28 -0
  20. package/dist/index.d.ts +144 -0
  21. package/dist/index.js +303 -0
  22. package/dist/job-store-6gR4pZRP.d.ts +5350 -0
  23. package/dist/memory-credit-ledger-I2G64DDK.js +9 -0
  24. package/dist/mpp-secret-state-WNAQQ6K4.js +127 -0
  25. package/dist/mpp-setup-MOBWGTWJ.js +30 -0
  26. package/dist/payout-reporter-4TNWRS5F.js +753 -0
  27. package/dist/postgres-consumed-credential-store-VHBT4KEA.js +72 -0
  28. package/dist/postgres-job-store-J5F4GUWU.js +7 -0
  29. package/dist/postgres-kv-store-JFBDP5IP.js +7 -0
  30. package/dist/postgres-replay-store-UJXRT6VO.js +7 -0
  31. package/dist/pricing-4CEB34RM.js +48 -0
  32. package/dist/processed-payment-store-HAA4SFNK.js +11 -0
  33. package/dist/revenue-reporter-GB4WKLDC.js +510 -0
  34. package/dist/server/index.d.ts +4168 -0
  35. package/dist/server/index.js +22716 -0
  36. package/dist/ssrf-DZi-xJyn.d.ts +325 -0
  37. package/dist/tempo-charge-store-6GJEMNUU.js +130 -0
  38. package/dist/tempo-session-store-FTEEGZXA.js +467 -0
  39. package/dist/testing/index.d.ts +135 -0
  40. package/dist/testing/index.js +151 -0
  41. package/dist/x402-35VLYFKZ.js +1272 -0
  42. package/package.json +89 -6
@@ -0,0 +1,4121 @@
1
+ import {
2
+ applyRetryToPool
3
+ } from "./chunk-H25M54MI.js";
4
+ import {
5
+ withSdkInitLock
6
+ } from "./chunk-S3XAHZQY.js";
7
+
8
+ // src/sdk/server/credit-ledger.ts
9
+ import { randomUUID } from "crypto";
10
+
11
+ // src/sdk/server/credit-lots.ts
12
+ var NON_CHANNEL_BITCOIN_RAILS = [
13
+ "cashu",
14
+ "lightning"
15
+ ];
16
+ function isNonChannelBitcoinRail(rail) {
17
+ return typeof rail === "string" && NON_CHANNEL_BITCOIN_RAILS.includes(rail);
18
+ }
19
+ function depleteLots(lots, amountMicro) {
20
+ const debits = [];
21
+ let left = Math.max(0, amountMicro);
22
+ let satsOwed = 0;
23
+ let backedMicro = 0;
24
+ for (const lot of lots) {
25
+ if (left <= 0) break;
26
+ const available = Math.max(0, lot.remainingMicro);
27
+ if (available === 0) continue;
28
+ const micro = Math.min(available, left);
29
+ const sats = lotSats(lot, available) - lotSats(lot, available - micro);
30
+ debits.push({ lotId: lot.lotId, micro, sats });
31
+ satsOwed += sats;
32
+ if (lot.satsFunded > 0) backedMicro += micro;
33
+ left -= micro;
34
+ }
35
+ return { debits, satsOwed, uncoveredMicro: left, backedMicro };
36
+ }
37
+ function inKindDrawMsats(depletion, amountMicro) {
38
+ return isInKindDepletion(depletion, amountMicro) ? depletion.satsOwed * 1e3 : null;
39
+ }
40
+ function lotOwedSats(lots) {
41
+ return lots.reduce((sum, lot) => sum + lotSats(lot, lot.remainingMicro), 0);
42
+ }
43
+ var DRAIN_DELIVERY_RESERVE_SATS = 8;
44
+ function netOwedSats(grossSats) {
45
+ return Math.max(0, grossSats - DRAIN_DELIVERY_RESERVE_SATS);
46
+ }
47
+ function fifoOrder(left, right) {
48
+ return left.createdAt - right.createdAt || left.lotId.localeCompare(right.lotId);
49
+ }
50
+ function isInKindDepletion(depletion, amountMicro) {
51
+ return depletion.uncoveredMicro === 0 && depletion.backedMicro === amountMicro && amountMicro > 0;
52
+ }
53
+ function lotSats(lot, micro) {
54
+ if (lot.satsFunded <= 0 || lot.creditedMicro <= 0 || micro <= 0) return 0;
55
+ const taken = Math.min(micro, lot.creditedMicro);
56
+ return Math.floor(lot.satsFunded * taken / lot.creditedMicro);
57
+ }
58
+
59
+ // src/sdk/server/x402-channel-store.ts
60
+ import { AsyncLocalStorage } from "async_hooks";
61
+ import { isDeepStrictEqual } from "util";
62
+ var X402SettlementRaceError = class extends Error {
63
+ };
64
+ var X402RelaySubmissionLockError = class extends Error {
65
+ constructor(lockKey, timeoutMs, holder) {
66
+ super(
67
+ `x402 self-relay submission lock '${lockKey}' still held after ${String(timeoutMs)}ms${holder ? ` (held by ${describeHolder(holder)})` : ""}`
68
+ );
69
+ this.lockKey = lockKey;
70
+ this.timeoutMs = timeoutMs;
71
+ this.holder = holder;
72
+ this.name = "X402RelaySubmissionLockError";
73
+ }
74
+ lockKey;
75
+ timeoutMs;
76
+ holder;
77
+ code = "x402_relay_submission_lock_timeout";
78
+ retryable = true;
79
+ };
80
+ var X402_WEDGED_SETTLEMENT_STATUSES = ["submitting", "settled"];
81
+ var X402_RESOLVED_SETTLEMENT_STATUSES = [
82
+ ...X402_WEDGED_SETTLEMENT_STATUSES,
83
+ "written_off"
84
+ ];
85
+ var X402_SPEND_BLOCKING_SETTLEMENT_STATUSES = [
86
+ ...X402_WEDGED_SETTLEMENT_STATUSES,
87
+ "written_off"
88
+ ];
89
+ var SETTLEMENT_POOL_MAX = 5;
90
+ var SETTLEMENT_CONNECT_TIMEOUT_MS = 1e4;
91
+ var FLEET_LOCK_TIMEOUT_MS = 3e4;
92
+ var RELAY_SUBMISSION_LOCK_TIMEOUT_MS = 3e4;
93
+ var LOCK_NOT_AVAILABLE = "55P03";
94
+ var PostgresX402ChannelStorage = class {
95
+ constructor(pool, opts) {
96
+ this.pool = pool;
97
+ this.relayLockTimeoutMs = opts?.relaySubmissionLockTimeoutMs ?? RELAY_SUBMISSION_LOCK_TIMEOUT_MS;
98
+ this.terminalizeUnbackedCredit = opts?.terminalizeUnbackedCredit;
99
+ if (!Number.isInteger(this.relayLockTimeoutMs) || this.relayLockTimeoutMs <= 0) {
100
+ throw new Error("relaySubmissionLockTimeoutMs must be a positive integer number of ms");
101
+ }
102
+ }
103
+ pool;
104
+ transaction = new AsyncLocalStorage();
105
+ refundBase = new AsyncLocalStorage();
106
+ settlementPool;
107
+ fleetLock = Promise.resolve();
108
+ fleetLockQueueDepth = 0;
109
+ relayLockTimeoutMs;
110
+ terminalizeUnbackedCredit;
111
+ /** Create the durable channel table under the SDK-wide DDL lock. */
112
+ async init() {
113
+ this.settlementPool ??= createSettlementPool(this.pool);
114
+ await withSdkInitLock(this.pool, async () => {
115
+ await this.pool.query(`
116
+ CREATE TABLE IF NOT EXISTS x402_batch_channels (
117
+ channel_id TEXT PRIMARY KEY,
118
+ channel_config JSONB NOT NULL,
119
+ charged_cumulative_amount NUMERIC(78, 0) NOT NULL,
120
+ signed_max_claimable NUMERIC(78, 0) NOT NULL,
121
+ signature TEXT NOT NULL,
122
+ balance NUMERIC(78, 0) NOT NULL,
123
+ total_claimed NUMERIC(78, 0) NOT NULL,
124
+ withdraw_requested_at BIGINT NOT NULL,
125
+ refund_nonce BIGINT NOT NULL,
126
+ onchain_synced_at BIGINT,
127
+ last_request_timestamp BIGINT NOT NULL,
128
+ pending_request JSONB
129
+ )
130
+ `);
131
+ await this.pool.query(
132
+ `CREATE INDEX IF NOT EXISTS idx_x402_batch_channels_withdrawal
133
+ ON x402_batch_channels(withdraw_requested_at)
134
+ WHERE withdraw_requested_at <> 0`
135
+ );
136
+ await this.pool.query(`
137
+ CREATE TABLE IF NOT EXISTS x402_batch_settlements (
138
+ settlement_id TEXT PRIMARY KEY,
139
+ channel_id TEXT NOT NULL,
140
+ operation TEXT NOT NULL CHECK (operation IN ('fund', 'refund')),
141
+ effect_id TEXT NOT NULL,
142
+ payment_id TEXT NOT NULL,
143
+ payment_payload JSONB NOT NULL,
144
+ payment_requirements JSONB NOT NULL,
145
+ channel_before JSONB,
146
+ submission_block NUMERIC(78, 0),
147
+ pending_id TEXT,
148
+ status TEXT NOT NULL,
149
+ settle_response JSONB,
150
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
151
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
152
+ UNIQUE (operation, effect_id),
153
+ UNIQUE (operation, payment_id)
154
+ )
155
+ `);
156
+ await this.pool.query(
157
+ `ALTER TABLE x402_batch_settlements ADD COLUMN IF NOT EXISTS channel_before JSONB`
158
+ );
159
+ await this.pool.query(
160
+ `ALTER TABLE x402_batch_settlements ADD COLUMN IF NOT EXISTS submission_block NUMERIC(78, 0)`
161
+ );
162
+ await this.pool.query(
163
+ `ALTER TABLE x402_batch_settlements ADD COLUMN IF NOT EXISTS channel_at_submission JSONB`
164
+ );
165
+ await this.pool.query(
166
+ `ALTER TABLE x402_batch_settlements ADD COLUMN IF NOT EXISTS write_off_note TEXT`
167
+ );
168
+ await this.pool.query(
169
+ `ALTER TABLE x402_batch_settlements ADD COLUMN IF NOT EXISTS resolved_at BIGINT`
170
+ );
171
+ await this.pool.query(
172
+ `ALTER TABLE x402_batch_settlements
173
+ DROP CONSTRAINT IF EXISTS x402_batch_settlements_status_check`
174
+ );
175
+ await this.pool.query(
176
+ `ALTER TABLE x402_batch_settlements
177
+ ADD CONSTRAINT x402_batch_settlements_status_check
178
+ CHECK (status IN ('prepared', 'submitting', 'settled', 'complete', 'written_off'))`
179
+ );
180
+ await this.pool.query(`DROP INDEX IF EXISTS idx_x402_batch_settlements_repair`);
181
+ await this.pool.query(
182
+ `CREATE INDEX IF NOT EXISTS idx_x402_batch_settlements_repair_created
183
+ ON x402_batch_settlements(created_at, settlement_id)
184
+ WHERE status IN ('submitting', 'settled', 'written_off')`
185
+ );
186
+ await this.pool.query(
187
+ `CREATE INDEX IF NOT EXISTS idx_x402_batch_settlements_channel_open
188
+ ON x402_batch_settlements(channel_id, created_at)
189
+ WHERE operation = 'refund'
190
+ AND status IN ('submitting', 'settled', 'written_off')`
191
+ );
192
+ await this.pool.query(`
193
+ CREATE TABLE IF NOT EXISTS x402_batch_settlement_state (
194
+ scope TEXT PRIMARY KEY,
195
+ pending_settle BOOLEAN NOT NULL,
196
+ updated_at BIGINT NOT NULL
197
+ )
198
+ `);
199
+ });
200
+ }
201
+ /**
202
+ * Read the fleet's durable "claimed funds are awaiting transfer" marker.
203
+ *
204
+ * Upstream keeps this in process memory, which a multi-machine fleet cannot
205
+ * share: the machine that won a claim tick is rarely the one that wins the
206
+ * next settle tick. An unset scope reads false, so a receiver that has never
207
+ * claimed never submits a settlement.
208
+ *
209
+ * Both accessors run on the settlement pool: every caller reads or writes the
210
+ * marker while {@link withFleetSettlementLock} holds a host-pool client, which
211
+ * is the nesting the internal-review split exists to keep off a non-terminal pool.
212
+ */
213
+ async isSettlePending(scope) {
214
+ const reader = await this.settlementWriter();
215
+ const { rows } = await reader.query(
216
+ `SELECT pending_settle FROM x402_batch_settlement_state WHERE scope = $1`,
217
+ [scope]
218
+ );
219
+ return rows[0]?.pending_settle ?? false;
220
+ }
221
+ /** Record whether a claim's funds still need a `settle(receiver, token)` transfer. */
222
+ async setSettlePending(scope, pending) {
223
+ const writer = await this.settlementWriter();
224
+ await writer.query(
225
+ `INSERT INTO x402_batch_settlement_state (scope, pending_settle, updated_at)
226
+ VALUES ($1, $2, $3)
227
+ ON CONFLICT (scope) DO UPDATE
228
+ SET pending_settle = EXCLUDED.pending_settle,
229
+ updated_at = EXCLUDED.updated_at`,
230
+ [scope, pending, Date.now()]
231
+ );
232
+ }
233
+ /**
234
+ * Look up the durable settlement state for one credit-ledger effect.
235
+ *
236
+ * On the dedicated settlement pool like every other marker, and for a sharper
237
+ * reason than they had: this and {@link prepareSettlement} now run *inside*
238
+ * the channel transaction (internal-review), which holds a host-pool client for its
239
+ * whole duration. Taking a second client from that same pool is the
240
+ * self-deadlock internal-review fixed — at pool max every client is a holder and a
241
+ * waiter — and moving these calls under the transaction is exactly what would
242
+ * reintroduce it.
243
+ */
244
+ async getSettlement(operation, effectId) {
245
+ const { rows } = await (await this.settlementWriter()).query(
246
+ `SELECT * FROM x402_batch_settlements WHERE operation = $1 AND effect_id = $2`,
247
+ [operation, effectId]
248
+ );
249
+ return rows[0] ? toSettlementIntent(rows[0]) : void 0;
250
+ }
251
+ /**
252
+ * The refund on this channel that is neither finished nor abandoned, if there
253
+ * is one — the credit ledger's spend gate (internal-review).
254
+ *
255
+ * `statuses` is the caller's, because the two gates disagree by design: a
256
+ * draw is blocked by {@link X402_SPEND_BLOCKING_SETTLEMENT_STATUSES}, a fresh
257
+ * drain only by {@link X402_WEDGED_SETTLEMENT_STATUSES}.
258
+ *
259
+ * On the dedicated settlement pool, and that is what makes the gate callable
260
+ * at all: a draw routinely runs inside an open channel transaction holding a
261
+ * host-pool client, and taking a second one from that pool is the internal-review
262
+ * self-deadlock. The oldest blocking row wins, so a channel that somehow
263
+ * accumulated two names the one an operator should reach for first.
264
+ *
265
+ * Projects three columns rather than decoding a whole {@link
266
+ * X402SettlementIntent}: the decoder refuses a pre-internal-review row that carries
267
+ * no reconciliation snapshot, and a gate that throws on one would take every
268
+ * draw on that channel down with it — when the row is exactly the kind that
269
+ * ought to block them.
270
+ */
271
+ async pendingRefundSettlement(channelId, statuses) {
272
+ const { rows } = await (await this.settlementWriter()).query(
273
+ `SELECT settlement_id, effect_id, status FROM x402_batch_settlements
274
+ WHERE channel_id = $1 AND operation = 'refund' AND status = ANY($2::text[])
275
+ ORDER BY created_at, settlement_id
276
+ LIMIT 1`,
277
+ [normalizeChannelId(channelId), statuses]
278
+ );
279
+ if (rows.length === 0) return void 0;
280
+ const row = rows[0];
281
+ return { settlementId: row.settlement_id, effectId: row.effect_id, status: row.status };
282
+ }
283
+ /**
284
+ * Read one settlement by its own identifier — the handle an operator holds
285
+ * (internal-review). Same dedicated pool as {@link getSettlement}, for the same
286
+ * reason: the repair reads this while a channel transaction may be open.
287
+ */
288
+ async getSettlementById(settlementId) {
289
+ const { rows } = await (await this.settlementWriter()).query(`SELECT * FROM x402_batch_settlements WHERE settlement_id = $1`, [
290
+ settlementId
291
+ ]);
292
+ return rows[0] ? toSettlementIntent(rows[0]) : void 0;
293
+ }
294
+ /** Lazily move a legacy settlement row onto its authorization/effect-scoped replay key. */
295
+ async normalizeLegacyPaymentId(settlementId, operation, legacyPaymentId, paymentId) {
296
+ const writer = await this.settlementWriter();
297
+ const updated = await writer.query(
298
+ `UPDATE x402_batch_settlements
299
+ SET payment_id = $3
300
+ WHERE settlement_id = $1 AND operation = $4 AND payment_id = $2
301
+ RETURNING *`,
302
+ [settlementId, legacyPaymentId, paymentId, operation]
303
+ );
304
+ if (updated.rows[0]) return toSettlementIntent(updated.rows[0]);
305
+ const existing = await this.getSettlementById(settlementId);
306
+ if (existing?.operation === operation && existing.paymentId === paymentId) return existing;
307
+ throw new Error(`x402 settlement ${settlementId} legacy payment identity changed`);
308
+ }
309
+ /**
310
+ * The operator's repair queue: settlements stuck between "chain paid" and
311
+ * "ledger booked", oldest write first (internal-review).
312
+ *
313
+ * Keyset-paginated on `(created_at, settlement_id)` for the reason the
314
+ * blocked-invoice queue is: a wedged row never self-clears, so a fixed first
315
+ * page of rows the operator has declined to act on would starve everything
316
+ * behind it on every run, forever.
317
+ *
318
+ * The keyset is on `created_at`, which never moves, while `updatedBeforeMs`
319
+ * filters on `updated_at`, which does — so a row an operator touches mid-walk
320
+ * can leave the queue but can never jump the cursor and be skipped. The cursor
321
+ * itself is Postgres's text rendering of that timestamp, handed back verbatim;
322
+ * see {@link X402SettlementCursor} for why a `Date` cannot carry it.
323
+ *
324
+ * `updatedBeforeMs` is the staleness floor. A healthy settlement passes
325
+ * through `submitting` and `settled` inside one request, so anything younger
326
+ * than the floor is in flight rather than wedged and must not be offered as
327
+ * repairable work.
328
+ */
329
+ async listSettlements(args) {
330
+ const { rows } = await (await this.settlementWriter()).query(
331
+ `SELECT *, created_at::text AS created_at_key FROM x402_batch_settlements
332
+ WHERE status = ANY($1::text[])
333
+ AND updated_at <= to_timestamp($2::double precision / 1000)
334
+ AND ($3::text IS NULL
335
+ OR (created_at, settlement_id) > ($3::timestamptz, $4::text))
336
+ ORDER BY created_at, settlement_id
337
+ LIMIT $5`,
338
+ [
339
+ args.statuses,
340
+ args.updatedBeforeMs,
341
+ args.after?.createdAt ?? null,
342
+ args.after?.settlementId ?? null,
343
+ args.limit
344
+ ]
345
+ );
346
+ const last = rows.length === args.limit ? rows[rows.length - 1] : void 0;
347
+ return {
348
+ settlements: rows.map(toSettlementIntent),
349
+ ...last ? { nextAfter: { createdAt: last.created_at_key, settlementId: last.settlement_id } } : {}
350
+ };
351
+ }
352
+ /**
353
+ * Close a wedged settlement without booking anything (internal-review).
354
+ *
355
+ * A single-statement CAS on the wedged statuses, on the settlement pool —
356
+ * there is no ledger effect to be atomic with, which is the whole point:
357
+ * the write-off records that an operator reviewed this settlement and is not
358
+ * completing it, and no dvmkit table moves. Idempotent, and the first note
359
+ * wins; `written` is the transition bit the caller logs on, so the one place
360
+ * the amount is ever recorded gets exactly one entry.
361
+ *
362
+ * Reversible by construction: a later reconcile calls
363
+ * {@link recordSettlementResponse}, whose `status <> 'complete'` CAS lifts a
364
+ * written-off row back to `settled`.
365
+ */
366
+ async writeOffSettlement(settlementId, note, nowMs) {
367
+ const writer = await this.settlementWriter();
368
+ const updated = await writer.query(
369
+ `UPDATE x402_batch_settlements
370
+ SET status = 'written_off', write_off_note = $2, resolved_at = $3, updated_at = NOW()
371
+ WHERE settlement_id = $1 AND status = ANY($4::text[])
372
+ RETURNING *`,
373
+ [settlementId, note ?? null, nowMs, X402_WEDGED_SETTLEMENT_STATUSES]
374
+ );
375
+ if (updated.rows[0]) return { intent: toSettlementIntent(updated.rows[0]), written: true };
376
+ const existing = await this.getSettlementById(settlementId);
377
+ return existing ? { intent: existing, written: false } : void 0;
378
+ }
379
+ /** Persist the reconciliation identity, refreshing a prepared retry's transient reservation. */
380
+ async prepareSettlement(intent) {
381
+ const settlements = await this.settlementWriter();
382
+ await settlements.query(
383
+ `INSERT INTO x402_batch_settlements (
384
+ settlement_id, channel_id, operation, effect_id, payment_id,
385
+ payment_payload, payment_requirements, channel_before, pending_id, status
386
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'prepared')
387
+ ON CONFLICT DO NOTHING`,
388
+ [
389
+ intent.settlementId,
390
+ normalizeChannelId(intent.channelId),
391
+ intent.operation,
392
+ intent.effectId,
393
+ intent.paymentId,
394
+ intent.payload,
395
+ intent.requirements,
396
+ intent.channelBefore,
397
+ intent.pendingId ?? null
398
+ ]
399
+ );
400
+ const { rows } = await settlements.query(
401
+ `SELECT * FROM x402_batch_settlements
402
+ WHERE settlement_id = $1
403
+ OR (operation = $2 AND effect_id = $3)
404
+ OR (operation = $2 AND payment_id = $4)
405
+ ORDER BY settlement_id = $1 DESC
406
+ LIMIT 1`,
407
+ [intent.settlementId, intent.operation, intent.effectId, intent.paymentId]
408
+ );
409
+ let stored = rows[0] ? toSettlementIntent(rows[0]) : void 0;
410
+ if (stored?.settlementId !== intent.settlementId || stored.channelId !== normalizeChannelId(intent.channelId) || stored.operation !== intent.operation || stored.effectId !== intent.effectId) {
411
+ throw new Error("x402 settlement identity conflicts with a prior credit effect");
412
+ }
413
+ if (stored.paymentId !== intent.paymentId || !isDeepStrictEqual(stored.payload, intent.payload) || !isDeepStrictEqual(stored.requirements, intent.requirements)) {
414
+ if (!canRefreshPreparedDepositAuthorization(stored, intent, Date.now())) {
415
+ throw new Error("x402 settlement identity conflicts with a prior credit effect");
416
+ }
417
+ const refreshed = await settlements.query(
418
+ `UPDATE x402_batch_settlements
419
+ SET payment_id = $2, payment_payload = $3, channel_before = $4,
420
+ pending_id = $5, updated_at = NOW()
421
+ WHERE settlement_id = $1 AND status = 'prepared'
422
+ AND payment_id = $6 AND payment_payload = $7
423
+ RETURNING *`,
424
+ [
425
+ intent.settlementId,
426
+ intent.paymentId,
427
+ intent.payload,
428
+ intent.channelBefore,
429
+ intent.pendingId ?? null,
430
+ stored.paymentId,
431
+ stored.payload
432
+ ]
433
+ );
434
+ if (refreshed.rows[0]) return toSettlementIntent(refreshed.rows[0]);
435
+ const raced = await this.getSettlementById(intent.settlementId);
436
+ if (raced?.paymentId !== intent.paymentId || !isDeepStrictEqual(raced.payload, intent.payload) || !isDeepStrictEqual(raced.requirements, intent.requirements)) {
437
+ throw new Error("x402 settlement identity conflicts with a prior credit effect");
438
+ }
439
+ stored = raced;
440
+ }
441
+ if (stored.status === "prepared" && (!isDeepStrictEqual(stored.channelBefore, intent.channelBefore) || stored.pendingId !== intent.pendingId)) {
442
+ const refreshed = await settlements.query(
443
+ `UPDATE x402_batch_settlements
444
+ SET channel_before = $2, pending_id = $3, updated_at = NOW()
445
+ WHERE settlement_id = $1 AND status = 'prepared'
446
+ RETURNING *`,
447
+ [intent.settlementId, intent.channelBefore, intent.pendingId ?? null]
448
+ );
449
+ if (refreshed.rowCount !== 1) {
450
+ throw new Error(`x402 settlement ${intent.settlementId} is no longer prepared for retry`);
451
+ }
452
+ return toSettlementIntent(refreshed.rows[0]);
453
+ }
454
+ if (!isDeepStrictEqual(stored.channelBefore, intent.channelBefore)) {
455
+ throw new Error("x402 settlement identity conflicts with a prior credit effect");
456
+ }
457
+ return stored;
458
+ }
459
+ /**
460
+ * Mark that the next operation is the ambiguous external-call boundary.
461
+ *
462
+ * `channelAtSubmission` is the live channel the facilitator is about to size
463
+ * and sign the settlement against (internal-review) — bookmarked here, with the
464
+ * block height, because these two are the only description of that instant
465
+ * that survives the call.
466
+ */
467
+ async markSettlementSubmitting(settlementId, submissionBlock, channelAtSubmission) {
468
+ if (!/^\d+$/.test(submissionBlock)) {
469
+ throw new Error(`x402 settlement ${settlementId} has an invalid submission block`);
470
+ }
471
+ const writer = await this.settlementWriter();
472
+ const result = await writer.query(
473
+ `UPDATE x402_batch_settlements
474
+ SET status = 'submitting', submission_block = $2,
475
+ channel_at_submission = $3, updated_at = NOW()
476
+ WHERE settlement_id = $1 AND status = 'prepared'`,
477
+ [settlementId, submissionBlock, channelAtSubmission ?? null]
478
+ );
479
+ if (result.rowCount !== 1) {
480
+ throw new Error(`x402 settlement ${settlementId} is not prepared for submission`);
481
+ }
482
+ }
483
+ /** Return an explicitly failed facilitator submission to the safe retry phase. */
484
+ async markSettlementRetryable(settlementId) {
485
+ const writer = await this.settlementWriter();
486
+ const result = await writer.query(
487
+ `UPDATE x402_batch_settlements
488
+ SET status = 'prepared', submission_block = NULL,
489
+ channel_at_submission = NULL, updated_at = NOW()
490
+ WHERE settlement_id = $1 AND status = 'submitting'`,
491
+ [settlementId]
492
+ );
493
+ if (result.rowCount !== 1) {
494
+ throw new Error(`x402 settlement ${settlementId} is not awaiting a submission result`);
495
+ }
496
+ }
497
+ /**
498
+ * Record a successful facilitator response independently of the enclosing
499
+ * ledger transaction.
500
+ *
501
+ * The write-off columns are cleared, not carried: this CAS is what lifts a
502
+ * `written_off` row back to `settled` (which is what makes the write-off
503
+ * reversible), and a row that is no longer written off must not still be
504
+ * carrying the note and resolution timestamp of a decision that has been
505
+ * reversed. On the live path the columns are already `NULL` and this is a
506
+ * no-op.
507
+ */
508
+ async recordSettlementResponse(settlementId, response) {
509
+ const writer = await this.settlementWriter();
510
+ const result = await writer.query(
511
+ `UPDATE x402_batch_settlements
512
+ SET status = 'settled', settle_response = $2,
513
+ write_off_note = NULL, resolved_at = NULL, updated_at = NOW()
514
+ WHERE settlement_id = $1 AND status <> 'complete'`,
515
+ [settlementId, response]
516
+ );
517
+ if (result.rowCount !== 1) {
518
+ throw new X402SettlementRaceError(`x402 settlement ${settlementId} is not recoverable`);
519
+ }
520
+ }
521
+ /**
522
+ * Mark the external settlement and ledger effect complete in the active transaction.
523
+ *
524
+ * `resolvedAtMs` is set only by the operator repair (internal-review), so an audit
525
+ * read can tell a settlement that completed on its own from one a human had
526
+ * to finish.
527
+ */
528
+ async completeSettlement(settlementId, resolvedAtMs) {
529
+ const q = this.transaction.getStore() ?? this.pool;
530
+ const result = await q.query(
531
+ `UPDATE x402_batch_settlements
532
+ SET status = 'complete',
533
+ resolved_at = COALESCE($2::bigint, resolved_at),
534
+ updated_at = NOW()
535
+ WHERE settlement_id = $1 AND status = 'settled'`,
536
+ [settlementId, resolvedAtMs ?? null]
537
+ );
538
+ if (result.rowCount !== 1) {
539
+ throw new X402SettlementRaceError(
540
+ `x402 settlement ${settlementId} has no recorded facilitator response`
541
+ );
542
+ }
543
+ }
544
+ /** Load one channel snapshot. */
545
+ async get(channelId) {
546
+ const q = this.transaction.getStore() ?? this.pool;
547
+ const { rows } = await q.query(
548
+ `SELECT * FROM x402_batch_channels WHERE channel_id = $1`,
549
+ [normalizeChannelId(channelId)]
550
+ );
551
+ return rows[0] ? toChannel(rows[0]) : void 0;
552
+ }
553
+ /** List all channel snapshots in stable channel-id order. */
554
+ async list() {
555
+ const q = this.transaction.getStore() ?? this.pool;
556
+ const { rows } = await q.query(
557
+ `SELECT * FROM x402_batch_channels ORDER BY channel_id`
558
+ );
559
+ return rows.map(toChannel);
560
+ }
561
+ /** Atomic backend-level read/modify/write for one channel. */
562
+ async updateChannel(channelId, update) {
563
+ const active = this.transaction.getStore();
564
+ if (active) return this.updateInTransaction(active, channelId, update, true);
565
+ const client = await this.pool.connect();
566
+ try {
567
+ await client.query("BEGIN");
568
+ const result = await this.updateInTransaction(client, channelId, update, false);
569
+ await client.query("COMMIT");
570
+ return result;
571
+ } catch (error) {
572
+ await client.query("ROLLBACK").catch(() => void 0);
573
+ throw error;
574
+ } finally {
575
+ client.release();
576
+ }
577
+ }
578
+ /**
579
+ * Run channel state and its ledger effect under the channel lock.
580
+ *
581
+ * Upstream's voucher-only settle updates `chargedCumulativeAmount` through
582
+ * `updateChannel`. Keeping that update and `commitMarkedFunding` in this one
583
+ * transaction means a ledger refusal rolls the voucher state back. External
584
+ * deposit/refund responses are recorded separately before this transaction
585
+ * commits, leaving a durable reconciliation row if COMMIT fails.
586
+ */
587
+ /**
588
+ * Sanction one cooperative-refund base for the updates inside `operation`.
589
+ *
590
+ * The store cannot derive the base — it comes from the credit ledger — so
591
+ * this is how the settlement layer says "this exact decrease is the one the
592
+ * payer's verified refund voucher authorises". Outside this scope a decrease
593
+ * of `chargedCumulativeAmount` is refused flat, which is what keeps the
594
+ * arbitrary-decrease guard meaningful (internal-review).
595
+ */
596
+ withSanctionedRefundBase(base, operation) {
597
+ return this.refundBase.run(base, operation);
598
+ }
599
+ async withChannelTransaction(channelId, operation) {
600
+ const client = await this.pool.connect();
601
+ try {
602
+ await client.query("BEGIN");
603
+ await lockChannel(client, channelId);
604
+ const result = await this.transaction.run(client, () => operation(client));
605
+ await client.query("COMMIT");
606
+ return result;
607
+ } catch (error) {
608
+ await client.query("ROLLBACK").catch(() => void 0);
609
+ throw error;
610
+ } finally {
611
+ client.release();
612
+ }
613
+ }
614
+ /**
615
+ * Run one fleet-wide settlement operation, optionally waiting for the current owner.
616
+ *
617
+ * The lock is session-scoped, so its client stays checked out for the whole
618
+ * operation — and `operation()` (upstream's claim/settle) re-enters the pool
619
+ * through {@link updateChannel}. Two bounds keep that from wedging the pool
620
+ * (internal-review): local callers are serialized in-process, so this holds at most
621
+ * one client per process no matter how many requests arrive, and a waiting
622
+ * acquire carries a `lock_timeout` so a wedged fleet-mate can't pin that
623
+ * client — and the caller's request — indefinitely. A timeout reports the
624
+ * lock as not acquired, which is what every caller already handles.
625
+ *
626
+ * The queue depth is reserved synchronously, before the promise chain can
627
+ * yield. That makes the non-waiting form a true local try-lock even while the
628
+ * first caller is still acquiring its connection: an overlapping request
629
+ * returns `false` instead of queuing behind the facilitator operation.
630
+ */
631
+ async withFleetSettlementLock(operation, wait = false) {
632
+ if (!wait && this.fleetLockQueueDepth > 0) return false;
633
+ this.fleetLockQueueDepth += 1;
634
+ const turn = this.fleetLock.then(() => this.runUnderFleetLock(operation, wait));
635
+ this.fleetLock = turn.catch(() => void 0);
636
+ try {
637
+ return await turn;
638
+ } finally {
639
+ this.fleetLockQueueDepth -= 1;
640
+ }
641
+ }
642
+ /**
643
+ * Hold the fleet-wide self-relay submission lock for one chain submission (internal-review).
644
+ *
645
+ * The session lock keeps one per-DVM gas EOA to one in-flight submission
646
+ * across the whole fleet — two machines submitting at once collide on the
647
+ * nonce. It is reached from `resource.settlePayment` inside
648
+ * {@link withChannelTransaction} and from upstream's claim/settle inside
649
+ * {@link withFleetSettlementLock}, so on both paths a host-pool client is
650
+ * already checked out: it therefore acquires from the terminal settlement
651
+ * pool, which is the rule in this class's doc, and never from the host pool
652
+ * the caller is holding.
653
+ *
654
+ * The wait is bounded by `lock_timeout` and a timeout **refuses** rather than
655
+ * proceeding unlocked, which is where this differs from
656
+ * {@link withFleetSettlementLock}: a fleet lock nobody got means the claim is
657
+ * someone else's turn, while a submission lock nobody got means the caller
658
+ * must not submit at all. The refusal is typed and retryable, and names the
659
+ * holder when Postgres will tell us who it is.
660
+ *
661
+ * The relay's own in-process tail serialises callers before they reach here,
662
+ * so one process pins at most one settlement-pool client no matter how many
663
+ * deposits arrive.
664
+ */
665
+ async withRelaySubmissionLock(key, operation) {
666
+ const pool = await this.settlementPoolRef();
667
+ const client = await pool.connect();
668
+ let locked = false;
669
+ let destroyClient = false;
670
+ try {
671
+ await client.query(`SET lock_timeout = ${String(this.relayLockTimeoutMs)}`);
672
+ try {
673
+ await client.query(RELAY_LOCK_SQL, [key]);
674
+ locked = true;
675
+ } catch (error) {
676
+ if (!isLockTimeout(error)) throw error;
677
+ throw new X402RelaySubmissionLockError(
678
+ key,
679
+ this.relayLockTimeoutMs,
680
+ await relayLockHolder(client, key)
681
+ );
682
+ }
683
+ return await operation();
684
+ } finally {
685
+ if (locked) {
686
+ await client.query(RELAY_UNLOCK_SQL, [key]).catch(() => {
687
+ destroyClient = true;
688
+ });
689
+ }
690
+ await client.query(`RESET lock_timeout`).catch(() => {
691
+ destroyClient = true;
692
+ });
693
+ client.release(destroyClient);
694
+ }
695
+ }
696
+ /** Release the dedicated settlement pool; the host-owned pool is left alone. */
697
+ async close() {
698
+ const settlement = this.settlementPool;
699
+ this.settlementPool = void 0;
700
+ if (settlement) await (await settlement).end();
701
+ }
702
+ async settlementWriter() {
703
+ return this.settlementPoolRef();
704
+ }
705
+ async settlementPoolRef() {
706
+ this.settlementPool ??= createSettlementPool(this.pool);
707
+ return this.settlementPool;
708
+ }
709
+ async runUnderFleetLock(operation, wait) {
710
+ const client = await this.pool.connect();
711
+ let locked = false;
712
+ let timeoutSet = false;
713
+ try {
714
+ if (wait) {
715
+ await client.query(`SET lock_timeout = ${FLEET_LOCK_TIMEOUT_MS}`);
716
+ timeoutSet = true;
717
+ try {
718
+ await client.query(
719
+ `SELECT pg_advisory_lock(hashtextextended('x402-batch-settlement', 1762))`
720
+ );
721
+ locked = true;
722
+ } catch (error) {
723
+ if (!isLockTimeout(error)) throw error;
724
+ }
725
+ } else {
726
+ const { rows } = await client.query(
727
+ `SELECT pg_try_advisory_lock(hashtextextended('x402-batch-settlement', 1762)) AS locked`
728
+ );
729
+ locked = rows[0]?.locked ?? false;
730
+ }
731
+ if (!locked) return false;
732
+ await operation();
733
+ return true;
734
+ } finally {
735
+ let destroyClient = false;
736
+ if (locked) {
737
+ await client.query(`SELECT pg_advisory_unlock(hashtextextended('x402-batch-settlement', 1762))`).catch(() => {
738
+ destroyClient = true;
739
+ });
740
+ }
741
+ if (timeoutSet) {
742
+ await client.query(`RESET lock_timeout`).catch(() => {
743
+ destroyClient = true;
744
+ });
745
+ }
746
+ client.release(destroyClient);
747
+ }
748
+ }
749
+ async updateInTransaction(client, channelId, update, alreadyLocked) {
750
+ const normalized = normalizeChannelId(channelId);
751
+ if (!alreadyLocked) await lockChannel(client, normalized);
752
+ const { rows } = await client.query(
753
+ `SELECT * FROM x402_batch_channels WHERE channel_id = $1 FOR UPDATE`,
754
+ [normalized]
755
+ );
756
+ const current = rows[0] ? toChannel(rows[0]) : void 0;
757
+ const next = update(current);
758
+ if (next === void 0) {
759
+ if (!current) return { channel: void 0, status: "unchanged" };
760
+ await client.query(`DELETE FROM x402_batch_channels WHERE channel_id = $1`, [normalized]);
761
+ return { channel: void 0, status: "deleted" };
762
+ }
763
+ assertChannelTransition(current, next, normalized, this.refundBase.getStore());
764
+ if (this.terminalizeUnbackedCredit && next.onchainSyncedAt !== void 0 && next.onchainSyncedAt !== current?.onchainSyncedAt && next.withdrawRequestedAt === 0 && BigInt(next.balance) === BigInt(next.totalClaimed)) {
765
+ await this.terminalizeUnbackedCredit(
766
+ {
767
+ channelId: normalized,
768
+ channelBalanceNative: BigInt(next.balance),
769
+ totalClaimedNative: BigInt(next.totalClaimed),
770
+ observedAt: next.onchainSyncedAt
771
+ },
772
+ client
773
+ );
774
+ }
775
+ if (current && sameChannel(current, next)) {
776
+ return { channel: current, status: "unchanged" };
777
+ }
778
+ await writeChannel(client, normalized, next);
779
+ return { channel: cloneChannel(next), status: "updated" };
780
+ }
781
+ };
782
+ async function createSettlementPool(pool) {
783
+ const pg = await import("pg");
784
+ const options = pool.options ?? {};
785
+ const settlement = new pg.default.Pool({
786
+ ...options,
787
+ // pg defines `password` non-enumerably on `pool.options`, so a spread drops
788
+ // it — read it through explicitly or every connection fails to authenticate.
789
+ ...options.password !== void 0 ? { password: options.password } : {},
790
+ max: SETTLEMENT_POOL_MAX,
791
+ connectionTimeoutMillis: SETTLEMENT_CONNECT_TIMEOUT_MS
792
+ });
793
+ applyRetryToPool(settlement, "x402-settlement");
794
+ return settlement;
795
+ }
796
+ var RELAY_LOCK_SQL = `SELECT pg_advisory_lock(hashtextextended($1, 1759))`;
797
+ var RELAY_UNLOCK_SQL = `SELECT pg_advisory_unlock(hashtextextended($1, 1759))`;
798
+ async function relayLockHolder(client, key) {
799
+ try {
800
+ const { rows } = await client.query(
801
+ `SELECT a.pid,
802
+ a.application_name,
803
+ a.client_addr::text AS client_addr,
804
+ a.state,
805
+ EXTRACT(EPOCH FROM (NOW() - a.query_start))::float8 AS held_seconds
806
+ FROM pg_locks l
807
+ JOIN pg_stat_activity a ON a.pid = l.pid
808
+ WHERE l.locktype = 'advisory'
809
+ AND l.granted
810
+ AND l.classid = ((hashtextextended($1, 1759) >> 32) & 4294967295)::oid
811
+ AND l.objid = (hashtextextended($1, 1759) & 4294967295)::oid
812
+ LIMIT 1`,
813
+ [key]
814
+ );
815
+ if (rows.length === 0) return void 0;
816
+ const row = rows[0];
817
+ return {
818
+ pid: row.pid,
819
+ ...row.application_name ? { applicationName: row.application_name } : {},
820
+ ...row.client_addr ? { clientAddr: row.client_addr } : {},
821
+ ...row.state ? { state: row.state } : {},
822
+ ...row.held_seconds !== null ? { heldSeconds: Math.round(row.held_seconds) } : {}
823
+ };
824
+ } catch {
825
+ return void 0;
826
+ }
827
+ }
828
+ function describeHolder(holder) {
829
+ const parts = [`pid ${String(holder.pid)}`];
830
+ if (holder.applicationName) parts.push(holder.applicationName);
831
+ if (holder.clientAddr) parts.push(holder.clientAddr);
832
+ if (holder.state) parts.push(holder.state);
833
+ if (holder.heldSeconds !== void 0) parts.push(`${String(holder.heldSeconds)}s in`);
834
+ return parts.join(", ");
835
+ }
836
+ function isLockTimeout(error) {
837
+ return typeof error === "object" && error !== null && error.code === LOCK_NOT_AVAILABLE;
838
+ }
839
+ function toSettlementIntent(row) {
840
+ if (!row.channel_before) {
841
+ throw new Error(`x402 settlement ${row.settlement_id} has no reconciliation snapshot`);
842
+ }
843
+ return {
844
+ settlementId: row.settlement_id,
845
+ channelId: row.channel_id,
846
+ operation: row.operation,
847
+ effectId: row.effect_id,
848
+ paymentId: row.payment_id,
849
+ payload: row.payment_payload,
850
+ requirements: row.payment_requirements,
851
+ channelBefore: row.channel_before,
852
+ ...row.channel_at_submission ? { channelAtSubmission: row.channel_at_submission } : {},
853
+ ...row.submission_block !== null ? { submissionBlock: row.submission_block } : {},
854
+ ...row.pending_id ? { pendingId: row.pending_id } : {},
855
+ status: row.status,
856
+ ...row.settle_response ? { response: row.settle_response } : {},
857
+ createdAt: row.created_at.getTime(),
858
+ updatedAt: row.updated_at.getTime(),
859
+ ...row.write_off_note !== null ? { writeOffNote: row.write_off_note } : {},
860
+ ...row.resolved_at !== null ? { resolvedAt: Number(row.resolved_at) } : {}
861
+ };
862
+ }
863
+ function canRefreshPreparedDepositAuthorization(stored, intent, nowMs) {
864
+ if (stored.status !== "prepared" || stored.operation !== "fund" || !isDeepStrictEqual(stored.requirements, intent.requirements))
865
+ return false;
866
+ const before = refreshableDepositAuthorization(stored.payload);
867
+ const after = refreshableDepositAuthorization(intent.payload);
868
+ if (!before || !after || !isDeepStrictEqual(before.shape, after.shape)) return false;
869
+ const nowSeconds = BigInt(Math.floor(nowMs / 1e3));
870
+ return before.validBefore < nowSeconds && after.validBefore > nowSeconds;
871
+ }
872
+ function refreshableDepositAuthorization(payload) {
873
+ const shape = structuredClone(payload);
874
+ const body = objectRecord(shape.payload);
875
+ const deposit = objectRecord(body?.deposit);
876
+ const wrapper = objectRecord(deposit?.authorization);
877
+ const authorization = objectRecord(wrapper?.erc3009Authorization);
878
+ const validBefore = authorization?.validBefore;
879
+ const signature = authorization?.signature;
880
+ if (!authorization || body?.type !== "deposit" || typeof validBefore !== "string" || !/^\d+$/.test(validBefore) || typeof signature !== "string" || signature.length === 0)
881
+ return void 0;
882
+ delete authorization.validBefore;
883
+ delete authorization.salt;
884
+ delete authorization.signature;
885
+ return { validBefore: BigInt(validBefore), shape };
886
+ }
887
+ function objectRecord(value) {
888
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
889
+ }
890
+ async function lockChannel(client, channelId) {
891
+ await client.query(`SELECT pg_advisory_xact_lock(hashtextextended($1, 1762))`, [
892
+ normalizeChannelId(channelId)
893
+ ]);
894
+ }
895
+ async function writeChannel(client, channelId, channel) {
896
+ await client.query(
897
+ `INSERT INTO x402_batch_channels (
898
+ channel_id, channel_config, charged_cumulative_amount, signed_max_claimable,
899
+ signature, balance, total_claimed, withdraw_requested_at, refund_nonce,
900
+ onchain_synced_at, last_request_timestamp, pending_request
901
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
902
+ ON CONFLICT (channel_id) DO UPDATE SET
903
+ channel_config = EXCLUDED.channel_config,
904
+ charged_cumulative_amount = EXCLUDED.charged_cumulative_amount,
905
+ signed_max_claimable = EXCLUDED.signed_max_claimable,
906
+ signature = EXCLUDED.signature,
907
+ balance = EXCLUDED.balance,
908
+ total_claimed = EXCLUDED.total_claimed,
909
+ withdraw_requested_at = EXCLUDED.withdraw_requested_at,
910
+ refund_nonce = EXCLUDED.refund_nonce,
911
+ onchain_synced_at = EXCLUDED.onchain_synced_at,
912
+ last_request_timestamp = EXCLUDED.last_request_timestamp,
913
+ pending_request = EXCLUDED.pending_request`,
914
+ [
915
+ channelId,
916
+ channel.channelConfig,
917
+ channel.chargedCumulativeAmount,
918
+ channel.signedMaxClaimable,
919
+ channel.signature,
920
+ channel.balance,
921
+ channel.totalClaimed,
922
+ channel.withdrawRequestedAt,
923
+ channel.refundNonce,
924
+ channel.onchainSyncedAt ?? null,
925
+ channel.lastRequestTimestamp,
926
+ channel.pendingRequest ?? null
927
+ ]
928
+ );
929
+ }
930
+ function toChannel(row) {
931
+ return {
932
+ channelId: row.channel_id,
933
+ channelConfig: row.channel_config,
934
+ chargedCumulativeAmount: row.charged_cumulative_amount,
935
+ signedMaxClaimable: row.signed_max_claimable,
936
+ signature: row.signature,
937
+ balance: row.balance,
938
+ totalClaimed: row.total_claimed,
939
+ withdrawRequestedAt: safeInteger(row.withdraw_requested_at, "withdraw_requested_at"),
940
+ refundNonce: safeInteger(row.refund_nonce, "refund_nonce"),
941
+ ...row.onchain_synced_at !== null ? { onchainSyncedAt: safeInteger(row.onchain_synced_at, "onchain_synced_at") } : {},
942
+ lastRequestTimestamp: safeInteger(row.last_request_timestamp, "last_request_timestamp"),
943
+ ...row.pending_request ? { pendingRequest: row.pending_request } : {}
944
+ };
945
+ }
946
+ function assertChannelTransition(current, next, expectedId, sanctionedRefundBase) {
947
+ if (normalizeChannelId(next.channelId) !== expectedId) {
948
+ throw new Error(`x402 channel update changed channelId ${expectedId}`);
949
+ }
950
+ if (!current) return;
951
+ if (!isDeepStrictEqual(current.channelConfig, next.channelConfig)) {
952
+ throw new Error(`x402 channel ${expectedId} attempted to mutate immutable channelConfig`);
953
+ }
954
+ const revokesUnclaimedAuthority = sanctionedRefundBase !== void 0 && BigInt(next.chargedCumulativeAmount) < BigInt(current.chargedCumulativeAmount) && BigInt(next.chargedCumulativeAmount) === BigInt(sanctionedRefundBase) && BigInt(next.chargedCumulativeAmount) >= BigInt(next.totalClaimed) && next.signedMaxClaimable === current.signedMaxClaimable && next.signature === current.signature && current.pendingRequest === void 0 && next.pendingRequest === void 0;
955
+ if (BigInt(next.chargedCumulativeAmount) < BigInt(current.chargedCumulativeAmount) && !revokesUnclaimedAuthority) {
956
+ throw new Error(`x402 channel ${expectedId} attempted to decrease chargedCumulativeAmount`);
957
+ }
958
+ if (BigInt(next.totalClaimed) < BigInt(current.totalClaimed)) {
959
+ throw new Error(`x402 channel ${expectedId} attempted to decrease totalClaimed`);
960
+ }
961
+ const reservesRevokedVoucher = BigInt(next.signedMaxClaimable) < BigInt(current.signedMaxClaimable) && next.chargedCumulativeAmount === current.chargedCumulativeAmount && next.signedMaxClaimable === next.chargedCumulativeAmount && current.pendingRequest === void 0 && next.pendingRequest !== void 0;
962
+ if (BigInt(next.signedMaxClaimable) < BigInt(current.signedMaxClaimable) && !reservesRevokedVoucher && (!current.pendingRequest || next.pendingRequest || BigInt(next.signedMaxClaimable) < BigInt(next.chargedCumulativeAmount))) {
963
+ throw new Error(`x402 channel ${expectedId} attempted to decrease signedMaxClaimable`);
964
+ }
965
+ if (next.refundNonce < current.refundNonce) {
966
+ throw new Error(`x402 channel ${expectedId} attempted to decrease refundNonce`);
967
+ }
968
+ }
969
+ function sameChannel(left, right) {
970
+ return JSON.stringify(left) === JSON.stringify(right);
971
+ }
972
+ function cloneChannel(channel) {
973
+ return structuredClone(channel);
974
+ }
975
+ function normalizeChannelId(channelId) {
976
+ const normalized = channelId.toLowerCase();
977
+ if (!/^0x[0-9a-f]{64}$/.test(normalized)) {
978
+ throw new Error(`x402 channelId must be a canonical bytes32 value; got ${channelId}`);
979
+ }
980
+ return normalized;
981
+ }
982
+ function safeInteger(value, field) {
983
+ const parsed = Number(value);
984
+ if (!Number.isSafeInteger(parsed) || parsed < 0) {
985
+ throw new Error(`x402 channel ${field} exceeds JavaScript's safe integer range`);
986
+ }
987
+ return parsed;
988
+ }
989
+
990
+ // src/sdk/server/credit-ledger.ts
991
+ var CreditLedger = class {
992
+ constructor(pool, tempoSessionStore) {
993
+ this.pool = pool;
994
+ this.tempoSessionStore = tempoSessionStore;
995
+ }
996
+ pool;
997
+ tempoSessionStore;
998
+ /**
999
+ * Postgres-backed: balances survive a restart and are shared across the
1000
+ * fleet, so this DVM may advertise credit (internal-review).
1001
+ */
1002
+ durable = true;
1003
+ x402Settlements;
1004
+ expiryReleaseOutbox;
1005
+ /**
1006
+ * Bind the durable x402 settlement state this ledger gates spending on
1007
+ * (internal-review).
1008
+ *
1009
+ * Late, because the settlement store is built with the batch-settlement
1010
+ * server, which is built *after* the ledger it reads earned draws from. An
1011
+ * unbound ledger simply does not gate — correct for a DVM with no x402
1012
+ * channel storage, where no settlement row can exist to wedge.
1013
+ */
1014
+ useX402SettlementGate(gate) {
1015
+ this.x402Settlements = gate;
1016
+ }
1017
+ /**
1018
+ * Bind the durable outbox that reports credit-expiry releases and their
1019
+ * revivals to the platform (internal-review).
1020
+ *
1021
+ * Held on the ledger rather than threaded through {@link fund} for the same
1022
+ * reason as the gate above: the revival half fires from *every* funding path
1023
+ * — Cashu commit, x402 exact and channel, Tempo, the Lightning invoice
1024
+ * settle, the implicit N=1 per-call payment — and a seam each of those has to
1025
+ * remember to pass is a seam one of them will eventually forget.
1026
+ *
1027
+ * Unbound (a `dvmctl dev` server, a self-hosted builder, a test host) the
1028
+ * sweep and the reversal still run and still write the ledger's own rows;
1029
+ * only the platform report is skipped. Reporting is bookkeeping and must
1030
+ * never gate the ledger.
1031
+ */
1032
+ useCreditExpiryReleaseOutbox(outbox) {
1033
+ this.expiryReleaseOutbox = outbox;
1034
+ }
1035
+ /** Create the `credits` / `credit_draws` tables if absent. Call once at SDK boot. */
1036
+ async init() {
1037
+ await withSdkInitLock(this.pool, () => this.createTables());
1038
+ }
1039
+ /** The boot DDL itself — always runs under {@link withSdkInitLock} (internal-review). */
1040
+ async createTables() {
1041
+ await this.pool.query(`
1042
+ CREATE TABLE IF NOT EXISTS credits (
1043
+ credit_id TEXT PRIMARY KEY,
1044
+ caller_pubkey TEXT NOT NULL,
1045
+ currency TEXT NOT NULL,
1046
+ balance_micro BIGINT NOT NULL,
1047
+ expiry_ms BIGINT NOT NULL,
1048
+ status TEXT NOT NULL DEFAULT 'active',
1049
+ last_ledger_seq BIGINT NOT NULL DEFAULT 0,
1050
+ created_at BIGINT NOT NULL,
1051
+ rail TEXT,
1052
+ native_asset TEXT,
1053
+ mint TEXT,
1054
+ funding_ref TEXT,
1055
+ tempo_channel_id TEXT,
1056
+ msats_remaining BIGINT NOT NULL DEFAULT 0,
1057
+ native_remaining BIGINT,
1058
+ x402_channel_id TEXT
1059
+ );
1060
+ `);
1061
+ await this.pool.query(`
1062
+ CREATE TABLE IF NOT EXISTS tempo_credit_losses (
1063
+ credit_id TEXT PRIMARY KEY,
1064
+ channel_id TEXT NOT NULL,
1065
+ caller_pubkey TEXT NOT NULL,
1066
+ currency TEXT NOT NULL,
1067
+ former_balance_micro BIGINT NOT NULL,
1068
+ settled_on_chain_native BIGINT NOT NULL,
1069
+ highest_voucher_native BIGINT NOT NULL,
1070
+ consumed_service_micro BIGINT NOT NULL,
1071
+ consumed_native BIGINT NOT NULL,
1072
+ observed_at BIGINT NOT NULL
1073
+ );
1074
+ `);
1075
+ await this.pool.query(
1076
+ `CREATE INDEX IF NOT EXISTS idx_tempo_credit_losses_observed
1077
+ ON tempo_credit_losses(observed_at, credit_id);`
1078
+ );
1079
+ await this.pool.query(`
1080
+ CREATE TABLE IF NOT EXISTS x402_credit_losses (
1081
+ credit_id TEXT PRIMARY KEY,
1082
+ channel_id TEXT NOT NULL,
1083
+ caller_pubkey TEXT NOT NULL,
1084
+ currency TEXT NOT NULL,
1085
+ former_balance_micro BIGINT NOT NULL,
1086
+ channel_balance_native BIGINT NOT NULL,
1087
+ total_claimed_native BIGINT NOT NULL,
1088
+ consumed_service_micro BIGINT NOT NULL,
1089
+ observed_at BIGINT NOT NULL
1090
+ );
1091
+ `);
1092
+ await this.pool.query(
1093
+ `CREATE INDEX IF NOT EXISTS idx_x402_credit_losses_observed
1094
+ ON x402_credit_losses(observed_at, credit_id);`
1095
+ );
1096
+ for (const column of [
1097
+ "rail TEXT",
1098
+ "native_asset TEXT",
1099
+ "mint TEXT",
1100
+ "funding_ref TEXT",
1101
+ "tempo_channel_id TEXT",
1102
+ "msats_remaining BIGINT NOT NULL DEFAULT 0",
1103
+ "native_remaining BIGINT",
1104
+ "x402_channel_id TEXT"
1105
+ ]) {
1106
+ await this.pool.query(`ALTER TABLE credits ADD COLUMN IF NOT EXISTS ${column}`);
1107
+ }
1108
+ await this.pool.query(
1109
+ `CREATE INDEX IF NOT EXISTS idx_credits_caller ON credits(caller_pubkey);`
1110
+ );
1111
+ await this.pool.query(
1112
+ `CREATE UNIQUE INDEX IF NOT EXISTS credits_x402_channel_uidx
1113
+ ON credits(x402_channel_id) WHERE x402_channel_id IS NOT NULL;`
1114
+ );
1115
+ await this.pool.query(`
1116
+ CREATE TABLE IF NOT EXISTS credit_draws (
1117
+ credit_id TEXT NOT NULL,
1118
+ draw_id TEXT NOT NULL,
1119
+ amount_micro BIGINT NOT NULL,
1120
+ job_id TEXT,
1121
+ ledger_seq BIGINT NOT NULL,
1122
+ status TEXT NOT NULL,
1123
+ balance_after_micro BIGINT NOT NULL,
1124
+ created_at BIGINT NOT NULL,
1125
+ resolved_at BIGINT,
1126
+ draw_msats BIGINT NOT NULL DEFAULT 0,
1127
+ draw_native BIGINT,
1128
+ grown_micro BIGINT NOT NULL DEFAULT 0,
1129
+ PRIMARY KEY (credit_id, draw_id)
1130
+ );
1131
+ `);
1132
+ for (const column of [
1133
+ "draw_msats BIGINT NOT NULL DEFAULT 0",
1134
+ "draw_native BIGINT",
1135
+ "grown_micro BIGINT NOT NULL DEFAULT 0",
1136
+ "allocated_msats BIGINT"
1137
+ ]) {
1138
+ await this.pool.query(`ALTER TABLE credit_draws ADD COLUMN IF NOT EXISTS ${column}`);
1139
+ }
1140
+ await this.pool.query(
1141
+ `CREATE UNIQUE INDEX IF NOT EXISTS credit_draws_seq_uidx
1142
+ ON credit_draws(credit_id, ledger_seq);`
1143
+ );
1144
+ await this.pool.query(
1145
+ `CREATE INDEX IF NOT EXISTS idx_credit_draws_pending
1146
+ ON credit_draws(credit_id) WHERE status = 'pending';`
1147
+ );
1148
+ await this.pool.query(
1149
+ `CREATE INDEX IF NOT EXISTS idx_credit_draws_pending_age
1150
+ ON credit_draws(created_at, credit_id, ledger_seq) WHERE status = 'pending';`
1151
+ );
1152
+ await this.pool.query(
1153
+ `CREATE INDEX IF NOT EXISTS idx_credit_draws_job_id
1154
+ ON credit_draws(job_id) WHERE job_id IS NOT NULL;`
1155
+ );
1156
+ await this.pool.query(`
1157
+ CREATE TABLE IF NOT EXISTS credit_fundings (
1158
+ credit_id TEXT NOT NULL,
1159
+ fund_id TEXT NOT NULL,
1160
+ amount_micro BIGINT NOT NULL,
1161
+ rail TEXT,
1162
+ created_at BIGINT NOT NULL,
1163
+ PRIMARY KEY (credit_id, fund_id)
1164
+ );
1165
+ `);
1166
+ await this.pool.query(`ALTER TABLE credit_fundings ADD COLUMN IF NOT EXISTS rail TEXT`);
1167
+ for (const column of [
1168
+ "caller_pubkey TEXT",
1169
+ "balance_after_micro BIGINT",
1170
+ "ledger_seq BIGINT",
1171
+ "receipt JSONB"
1172
+ ]) {
1173
+ await this.pool.query(`ALTER TABLE credit_fundings ADD COLUMN IF NOT EXISTS ${column}`);
1174
+ }
1175
+ await this.pool.query(`
1176
+ CREATE TABLE IF NOT EXISTS credit_funding_lots (
1177
+ lot_id TEXT PRIMARY KEY,
1178
+ credit_id TEXT NOT NULL,
1179
+ rail TEXT NOT NULL,
1180
+ sats_funded BIGINT NOT NULL,
1181
+ credited_micro BIGINT NOT NULL,
1182
+ remaining_micro BIGINT NOT NULL,
1183
+ funding_ref TEXT,
1184
+ created_at BIGINT NOT NULL
1185
+ );
1186
+ `);
1187
+ await this.pool.query(
1188
+ `CREATE INDEX IF NOT EXISTS idx_credit_funding_lots_open
1189
+ ON credit_funding_lots(credit_id, created_at, lot_id) WHERE remaining_micro > 0;`
1190
+ );
1191
+ await this.backfillFundingLots();
1192
+ await this.pool.query(`
1193
+ CREATE TABLE IF NOT EXISTS credit_drains (
1194
+ credit_id TEXT NOT NULL,
1195
+ drain_id TEXT NOT NULL,
1196
+ method TEXT NOT NULL,
1197
+ payout JSONB NOT NULL,
1198
+ amount_micro BIGINT NOT NULL,
1199
+ balance_after_micro BIGINT NOT NULL,
1200
+ drained_msats BIGINT NOT NULL DEFAULT 0,
1201
+ drained_native BIGINT,
1202
+ owed_sats BIGINT,
1203
+ lot_debits JSONB,
1204
+ ledger_seq BIGINT NOT NULL,
1205
+ status TEXT NOT NULL,
1206
+ token TEXT,
1207
+ sent_ref JSONB,
1208
+ receipts JSONB NOT NULL DEFAULT '[]'::jsonb,
1209
+ created_at BIGINT NOT NULL,
1210
+ parked_at BIGINT,
1211
+ picked_up_at BIGINT,
1212
+ sent_at BIGINT,
1213
+ released_at BIGINT,
1214
+ PRIMARY KEY (credit_id, drain_id)
1215
+ );
1216
+ `);
1217
+ await this.pool.query(
1218
+ `ALTER TABLE credit_drains ADD COLUMN IF NOT EXISTS balance_after_micro BIGINT NOT NULL DEFAULT 0`
1219
+ );
1220
+ await this.pool.query(
1221
+ `ALTER TABLE credit_drains ADD COLUMN IF NOT EXISTS drained_msats BIGINT NOT NULL DEFAULT 0`
1222
+ );
1223
+ await this.pool.query(
1224
+ `ALTER TABLE credit_drains ADD COLUMN IF NOT EXISTS drained_native BIGINT`
1225
+ );
1226
+ await this.pool.query(`ALTER TABLE credit_drains ADD COLUMN IF NOT EXISTS owed_sats BIGINT`);
1227
+ await this.pool.query(`ALTER TABLE credit_drains ADD COLUMN IF NOT EXISTS lot_debits JSONB`);
1228
+ await this.pool.query(`ALTER TABLE credit_drains ADD COLUMN IF NOT EXISTS released_at BIGINT`);
1229
+ await this.pool.query(`ALTER TABLE credit_drains ADD COLUMN IF NOT EXISTS fulfilment JSONB`);
1230
+ await this.pool.query(`ALTER TABLE credit_drains ADD COLUMN IF NOT EXISTS write_off_note TEXT`);
1231
+ await this.pool.query(
1232
+ `ALTER TABLE credit_drains ADD COLUMN IF NOT EXISTS written_off_at BIGINT`
1233
+ );
1234
+ await this.pool.query(
1235
+ `UPDATE credit_drains d SET drained_native = d.amount_micro
1236
+ FROM credits c
1237
+ WHERE d.credit_id = c.credit_id
1238
+ AND c.tempo_channel_id IS NOT NULL
1239
+ AND d.drained_native IS NULL`
1240
+ );
1241
+ await this.pool.query(`UPDATE credits SET rail = 'tempo' WHERE rail = 'mpp'`);
1242
+ await this.pool.query(`UPDATE credit_fundings SET rail = 'tempo' WHERE rail = 'mpp'`);
1243
+ await this.pool.query(`UPDATE credit_drains SET method = 'tempo' WHERE method = 'mpp'`);
1244
+ await this.pool.query(
1245
+ `CREATE UNIQUE INDEX IF NOT EXISTS credit_drains_seq_uidx
1246
+ ON credit_drains(credit_id, ledger_seq);`
1247
+ );
1248
+ await this.pool.query(
1249
+ `CREATE INDEX IF NOT EXISTS idx_credit_drains_pending
1250
+ ON credit_drains(status) WHERE status = 'pending';`
1251
+ );
1252
+ await this.pool.query(`
1253
+ CREATE TABLE IF NOT EXISTS credit_invoices (
1254
+ credit_id TEXT NOT NULL,
1255
+ fund_id TEXT NOT NULL,
1256
+ caller_pubkey TEXT NOT NULL,
1257
+ currency TEXT NOT NULL,
1258
+ amount_micro BIGINT NOT NULL,
1259
+ amount_msats BIGINT NOT NULL,
1260
+ payment_hash TEXT NOT NULL,
1261
+ bolt11 TEXT NOT NULL,
1262
+ status TEXT NOT NULL,
1263
+ blocked_reason TEXT,
1264
+ expires_at BIGINT NOT NULL,
1265
+ created_at BIGINT NOT NULL,
1266
+ settled_at BIGINT,
1267
+ PRIMARY KEY (credit_id, fund_id)
1268
+ );
1269
+ `);
1270
+ await this.pool.query(
1271
+ `ALTER TABLE credit_invoices ADD COLUMN IF NOT EXISTS blocked_reason TEXT`
1272
+ );
1273
+ await this.pool.query(
1274
+ `ALTER TABLE credit_invoices ADD COLUMN IF NOT EXISTS reconciled_credit_id TEXT`
1275
+ );
1276
+ await this.pool.query(
1277
+ `ALTER TABLE credit_invoices ADD COLUMN IF NOT EXISTS reconciled_fund_id TEXT`
1278
+ );
1279
+ await this.pool.query(
1280
+ `ALTER TABLE credit_invoices ADD COLUMN IF NOT EXISTS resolved_at BIGINT`
1281
+ );
1282
+ await this.pool.query(
1283
+ `ALTER TABLE credit_invoices ADD COLUMN IF NOT EXISTS write_off_note TEXT`
1284
+ );
1285
+ await this.pool.query(
1286
+ `CREATE UNIQUE INDEX IF NOT EXISTS credit_invoices_hash_uidx
1287
+ ON credit_invoices(payment_hash);`
1288
+ );
1289
+ await this.pool.query(
1290
+ `CREATE INDEX IF NOT EXISTS idx_credit_invoices_pending
1291
+ ON credit_invoices(caller_pubkey, created_at) WHERE status = 'pending';`
1292
+ );
1293
+ await this.pool.query(
1294
+ `CREATE INDEX IF NOT EXISTS idx_credit_invoices_blocked
1295
+ ON credit_invoices(created_at, credit_id, fund_id) WHERE status = 'blocked';`
1296
+ );
1297
+ await this.pool.query(`
1298
+ CREATE TABLE IF NOT EXISTS credit_expiry_releases (
1299
+ credit_id TEXT NOT NULL,
1300
+ release_id TEXT NOT NULL,
1301
+ caller_pubkey TEXT NOT NULL,
1302
+ currency TEXT NOT NULL,
1303
+ rail TEXT,
1304
+ amount_micro BIGINT NOT NULL,
1305
+ expiry_ms BIGINT NOT NULL,
1306
+ released_at BIGINT NOT NULL,
1307
+ reversed_at BIGINT,
1308
+ PRIMARY KEY (credit_id, release_id)
1309
+ );
1310
+ `);
1311
+ await this.pool.query(
1312
+ `CREATE INDEX IF NOT EXISTS idx_credit_expiry_releases_open
1313
+ ON credit_expiry_releases(credit_id) WHERE reversed_at IS NULL;`
1314
+ );
1315
+ }
1316
+ /**
1317
+ * Give every open non-channel Bitcoin credit a funding lot (internal-review).
1318
+ *
1319
+ * Runs inside the boot DDL, is guarded per credit by `NOT EXISTS`, and is
1320
+ * therefore both the one-time migration and a standing self-heal for a
1321
+ * credit that somehow reaches a positive balance with no lot behind it.
1322
+ *
1323
+ * The lot is derived from `credits.msats_remaining`, not replayed out of
1324
+ * `credit_fundings`. That is not a shortcut: `credit_fundings` records only
1325
+ * `{amount_micro, rail}` — no instrument amount at all — and is not written
1326
+ * on every funding path, whereas `msats_remaining` **is** the credit's
1327
+ * current unspent rail value, maintained pro rata on every draw since
1328
+ * internal-review. One synthetic lot pairing it with `balance_micro` therefore
1329
+ * reproduces today's in-kind position exactly, and FIFO over a single lot is
1330
+ * trivially correct. Historic per-funding rates are unrecoverable and would
1331
+ * change nothing: only the remaining position is owed.
1332
+ *
1333
+ * The semantics change is deliberately retroactive (internal-review) — every
1334
+ * current holder is first-party, so there is one regime and no legacy
1335
+ * branch.
1336
+ */
1337
+ async backfillFundingLots() {
1338
+ const { rowCount } = await this.pool.query(
1339
+ `INSERT INTO credit_funding_lots
1340
+ (lot_id, credit_id, rail, sats_funded, credited_micro, remaining_micro, funding_ref, created_at)
1341
+ SELECT 'lot:backfill:' || c.credit_id, c.credit_id, c.rail,
1342
+ c.msats_remaining / 1000, c.balance_micro, c.balance_micro, c.funding_ref, c.created_at
1343
+ FROM credits c
1344
+ WHERE ${BITCOIN_CREDIT_PREDICATE}
1345
+ AND NOT EXISTS (SELECT 1 FROM credit_funding_lots l WHERE l.credit_id = c.credit_id)
1346
+ ON CONFLICT (lot_id) DO NOTHING`
1347
+ );
1348
+ const { rows } = await this.pool.query(
1349
+ `SELECT COUNT(*) FILTER (WHERE cov.covered_micro < c.balance_micro) AS uncovered,
1350
+ COUNT(*) FILTER (WHERE cov.covered_micro >= c.balance_micro AND cov.basisless_micro > 0)
1351
+ AS unbacked
1352
+ FROM credits c
1353
+ CROSS JOIN LATERAL (${LOT_COVERAGE_LATERAL}) cov
1354
+ WHERE ${BITCOIN_CREDIT_PREDICATE}`
1355
+ );
1356
+ const uncovered = Number(rows[0]?.uncovered ?? 0);
1357
+ const unbacked = Number(rows[0]?.unbacked ?? 0);
1358
+ if (rowCount === 0 && uncovered === 0 && unbacked === 0) return;
1359
+ console.warn(
1360
+ JSON.stringify({
1361
+ level: uncovered > 0 ? "warn" : "info",
1362
+ event: "credit_funding_lot_backfill",
1363
+ backfilled: rowCount ?? 0,
1364
+ uncovered_credits: uncovered,
1365
+ unbacked_credits: unbacked,
1366
+ message: uncovered > 0 ? "Some open Bitcoin credits hold more balance than their funding lots cover. Those reclaims are priced off a live rate rather than in kind until the gap is repaired." : "Funding lots are in place for every open Bitcoin credit."
1367
+ })
1368
+ );
1369
+ }
1370
+ /**
1371
+ * Create a credit, or top up an existing one (same `creditId`). Top-ups add
1372
+ * to the balance and overwrite `expiry_ms` with the provided value — funding
1373
+ * an expired credit revives it (spec §5 carry-forward: the credit is a
1374
+ * rolling buffer, not per-job escrow). The existing row's `caller_pubkey`
1375
+ * and `currency` must match or the fund is refused with a typed error —
1376
+ * without that check a top-up against someone else's `credit_id` would
1377
+ * silently merge two callers' money.
1378
+ *
1379
+ * Pass `tx` (a client inside a caller-owned `BEGIN`) to commit the rail
1380
+ * receive and the ledger credit atomically (spec condition 3 — the internal-review
1381
+ * verifier does this). The ledger issues **no** transaction control on `tx`.
1382
+ *
1383
+ * Without `tx` it opens one of its own, because a funding is no longer a
1384
+ * single statement: it upserts the credit, records its funding lot
1385
+ * (internal-review), and reverses any standing expiry release (internal-review) — and that
1386
+ * last leg restores balance and queues a report. A crash between the upsert
1387
+ * and the reversal would leave a revived credit whose release still stands,
1388
+ * which the sweep's own exclusion then makes permanent: `balance_micro > 0`
1389
+ * but a standing release means it is neither drainable nor re-releasable.
1390
+ *
1391
+ * `basis` records the rail value behind the fiat (internal-review) so each draw can
1392
+ * be allocated its share of the rail-native amount actually received. A
1393
+ * top-up on a different rail than the credit's is refused with
1394
+ * `rail_mismatch`: sats and USDC microunits aren't summable, so a blended
1395
+ * credit would have no coherent native basis to allocate from.
1396
+ *
1397
+ * It is **required** (internal-review), in the type and again at runtime via
1398
+ * {@link assertFundingBasis}. A basis-less fund wrote `rail = NULL`, and a
1399
+ * draw against such a credit settles — a real debit — while
1400
+ * `railAsFundingMethod` correctly declines to guess a rail, so `bookRevenue`
1401
+ * books nothing and outstanding liability (`deposits − draw revenue`) stays
1402
+ * overstated by the drawn amount forever. `credits.rail` stays nullable
1403
+ * because rows written before this change still exist in live databases:
1404
+ * **write is closed, read is not.** A legacy row adopts a rail from its first
1405
+ * typed top-up via the `COALESCE` below.
1406
+ *
1407
+ * The rail-native **instrument** is pinned at first funding too, and both
1408
+ * channel guards are symmetric for the same reason (internal-review). A reusable
1409
+ * channel — Tempo session or x402 batch-settlement — may only ever top up
1410
+ * the credit it opened, and a credit opened by a one-shot (a Tempo charge, an
1411
+ * x402 exact authorization) may never adopt one. The x402 half used to admit
1412
+ * `credits.x402_channel_id IS NULL`, so a channel deposit bound itself to a
1413
+ * credit already holding exact-funded value: `earnedNativeForX402Channel`
1414
+ * then read that credit's whole settled-draw history as the channel's
1415
+ * earnings, and the first claim tick swept a deposit nobody had consumed,
1416
+ * while the unspent balance lost every exit (the drain's refund base goes
1417
+ * below `totalClaimed`, and a channel-bound credit refuses every non-x402
1418
+ * drain). `credits_x402_channel_uidx` does not cover this: it guards
1419
+ * one-credit-per-channel, not one-source-per-credit.
1420
+ *
1421
+ * An x402 channel deposit is additionally refused `settlement_pending` while
1422
+ * that channel carries an unresolved refund (internal-review) — see the gate read
1423
+ * below. This method is the authority on that rule, as it is on the binding
1424
+ * rules above; every door preflights it where a refusal is still free.
1425
+ */
1426
+ async fund(args) {
1427
+ assertAmount(args.amountMicro, { min: 1 });
1428
+ if (!Number.isSafeInteger(args.expiryMs) || args.expiryMs <= 0) {
1429
+ throw new CreditLedgerError(
1430
+ "invalid_expiry",
1431
+ `expiryMs must be a positive safe integer (got ${args.expiryMs})`
1432
+ );
1433
+ }
1434
+ const b = assertFundingBasis(args.basis);
1435
+ const nowMs = args.nowMs ?? Date.now();
1436
+ const creditId = args.creditId ?? randomUUID();
1437
+ if (args.tx) return this.fundOn(args.tx, args, b, nowMs, creditId);
1438
+ const client = await this.pool.connect();
1439
+ try {
1440
+ await client.query("BEGIN");
1441
+ const snapshot = await this.fundOn(client, args, b, nowMs, creditId);
1442
+ await client.query("COMMIT");
1443
+ return snapshot;
1444
+ } catch (err) {
1445
+ await client.query("ROLLBACK").catch(() => {
1446
+ });
1447
+ throw err;
1448
+ } finally {
1449
+ client.release();
1450
+ }
1451
+ }
1452
+ /**
1453
+ * The funding itself, on whichever handle {@link fund} chose. Every refusal
1454
+ * it raises is the caller's to see unchanged; a self-opened transaction rolls
1455
+ * back around it.
1456
+ */
1457
+ async fundOn(q, args, b, nowMs, creditId) {
1458
+ const wedged = await this.blockingX402Refund(
1459
+ b.x402Channel?.channelId,
1460
+ X402_SPEND_BLOCKING_SETTLEMENT_STATUSES
1461
+ );
1462
+ if (wedged) throw x402SettlementPending(creditId, wedged);
1463
+ let rows;
1464
+ try {
1465
+ ({ rows } = await q.query(
1466
+ `INSERT INTO credits (credit_id, caller_pubkey, currency, balance_micro, expiry_ms, status, created_at,
1467
+ rail, native_asset, mint, funding_ref, msats_remaining, native_remaining,
1468
+ tempo_channel_id, x402_channel_id)
1469
+ VALUES ($1, $2, $3, $4, $5, 'active', $6, $7, $8, $9, $10, $11, $12, $13, $14)
1470
+ ON CONFLICT (credit_id) DO UPDATE
1471
+ SET balance_micro = credits.balance_micro + EXCLUDED.balance_micro,
1472
+ expiry_ms = EXCLUDED.expiry_ms,
1473
+ rail = COALESCE(credits.rail, EXCLUDED.rail),
1474
+ native_asset = COALESCE(EXCLUDED.native_asset, credits.native_asset),
1475
+ mint = COALESCE(EXCLUDED.mint, credits.mint),
1476
+ funding_ref = COALESCE(EXCLUDED.funding_ref, credits.funding_ref),
1477
+ tempo_channel_id = COALESCE(credits.tempo_channel_id, EXCLUDED.tempo_channel_id),
1478
+ msats_remaining = credits.msats_remaining + EXCLUDED.msats_remaining,
1479
+ native_remaining = CASE
1480
+ WHEN EXCLUDED.native_remaining IS NULL THEN credits.native_remaining
1481
+ ELSE COALESCE(credits.native_remaining, 0) + EXCLUDED.native_remaining
1482
+ END,
1483
+ x402_channel_id = COALESCE(credits.x402_channel_id, EXCLUDED.x402_channel_id)
1484
+ WHERE credits.caller_pubkey = EXCLUDED.caller_pubkey
1485
+ AND credits.status = 'active'
1486
+ AND credits.currency = EXCLUDED.currency
1487
+ AND (credits.rail IS NULL OR credits.rail = EXCLUDED.rail)
1488
+ AND ((credits.tempo_channel_id IS NULL AND EXCLUDED.tempo_channel_id IS NULL)
1489
+ OR credits.tempo_channel_id = EXCLUDED.tempo_channel_id)
1490
+ AND ((credits.x402_channel_id IS NULL AND EXCLUDED.x402_channel_id IS NULL)
1491
+ OR credits.x402_channel_id = EXCLUDED.x402_channel_id)
1492
+ RETURNING *`,
1493
+ [
1494
+ creditId,
1495
+ args.callerPubkey,
1496
+ args.currency,
1497
+ args.amountMicro,
1498
+ args.expiryMs,
1499
+ nowMs,
1500
+ b.rail,
1501
+ b.nativeAsset ?? null,
1502
+ b.mint ?? null,
1503
+ b.fundingRef ?? null,
1504
+ b.paidMsats,
1505
+ b.nativeAmount ?? null,
1506
+ b.tempoSession?.channelId.toLowerCase() ?? null,
1507
+ b.x402Channel?.channelId ?? null
1508
+ ]
1509
+ ));
1510
+ } catch (err) {
1511
+ if (b.x402Channel && isUniqueViolation(err)) {
1512
+ const channelId = b.x402Channel.channelId;
1513
+ const { rows: bound } = await this.pool.query(
1514
+ `SELECT credit_id, rail, x402_channel_id
1515
+ FROM credits
1516
+ WHERE x402_channel_id = $1`,
1517
+ [channelId]
1518
+ );
1519
+ throw new CreditLedgerError(
1520
+ "rail_mismatch",
1521
+ bound[0] ? `x402 channel ${channelId} is already bound to credit ${bound[0].credit_id}` : `x402 channel ${channelId} is already bound to another credit`,
1522
+ {
1523
+ expectedRail: bound[0]?.rail ?? "x402",
1524
+ expectedInstrument: "x402_channel",
1525
+ expectedChannelId: channelId,
1526
+ ...bound[0] ? { boundCreditId: bound[0].credit_id } : {}
1527
+ },
1528
+ { cause: err }
1529
+ );
1530
+ }
1531
+ throw err;
1532
+ }
1533
+ if (rows.length === 0) {
1534
+ const { rows: existing } = await q.query(
1535
+ `SELECT caller_pubkey, currency, status, rail, tempo_channel_id, x402_channel_id
1536
+ FROM credits WHERE credit_id = $1`,
1537
+ [creditId]
1538
+ );
1539
+ const row = existing[0];
1540
+ if (row.caller_pubkey !== args.callerPubkey) {
1541
+ throw new CreditLedgerError(
1542
+ "caller_mismatch",
1543
+ `credit ${creditId} belongs to a different caller pubkey`
1544
+ );
1545
+ }
1546
+ if (row.status !== "active") throw terminalCreditError(creditId, row.status);
1547
+ if (row.currency !== args.currency) {
1548
+ throw new CreditLedgerError(
1549
+ "currency_mismatch",
1550
+ `credit ${creditId} is denominated in ${row.currency}, not ${args.currency}`
1551
+ );
1552
+ }
1553
+ if (row.rail !== null && row.rail !== b.rail) {
1554
+ throw new CreditLedgerError(
1555
+ "rail_mismatch",
1556
+ `credit ${creditId} was funded on the ${row.rail} rail, not ${b.rail}`,
1557
+ { expectedRail: row.rail }
1558
+ );
1559
+ }
1560
+ const incomingTempoChannel = b.tempoSession?.channelId.toLowerCase() ?? null;
1561
+ if (row.tempo_channel_id !== incomingTempoChannel) {
1562
+ throw new CreditLedgerError(
1563
+ "rail_mismatch",
1564
+ row.tempo_channel_id ? `credit ${creditId} belongs to Tempo session channel ${row.tempo_channel_id}` : row.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`,
1565
+ {
1566
+ expectedRail: row.rail,
1567
+ ...row.tempo_channel_id || row.rail === "tempo" ? { expectedInstrument: row.tempo_channel_id ? "tempo_session" : "tempo_charge" } : {},
1568
+ expectedChannelId: row.tempo_channel_id
1569
+ }
1570
+ );
1571
+ }
1572
+ if (row.x402_channel_id !== (b.x402Channel?.channelId ?? null)) {
1573
+ throw new CreditLedgerError(
1574
+ "rail_mismatch",
1575
+ row.x402_channel_id ? `credit ${creditId} is bound to x402 settlement channel ${row.x402_channel_id}` : row.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`,
1576
+ {
1577
+ expectedRail: row.rail,
1578
+ ...row.x402_channel_id || row.rail === "x402" ? { expectedInstrument: row.x402_channel_id ? "x402_channel" : "x402_exact" } : {},
1579
+ expectedChannelId: row.x402_channel_id
1580
+ }
1581
+ );
1582
+ }
1583
+ throw new CreditLedgerError(
1584
+ "rail_mismatch",
1585
+ `credit ${creditId} was funded on the ${row.rail} rail, not ${b.rail}`,
1586
+ { expectedRail: row.rail }
1587
+ );
1588
+ }
1589
+ await this.recordFundingLot(q, creditId, args.amountMicro, b, nowMs);
1590
+ const revived = await this.reverseExpiryReleases(q, creditId, nowMs);
1591
+ return this.snapshotFromRow(revived ?? rows[0], q, nowMs);
1592
+ }
1593
+ /**
1594
+ * Undo any expiry release this credit still carries, because a funding just
1595
+ * landed on it (internal-review — the revival rule), restoring the balance the
1596
+ * release took.
1597
+ *
1598
+ * A release records the credit's **final** undrawn remainder and zeroes it
1599
+ * (see {@link releaseExpiredCreditLocked}). Funding adds to the balance and
1600
+ * overwrites `expiry_ms` (spec §5 carry-forward: the credit is a rolling
1601
+ * buffer), so the moment money arrives the recorded remainder is no longer
1602
+ * final: the release stops counting and its micro come back.
1603
+ *
1604
+ * Deliberately unconditional on the incoming expiry rather than gated on
1605
+ * `expiryMs > nowMs`. A release exists only for a credit that had already
1606
+ * lapsed, so any funding that reaches one is money the release did not
1607
+ * account for — and a funding that arrives with an already-past expiry leaves
1608
+ * a credit the very next sweep re-releases at its new, larger remainder. One
1609
+ * rule, self-healing in both directions, and it never needs to read the
1610
+ * pre-funding expiry the upsert has already overwritten.
1611
+ *
1612
+ * Runs on the funding's own handle — which {@link fund} now guarantees is a
1613
+ * transaction — so the reversal, the restored balance and the report commit
1614
+ * with the money or not at all. A reversal that committed without its report
1615
+ * would be unrecoverable: `WHERE reversed_at IS NULL` means no later pass
1616
+ * re-derives it.
1617
+ *
1618
+ * @returns the credit row as the restore left it, or undefined when there was
1619
+ * nothing to reverse. The caller reads its snapshot off this rather than off
1620
+ * the funding upsert's `RETURNING`, which predates the restore.
1621
+ */
1622
+ async reverseExpiryReleases(q, creditId, nowMs) {
1623
+ const { rows } = await q.query(
1624
+ `UPDATE credit_expiry_releases
1625
+ SET reversed_at = $2
1626
+ WHERE credit_id = $1 AND reversed_at IS NULL
1627
+ RETURNING *`,
1628
+ [creditId, nowMs]
1629
+ );
1630
+ if (rows.length === 0) return void 0;
1631
+ const restoredMicro = rows.reduce((sum, row) => sum + toSafeInt(row.amount_micro), 0);
1632
+ const { rows: restored } = await q.query(
1633
+ `UPDATE credits SET balance_micro = balance_micro + $2 WHERE credit_id = $1 RETURNING *`,
1634
+ [creditId, restoredMicro]
1635
+ );
1636
+ const outbox = this.expiryReleaseOutbox;
1637
+ if (outbox) {
1638
+ for (const row of rows) {
1639
+ await outbox.enqueue(q, expiryReleaseReport(outbox.dvmId, row));
1640
+ }
1641
+ }
1642
+ return restored[0];
1643
+ }
1644
+ /**
1645
+ * Release the undrawn remainder of every credit whose TTL has run out
1646
+ * (internal-review). Returns the releases this pass recorded.
1647
+ *
1648
+ * **What is released.** The credit's whole `balance_micro`. Expiry ends
1649
+ * spending but never ownership of the record, so the balance stays readable;
1650
+ * what ends is the caller's claim on it, and on the prepaid rails the value
1651
+ * behind it is already in builder custody.
1652
+ *
1653
+ * **Tempo is excluded**, at both of its markers (`tempo_channel_id` and a
1654
+ * `tempo` rail). A Tempo credit is channel-backed and its undrawn value
1655
+ * returns to the *caller* at channel exit, so releasing it would book the
1656
+ * builder money the chain is about to hand back.
1657
+ *
1658
+ * **A credit with a pending hold is skipped, not partially released.** A draw
1659
+ * placed before expiry stays settleable afterwards (`draw`'s replay lookup
1660
+ * runs above the expiry check, so a lost response is still recoverable), so
1661
+ * the remainder is not final while a hold is outstanding. Skipping costs one
1662
+ * sweep interval and keeps the released figure exactly "what nothing bought";
1663
+ * holds do resolve — the orphan-draw watchdog (internal-review) is what guarantees
1664
+ * a stranded one still reaches a terminal state.
1665
+ *
1666
+ * **Idempotent** two ways. `release_id` is derived from the expiry instant,
1667
+ * so a re-sweep after a crash between the insert and its report collides on
1668
+ * the same primary key instead of writing a second row; and the candidate
1669
+ * scan excludes any credit that already has an un-reversed release.
1670
+ *
1671
+ * Each candidate is re-checked under the credit row lock every
1672
+ * `credit_draws` write is taken under, so a draw racing the sweep either
1673
+ * loses the race (its credit is already released and it would have been
1674
+ * refused `credit_expired` anyway) or wins it and leaves a pending hold the
1675
+ * locked re-check sees.
1676
+ */
1677
+ async sweepExpiredCredits(args) {
1678
+ const nowMs = args?.nowMs ?? Date.now();
1679
+ const limit = args?.limit ?? DEFAULT_EXPIRY_SWEEP_LIMIT;
1680
+ const { rows: candidates } = await this.pool.query(
1681
+ `SELECT c.credit_id
1682
+ FROM credits c
1683
+ WHERE c.expiry_ms <= $1
1684
+ AND c.balance_micro > 0
1685
+ AND c.status = 'active'
1686
+ AND c.tempo_channel_id IS NULL
1687
+ AND COALESCE(c.rail, '') <> 'tempo'
1688
+ AND NOT EXISTS (
1689
+ SELECT 1 FROM credit_expiry_releases r
1690
+ WHERE r.credit_id = c.credit_id AND r.reversed_at IS NULL
1691
+ )
1692
+ AND NOT EXISTS (
1693
+ SELECT 1 FROM credit_draws d
1694
+ WHERE d.credit_id = c.credit_id AND d.status = 'pending'
1695
+ )
1696
+ ORDER BY c.expiry_ms
1697
+ LIMIT $2`,
1698
+ [nowMs, limit]
1699
+ );
1700
+ const released = [];
1701
+ for (const candidate of candidates) {
1702
+ const row = await this.withCreditLock(
1703
+ candidate.credit_id,
1704
+ (client, credit) => this.releaseExpiredCreditLocked(client, credit, nowMs)
1705
+ );
1706
+ if (row) released.push(expiryReleaseFromRow(row));
1707
+ }
1708
+ return { released };
1709
+ }
1710
+ /**
1711
+ * Write one credit's expiry release under its row lock, zero the balance it
1712
+ * released, and re-assert every condition the unlocked candidate scan tested.
1713
+ * Returns undefined when the credit no longer qualifies — funded, drawn
1714
+ * against, or already released between the scan and the lock.
1715
+ *
1716
+ * **Zeroing the balance is what makes the release real**, and it is the whole
1717
+ * reason this runs under the lock rather than as a bare INSERT. The row alone
1718
+ * records that the caller's claim ended; it does not *end* it. `requestDrain`
1719
+ * has no expiry check by design (expiry gates new draws, not reclaims), so a
1720
+ * credit whose remainder had been released and reported was still fully
1721
+ * drainable: the DVM would pay out money the platform had already booked as
1722
+ * the builder's, and `deposited - drawn - drained - released` would go
1723
+ * negative on the same micro. With the balance at zero, `requestDrain`'s
1724
+ * existing `availableMicro <= 0` guard refuses with `nothing_to_drain` and no
1725
+ * new check is needed anywhere. {@link reverseExpiryReleases} puts the
1726
+ * balance back when a funding revives the credit.
1727
+ *
1728
+ * `status` deliberately stays `active`: a released credit must remain
1729
+ * fundable, or the revival rule has nothing to revive.
1730
+ *
1731
+ * The zeroed balance also takes the credit out of `BITCOIN_CREDIT_PREDICATE`
1732
+ * (`balance_micro > 0`), so the in-kind sweep floor stops counting sats the
1733
+ * builder no longer owes back — and counts them again after a revival, with
1734
+ * the credit's funding lots untouched throughout.
1735
+ */
1736
+ async releaseExpiredCreditLocked(client, credit, nowMs) {
1737
+ const expiryMs = toSafeInt(credit.expiry_ms);
1738
+ const balanceMicro = toSafeInt(credit.balance_micro);
1739
+ if (expiryMs > nowMs || balanceMicro <= 0 || credit.status !== "active") return void 0;
1740
+ if (credit.tempo_channel_id !== null || credit.rail === "tempo") return void 0;
1741
+ const pending = await this.pendingSums(client, credit.credit_id);
1742
+ if (pending.micro > 0) return void 0;
1743
+ const { rows: open } = await client.query(
1744
+ `SELECT 1 AS one FROM credit_expiry_releases
1745
+ WHERE credit_id = $1 AND reversed_at IS NULL`,
1746
+ [credit.credit_id]
1747
+ );
1748
+ if (open.length > 0) return void 0;
1749
+ const { rows: episodes } = await client.query(
1750
+ `SELECT COUNT(*)::text AS n FROM credit_expiry_releases WHERE credit_id = $1`,
1751
+ [credit.credit_id]
1752
+ );
1753
+ const seq = toSafeInt(episodes[0].n) + 1;
1754
+ const { rows } = await client.query(
1755
+ `INSERT INTO credit_expiry_releases
1756
+ (credit_id, release_id, caller_pubkey, currency, rail, amount_micro, expiry_ms, released_at)
1757
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
1758
+ ON CONFLICT (credit_id, release_id) DO NOTHING
1759
+ RETURNING *`,
1760
+ [
1761
+ credit.credit_id,
1762
+ expiryReleaseId(expiryMs, seq),
1763
+ credit.caller_pubkey,
1764
+ credit.currency,
1765
+ credit.rail,
1766
+ balanceMicro,
1767
+ expiryMs,
1768
+ nowMs
1769
+ ]
1770
+ );
1771
+ if (rows.length === 0) return void 0;
1772
+ const row = rows[0];
1773
+ await client.query(
1774
+ `UPDATE credits SET balance_micro = balance_micro - $2 WHERE credit_id = $1`,
1775
+ [credit.credit_id, balanceMicro]
1776
+ );
1777
+ const outbox = this.expiryReleaseOutbox;
1778
+ if (outbox) await outbox.enqueue(client, expiryReleaseReport(outbox.dvmId, row));
1779
+ return row;
1780
+ }
1781
+ /**
1782
+ * Record this funding's in-kind basis as a lot (internal-review).
1783
+ *
1784
+ * Called from inside {@link fund}, on the same handle, so the lot shares
1785
+ * whatever transaction the rail opened — `withCommitTx` for Cashu,
1786
+ * `ProcessedPaymentStore.withTx` for x402/Tempo, `settleInvoice`'s own
1787
+ * `BEGIN` for Lightning. The funding-atomicity invariant therefore extends
1788
+ * to it structurally rather than by every call site remembering: a lot
1789
+ * cannot exist without its ledger credit, or the credit without its lot.
1790
+ *
1791
+ * Only a **channel-backed** credit writes no lot: it is in-kind already —
1792
+ * the escrow itself retains the unused value — so a lot on one would be
1793
+ * counted as Bitcoin deposit liability the hub does not hold.
1794
+ *
1795
+ * Every other non-channel Bitcoin funding writes one, the implicit N=1
1796
+ * per-call payment included. That one funds and draws the same amount in
1797
+ * this transaction, so it looks like a lot born depleted — but the draw is a
1798
+ * *pending hold*, not a debit, and only a `completed` job settles it
1799
+ * (`JobManager.settlesDraw`). A failed, cancelled or stale-swept job
1800
+ * releases the hold and the whole funded amount becomes reclaimable balance,
1801
+ * so suppressing the lot here would leave routine traffic reclaiming off a
1802
+ * live rate and missing from the sweep floor. One small insert on the hot
1803
+ * path buys the invariant the liability read depends on: open lots cover
1804
+ * `balance_micro` for every Bitcoin credit, always.
1805
+ *
1806
+ * A zero-sats basis still writes a lot. It is honest — the credit exists and
1807
+ * we know its rail — and it is what makes "covered but unbacked" a
1808
+ * distinguishable state from "no lot at all" for the reclaim and the
1809
+ * liability rollup, instead of both reading as a gap.
1810
+ */
1811
+ async recordFundingLot(q, creditId, amountMicro, basis, nowMs) {
1812
+ if (!isNonChannelBitcoinRail(basis.rail)) return;
1813
+ if (basis.tempoSession || basis.x402Channel) return;
1814
+ await q.query(
1815
+ `INSERT INTO credit_funding_lots
1816
+ (lot_id, credit_id, rail, sats_funded, credited_micro, remaining_micro, funding_ref, created_at)
1817
+ VALUES ($1, $2, $3, $4, $5, $5, $6, $7)`,
1818
+ [
1819
+ `lot:${randomUUID()}`,
1820
+ creditId,
1821
+ basis.rail,
1822
+ Math.floor(basis.paidMsats / 1e3),
1823
+ amountMicro,
1824
+ basis.fundingRef ?? null,
1825
+ nowMs
1826
+ ]
1827
+ );
1828
+ }
1829
+ /**
1830
+ * Place a two-phase `pending` hold of `amountMicro` against the credit and
1831
+ * assign the next `ledger_seq`. Idempotent on `(creditId, drawId)`: a replay
1832
+ * returns the original `{balanceAfterMicro, ledgerSeq}` with
1833
+ * `replayed: true` and places no second hold — this is how a caller recovers
1834
+ * a lost response (spec condition 1). A replay whose `amountMicro` or
1835
+ * `jobId` differs from the recorded draw is not a replay but a money bug in
1836
+ * the caller, refused with `draw_conflict`.
1837
+ *
1838
+ * The replay lookup runs **before** the expiry check, deliberately: a draw
1839
+ * placed pre-expiry must stay recoverable after the credit expires, or the
1840
+ * DVM holds a debit the caller can never reconcile. Fresh draws on an
1841
+ * expired credit are refused with `credit_expired`; the balance stays
1842
+ * intact and readable (spec §5: expiry ends spending, never ownership).
1843
+ *
1844
+ * Pass `tx` (a client inside a caller-owned `BEGIN`) to place the hold in
1845
+ * the same transaction as the rail commit and the `fund` upsert (spec
1846
+ * condition 3 — the internal-review implicit N=1 path). The ledger issues no
1847
+ * transaction control on `tx`; the `SELECT … FOR UPDATE` row lock is still
1848
+ * taken on the caller's transaction, so the locking invariant holds.
1849
+ */
1850
+ async draw(args) {
1851
+ assertAmount(args.amountMicro, { min: 0 });
1852
+ const nowMs = args.nowMs ?? Date.now();
1853
+ const jobId = args.jobId ?? null;
1854
+ const run = async (client, credit, tempoLifecycle) => {
1855
+ const existing = await this.readDraw(client, args.creditId, args.drawId);
1856
+ if (existing) {
1857
+ const recordedJobId = existing.job_id ?? null;
1858
+ if (toSafeInt(existing.amount_micro) !== args.amountMicro || recordedJobId !== jobId) {
1859
+ throw new CreditLedgerError(
1860
+ "draw_conflict",
1861
+ `draw ${args.drawId} on credit ${args.creditId} was recorded with different parameters`,
1862
+ {
1863
+ expectedAmountMicro: toSafeInt(existing.amount_micro),
1864
+ expectedJobId: recordedJobId
1865
+ }
1866
+ );
1867
+ }
1868
+ return { ...drawResultFromRow(existing, credit), replayed: true };
1869
+ }
1870
+ if (credit.status !== "active") throw terminalCreditError(args.creditId, credit.status);
1871
+ if (tempoLifecycle && (tempoLifecycle.finalized || tempoLifecycle.closeRequestedAt !== 0n)) {
1872
+ throw new CreditLedgerError(
1873
+ "tempo_channel_closing",
1874
+ `credit ${args.creditId} is bound to a Tempo channel that is closing or finalized`
1875
+ );
1876
+ }
1877
+ const x402Refund = await this.blockingX402Refund(
1878
+ credit.x402_channel_id,
1879
+ X402_SPEND_BLOCKING_SETTLEMENT_STATUSES
1880
+ );
1881
+ if (x402Refund) throw x402SettlementPending(args.creditId, x402Refund);
1882
+ const balanceMicro = toSafeInt(credit.balance_micro);
1883
+ const expiryMs = toSafeInt(credit.expiry_ms);
1884
+ if (expiryMs <= nowMs) {
1885
+ throw new CreditLedgerError("credit_expired", `credit ${args.creditId} expired`, {
1886
+ expiryMs,
1887
+ balanceMicro
1888
+ });
1889
+ }
1890
+ const pending = await this.pendingSums(client, args.creditId);
1891
+ const available = balanceMicro - pending.micro;
1892
+ if (args.amountMicro > available) {
1893
+ throw new CreditLedgerError(
1894
+ "insufficient_credit",
1895
+ `draw of ${args.amountMicro} exceeds available balance ${available}`,
1896
+ { availableMicro: available, balanceMicro, requestedMicro: args.amountMicro }
1897
+ );
1898
+ }
1899
+ const nativeRemaining = credit.native_remaining === null ? null : toSafeInt(credit.native_remaining);
1900
+ const railValue = allocateDrawValue({
1901
+ amountMicro: args.amountMicro,
1902
+ availableMicro: available,
1903
+ availableMsats: Math.max(0, toSafeInt(credit.msats_remaining) - pending.msats),
1904
+ availableNative: nativeRemaining === null ? null : Math.max(0, nativeRemaining - pending.native)
1905
+ });
1906
+ const ledgerSeq = toSafeInt(credit.last_ledger_seq) + 1;
1907
+ const balanceAfterMicro = available - args.amountMicro;
1908
+ await client.query(`UPDATE credits SET last_ledger_seq = $2 WHERE credit_id = $1`, [
1909
+ args.creditId,
1910
+ ledgerSeq
1911
+ ]);
1912
+ await client.query(
1913
+ `INSERT INTO credit_draws
1914
+ (credit_id, draw_id, amount_micro, job_id, ledger_seq, status, balance_after_micro, created_at,
1915
+ draw_msats, draw_native)
1916
+ VALUES ($1, $2, $3, $4, $5, 'pending', $6, $7, $8, $9)`,
1917
+ [
1918
+ args.creditId,
1919
+ args.drawId,
1920
+ args.amountMicro,
1921
+ jobId,
1922
+ ledgerSeq,
1923
+ balanceAfterMicro,
1924
+ nowMs,
1925
+ railValue.drawMsats,
1926
+ railValue.drawNative
1927
+ ]
1928
+ );
1929
+ return {
1930
+ creditId: args.creditId,
1931
+ drawId: args.drawId,
1932
+ amountMicro: args.amountMicro,
1933
+ balanceAfterMicro,
1934
+ ledgerSeq,
1935
+ status: "pending",
1936
+ replayed: false,
1937
+ railValue: { ...railValue, ...creditBasisFromRow(credit) },
1938
+ resolvedAt: null
1939
+ };
1940
+ };
1941
+ const query = args.tx ?? this.pool;
1942
+ const { rows } = await query.query(
1943
+ `SELECT tempo_channel_id FROM credits WHERE credit_id = $1`,
1944
+ [args.creditId]
1945
+ );
1946
+ const channelId = rows[0]?.tempo_channel_id;
1947
+ if (channelId && this.tempoSessionStore) {
1948
+ return this.tempoSessionStore.withDrawPlacement(
1949
+ channelId,
1950
+ async (tx, lifecycle) => {
1951
+ const credit = await this.lockCreditRow(tx, args.creditId);
1952
+ return run(tx, credit, lifecycle);
1953
+ },
1954
+ args.tx
1955
+ );
1956
+ }
1957
+ if (args.tx) {
1958
+ const credit = await this.lockCreditRow(args.tx, args.creditId);
1959
+ return run(args.tx, credit);
1960
+ }
1961
+ return this.withCreditLock(args.creditId, (client, credit) => run(client, credit));
1962
+ }
1963
+ /**
1964
+ * The unresolved refund on `channelId` that must stop this ledger effect, if
1965
+ * any (internal-review).
1966
+ *
1967
+ * `undefined` whenever there is nothing to ask — an unbound credit, or a DVM
1968
+ * with no durable settlement store, where a settlement row cannot exist.
1969
+ *
1970
+ * Public since internal-review, so `/v1/credit`'s pre-payment preflight can ask the
1971
+ * same question `fund` will ask from inside the rail transaction — one read,
1972
+ * one answer, rather than a second copy of the rule in the routes.
1973
+ */
1974
+ async blockingX402Refund(channelId, statuses) {
1975
+ if (!channelId || !this.x402Settlements) return void 0;
1976
+ return this.x402Settlements.pendingRefundSettlement(channelId, statuses);
1977
+ }
1978
+ /**
1979
+ * Grow a pending draw by `addAmountMicro` (internal-review) — the ledger half of a
1980
+ * mid-job `requestPayment` top-up. One job keeps **one** draw: the mid-job
1981
+ * money funds the same credit and enlarges the hold the upfront leg placed,
1982
+ * so the receipt's `ReceiptCredit` block countersigns the job's full cost
1983
+ * and `revenue_events` still books exactly one row per job.
1984
+ *
1985
+ * `draw()` cannot express this: a same-`draw_id` call with a different
1986
+ * amount is `draw_conflict` by design (a replayed draw must never re-price).
1987
+ *
1988
+ * The arithmetic is `draw()`'s, unchanged: `addAmountMicro` is checked
1989
+ * against — and `balance_after_micro` recomputed from — the balance net of
1990
+ * **every** pending hold, this draw's own included. That is deliberately the
1991
+ * conservative reading (a fresh draw of `addAmountMicro` would see exactly
1992
+ * the same figure), and it is what keeps the total held after growth inside
1993
+ * the credit's balance.
1994
+ *
1995
+ * The increment's rail value comes from {@link growthRailValue}: earmarked
1996
+ * to the funding that backs it when the caller names one (the mid-job case),
1997
+ * pro-rata otherwise. Either way the draws of a credit keep summing to
1998
+ * precisely what the rails paid (internal-review).
1999
+ *
2000
+ * **`ledger_seq` is not re-taken.** The per-credit sequence is gap-free
2001
+ * (`credit_draws_seq_uidx`), so moving this draw forward would strand its
2002
+ * original number. The consequence is deliberate and worth knowing: the
2003
+ * recorded `balance_after_micro` is the post-growth figure stamped at the
2004
+ * *original* seq, so a credit with interleaved sibling activity has a
2005
+ * trajectory that is truthful per draw rather than monotonic across seqs.
2006
+ *
2007
+ * **No expiry check**, matching `settle`/`release`: the job was accepted
2008
+ * pre-expiry and is still running. Refusing here would strand the handler
2009
+ * mid-flight over a clock the caller can't influence.
2010
+ *
2011
+ * **Not idempotent by itself.** Growth carries no key of its own — it
2012
+ * inherits the rail's (the accumulator's `(dvm_id, request_id)` UNIQUE rolls
2013
+ * the whole transaction back on replay; x402/mpp collide on the
2014
+ * `processed_payments` marker). Never call it outside a rail commit.
2015
+ *
2016
+ * **`capMicro` bounds cumulative growth at what the job cumulatively asked
2017
+ * (internal-review)**, and it is the only ceiling that can refuse a top-up: the
2018
+ * available-balance check cannot, because the `fund` a moment earlier in
2019
+ * this same transaction raised the balance by exactly the amount being
2020
+ * drawn. Excess is **granted partially or not at all rather than thrown** —
2021
+ * this runs inside the rail commit, so a throw would roll back cashu proofs
2022
+ * the caller already sent. The refused remainder stays funded balance the
2023
+ * caller owns and can drain.
2024
+ */
2025
+ async growDraw(args) {
2026
+ assertAmount(args.addAmountMicro, { min: 1 });
2027
+ const run = async (client, credit) => {
2028
+ const existing = await this.readDraw(client, args.creditId, args.drawId);
2029
+ if (!existing) {
2030
+ throw new CreditLedgerError(
2031
+ "draw_not_found",
2032
+ `draw ${args.drawId} not found on credit ${args.creditId}`
2033
+ );
2034
+ }
2035
+ if (existing.status !== "pending") {
2036
+ throw new CreditLedgerError(
2037
+ "invalid_draw_state",
2038
+ `cannot grow draw ${args.drawId}: status is ${existing.status}`,
2039
+ { currentStatus: existing.status }
2040
+ );
2041
+ }
2042
+ if (credit.status !== "active") throw terminalCreditError(args.creditId, credit.status);
2043
+ if (args.jobId !== void 0 && (existing.job_id ?? null) !== args.jobId) {
2044
+ throw new CreditLedgerError(
2045
+ "draw_conflict",
2046
+ `draw ${args.drawId} on credit ${args.creditId} belongs to a different job`,
2047
+ { expectedJobId: existing.job_id ?? null }
2048
+ );
2049
+ }
2050
+ const priorGrown = toSafeInt(existing.grown_micro);
2051
+ const headroom = args.capMicro === void 0 ? args.addAmountMicro : Math.max(0, args.capMicro - priorGrown);
2052
+ const grantMicro = Math.min(args.addAmountMicro, headroom);
2053
+ const balanceMicro = toSafeInt(credit.balance_micro);
2054
+ const pending = await this.pendingSums(client, args.creditId);
2055
+ const available = balanceMicro - pending.micro;
2056
+ if (grantMicro <= 0) {
2057
+ return {
2058
+ creditId: args.creditId,
2059
+ drawId: args.drawId,
2060
+ amountMicro: toSafeInt(existing.amount_micro),
2061
+ balanceAfterMicro: toSafeInt(existing.balance_after_micro),
2062
+ ledgerSeq: toSafeInt(existing.ledger_seq),
2063
+ status: "pending",
2064
+ replayed: false,
2065
+ railValue: {
2066
+ drawMsats: toSafeInt(existing.draw_msats),
2067
+ drawNative: existing.draw_native === null ? null : toSafeInt(existing.draw_native),
2068
+ ...creditBasisFromRow(credit)
2069
+ },
2070
+ resolvedAt: null,
2071
+ addedRailValue: { drawMsats: 0, drawNative: null },
2072
+ addedAmountMicro: 0,
2073
+ cappedMicro: args.addAmountMicro
2074
+ };
2075
+ }
2076
+ if (grantMicro > available) {
2077
+ throw new CreditLedgerError(
2078
+ "insufficient_credit",
2079
+ `top-up draw of ${grantMicro} exceeds available balance ${available}`,
2080
+ { availableMicro: available, balanceMicro, requestedMicro: grantMicro }
2081
+ );
2082
+ }
2083
+ const priorMsats = toSafeInt(existing.draw_msats);
2084
+ const priorNative = existing.draw_native === null ? null : toSafeInt(existing.draw_native);
2085
+ const nativeRemaining = credit.native_remaining === null ? null : toSafeInt(credit.native_remaining);
2086
+ const added = growthRailValue({
2087
+ earmark: clipEarmark(args.addRailValue, grantMicro, args.addAmountMicro),
2088
+ amountMicro: grantMicro,
2089
+ availableMicro: available,
2090
+ availableMsats: Math.max(0, toSafeInt(credit.msats_remaining) - pending.msats),
2091
+ availableNative: nativeRemaining === null ? null : Math.max(0, nativeRemaining - pending.native)
2092
+ });
2093
+ const amountMicro = toSafeInt(existing.amount_micro) + grantMicro;
2094
+ const balanceAfterMicro = available - grantMicro;
2095
+ const drawMsats = priorMsats + added.drawMsats;
2096
+ const drawNative = added.drawNative === null ? priorNative : (priorNative ?? 0) + added.drawNative;
2097
+ await client.query(
2098
+ `UPDATE credit_draws
2099
+ SET amount_micro = $3, balance_after_micro = $4, draw_msats = $5, draw_native = $6,
2100
+ grown_micro = $7
2101
+ WHERE credit_id = $1 AND draw_id = $2`,
2102
+ [
2103
+ args.creditId,
2104
+ args.drawId,
2105
+ amountMicro,
2106
+ balanceAfterMicro,
2107
+ drawMsats,
2108
+ drawNative,
2109
+ priorGrown + grantMicro
2110
+ ]
2111
+ );
2112
+ return {
2113
+ creditId: args.creditId,
2114
+ drawId: args.drawId,
2115
+ amountMicro,
2116
+ balanceAfterMicro,
2117
+ ledgerSeq: toSafeInt(existing.ledger_seq),
2118
+ status: "pending",
2119
+ replayed: false,
2120
+ railValue: { drawMsats, drawNative, ...creditBasisFromRow(credit) },
2121
+ resolvedAt: null,
2122
+ addedRailValue: added,
2123
+ addedAmountMicro: grantMicro,
2124
+ cappedMicro: args.addAmountMicro - grantMicro
2125
+ };
2126
+ };
2127
+ if (args.tx) {
2128
+ const credit = await this.lockCreditRow(args.tx, args.creditId);
2129
+ return run(args.tx, credit);
2130
+ }
2131
+ return this.withCreditLock(args.creditId, run);
2132
+ }
2133
+ /**
2134
+ * Finalize a pending draw: the hold becomes a real debit
2135
+ * (`balance_micro −= amount`). Available balance is unchanged — the amount
2136
+ * moves from "held" to "spent" — so the `balanceAfterMicro` trajectory
2137
+ * recorded at draw time stays truthful for settled draws, which is what the
2138
+ * receipt chain countersigns. Idempotent: settling a settled draw returns
2139
+ * the original tuple. A released draw cannot be settled
2140
+ * (`invalid_draw_state`) — release-then-settle would debit money the caller
2141
+ * was already told is theirs again.
2142
+ *
2143
+ * Deliberately **no expiry check** here (nor on `release`): a job accepted
2144
+ * pre-expiry routinely reaches its terminal post-expiry, and refusing would
2145
+ * leak the hold forever. Expiry gates new draws only.
2146
+ */
2147
+ async settle(args) {
2148
+ if (!this.tempoSessionStore) return this.resolveDraw({ ...args, to: "settled" });
2149
+ const { rows } = await this.pool.query(
2150
+ `SELECT tempo_channel_id FROM credits WHERE credit_id = $1`,
2151
+ [args.creditId]
2152
+ );
2153
+ const channelId = rows[0]?.tempo_channel_id;
2154
+ if (!channelId) return this.resolveDraw({ ...args, to: "settled" });
2155
+ return this.tempoSessionStore.withTerminalConsumption(channelId, async (tx, lifecycle) => {
2156
+ const credit = await this.lockCreditRow(tx, args.creditId);
2157
+ const draw = await this.readDraw(tx, args.creditId, args.drawId);
2158
+ if (!draw) {
2159
+ throw new CreditLedgerError(
2160
+ "draw_not_found",
2161
+ `draw ${args.drawId} not found on credit ${args.creditId}`
2162
+ );
2163
+ }
2164
+ const drawNative = draw.draw_native === null ? null : BigInt(draw.draw_native);
2165
+ if (drawNative === null) {
2166
+ throw new CreditLedgerError(
2167
+ "invalid_basis",
2168
+ `Tempo session draw ${args.drawId} has no native value to consume`
2169
+ );
2170
+ }
2171
+ if (draw.status === "settled") {
2172
+ const value2 = await this.resolveDrawLocked(tx, credit, { ...args, to: "settled" });
2173
+ return { value: value2, amount: drawNative, consume: false };
2174
+ }
2175
+ if (credit.status === "unbacked") {
2176
+ const value2 = await this.resolveDrawLocked(tx, credit, {
2177
+ ...args,
2178
+ to: "released"
2179
+ });
2180
+ return { value: value2, amount: 0n, consume: false };
2181
+ }
2182
+ const closing = lifecycle.finalized || lifecycle.closeRequestedAt !== 0n;
2183
+ const covered = lifecycle.settledOnChain >= lifecycle.highestVoucherAmount;
2184
+ if (closing && !covered) {
2185
+ if (!lifecycle.finalized) {
2186
+ throw new CreditLedgerError(
2187
+ "tempo_settlement_pending",
2188
+ `Tempo channel settlement has not yet covered draw ${args.drawId}`
2189
+ );
2190
+ }
2191
+ const value2 = await this.resolveDrawLocked(tx, credit, {
2192
+ ...args,
2193
+ to: "released"
2194
+ });
2195
+ return { value: value2, amount: 0n, consume: false };
2196
+ }
2197
+ const value = await this.resolveDrawLocked(tx, credit, { ...args, to: "settled" });
2198
+ return {
2199
+ value,
2200
+ amount: drawNative,
2201
+ consume: !closing && !value.replayed
2202
+ };
2203
+ });
2204
+ }
2205
+ /**
2206
+ * Release a pending draw: the hold evaporates, the balance is untouched —
2207
+ * this is how "no debit on job failure" is mechanically real (spec §1).
2208
+ * Idempotent: releasing a released draw returns the original tuple. A
2209
+ * settled draw cannot be released (`invalid_draw_state`) — un-settling
2210
+ * booked revenue is a reconciliation problem, not a ledger verb.
2211
+ */
2212
+ async release(args) {
2213
+ return this.resolveDraw({ ...args, to: "released" });
2214
+ }
2215
+ /**
2216
+ * Record a fund-only top-up under its client-generated `fundId` (internal-review).
2217
+ * The deposit half of the evidence chain, and the top-up path's idempotency
2218
+ * key: `PRIMARY KEY (credit_id, fund_id)` means a concurrent duplicate
2219
+ * loses with a typed `funding_replayed` rather than crediting twice.
2220
+ *
2221
+ * Call it inside the same `tx` as {@link fund} — the rail commit, the
2222
+ * funding record, and the balance increment must land together or not at
2223
+ * all (spec §2 condition 3). Like `fund`, this issues no transaction
2224
+ * control on `tx`; it is a single statement.
2225
+ *
2226
+ * This does **not** move money on its own. `fund` still does the crediting;
2227
+ * this row is what lets a retry be answered with the original outcome.
2228
+ */
2229
+ async recordFunding(args) {
2230
+ assertAmount(args.amountMicro, { min: 1 });
2231
+ const q = args.tx ?? this.pool;
2232
+ const nowMs = args.nowMs ?? Date.now();
2233
+ const { rows } = await q.query(
2234
+ `INSERT INTO credit_fundings (credit_id, fund_id, amount_micro, rail, created_at)
2235
+ VALUES ($1, $2, $3, $4, $5)
2236
+ ON CONFLICT (credit_id, fund_id) DO NOTHING
2237
+ RETURNING *`,
2238
+ [args.creditId, args.fundId, args.amountMicro, args.rail ?? null, nowMs]
2239
+ );
2240
+ if (rows.length === 0) {
2241
+ throw new CreditLedgerError(
2242
+ "funding_replayed",
2243
+ `funding ${args.fundId} on credit ${args.creditId} was already recorded`
2244
+ );
2245
+ }
2246
+ return fundingRecordFromRow(rows[0]);
2247
+ }
2248
+ /**
2249
+ * Read one funding record (no lock). The `/v1/credit` fund path checks this
2250
+ * **before** touching a rail, so a retried top-up costs nothing and returns
2251
+ * the original outcome instead of presenting an artifact the rail would
2252
+ * refuse as spent.
2253
+ */
2254
+ async getFunding(args) {
2255
+ const { rows } = await this.pool.query(
2256
+ `SELECT * FROM credit_fundings WHERE credit_id = $1 AND fund_id = $2`,
2257
+ [args.creditId, args.fundId]
2258
+ );
2259
+ return rows.length > 0 ? fundingRecordFromRow(rows[0]) : void 0;
2260
+ }
2261
+ /** Fix the funding-time receipt tuple inside the rail's open transaction. */
2262
+ async completeFunding(args) {
2263
+ const q = args.tx ?? this.pool;
2264
+ const { rows } = await q.query(
2265
+ `UPDATE credit_fundings
2266
+ SET caller_pubkey = COALESCE(caller_pubkey, $3),
2267
+ balance_after_micro = COALESCE(balance_after_micro, $4),
2268
+ ledger_seq = COALESCE(ledger_seq, $5)
2269
+ WHERE credit_id = $1 AND fund_id = $2
2270
+ RETURNING *`,
2271
+ [args.creditId, args.fundId, args.callerPubkey, args.balanceAfterMicro, args.ledgerSeq]
2272
+ );
2273
+ if (rows.length === 0) {
2274
+ throw new CreditLedgerError(
2275
+ "credit_not_found",
2276
+ `funding ${args.fundId} on credit ${args.creditId} was not recorded`
2277
+ );
2278
+ }
2279
+ return fundingRecordFromRow(rows[0]);
2280
+ }
2281
+ /** Persist the first signature for a funding; concurrent issuers read back the winner. */
2282
+ async saveFundingReceipt(args) {
2283
+ const { rows } = await this.pool.query(
2284
+ `UPDATE credit_fundings SET receipt = COALESCE(receipt, $3::jsonb)
2285
+ WHERE credit_id = $1 AND fund_id = $2
2286
+ RETURNING *`,
2287
+ [args.creditId, args.fundId, JSON.stringify(args.receipt)]
2288
+ );
2289
+ if (!rows[0]?.receipt) {
2290
+ throw new CreditLedgerError(
2291
+ "credit_not_found",
2292
+ `funding ${args.fundId} on credit ${args.creditId} was not recorded`
2293
+ );
2294
+ }
2295
+ return rows[0].receipt;
2296
+ }
2297
+ /**
2298
+ * Record the bolt11 issued for a `(creditId, fundId)` top-up (internal-review), or
2299
+ * return the one already issued for it.
2300
+ *
2301
+ * **Returning the existing row is the point.** A caller re-polling an unpaid
2302
+ * top-up must get the invoice they were handed the first time; minting a
2303
+ * fresh bolt11 per attempt is the double-charge bug — they pay the second
2304
+ * one, the first stays payable, and both settle onto one `fund_id` that can
2305
+ * only be credited once.
2306
+ */
2307
+ async recordInvoice(args) {
2308
+ assertAmount(args.amountMicro, { min: 1 });
2309
+ const nowMs = args.nowMs ?? Date.now();
2310
+ let rows;
2311
+ try {
2312
+ ({ rows } = await this.pool.query(
2313
+ `INSERT INTO credit_invoices (credit_id, fund_id, caller_pubkey, currency, amount_micro,
2314
+ amount_msats, payment_hash, bolt11, status, expires_at, created_at)
2315
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'pending', $9, $10)
2316
+ ON CONFLICT (credit_id, fund_id) DO NOTHING
2317
+ RETURNING *`,
2318
+ [
2319
+ args.creditId,
2320
+ args.fundId,
2321
+ args.callerPubkey,
2322
+ args.currency,
2323
+ args.amountMicro,
2324
+ args.amountMsats,
2325
+ args.paymentHash,
2326
+ args.bolt11,
2327
+ args.expiresAt,
2328
+ nowMs
2329
+ ]
2330
+ ));
2331
+ } catch (err) {
2332
+ if (isUniqueViolation(err)) {
2333
+ throw new CreditLedgerError(
2334
+ "invoice_conflict",
2335
+ `payment hash ${args.paymentHash} is already bound to another funding`,
2336
+ {},
2337
+ { cause: err }
2338
+ );
2339
+ }
2340
+ throw err;
2341
+ }
2342
+ if (rows.length > 0) return invoiceRecordFromRow(rows[0]);
2343
+ const existing = await this.getInvoice({ creditId: args.creditId, fundId: args.fundId });
2344
+ if (!existing) {
2345
+ throw new CreditLedgerError(
2346
+ "invoice_not_found",
2347
+ `invoice for funding ${args.fundId} on credit ${args.creditId} vanished between write and read`
2348
+ );
2349
+ }
2350
+ return existing;
2351
+ }
2352
+ /** Read one invoice (no lock). */
2353
+ async getInvoice(args) {
2354
+ const { rows } = await this.pool.query(
2355
+ `SELECT * FROM credit_invoices WHERE credit_id = $1 AND fund_id = $2`,
2356
+ [args.creditId, args.fundId]
2357
+ );
2358
+ return rows.length > 0 ? invoiceRecordFromRow(rows[0]) : void 0;
2359
+ }
2360
+ /**
2361
+ * A caller's outstanding invoices, oldest first — the settlement sweep's
2362
+ * read. Local and indexed, so the wallet is only consulted when something is
2363
+ * genuinely outstanding.
2364
+ */
2365
+ async listPendingInvoices(args) {
2366
+ const { rows } = await this.pool.query(
2367
+ `SELECT * FROM credit_invoices
2368
+ WHERE caller_pubkey = $1 AND status = 'pending'
2369
+ AND ($2::text IS NULL OR credit_id = $2)
2370
+ ORDER BY created_at, fund_id
2371
+ LIMIT $3`,
2372
+ [args.callerPubkey, args.creditId ?? null, args.limit]
2373
+ );
2374
+ return rows.map(invoiceRecordFromRow);
2375
+ }
2376
+ /**
2377
+ * Apply an observed Lightning settlement to the ledger — **the exactly-once
2378
+ * boundary** (internal-review; spec §2 condition 3 for a rail whose commit happens
2379
+ * off-box).
2380
+ *
2381
+ * One transaction covers the funding record, the balance, and the invoice's
2382
+ * own status flip, so the three can never disagree. A crash anywhere inside
2383
+ * leaves the invoice `pending` and the balance untouched: the next
2384
+ * `lookup_invoice` observes the same settled payment and credits it, once.
2385
+ * A replayed check after the commit sees `settled` and moves nothing.
2386
+ *
2387
+ * The lock is on the **invoice** row, not the credit row: the first
2388
+ * Lightning top-up is what opens the credit, so there is frequently no
2389
+ * `credits` row to take `FOR UPDATE` yet. That does not weaken the ledger's
2390
+ * concurrency contract — no `credit_draws` write happens here, and `fund`'s
2391
+ * upsert is a single statement — while still serialising two settlement
2392
+ * checks racing on the same invoice.
2393
+ */
2394
+ async settleInvoice(args) {
2395
+ assertFundingBasis(args.basis);
2396
+ const nowMs = args.nowMs ?? Date.now();
2397
+ const settledAt = args.settledAtMs ?? nowMs;
2398
+ const client = await this.pool.connect();
2399
+ try {
2400
+ await client.query("BEGIN");
2401
+ const { rows } = await client.query(
2402
+ `SELECT * FROM credit_invoices WHERE credit_id = $1 AND fund_id = $2 FOR UPDATE`,
2403
+ [args.creditId, args.fundId]
2404
+ );
2405
+ if (rows.length === 0) {
2406
+ throw new CreditLedgerError(
2407
+ "invoice_not_found",
2408
+ `no invoice for funding ${args.fundId} on credit ${args.creditId}`
2409
+ );
2410
+ }
2411
+ const invoice = invoiceRecordFromRow(rows[0]);
2412
+ if (invoice.status === "settled") {
2413
+ await client.query("COMMIT");
2414
+ const [funding2, credit2] = await Promise.all([
2415
+ this.getFunding({ creditId: args.creditId, fundId: args.fundId }),
2416
+ this.get(args.creditId, { nowMs })
2417
+ ]);
2418
+ return { invoice, funding: funding2, credit: credit2, replayed: true };
2419
+ }
2420
+ const funding = await this.recordFunding({
2421
+ creditId: invoice.creditId,
2422
+ fundId: invoice.fundId,
2423
+ amountMicro: invoice.amountMicro,
2424
+ rail: "lightning",
2425
+ tx: client,
2426
+ nowMs
2427
+ });
2428
+ const credit = await this.fund({
2429
+ creditId: invoice.creditId,
2430
+ callerPubkey: invoice.callerPubkey,
2431
+ currency: invoice.currency,
2432
+ amountMicro: invoice.amountMicro,
2433
+ expiryMs: args.expiryMs,
2434
+ basis: args.basis,
2435
+ tx: client,
2436
+ nowMs
2437
+ });
2438
+ await this.completeFunding({
2439
+ creditId: invoice.creditId,
2440
+ fundId: invoice.fundId,
2441
+ callerPubkey: invoice.callerPubkey,
2442
+ balanceAfterMicro: credit.availableMicro,
2443
+ ledgerSeq: credit.lastLedgerSeq,
2444
+ tx: client
2445
+ });
2446
+ const settled = await client.query(
2447
+ `UPDATE credit_invoices SET status = 'settled', settled_at = $3
2448
+ WHERE credit_id = $1 AND fund_id = $2
2449
+ RETURNING *`,
2450
+ [args.creditId, args.fundId, settledAt]
2451
+ );
2452
+ if (args.depositOutbox) {
2453
+ await args.depositOutbox.enqueue(client, args.depositOutbox.payload);
2454
+ }
2455
+ await client.query("COMMIT");
2456
+ return {
2457
+ invoice: invoiceRecordFromRow(settled.rows[0]),
2458
+ funding,
2459
+ credit,
2460
+ replayed: false
2461
+ };
2462
+ } catch (err) {
2463
+ try {
2464
+ await client.query("ROLLBACK");
2465
+ } catch {
2466
+ }
2467
+ throw err;
2468
+ } finally {
2469
+ client.release();
2470
+ }
2471
+ }
2472
+ /**
2473
+ * Retire an invoice that expired without being paid — bookkeeping only, no
2474
+ * ledger effect. A CAS on `pending` so it can never overwrite a settlement
2475
+ * observed concurrently.
2476
+ */
2477
+ async markInvoiceExpired(args) {
2478
+ await this.pool.query(
2479
+ `UPDATE credit_invoices SET status = 'expired'
2480
+ WHERE credit_id = $1 AND fund_id = $2 AND status = 'pending'`,
2481
+ [args.creditId, args.fundId]
2482
+ );
2483
+ return this.getInvoice(args);
2484
+ }
2485
+ /**
2486
+ * Retire an invoice that **was paid** and can never be credited (internal-review).
2487
+ *
2488
+ * Distinct from `expired` because the money is the opposite way round: an
2489
+ * expired invoice was never paid and owes nobody anything, whereas a blocked
2490
+ * one has sats sitting in the builder's wallet with no ledger row to put them
2491
+ * on. Only an operator can resolve that, so the row records `reason` and
2492
+ * stops being swept — leaving it `pending` would spend one of the sweep's few
2493
+ * slots re-deriving the same verdict on every request the caller makes, and
2494
+ * three of them would wedge that caller's sweep so a genuinely payable
2495
+ * invoice behind them never credits.
2496
+ *
2497
+ * A CAS on `pending`, like the expiry retirement: a settlement observed
2498
+ * concurrently must win.
2499
+ */
2500
+ async markInvoiceBlocked(args) {
2501
+ await this.pool.query(
2502
+ `UPDATE credit_invoices SET status = 'blocked', blocked_reason = $3
2503
+ WHERE credit_id = $1 AND fund_id = $2 AND status = 'pending'`,
2504
+ [args.creditId, args.fundId, args.reason]
2505
+ );
2506
+ return this.getInvoice(args);
2507
+ }
2508
+ /** Read one invoice by the wallet's own identifier for the payment (no lock). */
2509
+ async getInvoiceByPaymentHash(paymentHash) {
2510
+ const { rows } = await this.pool.query(
2511
+ `SELECT * FROM credit_invoices WHERE payment_hash = $1`,
2512
+ [paymentHash]
2513
+ );
2514
+ return rows.length > 0 ? invoiceRecordFromRow(rows[0]) : void 0;
2515
+ }
2516
+ /**
2517
+ * The operator's queue: invoices the sweep gave up on (internal-review).
2518
+ *
2519
+ * **Keyset-paginated, not offset.** Blocked rows are terminal and never
2520
+ * self-clear, so a row the operator declines to act on sits at the head of
2521
+ * the age ordering forever; a fixed first page would starve everything
2522
+ * behind it on every run, which is the internal-review shape one page up.
2523
+ *
2524
+ * `includeResolved` widens to the operator-resolved statuses so a run can be
2525
+ * audited after the fact — the acceptance criterion this verb exists for.
2526
+ * Host-wide, like `listPendingDrains`: the ledger has no `dvm_id`, so on a
2527
+ * multi-mount host one builder's admin credential reads every mount's rows.
2528
+ */
2529
+ async listBlockedInvoices(args) {
2530
+ const statuses = args.includeResolved ? ["blocked", "reconciled", "written_off"] : ["blocked"];
2531
+ const { rows } = await this.pool.query(
2532
+ `SELECT * FROM credit_invoices
2533
+ WHERE status = ANY($1::text[])
2534
+ AND ($2::bigint IS NULL
2535
+ OR (created_at, credit_id, fund_id) > ($2::bigint, $3::text, $4::text))
2536
+ ORDER BY created_at, credit_id, fund_id
2537
+ LIMIT $5`,
2538
+ [
2539
+ statuses,
2540
+ args.after?.createdAt ?? null,
2541
+ args.after?.creditId ?? null,
2542
+ args.after?.fundId ?? null,
2543
+ args.limit
2544
+ ]
2545
+ );
2546
+ return rows.map(invoiceRecordFromRow);
2547
+ }
2548
+ /**
2549
+ * Apply a blocked invoice's payment to a credit an operator named — the
2550
+ * repair for the one Lightning outcome the DVM cannot fix itself (internal-review).
2551
+ *
2552
+ * Shaped statement-for-statement on {@link settleInvoice}, because it is the
2553
+ * same money doing the same thing a different way: one transaction covering
2554
+ * the funding record, the balance, the invoice's status flip and the deposit
2555
+ * outbox row, so the four can never disagree. Three deliberate differences:
2556
+ *
2557
+ * - **The lock is taken on `payment_hash`**, which is UNIQUE and is the
2558
+ * identifier the operator actually holds (it is what the
2559
+ * `lightning_settlement_blocked` log carries and what names the payment in
2560
+ * their wallet). Invoice row first, credit row second via `fund`'s upsert —
2561
+ * the same order `settleInvoice` takes, which is what keeps a reconcile and
2562
+ * a concurrent settlement check from deadlocking against each other.
2563
+ * - **The funding lands at `(targetCreditId, paymentHash)`, not the invoice's
2564
+ * own `(credit_id, fund_id)`.** That key is frequently the reason the row
2565
+ * is blocked at all — `funding_replayed_on_cashu` means something else
2566
+ * already holds it — and the operator's most natural target is that very
2567
+ * credit. The payment hash cannot collide with it, and it makes all three
2568
+ * references to this payment agree: `basis.fundingRef`, the deposit's
2569
+ * `funding_id`, and the funding row's `fund_id`.
2570
+ * - **`written_off` is an accepted input status.** A write-off unwound
2571
+ * nothing, so an operator who closed a row by mistake must not need raw SQL
2572
+ * against a money table to reopen it.
2573
+ *
2574
+ * The invoice row's status — never the funding row — is the idempotency
2575
+ * source of truth. `fund_id` is caller-chosen and the payment hash is
2576
+ * disclosed to them, so a caller *can* squat `(theirCredit, thatHash)` with a
2577
+ * top-up on another rail; when they have, `recordFunding` throws
2578
+ * `funding_replayed` and that surfaces as its own loud failure rather than
2579
+ * being absorbed as "already reconciled".
2580
+ */
2581
+ async reconcileBlockedInvoice(args) {
2582
+ assertFundingBasis(args.basis);
2583
+ const nowMs = args.nowMs ?? Date.now();
2584
+ const client = await this.pool.connect();
2585
+ try {
2586
+ await client.query("BEGIN");
2587
+ const { rows } = await client.query(
2588
+ `SELECT * FROM credit_invoices WHERE payment_hash = $1 FOR UPDATE`,
2589
+ [args.paymentHash]
2590
+ );
2591
+ if (rows.length === 0) {
2592
+ throw new CreditLedgerError(
2593
+ "invoice_not_found",
2594
+ `no invoice for payment hash ${args.paymentHash}`
2595
+ );
2596
+ }
2597
+ const invoice = invoiceRecordFromRow(rows[0]);
2598
+ if (invoice.status === "reconciled") {
2599
+ await client.query("COMMIT");
2600
+ const [funding2, credit2] = await Promise.all([
2601
+ invoice.reconciledCreditId && invoice.reconciledFundId ? this.getFunding({
2602
+ creditId: invoice.reconciledCreditId,
2603
+ fundId: invoice.reconciledFundId
2604
+ }) : Promise.resolve(void 0),
2605
+ invoice.reconciledCreditId ? this.get(invoice.reconciledCreditId, { nowMs }) : Promise.resolve(void 0)
2606
+ ]);
2607
+ return { invoice, funding: funding2, credit: credit2, replayed: true };
2608
+ }
2609
+ if (invoice.status !== "blocked" && invoice.status !== "written_off") {
2610
+ throw new CreditLedgerError(
2611
+ "invoice_not_blocked",
2612
+ `invoice ${args.paymentHash} is ${invoice.status}; only a blocked or written-off invoice can be reconciled`
2613
+ );
2614
+ }
2615
+ const funding = await this.recordFunding({
2616
+ creditId: args.targetCreditId,
2617
+ fundId: invoice.paymentHash,
2618
+ amountMicro: invoice.amountMicro,
2619
+ rail: "lightning",
2620
+ tx: client,
2621
+ nowMs
2622
+ });
2623
+ const credit = await this.fund({
2624
+ creditId: args.targetCreditId,
2625
+ callerPubkey: invoice.callerPubkey,
2626
+ currency: invoice.currency,
2627
+ amountMicro: invoice.amountMicro,
2628
+ expiryMs: args.expiryMs,
2629
+ basis: args.basis,
2630
+ tx: client,
2631
+ nowMs
2632
+ });
2633
+ await this.completeFunding({
2634
+ creditId: args.targetCreditId,
2635
+ fundId: invoice.paymentHash,
2636
+ callerPubkey: invoice.callerPubkey,
2637
+ balanceAfterMicro: credit.availableMicro,
2638
+ ledgerSeq: credit.lastLedgerSeq,
2639
+ tx: client
2640
+ });
2641
+ const reconciled = await client.query(
2642
+ `UPDATE credit_invoices
2643
+ SET status = 'reconciled',
2644
+ reconciled_credit_id = $2,
2645
+ reconciled_fund_id = $3,
2646
+ resolved_at = $4,
2647
+ settled_at = COALESCE($5, settled_at)
2648
+ WHERE payment_hash = $1
2649
+ RETURNING *`,
2650
+ [
2651
+ args.paymentHash,
2652
+ args.targetCreditId,
2653
+ invoice.paymentHash,
2654
+ nowMs,
2655
+ args.settledAtMs ?? null
2656
+ ]
2657
+ );
2658
+ if (args.depositOutbox) {
2659
+ await args.depositOutbox.enqueue(client, args.depositOutbox.payload);
2660
+ }
2661
+ await client.query("COMMIT");
2662
+ return {
2663
+ invoice: invoiceRecordFromRow(reconciled.rows[0]),
2664
+ funding,
2665
+ credit,
2666
+ replayed: false
2667
+ };
2668
+ } catch (err) {
2669
+ try {
2670
+ await client.query("ROLLBACK");
2671
+ } catch {
2672
+ }
2673
+ throw err;
2674
+ } finally {
2675
+ client.release();
2676
+ }
2677
+ }
2678
+ /**
2679
+ * Record that an operator reviewed a blocked invoice and chose not to credit
2680
+ * it (internal-review) — no ledger effect, purely a queue transition.
2681
+ *
2682
+ * A CAS on `blocked`, and idempotent: re-running returns the recorded row
2683
+ * rather than overwriting the first note. It cannot capture a `reconciled`
2684
+ * row, because that one moved money.
2685
+ *
2686
+ * `written` reports whether *this* call was the transition. The caller has
2687
+ * no other way to tell — the row reads identically either way — and the
2688
+ * write-off's audit log is the only record of the amount, so a re-emitted
2689
+ * one reads as a second decision about the same sats.
2690
+ */
2691
+ async writeOffBlockedInvoice(args) {
2692
+ const { rowCount } = await this.pool.query(
2693
+ `UPDATE credit_invoices SET status = 'written_off', write_off_note = $2, resolved_at = $3
2694
+ WHERE payment_hash = $1 AND status = 'blocked'`,
2695
+ [args.paymentHash, args.note ?? null, args.nowMs ?? Date.now()]
2696
+ );
2697
+ const invoice = await this.getInvoiceByPaymentHash(args.paymentHash);
2698
+ if (!invoice) return void 0;
2699
+ return { invoice, written: (rowCount ?? 0) > 0 };
2700
+ }
2701
+ /** Read one credit (no lock). Expired credits stay fully readable. */
2702
+ async get(creditId, opts) {
2703
+ const { rows } = await this.pool.query(
2704
+ `SELECT * FROM credits WHERE credit_id = $1`,
2705
+ [creditId]
2706
+ );
2707
+ if (rows.length === 0) return void 0;
2708
+ return this.snapshotFromRow(rows[0], this.pool, opts?.nowMs ?? Date.now());
2709
+ }
2710
+ /** Read all credits held by a caller pubkey, oldest first (no lock). */
2711
+ async getForCaller(callerPubkey, opts) {
2712
+ const { rows } = await this.pool.query(
2713
+ `SELECT * FROM credits WHERE caller_pubkey = $1 ORDER BY created_at, credit_id`,
2714
+ [callerPubkey]
2715
+ );
2716
+ const nowMs = opts?.nowMs ?? Date.now();
2717
+ const out = [];
2718
+ for (const row of rows) {
2719
+ out.push(await this.snapshotFromRow(row, this.pool, nowMs));
2720
+ }
2721
+ return out;
2722
+ }
2723
+ /** Read one draw (no lock), with its credit's rail basis joined in. */
2724
+ async getDraw(args) {
2725
+ const { rows } = await this.pool.query(
2726
+ `${DRAW_WITH_BASIS_SELECT} WHERE d.credit_id = $1 AND d.draw_id = $2`,
2727
+ [args.creditId, args.drawId]
2728
+ );
2729
+ return rows[0] ? drawRecordFromRow(rows[0], rows[0]) : void 0;
2730
+ }
2731
+ /** Read the sole draw bound to a pre-allocated job id; ambiguity fails closed. */
2732
+ async getDrawByJobId(jobId) {
2733
+ const { rows } = await this.pool.query(
2734
+ `${DRAW_WITH_BASIS_SELECT} WHERE d.job_id = $1 ORDER BY d.created_at LIMIT 2`,
2735
+ [jobId]
2736
+ );
2737
+ return rows.length === 1 ? drawRecordFromRow(rows[0], rows[0]) : void 0;
2738
+ }
2739
+ /**
2740
+ * All pending holds on a credit, in `ledger_seq` order (no lock). Read
2741
+ * surface for the internal-review sweeper wiring — a worker that dies mid-job
2742
+ * leaves its hold `pending` until something releases it.
2743
+ */
2744
+ async listPendingDraws(creditId) {
2745
+ const { rows } = await this.pool.query(
2746
+ `${DRAW_WITH_BASIS_SELECT} WHERE d.credit_id = $1 AND d.status = 'pending' ORDER BY d.ledger_seq`,
2747
+ [creditId]
2748
+ );
2749
+ return rows.map((row) => drawRecordFromRow(row, row));
2750
+ }
2751
+ /**
2752
+ * One page of pending holds host-wide placed before `createdBeforeMs`,
2753
+ * oldest first (no lock). The orphan sweeper's read surface (internal-review): a
2754
+ * draw commits with its `job_id` before the job row is persisted, so a crash
2755
+ * or a fail-closed refusal in that window strands a hold nothing can ever
2756
+ * resolve — the only release path keys off the `credit_id`/`draw_id` written
2757
+ * on the job row.
2758
+ *
2759
+ * Host-wide like {@link listPendingDrains}, not per-credit: the sweeper has
2760
+ * no candidate credit to start from. Age-bounded so it never sees a draw
2761
+ * whose job row is merely still in flight.
2762
+ *
2763
+ * `limit` is required and `after` keyset-paginates: an aged hold whose job
2764
+ * row *does* exist is legitimately un-sweepable and stays `pending`
2765
+ * indefinitely, so a caller that re-issues the same first page would never
2766
+ * see past a `limit`-sized wall of them. Pass the last row's
2767
+ * {@link StalePendingDrawCursor} to advance.
2768
+ */
2769
+ async listStalePendingDraws(args) {
2770
+ const after = args.after;
2771
+ const { rows } = await this.pool.query(
2772
+ `${DRAW_WITH_BASIS_SELECT}
2773
+ WHERE d.status = 'pending' AND d.created_at < $1
2774
+ AND ($3::bigint IS NULL
2775
+ OR (d.created_at, d.credit_id, d.ledger_seq) > ($3::bigint, $4::text, $5::bigint))
2776
+ ORDER BY d.created_at, d.credit_id, d.ledger_seq
2777
+ LIMIT $2`,
2778
+ [
2779
+ args.createdBeforeMs,
2780
+ args.limit,
2781
+ after?.createdAt ?? null,
2782
+ after?.creditId ?? null,
2783
+ after?.ledgerSeq ?? null
2784
+ ]
2785
+ );
2786
+ return rows.map((row) => drawRecordFromRow(row, row));
2787
+ }
2788
+ /**
2789
+ * Rail-native value the credit an x402 settlement channel funded has
2790
+ * actually earned — the sum of its **settled** draws, in the credit's native
2791
+ * atomic units (USDC micro on this rail). The batch-settlement claim job's
2792
+ * ceiling (internal-review): a channel is claimable up to what its credit's draws
2793
+ * have earned, never up to the deposit that funded it.
2794
+ *
2795
+ * `undefined` when no credit is bound to the channel, which the claim job
2796
+ * treats as "cannot be resolved" and claims nothing for — distinct from a
2797
+ * resolved credit that has earned `0`.
2798
+ *
2799
+ * Deliberately a sum over settled draws rather than `funded − remaining`:
2800
+ * a drain lowers `native_remaining` without earning anything, so the
2801
+ * subtraction would read the caller's own reclaim as revenue. Pending holds
2802
+ * are excluded for the mirror reason — a hold is work in flight, and
2803
+ * claiming against it would take money a release still owes back.
2804
+ */
2805
+ async earnedNativeForX402Channel(channelId) {
2806
+ const { rows } = await this.pool.query(
2807
+ `SELECT COALESCE(
2808
+ (SELECT SUM(d.draw_native)
2809
+ FROM credit_draws d
2810
+ WHERE d.credit_id = c.credit_id
2811
+ AND d.status = 'settled'
2812
+ AND d.draw_native IS NOT NULL),
2813
+ 0
2814
+ )::text AS earned
2815
+ FROM credits c
2816
+ WHERE c.x402_channel_id = $1`,
2817
+ [channelId]
2818
+ );
2819
+ if (rows.length === 0) return void 0;
2820
+ return toSafeInt(rows[0].earned);
2821
+ }
2822
+ /**
2823
+ * The credit an x402 settlement channel funded, if one is bound to it — the
2824
+ * binding is UNIQUE, so at most one row can answer (internal-review).
2825
+ *
2826
+ * The operator repair's entry point: a wedged settlement row carries the
2827
+ * channel and an `effect_id`, and this is what turns them back into the
2828
+ * credit id, the caller pubkey, and the drain id the effect encodes. Deriving
2829
+ * the credit id by splitting `effect_id` instead would be wrong — credit ids
2830
+ * carry their own colons (`imp:`, `fnd:` and the rail segment inside them).
2831
+ */
2832
+ async getCreditByX402Channel(channelId) {
2833
+ const { rows } = await this.pool.query(
2834
+ `SELECT * FROM credits WHERE x402_channel_id = $1`,
2835
+ [channelId]
2836
+ );
2837
+ return rows[0] ? this.snapshotFromRow(rows[0], this.pool, Date.now()) : void 0;
2838
+ }
2839
+ /** Retire every active credit backed by a chain-proven lost Tempo channel. */
2840
+ async terminalizeTempoCredits(evidence, tx) {
2841
+ const channelId = evidence.channelId.toLowerCase();
2842
+ const observedAt = evidence.observedAt ?? Date.now();
2843
+ if (tx) return this.terminalizeTempoCreditsLocked(tx, evidence, channelId, observedAt);
2844
+ const client = await this.pool.connect();
2845
+ try {
2846
+ await client.query("BEGIN");
2847
+ const losses = await this.terminalizeTempoCreditsLocked(
2848
+ client,
2849
+ evidence,
2850
+ channelId,
2851
+ observedAt
2852
+ );
2853
+ await client.query("COMMIT");
2854
+ return losses;
2855
+ } catch (error) {
2856
+ await client.query("ROLLBACK").catch(() => void 0);
2857
+ throw error;
2858
+ } finally {
2859
+ client.release();
2860
+ }
2861
+ }
2862
+ async terminalizeTempoCreditsLocked(q, evidence, channelId, observedAt) {
2863
+ const { rows: credits } = await q.query(
2864
+ `SELECT * FROM credits
2865
+ WHERE tempo_channel_id = $1
2866
+ ORDER BY credit_id
2867
+ FOR UPDATE`,
2868
+ [channelId]
2869
+ );
2870
+ for (const credit of credits) {
2871
+ if (credit.status === "unbacked") continue;
2872
+ if (credit.status !== "active") throw terminalCreditError(credit.credit_id, credit.status);
2873
+ const { rows: consumed } = await q.query(
2874
+ `SELECT COALESCE(SUM(amount_micro), 0)::text AS amount
2875
+ FROM credit_draws
2876
+ WHERE credit_id = $1 AND status = 'settled'`,
2877
+ [credit.credit_id]
2878
+ );
2879
+ await q.query(
2880
+ `INSERT INTO tempo_credit_losses
2881
+ (credit_id, channel_id, caller_pubkey, currency, former_balance_micro,
2882
+ settled_on_chain_native, highest_voucher_native, consumed_service_micro,
2883
+ consumed_native, observed_at)
2884
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
2885
+ ON CONFLICT (credit_id) DO NOTHING`,
2886
+ [
2887
+ credit.credit_id,
2888
+ channelId,
2889
+ credit.caller_pubkey,
2890
+ credit.currency,
2891
+ credit.balance_micro,
2892
+ evidence.settledOnChainNative.toString(),
2893
+ evidence.highestVoucherNative.toString(),
2894
+ consumed[0]?.amount ?? "0",
2895
+ evidence.consumedNative.toString(),
2896
+ observedAt
2897
+ ]
2898
+ );
2899
+ await q.query(`UPDATE credits SET status = 'unbacked' WHERE credit_id = $1`, [
2900
+ credit.credit_id
2901
+ ]);
2902
+ }
2903
+ return this.readTempoCreditLosses(q, { channelId });
2904
+ }
2905
+ /** List terminal Tempo credit losses, newest observation first. */
2906
+ async listTempoCreditLosses(args) {
2907
+ return this.readTempoCreditLosses(this.pool, { limit: args?.limit ?? 50 });
2908
+ }
2909
+ /**
2910
+ * Reconcile the credit bound to a chain-proven empty x402 channel.
2911
+ *
2912
+ * A channel with no unclaimed backing makes every remaining customer
2913
+ * liability unsafe, so those credits become terminal. A fully settled credit
2914
+ * has neither a balance nor an unfinished drain and is safe to retain for a
2915
+ * later deposit. This also repairs a zero-liability row an older build
2916
+ * terminalized from the same observation.
2917
+ */
2918
+ async terminalizeX402Credit(evidence, tx) {
2919
+ const channelId = evidence.channelId.toLowerCase();
2920
+ const observedAt = evidence.observedAt ?? Date.now();
2921
+ if (tx) return this.terminalizeX402CreditLocked(tx, evidence, channelId, observedAt);
2922
+ const client = await this.pool.connect();
2923
+ try {
2924
+ await client.query("BEGIN");
2925
+ const losses = await this.terminalizeX402CreditLocked(
2926
+ client,
2927
+ evidence,
2928
+ channelId,
2929
+ observedAt
2930
+ );
2931
+ await client.query("COMMIT");
2932
+ return losses;
2933
+ } catch (error) {
2934
+ await client.query("ROLLBACK").catch(() => void 0);
2935
+ throw error;
2936
+ } finally {
2937
+ client.release();
2938
+ }
2939
+ }
2940
+ async terminalizeX402CreditLocked(q, evidence, channelId, observedAt) {
2941
+ const { rows: credits } = await q.query(
2942
+ `SELECT * FROM credits WHERE x402_channel_id = $1 FOR UPDATE`,
2943
+ [channelId]
2944
+ );
2945
+ if (credits.length > 0) {
2946
+ const credit = credits[0];
2947
+ if (credit.status !== "active" && credit.status !== "unbacked") {
2948
+ throw terminalCreditError(credit.credit_id, credit.status);
2949
+ }
2950
+ const { rows: drains } = await q.query(
2951
+ `SELECT EXISTS(
2952
+ SELECT 1 FROM credit_drains
2953
+ WHERE credit_id = $1 AND status IN ('pending', 'parked')
2954
+ ) AS outstanding`,
2955
+ [credit.credit_id]
2956
+ );
2957
+ const hasLiability = toSafeInt(credit.balance_micro) > 0 || drains[0]?.outstanding;
2958
+ if (!hasLiability) {
2959
+ if (credit.status === "unbacked") {
2960
+ await q.query(`UPDATE credits SET status = 'active' WHERE credit_id = $1`, [
2961
+ credit.credit_id
2962
+ ]);
2963
+ await q.query(`DELETE FROM x402_credit_losses WHERE credit_id = $1`, [credit.credit_id]);
2964
+ }
2965
+ return this.readX402CreditLosses(q, { channelId });
2966
+ }
2967
+ if (credit.status !== "unbacked") {
2968
+ const { rows: consumed } = await q.query(
2969
+ `SELECT COALESCE(SUM(amount_micro), 0)::text AS amount
2970
+ FROM credit_draws
2971
+ WHERE credit_id = $1 AND status = 'settled'`,
2972
+ [credit.credit_id]
2973
+ );
2974
+ await q.query(
2975
+ `INSERT INTO x402_credit_losses
2976
+ (credit_id, channel_id, caller_pubkey, currency, former_balance_micro,
2977
+ channel_balance_native, total_claimed_native, consumed_service_micro, observed_at)
2978
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
2979
+ ON CONFLICT (credit_id) DO NOTHING`,
2980
+ [
2981
+ credit.credit_id,
2982
+ channelId,
2983
+ credit.caller_pubkey,
2984
+ credit.currency,
2985
+ credit.balance_micro,
2986
+ evidence.channelBalanceNative.toString(),
2987
+ evidence.totalClaimedNative.toString(),
2988
+ consumed[0]?.amount ?? "0",
2989
+ observedAt
2990
+ ]
2991
+ );
2992
+ await q.query(`UPDATE credits SET status = 'unbacked' WHERE credit_id = $1`, [
2993
+ credit.credit_id
2994
+ ]);
2995
+ }
2996
+ }
2997
+ return this.readX402CreditLosses(q, { channelId });
2998
+ }
2999
+ /** List terminal x402 credit losses, newest observation first. */
3000
+ async listX402CreditLosses(args) {
3001
+ return this.readX402CreditLosses(this.pool, { limit: args?.limit ?? 50 });
3002
+ }
3003
+ // ── Drains (internal-review) ─────────────────────────────────────────────────
3004
+ /**
3005
+ * Debit the caller's entire available balance into a drain liability
3006
+ * (spec §5: expiry ends spending, never ownership — this is the
3007
+ * builder-honored reclaim floor). Runs under the credit row lock: the
3008
+ * amount is `balance − pending holds` read under `FOR UPDATE`, the balance
3009
+ * is decremented in the same transaction, and the drain takes the next
3010
+ * gap-free `ledger_seq` — so the balance zeroes exactly once however many
3011
+ * machines race the request.
3012
+ *
3013
+ * Idempotent on `(creditId, drainId)`: a replay returns the recorded drain
3014
+ * (this is also the poll/pickup read). A replay whose `method` or `payout`
3015
+ * differs from the recorded drain is refused with `drain_conflict` —
3016
+ * silently honouring a changed payout would ship the caller's money to a
3017
+ * destination they never signed alongside this `drain_id`.
3018
+ *
3019
+ * Pending holds are NOT drained — in-flight jobs keep their money until
3020
+ * they settle or release. A release after a drain restores available
3021
+ * balance the caller reclaims with a **new** `drain_id`; drains are ledger
3022
+ * arithmetic, not a terminal credit state, so `status` stays `active`.
3023
+ *
3024
+ * Deliberately **no expiry check**: draining works before and after
3025
+ * expiry. The one thing expiry gates is new draws.
3026
+ */
3027
+ async requestDrain(args) {
3028
+ const nowMs = args.nowMs ?? Date.now();
3029
+ const run = async (client, credit) => {
3030
+ if (credit.caller_pubkey !== args.callerPubkey) {
3031
+ throw new CreditLedgerError(
3032
+ "caller_mismatch",
3033
+ `credit ${args.creditId} belongs to a different caller pubkey`
3034
+ );
3035
+ }
3036
+ const existing = await this.readDrain(client, args.creditId, args.drainId);
3037
+ if (existing) {
3038
+ if (existing.method !== args.method || JSON.stringify(existing.payout) !== JSON.stringify(args.payout)) {
3039
+ throw new CreditLedgerError(
3040
+ "drain_conflict",
3041
+ `drain ${args.drainId} on credit ${args.creditId} was recorded with a different method or payout`
3042
+ );
3043
+ }
3044
+ return {
3045
+ drain: drainRecordFromRow(existing, credit),
3046
+ credit: await this.snapshotFromRow(credit, client, nowMs),
3047
+ replayed: true
3048
+ };
3049
+ }
3050
+ if (credit.status !== "active") throw terminalCreditError(args.creditId, credit.status);
3051
+ const blocking = await this.blockingX402Refund(
3052
+ credit.x402_channel_id,
3053
+ X402_WEDGED_SETTLEMENT_STATUSES
3054
+ );
3055
+ if (blocking && blocking.effectId !== `${args.creditId}:${args.drainId}`) {
3056
+ throw x402SettlementPending(args.creditId, blocking);
3057
+ }
3058
+ const balanceMicro = toSafeInt(credit.balance_micro);
3059
+ const pending = await this.pendingSums(client, args.creditId);
3060
+ const availableMicro = balanceMicro - pending.micro;
3061
+ if (args.requireNoPending && pending.micro > 0) {
3062
+ throw new CreditLedgerError(
3063
+ "drain_conflict",
3064
+ `credit ${args.creditId} has pending draws that must finish before it can drain`,
3065
+ { drainConflict: "pending_draws" }
3066
+ );
3067
+ }
3068
+ if (availableMicro <= 0) {
3069
+ throw new CreditLedgerError(
3070
+ "nothing_to_drain",
3071
+ `credit ${args.creditId} has no available balance to drain`,
3072
+ { availableMicro, balanceMicro }
3073
+ );
3074
+ }
3075
+ const inKind = isNonChannelBitcoinRail(credit.rail) ? await this.reclaimInKind(client, args.creditId, availableMicro) : void 0;
3076
+ if (inKind?.owedSats === 0) {
3077
+ throw new CreditLedgerError(
3078
+ "drain_below_dust",
3079
+ `credit ${args.creditId} holds ${availableMicro} micro, worth ${inKind.grossSats ?? 0} sats at the rate it was funded \u2014 no more than the ${DRAIN_DELIVERY_RESERVE_SATS} sats it costs to hand back`,
3080
+ { availableMicro, balanceMicro }
3081
+ );
3082
+ }
3083
+ const ledgerSeq = toSafeInt(credit.last_ledger_seq) + 1;
3084
+ const balanceAfter = balanceMicro - availableMicro;
3085
+ const drainedMsats = Math.max(0, toSafeInt(credit.msats_remaining) - pending.msats);
3086
+ const drainedNative = credit.native_remaining === null ? null : Math.max(0, toSafeInt(credit.native_remaining) - pending.native);
3087
+ await client.query(
3088
+ `UPDATE credits
3089
+ SET balance_micro = $2,
3090
+ last_ledger_seq = $3,
3091
+ msats_remaining = LEAST(msats_remaining, $4),
3092
+ native_remaining = CASE WHEN native_remaining IS NULL THEN NULL
3093
+ ELSE LEAST(native_remaining, $5) END
3094
+ WHERE credit_id = $1`,
3095
+ [args.creditId, balanceAfter, ledgerSeq, pending.msats, pending.native]
3096
+ );
3097
+ const { rows } = await client.query(
3098
+ `INSERT INTO credit_drains
3099
+ (credit_id, drain_id, method, payout, amount_micro, balance_after_micro,
3100
+ drained_msats, drained_native, owed_sats, lot_debits, ledger_seq, status, created_at)
3101
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'pending', $12)
3102
+ RETURNING *`,
3103
+ [
3104
+ args.creditId,
3105
+ args.drainId,
3106
+ args.method,
3107
+ JSON.stringify(args.payout),
3108
+ availableMicro,
3109
+ balanceAfter,
3110
+ drainedMsats,
3111
+ drainedNative,
3112
+ inKind?.owedSats ?? null,
3113
+ inKind ? JSON.stringify(inKind.debits) : null,
3114
+ ledgerSeq,
3115
+ nowMs
3116
+ ]
3117
+ );
3118
+ if (inKind) await this.applyLotDebits(client, inKind.debits, -1);
3119
+ const updated = {
3120
+ ...credit,
3121
+ balance_micro: String(balanceAfter),
3122
+ last_ledger_seq: String(ledgerSeq)
3123
+ };
3124
+ return {
3125
+ drain: drainRecordFromRow(rows[0], credit),
3126
+ credit: await this.snapshotFromRow(updated, client, nowMs),
3127
+ replayed: false
3128
+ };
3129
+ };
3130
+ if (args.tx) {
3131
+ return run(args.tx, await this.lockCreditRow(args.tx, args.creditId));
3132
+ }
3133
+ return this.withCreditLock(args.creditId, run);
3134
+ }
3135
+ /** Read one drain (no lock), joined with its credit for currency + owner. */
3136
+ async getDrain(args) {
3137
+ const { rows } = await this.pool.query(
3138
+ `SELECT d.*, ${DRAIN_CREDIT_COLUMNS} FROM credit_drains d
3139
+ JOIN credits c ON c.credit_id = d.credit_id
3140
+ WHERE d.credit_id = $1 AND d.drain_id = $2`,
3141
+ [args.creditId, args.drainId]
3142
+ );
3143
+ return rows.length > 0 ? drainRecordFromJoinRow(rows[0]) : void 0;
3144
+ }
3145
+ /**
3146
+ * One credit's undepleted funding lots, oldest first (internal-review) — the
3147
+ * public read behind the reclaim's own arithmetic, for anything that needs
3148
+ * to show its work.
3149
+ */
3150
+ async listFundingLots(creditId) {
3151
+ return this.openLots(this.pool, creditId);
3152
+ }
3153
+ /**
3154
+ * What this DVM owes back in satoshis if every open non-channel Bitcoin
3155
+ * credit reclaimed right now (internal-review) — the deposit half of the hub
3156
+ * balance, and the floor a payout sweep must never go below.
3157
+ *
3158
+ * Read entirely off the funding lots, at their own funding rates, with no
3159
+ * exchange-rate lookup anywhere: the obligation is in kind, so a rate has
3160
+ * nothing to say about it. Pending job holds are deliberately **not**
3161
+ * subtracted — a hold either settles into revenue or releases back to the
3162
+ * caller, and until it does, treating it as already earned would understate
3163
+ * the floor. The figure is conservative by exactly that amount.
3164
+ *
3165
+ * The two coverage holes ride along rather than being folded in silently, so
3166
+ * a gap reads as a gap: `uncovered` is a credit whose lots fall short of its
3167
+ * balance, `unbacked` is one holding any open micro with no sats basis. A
3168
+ * reclaim on either prices the **whole** balance off a live rate — the
3169
+ * depletion refuses a mixture rather than blending two bases — so both are
3170
+ * reported as the whole balance, and neither contributes to `depositSats`.
3171
+ * A figure that counted the backed half of a mixed credit would be a floor
3172
+ * the payout never matches.
3173
+ */
3174
+ async bitcoinDepositLiability() {
3175
+ const { rows } = await this.pool.query(
3176
+ `SELECT c.rail, c.currency,
3177
+ COUNT(*) AS credits,
3178
+ COALESCE(SUM(cov.owed_sats) FILTER (WHERE ${IN_KIND_PREDICATE}), 0) AS owed_sats,
3179
+ COALESCE(SUM(c.balance_micro), 0) AS balance_micro,
3180
+ COUNT(*) FILTER (WHERE cov.covered_micro < c.balance_micro) AS uncovered_credits,
3181
+ COALESCE(SUM(c.balance_micro) FILTER (WHERE cov.covered_micro < c.balance_micro), 0)
3182
+ AS uncovered_micro,
3183
+ COUNT(*) FILTER (WHERE cov.covered_micro >= c.balance_micro AND cov.basisless_micro > 0)
3184
+ AS unbacked_credits,
3185
+ COALESCE(SUM(c.balance_micro)
3186
+ FILTER (WHERE cov.covered_micro >= c.balance_micro AND cov.basisless_micro > 0), 0)
3187
+ AS unbacked_micro
3188
+ FROM credits c
3189
+ CROSS JOIN LATERAL (${LOT_COVERAGE_LATERAL}) cov
3190
+ WHERE ${BITCOIN_CREDIT_PREDICATE}
3191
+ GROUP BY c.rail, c.currency
3192
+ ORDER BY c.rail, c.currency`
3193
+ );
3194
+ const liability = {
3195
+ depositSats: 0,
3196
+ credits: 0,
3197
+ byRail: [],
3198
+ byCurrency: [],
3199
+ uncoveredCredits: 0,
3200
+ uncoveredMicro: 0,
3201
+ unbackedCredits: 0,
3202
+ unbackedMicro: 0
3203
+ };
3204
+ const byRail = /* @__PURE__ */ new Map();
3205
+ const byCurrency = /* @__PURE__ */ new Map();
3206
+ for (const row of rows) {
3207
+ const sats = toSafeInt(row.owed_sats);
3208
+ const credits = toSafeInt(row.credits);
3209
+ liability.depositSats += sats;
3210
+ liability.credits += credits;
3211
+ liability.uncoveredCredits += toSafeInt(row.uncovered_credits);
3212
+ liability.uncoveredMicro += toSafeInt(row.uncovered_micro);
3213
+ liability.unbackedCredits += toSafeInt(row.unbacked_credits);
3214
+ liability.unbackedMicro += toSafeInt(row.unbacked_micro);
3215
+ const rail = byRail.get(row.rail) ?? { rail: row.rail, sats: 0, credits: 0 };
3216
+ rail.sats += sats;
3217
+ rail.credits += credits;
3218
+ byRail.set(row.rail, rail);
3219
+ const currency = byCurrency.get(row.currency) ?? {
3220
+ currency: row.currency,
3221
+ micro: 0,
3222
+ credits: 0
3223
+ };
3224
+ currency.micro += toSafeInt(row.balance_micro);
3225
+ currency.credits += credits;
3226
+ byCurrency.set(row.currency, currency);
3227
+ }
3228
+ liability.byRail = [...byRail.values()];
3229
+ liability.byCurrency = [...byCurrency.values()];
3230
+ return liability;
3231
+ }
3232
+ /**
3233
+ * Every unfulfilled drain, oldest first (no lock) — the builder batch
3234
+ * job's work list and the outstanding-refund-liability read the
3235
+ * scoped-budget assertion sums over. `pending` means un-parked (Cashu) or
3236
+ * un-sent (Lightning/x402/mpp); `parked` value is already committed to a
3237
+ * caller-locked token, so it is out of the liability the LN send budget
3238
+ * must cover.
3239
+ */
3240
+ async listPendingDrains() {
3241
+ const { rows } = await this.pool.query(
3242
+ `SELECT d.*, ${DRAIN_CREDIT_COLUMNS} FROM credit_drains d
3243
+ JOIN credits c ON c.credit_id = d.credit_id
3244
+ WHERE d.status = 'pending' ORDER BY d.created_at, d.credit_id, d.drain_id`
3245
+ );
3246
+ return rows.map(drainRecordFromJoinRow);
3247
+ }
3248
+ /**
3249
+ * One page of still-`pending` drains on a channel-backed credit, oldest
3250
+ * first (no lock) — the operator's repair queue (internal-review).
3251
+ *
3252
+ * Keyset-paginated for the x402 settlement queue's reason: a wedged row
3253
+ * never self-clears, so a caller that re-issues the same first page would
3254
+ * never see past a `limit`-sized wall of them. The keyset is
3255
+ * `(created_at, credit_id, drain_id)` — `created_at` is immutable, so no
3256
+ * concurrent write can push an unexamined row past the cursor. It is a
3257
+ * BIGINT of epoch milliseconds rather than a `TIMESTAMPTZ`, so the
3258
+ * microsecond-vs-millisecond cursor trap that bit `listSettlements` cannot
3259
+ * arise here: the value round-trips as the integer it already is.
3260
+ *
3261
+ * `createdBeforeMs` is the staleness floor. A healthy cooperative close
3262
+ * moves the drain to `sent` inside the request that opened it, so anything
3263
+ * younger is in flight rather than stuck.
3264
+ *
3265
+ * The method filter is `'tempo'`, and it is the one line here worth a second
3266
+ * look: `init()` migrates the pre-internal-review `'mpp'` spelling away and
3267
+ * `DrainMethod` no longer carries it, so a query naming it matches nothing a
3268
+ * DVM has ever written. It named it anyway until internal-review's rename sweep and
3269
+ * internal-review's live rung caught it independently — the whole operator queue
3270
+ * read empty in production while every route test stayed green, because
3271
+ * those run on `MemoryCreditLedger`, which filters on the typed value. The
3272
+ * two implementations of this one predicate must be read together.
3273
+ *
3274
+ * A written-off row is excluded unless `includeResolved`: the operator has
3275
+ * already decided about it, and leaving it on the live queue would re-serve
3276
+ * a closed decision on every walk.
3277
+ */
3278
+ async listChannelDrains(args) {
3279
+ const params = [args.createdBeforeMs, args.limit];
3280
+ let keyset = "";
3281
+ if (args.after) {
3282
+ params.push(args.after.createdAt, args.after.creditId, args.after.drainId);
3283
+ keyset = ` AND (d.created_at, d.credit_id, d.drain_id) > ($3, $4, $5)`;
3284
+ }
3285
+ const { rows } = await this.pool.query(
3286
+ `SELECT d.*, ${DRAIN_CREDIT_COLUMNS} FROM credit_drains d
3287
+ JOIN credits c ON c.credit_id = d.credit_id
3288
+ WHERE d.status = 'pending'
3289
+ AND d.method = 'tempo'
3290
+ AND c.tempo_channel_id IS NOT NULL
3291
+ AND d.created_at <= $1
3292
+ ${args.includeResolved ? "" : "AND d.written_off_at IS NULL"}
3293
+ ${keyset}
3294
+ ORDER BY d.created_at, d.credit_id, d.drain_id
3295
+ LIMIT $2`,
3296
+ params
3297
+ );
3298
+ return rows.map(drainRecordFromJoinRow);
3299
+ }
3300
+ /**
3301
+ * Record that an operator reviewed a floor-refused channel drain and is not
3302
+ * booking it (internal-review). Books nothing anywhere — no status change, no
3303
+ * balance movement, no platform report.
3304
+ *
3305
+ * A single-statement CAS on `written_off_at IS NULL`, so the first note on
3306
+ * file wins: a second operator asking for the same decision finds one
3307
+ * already recorded and is told so (`written: false`) rather than writing
3308
+ * over a colleague's reason. Only a `pending` drain may be written off — a
3309
+ * terminal one has an answer already.
3310
+ */
3311
+ async writeOffDrain(args) {
3312
+ const { rowCount } = await this.pool.query(
3313
+ `UPDATE credit_drains SET write_off_note = $3, written_off_at = $4
3314
+ WHERE credit_id = $1 AND drain_id = $2
3315
+ AND status = 'pending' AND written_off_at IS NULL`,
3316
+ [args.creditId, args.drainId, args.note ?? null, args.nowMs ?? Date.now()]
3317
+ );
3318
+ const drain = await this.getDrain({ creditId: args.creditId, drainId: args.drainId });
3319
+ if (!drain) return void 0;
3320
+ return { drain, written: (rowCount ?? 0) > 0 };
3321
+ }
3322
+ /**
3323
+ * Drop a recorded write-off, inside the transaction that books the drain.
3324
+ *
3325
+ * This is the reversibility half, and its placement is the whole guarantee:
3326
+ * a reconcile clears the note in the same transaction as the `pending →
3327
+ * sent` CAS, so a ledger leg that rolls back leaves the operator's decision
3328
+ * exactly as it found it. The x402 repair needs an explicit compensating
3329
+ * restore for this because its settlement row lives outside the ledger's
3330
+ * transaction; here the two are one row.
3331
+ */
3332
+ async clearDrainWriteOff(args) {
3333
+ const q = args.tx ?? this.pool;
3334
+ await q.query(
3335
+ `UPDATE credit_drains SET write_off_note = NULL, written_off_at = NULL
3336
+ WHERE credit_id = $1 AND drain_id = $2`,
3337
+ [args.creditId, args.drainId]
3338
+ );
3339
+ }
3340
+ /**
3341
+ * Cancel an unfulfilled Tempo cooperative drain and restore the exact
3342
+ * balance plus rail basis it removed (`pending -> released`). This is only
3343
+ * for a close the channel reconciliation proved cannot pay; ordinary payout
3344
+ * rails never re-credit a liability after it has been handed to a worker.
3345
+ */
3346
+ async releaseDrain(args) {
3347
+ const nowMs = args.nowMs ?? Date.now();
3348
+ const run = async (client, credit) => {
3349
+ const row = await this.readDrain(client, args.creditId, args.drainId);
3350
+ if (!row) {
3351
+ throw new CreditLedgerError(
3352
+ "drain_not_found",
3353
+ `drain ${args.drainId} on credit ${args.creditId} not found`
3354
+ );
3355
+ }
3356
+ if (row.status === "released") {
3357
+ return {
3358
+ drain: drainRecordFromRow(row, credit),
3359
+ credit: await this.snapshotFromRow(credit, client, nowMs),
3360
+ replayed: true
3361
+ };
3362
+ }
3363
+ if (row.status !== "pending") {
3364
+ throw new CreditLedgerError(
3365
+ "invalid_drain_state",
3366
+ `cannot release ${row.method} drain ${args.drainId} from status ${row.status}`
3367
+ );
3368
+ }
3369
+ const { rows } = await client.query(
3370
+ `UPDATE credit_drains SET status = 'released', released_at = $3
3371
+ WHERE credit_id = $1 AND drain_id = $2 AND status = 'pending'
3372
+ RETURNING *`,
3373
+ [args.creditId, args.drainId, nowMs]
3374
+ );
3375
+ if (!rows[0]) {
3376
+ throw new CreditLedgerError(
3377
+ "invalid_drain_state",
3378
+ `drain ${args.drainId} changed while it was being released`
3379
+ );
3380
+ }
3381
+ await client.query(
3382
+ `UPDATE credits
3383
+ SET balance_micro = balance_micro + $2,
3384
+ msats_remaining = msats_remaining + $3,
3385
+ native_remaining = CASE
3386
+ WHEN native_remaining IS NULL OR $4::BIGINT IS NULL THEN native_remaining
3387
+ ELSE native_remaining + $4
3388
+ END
3389
+ WHERE credit_id = $1`,
3390
+ [args.creditId, row.amount_micro, row.drained_msats, row.drained_native]
3391
+ );
3392
+ await this.applyLotDebits(client, row.lot_debits ?? [], 1);
3393
+ const restored = await this.lockCreditRow(client, args.creditId);
3394
+ return {
3395
+ drain: drainRecordFromRow(rows[0], restored),
3396
+ credit: await this.snapshotFromRow(restored, client, nowMs),
3397
+ replayed: false
3398
+ };
3399
+ };
3400
+ if (args.tx) return run(args.tx, await this.lockCreditRow(args.tx, args.creditId));
3401
+ return this.withCreditLock(args.creditId, run);
3402
+ }
3403
+ /**
3404
+ * Attach the parked Cashu token to a pending drain (`pending → parked`).
3405
+ * A single-statement CAS on `status = 'pending'` — no credit lock needed;
3406
+ * the money already left the balance at request time. Idempotent: parking
3407
+ * an already-parked/picked-up drain with the same token returns the
3408
+ * recorded row (the builder's park retry after a lost response); a
3409
+ * different token refuses with `drain_conflict` rather than silently
3410
+ * replacing notes the caller may already hold.
3411
+ *
3412
+ * Pass `tx` to commit the park atomically with the accumulator row swap
3413
+ * the admin route performs alongside it.
3414
+ */
3415
+ async parkDrain(args) {
3416
+ const q = args.tx ?? this.pool;
3417
+ const nowMs = args.nowMs ?? Date.now();
3418
+ const { rows } = await q.query(
3419
+ `UPDATE credit_drains
3420
+ SET status = 'parked', token = $3, parked_at = $4,
3421
+ fulfilment = COALESCE($5::jsonb, fulfilment)
3422
+ WHERE credit_id = $1 AND drain_id = $2 AND status = 'pending' AND method = 'cashu'
3423
+ RETURNING *`,
3424
+ [
3425
+ args.creditId,
3426
+ args.drainId,
3427
+ args.token,
3428
+ nowMs,
3429
+ args.fulfilment ? JSON.stringify(args.fulfilment) : null
3430
+ ]
3431
+ );
3432
+ if (rows.length > 0) return this.rejoin(q, rows[0]);
3433
+ const refusal = await this.refuseTransition(q, args.creditId, args.drainId, {
3434
+ idempotentWhen: (row) => (row.status === "parked" || row.status === "picked_up") && row.token === args.token,
3435
+ verb: "park"
3436
+ });
3437
+ return refusal.drain;
3438
+ }
3439
+ /**
3440
+ * Record that the caller collected the parked token (`parked →
3441
+ * picked_up`). Idempotent on `picked_up` — pickup must survive response
3442
+ * loss, so the caller's re-poll keeps returning the same token either way.
3443
+ *
3444
+ * Returns `replayed` because this is a **terminal** transition and the money
3445
+ * has left at exactly one of these calls (internal-review): the platform drain
3446
+ * report must be emitted by that caller and no other. Pass `tx` to write the
3447
+ * report through the same transaction as the CAS.
3448
+ */
3449
+ async markDrainPickedUp(args) {
3450
+ const q = args.tx ?? this.pool;
3451
+ const nowMs = args.nowMs ?? Date.now();
3452
+ const { rows } = await q.query(
3453
+ `UPDATE credit_drains SET status = 'picked_up', picked_up_at = $3
3454
+ WHERE credit_id = $1 AND drain_id = $2 AND status = 'parked'
3455
+ RETURNING *`,
3456
+ [args.creditId, args.drainId, nowMs]
3457
+ );
3458
+ if (rows.length > 0) return { drain: await this.rejoin(q, rows[0]), replayed: false };
3459
+ return this.refuseTransition(q, args.creditId, args.drainId, {
3460
+ idempotentWhen: (row) => row.status === "picked_up",
3461
+ verb: "mark picked up"
3462
+ });
3463
+ }
3464
+ /**
3465
+ * Record a completed send-back (`pending → sent`) for the non-parked
3466
+ * methods — the builder's batch job paid the Lightning invoice or shipped
3467
+ * the on-chain transfer and posts the settlement reference. Idempotent on
3468
+ * `sent` (the recorded `sent_ref` wins; a retry after a lost response
3469
+ * carries the same reference or none at all).
3470
+ *
3471
+ * `replayed` and `tx` carry the same meaning as on {@link markDrainPickedUp},
3472
+ * and matter more here: the builder's batch job re-POSTs `mark-drain-sent`
3473
+ * on every recovery pass, so the replay is the routine case rather than the
3474
+ * exception.
3475
+ */
3476
+ async markDrainSent(args) {
3477
+ const q = args.tx ?? this.pool;
3478
+ const nowMs = args.nowMs ?? Date.now();
3479
+ const { rows } = await q.query(
3480
+ `UPDATE credit_drains SET status = 'sent', sent_ref = $3, sent_at = $4
3481
+ WHERE credit_id = $1 AND drain_id = $2 AND status = 'pending' AND method <> 'cashu'
3482
+ RETURNING *`,
3483
+ [args.creditId, args.drainId, JSON.stringify(args.sentRef), nowMs]
3484
+ );
3485
+ if (rows.length > 0) return { drain: await this.rejoin(q, rows[0]), replayed: false };
3486
+ return this.refuseTransition(q, args.creditId, args.drainId, {
3487
+ idempotentWhen: (row) => row.status === "sent",
3488
+ verb: "mark sent"
3489
+ });
3490
+ }
3491
+ /**
3492
+ * Append a countersigned reclaim-event receipt to the drain's evidence
3493
+ * trail. A single-statement JSONB append — receipts are emitted after the
3494
+ * state transition they attest, and losing one never loses money (the
3495
+ * transition is the source of truth; the receipt is the proof the caller
3496
+ * can carry away).
3497
+ */
3498
+ async appendDrainReceipt(args) {
3499
+ const q = args.tx ?? this.pool;
3500
+ await q.query(
3501
+ `UPDATE credit_drains SET receipts = receipts || $3::jsonb
3502
+ WHERE credit_id = $1 AND drain_id = $2`,
3503
+ [args.creditId, args.drainId, JSON.stringify([args.receipt])]
3504
+ );
3505
+ }
3506
+ // ── Internals ─────────────────────────────────────────────────────────
3507
+ async readDrain(q, creditId, drainId) {
3508
+ const { rows } = await q.query(
3509
+ `SELECT * FROM credit_drains WHERE credit_id = $1 AND drain_id = $2`,
3510
+ [creditId, drainId]
3511
+ );
3512
+ return rows[0];
3513
+ }
3514
+ /** Re-read the credit join fields for a freshly-updated drain row. */
3515
+ async rejoin(q, row) {
3516
+ const { rows } = await q.query(
3517
+ `SELECT currency, caller_pubkey, tempo_channel_id, x402_channel_id
3518
+ FROM credits WHERE credit_id = $1`,
3519
+ [row.credit_id]
3520
+ );
3521
+ return drainRecordFromRow(row, rows[0]);
3522
+ }
3523
+ /** Shared refusal tail for the CAS transitions: idempotent replay or typed error. */
3524
+ async refuseTransition(q, creditId, drainId, opts) {
3525
+ const row = await this.readDrain(q, creditId, drainId);
3526
+ if (!row) {
3527
+ throw new CreditLedgerError(
3528
+ "drain_not_found",
3529
+ `drain ${drainId} not found on credit ${creditId}`
3530
+ );
3531
+ }
3532
+ if (opts.idempotentWhen(row)) return { drain: await this.rejoin(q, row), replayed: true };
3533
+ throw new CreditLedgerError(
3534
+ row.status === "parked" || row.status === "picked_up" || row.status === "sent" ? "drain_conflict" : "invalid_drain_state",
3535
+ `cannot ${opts.verb} drain ${drainId}: status is ${row.status} (method ${row.method})`
3536
+ );
3537
+ }
3538
+ /**
3539
+ * Price a reclaim in kind and say which lots it takes (internal-review).
3540
+ *
3541
+ * The debits are returned whether or not the reclaim can be priced, and the
3542
+ * caller applies them either way: the money is leaving the credit, so the
3543
+ * lots have to shrink with it or the deposit-liability rollup would keep
3544
+ * counting a balance that is gone.
3545
+ *
3546
+ * `owedSats` is `null` where {@link isInKindDepletion} refuses — the lots
3547
+ * are short of the balance, or the covering lots carry no sats basis (a
3548
+ * credit funded before internal-review). That row is priced off a live rate by the
3549
+ * admin surface instead, which is what *every* row did before this change,
3550
+ * so the fallback is the old behaviour rather than a new failure mode.
3551
+ *
3552
+ * What it publishes is **net of the delivery reserve** (internal-review): handing a
3553
+ * refund over costs a mint fee and, when the notes have to be minted just in
3554
+ * time, a Lightning hop. The caller carries that cost per internal-review, and a
3555
+ * flat reserve is how they carry it without the figure moving — an
3556
+ * actual-fee true-up could only be applied after the promise was made. The
3557
+ * debits stay gross because they are denominated in micro and are what
3558
+ * `releaseDrain` restores; only the sats figure is netted.
3559
+ */
3560
+ async reclaimInKind(q, creditId, amountMicro) {
3561
+ const depletion = depleteLots(await this.openLots(q, creditId), amountMicro);
3562
+ const inKind = isInKindDepletion(depletion, amountMicro);
3563
+ return {
3564
+ owedSats: inKind ? netOwedSats(depletion.satsOwed) : null,
3565
+ grossSats: inKind ? depletion.satsOwed : null,
3566
+ debits: depletion.debits
3567
+ };
3568
+ }
3569
+ /**
3570
+ * This credit's undepleted funding lots, oldest first (internal-review).
3571
+ *
3572
+ * Ordered in SQL on the same `(created_at, lot_id)` key the partial index
3573
+ * carries, so FIFO is the index's own order rather than something a reader
3574
+ * re-establishes. Depleted lots stay on the table as the deposit's audit
3575
+ * trail and are excluded here — they price nothing.
3576
+ */
3577
+ async openLots(q, creditId) {
3578
+ const { rows } = await q.query(
3579
+ `SELECT * FROM credit_funding_lots
3580
+ WHERE credit_id = $1 AND remaining_micro > 0
3581
+ ORDER BY created_at, lot_id`,
3582
+ [creditId]
3583
+ );
3584
+ return rows.map(fundingLotFromRow);
3585
+ }
3586
+ /**
3587
+ * Move `debits` out of (`sign: -1`) or back into (`sign: 1`) their lots.
3588
+ *
3589
+ * Always runs under the credit row lock the caller already holds, on that
3590
+ * caller's handle, so the read-then-write is serialized per credit exactly
3591
+ * as every other balance move is.
3592
+ */
3593
+ async applyLotDebits(q, debits, sign) {
3594
+ for (const debit of debits) {
3595
+ await q.query(
3596
+ `UPDATE credit_funding_lots
3597
+ SET remaining_micro = GREATEST(LEAST(remaining_micro + $2, credited_micro), 0)
3598
+ WHERE lot_id = $1`,
3599
+ [debit.lotId, sign * debit.micro]
3600
+ );
3601
+ }
3602
+ }
3603
+ /** Shared settle/release state machine — see the public JSDoc on each verb. */
3604
+ async resolveDraw(args) {
3605
+ if (args.tx) {
3606
+ const credit = await this.lockCreditRow(args.tx, args.creditId);
3607
+ return this.resolveDrawLocked(args.tx, credit, args);
3608
+ }
3609
+ return this.withCreditLock(
3610
+ args.creditId,
3611
+ (client, credit) => this.resolveDrawLocked(client, credit, args)
3612
+ );
3613
+ }
3614
+ async resolveDrawLocked(client, credit, args) {
3615
+ const nowMs = args.nowMs ?? Date.now();
3616
+ const draw = await this.readDraw(client, args.creditId, args.drawId);
3617
+ if (!draw) {
3618
+ throw new CreditLedgerError(
3619
+ "draw_not_found",
3620
+ `draw ${args.drawId} not found on credit ${args.creditId}`
3621
+ );
3622
+ }
3623
+ let balanceMicro = toSafeInt(credit.balance_micro);
3624
+ let replayed = false;
3625
+ if (draw.status === args.to) {
3626
+ replayed = true;
3627
+ } else if (draw.status !== "pending") {
3628
+ throw new CreditLedgerError(
3629
+ "invalid_draw_state",
3630
+ `cannot ${args.to === "settled" ? "settle" : "release"} draw ${args.drawId}: status is ${draw.status}`,
3631
+ { currentStatus: draw.status }
3632
+ );
3633
+ } else {
3634
+ await client.query(
3635
+ `UPDATE credit_draws SET status = $3, resolved_at = $4
3636
+ WHERE credit_id = $1 AND draw_id = $2`,
3637
+ [args.creditId, args.drawId, args.to, nowMs]
3638
+ );
3639
+ if (args.to === "settled") {
3640
+ const amountMicro = toSafeInt(draw.amount_micro);
3641
+ balanceMicro -= amountMicro;
3642
+ const forecastMsats = toSafeInt(draw.draw_msats);
3643
+ let settledMsats = forecastMsats;
3644
+ if (isNonChannelBitcoinRail(credit.rail)) {
3645
+ const depletion = depleteLots(await this.openLots(client, args.creditId), amountMicro);
3646
+ settledMsats = inKindDrawMsats(depletion, amountMicro) ?? forecastMsats;
3647
+ await this.applyLotDebits(client, depletion.debits, -1);
3648
+ }
3649
+ if (settledMsats !== forecastMsats) {
3650
+ await client.query(
3651
+ `UPDATE credit_draws SET draw_msats = $3, allocated_msats = $4
3652
+ WHERE credit_id = $1 AND draw_id = $2`,
3653
+ [args.creditId, args.drawId, settledMsats, forecastMsats]
3654
+ );
3655
+ draw.draw_msats = String(settledMsats);
3656
+ draw.allocated_msats = String(forecastMsats);
3657
+ }
3658
+ await client.query(
3659
+ `UPDATE credits
3660
+ SET balance_micro = $2,
3661
+ msats_remaining = GREATEST(msats_remaining - $3, 0),
3662
+ native_remaining = CASE WHEN native_remaining IS NULL THEN NULL
3663
+ ELSE GREATEST(native_remaining - $4, 0) END
3664
+ WHERE credit_id = $1`,
3665
+ [args.creditId, balanceMicro, settledMsats, Number(draw.draw_native ?? 0)]
3666
+ );
3667
+ }
3668
+ }
3669
+ const availableMicro = credit.status === "unbacked" ? 0 : balanceMicro - (await this.pendingSums(client, args.creditId)).micro;
3670
+ const resolution = {
3671
+ ...drawResultFromRow(draw, credit),
3672
+ status: args.to,
3673
+ replayed,
3674
+ availableMicro,
3675
+ resolvedAt: draw.status === "pending" ? nowMs : Number(draw.resolved_at ?? nowMs)
3676
+ };
3677
+ if (!replayed && args.to === "released" && resolution.amountMicro > 0 && args.releaseOutbox) {
3678
+ await args.releaseOutbox.enqueue(client, {
3679
+ dvmId: args.releaseOutbox.dvmId,
3680
+ creditId: resolution.creditId,
3681
+ drawId: resolution.drawId,
3682
+ amountMicro: resolution.amountMicro,
3683
+ creditCurrency: credit.currency,
3684
+ ledgerSeq: resolution.ledgerSeq,
3685
+ releasedAt: resolution.resolvedAt
3686
+ });
3687
+ }
3688
+ return resolution;
3689
+ }
3690
+ /**
3691
+ * Run `fn` inside a transaction holding `SELECT … FOR UPDATE` on the credit
3692
+ * row — the lock every `credit_draws` write must be under. Commits on
3693
+ * success (harmless for read-only replay paths), rolls back on throw.
3694
+ */
3695
+ async withCreditLock(creditId, fn) {
3696
+ const client = await this.pool.connect();
3697
+ try {
3698
+ await client.query("BEGIN");
3699
+ const credit = await this.lockCreditRow(client, creditId);
3700
+ const result = await fn(client, credit);
3701
+ await client.query("COMMIT");
3702
+ return result;
3703
+ } catch (err) {
3704
+ try {
3705
+ await client.query("ROLLBACK");
3706
+ } catch {
3707
+ }
3708
+ throw err;
3709
+ } finally {
3710
+ client.release();
3711
+ }
3712
+ }
3713
+ /** `SELECT … FOR UPDATE` the credit row on `q` (which must be inside a transaction). */
3714
+ async lockCreditRow(q, creditId) {
3715
+ const { rows } = await q.query(
3716
+ `SELECT * FROM credits WHERE credit_id = $1 FOR UPDATE`,
3717
+ [creditId]
3718
+ );
3719
+ if (rows.length === 0) {
3720
+ throw new CreditLedgerError("credit_not_found", `credit ${creditId} not found`);
3721
+ }
3722
+ return rows[0];
3723
+ }
3724
+ async readDraw(q, creditId, drawId) {
3725
+ const { rows } = await q.query(
3726
+ `SELECT * FROM credit_draws WHERE credit_id = $1 AND draw_id = $2`,
3727
+ [creditId, drawId]
3728
+ );
3729
+ return rows[0];
3730
+ }
3731
+ /**
3732
+ * Held-but-unresolved totals for a credit, in all three units at once — the
3733
+ * fiat micro that defines available balance plus the rail-value remainders
3734
+ * a new draw allocates against (internal-review). One round-trip, since every
3735
+ * caller needs the micro sum anyway.
3736
+ */
3737
+ async pendingSums(q, creditId) {
3738
+ const { rows } = await q.query(
3739
+ `SELECT COALESCE(SUM(amount_micro), 0) AS micro,
3740
+ COALESCE(SUM(draw_msats), 0) AS msats,
3741
+ COALESCE(SUM(draw_native), 0) AS native
3742
+ FROM credit_draws WHERE credit_id = $1 AND status = 'pending'`,
3743
+ [creditId]
3744
+ );
3745
+ return {
3746
+ micro: toSafeInt(rows[0].micro),
3747
+ msats: toSafeInt(rows[0].msats),
3748
+ native: toSafeInt(rows[0].native)
3749
+ };
3750
+ }
3751
+ async snapshotFromRow(row, q, nowMs) {
3752
+ const balanceMicro = toSafeInt(row.balance_micro);
3753
+ const expiryMs = toSafeInt(row.expiry_ms);
3754
+ const pending = await this.pendingSums(q, row.credit_id);
3755
+ const live = row.status === "active";
3756
+ return {
3757
+ creditId: row.credit_id,
3758
+ callerPubkey: row.caller_pubkey,
3759
+ currency: row.currency,
3760
+ balanceMicro,
3761
+ availableMicro: live ? balanceMicro - pending.micro : 0,
3762
+ nativeAvailable: row.native_remaining === null ? null : live ? Math.max(0, toSafeInt(row.native_remaining) - pending.native) : 0,
3763
+ expiryMs,
3764
+ expired: expiryMs <= nowMs,
3765
+ status: row.status === "unbacked" ? "unbacked" : "active",
3766
+ lastLedgerSeq: toSafeInt(row.last_ledger_seq),
3767
+ createdAt: toSafeInt(row.created_at),
3768
+ rail: row.rail,
3769
+ tempoChannelId: row.tempo_channel_id ?? null,
3770
+ x402ChannelId: row.x402_channel_id
3771
+ };
3772
+ }
3773
+ async readTempoCreditLosses(q, args) {
3774
+ const { rows } = await q.query(
3775
+ `SELECT * FROM tempo_credit_losses
3776
+ WHERE ($1::text IS NULL OR channel_id = $1)
3777
+ ORDER BY observed_at DESC, credit_id
3778
+ LIMIT $2`,
3779
+ [args.channelId ?? null, args.limit ?? 1e3]
3780
+ );
3781
+ return rows.map(tempoCreditLossFromRow);
3782
+ }
3783
+ async readX402CreditLosses(q, args) {
3784
+ const { rows } = await q.query(
3785
+ `SELECT * FROM x402_credit_losses
3786
+ WHERE ($1::text IS NULL OR channel_id = $1)
3787
+ ORDER BY observed_at DESC, credit_id
3788
+ LIMIT $2`,
3789
+ [args.channelId ?? null, args.limit ?? 1e3]
3790
+ );
3791
+ return rows.map(x402CreditLossFromRow);
3792
+ }
3793
+ };
3794
+ var DRAIN_METHODS = ["cashu", "x402", "tempo"];
3795
+ function isDrainMethod(value) {
3796
+ return DRAIN_METHODS.includes(value);
3797
+ }
3798
+ var FUNDING_RAILS = {
3799
+ cashu: true,
3800
+ x402: true,
3801
+ tempo: true,
3802
+ lightning: true
3803
+ };
3804
+ function isFundingRail(rail) {
3805
+ return typeof rail === "string" && Object.hasOwn(FUNDING_RAILS, rail);
3806
+ }
3807
+ function assertFundingBasis(basis) {
3808
+ if (!basis) {
3809
+ throw new CreditLedgerError("invalid_basis", "basis is required (internal-review)");
3810
+ }
3811
+ if (!isFundingRail(basis.rail)) {
3812
+ throw new CreditLedgerError(
3813
+ "invalid_basis",
3814
+ `basis.rail must be one of ${Object.keys(FUNDING_RAILS).join(", ")} (got ${String(basis.rail)})`
3815
+ );
3816
+ }
3817
+ if (!Number.isSafeInteger(basis.paidMsats) || basis.paidMsats < 0) {
3818
+ throw new CreditLedgerError(
3819
+ "invalid_basis",
3820
+ `basis.paidMsats must be a non-negative safe integer (got ${String(basis.paidMsats)})`
3821
+ );
3822
+ }
3823
+ return basis;
3824
+ }
3825
+ function allocateDrawValue(args) {
3826
+ const share = (remaining) => {
3827
+ if (args.availableMicro <= 0 || args.amountMicro <= 0 || remaining <= 0) return 0;
3828
+ return Math.min(remaining, Math.round(remaining * args.amountMicro / args.availableMicro));
3829
+ };
3830
+ return {
3831
+ drawMsats: share(args.availableMsats),
3832
+ drawNative: args.availableNative === null ? null : share(args.availableNative)
3833
+ };
3834
+ }
3835
+ function growthRailValue(args) {
3836
+ const { earmark } = args;
3837
+ if (!earmark) return allocateDrawValue(args);
3838
+ return {
3839
+ drawMsats: Math.min(Math.max(0, earmark.drawMsats), args.availableMsats),
3840
+ drawNative: args.availableNative === null ? null : Math.min(Math.max(0, earmark.drawNative ?? 0), args.availableNative)
3841
+ };
3842
+ }
3843
+ function clipEarmark(earmark, grantMicro, addAmountMicro) {
3844
+ if (!earmark || grantMicro >= addAmountMicro || addAmountMicro <= 0) return earmark;
3845
+ const scale = (value) => Math.min(value, Math.ceil(value * grantMicro / addAmountMicro));
3846
+ return {
3847
+ drawMsats: scale(earmark.drawMsats),
3848
+ drawNative: earmark.drawNative === null ? null : scale(earmark.drawNative)
3849
+ };
3850
+ }
3851
+ var CreditLedgerError = class extends Error {
3852
+ code;
3853
+ details;
3854
+ constructor(code, message, details = {}, opts) {
3855
+ super(message, opts);
3856
+ this.name = "CreditLedgerError";
3857
+ this.code = code;
3858
+ this.details = details;
3859
+ }
3860
+ };
3861
+ function x402SettlementPending(creditId, refund) {
3862
+ const drainId = X402_WEDGED_SETTLEMENT_STATUSES.includes(refund.status) && refund.effectId.startsWith(`${creditId}:`) ? refund.effectId.slice(creditId.length + 1) : "";
3863
+ return new CreditLedgerError(
3864
+ "settlement_pending",
3865
+ `credit ${creditId} is bound to an x402 channel whose refund ${refund.settlementId} is ${refund.status}`,
3866
+ {
3867
+ settlementId: refund.settlementId,
3868
+ settlementStatus: refund.status,
3869
+ ...drainId ? { blockingDrainId: drainId } : {}
3870
+ }
3871
+ );
3872
+ }
3873
+ function terminalCreditError(creditId, status) {
3874
+ return new CreditLedgerError(
3875
+ "credit_unbacked",
3876
+ `credit ${creditId} is terminal (${status}); its Tempo backing finalized before the DVM was protected`
3877
+ );
3878
+ }
3879
+ function isUniqueViolation(err) {
3880
+ return typeof err === "object" && err !== null && err.code === "23505";
3881
+ }
3882
+ var DRAW_WITH_BASIS_SELECT = `
3883
+ SELECT d.*, c.rail, c.native_asset, c.mint, c.funding_ref, c.currency
3884
+ FROM credit_draws d
3885
+ LEFT JOIN credits c ON c.credit_id = d.credit_id`;
3886
+ var DRAIN_CREDIT_COLUMNS = "c.currency, c.caller_pubkey, c.tempo_channel_id, c.x402_channel_id";
3887
+ var BITCOIN_CREDIT_PREDICATE = `c.rail IN ('cashu', 'lightning')
3888
+ AND c.tempo_channel_id IS NULL
3889
+ AND c.x402_channel_id IS NULL
3890
+ AND c.balance_micro > 0`;
3891
+ var LOT_COVERAGE_LATERAL = `SELECT
3892
+ COALESCE(SUM(l.remaining_micro), 0) AS covered_micro,
3893
+ COALESCE(SUM(l.remaining_micro) FILTER (WHERE l.sats_funded = 0), 0) AS basisless_micro,
3894
+ COALESCE(SUM(l.sats_funded * l.remaining_micro / NULLIF(l.credited_micro, 0)), 0) AS owed_sats
3895
+ FROM credit_funding_lots l
3896
+ WHERE l.credit_id = c.credit_id AND l.remaining_micro > 0`;
3897
+ var IN_KIND_PREDICATE = `cov.covered_micro >= c.balance_micro AND cov.basisless_micro = 0`;
3898
+ function toSafeInt(value) {
3899
+ const n = typeof value === "number" ? value : Number(value);
3900
+ if (!Number.isSafeInteger(n)) {
3901
+ throw new Error(`credit ledger: BIGINT value ${value} outside safe integer range`);
3902
+ }
3903
+ return n;
3904
+ }
3905
+ function assertAmount(amountMicro, opts) {
3906
+ if (!Number.isSafeInteger(amountMicro) || amountMicro < opts.min) {
3907
+ throw new CreditLedgerError(
3908
+ "invalid_amount",
3909
+ `amountMicro must be a safe integer \u2265 ${opts.min} (got ${amountMicro})`
3910
+ );
3911
+ }
3912
+ }
3913
+ function creditBasisFromRow(row) {
3914
+ return {
3915
+ rail: row.rail,
3916
+ nativeAsset: row.native_asset,
3917
+ mint: row.mint,
3918
+ fundingRef: row.funding_ref,
3919
+ creditCurrency: row.currency
3920
+ };
3921
+ }
3922
+ function railValueFromRow(row, basis) {
3923
+ return {
3924
+ drawMsats: toSafeInt(row.draw_msats),
3925
+ drawNative: row.draw_native === null ? null : toSafeInt(row.draw_native),
3926
+ ...creditBasisFromRow(basis)
3927
+ };
3928
+ }
3929
+ function drawResultFromRow(row, basis) {
3930
+ return {
3931
+ creditId: row.credit_id,
3932
+ drawId: row.draw_id,
3933
+ amountMicro: toSafeInt(row.amount_micro),
3934
+ balanceAfterMicro: toSafeInt(row.balance_after_micro),
3935
+ ledgerSeq: toSafeInt(row.ledger_seq),
3936
+ status: row.status,
3937
+ replayed: false,
3938
+ railValue: railValueFromRow(row, basis),
3939
+ resolvedAt: row.resolved_at === null ? null : toSafeInt(row.resolved_at)
3940
+ };
3941
+ }
3942
+ function drawRecordFromRow(row, basis) {
3943
+ const { replayed: _replayed, ...rest } = drawResultFromRow(row, basis);
3944
+ return {
3945
+ ...rest,
3946
+ jobId: row.job_id,
3947
+ createdAt: toSafeInt(row.created_at),
3948
+ allocatedMsats: row.allocated_msats === null ? rest.railValue.drawMsats : toSafeInt(row.allocated_msats)
3949
+ };
3950
+ }
3951
+ function tempoCreditLossFromRow(row) {
3952
+ return {
3953
+ creditId: row.credit_id,
3954
+ channelId: row.channel_id,
3955
+ callerPubkey: row.caller_pubkey,
3956
+ currency: row.currency,
3957
+ formerBalanceMicro: toSafeInt(row.former_balance_micro),
3958
+ settledOnChainNative: BigInt(row.settled_on_chain_native),
3959
+ highestVoucherNative: BigInt(row.highest_voucher_native),
3960
+ consumedServiceMicro: toSafeInt(row.consumed_service_micro),
3961
+ consumedNative: BigInt(row.consumed_native),
3962
+ observedAt: toSafeInt(row.observed_at)
3963
+ };
3964
+ }
3965
+ function x402CreditLossFromRow(row) {
3966
+ return {
3967
+ creditId: row.credit_id,
3968
+ channelId: row.channel_id,
3969
+ callerPubkey: row.caller_pubkey,
3970
+ currency: row.currency,
3971
+ formerBalanceMicro: toSafeInt(row.former_balance_micro),
3972
+ channelBalanceNative: BigInt(row.channel_balance_native),
3973
+ totalClaimedNative: BigInt(row.total_claimed_native),
3974
+ consumedServiceMicro: toSafeInt(row.consumed_service_micro),
3975
+ observedAt: toSafeInt(row.observed_at)
3976
+ };
3977
+ }
3978
+ function drainRecordFromRow(row, credit) {
3979
+ return {
3980
+ creditId: row.credit_id,
3981
+ drainId: row.drain_id,
3982
+ callerPubkey: credit.caller_pubkey,
3983
+ currency: credit.currency,
3984
+ method: row.method,
3985
+ // One column or the other, never both: a credit binds to at most one
3986
+ // channel, and the two rails' ids never coexist on a row.
3987
+ channelId: credit.tempo_channel_id ?? credit.x402_channel_id,
3988
+ payout: row.payout,
3989
+ amountMicro: toSafeInt(row.amount_micro),
3990
+ drainedNative: row.drained_native === null ? null : toSafeInt(row.drained_native),
3991
+ owedSats: row.owed_sats === null ? null : toSafeInt(row.owed_sats),
3992
+ balanceAfterMicro: toSafeInt(row.balance_after_micro),
3993
+ ledgerSeq: toSafeInt(row.ledger_seq),
3994
+ status: row.status,
3995
+ token: row.token,
3996
+ sentRef: row.sent_ref,
3997
+ fulfilment: row.fulfilment,
3998
+ receipts: row.receipts,
3999
+ createdAt: toSafeInt(row.created_at),
4000
+ parkedAt: row.parked_at === null ? null : toSafeInt(row.parked_at),
4001
+ pickedUpAt: row.picked_up_at === null ? null : toSafeInt(row.picked_up_at),
4002
+ sentAt: row.sent_at === null ? null : toSafeInt(row.sent_at),
4003
+ releasedAt: row.released_at === null ? null : toSafeInt(row.released_at),
4004
+ writeOffNote: row.write_off_note,
4005
+ writtenOffAt: row.written_off_at === null ? null : toSafeInt(row.written_off_at)
4006
+ };
4007
+ }
4008
+ function drainRecordFromJoinRow(row) {
4009
+ return drainRecordFromRow(row, {
4010
+ currency: row.currency,
4011
+ caller_pubkey: row.caller_pubkey,
4012
+ tempo_channel_id: row.tempo_channel_id,
4013
+ x402_channel_id: row.x402_channel_id
4014
+ });
4015
+ }
4016
+ function fundingLotFromRow(row) {
4017
+ return {
4018
+ lotId: row.lot_id,
4019
+ creditId: row.credit_id,
4020
+ rail: row.rail,
4021
+ satsFunded: toSafeInt(row.sats_funded),
4022
+ creditedMicro: toSafeInt(row.credited_micro),
4023
+ remainingMicro: toSafeInt(row.remaining_micro),
4024
+ fundingRef: row.funding_ref,
4025
+ createdAt: toSafeInt(row.created_at)
4026
+ };
4027
+ }
4028
+ function fundingRecordFromRow(row) {
4029
+ return {
4030
+ creditId: row.credit_id,
4031
+ fundId: row.fund_id,
4032
+ amountMicro: toSafeInt(row.amount_micro),
4033
+ rail: row.rail,
4034
+ createdAt: toSafeInt(row.created_at),
4035
+ callerPubkey: row.caller_pubkey,
4036
+ balanceAfterMicro: row.balance_after_micro === null ? null : toSafeInt(row.balance_after_micro),
4037
+ ledgerSeq: row.ledger_seq === null ? null : toSafeInt(row.ledger_seq),
4038
+ receipt: row.receipt
4039
+ };
4040
+ }
4041
+ function invoiceRecordFromRow(row) {
4042
+ return {
4043
+ creditId: row.credit_id,
4044
+ fundId: row.fund_id,
4045
+ callerPubkey: row.caller_pubkey,
4046
+ currency: row.currency,
4047
+ amountMicro: toSafeInt(row.amount_micro),
4048
+ amountMsats: toSafeInt(row.amount_msats),
4049
+ paymentHash: row.payment_hash,
4050
+ bolt11: row.bolt11,
4051
+ status: row.status,
4052
+ blockedReason: row.blocked_reason,
4053
+ reconciledCreditId: row.reconciled_credit_id,
4054
+ reconciledFundId: row.reconciled_fund_id,
4055
+ resolvedAt: row.resolved_at === null ? null : toSafeInt(row.resolved_at),
4056
+ writeOffNote: row.write_off_note,
4057
+ expiresAt: toSafeInt(row.expires_at),
4058
+ createdAt: toSafeInt(row.created_at),
4059
+ settledAt: row.settled_at === null ? null : toSafeInt(row.settled_at)
4060
+ };
4061
+ }
4062
+ var DEFAULT_EXPIRY_SWEEP_LIMIT = 200;
4063
+ function expiryReleaseId(expiryMs, seq) {
4064
+ return `xrel:${expiryMs}:${seq}`;
4065
+ }
4066
+ function expiryReleaseFromRow(row) {
4067
+ return {
4068
+ creditId: row.credit_id,
4069
+ releaseId: row.release_id,
4070
+ callerPubkey: row.caller_pubkey,
4071
+ currency: row.currency,
4072
+ rail: row.rail,
4073
+ amountMicro: toSafeInt(row.amount_micro),
4074
+ expiryMs: toSafeInt(row.expiry_ms),
4075
+ releasedAt: toSafeInt(row.released_at),
4076
+ reversedAt: row.reversed_at === null ? null : toSafeInt(row.reversed_at)
4077
+ };
4078
+ }
4079
+ function expiryReleaseReport(dvmId, row) {
4080
+ const release = expiryReleaseFromRow(row);
4081
+ return {
4082
+ dvmId,
4083
+ creditId: release.creditId,
4084
+ releaseId: release.releaseId,
4085
+ callerPubkey: release.callerPubkey,
4086
+ rail: release.rail,
4087
+ amountMicro: release.amountMicro,
4088
+ creditCurrency: release.currency,
4089
+ expiryMs: release.expiryMs,
4090
+ releasedAt: release.releasedAt,
4091
+ reversedAt: release.reversedAt
4092
+ };
4093
+ }
4094
+
4095
+ export {
4096
+ isNonChannelBitcoinRail,
4097
+ depleteLots,
4098
+ inKindDrawMsats,
4099
+ lotOwedSats,
4100
+ DRAIN_DELIVERY_RESERVE_SATS,
4101
+ netOwedSats,
4102
+ fifoOrder,
4103
+ isInKindDepletion,
4104
+ X402SettlementRaceError,
4105
+ X402RelaySubmissionLockError,
4106
+ X402_WEDGED_SETTLEMENT_STATUSES,
4107
+ X402_RESOLVED_SETTLEMENT_STATUSES,
4108
+ X402_SPEND_BLOCKING_SETTLEMENT_STATUSES,
4109
+ PostgresX402ChannelStorage,
4110
+ CreditLedger,
4111
+ DRAIN_METHODS,
4112
+ isDrainMethod,
4113
+ FUNDING_RAILS,
4114
+ isFundingRail,
4115
+ assertFundingBasis,
4116
+ allocateDrawValue,
4117
+ growthRailValue,
4118
+ clipEarmark,
4119
+ CreditLedgerError,
4120
+ x402SettlementPending
4121
+ };