@dvmkit/sdk 0.2.0-rc.9 → 0.3.0-rc.9

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 (58) hide show
  1. package/README.md +2 -0
  2. package/dist/{chunk-V7EVFLAK.js → chunk-2D75MNNG.js} +154 -523
  3. package/dist/{chunk-BIFLRKMO.js → chunk-2KGG5ORW.js} +1 -1
  4. package/dist/{chunk-5URG56JJ.js → chunk-A4CM2BD3.js} +18 -2
  5. package/dist/{chunk-FT6HTUM4.js → chunk-AJNFXR7J.js} +1 -1
  6. package/dist/{chunk-S3XAHZQY.js → chunk-COYKJRMM.js} +24 -3
  7. package/dist/{chunk-UP2F5RRT.js → chunk-D7YQYLLM.js} +1 -4
  8. package/dist/{chunk-OMIQMMME.js → chunk-FPOLJRBF.js} +3 -3
  9. package/dist/{chunk-QP53RWAD.js → chunk-HHTL3ZFT.js} +9 -6
  10. package/dist/{chunk-PCUQZDZA.js → chunk-LTAIBLS2.js} +537 -321
  11. package/dist/{chunk-JDT5LCJC.js → chunk-MD3H3BF7.js} +1 -1
  12. package/dist/{chunk-KSQEFVFJ.js → chunk-MMCE7XSR.js} +1 -1
  13. package/dist/{chunk-MRAGS5VP.js → chunk-N5B66UOS.js} +4 -16
  14. package/dist/{chunk-2ATUAUAO.js → chunk-O6D72KYA.js} +2 -2
  15. package/dist/{chunk-KXCKFIJI.js → chunk-QX52F6IB.js} +1 -1
  16. package/dist/{chunk-PHHAYRQV.js → chunk-U5VNEALI.js} +23 -10
  17. package/dist/{chunk-JLXYOV4Y.js → chunk-V5ZRATJH.js} +1 -1
  18. package/dist/{chunk-6GRIKOFB.js → chunk-VA7GT6C6.js} +5 -16
  19. package/dist/{chunk-O2X2CCKH.js → chunk-VIVXB4CD.js} +1 -1
  20. package/dist/{chunk-L67WTZX2.js → chunk-XMDQOXHO.js} +10 -33
  21. package/dist/{chunk-QT4ONTST.js → chunk-XRLKGCAW.js} +1 -1
  22. package/dist/{chunk-KMZXTBLA.js → chunk-XZ7LW4S5.js} +0 -1
  23. package/dist/{chunk-XQXJKJ3P.js → chunk-YO56VCTQ.js} +0 -4
  24. package/dist/{credit-ledger-5ZEJRI46.js → credit-ledger-MHA652TV.js} +2 -2
  25. package/dist/{credit-menu-D4Gcdgc4.d.ts → credit-menu-BlCL7gZx.d.ts} +175 -533
  26. package/dist/{fx-B0SLBe5x.d.ts → fx-DhyT1Cjh.d.ts} +1 -1
  27. package/dist/index.d.ts +5 -17
  28. package/dist/internal/caller.d.ts +109 -698
  29. package/dist/internal/caller.js +10 -12
  30. package/dist/internal/server.d.ts +17 -42
  31. package/dist/internal/server.js +16 -15
  32. package/dist/{job-store-B2uZvga4.d.ts → job-store-D3CcZp_g.d.ts} +6 -10
  33. package/dist/{lightning-backend-CQBnQgsT.d.ts → lightning-backend-BhsyrBLa.d.ts} +1 -15
  34. package/dist/{memory-credit-ledger-ZOH6C3N4.js → memory-credit-ledger-BYBATRN3.js} +3 -3
  35. package/dist/{mpp-secret-state-WNAQQ6K4.js → mpp-secret-state-B5GCILVH.js} +1 -1
  36. package/dist/{mpp-setup-IOJBF7DB.js → mpp-setup-MPP3ST5X.js} +1 -1
  37. package/dist/{payout-reporter-5PIRYFVQ.js → payout-reporter-KEMUXEUO.js} +2 -2
  38. package/dist/{postgres-consumed-credential-store-ISRHBMOU.js → postgres-consumed-credential-store-6N2V5MJN.js} +1 -1
  39. package/dist/postgres-job-store-VE2JRUEC.js +7 -0
  40. package/dist/postgres-kv-store-DXGTKXLU.js +7 -0
  41. package/dist/{postgres-replay-store-IZFLTTAC.js → postgres-replay-store-ZF4JNZOW.js} +2 -2
  42. package/dist/{processed-payment-store-FIDI3RNH.js → processed-payment-store-GKMAWOM3.js} +2 -2
  43. package/dist/revenue-reporter-O75BGYFK.js +7 -0
  44. package/dist/server/index.d.ts +37 -140
  45. package/dist/server/index.js +64 -127
  46. package/dist/{step-cache-CXg7ziML.d.ts → step-cache-v2yhyhSZ.d.ts} +359 -1112
  47. package/dist/{tempo-charge-store-76TDAF34.js → tempo-charge-store-SDTUNTKA.js} +2 -2
  48. package/dist/{tempo-lifecycle-DXM7QXJQ.js → tempo-lifecycle-G5SB4HFC.js} +4 -4
  49. package/dist/{tempo-session-store-2JNOKJGX.js → tempo-session-store-WF3VZXNF.js} +2 -2
  50. package/dist/{tempo-wallet-O67H5M4N.js → tempo-wallet-ZFNUDFVO.js} +1 -1
  51. package/dist/testing/index.d.ts +2 -2
  52. package/dist/testing/index.js +2 -2
  53. package/dist/{usd-BNDg1715.d.ts → usd-Cu9G6xv2.d.ts} +1 -1
  54. package/dist/{x402-7S2EFINY.js → x402-CI6PSHNO.js} +4 -4
  55. package/package.json +2 -2
  56. package/dist/postgres-job-store-OGQ6IT4U.js +0 -7
  57. package/dist/postgres-kv-store-D5E2EZ24.js +0 -7
  58. package/dist/revenue-reporter-NNCNRY4C.js +0 -7
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  withSdkInitLock
3
- } from "./chunk-S3XAHZQY.js";
3
+ } from "./chunk-COYKJRMM.js";
4
4
  import {
5
5
  applyRetryToPool
6
6
  } from "./chunk-C3MTFLC6.js";
