@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,7 +1,7 @@
1
1
  import {
2
2
  MemoryProcessedPaymentStore,
3
3
  ProcessedPaymentReplayError
4
- } from "./chunk-QT4ONTST.js";
4
+ } from "./chunk-XRLKGCAW.js";
5
5
  import {
6
6
  createX402FacilitatorAuthHeaders,
7
7
  createX402FacilitatorClient,
@@ -10,7 +10,10 @@ import {
10
10
  } from "./chunk-BQ2NMWKE.js";
11
11
  import {
12
12
  MemoryCreditLedger
13
- } from "./chunk-PHHAYRQV.js";
13
+ } from "./chunk-U5VNEALI.js";
14
+ import {
15
+ PostgresJobStore
16
+ } from "./chunk-HHTL3ZFT.js";
14
17
  import {
15
18
  SIGNED_ENVELOPE_FIELDS,
16
19
  SignedRequestError,
@@ -35,7 +38,7 @@ import {
35
38
  localMessage,
36
39
  providerMessage,
37
40
  toJobRecord
38
- } from "./chunk-JDT5LCJC.js";
41
+ } from "./chunk-MD3H3BF7.js";
39
42
  import {
40
43
  ADMIN_AUTH_CLOCK_SKEW_SECONDS,
41
44
  ADMIN_AUTH_NONCE_TTL_SECONDS,
@@ -60,7 +63,7 @@ import {
60
63
  toLockPubkey,
61
64
  verifyCashuToken,
62
65
  verifyChallenge
63
- } from "./chunk-KXCKFIJI.js";
66
+ } from "./chunk-QX52F6IB.js";
64
67
  import {
65
68
  X402_BATCH_SETTLEMENT_SCHEME,
66
69
  X402_DEFAULT_FACILITATOR,
@@ -83,7 +86,7 @@ import {
83
86
  msatsToUsd,
84
87
  msatsToUsdc,
85
88
  usdcToMsats
86
- } from "./chunk-5URG56JJ.js";
89
+ } from "./chunk-A4CM2BD3.js";
87
90
  import {
88
91
  resolveRpcOverride
89
92
  } from "./chunk-MKI6OVW4.js";
@@ -100,7 +103,7 @@ import {
100
103
  pinDimension,
101
104
  withSpan,
102
105
  withTraceContext
103
- } from "./chunk-XQXJKJ3P.js";
106
+ } from "./chunk-YO56VCTQ.js";
104
107
  import {
105
108
  FxRateUnavailableError,
106
109
  InvalidCurrencyError,
@@ -130,10 +133,10 @@ import {
130
133
  isNonChannelBitcoinRail,
131
134
  netOwedSats,
132
135
  x402SettlementPending
133
- } from "./chunk-V7EVFLAK.js";
136
+ } from "./chunk-2D75MNNG.js";
134
137
  import {
135
138
  withSdkInitLock
136
- } from "./chunk-S3XAHZQY.js";
139
+ } from "./chunk-COYKJRMM.js";
137
140
  import {
138
141
  withPostgresRecoveryContext
139
142
  } from "./chunk-C3MTFLC6.js";
@@ -141,7 +144,7 @@ import {
141
144
  advertisedMethods,
142
145
  challengeMeta,
143
146
  isTempoInsufficientFundsError
144
- } from "./chunk-KMZXTBLA.js";
147
+ } from "./chunk-XZ7LW4S5.js";
145
148
 
146
149
  // src/sdk/server/lock-pubkey-store.ts
147
150
  async function initLockPubkeyTable(db) {
@@ -881,16 +884,13 @@ async function buildCreditMenu(args) {
881
884
  ttl_ms: args.config.ttlSeconds * 1e3,
882
885
  currency,
883
886
  funding,
884
- // Rendered when either half has something to say: a DVM configured for
885
- // sessions only would otherwise lose the whole block the moment the gate
886
- // closed, taking its capability signal with it.
887
+ // Keep withheld Tempo capability discoverable even when no method is currently offered.
887
888
  ...(tempoMethods.length > 0 || tempoWithheld.length > 0) && {
888
889
  tempo: {
889
890
  methods: tempoMethods,
890
891
  ...tempoWithheld.length > 0 && { withheld: tempoWithheld }
891
892
  }
892
893
  },
893
- // Tied to the rail's own presence, the way `lightning_min_micro` is below:
894
894
  // a builder who overrides `paymentMethods` can drop x402 from `funding`
895
895
  // while the rail stays wired, and terms for a rail the menu doesn't offer
896
896
  // are the same misadvertisement in the other direction.
@@ -3651,7 +3651,7 @@ async function verifyUpfrontPayment(opts) {
3651
3651
  x402Requirements
3652
3652
  });
3653
3653
  }
3654
- const { verifyX402Payment } = await import("./x402-7S2EFINY.js");
3654
+ const { verifyX402Payment } = await import("./x402-CI6PSHNO.js");
3655
3655
  const receipt = await verifyX402Payment(
3656
3656
  x402Payment,
3657
3657
  x402Config,
@@ -4585,7 +4585,7 @@ async function verifyIncomingPayment(body, opts, snapshot) {
4585
4585
  snapshot
4586
4586
  });
4587
4587
  }
