@dvmkit/sdk 0.1.0-rc.1 → 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.
@@ -739,7 +739,12 @@ type NonChannelBitcoinRail = (typeof NON_CHANNEL_BITCOIN_RAILS)[number];
739
739
  * bought, with however much of that micro is still unspent.
740
740
  *
741
741
  * `remainingMicro` is depleted FIFO by settled draws and by reclaims, so the
742
- * sum of a credit's lots tracks `credits.balance_micro` exactly.
742
+ * sum of a credit's lots tracks `credits.balance_micro` exactly — with one
743
+ * deliberate exception: an expiry release (internal-review) zeroes the balance and
744
+ * leaves the lots standing, because a revival restores that balance and its
745
+ * in-kind basis has to still be there. A released credit is excluded from the
746
+ * sweep floor by `BITCOIN_CREDIT_PREDICATE`'s own `balance_micro > 0`, so the
747
+ * over-coverage is never counted as sats the builder owes back.
743
748
  */
744
749
  interface FundingLot {
745
750
  lotId: string;
@@ -855,6 +860,14 @@ interface CreditDepositPayload {
855
860
  /** 32-byte channel id, `0x`-prefixed and lowercased. */
856
861
  channelId: string;
857
862
  };
863
+ /**
864
+ * Set by the blocked-invoice repair (internal-review): this deposit is a paid
865
+ * Lightning invoice the builder's agent reconciled onto a credit. The money
866
+ * landed in the receive wallet at payment time and this deposit is the one
867
+ * row it gets, so the settlements table types it `repair` off this flag
868
+ * rather than off a second row.
869
+ */
870
+ repaired?: boolean;
858
871
  }
859
872
  /**
860
873
  * JSON wire shape POSTed to the platform's `/_internal/credit-draw-release`
@@ -944,6 +957,64 @@ type CreditDrawReleaseEnqueue = (tx: RevenueReporterQuerier, payload: CreditDraw
944
957
  * it before committing the status CAS that moved the money.
945
958
  */
946
959
  type CreditDrainEnqueue = (tx: RevenueReporterQuerier, payload: CreditDrainPayload) => Promise<void>;
960
+ /**
961
+ * JSON wire shape POSTed to the platform's `/_internal/credit-expiry-release`
962
+ * endpoint (internal-review). A prepaid credit that reaches its TTL with an undrawn
963
+ * remainder RELEASES that remainder to the builder: on cashu, Lightning and
964
+ * x402 the funding value reached builder custody at funding time or on the
965
+ * settlement cycle, so expiry extinguishes the caller's remaining claim.
966
+ * Reported so committed value nets to
967
+ * `deposited − drawn − drained − released`; without it an expired credit is
968
+ * owed forever on paper, exactly like an unreported drain.
969
+ *
970
+ * Never sent for a Tempo credit. Tempo balances are channel-backed and undrawn
971
+ * channel value returns to the *caller* at exit, so no release exists on that
972
+ * rail — the sweep skips them and the platform refuses one outright.
973
+ *
974
+ * Two reports share this shape, distinguished only by `reversedAt`:
975
+ *
976
+ * - **The release**, written by the expiry sweep with `reversedAt` absent.
977
+ * - **The reversal**, written when a later funding revives the credit
978
+ * (`CreditLedger.fund` overwrites `expiry_ms`, so the recorded remainder is
979
+ * no longer final). It repeats the whole payload rather than sending a
980
+ * reference, because the retry loop can deliver the two out of order and the
981
+ * platform's upsert must be able to land either one first.
982
+ *
983
+ * Idempotent on `(dvmId, creditId, releaseId)`, so the shared retry loop can
984
+ * redeliver freely.
985
+ */
986
+ interface CreditExpiryReleasePayload {
987
+ /** Required. Platform DVM record ID (`dvms.id`); must match the bearer token's DVM. */
988
+ dvmId: string;
989
+ /** Required. Credit whose remainder expired. */
990
+ creditId: string;
991
+ /** Required. Per-expiry-episode key; the platform idempotency key with DVM and credit. */
992
+ releaseId: string;
993
+ /** Required. Caller whose claim on the remainder ended. */
994
+ callerPubkey: string;
995
+ /** Funding rail. `null` on a credit funded before the basis became mandatory. */
996
+ rail: string | null;
997
+ /** Required. Fiat micro released — the liability this expiry extinguishes. */
998
+ amountMicro: number;
999
+ /** Required. Currency `amountMicro` is 1e-6 of. */
1000
+ creditCurrency: string;
1001
+ /** Required. When the credit's TTL ran out (epoch ms) — when the liability ended. */
1002
+ expiryMs: number;
1003
+ /** Required. When the sweep observed the expiry (epoch ms). */
1004
+ releasedAt: number;
1005
+ /** Set only on the reversal report: when the revival funding landed (epoch ms). */
1006
+ reversedAt?: number | null;
1007
+ }
1008
+ /**
1009
+ * Transactional expiry-release outbox seam, threaded through the sweep's insert
1010
+ * and through the funding that reverses a release (internal-review).
1011
+ *
1012
+ * Implemented by {@link RevenueReporter.enqueueCreditExpiryRelease}; callers
1013
+ * must await it before committing the write that made the release (or its
1014
+ * reversal) true, for the same reason the drain seam does — a report lost
1015
+ * between the two overstates or understates committed value forever.
1016
+ */
1017
+ type CreditExpiryReleaseEnqueue = (tx: RevenueReporterQuerier, payload: CreditExpiryReleasePayload) => Promise<void>;
947
1018
  /** Distinct causes for a settled credit draw that could not book revenue. */