@@ -108,7 +108,6 @@ var PostgresX402ChannelStorage = class {
108
108
  fleetLockQueueDepth = 0;
109
109
  relayLockTimeoutMs;
110
110
  terminalizeUnbackedCredit;
111
- /** Create the durable channel table under the SDK-wide DDL lock. */
112
111
  async init() {
113
112
  this.settlementPool ??= createSettlementPool(this.pool);
114
113
  await withSdkInitLock(this.pool, async () => {
@@ -541,7 +540,6 @@ var PostgresX402ChannelStorage = class {
541
540
  );
542
541
  }
543
542
  }
544
- /** Load one channel snapshot. */
545
543
  async get(channelId) {
546
544
  const q = this.transaction.getStore() ?? this.pool;
547
545
  const { rows } = await q.query(
@@ -1003,39 +1001,23 @@ var CreditLedger = class {
1003
1001
  x402Settlements;
1004
1002
  expiryReleaseOutbox;
1005
1003
  /**
1006
- * Bind the durable x402 settlement state this ledger gates spending on
1007
- *
1008
- * Late, because the settlement store is built with the batch-settlement
1009
- * server, which is built *after* the ledger it reads earned draws from. An
1010
- * unbound ledger simply does not gate — correct for a DVM with no x402
1011
- * channel storage, where no settlement row can exist to wedge.
1004
+ * Bound after construction because the settlement store shares this ledger and is
1005
+ * initialized separately. Ledger effects and route preflights use the same refund gate.
1012
1006
  */
1013
1007
  useX402SettlementGate(gate) {
1014
1008
  this.x402Settlements = gate;
1015
1009
  }
1016
1010
  /**
1017
- * Bind the durable outbox that reports credit-expiry releases and their
1018
- * revivals to the platform.
1019
- *
1020
- * Held on the ledger rather than threaded through {@link fund} for the same
1021
- * reason as the gate above: the revival half fires from *every* funding path
1022
- * Cashu commit, x402 exact and channel, Tempo, the Lightning invoice
1023
- * settle, the implicit N=1 per-call payment — and a seam each of those has to
1024
- * remember to pass is a seam one of them will eventually forget.
1025
- *
1026
- * Unbound (a `dvmctl dev` server, a self-hosted builder, a test host) the
1027
- * sweep and the reversal still run and still write the ledger's own rows;
1028
- * only the platform report is skipped. Reporting is bookkeeping and must
1029
- * never gate the ledger.
1011
+ * Expiry release and revival reports join the ledger transaction. A caller-owned funding
1012
+ * transaction must not commit a reversal independently of its outbox row. Without this
1013
+ * wiring, ledger transitions still run.
1030
1014
  */
1031
1015
  useCreditExpiryReleaseOutbox(outbox) {
1032
1016
  this.expiryReleaseOutbox = outbox;
1033
1017
  }
1034
- /** Create the `credits` / `credit_draws` tables if absent. Call once at SDK boot. */
1035
1018
  async init() {
1036
1019
  await withSdkInitLock(this.pool, () => this.createTables());
1037
1020
  }
1038
- /** The boot DDL itself — always runs under {@link withSdkInitLock}. */
1039
1021
  async createTables() {
1040
1022
  await this.pool.query(`
1041
1023
  CREATE TABLE IF NOT EXISTS credits (
@@ -1313,25 +1295,11 @@ var CreditLedger = class {
1313
1295
  );
1314
1296
  }
1315
1297
  /**
1316
- * Give every open non-channel Bitcoin credit a funding lot.
1317
- *
1318
- * Runs inside the boot DDL, is guarded per credit by `NOT EXISTS`, and is
1319
- * therefore both the one-time migration and a standing self-heal for a
1320
- * credit that somehow reaches a positive balance with no lot behind it.
1321
- *
1322
- * The lot is derived from `credits.msats_remaining`, not replayed out of
1323
- * `credit_fundings`. That is not a shortcut: `credit_fundings` records only
1324
- * `{amount_micro, rail}` — no instrument amount at all — and is not written
1325
- * on every funding path, whereas `msats_remaining` **is** the credit's
1326
- * current unspent rail value, maintained pro rata on every draw since
1327
- * One synthetic lot pairing it with `balance_micro` therefore
1328
- * reproduces today's in-kind position exactly, and FIFO over a single lot is
1329
- * trivially correct. Historic per-funding rates are unrecoverable and would
1330
- * change nothing: only the remaining position is owed.
1331
- *
1332
- * The semantics change is deliberately retroactive — every
1333
- * current holder is first-party, so there is one regime and no legacy
1334
- * branch.
1298
+ * Backfill an open non-channel Bitcoin credit only when it has no funding lot. Pair
1299
+ * balance_micro with current msats_remaining; credit_fundings lacks historical instrument
1300
+ * amounts and does not cover every funding path. Historical per-funding rates cannot be
1301
+ * recovered. Existing lots are left untouched, so this does not repair partial coverage
1302
+ * gaps.
1335
1303
  */
1336
1304
  async backfillFundingLots() {
1337
1305
  const { rowCount } = await this.pool.query(
@@ -1367,60 +1335,19 @@ var CreditLedger = class {
1367
1335
  );
1368
1336
  }
1369
1337
  /**
1370
- * Create a credit, or top up an existing one (same `creditId`). Top-ups add
1371
- * to the balance and overwrite `expiry_ms` with the provided value — funding
1372
- * an expired credit revives it (the caller-ownership contract carry-forward: the credit is a
1373
- * rolling buffer, not per-job escrow). The existing row's `caller_pubkey`
1374
- * and `currency` must match or the fund is refused with a typed error —
1375
- * without that check a top-up against someone else's `credit_id` would
1376
- * silently merge two callers' money.
1377
- *
1378
- * Pass `tx` (a client inside a caller-owned `BEGIN`) to commit the rail
1379
- * receive and the ledger credit atomically (spec condition 3 — the
1380
- * verifier does this). The ledger issues **no** transaction control on `tx`.
1381
- *
1382
- * Without `tx` it opens one of its own, because a funding is no longer a
1383
- * single statement: it upserts the credit, records its funding lot
1384
- * and reverses any standing expiry release — and that
1385
- * last leg restores balance and queues a report. A crash between the upsert
1386
- * and the reversal would leave a revived credit whose release still stands,
1387
- * which the sweep's own exclusion then makes permanent: `balance_micro > 0`
1388
- * but a standing release means it is neither drainable nor re-releasable.
1389
- *
1390
- * `basis` records the rail value behind the fiat so each draw can
1391
- * be allocated its share of the rail-native amount actually received. A
1392
- * top-up on a different rail than the credit's is refused with
1393
- * `rail_mismatch`: sats and USDC microunits aren't summable, so a blended
1394
- * credit would have no coherent native basis to allocate from.
1395
- *
1396
- * It is **required**, in the type and again at runtime via
1397
- * {@link assertFundingBasis}. A basis-less fund wrote `rail = NULL`, and a
1398
- * draw against such a credit settles — a real debit — while
1399
- * `railAsFundingMethod` correctly declines to guess a rail, so `bookRevenue`
1400
- * books nothing and outstanding liability (`deposits − draw revenue`) stays
1401
- * overstated by the drawn amount forever. `credits.rail` stays nullable
1402
- * because rows written before this change still exist in live databases:
1403
- * **write is closed, read is not.** A legacy row adopts a rail from its first
1404
- * typed top-up via the `COALESCE` below.
1405
- *
1406
- * The rail-native **instrument** is pinned at first funding too, and both
1407
- * channel guards are symmetric for the same reason. A reusable
1408
- * channel — Tempo session or x402 batch-settlement — may only ever top up
1409
- * the credit it opened, and a credit opened by a one-shot (a Tempo charge, an
1410
- * x402 exact authorization) may never adopt one. The x402 half used to admit
1411
- * `credits.x402_channel_id IS NULL`, so a channel deposit bound itself to a
1412
- * credit already holding exact-funded value: `earnedNativeForX402Channel`
1413
- * then read that credit's whole settled-draw history as the channel's
1414
- * earnings, and the first claim tick swept a deposit nobody had consumed,
1415
- * while the unspent balance lost every exit (the drain's refund base goes
1416
- * below `totalClaimed`, and a channel-bound credit refuses every non-x402
1417
- * drain). `credits_x402_channel_uidx` does not cover this: it guards
1418
- * one-credit-per-channel, not one-source-per-credit.
1419
- *
1420
- * An x402 channel deposit is additionally refused `settlement_pending` while
1421
- * that channel carries an unresolved refund — see the gate read
1422
- * below. This method is the authority on that rule, as it is on the binding
1423
- * rules above; every door preflights it where a refusal is still free.
1338
+ * Funding adds balance and overwrites expiry, reviving an expired credit. It is additive,
1339
+ * not idempotent: the caller’s rail marker or funding record must prevent replay. Owner and
1340
+ * currency must match; rail and reusable-channel bindings cannot be mixed with other
1341
+ * instruments. A legacy NULL rail adopts its first typed top-up.
1342
+ *
1343
+ * With tx, all writes join the caller’s transaction without transaction control. Otherwise
1344
+ * the ledger owns a transaction covering balance, funding lot, reversal of standing expiry
1345
+ * releases and their reports. A partial revival would leave restored balance with a release
1346
+ * still counted.
1347
+ *
1348
+ * An incoming x402 channel with an unresolved refund is refused before the upsert. That
1349
+ * preflight is not atomic with a concurrent cooperative drain; see the incoming-channel
1350
+ * gate below.
1424
1351
  */
1425
1352
  async fund(args) {
1426
1353
  assertAmount(args.amountMicro, { min: 1 });
@@ -1448,11 +1375,6 @@ var CreditLedger = class {
1448
1375
  client.release();
1449
1376
  }
1450
1377
  }
1451
- /**
1452
- * The funding itself, on whichever handle {@link fund} chose. Every refusal
1453
- * it raises is the caller's to see unchanged; a self-opened transaction rolls
1454
- * back around it.
1455
- */
1456
1378
  async fundOn(q, args, b, nowMs, creditId) {
1457
1379
  const wedged = await this.blockingX402Refund(
1458
1380
  b.x402Channel?.channelId,
@@ -1590,33 +1512,12 @@ var CreditLedger = class {
1590
1512
  return this.snapshotFromRow(revived ?? rows[0], q, nowMs);
1591
1513
  }
1592
1514
  /**
1593
- * Undo any expiry release this credit still carries, because a funding just
1594
- * landed on it (the revival rule), restoring the balance the
1595
- * release took.
1515
+ * Funding reverses standing expiry releases, restores their released micro and queues
1516
+ * reversal reports on the funding transaction. Do this even when the new expiry is already
1517
+ * past: the next sweep must release the enlarged remainder as a new episode.
1596
1518
  *
1597
- * A release records the credit's **final** undrawn remainder and zeroes it
1598
- * (see {@link releaseExpiredCreditLocked}). Funding adds to the balance and
1599
- * overwrites `expiry_ms` (the caller-ownership contract carry-forward: the credit is a rolling
1600
- * buffer), so the moment money arrives the recorded remainder is no longer
1601
- * final: the release stops counting and its micro come back.
1602
- *
1603
- * Deliberately unconditional on the incoming expiry rather than gated on
1604
- * `expiryMs > nowMs`. A release exists only for a credit that had already
1605
- * lapsed, so any funding that reaches one is money the release did not
1606
- * account for — and a funding that arrives with an already-past expiry leaves
1607
- * a credit the very next sweep re-releases at its new, larger remainder. One
1608
- * rule, self-healing in both directions, and it never needs to read the
1609
- * pre-funding expiry the upsert has already overwritten.
1610
- *
1611
- * Runs on the funding's own handle — which {@link fund} now guarantees is a
1612
- * transaction — so the reversal, the restored balance and the report commit
1613
- * with the money or not at all. A reversal that committed without its report
1614
- * would be unrecoverable: `WHERE reversed_at IS NULL` means no later pass
1615
- * re-derives it.
1616
- *
1617
- * @returns the credit row as the restore left it, or undefined when there was
1618
- * nothing to reverse. The caller reads its snapshot off this rather than off
1619
- * the funding upsert's `RETURNING`, which predates the restore.
1519
+ * Returns the row after restoration, or undefined when no release stood. The upsert’s
1520
+ * RETURNING row predates the restoration and is not the final balance.
1620
1521
  */
1621
1522
  async reverseExpiryReleases(q, creditId, nowMs) {
1622
1523
  const { rows } = await q.query(
@@ -1641,37 +1542,15 @@ var CreditLedger = class {
1641
1542
  return restored[0];
1642
1543
  }
1643
1544
  /**
1644
- * Release the undrawn remainder of every credit whose TTL has run out
1645
- * Returns the releases this pass recorded.
1646
- *
1647
- * **What is released.** The credit's whole `balance_micro`. Expiry ends
1648
- * spending but never ownership of the record, so the balance stays readable;
1649
- * what ends is the caller's claim on it, and on the prepaid rails the value
1650
- * behind it is already in builder custody.
1651
- *
1652
- * **Tempo is excluded**, at both of its markers (`tempo_channel_id` and a
1653
- * `tempo` rail). A Tempo credit is channel-backed and its undrawn value
1654
- * returns to the *caller* at channel exit, so releasing it would book the
1655
- * builder money the chain is about to hand back.
1656
- *
1657
- * **A credit with a pending hold is skipped, not partially released.** A draw
1658
- * placed before expiry stays settleable afterwards (`draw`'s replay lookup
1659
- * runs above the expiry check, so a lost response is still recoverable), so
1660
- * the remainder is not final while a hold is outstanding. Skipping costs one
1661
- * sweep interval and keeps the released figure exactly "what nothing bought";
1662
- * holds do resolve — the orphan-draw watchdog is what guarantees
1663
- * a stranded one still reaches a terminal state.
1664
- *
1665
- * **Idempotent** two ways. `release_id` is derived from the expiry instant,
1666
- * so a re-sweep after a crash between the insert and its report collides on
1667
- * the same primary key instead of writing a second row; and the candidate
1668
- * scan excludes any credit that already has an un-reversed release.
1669
- *
1670
- * Each candidate is re-checked under the credit row lock every
1671
- * `credit_draws` write is taken under, so a draw racing the sweep either
1672
- * loses the race (its credit is already released and it would have been
1673
- * refused `credit_expired` anyway) or wins it and leaves a pending hold the
1674
- * locked re-check sees.
1545
+ * Release expired undrawn balances, retaining readable credit records. Tempo rail or
1546
+ * channel-backed credits are excluded because their collateral returns to the caller
1547
+ * through the channel lifecycle.
1548
+ *
1549
+ * Pending holds prevent release: jobs accepted before expiry may still settle or release.
1550
+ * Candidates are rechecked under the same parent-credit lock used by draw writes. The
1551
+ * balance debit, episode record and report commit together; standing releases exclude
1552
+ * repeat work. A later revival may reuse the expiry instant but receives a new release
1553
+ * sequence.
1675
1554
  */
1676
1555
  async sweepExpiredCredits(args) {
1677
1556
  const nowMs = args?.nowMs ?? Date.now();
@@ -1707,30 +1586,12 @@ var CreditLedger = class {
1707
1586
  return { released };
1708
1587
  }
1709
1588
  /**
1710
- * Write one credit's expiry release under its row lock, zero the balance it
1711
- * released, and re-assert every condition the unlocked candidate scan tested.
1712
- * Returns undefined when the credit no longer qualifies — funded, drawn
1713
- * against, or already released between the scan and the lock.
1714
- *
1715
- * **Zeroing the balance is what makes the release real**, and it is the whole
1716
- * reason this runs under the lock rather than as a bare INSERT. The row alone
1717
- * records that the caller's claim ended; it does not *end* it. `requestDrain`
1718
- * has no expiry check by design (expiry gates new draws, not reclaims), so a
1719
- * credit whose remainder had been released and reported was still fully
1720
- * drainable: the DVM would pay out money the platform had already booked as
1721
- * the builder's, and `deposited - drawn - drained - released` would go
1722
- * negative on the same micro. With the balance at zero, `requestDrain`'s
1723
- * existing `availableMicro <= 0` guard refuses with `nothing_to_drain` and no
1724
- * new check is needed anywhere. {@link reverseExpiryReleases} puts the
1725
- * balance back when a funding revives the credit.
1589
+ * Recheck eligibility under the credit lock and zero the released balance in the same
1590
+ * transaction as its episode/report. Otherwise requestDrain, which intentionally ignores
1591
+ * expiry, could reclaim value already released to the builder.
1726
1592
  *
1727
- * `status` deliberately stays `active`: a released credit must remain
1728
- * fundable, or the revival rule has nothing to revive.
1729
- *
1730
- * The zeroed balance also takes the credit out of `BITCOIN_CREDIT_PREDICATE`
1731
- * (`balance_micro > 0`), so the in-kind sweep floor stops counting sats the
1732
- * builder no longer owes back — and counts them again after a revival, with
1733
- * the credit's funding lots untouched throughout.
1593
+ * Keep status active so future funding can revive it. Funding lots remain intact: balance
1594
+ * zero excludes this credit from Bitcoin liability, and reversal restores that liability.
1734
1595
  */
1735
1596
  async releaseExpiredCreditLocked(client, credit, nowMs) {
1736
1597
  const expiryMs = toSafeInt(credit.expiry_ms);
@@ -1778,34 +1639,12 @@ var CreditLedger = class {
1778
1639
  return row;
1779
1640
  }
1780
1641
  /**
1781
- * Record this funding's in-kind basis as a lot.
1782
- *
1783
- * Called from inside {@link fund}, on the same handle, so the lot shares
1784
- * whatever transaction the rail opened — `withCommitTx` for Cashu,
1785
- * `ProcessedPaymentStore.withTx` for x402/Tempo, `settleInvoice`'s own
1786
- * `BEGIN` for Lightning. The funding-atomicity invariant therefore extends
1787
- * to it structurally rather than by every call site remembering: a lot
1788
- * cannot exist without its ledger credit, or the credit without its lot.
1789
- *
1790
- * Only a **channel-backed** credit writes no lot: it is in-kind already —
1791
- * the escrow itself retains the unused value — so a lot on one would be
1792
- * counted as Bitcoin deposit liability the hub does not hold.
1793
- *
1794
- * Every other non-channel Bitcoin funding writes one, the implicit N=1
1795
- * per-call payment included. That one funds and draws the same amount in
1796
- * this transaction, so it looks like a lot born depleted — but the draw is a
1797
- * *pending hold*, not a debit, and only a `completed` job settles it
1798
- * (`JobManager.settlesDraw`). A failed, cancelled or stale-swept job
1799
- * releases the hold and the whole funded amount becomes reclaimable balance,
1800
- * so suppressing the lot here would leave routine traffic reclaiming off a
1801
- * live rate and missing from the sweep floor. One small insert on the hot
1802
- * path buys the invariant the liability read depends on: open lots cover
1803
- * `balance_micro` for every Bitcoin credit, always.
1642
+ * Record non-channel Bitcoin funding lots on the funding transaction. An implicit payment
1643
+ * also needs a lot: its draw is initially only a hold, and failure releases the whole
1644
+ * balance for reclaim. Channel collateral is excluded from Bitcoin lots.
1804
1645
  *
1805
- * A zero-sats basis still writes a lot. It is honest — the credit exists and
1806
- * we know its rail — and it is what makes "covered but unbacked" a
1807
- * distinguishable state from "no lot at all" for the reclaim and the
1808
- * liability rollup, instead of both reading as a gap.
1646
+ * A zero-sats basis still records a lot, distinguishing covered but basisless credit from a
1647
+ * missing-lot coverage gap.
1809
1648
  */
1810
1649
  async recordFundingLot(q, creditId, amountMicro, basis, nowMs) {
1811
1650
  if (!isNonChannelBitcoinRail(basis.rail)) return;
@@ -1826,25 +1665,13 @@ var CreditLedger = class {
1826
1665
  );
1827
1666
  }
1828
1667
  /**
1829
- * Place a two-phase `pending` hold of `amountMicro` against the credit and
1830
- * assign the next `ledger_seq`. Idempotent on `(creditId, drawId)`: a replay
1831
- * returns the original `{balanceAfterMicro, ledgerSeq}` with
1832
- * `replayed: true` and places no second hold — this is how a caller recovers
1833
- * a lost response (spec condition 1). A replay whose `amountMicro` or
1834
- * `jobId` differs from the recorded draw is not a replay but a money bug in
1835
- * the caller, refused with `draw_conflict`.
1668
+ * Place a pending hold with the next shared per-credit sequence. A matching (creditId,
1669
+ * drawId) replay returns its recorded tuple without another hold; conflicting amount or
1670
+ * jobId is draw_conflict.
1836
1671
  *
1837
- * The replay lookup runs **before** the expiry check, deliberately: a draw
1838
- * placed pre-expiry must stay recoverable after the credit expires, or the
1839
- * DVM holds a debit the caller can never reconcile. Fresh draws on an
1840
- * expired credit are refused with `credit_expired`; the balance stays
1841
- * intact and readable (the caller-ownership contract: expiry ends spending, never ownership).
1842
- *
1843
- * Pass `tx` (a client inside a caller-owned `BEGIN`) to place the hold in
1844
- * the same transaction as the rail commit and the `fund` upsert (spec
1845
- * condition 3 — the implicit N=1 path). The ledger issues no
1846
- * transaction control on `tx`; the `SELECT … FOR UPDATE` row lock is still
1847
- * taken on the caller's transaction, so the locking invariant holds.
1672
+ * Replay precedes expiry and settlement gates so a lost response stays recoverable. Fresh
1673
+ * draws require spendable credit. With tx, take the parent-credit lock on the caller’s
1674
+ * transaction without issuing transaction control.
1848
1675
  */
1849
1676
  async draw(args) {
1850
1677
  assertAmount(args.amountMicro, { min: 0 });
@@ -1960,66 +1787,26 @@ var CreditLedger = class {
1960
1787
  return this.withCreditLock(args.creditId, (client, credit) => run(client, credit));
1961
1788
  }
1962
1789
  /**
1963
- * The unresolved refund on `channelId` that must stop this ledger effect, if
1964
- * any.
1965
- *
1966
- * `undefined` whenever there is nothing to ask — an unbound credit, or a DVM
1967
- * with no durable settlement store, where a settlement row cannot exist.
1968
- *
1969
- * Public since the behavior was introduced, so `/v1/credit`'s pre-payment preflight can ask the
1970
- * same question `fund` will ask from inside the rail transaction — one read,
1971
- * one answer, rather than a second copy of the rule in the routes.
1790
+ * Return the unresolved refund that blocks an effect on this channel, or undefined. Route
1791
+ * preflights use the same rule before moving money; draw and drain also read it under the
1792
+ * credit lock.
1972
1793
  */
1973
1794
  async blockingX402Refund(channelId, statuses) {
1974
1795
  if (!channelId || !this.x402Settlements) return void 0;
1975
1796
  return this.x402Settlements.pendingRefundSettlement(channelId, statuses);
1976
1797
  }
1977
1798
  /**
1978
- * Grow a pending draw by `addAmountMicro` — the ledger half of a
1979
- * mid-job `requestPayment` top-up. One job keeps **one** draw: the mid-job
1980
- * money funds the same credit and enlarges the hold the upfront leg placed,
1981
- * so the receipt's `ReceiptCredit` block countersigns the job's full cost
1982
- * and `revenue_events` still books exactly one row per job.
1983
- *
1984
- * `draw()` cannot express this: a same-`draw_id` call with a different
1985
- * amount is `draw_conflict` by design (a replayed draw must never re-price).
1986
- *
1987
- * The arithmetic is `draw()`'s, unchanged: `addAmountMicro` is checked
1988
- * against — and `balance_after_micro` recomputed from — the balance net of
1989
- * **every** pending hold, this draw's own included. That is deliberately the
1990
- * conservative reading (a fresh draw of `addAmountMicro` would see exactly
1991
- * the same figure), and it is what keeps the total held after growth inside
1992
- * the credit's balance.
1993
- *
1994
- * The increment's rail value comes from {@link growthRailValue}: earmarked
1995
- * to the funding that backs it when the caller names one (the mid-job case),
1996
- * pro-rata otherwise. Either way the draws of a credit keep summing to
1997
- * precisely what the rails paid.
1998
- *
1999
- * **`ledger_seq` is not re-taken.** The per-credit sequence is gap-free
2000
- * (`credit_draws_seq_uidx`), so moving this draw forward would strand its
2001
- * original number. The consequence is deliberate and worth knowing: the
2002
- * recorded `balance_after_micro` is the post-growth figure stamped at the
2003
- * *original* seq, so a credit with interleaved sibling activity has a
2004
- * trajectory that is truthful per draw rather than monotonic across seqs.
2005
- *
2006
- * **No expiry check**, matching `settle`/`release`: the job was accepted
2007
- * pre-expiry and is still running. Refusing here would strand the handler
2008
- * mid-flight over a clock the caller can't influence.
1799
+ * Grow an accepted job’s pending hold while preserving its original ledger_seq. The balance
1800
+ * tuple is updated at that sequence, so interleaved draws need not form a monotonic balance
1801
+ * trajectory. No expiry check applies to an already accepted job.
2009
1802
  *
2010
- * **Not idempotent by itself.** Growth carries no key of its own — it
2011
- * inherits the rail's (the accumulator's `(dvm_id, request_id)` UNIQUE rolls
2012
- * the whole transaction back on replay; x402/mpp collide on the
2013
- * `processed_payments` marker). Never call it outside a rail commit.
1803
+ * Available balance excludes every pending hold, including this one. An earmark allocates
1804
+ * the incoming funding’s rail value; absent one, growth uses the pool.
2014
1805
  *
2015
- * **`capMicro` bounds cumulative growth at what the job cumulatively asked,**
2016
- * and it is the only ceiling that can refuse a top-up: the
2017
- * available-balance check cannot, because the `fund` a moment earlier in
2018
- * this same transaction raised the balance by exactly the amount being
2019
- * drawn. Excess is **granted partially or not at all rather than thrown** —
2020
- * this runs inside the rail commit, so a throw would roll back cashu proofs
2021
- * the caller already sent. The refused remainder stays funded balance the
2022
- * caller owns and can drain.
1806
+ * Growth has no independent replay key: compose it with the rail’s replay protection in one
1807
+ * transaction. capMicro bounds cumulative growth at the job’s cumulative ask. Excess
1808
+ * remains caller-owned funded balance; partial or zero growth returns that excess rather
1809
+ * than throwing and rolling back accepted proofs.
2023
1810
  */
2024
1811
  async growDraw(args) {
2025
1812
  assertAmount(args.addAmountMicro, { min: 1 });
@@ -2212,18 +1999,9 @@ var CreditLedger = class {
2212
1999
  return this.resolveDraw({ ...args, to: "released" });
2213
2000
  }
2214
2001
  /**
2215
- * Record a fund-only top-up under its client-generated `fundId`.
2216
- * The deposit half of the evidence chain, and the top-up path's idempotency
2217
- * key: `PRIMARY KEY (credit_id, fund_id)` means a concurrent duplicate
2218
- * loses with a typed `funding_replayed` rather than crediting twice.
2219
- *
2220
- * Call it inside the same `tx` as {@link fund} — the rail commit, the
2221
- * funding record, and the balance increment must land together or not at
2222
- * all (the funding-commitment rule). Like `fund`, this issues no transaction
2223
- * control on `tx`; it is a single statement.
2224
- *
2225
- * This does **not** move money on its own. `fund` still does the crediting;
2226
- * this row is what lets a retry be answered with the original outcome.
2002
+ * Record the fund-only replay key without moving money. A duplicate (credit_id, fund_id)
2003
+ * raises funding_replayed. Compose this insert, fund and rail acceptance on the same
2004
+ * caller-owned transaction; a retry reads the recorded funding outcome.
2227
2005
  */
2228
2006
  async recordFunding(args) {
2229
2007
  assertAmount(args.amountMicro, { min: 1 });
@@ -2348,7 +2126,6 @@ var CreditLedger = class {
2348
2126
  }
2349
2127
  return existing;
2350
2128
  }
2351
- /** Read one invoice (no lock). */
2352
2129
  async getInvoice(args) {
2353
2130
  const { rows } = await this.pool.query(
2354
2131
  `SELECT * FROM credit_invoices WHERE credit_id = $1 AND fund_id = $2`,
@@ -2373,22 +2150,10 @@ var CreditLedger = class {
2373
2150
  return rows.map(invoiceRecordFromRow);
2374
2151
  }
2375
2152
  /**
2376
- * Apply an observed Lightning settlement to the ledger — **the exactly-once
2377
- * boundary** (the funding-commitment rule for a rail whose commit happens
2378
- * off-box).
2379
- *
2380
- * One transaction covers the funding record, the balance, and the invoice's
2381
- * own status flip, so the three can never disagree. A crash anywhere inside
2382
- * leaves the invoice `pending` and the balance untouched: the next
2383
- * `lookup_invoice` observes the same settled payment and credits it, once.
2384
- * A replayed check after the commit sees `settled` and moves nothing.
2385
- *
2386
- * The lock is on the **invoice** row, not the credit row: the first
2387
- * Lightning top-up is what opens the credit, so there is frequently no
2388
- * `credits` row to take `FOR UPDATE` yet. That does not weaken the ledger's
2389
- * concurrency contract — no `credit_draws` write happens here, and `fund`'s
2390
- * upsert is a single statement — while still serialising two settlement
2391
- * checks racing on the same invoice.
2153
+ * Serialize settlement on the invoice row, which exists before the first credit does.
2154
+ * Funding record, balance, invoice status and optional deposit outbox commit together. An
2155
+ * interrupted transaction leaves the prior invoice status intact for retry; a settled replay
2156
+ * moves no money.
2392
2157
  */
2393
2158
  async settleInvoice(args) {
2394
2159
  assertFundingBasis(args.basis);
@@ -2482,19 +2247,9 @@ var CreditLedger = class {
2482
2247
  return this.getInvoice(args);
2483
2248
  }
2484
2249
  /**
2485
- * Retire an invoice that **was paid** and can never be credited.
2486
- *
2487
- * Distinct from `expired` because the money is the opposite way round: an
2488
- * expired invoice was never paid and owes nobody anything, whereas a blocked
2489
- * one has sats sitting in the builder's wallet with no ledger row to put them
2490
- * on. Only an operator can resolve that, so the row records `reason` and
2491
- * stops being swept — leaving it `pending` would spend one of the sweep's few
2492
- * slots re-deriving the same verdict on every request the caller makes, and
2493
- * three of them would wedge that caller's sweep so a genuinely payable
2494
- * invoice behind them never credits.
2495
- *
2496
- * A CAS on `pending`, like the expiry retirement: a settlement observed
2497
- * concurrently must win.
2250
+ * Mark a paid invoice blocked when its payment cannot be credited. This removes it from the
2251
+ * bounded pending sweep and leaves its reason for operator repair. The pending-only CAS
2252
+ * cannot overwrite a committed settlement.
2498
2253
  */
2499
2254
  async markInvoiceBlocked(args) {
2500
2255
  await this.pool.query(
@@ -2504,7 +2259,6 @@ var CreditLedger = class {
2504
2259
  );
2505
2260
  return this.getInvoice(args);
2506
2261
  }
2507
- /** Read one invoice by the wallet's own identifier for the payment (no lock). */
2508
2262
  async getInvoiceByPaymentHash(paymentHash) {
2509
2263
  const { rows } = await this.pool.query(
2510
2264
  `SELECT * FROM credit_invoices WHERE payment_hash = $1`,
@@ -2513,17 +2267,9 @@ var CreditLedger = class {
2513
2267
  return rows.length > 0 ? invoiceRecordFromRow(rows[0]) : void 0;
2514
2268
  }
2515
2269
  /**
2516
- * The operator's queue: invoices the sweep gave up on.
2517
- *
2518
- * **Keyset-paginated, not offset.** Blocked rows are terminal and never
2519
- * self-clear, so a row the operator declines to act on sits at the head of
2520
- * the age ordering forever; a fixed first page would starve everything
2521
- * behind it on every run, which is the shape one page up.
2522
- *
2523
- * `includeResolved` widens to the operator-resolved statuses so a run can be
2524
- * audited after the fact.
2525
- * Host-wide, like `listPendingDrains`: the ledger has no `dvm_id`, so on a
2526
- * multi-mount host one builder's admin credential reads every mount's rows.
2270
+ * Host-wide operator queue, keyset-paginated so an untouched blocked row does not starve
2271
+ * later rows. includeResolved also exposes reconciled and written-off history; the ledger
2272
+ * has no per-mount dvm_id.
2527
2273
  */
2528
2274
  async listBlockedInvoices(args) {
2529
2275
  const statuses = args.includeResolved ? ["blocked", "reconciled", "written_off"] : ["blocked"];
@@ -2545,37 +2291,15 @@ var CreditLedger = class {
2545
2291
  return rows.map(invoiceRecordFromRow);
2546
2292
  }
2547
2293
  /**
2548
- * Apply a blocked invoice's payment to a credit an operator named — the
2549
- * repair for the one Lightning outcome the DVM cannot fix itself.
2550
- *
2551
- * Shaped statement-for-statement on {@link settleInvoice}, because it is the
2552
- * same money doing the same thing a different way: one transaction covering
2553
- * the funding record, the balance, the invoice's status flip and the deposit
2554
- * outbox row, so the four can never disagree. Three deliberate differences:
2555
- *
2556
- * - **The lock is taken on `payment_hash`**, which is UNIQUE and is the
2557
- * identifier the operator actually holds (it is what the
2558
- * `lightning_settlement_blocked` log carries and what names the payment in
2559
- * their wallet). Invoice row first, credit row second via `fund`'s upsert —
2560
- * the same order `settleInvoice` takes, which is what keeps a reconcile and
2561
- * a concurrent settlement check from deadlocking against each other.
2562
- * - **The funding lands at `(targetCreditId, paymentHash)`, not the invoice's
2563
- * own `(credit_id, fund_id)`.** That key is frequently the reason the row
2564
- * is blocked at all — `funding_replayed_on_cashu` means something else
2565
- * already holds it — and the operator's most natural target is that very
2566
- * credit. The payment hash cannot collide with it, and it makes all three
2567
- * references to this payment agree: `basis.fundingRef`, the deposit's
2568
- * `funding_id`, and the funding row's `fund_id`.
2569
- * - **`written_off` is an accepted input status.** A write-off unwound
2570
- * nothing, so an operator who closed a row by mistake must not need raw SQL
2571
- * against a money table to reopen it.
2572
- *
2573
- * The invoice row's status — never the funding row — is the idempotency
2574
- * source of truth. `fund_id` is caller-chosen and the payment hash is
2575
- * disclosed to them, so a caller *can* squat `(theirCredit, thatHash)` with a
2576
- * top-up on another rail; when they have, `recordFunding` throws
2577
- * `funding_replayed` and that surfaces as its own loud failure rather than
2578
- * being absorbed as "already reconciled".
2294
+ * Apply a blocked or written-off invoice to an operator-named credit. Lock the invoice by
2295
+ * its unique payment_hash before funding the credit, matching settlement’s
2296
+ * invoice-to-credit lock order. Funding, balance, resolution and optional outbox share one
2297
+ * transaction.
2298
+ *
2299
+ * The repair funding key is (targetCreditId, paymentHash), distinct from the original
2300
+ * caller-chosen fundId. Invoice status determines replay. A caller can still occupy the
2301
+ * repair key with another payment; funding_replayed must remain a refusal, not be absorbed
2302
+ * as already reconciled. Preserve blocked_reason and the wallet’s settlement instant.
2579
2303
  */
2580
2304
  async reconcileBlockedInvoice(args) {
2581
2305
  assertFundingBasis(args.basis);
@@ -2675,17 +2399,9 @@ var CreditLedger = class {
2675
2399
  }
2676
2400
  }
2677
2401
  /**
2678
- * Record that an operator reviewed a blocked invoice and chose not to credit
2679
- * it — no ledger effect, purely a queue transition.
2680
- *
2681
- * A CAS on `blocked`, and idempotent: re-running returns the recorded row
2682
- * rather than overwriting the first note. It cannot capture a `reconciled`
2683
- * row, because that one moved money.
2684
- *
2685
- * `written` reports whether *this* call was the transition. The caller has
2686
- * no other way to tell — the row reads identically either way — and the
2687
- * write-off's audit log is the only record of the amount, so a re-emitted
2688
- * one reads as a second decision about the same sats.
2402
+ * Write off an invoice without crediting or moving money. Only the first transition records
2403
+ * the note; its written bit controls the audit event. An operator can later reconcile a
2404
+ * written-off invoice, because write-off did not return its payment.
2689
2405
  */
2690
2406
  async writeOffBlockedInvoice(args) {
2691
2407
  const { rowCount } = await this.pool.query(
@@ -2719,7 +2435,6 @@ var CreditLedger = class {
2719
2435
  }
2720
2436
  return out;
2721
2437
  }
2722
- /** Read one draw (no lock), with its credit's rail basis joined in. */
2723
2438
  async getDraw(args) {
2724
2439
  const { rows } = await this.pool.query(
2725
2440
  `${DRAW_WITH_BASIS_SELECT} WHERE d.credit_id = $1 AND d.draw_id = $2`,
@@ -2735,7 +2450,6 @@ var CreditLedger = class {
2735
2450
  );
2736
2451
  return rows.length === 1 ? drawRecordFromRow(rows[0], rows[0]) : void 0;
2737
2452
  }
2738
- /** Read every draw linked to a job, including already-resolved rows. */
2739
2453
  async listDrawsByJobId(jobId) {
2740
2454
  const { rows } = await this.pool.query(
2741
2455
  `${DRAW_WITH_BASIS_SELECT} WHERE d.job_id = $1 ORDER BY d.created_at, d.credit_id, d.ledger_seq`,
@@ -2756,22 +2470,10 @@ var CreditLedger = class {
2756
2470
  return rows.map((row) => drawRecordFromRow(row, row));
2757
2471
  }
2758
2472
  /**
2759
- * One page of pending holds host-wide placed before `createdBeforeMs`,
2760
- * oldest first (no lock). The orphan sweeper's read surface: a
2761
- * draw commits with its `job_id` before the job row is persisted, so a crash
2762
- * or a fail-closed refusal in that window strands a hold nothing can ever
2763
- * resolve — the only release path keys off the `credit_id`/`draw_id` written
2764
- * on the job row.
2765
- *
2766
- * Host-wide like {@link listPendingDrains}, not per-credit: the sweeper has
2767
- * no candidate credit to start from. Age-bounded so it never sees a draw
2768
- * whose job row is merely still in flight.
2769
- *
2770
- * `limit` is required and `after` keyset-paginates: an aged hold whose job
2771
- * row *does* exist is legitimately un-sweepable and stays `pending`
2772
- * indefinitely, so a caller that re-issues the same first page would never
2773
- * see past a `limit`-sized wall of them. Pass the last row's
2774
- * {@link StalePendingDrawCursor} to advance.
2473
+ * Page host-wide pending holds older than createdBeforeMs by (created_at, credit_id,
2474
+ * ledger_seq). Use the same compound key for ordering and continuation, preserving
2475
+ * same-millisecond rows. Terminal resolution still rechecks state under the parent-credit
2476
+ * lock.
2775
2477
  */
2776
2478
  async listStalePendingDraws(args) {
2777
2479
  const after = args.after;
@@ -2793,21 +2495,9 @@ var CreditLedger = class {
2793
2495
  return rows.map((row) => drawRecordFromRow(row, row));
2794
2496
  }
2795
2497
  /**
2796
- * Rail-native value the credit an x402 settlement channel funded has
2797
- * actually earned — the sum of its **settled** draws, in the credit's native
2798
- * atomic units (USDC micro on this rail). The batch-settlement claim job's
2799
- * ceiling: a channel is claimable up to what its credit's draws
2800
- * have earned, never up to the deposit that funded it.
2801
- *
2802
- * `undefined` when no credit is bound to the channel, which the claim job
2803
- * treats as "cannot be resolved" and claims nothing for — distinct from a
2804
- * resolved credit that has earned `0`.
2805
- *
2806
- * Deliberately a sum over settled draws rather than `funded − remaining`:
2807
- * a drain lowers `native_remaining` without earning anything, so the
2808
- * subtraction would read the caller's own reclaim as revenue. Pending holds
2809
- * are excluded for the mirror reason — a hold is work in flight, and
2810
- * claiming against it would take money a release still owes back.
2498
+ * Sum settled draws’ native value for the credit bound to this x402 channel.
2499
+ * Pending/released holds and drains earn nothing. Return undefined for an unbound channel,
2500
+ * distinct from earned zero, so settlement cannot guess a claim limit.
2811
2501
  */
2812
2502
  async earnedNativeForX402Channel(channelId) {
2813
2503
  const { rows } = await this.pool.query(
@@ -2827,14 +2517,8 @@ var CreditLedger = class {
2827
2517
  return toSafeInt(rows[0].earned);
2828
2518
  }
2829
2519
  /**
2830
- * The credit an x402 settlement channel funded, if one is bound to it — the
2831
- * binding is UNIQUE, so at most one row can answer.
2832
- *
2833
- * The operator repair's entry point: a wedged settlement row carries the
2834
- * channel and an `effect_id`, and this is what turns them back into the
2835
- * credit id, the caller pubkey, and the drain id the effect encodes. Deriving
2836
- * the credit id by splitting `effect_id` instead would be wrong — credit ids
2837
- * carry their own colons (`imp:`, `fnd:` and the rail segment inside them).
2520
+ * Resolve the unique credit bound to the channel. The ID is opaque and can contain colons;
2521
+ * do not reconstruct it by splitting settlement effect IDs.
2838
2522
  */
2839
2523
  async getCreditByX402Channel(channelId) {
2840
2524
  const { rows } = await this.pool.query(
@@ -2843,7 +2527,6 @@ var CreditLedger = class {
2843
2527
  );
2844
2528
  return rows[0] ? this.snapshotFromRow(rows[0], this.pool, Date.now()) : void 0;
2845
2529
  }
2846
- /** Retire every active credit backed by a chain-proven lost Tempo channel. */
2847
2530
  async terminalizeTempoCredits(evidence, tx) {
2848
2531
  const channelId = evidence.channelId.toLowerCase();
2849
2532
  const observedAt = evidence.observedAt ?? Date.now();
@@ -2909,7 +2592,6 @@ var CreditLedger = class {
2909
2592
  }
2910
2593
  return this.readTempoCreditLosses(q, { channelId });
2911
2594
  }
2912
- /** List terminal Tempo credit losses, newest observation first. */
2913
2595
  async listTempoCreditLosses(args) {
2914
2596
  return this.readTempoCreditLosses(this.pool, { limit: args?.limit ?? 50 });
2915
2597
  }
@@ -3003,32 +2685,18 @@ var CreditLedger = class {
3003
2685
  }
3004
2686
  return this.readX402CreditLosses(q, { channelId });
3005
2687
  }
3006
- /** List terminal x402 credit losses, newest observation first. */
3007
2688
  async listX402CreditLosses(args) {
3008
2689
  return this.readX402CreditLosses(this.pool, { limit: args?.limit ?? 50 });
3009
2690
  }
3010
2691
  /**
3011
- * Debit the caller's entire available balance into a drain liability
3012
- * (the caller-ownership contract: expiry ends spending, never ownership — this is the
3013
- * builder-honored reclaim floor). Runs under the credit row lock: the
3014
- * amount is `balance − pending holds` read under `FOR UPDATE`, the balance
3015
- * is decremented in the same transaction, and the drain takes the next
3016
- * gap-free `ledger_seq` — so the balance zeroes exactly once however many
3017
- * machines race the request.
3018
- *
3019
- * Idempotent on `(creditId, drainId)`: a replay returns the recorded drain
3020
- * (this is also the poll/pickup read). A replay whose `method` or `payout`
3021
- * differs from the recorded drain is refused with `drain_conflict` —
3022
- * silently honouring a changed payout would ship the caller's money to a
3023
- * destination they never signed alongside this `drain_id`.
3024
- *
3025
- * Pending holds are NOT drained — in-flight jobs keep their money until
3026
- * they settle or release. A release after a drain restores available
3027
- * balance the caller reclaims with a **new** `drain_id`; drains are ledger
3028
- * arithmetic, not a terminal credit state, so `status` stays `active`.
3029
- *
3030
- * Deliberately **no expiry check**: draining works before and after
3031
- * expiry. The one thing expiry gates is new draws.
2692
+ * Move all available balance to a drain liability under the credit lock; pending holds stay
2693
+ * reserved. Expiry alone does not prevent reclaim before a sweep releases the balance.
2694
+ * Repeating (creditId, drainId) returns the recorded liability; owner, method and payout
2695
+ * conflicts refuse.
2696
+ *
2697
+ * An unresolved channel refund blocks fresh drain IDs but admits its own effect ID for
2698
+ * repair. Caller-supplied payout validation belongs to the route. With tx, join the channel
2699
+ * transaction without controlling it.
3032
2700
  */
3033
2701
  async requestDrain(args) {
3034
2702
  const nowMs = args.nowMs ?? Date.now();
@@ -3138,7 +2806,6 @@ var CreditLedger = class {
3138
2806
  }
3139
2807
  return this.withCreditLock(args.creditId, run);
3140
2808
  }
3141
- /** Read one drain (no lock), joined with its credit for currency + owner. */
3142
2809
  async getDrain(args) {
3143
2810
  const { rows } = await this.pool.query(
3144
2811
  `SELECT d.*, ${DRAIN_CREDIT_COLUMNS} FROM credit_drains d
@@ -3157,25 +2824,11 @@ var CreditLedger = class {
3157
2824
  return this.openLots(this.pool, creditId);
3158
2825
  }
3159
2826
  /**
3160
- * What this DVM owes back in satoshis if every open non-channel Bitcoin
3161
- * credit reclaimed right now — the deposit half of the hub
3162
- * balance, and the floor a payout sweep must never go below.
3163
- *
3164
- * Read entirely off the funding lots, at their own funding rates, with no
3165
- * exchange-rate lookup anywhere: the obligation is in kind, so a rate has
3166
- * nothing to say about it. Pending job holds are deliberately **not**
3167
- * subtracted — a hold either settles into revenue or releases back to the
3168
- * caller, and until it does, treating it as already earned would understate
3169
- * the floor. The figure is conservative by exactly that amount.
3170
- *
3171
- * The two coverage holes ride along rather than being folded in silently, so
3172
- * a gap reads as a gap: `uncovered` is a credit whose lots fall short of its
3173
- * balance, `unbacked` is one holding any open micro with no sats basis. A
3174
- * reclaim on either prices the **whole** balance off a live rate — the
3175
- * depletion refuses a mixture rather than blending two bases — so both are
3176
- * reported as the whole balance, and neither contributes to `depositSats`.
3177
- * A figure that counted the backed half of a mixed credit would be a floor
3178
- * the payout never matches.
2827
+ * Host-wide in-kind Bitcoin deposit liability, excluding channel collateral. Credits with
2828
+ * missing coverage or any basisless lot are reported as gaps and use the live-rate fallback
2829
+ * rather than a guessed sats obligation. Group fiat micro by currency; never sum
2830
+ * denominations.
2831
+ * Pending job holds remain liabilities until settled because they may release to the caller.
3179
2832
  */
3180
2833
  async bitcoinDepositLiability() {
3181
2834
  const { rows } = await this.pool.query(
@@ -3203,21 +2856,43 @@ var CreditLedger = class {
3203
2856
  byRail: [],
3204
2857
  byCurrency: [],
3205
2858
  uncoveredCredits: 0,
3206
- uncoveredMicro: 0,
2859
+ uncoveredByCurrency: [],
3207
2860
  unbackedCredits: 0,
3208
- unbackedMicro: 0
2861
+ unbackedByCurrency: []
3209
2862
  };
3210
2863
  const byRail = /* @__PURE__ */ new Map();
3211
2864
  const byCurrency = /* @__PURE__ */ new Map();
2865
+ const uncoveredByCurrency = /* @__PURE__ */ new Map();
2866
+ const unbackedByCurrency = /* @__PURE__ */ new Map();
3212
2867
  for (const row of rows) {
3213
2868
  const sats = toSafeInt(row.owed_sats);
3214
2869
  const credits = toSafeInt(row.credits);
2870
+ const uncoveredCredits = toSafeInt(row.uncovered_credits);
2871
+ const unbackedCredits = toSafeInt(row.unbacked_credits);
3215
2872
  liability.depositSats += sats;
3216
2873
  liability.credits += credits;
3217
- liability.uncoveredCredits += toSafeInt(row.uncovered_credits);
3218
- liability.uncoveredMicro += toSafeInt(row.uncovered_micro);
3219
- liability.unbackedCredits += toSafeInt(row.unbacked_credits);
3220
- liability.unbackedMicro += toSafeInt(row.unbacked_micro);
2874
+ liability.uncoveredCredits += uncoveredCredits;
2875
+ liability.unbackedCredits += unbackedCredits;
2876
+ if (uncoveredCredits > 0) {
2877
+ const currency2 = uncoveredByCurrency.get(row.currency) ?? {
2878
+ currency: row.currency,
2879
+ micro: 0,
2880
+ credits: 0
2881
+ };
2882
+ currency2.micro += toSafeInt(row.uncovered_micro);
2883
+ currency2.credits += uncoveredCredits;
2884
+ uncoveredByCurrency.set(row.currency, currency2);
2885
+ }
2886
+ if (unbackedCredits > 0) {
2887
+ const currency2 = unbackedByCurrency.get(row.currency) ?? {
2888
+ currency: row.currency,
2889
+ micro: 0,
2890
+ credits: 0
2891
+ };
2892
+ currency2.micro += toSafeInt(row.unbacked_micro);
2893
+ currency2.credits += unbackedCredits;
2894
+ unbackedByCurrency.set(row.currency, currency2);
2895
+ }
3221
2896
  const rail = byRail.get(row.rail) ?? { rail: row.rail, sats: 0, credits: 0 };
3222
2897
  rail.sats += sats;
3223
2898
  rail.credits += credits;
@@ -3233,6 +2908,8 @@ var CreditLedger = class {
3233
2908
  }
3234
2909
  liability.byRail = [...byRail.values()];
3235
2910
  liability.byCurrency = [...byCurrency.values()];
2911
+ liability.uncoveredByCurrency = [...uncoveredByCurrency.values()];
2912
+ liability.unbackedByCurrency = [...unbackedByCurrency.values()];
3236
2913
  return liability;
3237
2914
  }
3238
2915
  /**
@@ -3252,34 +2929,10 @@ var CreditLedger = class {
3252
2929
  return rows.map(drainRecordFromJoinRow);
3253
2930
  }
3254
2931
  /**
3255
- * One page of still-`pending` drains on a channel-backed credit, oldest
3256
- * first (no lock) — the operator's repair queue.
3257
- *
3258
- * Keyset-paginated for the x402 settlement queue's reason: a wedged row
3259
- * never self-clears, so a caller that re-issues the same first page would
3260
- * never see past a `limit`-sized wall of them. The keyset is
3261
- * `(created_at, credit_id, drain_id)` — `created_at` is immutable, so no
3262
- * concurrent write can push an unexamined row past the cursor. It is a
3263
- * BIGINT of epoch milliseconds rather than a `TIMESTAMPTZ`, so the
3264
- * microsecond-vs-millisecond cursor trap that bit `listSettlements` cannot
3265
- * arise here: the value round-trips as the integer it already is.
3266
- *
3267
- * `createdBeforeMs` is the staleness floor. A healthy cooperative close
3268
- * moves the drain to `sent` inside the request that opened it, so anything
3269
- * younger is in flight rather than stuck.
3270
- *
3271
- * The method filter is `'tempo'`, and it is the one line here worth a second
3272
- * look: `init()` migrates the legacy `'mpp'` spelling away and
3273
- * `DrainMethod` no longer carries it, so a query naming it matches nothing a
3274
- * DVM has ever written. It named it anyway until rename sweep and
3275
- * live rung caught it independently — the whole operator queue
3276
- * read empty in production while every route test stayed green, because
3277
- * those run on `MemoryCreditLedger`, which filters on the typed value. The
3278
- * two implementations of this one predicate must be read together.
3279
- *
3280
- * A written-off row is excluded unless `includeResolved`: the operator has
3281
- * already decided about it, and leaving it on the live queue would re-serve
3282
- * a closed decision on every walk.
2932
+ * Page pending Tempo-channel drains by (created_at, credit_id, drain_id), optionally
2933
+ * age-bounded, excluding written-off rows. Accept the legacy mpp stored method as well as
2934
+ * tempo so pre-migration drains remain repairable. Use the compound key for both ordering
2935
+ * and continuation.
3283
2936
  */
3284
2937
  async listChannelDrains(args) {
3285
2938
  const params = [args.createdBeforeMs, args.limit];
@@ -3495,11 +3148,9 @@ var CreditLedger = class {
3495
3148
  });
3496
3149
  }
3497
3150
  /**
3498
- * Append a countersigned reclaim-event receipt to the drain's evidence
3499
- * trail. A single-statement JSONB append — receipts are emitted after the
3500
- * state transition they attest, and losing one never loses money (the
3501
- * transition is the source of truth; the receipt is the proof the caller
3502
- * can carry away).
3151
+ * Append an opaque supplied receipt observation to the drain’s JSONB evidence array.
3152
+ * Issuance and authentication belong to callers/consumers; this store does not verify
3153
+ * signatures. Preserve append order on replay/poll reads.
3503
3154
  */
3504
3155
  async appendDrainReceipt(args) {
3505
3156
  const q = args.tx ?? this.pool;
@@ -3516,7 +3167,6 @@ var CreditLedger = class {
3516
3167
  );
3517
3168
  return rows[0];
3518
3169
  }
3519
- /** Re-read the credit join fields for a freshly-updated drain row. */
3520
3170
  async rejoin(q, row) {
3521
3171
  const { rows } = await q.query(
3522
3172
  `SELECT currency, caller_pubkey, tempo_channel_id, x402_channel_id
@@ -3525,7 +3175,6 @@ var CreditLedger = class {
3525
3175
  );
3526
3176
  return drainRecordFromRow(row, rows[0]);
3527
3177
  }
3528
- /** Shared refusal tail for the CAS transitions: idempotent replay or typed error. */
3529
3178
  async refuseTransition(q, creditId, drainId, opts) {
3530
3179
  const row = await this.readDrain(q, creditId, drainId);
3531
3180
  if (!row) {
@@ -3541,26 +3190,10 @@ var CreditLedger = class {
3541
3190
  );
3542
3191
  }
3543
3192
  /**
3544
- * Price a reclaim in kind and say which lots it takes.
3545
- *
3546
- * The debits are returned whether or not the reclaim can be priced, and the
3547
- * caller applies them either way: the money is leaving the credit, so the
3548
- * lots have to shrink with it or the deposit-liability rollup would keep
3549
- * counting a balance that is gone.
3550
- *
3551
- * `owedSats` is `null` where {@link isInKindDepletion} refuses — the lots
3552
- * are short of the balance, or the covering lots carry no sats basis (a
3553
- * legacy credit whose funding recorded no sats basis. That row is priced off a live rate by the
3554
- * admin surface instead, which is what *every* row did before this change,
3555
- * so the fallback is the old behaviour rather than a new failure mode.
3556
- *
3557
- * What it publishes is **net of the delivery reserve**: handing a
3558
- * refund over costs a mint fee and, when the notes have to be minted just in
3559
- * time, a Lightning hop. The caller carries that cost per and a
3560
- * flat reserve is how they carry it without the figure moving — an
3561
- * actual-fee true-up could only be applied after the promise was made. The
3562
- * debits stay gross because they are denominated in micro and are what
3563
- * `releaseDrain` restores; only the sats figure is netted.
3193
+ * Deplete FIFO lots even when they cannot price the reclaim; stale open lots must not
3194
+ * remain behind a debited balance. Publish owedSats only with complete positive sats
3195
+ * coverage, net of one delivery reserve. Otherwise preserve null for a live-rate fallback.
3196
+ * Returned debits allow an unpaid drain release to restore the exact lots.
3564
3197
  */
3565
3198
  async reclaimInKind(q, creditId, amountMicro) {
3566
3199
  const depletion = depleteLots(await this.openLots(q, creditId), amountMicro);
@@ -3605,7 +3238,6 @@ var CreditLedger = class {
3605
3238
  );
3606
3239
  }
3607
3240
  }
3608
- /** Shared settle/release state machine — see the public JSDoc on each verb. */
3609
3241
  async resolveDraw(args) {
3610
3242
  if (args.tx) {
3611
3243
  const credit = await this.lockCreditRow(args.tx, args.creditId);
@@ -3715,7 +3347,6 @@ var CreditLedger = class {
3715
3347
  client.release();
3716
3348
  }
3717
3349
  }
3718
- /** `SELECT … FOR UPDATE` the credit row on `q` (which must be inside a transaction). */
3719
3350
  async lockCreditRow(q, creditId) {
3720
3351
  const { rows } = await q.query(
3721
3352
  `SELECT * FROM credits WHERE credit_id = $1 FOR UPDATE`,