4588
- const { verifyX402Payment } = await import("./x402-7S2EFINY.js");
4588
+ const { verifyX402Payment } = await import("./x402-CI6PSHNO.js");
4589
4589
  const receipt = await verifyX402Payment(
4590
4590
  body.content.x402_payment,
4591
4591
  opts.x402Config,
@@ -5002,7 +5002,7 @@ async function processIncomingPayment(job, body, opts) {
5002
5002
  try {
5003
5003
  const requiredMsats = job.pendingPaymentMsats ?? 0;
5004
5004
  const requiredUsdcMicro = job.pendingX402AmountUsdcMicro !== void 0 ? BigInt(job.pendingX402AmountUsdcMicro) : BigInt(msatsToUsdc(requiredMsats, rate));
5005
- const { verifyX402Payment } = await import("./x402-7S2EFINY.js");
5005
+ const { verifyX402Payment } = await import("./x402-CI6PSHNO.js");
5006
5006
  const receipt = await verifyX402Payment(
5007
5007
  body.content.x402_payment,
5008
5008
  opts.x402Config,
@@ -6691,14 +6691,8 @@ var CREDIT_REQUEST_SCHEMA = z.object({
6691
6691
  /** Client-generated idempotency key — the same id polls/collects one drain. */
6692
6692
  drain_id: z.string().min(1),
6693
6693
  /**
6694
- * Payout rail. Required on the first request; optional on a poll.
6695
- *
6696
- * Deliberately a bare string rather than the `DrainMethod` enum, so a
6697
- * rail this build does not carry is answered by `validateDrainTarget`
6698
- * with a typed `drain_method_unsupported` naming the rails that do
6699
- * work — not by a Zod parse failure that says only "invalid enum". The
6700
- * caller of a removed rail (`lightning`) and the caller of a
6701
- * typo deserve the same actionable refusal.
6694
+ * Required on a fresh drain, optional on poll. Keep this a string so an unsupported
6695
+ * rail receives its specific typed refusal before payout-shape validation.
6702
6696
  */
6703
6697
  method: z.string().min(1).optional(),
6704
6698
  /**
@@ -6765,6 +6759,18 @@ async function handleCreditRequest(c, deps) {
6765
6759
  await deps.settleLightning(callerPubkey, body.credit_id);
6766
6760
  return handleDrain(c, deps, { callerPubkey, body, envelope });
6767
6761
  case "fund":
6762
+ if (deps.withFundingAdmission && body.fund) {
6763
+ const creditId = body.credit_id ?? derivedFundCreditId(callerPubkey, body.fund.fund_id);
6764
+ const [prior, invoice] = await Promise.all([
6765
+ deps.ledger.getFunding({ creditId, fundId: body.fund.fund_id }),
6766
+ body.fund.method === "lightning" ? deps.ledger.getInvoice({ creditId, fundId: body.fund.fund_id }) : Promise.resolve(void 0)
6767
+ ]);
6768
+ const priorCredit = prior ? await deps.ledger.get(creditId) : void 0;
6769
+ if (priorCredit?.callerPubkey === callerPubkey || invoice?.callerPubkey === callerPubkey) {
6770
+ return handleFund(c, deps, { callerPubkey, body, envelope, menu });
6771
+ }
6772
+ return deps.withFundingAdmission(c, body, () => handleFund(c, deps, { callerPubkey, body, envelope, menu }));
6773
+ }
6768
6774
  return handleFund(c, deps, { callerPubkey, body, envelope, menu });
6769
6775
  }
6770
6776
  }
@@ -6824,17 +6830,12 @@ async function handleBalance(c, deps, args) {
6824
6830
  const primary = pickPrimary(all, args.menu.currency);
6825
6831
  return c.json({
6826
6832
  op: "balance",
6827
- // Expired credits are included: expiry ends spending, never ownership
6828
- // (the caller-ownership contract), so stranded value must stay visible to whoever owns it.
6833
+ // Keep expired credit records visible. Nominal expiry alone does not release ownership;
6834
+ // an expiry sweep can later zero the undrawn balance.
6829
6835
  credits: all.map(
6830
6836
  (credit) => creditBody(credit, pendingChannels.has(credit.tempoChannelId ?? ""))
6831
6837
  ),
6832
6838
  ...primary ? { credit: creditBody(primary, pendingChannels.has(primary.tempoChannelId ?? "")) } : {},
6833
- // the funding menu the quote and 402 surfaces already advertise,
6834
- // echoed here so one read answers "what do I hold, and what may I add".
6835
- // Under the key `menu`, not `credit` — that one is the credit projection on
6836
- // this route. Carried without the caller echo fields: `credits[]` above says
6837
- // the same thing in more detail, so `deps.menu()` is called without a pubkey.
6838
6839
  menu: args.menu
6839
6840
  });
6840
6841
  }
@@ -7113,11 +7114,8 @@ async function completeTempoDrain(c, deps, record, expectedChannelId, credit) {
7113
7114
  resourcePath: c.req.path,
7114
7115
  action: "close",
7115
7116
  expectedChannelId,
7116
- // The close intent is re-asserted from inside the verifier, once the
7117
- // credential is bound to this credit's own channel and before anything
7118
- // broadcasts. Run above the call instead and a credential
7119
- // signed for another channel touches the reservation on its way to a
7120
- // refusal that was never going to close anything.
7117
+ // Reassert the intent only after the verifier binds the credential to this channel, and
7118
+ // before broadcast. A wrong-channel signature cannot create or cancel its close intent.
7121
7119
  onBound: async () => {
7122
7120
  await beginClose(expectedChannelId, record.drainId);
7123
7121
  recoveryVersion = await deps.tempoSettlementReadiness?.captureRecoveryVersion();
@@ -7419,9 +7417,6 @@ async function handleX402ChannelDrain(c, deps, args) {
7419
7417
  amount: "0",
7420
7418
  operation: "refund",
7421
7419
  effectId: `${args.credit.creditId}:${args.drainId}`,
7422
- // The voucher the caller presents is what actually gets revoked and paid,
7423
- // so the settlement layer refuses one signed for any other channel before
7424
- // it touches anything.
7425
7420
  expectedChannelId: channelId,
7426
7421
  refundBoundNative,
7427
7422
  commit: async (_funding, tx) => {
@@ -7640,12 +7635,9 @@ function drainBody(record) {
7640
7635
  status: record.status,
7641
7636
  amount_micro: record.amountMicro,
7642
7637
  currency: record.currency,
7643
- // The in-kind obligation, where there is one: a Bitcoin
7644
- // reclaim returns the deposited sats pro rata, at the rate each funding
7645
- // landed at, so this figure was fixed when the reclaim was requested and
7646
- // does not move with the market. Published beside the fiat one rather than
7647
- // instead of it — the fiat figure is what the caller's balance was
7648
- // denominated in, and both are things an agent relays.
7638
+ // Fixed in-kind Bitcoin refund obligation after the delivery reserve. It reflects the
7639
+ // funding lots remaining at request time; null means no whole-balance in-kind price was
7640
+ // available.
7649
7641
  ...record.owedSats !== null ? { owed_sats: record.owedSats } : {},
7650
7642
  ledger_seq: record.ledgerSeq,
7651
7643
  created_at: record.createdAt,
@@ -7659,17 +7651,7 @@ function drainCopy(record) {
7659
7651
  case "pending":
7660
7652
  if (record.method === "cashu") {
7661
7653
  return {
7662
- display: record.owedSats === null ? "The refund is approved and being prepared. The balance is already set aside for you." : (
7663
- // Named because it is the obligation, and because it is
7664
- // fixed: a Bitcoin refund returns the share of the Bitcoin you
7665
- // deposited that you never spent, so it does not move if the
7666
- // price does before you collect it. The mint's own
7667
- // redemption charge is named rather than glossed — it is a sat
7668
- // or two at a fee-charging mint and nothing at a free one, and
7669
- // a figure an agent relays to a human should not be a promise
7670
- // that lands short.
7671
- `The refund is approved and being prepared: ${record.owedSats} sats, the share of the Bitcoin you deposited that you haven't spent, less whatever the mint charges to hand it over. That figure won't move while you wait, whatever the price does.`
7672
- ),
7654
+ display: record.owedSats === null ? "The refund is approved and being prepared. The balance is already set aside for you." : `The refund is approved and being prepared: ${record.owedSats} sats, the share of the Bitcoin you deposited that you haven't spent, less whatever the mint charges to hand it over. That figure won't move while you wait, whatever the price does.`,
7673
7655
  hint: "Poll this op with the same drain_id until a token appears. Preparation can be delayed (e.g. the mint is temporarily down) \u2014 the refund stays owed to you either way."
7674
7656
  };
7675
7657
  }
@@ -7795,11 +7777,6 @@ async function handleFund(c, deps, args) {
7795
7777
  fundId: fund.fund_id,
7796
7778
  amountMicro: fund.amount_micro,
7797
7779
  menu,
7798
- // Lightning and MPP name their rail in the signed body. Header-attached
7799
- // Cashu and x402 are discriminated by `verifyUpfrontPayment`, which still
7800
- // refuses their mismatches before money moves. Lightning must be checked
7801
- // here because past this point the bolt11 is minted and payable; MPP is
7802
- // checked here so discovery never advertises the wrong Tempo instrument.
7803
7780
  rail: fund.method,
7804
7781
  tempoIntent: fund.method === "tempo" ? fund.intent : void 0
7805
7782
  });
@@ -8090,9 +8067,8 @@ function lightningInvoiceBlock(invoice) {
8090
8067
  invoice: invoice.bolt11,
8091
8068
  payment_hash: invoice.paymentHash,
8092
8069
  amount_msats: invoice.amountMsats,
8093
- // Named for its unit, like every other timestamp an agent reads off this
8094
- // API (`ttl_ms`, `expiry_ms`): bolt11 expiries are conventionally quoted
8095
- // in seconds, and a bare `expires_at` invites a reader to assume that.
8070
+ // Wire timestamp is milliseconds; bolt11 lifetimes are conventionally expressed in
8071
+ // seconds.
8096
8072
  expires_at_ms: invoice.expiresAt
8097
8073
  }
8098
8074
  };
@@ -8323,26 +8299,13 @@ var DRAIN_CONFLICT_COPY = {
8323
8299
  retryable: true,
8324
8300
  drainRegistered: false
8325
8301
  },
8326
- // Deliberately rail-neutral: `refundFloorEvidence` is one floor over both
8327
- // channel rails, and two doors with two answers about one shortfall is the
8328
- // drift consolidating it was meant to prevent. Tempo answers its
8329
- // own through `tempoFloorRefusal`, which reaches the same conclusion a step
8330
- // earlier and adds the one rail-specific fact — its channel is already closed.
8331
8302
  refund_floor: {
8332
8303
  display: "The refund reached the chain, but it paid less than the balance this service had recorded, so nothing was booked here. Your on-chain refund arrived; the difference has to be reconciled by the service operator.",
8333
8304
  hint: ({ creditId, drainId }) => `Report drain ${drainId} on credit ${creditId} to the service operator \u2014 only they can reconcile the difference. Retrying this drain, or requesting a new drain_id, lands here again.`,
8334
- // Terminal for the caller, unlike the two above: no amount of waiting or
8335
- // re-signing changes what the chain already paid.
8336
8305
  retryable: false,
8337
- // True even though the ledger transaction that would have written the drain
8338
- // row rolled back around this refusal. The wedged settlement row is recorded
8339
- // outside that transaction and holds this drain id as its
8340
- // `effect_id`, which is the id `X402SettlementRepair.reconcile` books the
8341
- // drain under — so the caller has to keep it. Dropping it mints a fresh one
8342
- // that the wedged-channel gate refuses as `settlement_pending` anyway, and
8343
- // re-presenting *this* id is that gate's own documented exemption. Tempo
8344
- // reaches the same `true` one artifact earlier, off a `credit_drains` row
8345
- // its close-intent reservation already committed.
8306
+ // The settlement evidence survives outside the rolled-back ledger transaction. Keep
8307
+ // drain_registered:true so the caller retains this effect ID for repair even when no
8308
+ // drain row committed.
8346
8309
  drainRegistered: true
8347
8310
  }
8348
8311
  };
@@ -8388,8 +8351,6 @@ function drainLedgerErrorResponse(error, creditId, drainId) {
8388
8351
  message: error.message,
8389
8352
  display: copy?.display,
8390
8353
  hint: copy?.hint({ creditId, drainId }),
8391
- // An unclassified conflict keeps the duplicate-payout copy's own
8392
- // `retryable`, and the id it was never told anything about.
8393
8354
  retryable: copy?.retryable,
8394
8355
  extra: { drain_registered: copy?.drainRegistered ?? false }
8395
8356
  });
@@ -8440,8 +8401,8 @@ function creditBody(credit, reconciliationPending = false) {
8440
8401
  remaining_micro: credit.availableMicro,
8441
8402
  expiry_ms: credit.expiryMs,
8442
8403
  expired: credit.expired,
8443
- // Reclaimable via op "drain" — true whenever spendable-or-
8444
- // expired value remains. Expiry ends spending, never ownership.
8404
+ // Nominal expiry does not itself reclaim ownership; a committed sweep can remove undrawn
8405
+ // value.
8445
8406
  drainable: credit.status === "active" && credit.availableMicro > 0,
8446
8407
  ...reconciliationPending && { reconciliation_pending: true },
8447
8408
  ledger_seq: credit.lastLedgerSeq
@@ -8975,11 +8936,9 @@ var X402ExactSettlementServer = class {
8975
8936
  }
8976
8937
  return this.finalize(intent, args.btcUsdRate, args.commit);
8977
8938
  }
8978
- /** Read the operator queue. */
8979
8939
  list(args) {
8980
8940
  return this.opts.store.list(args);
8981
8941
  }
8982
- /** Read durable attempt evidence for one intent. */
8983
8942
  attempts(dvmId, intentId) {
8984
8943
  return this.opts.store.attempts(dvmId, intentId);
8985
8944
  }
@@ -9127,6 +9086,269 @@ function safeError(err) {
9127
9086
  return "settlement_transport_error";
9128
9087
  }
9129
9088
 
9089
+ // src/sdk/server/hosting-control.ts
9090
+ import { createHmac, timingSafeEqual } from "crypto";
9091
+ function signHostingControl(secret, command) {
9092
+ return createHmac("sha256", secret).update(JSON.stringify([
9093
+ "dvmkit-hosting-v1",
9094
+ command.action,
9095
+ command.scope,
9096
+ command.dvmId,
9097
+ command.generation ?? null,
9098
+ command.deadlineMs ?? null,
9099
+ command.timestampMs,
9100
+ command.nonce
9101
+ ])).digest("hex");
9102
+ }
9103
+ var HostingControl = class {
9104
+ constructor(pool, dvmId, config) {
9105
+ this.pool = pool;
9106
+ this.dvmId = dvmId;
9107
+ this.config = config;
9108
+ if (Buffer.byteLength(config.secret) < 32 || !config.scope) {
9109
+ throw new Error("Hosting control requires a 32-byte secret and a non-empty scope");
9110
+ }
9111
+ }
9112
+ pool;
9113
+ dvmId;
9114
+ config;
9115
+ async init() {
9116
+ await withSdkInitLock(this.pool, async () => {
9117
+ await this.pool.query(`
9118
+ CREATE TABLE IF NOT EXISTS sdk_hosting_control (
9119
+ dvm_id TEXT NOT NULL, scope TEXT NOT NULL, state TEXT NOT NULL,
9120
+ generation BIGINT NOT NULL, deadline_ms BIGINT,
9121
+ PRIMARY KEY (dvm_id, scope)
9122
+ );
9123
+ CREATE TABLE IF NOT EXISTS sdk_hosting_nonces (
9124
+ dvm_id TEXT NOT NULL, scope TEXT NOT NULL, nonce TEXT NOT NULL,
9125
+ created_at_ms BIGINT NOT NULL,
9126
+ PRIMARY KEY (dvm_id, scope, nonce)
9127
+ );
9128
+ CREATE TABLE IF NOT EXISTS sdk_hosting_admissions (
9129
+ id TEXT PRIMARY KEY, dvm_id TEXT NOT NULL, scope TEXT NOT NULL,
9130
+ job_id TEXT NOT NULL, admitted_at_ms BIGINT NOT NULL,
9131
+ outcome TEXT NOT NULL DEFAULT 'pending', drain_deadline_ms BIGINT,
9132
+ credit_id TEXT, fund_id TEXT, payment_artifact_hash TEXT
9133
+ );
9134
+ ALTER TABLE sdk_hosting_admissions ADD COLUMN IF NOT EXISTS drain_deadline_ms BIGINT;
9135
+ ALTER TABLE sdk_hosting_admissions ADD COLUMN IF NOT EXISTS credit_id TEXT;
9136
+ ALTER TABLE sdk_hosting_admissions ADD COLUMN IF NOT EXISTS fund_id TEXT;
9137
+ ALTER TABLE sdk_hosting_admissions ADD COLUMN IF NOT EXISTS payment_artifact_hash TEXT;
9138
+ CREATE INDEX IF NOT EXISTS sdk_hosting_admissions_pending
9139
+ ON sdk_hosting_admissions (dvm_id, scope) WHERE outcome = 'pending';
9140
+ CREATE INDEX IF NOT EXISTS sdk_hosting_admissions_unresolved
9141
+ ON sdk_hosting_admissions (dvm_id, scope) WHERE outcome = 'unresolved';
9142
+ CREATE INDEX IF NOT EXISTS sdk_hosting_admissions_artifact
9143
+ ON sdk_hosting_admissions (dvm_id, scope, payment_artifact_hash)
9144
+ WHERE payment_artifact_hash IS NOT NULL;
9145
+ CREATE INDEX IF NOT EXISTS sdk_hosting_admissions_job
9146
+ ON sdk_hosting_admissions (dvm_id, job_id, scope);
9147
+ CREATE INDEX IF NOT EXISTS sdk_hosting_nonces_age ON sdk_hosting_nonces (created_at_ms);
9148
+ `);
9149
+ await this.pool.query(
9150
+ `INSERT INTO sdk_hosting_control (dvm_id, scope, state, generation)
9151
+ VALUES ($1, $2, 'accepting', 0) ON CONFLICT DO NOTHING`,
9152
+ [this.dvmId, this.config.scope]
9153
+ );
9154
+ });
9155
+ }
9156
+ async authorize(command, signature) {
9157
+ if (command.scope !== this.config.scope || command.dvmId !== this.dvmId || !Number.isSafeInteger(command.timestampMs) || Math.abs(Date.now() - command.timestampMs) > 3e5 || typeof command.nonce !== "string" || !/^[A-Za-z0-9_-]{16,128}$/.test(command.nonce) || !signature || !/^[0-9a-f]{64}$/i.test(signature)) return false;
9158
+ const actual = Buffer.from(signature, "hex");
9159
+ const expected = Buffer.from(signHostingControl(this.config.secret, command), "hex");
9160
+ if (!timingSafeEqual(actual, expected)) return false;
9161
+ const result = await this.pool.query(
9162
+ `INSERT INTO sdk_hosting_nonces (dvm_id, scope, nonce, created_at_ms)
9163
+ VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING`,
9164
+ [this.dvmId, this.config.scope, command.nonce, Date.now()]
9165
+ );
9166
+ await this.pool.query("DELETE FROM sdk_hosting_nonces WHERE created_at_ms < $1", [Date.now() - 6e5]);
9167
+ return result.rowCount === 1;
9168
+ }
9169
+ async admit(id, jobId, details) {
9170
+ const client = await this.pool.connect();
9171
+ try {
9172
+ await client.query("BEGIN");
9173
+ const { rows } = await client.query(
9174
+ `SELECT state FROM sdk_hosting_control WHERE dvm_id=$1 AND scope=$2 FOR UPDATE`,
9175
+ [this.dvmId, this.config.scope]
9176
+ );
9177
+ if (rows[0]?.state !== "accepting") {
9178
+ await client.query("ROLLBACK");
9179
+ return false;
9180
+ }
9181
+ await client.query(
9182
+ `INSERT INTO sdk_hosting_admissions
9183
+ (id, dvm_id, scope, job_id, admitted_at_ms, credit_id, fund_id, payment_artifact_hash)
9184
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
9185
+ [
9186
+ id,
9187
+ this.dvmId,
9188
+ this.config.scope,
9189
+ jobId,
9190
+ Date.now(),
9191
+ details?.creditId ?? null,
9192
+ details?.fundId ?? null,
9193
+ details?.paymentArtifactHash ?? null
9194
+ ]
9195
+ );
9196
+ await client.query("COMMIT");
9197
+ return true;
9198
+ } catch (error) {
9199
+ await client.query("ROLLBACK").catch(() => void 0);
9200
+ throw error;
9201
+ } finally {
9202
+ client.release();
9203
+ }
9204
+ }
9205
+ async acceptedJobByPaymentArtifactHash(hash2) {
9206
+ const { rows } = await this.pool.query(
9207
+ `SELECT a.job_id FROM sdk_hosting_admissions a
9208
+ JOIN jobs j ON j.id=a.job_id
9209
+ WHERE a.dvm_id=$1 AND a.scope=$2 AND a.payment_artifact_hash=$3
9210
+ AND a.outcome IN ('accepted', 'unresolved', 'pending')
9211
+ ORDER BY a.admitted_at_ms DESC LIMIT 1`,
9212
+ [this.dvmId, this.config.scope, hash2]
9213
+ );
9214
+ return rows[0]?.job_id;
9215
+ }
9216
+ async rebindAdmissionJob(id, jobId) {
9217
+ await this.pool.query(
9218
+ `UPDATE sdk_hosting_admissions SET job_id=$2 WHERE id=$1 AND outcome='pending'`,
9219
+ [id, jobId]
9220
+ );
9221
+ }
9222
+ async finishAdmission(id, outcome, jobId) {
9223
+ await this.pool.query(
9224
+ `UPDATE sdk_hosting_admissions SET outcome=$2, job_id=COALESCE($3, job_id)
9225
+ WHERE id=$1 AND outcome='pending'`,
9226
+ [id, outcome, jobId ?? null]
9227
+ );
9228
+ }
9229
+ async jobDeadline(jobId) {
9230
+ const { rows } = await this.pool.query(
9231
+ `SELECT MAX(drain_deadline_ms)::text AS drain_deadline_ms
9232
+ FROM sdk_hosting_admissions WHERE dvm_id=$1 AND scope=$2 AND job_id=$3`,
9233
+ [this.dvmId, this.config.scope, jobId]
9234
+ );
9235
+ return rows[0]?.drain_deadline_ms === null || !rows[0] ? null : Number(rows[0].drain_deadline_ms);
9236
+ }
9237
+ async transition(command) {
9238
+ if (command.action !== "drain" && command.action !== "resume" || !Number.isSafeInteger(command.generation) || command.generation < 1 || command.action === "drain" && !Number.isSafeInteger(command.deadlineMs) || command.action === "resume" && command.deadlineMs !== void 0) return "conflict";
9239
+ const client = await this.pool.connect();
9240
+ try {
9241
+ await client.query("BEGIN");
9242
+ const { rows } = await client.query(
9243
+ `SELECT state, generation, deadline_ms FROM sdk_hosting_control
9244
+ WHERE dvm_id=$1 AND scope=$2 FOR UPDATE`,
9245
+ [this.dvmId, this.config.scope]
9246
+ );
9247
+ const prior = rows[0];
9248
+ const generation = Number(prior.generation);
9249
+ const nextState = command.action === "drain" ? "draining" : "accepting";
9250
+ if (command.generation === generation && prior.state === nextState && Number(prior.deadline_ms ?? 0) === (command.deadlineMs ?? 0)) {
9251
+ await client.query("COMMIT");
9252
+ return "ok";
9253
+ }
9254
+ if (command.generation <= generation) {
9255
+ await client.query("ROLLBACK");
9256
+ return "conflict";
9257
+ }
9258
+ if (command.action === "drain" && (command.deadlineMs <= Date.now() || command.deadlineMs > Date.now() + 864e5)) {
9259
+ await client.query("ROLLBACK");
9260
+ return "conflict";
9261
+ }
9262
+ await client.query(
9263
+ `UPDATE sdk_hosting_control SET state=$3, generation=$4, deadline_ms=$5
9264
+ WHERE dvm_id=$1 AND scope=$2`,
9265
+ [this.dvmId, this.config.scope, nextState, command.generation, command.deadlineMs ?? null]
9266
+ );
9267
+ if (command.action === "drain") {
9268
+ await client.query(
9269
+ `UPDATE sdk_hosting_admissions SET drain_deadline_ms=$3
9270
+ WHERE dvm_id=$1 AND scope=$2 AND outcome='pending'`,
9271
+ [this.dvmId, this.config.scope, command.deadlineMs]
9272
+ );
9273
+ }
9274
+ await client.query("COMMIT");
9275
+ return "ok";
9276
+ } catch (error) {
9277
+ await client.query("ROLLBACK").catch(() => void 0);
9278
+ throw error;
9279
+ } finally {
9280
+ client.release();
9281
+ }
9282
+ }
9283
+ async status() {
9284
+ await this.pool.query(
9285
+ `UPDATE sdk_hosting_admissions a SET outcome='accepted'
9286
+ WHERE a.dvm_id=$1 AND a.scope=$2 AND a.outcome='pending'
9287
+ AND EXISTS (SELECT 1 FROM jobs j WHERE j.id=a.job_id)`,
9288
+ [this.dvmId, this.config.scope]
9289
+ );
9290
+ const creditTable = await this.pool.query(
9291
+ "SELECT to_regclass('credit_fundings') IS NOT NULL AS present"
9292
+ );
9293
+ await this.pool.query(
9294
+ `UPDATE sdk_hosting_admissions a SET outcome='accepted'
9295
+ WHERE a.dvm_id=$1 AND a.scope=$2 AND a.outcome='unresolved'
9296
+ AND (EXISTS (SELECT 1 FROM jobs j WHERE j.id=a.job_id)
9297
+ ${creditTable.rows[0].present ? `OR (a.credit_id IS NOT NULL AND EXISTS (
9298
+ SELECT 1 FROM credit_fundings f WHERE f.credit_id=a.credit_id AND f.fund_id=a.fund_id
9299
+ ))` : ""})`,
9300
+ [this.dvmId, this.config.scope]
9301
+ );
9302
+ const { rows } = await this.pool.query(
9303
+ `SELECT state, generation, deadline_ms FROM sdk_hosting_control WHERE dvm_id=$1 AND scope=$2`,
9304
+ [this.dvmId, this.config.scope]
9305
+ );
9306
+ const active = await this.pool.query(
9307
+ `SELECT j.id FROM jobs j WHERE j.dvm_id=$1
9308
+ AND j.status IN ('processing', 'working', 'awaiting-input')
9309
+ AND EXISTS (SELECT 1 FROM sdk_hosting_admissions a
9310
+ WHERE a.dvm_id=j.dvm_id AND a.job_id=j.id AND a.scope=$2)
9311
+ ORDER BY j.id`,
9312
+ [this.dvmId, this.config.scope]
9313
+ );
9314
+ const unowned = await this.pool.query(
9315
+ `SELECT j.id FROM jobs j WHERE j.dvm_id=$1
9316
+ AND j.status IN ('processing', 'working', 'awaiting-input')
9317
+ AND NOT EXISTS (SELECT 1 FROM sdk_hosting_admissions a
9318
+ WHERE a.dvm_id=j.dvm_id AND a.job_id=j.id)
9319
+ ORDER BY j.id`,
9320
+ [this.dvmId]
9321
+ );
9322
+ const pending = await this.pool.query(
9323
+ `SELECT COUNT(*) FILTER (WHERE outcome='pending')::text AS pending,
9324
+ COUNT(*) FILTER (WHERE outcome='unresolved')::text AS unresolved
9325
+ FROM sdk_hosting_admissions WHERE dvm_id=$1 AND scope=$2`,
9326
+ [this.dvmId, this.config.scope]
9327
+ );
9328
+ const unresolved = await this.pool.query(
9329
+ `SELECT id, job_id FROM sdk_hosting_admissions
9330
+ WHERE dvm_id=$1 AND scope=$2 AND outcome='unresolved' ORDER BY admitted_at_ms, id`,
9331
+ [this.dvmId, this.config.scope]
9332
+ );
9333
+ const inFlight = await this.pool.query(
9334
+ `SELECT id, job_id FROM sdk_hosting_admissions
9335
+ WHERE dvm_id=$1 AND scope=$2 AND outcome='pending' ORDER BY admitted_at_ms, id`,
9336
+ [this.dvmId, this.config.scope]
9337
+ );
9338
+ return {
9339
+ state: rows[0].state,
9340
+ generation: Number(rows[0].generation),
9341
+ deadlineMs: rows[0].deadline_ms === null ? null : Number(rows[0].deadline_ms),
9342
+ activeJobIds: active.rows.map((row) => row.id),
9343
+ unownedActiveJobIds: unowned.rows.map((row) => row.id),
9344
+ pendingAdmissionCount: Number(pending.rows[0].pending),
9345
+ pendingAdmissions: inFlight.rows.map((row) => ({ admissionId: row.id, workId: row.job_id })),
9346
+ unresolvedAdmissionCount: Number(pending.rows[0].unresolved),
9347
+ unresolvedAdmissions: unresolved.rows.map((row) => ({ admissionId: row.id, jobId: row.job_id }))
9348
+ };
9349
+ }
9350
+ };
9351
+
9130
9352
  // src/sdk/server/job-credential.ts
9131
9353
  function jobCredentialGate(opts) {
9132
9354
  const alternative = opts.acceptsBearer ? ", or the Authorization: Bearer token the job was submitted with" : "";
@@ -9756,20 +9978,10 @@ var ReceiptIssuer = class {
9756
9978
  this.pubkey = receiptPubkeyFromSecret(opts.secret);
9757
9979
  }
9758
9980
  /**
9759
- * Build and sign the receipt for a terminal job. `seq` comes from
9760
- * `JobStore.claimReceiptSeq`; `issuedAt` is epoch seconds and defaults to now.
9761
- *
9762
- * Free jobs get receipts too, with `paid.msats: 0` — they still consume a
9763
- * sequence number and still attest an outcome. Whether feedback is
9764
- * paid-only is the feedback layer's call, not this one's.
9765
- *
9766
- * Timing and attribution come from the committed terminal row, not signing
9767
- * time or handler input. Legacy rows without those durable inputs omit the
9768
- * optional blocks rather than inventing historical evidence.
9769
- *
9770
- * `credit` is the resolved draw block — present on every job
9771
- * whose payment funded/drew the ledger, absent otherwise. Signature-
9772
- * compatible either way: `canonicalize` omits absent keys.
9981
+ * seq is store-allocated; issuedAt is epoch seconds. Free jobs also consume a
9982
+ * sequence and attest their outcome. Timing/attribution comes from the committed
9983
+ * terminal row; legacy rows omit unknown historical fields. credit is present
9984
+ * only for a resolved ledger draw.
9773
9985
  */
9774
9986
  issue(record, seq, issuedAt, credit) {
9775
9987
  const unsigned = {
@@ -9880,7 +10092,6 @@ function resultHashFor(record) {
9880
10092
  }
9881
10093
 
9882
10094
  // src/lib/cashu/mint-info.ts
9883
- var MINT_INFO_TTL_MS = 5 * 6e4;
9884
10095
  async function getKeysetFinalExpiry(mintUrl, keysetId) {
9885
10096
  try {
9886
10097
  const wallet = await getWallet(mintUrl);
@@ -10324,11 +10535,9 @@ var JobManager = class {
10324
10535
  get receiptsEnabled() {
10325
10536
  return this.opts.receiptIssuer !== void 0 && isReceiptIssuingStore(this.jobStore);
10326
10537
  }
10327
- /** True when the named capability exists on this descriptor. */
10328
10538
  hasCapability(name) {
10329
10539
  return Object.prototype.hasOwnProperty.call(this.descriptor.capabilities, name);
10330
10540
  }
10331
- /** Names of every capability this descriptor exposes — used for error envelopes. */
10332
10541
  capabilityNames() {
10333
10542
  return Object.keys(this.descriptor.capabilities);
10334
10543
  }
@@ -10925,7 +11134,6 @@ var JobManager = class {
10925
11134
  job.messageAppenderTail = next;
10926
11135
  };
10927
11136
  }
10928
- /** Start (or restart) the idle timer for a job. */
10929
11137
  startIdleTimer(job) {
10930
11138
  this.clearIdleTimer(job.id);
10931
11139
  const timeoutMs = this.descriptor.idleTimeout * 1e3;
@@ -10934,7 +11142,6 @@ var JobManager = class {
10934
11142
  }, timeoutMs);
10935
11143
  this.idleTimers.set(job.id, timer);
10936
11144
  }
10937
- /** Clear the idle timer for a job. */
10938
11145
  clearIdleTimer(id) {
10939
11146
  const timer = this.idleTimers.get(id);
10940
11147
  if (timer) {
@@ -12533,7 +12740,6 @@ var JobManager = class {
12533
12740
  for (const record of records) await this.reportTerminalJob(record.id);
12534
12741
  this.terminalReportRecoveryCursor = records.length === 100 ? records[records.length - 1].id : void 0;
12535
12742
  }
12536
- /** Handle job reaching terminal state (completed, failed, cancelled). */
12537
12743
  async handleJobTerminal(job) {
12538
12744
  this.clearIdleTimer(job.id);
12539
12745
  if (await this.adoptDurableTerminal(job)) return;
@@ -12809,7 +13015,6 @@ var JobManager = class {
12809
13015
  }, 6e4);
12810
13016
  this.cleanupTimers.set(job.id, timer);
12811
13017
  }
12812
- /** Clear all timers. Called on server shutdown to allow clean exit. */
12813
13018
  shutdown() {
12814
13019
  for (const [id] of this.idleTimers) {
12815
13020
  this.clearIdleTimer(id);
@@ -13054,7 +13259,6 @@ var MintHealthTracker = class {
13054
13259
  isNutIncompatible(mintUrl) {
13055
13260
  return this.state.get(normaliseMintUrl(mintUrl))?.nutIncompatible ?? false;
13056
13261
  }
13057
- /** Plain-object snapshot keyed by mint URL — for tests + observability. */
13058
13262
  snapshot() {
13059
13263
  return Object.fromEntries(Array.from(this.state.entries()).map(([k, v]) => [k, { ...v }]));
13060
13264
  }
@@ -13112,12 +13316,7 @@ var MintHealthTracker = class {
13112
13316
  );
13113
13317
  }
13114
13318
  }
13115
- /**
13116
- * One-shot structured summary emitted after the first probe tick. Replaces
13117
- * the boot check's per-mint `cashu_mint_startup_health` lines and surfaces an
13118
- * all-mints-unhealthy warning — the operator alert that the Fly restart-loop
13119
- * used to be before boot stopped refusing to start.
13120
- */
13319
+ /** One boot summary, including an all-unhealthy warning. */
13121
13320
  logBootHealth() {
13122
13321
  const mints = Object.entries(this.snapshot()).map(([mintUrl, s]) => ({
13123
13322
  mintUrl,
@@ -13248,7 +13447,6 @@ var PostgresX402ExactSettlementStore = class {
13248
13447
  this.pool = pool;
13249
13448
  }
13250
13449
  pool;
13251
- /** Create the exact settlement tables. */
13252
13450
  async init() {
13253
13451
  await withSdkInitLock(this.pool, async () => {
13254
13452
  await this.pool.query(`
@@ -14194,20 +14392,11 @@ async function handlePendingDrains(c, opts) {
14194
14392
  amount_micro: d.amountMicro,
14195
14393
  amount_sats: amountSats,
14196
14394
  amount_msats: amountSats === null ? null : amountSats * 1e3,
14197
- // Whether this row's sats figure is the in-kind obligation or a live-rate
14198
- // conversion. An operator settling a refund should be able to see which,
14199
- // and the servicer's own logs carry it for the same reason.
14200
14395
  sats_basis: amountSats === null ? null : d.owedSats === null ? "rate" : "in_kind",
14201
- // The figure a `manual` payout is actually sized in: `amount_micro` is
14202
- // fiat, and the operator sends the rail's own token. It is the same debit
14203
- // `completeX402ChannelDrain` treats as its settlement floor,
14204
- // so publishing it is what lets an operator settle from the row instead
14205
- // of assuming their stablecoin happens to be six-decimal.
14396
+ // Rail-native debit, distinct from fiat amount_micro. This is the channel refund floor
14397
+ // and the size a manual payout must cover.
14206
14398
  amount_native: d.drainedNative,
14207
14399
  settlement,
14208
- // Why this row is classified as it is, and the channel an operator would
14209
- // look up to see the close land. `payout` cannot answer it: a
14210
- // channel-backed mpp drain carries `{}`.
14211
14400
  channel_id: d.channelId,
14212
14401
  created_at: d.createdAt
14213
14402
  };
@@ -14218,13 +14407,7 @@ async function handlePendingDrains(c, opts) {
14218
14407
  acc[method] = pending.filter((d) => d.method === method).reduce((sum, d) => sum + d.amountMicro, 0);
14219
14408
  return acc;
14220
14409
  }, {}),
14221
- // What no job will ever pay down: the operator's own worklist, in the one
14222
- // denomination these rows have.
14223
14410
  manual_micro: drains.filter((d) => d.settlement === "manual").reduce((sum, d) => sum + d.amount_micro, 0),
14224
- // The rest of the stablecoin liability — owed, but by a channel close in
14225
- // flight rather than by a person. Published beside `manual_micro` so the
14226
- // difference between the two is visible rather than inferred from a
14227
- // `by_method_micro` figure neither of them matches.
14228
14411
  channel_micro: drains.filter((d) => d.settlement === "channel").reduce((sum, d) => sum + d.amount_micro, 0)
14229
14412
  };
14230
14413
  return c.json({ drains, liability, mints: opts.mints?.() ?? [] });
@@ -14253,17 +14436,21 @@ async function handleBitcoinLiability(c, opts) {
14253
14436
  drain_liability_sats: drainSats,
14254
14437
  drain_count: promised.length,
14255
14438
  total_owed_sats: deposit.depositSats + drainSats,
14256
- // Money this figure does not speak for, published rather than folded in:
14257
- // `uncovered` is a credit whose lots fall short of its balance, `unbacked`
14258
- // is one holding any open micro with no sats basis. A reclaim on either
14259
- // prices the whole balance off a live rate, so both are reported whole and
14260
- // neither contributes to `deposit_liability_sats` — a sweep computed
14261
- // against `total_owed_sats` alone is short by whatever they turn out to be
14262
- // worth.
14439
+ // Uncovered or basisless balances require whole-balance live-rate pricing and do not
14440
+ // contribute to deposit_liability_sats. total_owed_sats alone cannot reserve their
14441
+ // eventual refunds.
14263
14442
  uncovered_credits: deposit.uncoveredCredits,
14264
- uncovered_micro: deposit.uncoveredMicro,
14443
+ uncovered_by_currency: deposit.uncoveredByCurrency.map((currency) => ({
14444
+ currency: currency.currency,
14445
+ micro: currency.micro,
14446
+ credits: currency.credits
14447
+ })),
14265
14448
  unbacked_credits: deposit.unbackedCredits,
14266
- unbacked_micro: deposit.unbackedMicro,
14449
+ unbacked_by_currency: deposit.unbackedByCurrency.map((currency) => ({
14450
+ currency: currency.currency,
14451
+ micro: currency.micro,
14452
+ credits: currency.credits
14453
+ })),
14267
14454
  unpriced_drains: drainsUnpriced
14268
14455
  });
14269
14456
  }
@@ -14701,9 +14888,6 @@ async function handleReconcileInvoice(c, opts) {
14701
14888
  const result = await opts.ledger.reconcileBlockedInvoice({
14702
14889
  paymentHash,
14703
14890
  targetCreditId,
14704
- // `fund`'s upsert OVERWRITES `expiry_ms`, so a bare `now + ttl` would
14705
- // shorten a target credit with more life left on it — destroying
14706
- // spendable value while purporting to add some.
14707
14891
  expiryMs,
14708
14892
  basis,
14709
14893
  ...settledAtMs === void 0 ? {} : { settledAtMs },
@@ -14724,9 +14908,6 @@ async function handleReconcileInvoice(c, opts) {
14724
14908
  creditCurrency: invoice.currency,
14725
14909
  fundedAt: settledAtMs ?? invoice.settledAt ?? nowMs,
14726
14910
  expiryMs,
14727
- // The settlements table types this deposit `repair` off the
14728
- // flag: the sats landed at payment time and the
14729
- // deposit is the one row they get.
14730
14911
  repaired: true
14731
14912
  }
14732
14913
  }
@@ -14809,8 +14990,6 @@ async function handleWriteOffInvoice(c, opts) {
14809
14990
  amount_micro: written.amountMicro,
14810
14991
  currency: written.currency,
14811
14992
  note: written.writeOffNote,
14812
- // Mirrors the reconcile's `replayed`: the operator asked for a decision and
14813
- // finds one already recorded, possibly with someone else's note on it.
14814
14993
  replayed: !outcome.written
14815
14994
  });
14816
14995
  }
@@ -16829,6 +17008,7 @@ async function createDVMServer(descriptor, opts) {
16829
17008
  let x402ChannelStorage;
16830
17009
  let x402BatchSettlement;
16831
17010
  let server;
17011
+ let hostingControl;
16832
17012
  let shutdownPromise;
16833
17013
  const shutdown = () => shutdownPromise ??= (async () => {
16834
17014
  const errors = [];
@@ -16880,12 +17060,12 @@ async function createDVMServer(descriptor, opts) {
16880
17060
  const platformUrl = opts.platformUrl ?? envMaybe.DVMKIT_PLATFORM_URL;
16881
17061
  const dvmId = opts.dvmId;
16882
17062
  if (!opts.onJobCompleted && platformToken && platformUrl && dvmId && opts.db) {
16883
- const { RevenueReporter } = await import("./revenue-reporter-NNCNRY4C.js");
17063
+ const { RevenueReporter } = await import("./revenue-reporter-O75BGYFK.js");
16884
17064
  const reporter = new RevenueReporter(opts.db, platformUrl, platformToken);
16885
17065
  revenueReporter = reporter;
16886
17066
  await reporter.init();
16887
17067
  reporter.startRetryLoop();
16888
- const { MemoryX402BatchStore, PayoutReporter } = await import("./payout-reporter-5PIRYFVQ.js");
17068
+ const { MemoryX402BatchStore, PayoutReporter } = await import("./payout-reporter-KEMUXEUO.js");
16889
17069
  payoutReporter = new PayoutReporter({
16890
17070
  dvmId,
16891
17071
  db: opts.db,
@@ -17022,8 +17202,19 @@ async function createDVMServer(descriptor, opts) {
17022
17202
  });
17023
17203
  }
17024
17204
  }) : void 0;
17205
+ if (opts.hostingControl) {
17206
+ if (!opts.db || !opts.dvmId || !(opts.jobStore instanceof PostgresJobStore)) {
17207
+ throw new Error("Hosting control requires a Postgres pool, dvmId, and PostgresJobStore");
17208
+ }
17209
+ if (descriptor.credit && !opts.creditLedger?.durable) {
17210
+ throw new Error("Hosting control with credit requires a durable credit ledger");
17211
+ }
17212
+ hostingControl = new HostingControl(opts.db, opts.dvmId, opts.hostingControl);
17213
+ await hostingControl.init();
17214
+ }
17025
17215
  server = new DVMServer(descriptor, {
17026
17216
  ...opts,
17217
+ hostingControlInstance: hostingControl,
17027
17218
  creditLedger,
17028
17219
  processedPayments,
17029
17220
  onJobCompleted: effectiveOnJobCompleted,
@@ -17100,6 +17291,7 @@ var DVMServer = class {
17100
17291
  x402ExactSettlement;
17101
17292
  /** Withdraws this mount's Tempo session capability from the poll. */
17102
17293
  undeclareSessionCapability;
17294
+ hostingSweepTimer;
17103
17295
  constructor(descriptor, opts) {
17104
17296
  this.descriptor = descriptor;
17105
17297
  this.pricingCurrency = descriptor.currency ?? "usd";
@@ -17143,17 +17335,9 @@ var DVMServer = class {
17143
17335
  mints: opts.mints,
17144
17336
  jobStore: opts.jobStore,
17145
17337
  devMode: opts.devMode,
17146
- // Derived, not raw. `ctx.requestPayment` gates its x402 and
17147
- // Tempo challenge issuance on this list, while `/v1/info`, `/v1/quote`
17148
- // and the upfront 402 all read `resolvePaymentMethods()` — so passing the
17149
- // raw option meant a DVM that never set `paymentMethods` (no first-party
17150
- // one does; the descriptor documents it as "derived from configured
17151
- // rails") advertised both rails, took the upfront payment on either, and
17152
- // then emitted a mid-job payment-request carrying no rail at all. The
17153
- // caller's responder had nothing to sign and the job stalled at
17154
- // needs-approval — reachable only from a rung that raises a real mid-job
17155
- // ask, which nothing above L1 did. Facilitator availability is still
17156
- // re-checked per ask in `context.ts`, so a static list is safe here.
17338
+ // Use the same derived rail list as discovery/upfront payment; otherwise
17339
+ // default-configured rails vanish from mid-job asks. Availability is rechecked
17340
+ // per ask.
17157
17341
  paymentMethods: (opts.paymentMethods ?? this.defaultPaymentMethods()).filter(
17158
17342
  (method) => method !== "x402" || this.x402CurrencySupported()
17159
17343
  ),
@@ -17191,6 +17375,17 @@ var DVMServer = class {
17191
17375
  this.app = new Hono();
17192
17376
  try {
17193
17377
  this.registerRoutes();
17378
+ if (opts.hostingControlInstance) {
17379
+ this.hostingSweepTimer = setInterval(() => {
17380
+ void this.sweepHostingDeadline().catch((error) => {
17381
+ console.error("Hosting deadline sweep failed:", error);
17382
+ });
17383
+ }, 1e3);
17384
+ this.hostingSweepTimer.unref();
17385
+ void this.sweepHostingDeadline().catch((error) => {
17386
+ console.error("Hosting deadline sweep failed:", error);
17387
+ });
17388
+ }
17194
17389
  if (this.ownsMintHealthTracker) this.mintHealthTracker?.start();
17195
17390
  if (this.opts.tempoObserverHealth?.declareSessionCapability) {
17196
17391
  this.undeclareSessionCapability = this.opts.tempoObserverHealth.declareSessionCapability(
@@ -17224,6 +17419,7 @@ var DVMServer = class {
17224
17419
  * and host teardown layers can finish later cleanup before surfacing it.
17225
17420
  */
17226
17421
  shutdown() {
17422
+ if (this.hostingSweepTimer) clearInterval(this.hostingSweepTimer);
17227
17423
  this.jobManager.shutdown();
17228
17424
  const settlement = this.x402BatchSettlementInit ?? Promise.resolve(this.x402BatchSettlement);
17229
17425
  const stopped = settlement.then((s) => s?.stop());
@@ -17232,6 +17428,42 @@ var DVMServer = class {
17232
17428
  this.undeclareSessionCapability = void 0;
17233
17429
  return stopped;
17234
17430
  }
17431
+ async sweepHostingDeadline() {
17432
+ const control = this.opts.hostingControlInstance;
17433
+ if (!control) return;
17434
+ const status = await control.status();
17435
+ if (status.state !== "draining" || status.deadlineMs === null || Date.now() < status.deadlineMs)
17436
+ return;
17437
+ const store = this.jobManager.store;
17438
+ if (!(store instanceof PostgresJobStore)) return;
17439
+ for (const jobId of status.activeJobIds) {
17440
+ if (await store.cancelJobForHosting(jobId, "Hosting drain deadline reached")) {
17441
+ await this.jobManager.finalizeStoredTerminal(jobId);
17442
+ }
17443
+ }
17444
+ }
17445
+ async handleHostingCommand(c, statusOnly) {
17446
+ const control = this.opts.hostingControlInstance;
17447
+ if (!control) return c.json({ error: "not_found" }, 404);
17448
+ let command;
17449
+ try {
17450
+ command = await c.req.json();
17451
+ } catch {
17452
+ return c.json({ error: "invalid_hosting_control" }, 400);
17453
+ }
17454
+ if (!command || (statusOnly ? command.action !== "status" : command.action !== "drain" && command.action !== "resume")) {
17455
+ return c.json({ error: "invalid_hosting_control" }, 400);
17456
+ }
17457
+ if (!await control.authorize(command, c.req.header("X-DVM-Hosting-Signature"))) {
17458
+ return c.json({ error: "hosting_control_unauthorized" }, 401);
17459
+ }
17460
+ if (!statusOnly && command.action === "resume") await this.sweepHostingDeadline();
17461
+ if (!statusOnly && await control.transition(command) === "conflict") {
17462
+ return c.json({ error: "hosting_generation_conflict" }, 409);
17463
+ }
17464
+ if (!statusOnly) await this.sweepHostingDeadline();
17465
+ return c.json(await control.status());
17466
+ }
17235
17467
  registerRoutes() {
17236
17468
  const dvmSlug = this.descriptor.name;
17237
17469
  const builderPubkey = this.opts.builder?.pubkey;
@@ -17339,6 +17571,10 @@ var DVMServer = class {
17339
17571
  this.app.get("/v1/info", (c) => this.handleInfo(c));
17340
17572
  this.app.post("/v1/quote", (c) => this.handleQuote(c));
17341
17573
  this.app.post("/v1/job", (c) => this.handleCreateJob(c));
17574
+ if (this.opts.hostingControlInstance) {
17575
+ this.app.post("/_hosting/control", (c) => this.handleHostingCommand(c, false));
17576
+ this.app.post("/_hosting/status", (c) => this.handleHostingCommand(c, true));
17577
+ }
17342
17578
  this.app.post("/v1/request-resume/:requestId", (c) => this.handleCreateJob(c, true));
17343
17579
  this.app.post("/v1/request-result/:requestId", (c) => this.handleRequestResult(c));
17344
17580
  this.app.get("/v1/job/:id", (c) => this.handleGetJob(c));
@@ -17411,12 +17647,8 @@ var DVMServer = class {
17411
17647
  return state;
17412
17648
  }
17413
17649
  /**
17414
- * Resolve the active lock-pubkey set for the mid-job cashu accept path
17415
- * Returns the same `[current, ...retired_in_grace]` shape the
17416
- * upfront flow uses at lines 709-718 so accept-time rotation tolerance is
17417
- * consistent across upfront and mid-job. Falls back to `undefined` when
17418
- * accumulator mode isn't wired (the receive path then rejects with
17419
- * `cashu_not_p2pk_locked`).
17650
+ * Mid-job and upfront receive paths use current plus grace-period retired keys.
17651
+ * Without accumulator mode this is undefined and the receive path refuses it.
17420
17652
  */
17421
17653
  async resolveLockPubkeysForReceive() {
17422
17654
  if (this.opts.disposableCashu && this.opts.lockPubkey) return [toLockPubkey(this.opts.lockPubkey)];
@@ -17431,12 +17663,8 @@ var DVMServer = class {
17431
17663
  }
17432
17664
  }
17433
17665
  /**
17434
- * Best-effort lock-pubkey state for read-only consumers (`/v1/info` and the
17435
- * accept path). Tries Postgres; on failure falls back to the last-good
17436
- * cached state, and only as a final resort to the env-derived seed in a
17437
- * single-entry array. The cache makes the degradation safe across a
17438
- * rotation — without it the env seed advertises and accepts the retired
17439
- * pubkey while the DB is unreachable.
17666
+ * Read Postgres first, then fall back to last-observed state, then the env seed.
17667
+ * Caching avoids reverting an observed rotation to the old seed during an outage.
17440
17668
  */
17441
17669
  async lockPubkeyStateForRead(db, dvmId) {
17442
17670
  try {
@@ -17448,22 +17676,9 @@ var DVMServer = class {
17448
17676
  }
17449
17677
  }
17450
17678
  /**
17451
- * Run the routine Tempo settlement cadence opportunistically, on ordinary
17452
- * traffic.
17453
- *
17454
- * Routine settlement used to ride the per-DVM watcher machine's four-minute
17455
- * poll, which is exactly what kept an idle DVM awake and billing. It cannot
17456
- * move to a timer for the same reason — a timer on a machine that ought to
17457
- * be suspended either does not fire or defeats the suspension. So it rides
17458
- * requests instead, the way Lightning funding is credited: a DVM
17459
- * with no traffic has nothing to settle on a 24-hour cadence anyway, and the
17460
- * forced-close guarantee does not depend on this path at all — that is the
17461
- * chain-event wake, which reaches a suspended DVM through Fly Proxy.
17462
- *
17463
- * Fire-and-forget and self-throttled: the request never waits on it, only
17464
- * one runs at a time, and a failure is logged rather than surfaced. The
17465
- * sweep re-checks each channel's own schedule, so the throttle here only
17466
- * bounds how often we pay for the store scan.
17679
+ * Opportunistic traffic avoids a timer that keeps an idle machine awake. Forced
17680
+ * close servicing is independent. Fire-and-forget, one sweep at a time; failures
17681
+ * are logged. The throttle bounds scans, not each channel's settlement schedule.
17467
17682
  */
17468
17683
  maybeSweepTempoSessions() {
17469
17684
  const sweep = this.opts.mpp?.sweepTempoSessions;
@@ -17528,22 +17743,9 @@ var DVMServer = class {
17528
17743
  ]);
17529
17744
  }
17530
17745
  /**
17531
- * Whether this DVM is **configured** to take TIP-1034 session channels — the
17532
- * value the observer-health poll carries back to the platform, where it keeps
17533
- * `dvms.tempo_session_advertised_at` current between container deploys
17534
- *
17535
- * Deliberately the same question the platform's post-deploy `/v1/info` probe
17536
- * answers (`infoAdvertisesTempoSession`), from the same two facts: the credit
17537
- * menu exists at all, and the configured MPP methods include `tempo/session`.
17538
- * Credit-scoped because sessions are confined to `POST /v1/credit` — a
17539
- * top-level session method with no credit menu is not a channel anyone can
17540
- * open, so it is not a candidate the payee fallback should reach for.
17541
- *
17542
- * It reads `advertisedMethods` rather than {@link creditMppSplit}'s `offered`,
17543
- * and never {@link tempoSessionOffered}: what the column records is
17544
- * capability, not availability, so an observer blip must leave it alone. That
17545
- * distinction is the whole of and answering it here from the gate
17546
- * would reintroduce it on a one-minute cadence.
17746
+ * Report configured session capability independently of current availability.
17747
+ * A temporary observer outage must not remove the platform's watch signal.
17748
+ * Credit menu, auth and session method configuration still gate that capability.
17547
17749
  */
17548
17750
  tempoSessionCapability() {
17549
17751
  if (!this.opts.mpp) return false;
@@ -17561,25 +17763,11 @@ var DVMServer = class {
17561
17763
  );
17562
17764
  }
17563
17765
  /**
17564
- * `POST /_internal/tempo-close-event` — the forced-close wake path
17565
- * replacing the per-DVM `tempo-session-watch` Fly Machine.
17566
- *
17567
- * Deliberately **unauthenticated**, and that is the design rather than an
17568
- * omission: the body is an event locator, so the DVM re-reads the event from
17569
- * its own Tempo RPC and verifies chain, escrow, topic and local channel
17570
- * ownership before touching money (see `handleTempoCloseEvent`). An
17571
- * unverifiable locator can therefore do nothing, which is what lets the
17572
- * platform wake a DVM without holding a signing key or a recoverable copy of
17573
- * the DVM's bearer token.
17574
- *
17575
- * What an unauthenticated caller *can* do is spend the DVM's resources: each
17576
- * POST is an unthrottled outbound `eth_getTransactionReceipt` against the
17577
- * builder's own Tempo provider, billed to them, before anything about the
17578
- * locator has been checked — the verification needs the receipt to happen at
17579
- * all. So the cost of an open endpoint is metered RPC calls, not money: no
17580
- * locator can move value except one naming a real close of a channel this
17581
- * DVM owns, and settling that is what the routine cadence would do anyway.
17582
- * Watch the provider's quota rather than the ledger.
17766
+ * Unauthenticated wake carrying only an event locator. After parsing, verify
17767
+ * the receipt from the DVM's own RPC, including chain, escrow, topic and local
17768
+ * channel ownership before settlement. The observer needs no spending secret.
17769
+ * Syntactically valid requests can still consume unthrottled provider RPC quota;
17770
+ * malformed locators return 400 before that read.
17583
17771
  */
17584
17772
  registerTempoCloseEventRoute() {
17585
17773
  const mpp = this.opts.mpp;
@@ -17640,16 +17828,8 @@ var DVMServer = class {
17640
17828
  });
17641
17829
  }
17642
17830
  /**
17643
- * Compose the `/v1/info#builder` block from `opts.builder` (inline SDK
17644
- * config) with a fallback to deploy-injected env vars. Each
17645
- * field is independently sourced (opts wins per-field, env fills gaps);
17646
- * all three of pubkey / attestation / signature must resolve to emit the
17647
- * block — partial state is treated as absent rather than half-emitted.
17648
- *
17649
- * Env-var form: `DVMKIT_BUILDER_ATTESTATION` is a JSON string (Fly secrets
17650
- * can't carry structured data), so it's parsed here. A malformed JSON
17651
- * string drops the block rather than throwing, so a corrupted env var
17652
- * can't take the DVM down.
17831
+ * Options override environment per field. Emit only when pubkey, attestation
17832
+ * and signature all exist; malformed attestation JSON omits the block.
17653
17833
  */
17654
17834
  builderAttestationBlock() {
17655
17835
  const builder = this.opts.builder;
@@ -18013,6 +18193,32 @@ var DVMServer = class {
18013
18193
  receiptIssuer: this.receiptIssuer,
18014
18194
  lightning: this.opts.lightningReceive ? { receive: this.opts.lightningReceive } : void 0,
18015
18195
  settleLightning: (callerPubkey, creditId) => this.settleLightningFunding(callerPubkey, creditId),
18196
+ withFundingAdmission: this.opts.hostingControlInstance ? async (ctx, body, run) => {
18197
+ const control = this.opts.hostingControlInstance;
18198
+ const admissionId = randomUUID4();
18199
+ const fundId = body.fund?.fund_id ?? admissionId;
18200
+ const creditId = body.credit_id ?? derivedFundCreditId(body.pubkey, fundId);
18201
+ const workId = `credit-fund:${creditId}:${fundId}`;
18202
+ if (!await control.admit(admissionId, workId, { creditId, fundId })) {
18203
+ return ctx.json({
18204
+ error: "hosting_draining",
18205
+ message: "This service is draining and is not accepting new credit funding.",
18206
+ hint: "Balance, reclaim, and already-issued funding recovery remain available."
18207
+ }, 503);
18208
+ }
18209
+ const authorization = ctx.req.header("Authorization");
18210
+ const paymentArtifact = Boolean(
18211
+ ctx.req.header("X-Cashu") || ctx.req.header("PAYMENT-SIGNATURE") || ctx.req.header("X-PAYMENT") || authorization && /^Payment\s/i.test(authorization)
18212
+ );
18213
+ let outcome = paymentArtifact ? "unresolved" : "failed";
18214
+ try {
18215
+ const response = await run();
18216
+ if (response.ok || body.fund?.method === "lightning" && await this.creditLedger.getInvoice({ creditId, fundId })) outcome = "accepted";
18217
+ return response;
18218
+ } finally {
18219
+ await control.finishAdmission(admissionId, outcome);
18220
+ }
18221
+ } : void 0,
18016
18222
  tempoSettlementReadiness: this.opts.tempoSettlementReadiness
18017
18223
  });
18018
18224
  }
@@ -18032,11 +18238,8 @@ var DVMServer = class {
18032
18238
  return this.pricingCurrency;
18033
18239
  }
18034
18240
  /**
18035
- * Quote-response `payment` block. Carries the same `methods` array
18036
- * `/v1/info` advertises plus a per-rail `refundable` map keyed by method, so a
18037
- * caller can see which rails return funds on failure before committing. The
18038
- * `methods` shape stays identical to `/v1/info` and the platform isolate route
18039
- * (current nested `payment.methods`); refundability rides alongside.
18241
+ * Advertise the same method list as info. Refundability describes the rail
18242
+ * itself; failed jobs release their pending credit draw separately.
18040
18243
  */
18041
18244
  quotePaymentBlock() {
18042
18245
  const methods = this.resolvePaymentMethods();
@@ -18149,12 +18352,8 @@ var DVMServer = class {
18149
18352
  return c.json(info);
18150
18353
  }
18151
18354
  /**
18152
- * Render the `/v1/info.capabilities` block. Each capability
18153
- * advertises its `description`, optional `input_schema`, and optional
18154
- * `pricing` block — `pricing.quote.schema` for dynamic-priced capabilities,
18155
- * `pricing.max` for static-priced ones. Capabilities without an `input`
18156
- * schema or any pricing surface omit those fields entirely; free
18157
- * capabilities (no `price`) omit `pricing` entirely.
18355
+ * Input-mode JSON Schema keeps defaulted fields optional on the advertised
18356
+ * wire contract. Capability names are the descriptor's keys.
18158
18357
  */
18159
18358
  renderCapabilities() {
18160
18359
  const out = {};
@@ -18355,11 +18554,8 @@ var DVMServer = class {
18355
18554
  return c.json(res);
18356
18555
  }
18357
18556
  /**
18358
- * Build the {@link QuoteContext} a pricing handler receives. Extracted so
18359
- * the two callers — `/v1/quote` and `computeDynamicPrice` on the submit
18360
- * path — cannot drift: a field added for one is present in the other by
18361
- * construction, and both paths price against the same view of the caller's
18362
- * credit.
18557
+ * Callers supply quote-schema-parsed data when a quote schema exists, alongside
18558
+ * raw input text and params. Quote and job pricing share these runtime helpers.
18363
18559
  */
18364
18560
  async buildQuoteContext(args) {
18365
18561
  const ctx = {
@@ -18381,16 +18577,8 @@ var DVMServer = class {
18381
18577
  return ctx;
18382
18578
  }
18383
18579
  /**
18384
- * Validate the `capability` field on a `/v1/job` or `/v1/quote` body
18385
- * Returns the canonical name when set + known; otherwise a
18386
- * structured 400 envelope agents can relay verbatim to a human caller.
18387
- *
18388
- * - **Missing** — `body.capability` is absent or empty. Surface as
18389
- * `capability_required` with a hint pointing at `/v1/info` so the agent
18390
- * can re-discover the capability set.
18391
- * - **Unknown** — `body.capability` is set but doesn't match any
18392
- * capability on this descriptor. Surface as `unknown_capability` listing
18393
- * the known names so the agent can correct without a round-trip.
18580
+ * Missing or unknown capabilities return structured errors with the current
18581
+ * info-schema URL.
18394
18582
  */
18395
18583
  requireCapabilityFromBody(c, capability) {
18396
18584
  const known = this.jobManager.capabilityNames();
@@ -18494,22 +18682,9 @@ var DVMServer = class {
18494
18682
  );
18495
18683
  }
18496
18684
  /**
18497
- * True when a failed capability-input parse is the auth gate's answer rather
18498
- * than {@link invalidInputResponse}'s.
18499
- *
18500
- * A capability schema may declare the signing-envelope fields required
18501
- * (cast's `addEpisodeSchema`) or optional (scribe's `transcribeSchema`). On
18502
- * the required shape an *unsigned* request fails the input parse naming
18503
- * `pubkey` / `signature` — a signing failure, and answering it with "your
18504
- * input is invalid: pubkey required" is the mistake in reverse. So a
18505
- * parse whose issues are **only** envelope-side falls through to the auth
18506
- * gate, which answers 401 `signature_invalid` with the remedy that works.
18507
- *
18508
- * Deferring is safe because the gate below re-parses the same schema over the
18509
- * same bytes, so a caller that reaches it with an envelope-side failure is
18510
- * rejected there for certain; both routes still carry a backstop for the
18511
- * impossible case. It applies only to a DVM that declares `auth` — with no
18512
- * gate below, the 400 is the only answer available.
18685
+ * On authenticated DVMs, envelope-only parse failures defer to auth
18686
+ * (401 signature_invalid). Builder input issues remain 400 invalid_input.
18687
+ * Auth reparses the same schema/raw input; a backstop refuses disagreement.
18513
18688
  */
18514
18689
  deferToAuthGate(err) {
18515
18690
  if (!this.descriptor.auth) return false;
@@ -18708,8 +18883,40 @@ var DVMServer = class {
18708
18883
  { capability: capabilityName, input: body.input, params: body.params, data: body.data },
18709
18884
  { stripAuthEnvelope: false }
18710
18885
  ) : void 0;
18886
+ const authorization = c.req.header("Authorization");
18887
+ const attachedPayment = c.req.header("X-Cashu") ?? c.req.header("PAYMENT-SIGNATURE") ?? c.req.header("X-PAYMENT") ?? (authorization && /^Payment\s/i.test(authorization) ? authorization : void 0);
18888
+ const hostingPaymentArtifactHash = attachedPayment ? createHash7("sha256").update(attachedPayment).digest("hex") : void 0;
18889
+ if (!resumeOnly && this.opts.hostingControlInstance && hostingPaymentArtifactHash) {
18890
+ const priorId = await this.opts.hostingControlInstance.acceptedJobByPaymentArtifactHash(
18891
+ hostingPaymentArtifactHash
18892
+ );
18893
+ const prior = priorId ? await this.jobManager.store.get(priorId) : void 0;
18894
+ if (prior && prior.requestFingerprint === fingerprint && prior.requesterId === c.get("requesterId") && (!this.descriptor.auth || prior.requesterPubkey === authPubkey)) {
18895
+ await this.jobManager.ensureReceipt(prior);
18896
+ return buildSubmitResponse(c, prior, prior.requesterToken);
18897
+ }
18898
+ }
18899
+ if (!resumeOnly && this.opts.hostingControlInstance && creditEnvelope && authPubkey) {
18900
+ const draw = await this.creditLedger.getDraw({
18901
+ creditId: creditEnvelope.creditId,
18902
+ drawId: creditEnvelope.drawId
18903
+ });
18904
+ if (draw?.jobId && await this.jobManager.store.get(draw.jobId)) {
18905
+ const recovered = await this.recoverDrawnJob(c, {
18906
+ creditId: creditEnvelope.creditId,
18907
+ drawId: creditEnvelope.drawId,
18908
+ jobId: draw.jobId,
18909
+ fingerprint,
18910
+ callerPubkey: authPubkey
18911
+ });
18912
+ if (recovered) return recovered;
18913
+ }
18914
+ }
18711
18915
  let publicRequestClaim;
18712
18916
  let publicRequestClaimResult;
18917
+ let hostingAdmissionId;
18918
+ let hostingAdmissionAccepted = false;
18919
+ let hostingPaymentMayHaveLanded = false;
18713
18920
  if (publicRequestId && authPubkey && recoveryStore) {
18714
18921
  publicRequestClaim = {
18715
18922
  requestId: publicRequestId,
@@ -18732,6 +18939,18 @@ var DVMServer = class {
18732
18939
  }
18733
18940
  }
18734
18941
  let jobId = publicRequestClaimResult?.jobId ?? this.jobManager.allocateJobId();
18942
+ if (!resumeOnly && this.opts.hostingControlInstance) {
18943
+ hostingAdmissionId = randomUUID4();
18944
+ if (!await this.opts.hostingControlInstance.admit(hostingAdmissionId, jobId, {
18945
+ paymentArtifactHash: hostingPaymentArtifactHash
18946
+ })) {
18947
+ return c.json({
18948
+ error: "hosting_draining",
18949
+ message: "This service is draining and is not accepting new jobs or payments.",
18950
+ hint: "Keep any existing job token to fetch its result or reclaim unused credit. Retry a new job after service resumes."
18951
+ }, 503);
18952
+ }
18953
+ }
18735
18954
  let payment = { paidMsats: 0, receivedProofs: [] };
18736
18955
  let resumedAcceptedPayment = false;
18737
18956
  if (resumeOnly) {
@@ -18788,6 +19007,9 @@ var DVMServer = class {
18788
19007
  const skipPayment = devModeSkipsPaymentVerification(this.opts);
18789
19008
  if (!skipPayment && !resumedAcceptedPayment) {
18790
19009
  const railOpts = await this.railVerifyOpts(c);
19010
+ hostingPaymentMayHaveLanded = Boolean(
19011
+ creditEnvelope || railOpts.cashuToken || railOpts.x402Payment || railOpts.mppCredential
19012
+ );
18791
19013
  try {
18792
19014
  payment = await loggers.isolate.span(
18793
19015
  "dvm.payment.validate",
@@ -18901,8 +19123,12 @@ var DVMServer = class {
18901
19123
  );
18902
19124
  }
18903
19125
  const exactAdmission = payment.x402ExactAdmission;
19126
+ hostingPaymentMayHaveLanded ||= resumedAcceptedPayment || payment.paidMsats > 0 || Boolean(payment.funding || exactAdmission);
18904
19127
  if (exactAdmission) {
18905
19128
  jobId = exactAdmission.jobId;
19129
+ if (hostingAdmissionId) {
19130
+ await this.opts.hostingControlInstance.rebindAdmissionJob(hostingAdmissionId, jobId);
19131
+ }
18906
19132
  const prior = await this.jobManager.store.get(jobId);
18907
19133
  if (prior) {
18908
19134
  if (prior.requestFingerprint !== fingerprint || prior.requesterPubkey !== authPubkey || prior.requesterId !== c.get("requesterId")) {
@@ -18953,10 +19179,6 @@ var DVMServer = class {
18953
19179
  priceMsats,
18954
19180
  capabilityName,
18955
19181
  requesterTokenHash,
18956
- // everything the idempotent-replay path needs to recognise a
18957
- // retry of THIS call and re-issue the token its lost response carried.
18958
- // `jobId` is the pre-allocated id the payment's ledger draw
18959
- // already committed under.
18960
19182
  {
18961
19183
  requesterToken: jobToken,
18962
19184
  requestFingerprint: fingerprint,
@@ -18967,6 +19189,21 @@ var DVMServer = class {
18967
19189
  }
18968
19190
  );
18969
19191
  await jm.persistJob(job);
19192
+ if (hostingAdmissionId) {
19193
+ await this.opts.hostingControlInstance.finishAdmission(hostingAdmissionId, "accepted", jobId);
19194
+ hostingAdmissionAccepted = true;
19195
+ }
19196
+ const reservedDrainDeadline = this.opts.hostingControlInstance ? await this.opts.hostingControlInstance.jobDeadline(jobId) : null;
19197
+ const hostingStatus = await this.opts.hostingControlInstance?.status();
19198
+ const currentDrainDeadline = hostingStatus?.state === "draining" ? hostingStatus.deadlineMs : null;
19199
+ if (reservedDrainDeadline !== null && Date.now() >= reservedDrainDeadline || currentDrainDeadline !== null && currentDrainDeadline !== void 0 && Date.now() >= currentDrainDeadline) {
19200
+ const store = jm.store;
19201
+ if (store instanceof PostgresJobStore && await store.cancelJobForHosting(jobId, "Hosting drain deadline reached")) {
19202
+ await jm.finalizeStoredTerminal(jobId);
19203
+ }
19204
+ const terminal = await store.get(jobId);
19205
+ if (terminal) return buildSubmitResponse(c, terminal, jobToken);
19206
+ }
18970
19207
  if (exactAdmission) {
18971
19208
  await this.x402ExactSettlement?.completeAdmission(
18972
19209
  this.opts.dvmId ?? "",
@@ -18988,6 +19225,12 @@ var DVMServer = class {
18988
19225
  }
18989
19226
  return response;
18990
19227
  } finally {
19228
+ if (hostingAdmissionId && !hostingAdmissionAccepted) {
19229
+ await this.opts.hostingControlInstance.finishAdmission(
19230
+ hostingAdmissionId,
19231
+ hostingPaymentMayHaveLanded ? "unresolved" : "failed"
19232
+ );
19233
+ }
18991
19234
  if (publicRequestClaim && recoveryStore) {
18992
19235
  await recoveryStore.releaseRequestIdClaim(publicRequestClaim);
18993
19236
  }
@@ -19039,20 +19282,9 @@ var DVMServer = class {
19039
19282
  });
19040
19283
  }
19041
19284
  /**
19042
- * The idempotent half of `POST /v1/job`. A paid submit whose
19043
- * response the caller lost is safe to retry — the accumulator's
19044
- * `UNIQUE(dvm_id, request_id, proof_secret)` makes the duplicate a no-op —
19045
- * but until now it was also useless: the caller got a 409 and the `job_id` /
19046
- * `job_token` it paid for stayed unreachable forever.
19047
- *
19048
- * Returns the original job's submit response iff the retry proves it is the
19049
- * original caller. Proof is reproducing the request, **not** possessing the
19050
- * spent Cashu token: the token is a bearer secret, so gating on it alone
19051
- * would hand the job (and its result) to anyone who lifted it. The legitimate
19052
- * retry always has the body — the resume sweep re-POSTs it — while a
19053
- * token thief does not. On a descriptor-auth DVM the caller pubkey must match
19054
- * too. Anything short of that falls through to the 409 the caller has always
19055
- * gotten.
19285
+ * Recover a lost paid response only for matching request fingerprint,
19286
+ * authenticated pubkey and requester identity. A spent bearer token alone
19287
+ * is not proof of request ownership.
19056
19288
  */
19057
19289
  async recoverReplayedJob(c, opts) {
19058
19290
  if (!opts.requestId) return void 0;
@@ -19076,16 +19308,9 @@ var DVMServer = class {
19076
19308
  return buildSubmitResponse(c, prior, prior.requesterToken);
19077
19309
  }
19078
19310
  /**
19079
- * The draw-keyed half of idempotent recovery. The signed body's
19080
- * `(credit_id, draw_id)` matched a recorded draw;
19081
- * resolve the job it paid for (the draw carries its pre-allocated job id)
19082
- * and re-issue the original response under the SAME proof-of-origin gates
19083
- * as the header regime: reproduce the fingerprint, sign as the same caller
19084
- * (explicit draws only exist on descriptor-auth DVMs), match requesterId.
19085
- *
19086
- * A recorded draw whose job row never landed (crash between the ledger
19087
- * commit and the job persist) has nothing to hand back — refuse with the
19088
- * 409 and log loudly; the pending hold is surfaced by `listPendingDraws`.
19311
+ * Resolve the recorded draw's job under the same fingerprint/pubkey/requester
19312
+ * checks as header recovery. If no job exists, the caller may still recover a
19313
+ * pending durable acceptance under its bound public request-id claim.
19089
19314
  */
19090
19315
  async recoverDrawnJob(c, opts) {
19091
19316
  let jobId = opts.jobId;
@@ -19195,12 +19420,9 @@ var DVMServer = class {
19195
19420
  return payment;
19196
19421
  }
19197
19422
  /**
19198
- * Recompute the upfront price for a dynamic-priced capability by
19199
- * re-invoking its `onQuote.handler` with the parsed job input as `data`
19200
- * Returns a 400-style error envelope when the input doesn't
19201
- * satisfy `onQuote.schema` or the handler throws — failing closed is the
19202
- * right default, since the alternative (running the job with
19203
- * `requiredMsats=undefined`) lets unpaid jobs through.
19423
+ * Recompute dynamic upfront pricing from parsed job input, applying the quote
19424
+ * schema separately. Refuse invalid input/results, currency mismatch or missing
19425
+ * FX rather than running without a price (400/500/503 as classified below).
19204
19426
  */
19205
19427
  async computeDynamicPrice(onQuote, parsedInput, body, callerPubkey) {
19206
19428
  let data = parsedInput;
@@ -19977,16 +20199,9 @@ var PlatformTempoObserverHealth = class {
19977
20199
  };
19978
20200
  }
19979
20201
  /**
19980
- * Prime the signal during boot without making a menu request wait.
19981
- *
19982
- * De-dupes on the probe in flight, but only while that probe still speaks for
19983
- * the current set of declarations. `host.ts` primes this client before any
19984
- * mount exists, so at boot there is routinely an open probe carrying **no**
19985
- * `tempo_session` at all — handing it back to the mount that just declared
19986
- * one would leave a scale-to-zero DVM unstamped until traffic arrived, which
19987
- * is exactly the container-restart case is about. Same shape with
19988
- * two mounts: the second would join a probe whose URL already read the first
19989
- * one's `false`.
20202
+ * De-duplicate probes only while their declaration epoch is current. A mount
20203
+ * declared during boot or another mount's probe must await a fresh probe so
20204
+ * the platform receives the updated capability, even before traffic arrives.
19990
20205
  */
19991
20206
  async refresh() {
19992
20207
  const open = this.inFlight;
@@ -20147,6 +20362,7 @@ export {
20147
20362
  createAdminCashuNonceStore,
20148
20363
  X402ExactSettlementServer,
20149
20364
  X402ExactSettlementEvidenceMissingError,
20365
+ signHostingControl,
20150
20366
  jobCredentialGate,
20151
20367
  REQUEST_ID_FIELD,
20152
20368
  protocolEnvelopeIgnoreFields,