948
1019
  type RevenueSkippedNoRailReason = "credit_rail_null" | "credit_rail_unrecognized" | "job_rail_missing" | "ledger_not_configured";
949
1020
  /**
@@ -1451,6 +1522,7 @@ declare class CreditLedger {
1451
1522
  */
1452
1523
  readonly durable = true;
1453
1524
  private x402Settlements?;
1525
+ private expiryReleaseOutbox?;
1454
1526
  constructor(pool: CreditLedgerPool, tempoSessionStore?: TempoSessionSettlementStore | undefined);
1455
1527
  /**
1456
1528
  * Bind the durable x402 settlement state this ledger gates spending on
@@ -1462,6 +1534,22 @@ declare class CreditLedger {
1462
1534
  * channel storage, where no settlement row can exist to wedge.
1463
1535
  */
1464
1536
  useX402SettlementGate(gate: X402RefundSettlementGate): void;
1537
+ /**
1538
+ * Bind the durable outbox that reports credit-expiry releases and their
1539
+ * revivals to the platform (internal-review).
1540
+ *
1541
+ * Held on the ledger rather than threaded through {@link fund} for the same
1542
+ * reason as the gate above: the revival half fires from *every* funding path
1543
+ * — Cashu commit, x402 exact and channel, Tempo, the Lightning invoice
1544
+ * settle, the implicit N=1 per-call payment — and a seam each of those has to
1545
+ * remember to pass is a seam one of them will eventually forget.
1546
+ *
1547
+ * Unbound (a `dvmctl dev` server, a self-hosted builder, a test host) the
1548
+ * sweep and the reversal still run and still write the ledger's own rows;
1549
+ * only the platform report is skipped. Reporting is bookkeeping and must
1550
+ * never gate the ledger.
1551
+ */
1552
+ useCreditExpiryReleaseOutbox(outbox: CreditExpiryReleaseOutbox): void;
1465
1553
  /** Create the `credits` / `credit_draws` tables if absent. Call once at SDK boot. */
1466
1554
  init(): Promise<void>;
1467
1555
  /** The boot DDL itself — always runs under {@link withSdkInitLock} (internal-review). */
@@ -1499,9 +1587,15 @@ declare class CreditLedger {
1499
1587
  *
1500
1588
  * Pass `tx` (a client inside a caller-owned `BEGIN`) to commit the rail
1501
1589
  * receive and the ledger credit atomically (spec condition 3 — the internal-review
1502
- * verifier does this). The ledger issues **no** transaction control on `tx`;
1503
- * the upsert is a single statement, so without `tx` it is equally atomic on
1504
- * the pool.
1590
+ * verifier does this). The ledger issues **no** transaction control on `tx`.
1591
+ *
1592
+ * Without `tx` it opens one of its own, because a funding is no longer a
1593
+ * single statement: it upserts the credit, records its funding lot
1594
+ * (internal-review), and reverses any standing expiry release (internal-review) — and that
1595
+ * last leg restores balance and queues a report. A crash between the upsert
1596
+ * and the reversal would leave a revived credit whose release still stands,
1597
+ * which the sweep's own exclusion then makes permanent: `balance_micro > 0`
1598
+ * but a standing release means it is neither drainable nor re-releasable.
1505
1599
  *
1506
1600
  * `basis` records the rail value behind the fiat (internal-review) so each draw can
1507
1601
  * be allocated its share of the rail-native amount actually received. A
@@ -1538,21 +1632,110 @@ declare class CreditLedger {
1538
1632
  * below. This method is the authority on that rule, as it is on the binding
1539
1633
  * rules above; every door preflights it where a refusal is still free.
1540
1634
  */
1541
- fund(args: {
1542
- /** Omit to mint a fresh credit id; provide to top up (or idempotently create). */
1543
- creditId?: string;
1544
- callerPubkey: string;
1545
- /** The DVM's pricing currency; `amountMicro` is 1e-6 of it. */
1546
- currency: string;
1547
- amountMicro: number;
1548
- /** Absolute expiry, ms since epoch. Overwrites the previous value on top-up. */
1549
- expiryMs: number;
1550
- /** Rail-value basis this funding contributes (internal-review, required by internal-review). */
1551
- basis: CreditFundingBasis;
1552
- /** External transaction handle — caller owns BEGIN/COMMIT (spec condition 3). */
1553
- tx?: CreditLedgerQuerier;
1635
+ fund(args: CreditFundArgs): Promise<CreditSnapshot>;
1636
+ /**
1637
+ * The funding itself, on whichever handle {@link fund} chose. Every refusal
1638
+ * it raises is the caller's to see unchanged; a self-opened transaction rolls
1639
+ * back around it.
1640
+ */
1641
+ private fundOn;
1642
+ /**
1643
+ * Undo any expiry release this credit still carries, because a funding just
1644
+ * landed on it (internal-review the revival rule), restoring the balance the
1645
+ * release took.
1646
+ *
1647
+ * A release records the credit's **final** undrawn remainder and zeroes it
1648
+ * (see {@link releaseExpiredCreditLocked}). Funding adds to the balance and
1649
+ * overwrites `expiry_ms` (spec §5 carry-forward: the credit is a rolling
1650
+ * buffer), so the moment money arrives the recorded remainder is no longer
1651
+ * final: the release stops counting and its micro come back.
1652
+ *
1653
+ * Deliberately unconditional on the incoming expiry rather than gated on
1654
+ * `expiryMs > nowMs`. A release exists only for a credit that had already
1655
+ * lapsed, so any funding that reaches one is money the release did not
1656
+ * account for — and a funding that arrives with an already-past expiry leaves
1657
+ * a credit the very next sweep re-releases at its new, larger remainder. One
1658
+ * rule, self-healing in both directions, and it never needs to read the
1659
+ * pre-funding expiry the upsert has already overwritten.
1660
+ *
1661
+ * Runs on the funding's own handle — which {@link fund} now guarantees is a
1662
+ * transaction — so the reversal, the restored balance and the report commit
1663
+ * with the money or not at all. A reversal that committed without its report
1664
+ * would be unrecoverable: `WHERE reversed_at IS NULL` means no later pass
1665
+ * re-derives it.
1666
+ *
1667
+ * @returns the credit row as the restore left it, or undefined when there was
1668
+ * nothing to reverse. The caller reads its snapshot off this rather than off
1669
+ * the funding upsert's `RETURNING`, which predates the restore.
1670
+ */
1671
+ private reverseExpiryReleases;
1672
+ /**
1673
+ * Release the undrawn remainder of every credit whose TTL has run out
1674
+ * (internal-review). Returns the releases this pass recorded.
1675
+ *
1676
+ * **What is released.** The credit's whole `balance_micro`. Expiry ends
1677
+ * spending but never ownership of the record, so the balance stays readable;
1678
+ * what ends is the caller's claim on it, and on the prepaid rails the value
1679
+ * behind it is already in builder custody.
1680
+ *
1681
+ * **Tempo is excluded**, at both of its markers (`tempo_channel_id` and a
1682
+ * `tempo` rail). A Tempo credit is channel-backed and its undrawn value
1683
+ * returns to the *caller* at channel exit, so releasing it would book the
1684
+ * builder money the chain is about to hand back.
1685
+ *
1686
+ * **A credit with a pending hold is skipped, not partially released.** A draw
1687
+ * placed before expiry stays settleable afterwards (`draw`'s replay lookup
1688
+ * runs above the expiry check, so a lost response is still recoverable), so
1689
+ * the remainder is not final while a hold is outstanding. Skipping costs one
1690
+ * sweep interval and keeps the released figure exactly "what nothing bought";
1691
+ * holds do resolve — the orphan-draw watchdog (internal-review) is what guarantees
1692
+ * a stranded one still reaches a terminal state.
1693
+ *
1694
+ * **Idempotent** two ways. `release_id` is derived from the expiry instant,
1695
+ * so a re-sweep after a crash between the insert and its report collides on
1696
+ * the same primary key instead of writing a second row; and the candidate
1697
+ * scan excludes any credit that already has an un-reversed release.
1698
+ *
1699
+ * Each candidate is re-checked under the credit row lock every
1700
+ * `credit_draws` write is taken under, so a draw racing the sweep either
1701
+ * loses the race (its credit is already released and it would have been
1702
+ * refused `credit_expired` anyway) or wins it and leaves a pending hold the
1703
+ * locked re-check sees.
1704
+ */
1705
+ sweepExpiredCredits(args?: {
1554
1706
  nowMs?: number;
1555
- }): Promise<CreditSnapshot>;
1707
+ /** Credits released per pass. Bounds the work a long-idle DVM does at boot. */
1708
+ limit?: number;
1709
+ }): Promise<{
1710
+ released: CreditExpiryRelease[];
1711
+ }>;
1712
+ /**
1713
+ * Write one credit's expiry release under its row lock, zero the balance it
1714
+ * released, and re-assert every condition the unlocked candidate scan tested.
1715
+ * Returns undefined when the credit no longer qualifies — funded, drawn
1716
+ * against, or already released between the scan and the lock.
1717
+ *
1718
+ * **Zeroing the balance is what makes the release real**, and it is the whole
1719
+ * reason this runs under the lock rather than as a bare INSERT. The row alone
1720
+ * records that the caller's claim ended; it does not *end* it. `requestDrain`
1721
+ * has no expiry check by design (expiry gates new draws, not reclaims), so a
1722
+ * credit whose remainder had been released and reported was still fully
1723
+ * drainable: the DVM would pay out money the platform had already booked as
1724
+ * the builder's, and `deposited - drawn - drained - released` would go
1725
+ * negative on the same micro. With the balance at zero, `requestDrain`'s
1726
+ * existing `availableMicro <= 0` guard refuses with `nothing_to_drain` and no
1727
+ * new check is needed anywhere. {@link reverseExpiryReleases} puts the
1728
+ * balance back when a funding revives the credit.
1729
+ *
1730
+ * `status` deliberately stays `active`: a released credit must remain
1731
+ * fundable, or the revival rule has nothing to revive.
1732
+ *
1733
+ * The zeroed balance also takes the credit out of `BITCOIN_CREDIT_PREDICATE`
1734
+ * (`balance_micro > 0`), so the in-kind sweep floor stops counting sats the
1735
+ * builder no longer owes back — and counts them again after a revival, with
1736
+ * the credit's funding lots untouched throughout.
1737
+ */
1738
+ private releaseExpiredCreditLocked;
1556
1739
  /**
1557
1740
  * Record this funding's in-kind basis as a lot (internal-review).
1558
1741
  *
@@ -2054,7 +2237,15 @@ declare class CreditLedger {
2054
2237
  listTempoCreditLosses(args?: {
2055
2238
  limit?: number;
2056
2239
  }): Promise<TempoCreditLoss[]>;
2057
- /** Retire the active credit bound to a chain-proven empty x402 channel. */
2240
+ /**
2241
+ * Reconcile the credit bound to a chain-proven empty x402 channel.
2242
+ *
2243
+ * A channel with no unclaimed backing makes every remaining customer
2244
+ * liability unsafe, so those credits become terminal. A fully settled credit
2245
+ * has neither a balance nor an unfinished drain and is safe to retain for a
2246
+ * later deposit. This also repairs a zero-liability row an older build
2247
+ * terminalized from the same observation.
2248
+ */
2058
2249
  terminalizeX402Credit(evidence: X402CreditLossEvidence, tx?: CreditLedgerQuerier): Promise<X402CreditLoss[]>;
2059
2250
  private terminalizeX402CreditLocked;
2060
2251
  /** List terminal x402 credit losses, newest observation first. */
@@ -2386,6 +2577,27 @@ interface CreditLedgerLike {
2386
2577
  * {@link CreditLedger.useX402SettlementGate}.
2387
2578
  */
2388
2579
  useX402SettlementGate?(gate: X402RefundSettlementGate): void;
2580
+ /**
2581
+ * Bind the outbox that reports credit-expiry releases and their revivals
2582
+ * (internal-review).
2583
+ *
2584
+ * Optional, and absent on `MemoryCreditLedger`: an expiry release is a
2585
+ * durable liability fact, and a ledger whose balances do not survive a
2586
+ * restart has none to report. A host on a non-durable ledger simply never
2587
+ * sweeps. See {@link CreditLedger.useCreditExpiryReleaseOutbox}.
2588
+ */
2589
+ useCreditExpiryReleaseOutbox?(outbox: CreditExpiryReleaseOutbox): void;
2590
+ /**
2591
+ * Release the undrawn remainder of every credit past its TTL (internal-review).
2592
+ * Optional on the same terms as the binder above. See
2593
+ * {@link CreditLedger.sweepExpiredCredits}.
2594
+ */
2595
+ sweepExpiredCredits?(args?: {
2596
+ nowMs?: number;
2597
+ limit?: number;
2598
+ }): Promise<{
2599
+ released: CreditExpiryRelease[];
2600
+ }>;
2389
2601
  /**
2390
2602
  * Ask that gate directly, so a route can refuse a wedged channel **before**
2391
2603
  * the rail moves anything (internal-review). Optional on the same terms as the
@@ -2641,6 +2853,38 @@ interface CreditDrawReleaseOutbox {
2641
2853
  /** Reporter-owned insert executed through the ledger transaction. */
2642
2854
  enqueue: CreditDrawReleaseEnqueue;
2643
2855
  }
2856
+ /** Reporter seam for credit-expiry releases and the revivals that reverse them (internal-review). */
2857
+ interface CreditExpiryReleaseOutbox {
2858
+ /** Platform DVM record ID bound into the report. */
2859
+ dvmId: string;
2860
+ /** Reporter-owned insert executed through the ledger transaction. */
2861
+ enqueue: CreditExpiryReleaseEnqueue;
2862
+ }
2863
+ /**
2864
+ * One recorded expiry episode: a credit's undrawn remainder passing to the
2865
+ * builder when its TTL ran out (internal-review).
2866
+ *
2867
+ * `reversedAt` is set when a later funding revived the credit, at which point
2868
+ * the release stops counting against committed value — the row stays as the
2869
+ * record that it once did.
2870
+ */
2871
+ interface CreditExpiryRelease {
2872
+ creditId: string;
2873
+ /** Per-expiry-episode key, derived from {@link CreditExpiryRelease.expiryMs}. */
2874
+ releaseId: string;
2875
+ callerPubkey: string;
2876
+ currency: string;
2877
+ /** Funding rail, or `null` on a credit funded before the basis was mandatory. */
2878
+ rail: string | null;
2879
+ /** Fiat micro released, 1e-6 of `currency`. */
2880
+ amountMicro: number;
2881
+ /** When the credit's TTL ran out — when the liability ended. */
2882
+ expiryMs: number;
2883
+ /** When the sweep observed the expiry. */
2884
+ releasedAt: number;
2885
+ /** When a funding revived the credit and undid this release. */
2886
+ reversedAt: number | null;
2887
+ }
2644
2888
  /** Pool-shaped handle (`query` + `connect`). Required for the locked write paths. */
2645
2889
  type CreditLedgerPool = Pick<Pool, "query" | "connect">;
2646
2890
  /**
@@ -3059,6 +3303,22 @@ interface CreditFundingBasis {
3059
3303
  maxClaimableAmount: string;
3060
3304
  };
3061
3305
  }
3306
+ /** What {@link CreditLedger.fund} takes. Named so the private body can share the shape. */
3307
+ interface CreditFundArgs {
3308
+ /** Omit to mint a fresh credit id; provide to top up (or idempotently create). */
3309
+ creditId?: string;
3310
+ callerPubkey: string;
3311
+ /** The DVM's pricing currency; `amountMicro` is 1e-6 of it. */
3312
+ currency: string;
3313
+ amountMicro: number;
3314
+ /** Absolute expiry, ms since epoch. Overwrites the previous value on top-up. */
3315
+ expiryMs: number;
3316
+ /** Rail-value basis this funding contributes (internal-review, required by internal-review). */
3317
+ basis: CreditFundingBasis;
3318
+ /** External transaction handle — caller owns BEGIN/COMMIT (spec condition 3). */
3319
+ tx?: CreditLedgerQuerier;
3320
+ nowMs?: number;
3321
+ }
3062
3322
  /**
3063
3323
  * Allocate a draw's share of a credit's unspent rail value (internal-review). Pure —
3064
3324
  * shared by the Postgres and in-memory ledgers so the two can't drift.
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  MemoryCreditLedger
3
- } from "./chunk-NTK5DJ6R.js";
4
- import "./chunk-RPXHKMYE.js";
3
+ } from "./chunk-OJ5WFIB2.js";
4
+ import "./chunk-365P52XQ.js";
5
5
  import "./chunk-H25M54MI.js";
6
6
  import "./chunk-S3XAHZQY.js";
7
7
  export {