@dvmkit/sdk 0.8.1 → 0.10.0

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 (52) hide show
  1. package/dist/{chunk-ILBSZMJL.js → chunk-2XQLL6N6.js} +1 -1
  2. package/dist/{chunk-UVS7MRJX.js → chunk-3Q6FTZDB.js} +2 -2
  3. package/dist/{chunk-OP7VCGQR.js → chunk-3R7NQA3Z.js} +1 -1
  4. package/dist/{chunk-RSNC5T6C.js → chunk-AJ6UHXUA.js} +1 -1
  5. package/dist/{chunk-ALWVJ2RB.js → chunk-EWZFANHJ.js} +3 -3
  6. package/dist/{chunk-J5J63WDC.js → chunk-FFXXGCVG.js} +3 -3
  7. package/dist/{chunk-UDJNZ3UG.js → chunk-JNECYURT.js} +1 -1
  8. package/dist/{chunk-IHQUBATH.js → chunk-KFCEXCZM.js} +3 -3
  9. package/dist/{chunk-NA2KOR6G.js → chunk-LDIPVPKI.js} +3 -3
  10. package/dist/{chunk-IOQIXRGM.js → chunk-M3T5JYDJ.js} +15 -8
  11. package/dist/{chunk-UWGBI5WK.js → chunk-MB6R4XPZ.js} +3 -3
  12. package/dist/{chunk-7PM4R4GD.js → chunk-MMGSYMIC.js} +1 -1
  13. package/dist/{chunk-QKE5NDPF.js → chunk-MU3IE434.js} +348 -80
  14. package/dist/{chunk-BFGQG4WY.js → chunk-QGNXYVBO.js} +52 -12
  15. package/dist/{chunk-JIZ4ZIXX.js → chunk-QNKK7SPY.js} +652 -245
  16. package/dist/{chunk-FM6QAGJC.js → chunk-STJBBVNL.js} +24 -8
  17. package/dist/{chunk-NU5RWJ5B.js → chunk-VMHYGKWT.js} +3 -3
  18. package/dist/{chunk-AVV5SYGO.js → chunk-VTHYQCDB.js} +1 -1
  19. package/dist/{chunk-ZEPJF4JC.js → chunk-VUGEEMLT.js} +4 -4
  20. package/dist/{chunk-2U6LAASN.js → chunk-W3VGCNG6.js} +1 -1
  21. package/dist/{chunk-G3MPHW5I.js → chunk-Y6DO5WSO.js} +3 -3
  22. package/dist/{chunk-AGE5XZKE.js → chunk-YOSZIWC3.js} +17 -4
  23. package/dist/{content-retention-B7ddtJ2y.d.ts → content-retention-CrILCNxk.d.ts} +1 -1
  24. package/dist/{credit-ledger-KIQIE6I6.js → credit-ledger-4PZNAMDT.js} +4 -4
  25. package/dist/{credit-menu-BH3g9GQa.d.ts → credit-menu-VwX_6KM7.d.ts} +4 -4
  26. package/dist/{fx-DN26_J7o.d.ts → fx-BM5h4o5S.d.ts} +1 -1
  27. package/dist/index.d.ts +5 -5
  28. package/dist/index.js +4 -4
  29. package/dist/internal/caller.d.ts +7 -7
  30. package/dist/internal/caller.js +16 -16
  31. package/dist/internal/server.d.ts +7 -7
  32. package/dist/internal/server.js +26 -26
  33. package/dist/{job-store-Cq21JT37.d.ts → job-store-92v8e1LQ.d.ts} +1 -1
  34. package/dist/{lightning-backend-S8F8nf8q.d.ts → lightning-backend-9oky_wJi.d.ts} +1 -1
  35. package/dist/{memory-credit-ledger-ATYFAYBG.js → memory-credit-ledger-RP7DT5IY.js} +5 -5
  36. package/dist/{mpp-setup-ZTORW3VF.js → mpp-setup-FURD45LI.js} +5 -3
  37. package/dist/{payout-reporter-NN6K2IVQ.js → payout-reporter-HVJ6WUF3.js} +2 -2
  38. package/dist/{postgres-consumed-credential-store-XJTE5PCA.js → postgres-consumed-credential-store-WO6OCFXZ.js} +3 -3
  39. package/dist/{postgres-job-store-AR6ZJUQV.js → postgres-job-store-FSRUPYH2.js} +2 -2
  40. package/dist/{postgres-replay-store-33K33QV7.js → postgres-replay-store-XXJSA457.js} +2 -2
  41. package/dist/{pricing-G5IN5YJE.js → pricing-X4XU5HXY.js} +3 -3
  42. package/dist/{revenue-reporter-KDUZLD5F.js → revenue-reporter-EV3K5L6B.js} +2 -2
  43. package/dist/server/index.d.ts +78 -11
  44. package/dist/server/index.js +87 -45
  45. package/dist/{step-cache-CR12EZ1Q.d.ts → step-cache--7yA5Ihr.d.ts} +99 -12
  46. package/dist/{tempo-charge-store-XEGJYM3G.js → tempo-charge-store-5JOIYUIH.js} +2 -2
  47. package/dist/{tempo-session-store-3WTDOO7H.js → tempo-session-store-S6QMLQUA.js} +3 -3
  48. package/dist/testing/index.d.ts +3 -3
  49. package/dist/testing/index.js +7 -7
  50. package/dist/{usd-s_VZ_eS7.d.ts → usd-C7lvLvLk.d.ts} +1 -1
  51. package/dist/{x402-HQ5GRQZU.js → x402-EN5LSOVM.js} +4 -4
  52. package/package.json +1 -1
@@ -1,16 +1,11 @@
1
1
  import {
2
- TEMPO_MODERATO_CHAIN_ID,
3
- TEMPO_MODERATO_RPC,
4
- advertisedMethods,
5
- challengeMeta,
6
- installHostedTempoChargeGuard,
7
- installHostedTempoCloseGuard,
8
- isTempoInsufficientFundsError,
9
- resolveTempoNetwork
10
- } from "./chunk-QKE5NDPF.js";
2
+ MemoryProcessedPaymentStore,
3
+ PostgresProcessedPaymentStore,
4
+ ProcessedPaymentReplayError
5
+ } from "./chunk-XRLKGCAW.js";
11
6
  import {
12
7
  x402EarnedCeiling
13
- } from "./chunk-J5J63WDC.js";
8
+ } from "./chunk-FFXXGCVG.js";
14
9
  import {
15
10
  createX402FacilitatorAuthHeaders,
16
11
  createX402FacilitatorClient,
@@ -19,13 +14,13 @@ import {
19
14
  verifyWithFacilitator,
20
15
  withPaymentDiagnostics,
21
16
  withPaymentStage
22
- } from "./chunk-AVV5SYGO.js";
17
+ } from "./chunk-VTHYQCDB.js";
23
18
  import {
24
19
  MemoryCreditLedger
25
- } from "./chunk-AGE5XZKE.js";
20
+ } from "./chunk-YOSZIWC3.js";
26
21
  import {
27
22
  PostgresJobStore
28
- } from "./chunk-G3MPHW5I.js";
23
+ } from "./chunk-Y6DO5WSO.js";
29
24
  import {
30
25
  parseStoreValue
31
26
  } from "./chunk-YG7G4DPZ.js";
@@ -36,7 +31,7 @@ import {
36
31
  humanizeZodIssues,
37
32
  signedRequestInput,
38
33
  zodIssuesOf
39
- } from "./chunk-ILBSZMJL.js";
34
+ } from "./chunk-2XQLL6N6.js";
40
35
  import {
41
36
  MemoryJobStore,
42
37
  UNCAPPABLE_ASK_TOTAL,
@@ -45,7 +40,7 @@ import {
45
40
  isJobRetentionStore,
46
41
  isReceiptIssuingStore,
47
42
  isStreamableJobStore
48
- } from "./chunk-IHQUBATH.js";
43
+ } from "./chunk-KFCEXCZM.js";
49
44
  import {
50
45
  ContentHoldError,
51
46
  JobCancelledError,
@@ -85,7 +80,7 @@ import {
85
80
  signReceipt,
86
81
  toLockPubkey,
87
82
  verifyChallenge
88
- } from "./chunk-ZEPJF4JC.js";
83
+ } from "./chunk-VUGEEMLT.js";
89
84
  import {
90
85
  X402_BATCH_SETTLEMENT_SCHEME,
91
86
  X402_DEFAULT_FACILITATOR,
@@ -113,13 +108,7 @@ import {
113
108
  } from "./chunk-UKD3R2KU.js";
114
109
  import {
115
110
  createConsoleLogger
116
- } from "./chunk-OP7VCGQR.js";
117
- import {
118
- loggers,
119
- pinDimension,
120
- withSpan,
121
- withTraceContext
122
- } from "./chunk-IOQIXRGM.js";
111
+ } from "./chunk-3R7NQA3Z.js";
123
112
  import {
124
113
  StepCache
125
114
  } from "./chunk-BQJIFRAZ.js";
@@ -136,7 +125,7 @@ import {
136
125
  roundUsd,
137
126
  satsToFiat,
138
127
  validateCurrency
139
- } from "./chunk-2U6LAASN.js";
128
+ } from "./chunk-W3VGCNG6.js";
140
129
  import {
141
130
  CreditLedger,
142
131
  CreditLedgerError,
@@ -152,26 +141,38 @@ import {
152
141
  isNonChannelBitcoinRail,
153
142
  netOwedSats,
154
143
  x402SettlementPending
155
- } from "./chunk-FM6QAGJC.js";
144
+ } from "./chunk-STJBBVNL.js";
145
+ import {
146
+ withSdkInitLock
147
+ } from "./chunk-COYKJRMM.js";
156
148
  import {
157
149
  withPostgresRecoveryContext
158
- } from "./chunk-7PM4R4GD.js";
150
+ } from "./chunk-MMGSYMIC.js";
159
151
  import {
160
152
  DvmError,
161
153
  x402NetworkByCaip2
162
154
  } from "./chunk-BPYMEMQX.js";
163
155
  import {
164
- createDiagnosticCapabilityAuthority,
165
- diagnosticConsole
166
- } from "./chunk-BFGQG4WY.js";
156
+ TEMPO_MODERATO_CHAIN_ID,
157
+ TEMPO_MODERATO_RPC,
158
+ TempoCloseDescriptorError,
159
+ advertisedMethods,
160
+ challengeMeta,
161
+ installHostedTempoChargeGuard,
162
+ installHostedTempoCloseGuard,
163
+ isTempoInsufficientFundsError,
164
+ resolveTempoNetwork
165
+ } from "./chunk-MU3IE434.js";
167
166
  import {
168
- MemoryProcessedPaymentStore,
169
- PostgresProcessedPaymentStore,
170
- ProcessedPaymentReplayError
171
- } from "./chunk-XRLKGCAW.js";
167
+ loggers,
168
+ pinDimension,
169
+ withSpan,
170
+ withTraceContext
171
+ } from "./chunk-M3T5JYDJ.js";
172
172
  import {
173
- withSdkInitLock
174
- } from "./chunk-COYKJRMM.js";
173
+ createDiagnosticCapabilityAuthority,
174
+ diagnosticConsole
175
+ } from "./chunk-QGNXYVBO.js";
175
176
 
176
177
  // src/sdk/server/lock-pubkey-store.ts
177
178
  async function initLockPubkeyTable(db) {
@@ -3883,7 +3884,7 @@ async function verifyUpfrontPaymentInScope(opts) {
3883
3884
  x402Requirements
3884
3885
  });
3885
3886
  }
3886
- const { verifyX402Payment } = await import("./x402-HQ5GRQZU.js");
3887
+ const { verifyX402Payment } = await import("./x402-EN5LSOVM.js");
3887
3888
  const receipt = await withPaymentStage("verifier_broadcast", () => verifyX402Payment(
3888
3889
  x402Payment,
3889
3890
  x402Config,
@@ -4192,7 +4193,18 @@ async function verifyUpfrontPaymentInScope(opts) {
4192
4193
  replayRealm,
4193
4194
  cred.challenge.id,
4194
4195
  CONSUMED_CREDENTIAL_TTL_MS / 1e3
4195
- ));
4196
+ )).catch((error) => {
4197
+ const transactionHash = tempoReceiptTransactionHash(tempoReceipt);
4198
+ if (!transactionHash) throw error;
4199
+ diagnosticConsole.error({
4200
+ level: "error",
4201
+ event: "tempo_replay_mark_failed",
4202
+ phase: "upfront",
4203
+ rail: "tempo",
4204
+ transaction_hash: transactionHash,
4205
+ ...paymentFailureFields(error)
4206
+ });
4207
+ });
4196
4208
  }
4197
4209
  if (ledgerCtx) {
4198
4210
  try {
@@ -4896,7 +4908,7 @@ async function verifyIncomingPaymentInScope(body, opts, snapshot) {
4896
4908
  snapshot
4897
4909
  });
4898
4910
  }
4899
- const { verifyX402Payment } = await import("./x402-HQ5GRQZU.js");
4911
+ const { verifyX402Payment } = await import("./x402-EN5LSOVM.js");
4900
4912
  const receipt = await withPaymentStage("verifier_broadcast", () => verifyX402Payment(
4901
4913
  body.content.x402_payment,
4902
4914
  opts.x402Config,
@@ -5327,7 +5339,7 @@ async function processIncomingPaymentInScope(job, body, opts) {
5327
5339
  try {
5328
5340
  const requiredMsats = job.pendingPaymentMsats ?? 0;
5329
5341
  const requiredUsdcMicro = job.pendingX402AmountUsdcMicro !== void 0 ? BigInt(job.pendingX402AmountUsdcMicro) : BigInt(msatsToUsdc(requiredMsats, rate));
5330
- const { verifyX402Payment } = await import("./x402-HQ5GRQZU.js");
5342
+ const { verifyX402Payment } = await import("./x402-EN5LSOVM.js");
5331
5343
  const receipt = await withPaymentStage("verifier_broadcast", () => verifyX402Payment(
5332
5344
  body.content.x402_payment,
5333
5345
  opts.x402Config,
@@ -6953,6 +6965,10 @@ async function completeTempoChannelDrain(args) {
6953
6965
  tx_hash: args.reference,
6954
6966
  channel_id: args.channelId,
6955
6967
  ...settlementEvidence,
6968
+ ...args.finalization && {
6969
+ terminal_outcome: args.finalization.outcome,
6970
+ credit_unbacked: args.finalization.creditUnbacked
6971
+ },
6956
6972
  ...args.repaired ? { repaired: true, repair_kind: "tempo_close_reconciled" } : {}
6957
6973
  },
6958
6974
  tx: innerTx
@@ -7231,14 +7247,14 @@ async function handleDrain(c, deps, args) {
7231
7247
  404
7232
7248
  );
7233
7249
  }
7234
- if (credit.status === "unbacked") {
7250
+ let existing = await deps.ledger.getDrain({ creditId, drainId: drain.drain_id });
7251
+ if (credit.status === "unbacked" && existing?.status !== "sent") {
7235
7252
  const rejected2 = await burnNonce(c, deps, args.envelope);
7236
7253
  if (rejected2) return rejected2;
7237
7254
  return jsonPaymentError("credit_unbacked", 409, {
7238
7255
  message: `Credit ${creditId} is terminal because its Tempo backing finalized before the DVM was protected.`
7239
7256
  });
7240
7257
  }
7241
- let existing = await deps.ledger.getDrain({ creditId, drainId: drain.drain_id });
7242
7258
  if (existing) {
7243
7259
  const tempoDiscovery = existing.method === "tempo" && existing.status === "pending" && drain.method === "tempo" && !!credit.tempoChannelId && !c.req.header("Authorization");
7244
7260
  if (!tempoDiscovery) {
@@ -7254,6 +7270,17 @@ async function handleDrain(c, deps, args) {
7254
7270
  const released = await releaseTempoDrain(c, deps, existing, credit.tempoChannelId);
7255
7271
  return respondDrain(c, deps, { record: released, credit });
7256
7272
  }
7273
+ if (existing.status === "exit_required") {
7274
+ const mpp = (await deps.railOpts(c)).mpp;
7275
+ if (mpp && credit.tempoChannelId) {
7276
+ const recovered = await recoverTempoDrain(deps, mpp, existing, credit.tempoChannelId);
7277
+ if (recovered) {
7278
+ if (isHttpResponse(recovered)) return recovered;
7279
+ return respondDrain(c, deps, { record: recovered, credit });
7280
+ }
7281
+ }
7282
+ return tempoExitRequiredResponse(existing);
7283
+ }
7257
7284
  if (existing.method === "tempo" && existing.status === "pending" && credit.tempoChannelId) {
7258
7285
  if (credit.balanceMicro !== credit.availableMicro) {
7259
7286
  const released = await releaseTempoDrain(c, deps, existing, credit.tempoChannelId);
@@ -7491,7 +7518,8 @@ async function completeTempoDrain(c, deps, record, expectedChannelId, credit) {
7491
7518
  deps.hostedSettlementReports,
7492
7519
  rail.mpp,
7493
7520
  record,
7494
- expectedChannelId
7521
+ expectedChannelId,
7522
+ deps.ledger
7495
7523
  );
7496
7524
  if (!rail.mpp?.permitHostedTempoClose) {
7497
7525
  throw new Error("Hosted Tempo close credential guard is unavailable");
@@ -7521,13 +7549,21 @@ async function completeTempoDrain(c, deps, record, expectedChannelId, credit) {
7521
7549
  }
7522
7550
  const wrongChannel = tempoWrongChannelRefusal(error, record);
7523
7551
  if (wrongChannel) return wrongChannel;
7524
- if (error instanceof HostedTempoCloseAttemptsExhaustedError) {
7525
- return jsonPaymentError("tempo_channel_exit_required", 409, {
7526
- message: `Tempo drain ${record.drainId} reached its limit of cooperative close attempts that never reached the chain.`,
7527
- display: "The service could not complete a cooperative close of this stablecoin channel after several attempts. Your funds stay in the channel; use the timed on-chain exit to recover them.",
7528
- hint: "Start the channel's timed on-chain exit. Retrying this drain with another close credential is refused.",
7529
- extra: { drain_registered: true, drain_id: record.drainId, channel_id: expectedChannelId }
7530
- });
7552
+ if (error instanceof HostedTempoCloseAttemptsExhaustedError || error instanceof TempoCloseDescriptorError) {
7553
+ const result = deps.ledger.requireTempoDrainExit ? await deps.ledger.requireTempoDrainExit({ creditId: record.creditId, drainId: record.drainId }) : { drain: record, replayed: false };
7554
+ if (!result.replayed && error instanceof TempoCloseDescriptorError) {
7555
+ loggers.tempo.warn("tempo_session_close_descriptor_refused", {
7556
+ credit_id: record.creditId,
7557
+ drain_id: record.drainId,
7558
+ channel_id: expectedChannelId,
7559
+ amount_at_stake_native: record.drainedNative?.toString() ?? null,
7560
+ amount_at_stake_micro: record.amountMicro,
7561
+ currency: record.currency,
7562
+ error: error.message
7563
+ });
7564
+ }
7565
+ if (result.drain.status === "sent") return respondDrain(c, deps, { record: result.drain, credit });
7566
+ return tempoExitRequiredResponse(result.drain);
7531
7567
  }
7532
7568
  const refused = tempoFloorRefusal(error, record);
7533
7569
  if (refused) return refused;
@@ -7547,13 +7583,8 @@ async function completeTempoDrain(c, deps, record, expectedChannelId, credit) {
7547
7583
  try {
7548
7584
  const state = await rail.mpp.getTempoSessionDrainState?.(expectedChannelId);
7549
7585
  if (state?.finalized || state?.deposit === 0n) {
7550
- const recovered2 = await rail.mpp.recoverTempoSessionClose?.(
7551
- expectedChannelId,
7552
- record.drainId
7553
- );
7554
- if (recovered2) {
7555
- return await markTempoDrainSent(deps, rail.mpp, record, expectedChannelId, recovered2);
7556
- }
7586
+ const recovered2 = await recoverTempoDrain(deps, rail.mpp, record, expectedChannelId);
7587
+ if (recovered2) return recovered2;
7557
7588
  }
7558
7589
  if (state && (state.closeRequestedAt !== 0n || isDefinitiveTempoCloseFailure(error))) {
7559
7590
  return await releaseTempoDrain(c, deps, record, expectedChannelId);
@@ -7580,13 +7611,15 @@ var HOSTED_TEMPO_CLOSE_MAX_RELEASES = 3;
7580
7611
  var HOSTED_TEMPO_CLOSE_IN_FLIGHT = "Hosted Tempo close already has an original effect attempt";
7581
7612
  var HostedTempoCloseAttemptsExhaustedError = class extends Error {
7582
7613
  };
7583
- async function reserveHostedTempoClose(reports, mpp, record, channelId) {
7614
+ async function reserveHostedTempoClose(reports, mpp, record, channelId, ledger) {
7584
7615
  const pending = await reports.pendingTempoClose(channelId, record.drainId);
7585
7616
  if (!pending) {
7586
7617
  const reserved = await reports.reserveTempo(channelId, "close", record.drainId);
7587
7618
  if (reserved.replayed) throw new Error(HOSTED_TEMPO_CLOSE_IN_FLIGHT);
7588
7619
  return reserved.id;
7589
7620
  }
7621
+ if (pending.state === "exhausted") throw new HostedTempoCloseAttemptsExhaustedError();
7622
+ if (pending.state === "unmatched") throw new Error("Hosted Tempo close report requires an operator retry");
7590
7623
  if (pending.txHash || !mpp.getTempoSessionDrainState) throw new Error(HOSTED_TEMPO_CLOSE_IN_FLIGHT);
7591
7624
  const state = await mpp.getTempoSessionDrainState(channelId);
7592
7625
  if (state.finalized || state.deposit === 0n || state.closeRequestedAt !== 0n) {
@@ -7594,7 +7627,10 @@ async function reserveHostedTempoClose(reports, mpp, record, channelId) {
7594
7627
  }
7595
7628
  const result = await reports.releaseStaleTempoClose(pending.id, channelId, record.drainId, {
7596
7629
  staleAfterMs: HOSTED_TEMPO_CLOSE_STALE_MS,
7597
- maxReleases: HOSTED_TEMPO_CLOSE_MAX_RELEASES
7630
+ maxReleases: HOSTED_TEMPO_CLOSE_MAX_RELEASES,
7631
+ onExhausted: async (tx) => {
7632
+ await ledger.requireTempoDrainExit?.({ creditId: record.creditId, drainId: record.drainId, tx });
7633
+ }
7598
7634
  });
7599
7635
  if (result.outcome === "in_flight") throw new Error(HOSTED_TEMPO_CLOSE_IN_FLIGHT);
7600
7636
  if (result.outcome === "exhausted") {
@@ -7603,7 +7639,8 @@ async function reserveHostedTempoClose(reports, mpp, record, channelId) {
7603
7639
  drain_id: record.drainId,
7604
7640
  channel_id: channelId,
7605
7641
  obligation_id: pending.id,
7606
- attempt: result.releases + 1
7642
+ attempt: result.releases + 1,
7643
+ amount_at_stake_native: await reports.tempoCloseAmountAtStake(pending.id)
7607
7644
  });
7608
7645
  throw new HostedTempoCloseAttemptsExhaustedError(
7609
7646
  `Tempo drain ${record.drainId} exhausted its cooperative close attempts`
@@ -7618,13 +7655,55 @@ async function reserveHostedTempoClose(reports, mpp, record, channelId) {
7618
7655
  });
7619
7656
  return result.id;
7620
7657
  }
7658
+ function tempoExitRequiredResponse(record) {
7659
+ const terminal = record.status === "exit_required";
7660
+ return jsonPaymentError("tempo_channel_exit_required", 409, {
7661
+ message: terminal ? `Tempo drain ${record.drainId} cannot cooperatively close this channel.` : `This Tempo close credential cannot complete drain ${record.drainId}.`,
7662
+ display: "Your funds remain in the channel. Use the timed on-chain exit to recover them.",
7663
+ hint: terminal ? "Start the channel's timed on-chain exit. A corrected close credential does not reopen this drain or debit it again." : "This drain remains pending. Use the channel's timed on-chain exit, or retry the same drain with a valid close credential if cooperative attempts remain. Retrying does not debit it again.",
7664
+ extra: {
7665
+ drain_registered: true,
7666
+ drain_id: record.drainId,
7667
+ channel_id: record.channelId,
7668
+ status: record.status
7669
+ }
7670
+ });
7671
+ }
7621
7672
  function tempoCloseCredentialNeedsRefresh(error) {
7622
7673
  return error instanceof MppxErrors.InvalidChallengeError || error instanceof MppxErrors.PaymentExpiredError;
7623
7674
  }
7675
+ function tempoRefundUnprovenResponse(record) {
7676
+ return jsonPaymentError("settlement_pending", 409, {
7677
+ message: "The full Tempo refund could not be proven yet.",
7678
+ display: "Your funds remain held and have not been lost. The DVM operator has been alerted.",
7679
+ hint: `Keep drain ${record.drainId}; no replacement drain is needed.`,
7680
+ extra: { drain_registered: true, drain_id: record.drainId }
7681
+ });
7682
+ }
7624
7683
  async function recoverTempoDrain(deps, mpp, record, channelId) {
7625
7684
  try {
7626
- const reference = await mpp.recoverTempoSessionClose?.(channelId, record.drainId);
7627
- return reference ? await markTempoDrainSent(deps, mpp, record, channelId, reference) : null;
7685
+ const result = await mpp.recoverTempoSessionClose?.(channelId, record.drainId, record.creditId);
7686
+ const durable = await deps.ledger.getDrain({ creditId: record.creditId, drainId: record.drainId });
7687
+ if (durable?.status === "sent") return durable;
7688
+ if (result && typeof result === "object") {
7689
+ return tempoRefundUnprovenResponse(durable ?? record);
7690
+ }
7691
+ if (!result) return null;
7692
+ if (!/^0x[0-9a-fA-F]{64}$/.test(result)) {
7693
+ loggers.tempo.warn("tempo_session_drain_refund_unproven", {
7694
+ channel_id: channelId,
7695
+ credit_id: record.creditId,
7696
+ drain_id: record.drainId,
7697
+ amount_at_stake_native: record.drainedNative?.toString() ?? null,
7698
+ amount_at_stake_micro: record.amountMicro,
7699
+ currency: record.currency,
7700
+ reason: "close_evidence_missing",
7701
+ error: "Recovery returned no proven refund"
7702
+ });
7703
+ return tempoRefundUnprovenResponse(durable ?? record);
7704
+ }
7705
+ if (durable?.status === "exit_required") return null;
7706
+ return await markTempoDrainSent(deps, mpp, durable ?? record, channelId, result);
7628
7707
  } catch (error) {
7629
7708
  const refused = tempoFloorRefusal(error, record);
7630
7709
  if (refused) return refused;
@@ -7634,7 +7713,9 @@ async function recoverTempoDrain(deps, mpp, record, channelId) {
7634
7713
  error: error instanceof Error ? error.message : String(error)
7635
7714
  });
7636
7715
  return jsonPaymentError("payment_invalid", 402, {
7637
- message: "The Tempo session close is still being reconciled; retry this drain."
7716
+ message: "The Tempo session close is still being reconciled; retry this drain.",
7717
+ hint: `Retry this same drain ${record.drainId} after the service can read and persist the close evidence.`,
7718
+ retryable: true
7638
7719
  });
7639
7720
  }
7640
7721
  }
@@ -8109,6 +8190,11 @@ function drainCopy(record) {
8109
8190
  display: "The refund was sent to the payout destination you provided.",
8110
8191
  hint: "See `sent` for the settlement reference (e.g. the on-chain transaction hash)."
8111
8192
  };
8193
+ case "exit_required":
8194
+ return {
8195
+ display: "This drain cannot cooperatively close the channel. Your funds remain in the channel.",
8196
+ hint: "Use the channel's timed on-chain exit. A corrected credential does not reopen this drain."
8197
+ };
8112
8198
  case "released":
8113
8199
  return {
8114
8200
  display: "The channel could not complete this refund, so the service restored the amount to your prepaid balance.",
@@ -9586,172 +9672,6 @@ function safeError(err) {
9586
9672
  return "settlement_transport_error";
9587
9673
  }
9588
9674
 
9589
- // src/sdk/server/hosting-control.ts
9590
- import { createHmac, randomUUID as randomUUID4, timingSafeEqual } from "crypto";
9591
-
9592
- // src/sdk/server/hosting-cleanup-fence.ts
9593
- var HostingCleanupFence = class {
9594
- constructor(pool, dvmId, scope) {
9595
- this.pool = pool;
9596
- this.dvmId = dvmId;
9597
- this.scope = scope;
9598
- }
9599
- pool;
9600
- dvmId;
9601
- scope;
9602
- async assertNewIntent(tx, intent) {
9603
- const continuation = intent?.admissionId || intent?.paymentSeq !== void 0 || intent?.invoicePaymentHash || intent?.invoiceRepairPaymentHash || intent?.creditId && intent.fundId && intent.callerPubkey;
9604
- const { rows: rows2 } = await tx.query(
9605
- `SELECT state, closed_at_ms, closed_at_us FROM sdk_hosting_control
9606
- WHERE dvm_id=$1 AND scope=$2 ${continuation ? "" : "FOR UPDATE"}`,
9607
- [this.dvmId, this.scope]
9608
- );
9609
- const control = rows2[0];
9610
- if (!control) throw new Error("Hosted obligation fence is unavailable");
9611
- if (control.state === "closed" && (control.closed_at_ms === null || control.closed_at_us === null)) {
9612
- throw new Error("Hosted closure cutoff is unavailable");
9613
- }
9614
- if (control.state !== "closed" && !continuation) return;
9615
- if (intent?.invoicePaymentHash || intent?.invoiceRepairPaymentHash) {
9616
- const repair = !!intent.invoiceRepairPaymentHash;
9617
- const invoice = await tx.query(
9618
- `SELECT i.payment_hash FROM credit_invoices i
9619
- LEFT JOIN sdk_hosting_admissions a ON a.id=i.hosting_admission_id
9620
- WHERE i.payment_hash=$1 AND i.caller_pubkey=$4 AND i.amount_micro=$5
9621
- AND (($10::boolean AND $3=i.payment_hash AND i.status IN ('blocked','written_off'))
9622
- OR (NOT $10::boolean AND i.credit_id=$2 AND i.fund_id=$3 AND i.status='pending'))
9623
- AND ((i.hosting_admission_id IS NULL AND
9624
- ($9::bigint IS NULL OR i.created_at_db_us <= $9)) OR
9625
- (a.dvm_id=$6 AND a.scope=$7 AND a.credit_id=i.credit_id
9626
- AND a.fund_id=i.fund_id AND a.caller_pubkey=i.caller_pubkey
9627
- AND a.outcome IN ('pending','accepted','unresolved')
9628
- AND ($8::bigint IS NULL OR a.admitted_at_ms <= $8)))`,
9629
- [
9630
- intent.invoicePaymentHash ?? intent.invoiceRepairPaymentHash,
9631
- intent.creditId,
9632
- intent.fundId,
9633
- intent.callerPubkey,
9634
- intent.amountMicro,
9635
- this.dvmId,
9636
- this.scope,
9637
- control.closed_at_ms,
9638
- control.closed_at_us,
9639
- repair
9640
- ]
9641
- );
9642
- if (invoice.rows[0]) return;
9643
- throw new Error("Hosted invoice has no original payable admission");
9644
- }
9645
- if (intent?.paymentSeq !== void 0 && intent.jobId) {
9646
- const payment = await tx.query(
9647
- `SELECT m.job_id FROM job_messages m JOIN jobs j ON j.id=m.job_id
9648
- WHERE m.job_id=$1 AND m.seq=$2 AND m.status='pending-verification'
9649
- AND m.created_at_ms IS NOT NULL
9650
- AND ($3::bigint IS NULL OR m.created_at_ms <= $3) AND j.dvm_id=$4
9651
- AND ($6::text IS NULL OR m.data->'content'->'tempo_credential'->'challenge'->>'id'=$6)
9652
- AND EXISTS (SELECT 1 FROM sdk_hosting_admissions a
9653
- WHERE a.dvm_id=$4 AND a.scope=$5 AND a.job_id=m.job_id AND a.outcome <> 'failed')`,
9654
- [
9655
- intent.jobId,
9656
- intent.paymentSeq,
9657
- control.closed_at_ms,
9658
- this.dvmId,
9659
- this.scope,
9660
- intent.challengeId ?? null
9661
- ]
9662
- );
9663
- if (payment.rows[0]) return;
9664
- throw new Error("Hosted payment has no pre-closure inbound intent");
9665
- }
9666
- if (!intent?.admissionId && !intent?.jobId && !(intent?.creditId && intent.fundId && intent.callerPubkey) || !intent?.jobId && !(intent?.creditId && intent.fundId)) {
9667
- throw new Error("Hosted obligations are closed to new intents");
9668
- }
9669
- const accepted = await tx.query(
9670
- `SELECT id FROM sdk_hosting_admissions
9671
- WHERE ($1::text IS NULL OR id=$1) AND dvm_id=$2 AND scope=$3
9672
- AND ($4::bigint IS NULL OR admitted_at_ms <= $4)
9673
- AND (outcome IN ('pending', 'unresolved') OR ($11::boolean AND outcome='accepted'))
9674
- AND ($5::text IS NULL OR job_id=$5)
9675
- AND ($6::text IS NULL OR credit_id=$6)
9676
- AND ($7::text IS NULL OR fund_id=$7)
9677
- AND ($8::text IS NULL OR caller_pubkey=$8)
9678
- AND ($9::text IS NULL OR funding_commitment=$9)
9679
- AND ($10::text IS NULL OR payment_artifact_hash=$10)`,
9680
- [
9681
- intent.admissionId ?? null,
9682
- this.dvmId,
9683
- this.scope,
9684
- control.closed_at_ms,
9685
- intent.jobId ?? null,
9686
- intent.creditId ?? null,
9687
- intent.fundId ?? null,
9688
- intent.callerPubkey ?? null,
9689
- intent.fundingCommitment ?? null,
9690
- intent.paymentArtifactHash ?? null,
9691
- control.state !== "closed"
9692
- ]
9693
- );
9694
- if (!accepted.rows[0]) throw new Error("Hosted obligation has no pre-closure admission");
9695
- }
9696
- async withNewIntent(intent, operation) {
9697
- return this.withNewIntentOn(this.pool, intent, operation);
9698
- }
9699
- async assertFundingReservation(tx, reservation) {
9700
- await this.assertNewIntent(tx, {
9701
- admissionId: reservation.admissionId,
9702
- creditId: reservation.creditId,
9703
- fundId: reservation.fundId,
9704
- callerPubkey: reservation.callerPubkey,
9705
- fundingCommitment: reservation.commitment,
9706
- paymentArtifactHash: reservation.paymentArtifactHash
9707
- });
9708
- const verified = await tx.query(
9709
- `SELECT id FROM sdk_hosting_admissions
9710
- WHERE id=$1 AND dvm_id=$2 AND scope=$3 AND credit_id=$4 AND fund_id=$5
9711
- AND caller_pubkey=$6 AND funding_commitment=$7 AND payment_artifact_hash=$8
9712
- AND outcome IN ('pending','accepted','unresolved')`,
9713
- [
9714
- reservation.admissionId,
9715
- this.dvmId,
9716
- this.scope,
9717
- reservation.creditId,
9718
- reservation.fundId,
9719
- reservation.callerPubkey,
9720
- reservation.commitment,
9721
- reservation.paymentArtifactHash
9722
- ]
9723
- );
9724
- if (!verified.rows[0]) throw new Error("Hosted funding has no authentic admission");
9725
- }
9726
- async fundingAdmission(tx, creditId, fundId, callerPubkey) {
9727
- await this.assertNewIntent(tx, { creditId, fundId, callerPubkey });
9728
- const { rows: rows2 } = await tx.query(
9729
- `SELECT id FROM sdk_hosting_admissions
9730
- WHERE dvm_id=$1 AND scope=$2 AND credit_id=$3 AND fund_id=$4
9731
- AND caller_pubkey=$5 AND outcome IN ('pending','unresolved')
9732
- ORDER BY admitted_at_ms,id LIMIT 2`,
9733
- [this.dvmId, this.scope, creditId, fundId, callerPubkey]
9734
- );
9735
- if (rows2.length !== 1) throw new Error("Hosted invoice funding admission is missing or ambiguous");
9736
- return rows2[0].id;
9737
- }
9738
- async withNewIntentOn(db, intent, operation) {
9739
- const tx = await db.connect();
9740
- try {
9741
- await tx.query("BEGIN");
9742
- await this.assertNewIntent(tx, intent);
9743
- const result = await operation(tx);
9744
- await tx.query("COMMIT");
9745
- return result;
9746
- } catch (error) {
9747
- await tx.query("ROLLBACK").catch(() => void 0);
9748
- throw error;
9749
- } finally {
9750
- tx.release();
9751
- }
9752
- }
9753
- };
9754
-
9755
9675
  // src/sdk/server/hosting-settlement-report-obligations.ts
9756
9676
  import { createHash as createHash6, randomUUID as randomUUID3 } from "crypto";
9757
9677
  import { isDeepStrictEqual as isDeepStrictEqual2 } from "util";
@@ -9762,6 +9682,16 @@ import { createPublicClient as createPublicClient4, decodeEventLog, decodeFuncti
9762
9682
  var HOSTED_SETTLEMENT_UNRESOLVED_SQL = `state NOT IN ('delivered','no_report')
9763
9683
  OR (rail='x402' AND operation IN ('claim','refund')
9764
9684
  AND COALESCE(original->>'authorizationCallData','') !~* '^0x([0-9a-f]{2})+$')`;
9685
+ var TEMPO_CLOSE_RETRYABLE_SQL = `target.rail='tempo' AND target.operation='close'
9686
+ AND target.state IN ('unmatched','no_report') AND target.tx_hash IS NULL
9687
+ AND NOT EXISTS (
9688
+ SELECT 1 FROM sdk_hosted_settlement_report_obligations other
9689
+ WHERE other.dvm_id=target.dvm_id AND other.scope=target.scope
9690
+ AND other.rail='tempo' AND other.operation='close' AND other.channel_id=target.channel_id
9691
+ AND other.id<>target.id AND (other.state<>'no_report' OR other.tx_hash IS NOT NULL)
9692
+ )`;
9693
+ var TEMPO_CLOSE_WRITE_OFF_ALLOWED_SQL = `target.rail='tempo' AND target.operation='close'
9694
+ AND target.state='unmatched' AND target.tx_hash IS NULL`;
9765
9695
  var X402_SETTLED_EVENT = parseAbiItem2(
9766
9696
  "event Settled(address indexed receiver,address indexed token,address indexed sender,uint128 amount)"
9767
9697
  );
@@ -9975,14 +9905,13 @@ var HostedSettlementReportObligations = class {
9975
9905
  operation TEXT NOT NULL CHECK (operation IN ('claim','refund','settle','close')),
9976
9906
  network TEXT NOT NULL, contract TEXT NOT NULL, channel_id TEXT NOT NULL,
9977
9907
  original JSONB NOT NULL,
9978
- state TEXT NOT NULL CHECK (state IN ('reserved','observed','report_queued','delivered','no_report')),
9908
+ state TEXT NOT NULL CHECK (state IN ('reserved','observed','report_queued','delivered','no_report','exhausted','unmatched')),
9909
+ close_search_before BIGINT, close_write_off_note TEXT, close_written_off_at_ms BIGINT,
9979
9910
  tx_hash TEXT, block_number BIGINT, block_hash TEXT, delta_native NUMERIC(78,0),
9980
9911
  payout_id TEXT, report_id TEXT, report_payload JSONB, delivered_at_ms BIGINT,
9981
9912
  created_at_ms BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint,
9982
9913
  updated_at_ms BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint
9983
9914
  );
9984
- ALTER TABLE sdk_hosted_settlement_report_obligations
9985
- ADD COLUMN IF NOT EXISTS close_search_before BIGINT;
9986
9915
  CREATE INDEX IF NOT EXISTS sdk_hosted_settlement_report_pending
9987
9916
  ON sdk_hosted_settlement_report_obligations (dvm_id,scope,state)
9988
9917
  WHERE state NOT IN ('delivered','no_report');
@@ -9990,6 +9919,29 @@ var HostedSettlementReportObligations = class {
9990
9919
  ON sdk_hosted_settlement_report_obligations (report_id)
9991
9920
  WHERE report_id IS NOT NULL;
9992
9921
  `);
9922
+ const columns = await this.pool.query(
9923
+ `SELECT attname FROM pg_attribute
9924
+ WHERE attrelid='sdk_hosted_settlement_report_obligations'::regclass AND NOT attisdropped`
9925
+ );
9926
+ for (const [name, type] of [["close_search_before", "BIGINT"], ["close_write_off_note", "TEXT"], ["close_written_off_at_ms", "BIGINT"]]) {
9927
+ if (!columns.rows.some((column) => column.attname === name)) {
9928
+ await this.pool.query(`ALTER TABLE sdk_hosted_settlement_report_obligations ADD COLUMN ${name} ${type}`);
9929
+ }
9930
+ }
9931
+ const constraint = await this.pool.query(
9932
+ `SELECT pg_get_constraintdef(oid) AS definition FROM pg_constraint
9933
+ WHERE conrelid='sdk_hosted_settlement_report_obligations'::regclass
9934
+ AND conname='sdk_hosted_settlement_report_obligations_state_check'`
9935
+ );
9936
+ const states = ["reserved", "observed", "report_queued", "delivered", "no_report", "exhausted", "unmatched"];
9937
+ const expected = `CHECK ((state = ANY (ARRAY[${states.map((state) => `'${state}'::text`).join(", ")}])))`;
9938
+ if (constraint.rows[0]?.definition !== expected) {
9939
+ await this.pool.query(`ALTER TABLE sdk_hosted_settlement_report_obligations
9940
+ DROP CONSTRAINT IF EXISTS sdk_hosted_settlement_report_obligations_state_check;
9941
+ ALTER TABLE sdk_hosted_settlement_report_obligations
9942
+ ADD CONSTRAINT sdk_hosted_settlement_report_obligations_state_check
9943
+ CHECK (state IN ('reserved','observed','report_queued','delivered','no_report','exhausted','unmatched'))`);
9944
+ }
9993
9945
  await this.pool.query(
9994
9946
  `INSERT INTO sdk_hosted_settlement_report_history (singleton,complete)
9995
9947
  VALUES (TRUE,$1) ON CONFLICT (singleton) DO NOTHING`,
@@ -10093,9 +10045,9 @@ var HostedSettlementReportObligations = class {
10093
10045
  }
10094
10046
  /**
10095
10047
  * Retire a close reservation that never recorded a transaction and reserve
10096
- * the drain's next attempt, in one transaction, so the drain always keeps one
10097
- * outstanding close obligation. A close from the retired attempt that lands
10098
- * late is a close of the same channel: `recordSubmitted` on the retired row
10048
+ * the drain's next attempt in one transaction. At the retry cap the final
10049
+ * row becomes terminal `exhausted` and remains claimable by a late landing.
10050
+ * A close from the retired attempt that lands late is the same channel's close: `recordSubmitted` on the retired row
10099
10051
  * writes it onto the successor, and recovery attaches it there if that
10100
10052
  * attempt never got to write, so it is reported once.
10101
10053
  *
@@ -10122,7 +10074,15 @@ var HostedSettlementReportObligations = class {
10122
10074
  AND original->>'drainId'=$4 AND state='no_report' AND tx_hash IS NULL`,
10123
10075
  [this.dvmId, this.scope, channelId.toLowerCase(), drainId]
10124
10076
  )).rows[0].n);
10125
- if (released >= limits.maxReleases) return { outcome: "exhausted", releases: released };
10077
+ if (released >= limits.maxReleases) {
10078
+ await tx.query(
10079
+ `UPDATE sdk_hosted_settlement_report_obligations SET state='exhausted',
10080
+ updated_at_ms=FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint WHERE id=$1`,
10081
+ [id]
10082
+ );
10083
+ await limits.onExhausted?.(tx);
10084
+ return { outcome: "exhausted", releases: released };
10085
+ }
10126
10086
  await tx.query(
10127
10087
  `UPDATE sdk_hosted_settlement_report_obligations SET state='no_report',
10128
10088
  updated_at_ms=FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint WHERE id=$1`,
@@ -10141,11 +10101,126 @@ var HostedSettlementReportObligations = class {
10141
10101
  `UPDATE sdk_hosted_settlement_report_obligations SET state='no_report',
10142
10102
  updated_at_ms=FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint
10143
10103
  WHERE id=$1 AND dvm_id=$2 AND scope=$3 AND rail='tempo' AND operation='close'
10144
- AND state='reserved' AND tx_hash IS NULL`,
10104
+ AND state IN ('reserved','exhausted') AND tx_hash IS NULL`,
10105
+ [id, this.dvmId, this.scope]
10106
+ );
10107
+ return result.rowCount === 1;
10108
+ }
10109
+ async tempoCloseAmountAtStake(id) {
10110
+ const row = await this.lockRow(this.pool, id);
10111
+ if (row.rail !== "tempo" || row.operation !== "close") throw new Error("Not a Tempo close obligation");
10112
+ return (BigInt(String(row.original.highestVoucherAmount)) - BigInt(String(row.original.settledOnChain))).toString();
10113
+ }
10114
+ /** Stop automatic searches without declaring a possibly missing payout discharged. */
10115
+ async markTempoCloseUnmatched(id) {
10116
+ const result = await this.pool.query(
10117
+ `UPDATE sdk_hosted_settlement_report_obligations SET state='unmatched',
10118
+ updated_at_ms=FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint
10119
+ WHERE id=$1 AND dvm_id=$2 AND scope=$3 AND rail='tempo' AND operation='close'
10120
+ AND state IN ('reserved','exhausted') AND tx_hash IS NULL`,
10121
+ [id, this.dvmId, this.scope]
10122
+ );
10123
+ return result.rowCount === 1;
10124
+ }
10125
+ async listTempoCloseReports(args) {
10126
+ const result = await this.pool.query(
10127
+ `WITH candidates AS (
10128
+ SELECT target.*, (${TEMPO_CLOSE_RETRYABLE_SQL}) AS retryable,
10129
+ (${TEMPO_CLOSE_WRITE_OFF_ALLOWED_SQL}) AS write_off_allowed
10130
+ FROM sdk_hosted_settlement_report_obligations target
10131
+ WHERE target.dvm_id=$1 AND target.scope=$2 AND target.rail='tempo' AND target.operation='close'
10132
+ AND target.state IN ('unmatched','no_report')
10133
+ )
10134
+ SELECT * FROM candidates
10135
+ WHERE ($6::boolean OR (state='unmatched' AND (retryable OR write_off_allowed)))
10136
+ AND ($4::bigint IS NULL OR (created_at_ms,id)>($4,$5))
10137
+ ORDER BY created_at_ms,id LIMIT $3`,
10138
+ [
10139
+ this.dvmId,
10140
+ this.scope,
10141
+ args.limit,
10142
+ args.after?.createdAtMs ?? null,
10143
+ args.after?.obligationId ?? null,
10144
+ args.all ?? false
10145
+ ]
10146
+ );
10147
+ return result.rows.map((row) => ({
10148
+ id: row.id,
10149
+ channelId: row.channel_id,
10150
+ amountNative: tempoCloseAmount(row.original),
10151
+ status: row.state,
10152
+ createdAtMs: Number(row.created_at_ms),
10153
+ updatedAtMs: Number(row.updated_at_ms),
10154
+ deliveredAtMs: row.delivered_at_ms === null ? null : Number(row.delivered_at_ms),
10155
+ retryable: row.retryable,
10156
+ writeOffAllowed: row.write_off_allowed,
10157
+ writtenOff: row.close_write_off_note !== null,
10158
+ writeOffNote: row.close_write_off_note,
10159
+ writtenOffAtMs: row.close_written_off_at_ms === null ? null : Number(row.close_written_off_at_ms)
10160
+ }));
10161
+ }
10162
+ async hasTempoCloseReport(id) {
10163
+ const result = await this.pool.query(
10164
+ `SELECT id FROM sdk_hosted_settlement_report_obligations
10165
+ WHERE id=$1 AND dvm_id=$2 AND scope=$3 AND rail='tempo' AND operation='close'`,
10145
10166
  [id, this.dvmId, this.scope]
10146
10167
  );
10147
10168
  return result.rowCount === 1;
10148
10169
  }
10170
+ async writeOffTempoClose(id, note) {
10171
+ const result = await this.pool.query(
10172
+ `UPDATE sdk_hosted_settlement_report_obligations target
10173
+ SET state='no_report', close_write_off_note=$4,
10174
+ close_written_off_at_ms=FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint,
10175
+ updated_at_ms=FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint
10176
+ WHERE id=$1 AND dvm_id=$2 AND scope=$3 AND ${TEMPO_CLOSE_WRITE_OFF_ALLOWED_SQL}
10177
+ RETURNING channel_id,original`,
10178
+ [id, this.dvmId, this.scope, note]
10179
+ );
10180
+ const row = result.rows[0];
10181
+ if (!row) return void 0;
10182
+ const written = { channelId: row.channel_id, amount: tempoCloseAmount(row.original) };
10183
+ loggers.tempo.warn("tempo_session_close_report_written_off", {
10184
+ channel_id: written.channelId,
10185
+ obligation_id: id,
10186
+ amount_forgone_native: written.amount
10187
+ });
10188
+ return written;
10189
+ }
10190
+ async reopenTempoClose(id) {
10191
+ const reopened = await transaction(this.pool, async (tx) => {
10192
+ const owned = await tx.query(
10193
+ "SELECT id FROM sdk_hosted_settlement_report_obligations WHERE id=$1 AND dvm_id=$2 AND scope=$3",
10194
+ [id, this.dvmId, this.scope]
10195
+ );
10196
+ if (!owned.rowCount) return void 0;
10197
+ await this.lockIdentity(tx);
10198
+ const prior = await tx.query(
10199
+ `SELECT channel_id,state,close_write_off_note,original FROM sdk_hosted_settlement_report_obligations
10200
+ WHERE id=$1 AND dvm_id=$2 AND scope=$3 FOR UPDATE`,
10201
+ [id, this.dvmId, this.scope]
10202
+ );
10203
+ const row = prior.rows[0];
10204
+ if (!row) return void 0;
10205
+ const result = await tx.query(
10206
+ `UPDATE sdk_hosted_settlement_report_obligations target
10207
+ SET state='reserved', close_search_before=NULL,
10208
+ updated_at_ms=FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint
10209
+ WHERE id=$1 AND dvm_id=$2 AND scope=$3 AND ${TEMPO_CLOSE_RETRYABLE_SQL}`,
10210
+ [id, this.dvmId, this.scope]
10211
+ );
10212
+ return result.rowCount === 1 ? row : void 0;
10213
+ });
10214
+ if (!reopened) return false;
10215
+ loggers.tempo.info("tempo_session_close_report_reopened", {
10216
+ obligation_id: id,
10217
+ channel_id: reopened.channel_id,
10218
+ prior_status: reopened.state,
10219
+ written_off: reopened.close_write_off_note !== null,
10220
+ amount_native: tempoCloseAmount(reopened.original)
10221
+ });
10222
+ return true;
10223
+ }
10149
10224
  async reserveTempoIn(tx, channelId, operation, drainId) {
10150
10225
  const config = this.tempo;
10151
10226
  if (!config || !hex(channelId, 32)) throw new Error("Hosted Tempo settlement configuration is unavailable");
@@ -10204,7 +10279,7 @@ var HostedSettlementReportObligations = class {
10204
10279
  /** The original close obligation reserved for this drain, while its report is outstanding. */
10205
10280
  async pendingTempoClose(channelId, drainId) {
10206
10281
  const result = await this.pool.query(
10207
- `SELECT id,tx_hash,created_at_ms,close_search_before FROM sdk_hosted_settlement_report_obligations
10282
+ `SELECT id,state,tx_hash,created_at_ms,close_search_before FROM sdk_hosted_settlement_report_obligations
10208
10283
  WHERE dvm_id=$1 AND scope=$2 AND rail='tempo' AND operation='close' AND channel_id=$3
10209
10284
  AND original->>'drainId'=$4 AND state NOT IN ('delivered','no_report')
10210
10285
  ORDER BY created_at_ms DESC LIMIT 1`,
@@ -10213,11 +10288,23 @@ var HostedSettlementReportObligations = class {
10213
10288
  const row = result.rows[0];
10214
10289
  return row && {
10215
10290
  id: row.id,
10291
+ state: row.state,
10216
10292
  txHash: row.tx_hash,
10217
10293
  reservedAtMs: Number(row.created_at_ms),
10218
10294
  searchBefore: searchCursor(row.close_search_before)
10219
10295
  };
10220
10296
  }
10297
+ /** A recorded close transaction remains refund evidence after its report is delivered. */
10298
+ async tempoCloseTransaction(channelId, drainId) {
10299
+ const result = await this.pool.query(
10300
+ `SELECT tx_hash FROM sdk_hosted_settlement_report_obligations
10301
+ WHERE dvm_id=$1 AND scope=$2 AND rail='tempo' AND operation='close' AND channel_id=$3
10302
+ AND original->>'drainId'=$4 AND tx_hash IS NOT NULL
10303
+ ORDER BY created_at_ms DESC LIMIT 1`,
10304
+ [this.dvmId, this.scope, channelId.toLowerCase(), drainId]
10305
+ );
10306
+ return result.rows[0]?.tx_hash;
10307
+ }
10221
10308
  /**
10222
10309
  * Where the next search for this close obligation's transaction resumes. It
10223
10310
  * lives on the obligation, not the drain's close intent, because releasing
@@ -10346,6 +10433,7 @@ var HostedSettlementReportObligations = class {
10346
10433
  }
10347
10434
  await tx.query(
10348
10435
  `UPDATE sdk_hosted_settlement_report_obligations SET tx_hash=COALESCE(tx_hash,$2),
10436
+ state=CASE WHEN state IN ('exhausted','unmatched') THEN 'reserved' ELSE state END,
10349
10437
  updated_at_ms=FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint WHERE id=$1`,
10350
10438
  [row.id, txHash.toLowerCase()]
10351
10439
  );
@@ -10353,15 +10441,15 @@ var HostedSettlementReportObligations = class {
10353
10441
  }
10354
10442
  /**
10355
10443
  * Close reservations that never recorded a transaction, oldest first. Each
10356
- * one owes a payout report only if its close lands, so it stays unresolved
10357
- * until a reconcile reads how its channel finalized.
10444
+ * one owes a payout report if its close lands. Exhausted attempts remain
10445
+ * observable for late landings; unmatched searches require an explicit retry.
10358
10446
  */
10359
10447
  async unsubmittedTempoCloses(limit = 100) {
10360
10448
  const result = await this.pool.query(
10361
10449
  `SELECT id,channel_id,original->>'drainId' AS drain_id,created_at_ms,close_search_before
10362
10450
  FROM sdk_hosted_settlement_report_obligations
10363
10451
  WHERE dvm_id=$1 AND scope=$2 AND rail='tempo' AND operation='close'
10364
- AND state='reserved' AND tx_hash IS NULL
10452
+ AND state IN ('reserved','exhausted') AND tx_hash IS NULL
10365
10453
  ORDER BY created_at_ms,id LIMIT $3`,
10366
10454
  [this.dvmId, this.scope, limit]
10367
10455
  );
@@ -10859,10 +10947,190 @@ var HostedSettlementReportObligations = class {
10859
10947
  return { id: row.id, replayed: true };
10860
10948
  }
10861
10949
  };
10950
+ async function retryHostedTempoCloseReport(args) {
10951
+ const obligations = new HostedSettlementReportObligations(args.db, args.dvmId, args.scope);
10952
+ return obligations.reopenTempoClose(args.obligationId);
10953
+ }
10954
+ async function writeOffHostedTempoCloseReport(args) {
10955
+ const note = args.note.trim();
10956
+ if (!note || note.length > 2e3) throw new Error("A write-off requires a note of 1 to 2000 characters");
10957
+ const row = await new HostedSettlementReportObligations(args.db, args.dvmId, args.scope).writeOffTempoClose(args.obligationId, note);
10958
+ if (!row) return false;
10959
+ return args.includeDetails ? { channelId: row.channelId, amountForgoneNative: row.amount } : true;
10960
+ }
10961
+ function tempoCloseAmount(original) {
10962
+ return (BigInt(String(original.highestVoucherAmount)) - BigInt(String(original.settledOnChain))).toString();
10963
+ }
10862
10964
  function searchCursor(value) {
10863
10965
  return value === null ? null : BigInt(value);
10864
10966
  }
10865
10967
 
10968
+ // src/sdk/server/hosting-control.ts
10969
+ import { createHmac, randomUUID as randomUUID4, timingSafeEqual } from "crypto";
10970
+
10971
+ // src/sdk/server/hosting-cleanup-fence.ts
10972
+ var HostingCleanupFence = class {
10973
+ constructor(pool, dvmId, scope) {
10974
+ this.pool = pool;
10975
+ this.dvmId = dvmId;
10976
+ this.scope = scope;
10977
+ }
10978
+ pool;
10979
+ dvmId;
10980
+ scope;
10981
+ async assertNewIntent(tx, intent) {
10982
+ const continuation = intent?.admissionId || intent?.paymentSeq !== void 0 || intent?.invoicePaymentHash || intent?.invoiceRepairPaymentHash || intent?.creditId && intent.fundId && intent.callerPubkey;
10983
+ const { rows: rows2 } = await tx.query(
10984
+ `SELECT state, closed_at_ms, closed_at_us FROM sdk_hosting_control
10985
+ WHERE dvm_id=$1 AND scope=$2 ${continuation ? "" : "FOR UPDATE"}`,
10986
+ [this.dvmId, this.scope]
10987
+ );
10988
+ const control = rows2[0];
10989
+ if (!control) throw new Error("Hosted obligation fence is unavailable");
10990
+ if (control.state === "closed" && (control.closed_at_ms === null || control.closed_at_us === null)) {
10991
+ throw new Error("Hosted closure cutoff is unavailable");
10992
+ }
10993
+ if (control.state !== "closed" && !continuation) return;
10994
+ if (intent?.invoicePaymentHash || intent?.invoiceRepairPaymentHash) {
10995
+ const repair = !!intent.invoiceRepairPaymentHash;
10996
+ const invoice = await tx.query(
10997
+ `SELECT i.payment_hash FROM credit_invoices i
10998
+ LEFT JOIN sdk_hosting_admissions a ON a.id=i.hosting_admission_id
10999
+ WHERE i.payment_hash=$1 AND i.caller_pubkey=$4 AND i.amount_micro=$5
11000
+ AND (($10::boolean AND $3=i.payment_hash AND i.status IN ('blocked','written_off'))
11001
+ OR (NOT $10::boolean AND i.credit_id=$2 AND i.fund_id=$3 AND i.status='pending'))
11002
+ AND ((i.hosting_admission_id IS NULL AND
11003
+ ($9::bigint IS NULL OR i.created_at_db_us <= $9)) OR
11004
+ (a.dvm_id=$6 AND a.scope=$7 AND a.credit_id=i.credit_id
11005
+ AND a.fund_id=i.fund_id AND a.caller_pubkey=i.caller_pubkey
11006
+ AND a.outcome IN ('pending','accepted','unresolved')
11007
+ AND ($8::bigint IS NULL OR a.admitted_at_ms <= $8)))`,
11008
+ [
11009
+ intent.invoicePaymentHash ?? intent.invoiceRepairPaymentHash,
11010
+ intent.creditId,
11011
+ intent.fundId,
11012
+ intent.callerPubkey,
11013
+ intent.amountMicro,
11014
+ this.dvmId,
11015
+ this.scope,
11016
+ control.closed_at_ms,
11017
+ control.closed_at_us,
11018
+ repair
11019
+ ]
11020
+ );
11021
+ if (invoice.rows[0]) return;
11022
+ throw new Error("Hosted invoice has no original payable admission");
11023
+ }
11024
+ if (intent?.paymentSeq !== void 0 && intent.jobId) {
11025
+ const payment = await tx.query(
11026
+ `SELECT m.job_id FROM job_messages m JOIN jobs j ON j.id=m.job_id
11027
+ WHERE m.job_id=$1 AND m.seq=$2 AND m.status='pending-verification'
11028
+ AND m.created_at_ms IS NOT NULL
11029
+ AND ($3::bigint IS NULL OR m.created_at_ms <= $3) AND j.dvm_id=$4
11030
+ AND ($6::text IS NULL OR m.data->'content'->'tempo_credential'->'challenge'->>'id'=$6)
11031
+ AND EXISTS (SELECT 1 FROM sdk_hosting_admissions a
11032
+ WHERE a.dvm_id=$4 AND a.scope=$5 AND a.job_id=m.job_id AND a.outcome <> 'failed')`,
11033
+ [
11034
+ intent.jobId,
11035
+ intent.paymentSeq,
11036
+ control.closed_at_ms,
11037
+ this.dvmId,
11038
+ this.scope,
11039
+ intent.challengeId ?? null
11040
+ ]
11041
+ );
11042
+ if (payment.rows[0]) return;
11043
+ throw new Error("Hosted payment has no pre-closure inbound intent");
11044
+ }
11045
+ if (!intent?.admissionId && !intent?.jobId && !(intent?.creditId && intent.fundId && intent.callerPubkey) || !intent?.jobId && !(intent?.creditId && intent.fundId)) {
11046
+ throw new Error("Hosted obligations are closed to new intents");
11047
+ }
11048
+ const accepted = await tx.query(
11049
+ `SELECT id FROM sdk_hosting_admissions
11050
+ WHERE ($1::text IS NULL OR id=$1) AND dvm_id=$2 AND scope=$3
11051
+ AND ($4::bigint IS NULL OR admitted_at_ms <= $4)
11052
+ AND (outcome IN ('pending', 'unresolved') OR ($11::boolean AND outcome='accepted'))
11053
+ AND ($5::text IS NULL OR job_id=$5)
11054
+ AND ($6::text IS NULL OR credit_id=$6)
11055
+ AND ($7::text IS NULL OR fund_id=$7)
11056
+ AND ($8::text IS NULL OR caller_pubkey=$8)
11057
+ AND ($9::text IS NULL OR funding_commitment=$9)
11058
+ AND ($10::text IS NULL OR payment_artifact_hash=$10)`,
11059
+ [
11060
+ intent.admissionId ?? null,
11061
+ this.dvmId,
11062
+ this.scope,
11063
+ control.closed_at_ms,
11064
+ intent.jobId ?? null,
11065
+ intent.creditId ?? null,
11066
+ intent.fundId ?? null,
11067
+ intent.callerPubkey ?? null,
11068
+ intent.fundingCommitment ?? null,
11069
+ intent.paymentArtifactHash ?? null,
11070
+ control.state !== "closed"
11071
+ ]
11072
+ );
11073
+ if (!accepted.rows[0]) throw new Error("Hosted obligation has no pre-closure admission");
11074
+ }
11075
+ async withNewIntent(intent, operation) {
11076
+ return this.withNewIntentOn(this.pool, intent, operation);
11077
+ }
11078
+ async assertFundingReservation(tx, reservation) {
11079
+ await this.assertNewIntent(tx, {
11080
+ admissionId: reservation.admissionId,
11081
+ creditId: reservation.creditId,
11082
+ fundId: reservation.fundId,
11083
+ callerPubkey: reservation.callerPubkey,
11084
+ fundingCommitment: reservation.commitment,
11085
+ paymentArtifactHash: reservation.paymentArtifactHash
11086
+ });
11087
+ const verified = await tx.query(
11088
+ `SELECT id FROM sdk_hosting_admissions
11089
+ WHERE id=$1 AND dvm_id=$2 AND scope=$3 AND credit_id=$4 AND fund_id=$5
11090
+ AND caller_pubkey=$6 AND funding_commitment=$7 AND payment_artifact_hash=$8
11091
+ AND outcome IN ('pending','accepted','unresolved')`,
11092
+ [
11093
+ reservation.admissionId,
11094
+ this.dvmId,
11095
+ this.scope,
11096
+ reservation.creditId,
11097
+ reservation.fundId,
11098
+ reservation.callerPubkey,
11099
+ reservation.commitment,
11100
+ reservation.paymentArtifactHash
11101
+ ]
11102
+ );
11103
+ if (!verified.rows[0]) throw new Error("Hosted funding has no authentic admission");
11104
+ }
11105
+ async fundingAdmission(tx, creditId, fundId, callerPubkey) {
11106
+ await this.assertNewIntent(tx, { creditId, fundId, callerPubkey });
11107
+ const { rows: rows2 } = await tx.query(
11108
+ `SELECT id FROM sdk_hosting_admissions
11109
+ WHERE dvm_id=$1 AND scope=$2 AND credit_id=$3 AND fund_id=$4
11110
+ AND caller_pubkey=$5 AND outcome IN ('pending','unresolved')
11111
+ ORDER BY admitted_at_ms,id LIMIT 2`,
11112
+ [this.dvmId, this.scope, creditId, fundId, callerPubkey]
11113
+ );
11114
+ if (rows2.length !== 1) throw new Error("Hosted invoice funding admission is missing or ambiguous");
11115
+ return rows2[0].id;
11116
+ }
11117
+ async withNewIntentOn(db, intent, operation) {
11118
+ const tx = await db.connect();
11119
+ try {
11120
+ await tx.query("BEGIN");
11121
+ await this.assertNewIntent(tx, intent);
11122
+ const result = await operation(tx);
11123
+ await tx.query("COMMIT");
11124
+ return result;
11125
+ } catch (error) {
11126
+ await tx.query("ROLLBACK").catch(() => void 0);
11127
+ throw error;
11128
+ } finally {
11129
+ tx.release();
11130
+ }
11131
+ }
11132
+ };
11133
+
10866
11134
  // src/sdk/server/hosting-channel-evidence.ts
10867
11135
  import { createHash as createHash7 } from "crypto";
10868
11136
  import { Session as TempoSession2 } from "mppx/tempo";
@@ -12024,7 +12292,7 @@ function jobCredentialGate(opts) {
12024
12292
  // to a human verbatim, so the conditional advice lives in `hint`, which
12025
12293
  // is what the agent acts on.
12026
12294
  display: "Couldn't retrieve your result \u2014 the request was missing its job credential.",
12027
- hint: `Send the job_token from the POST /v1/job response as the X-Job-Token header${alternative}. The token is minted once, at submit, and the DVM cannot reissue it \u2014 a job submitted from another machine, or by a caller that never stored it, can't be read here. If your caller never sends X-Job-Token at all, upgrade: npm i -g @dvmkit/dvm-cli@0.2.0, then retry the same command with --endpoint <url> --job-token <hex> (or set DVM_JOB_ENDPOINT and DVM_JOB_TOKEN).`
12295
+ hint: `Send the job_token from the POST /v1/job response as the X-Job-Token header${alternative}. The token is minted once, at submit, and the DVM cannot reissue it \u2014 a job submitted from another machine, or by a caller that never stored it, can't be read here. If your caller never sends X-Job-Token at all, upgrade: npm i -g @dvmkit/dvm-cli@latest, then retry the same command with --endpoint <url> --job-token <hex> (or set DVM_JOB_ENDPOINT and DVM_JOB_TOKEN).`
12028
12296
  };
12029
12297
  }
12030
12298
  };
@@ -16883,6 +17151,9 @@ function installAdminCreditRoutes(app, opts) {
16883
17151
  "/admin/credit/reconcile-x402-exact-settlement",
16884
17152
  (c) => handleReconcileX402Exact(c, opts)
16885
17153
  );
17154
+ app.get("/admin/credit/tempo-close-reports", (c) => handleTempoCloseReports(c, opts));
17155
+ app.post("/admin/credit/retry-tempo-close-report", (c) => handleTempoCloseReportAction(c, opts, "retry"));
17156
+ app.post("/admin/credit/write-off-tempo-close-report", (c) => handleTempoCloseReportAction(c, opts, "write-off"));
16886
17157
  app.get("/admin/credit/tempo-drains", (c) => handleTempoDrains(c, opts));
16887
17158
  app.get("/admin/credit/tempo-losses", (c) => handleTempoLosses(c, opts));
16888
17159
  app.post("/admin/credit/reconcile-tempo-drain", (c) => handleReconcileTempo(c, opts));
@@ -17858,6 +18129,101 @@ async function handleWriteOffX402(c, opts) {
17858
18129
  replayed: !outcome.written
17859
18130
  });
17860
18131
  }
18132
+ function tempoCloseReportsUnavailable(c) {
18133
+ return c.json({
18134
+ error: "tempo_close_report_unavailable",
18135
+ message: "Hosted settlement reports are not configured on this DVM."
18136
+ }, 503);
18137
+ }
18138
+ async function handleTempoCloseReports(c, opts) {
18139
+ const auth = await requireAuth(c, opts);
18140
+ if ("response" in auth) return auth.response;
18141
+ if (!opts.hostedSettlementReports) return tempoCloseReportsUnavailable(c);
18142
+ const limit = parseTempoLimit(c.req.query("limit"));
18143
+ if (limit === void 0) {
18144
+ return c.json({ error: "invalid_query", message: "limit must be an integer in 1..50." }, 400);
18145
+ }
18146
+ let after;
18147
+ const rawAfter = c.req.query("after");
18148
+ if (rawAfter !== void 0) {
18149
+ try {
18150
+ const value = JSON.parse(Buffer.from(rawAfter, "base64url").toString("utf8"));
18151
+ if (!Array.isArray(value) || value.length !== 2 || !Number.isSafeInteger(value[0]) || value[0] < 0 || typeof value[1] !== "string" || !value[1]) throw new Error("invalid cursor");
18152
+ after = { createdAtMs: value[0], obligationId: value[1] };
18153
+ } catch {
18154
+ return c.json({ error: "invalid_query", message: "after must be a next_after value from a previous response." }, 400);
18155
+ }
18156
+ }
18157
+ const all = c.req.query("all");
18158
+ if (all !== void 0 && all !== "0" && all !== "1") {
18159
+ return c.json({ error: "invalid_query", message: "all must be 0 or 1." }, 400);
18160
+ }
18161
+ const rows2 = await opts.hostedSettlementReports.store.listTempoCloseReports({ limit, after, all: all === "1" });
18162
+ const last = rows2.length === limit ? rows2.at(-1) : void 0;
18163
+ const response = {
18164
+ reports: rows2.map((row) => ({
18165
+ obligation_id: row.id,
18166
+ channel_id: row.channelId,
18167
+ amount_native: row.amountNative,
18168
+ status: row.status,
18169
+ created_at_ms: row.createdAtMs,
18170
+ updated_at_ms: row.updatedAtMs,
18171
+ delivered_at_ms: row.deliveredAtMs,
18172
+ retryable: row.retryable,
18173
+ write_off_allowed: row.writeOffAllowed,
18174
+ written_off: row.writtenOff,
18175
+ write_off_note: row.writeOffNote,
18176
+ written_off_at_ms: row.writtenOffAtMs
18177
+ })),
18178
+ ...last ? { next_after: Buffer.from(JSON.stringify([last.createdAtMs, last.id])).toString("base64url") } : {}
18179
+ };
18180
+ return c.json(response);
18181
+ }
18182
+ async function handleTempoCloseReportAction(c, opts, action) {
18183
+ const auth = await requireAuth(c, opts);
18184
+ if ("response" in auth) return auth.response;
18185
+ const reports = opts.hostedSettlementReports;
18186
+ if (!reports) return tempoCloseReportsUnavailable(c);
18187
+ const body = await c.req.json().catch(() => null);
18188
+ if (!body || typeof body.obligation_id !== "string" || !body.obligation_id.trim()) {
18189
+ return c.json({ error: "invalid_request", message: "obligation_id must be a non-empty string." }, 400);
18190
+ }
18191
+ const obligationId = body.obligation_id.trim();
18192
+ if (action === "retry") {
18193
+ if (await reports.store.reopenTempoClose(obligationId)) {
18194
+ const response = { status: "reserved", obligation_id: obligationId };
18195
+ return c.json(response);
18196
+ }
18197
+ } else {
18198
+ if (typeof body.note !== "string" || !body.note.trim() || body.note.trim().length > 2e3) {
18199
+ return c.json({ error: "invalid_request", message: "note must contain 1 to 2000 characters after trimming." }, 400);
18200
+ }
18201
+ const note = body.note.trim();
18202
+ const result = await reports.store.writeOffTempoClose(obligationId, note);
18203
+ if (result) {
18204
+ const response = {
18205
+ status: "written_off",
18206
+ obligation_id: obligationId,
18207
+ channel_id: result.channelId,
18208
+ amount_forgone_native: result.amount,
18209
+ note
18210
+ };
18211
+ return c.json(response);
18212
+ }
18213
+ }
18214
+ if (!await reports.store.hasTempoCloseReport(obligationId)) {
18215
+ return c.json({
18216
+ error: "tempo_close_report_not_found",
18217
+ message: "Close-report obligation not found.",
18218
+ obligation_id: obligationId
18219
+ }, 404);
18220
+ }
18221
+ return c.json({
18222
+ error: "tempo_close_report_conflict",
18223
+ message: action === "retry" ? "Retry requires an unsubmitted unmatched or no_report close obligation without a competing report." : "Write-off requires an unsubmitted unmatched close obligation.",
18224
+ obligation_id: obligationId
18225
+ }, 409);
18226
+ }
17861
18227
  async function handleTempoDrains(c, opts) {
17862
18228
  const auth = await requireAuth(c, opts);
17863
18229
  if ("response" in auth) return auth.response;
@@ -18347,7 +18713,7 @@ async function requireWedgedTempoDrain(c, opts, args, admitBooked) {
18347
18713
  )
18348
18714
  };
18349
18715
  }
18350
- if (record.status !== "pending" && !(admitBooked && record.status === "sent")) {
18716
+ if (record.status !== "pending" && !(admitBooked && (record.status === "sent" || record.status === "exit_required"))) {
18351
18717
  return { response: tempoNotWedgedRefusal(c, args, record.status) };
18352
18718
  }
18353
18719
  return { record, channelId: record.channelId };
@@ -19763,7 +20129,7 @@ async function createDVMServer(descriptor, opts) {
19763
20129
  }
19764
20130
  }
19765
20131
  if (!opts.onJobCompleted && platformToken && platformUrl && dvmId && opts.db) {
19766
- const { RevenueReporter } = await import("./revenue-reporter-KDUZLD5F.js");
20132
+ const { RevenueReporter } = await import("./revenue-reporter-EV3K5L6B.js");
19767
20133
  const reporter = new RevenueReporter(
19768
20134
  opts.db,
19769
20135
  platformUrl,
@@ -19773,7 +20139,7 @@ async function createDVMServer(descriptor, opts) {
19773
20139
  revenueReporter = reporter;
19774
20140
  await reporter.init();
19775
20141
  reporter.startRetryLoop();
19776
- const { MemoryX402BatchStore, PayoutReporter } = await import("./payout-reporter-NN6K2IVQ.js");
20142
+ const { MemoryX402BatchStore, PayoutReporter } = await import("./payout-reporter-HVJ6WUF3.js");
19777
20143
  payoutReporter = new PayoutReporter({
19778
20144
  dvmId,
19779
20145
  db: opts.db,
@@ -20074,7 +20440,43 @@ var DVMServer = class {
20074
20440
  dvmId: this.receiptDvmId()
20075
20441
  }) : void 0;
20076
20442
  this.creditLedger = opts.creditLedger ?? new MemoryCreditLedger();
20077
- this.opts.mpp?.useTempoCreditLedger?.(this.creditLedger);
20443
+ this.opts.mpp?.useTempoCreditLedger?.(this.creditLedger, async (drain, evidence) => {
20444
+ const completed = await completeTempoChannelDrain({
20445
+ deps: { ledger: this.creditLedger, drainReport: {
20446
+ db: this.opts.db,
20447
+ dvmId: this.opts.dvmId,
20448
+ enqueueCreditDrain: this.opts.enqueueCreditDrain
20449
+ } },
20450
+ creditId: drain.creditId,
20451
+ drainId: drain.drainId,
20452
+ channelId: drain.channelId,
20453
+ refundedNative: evidence.refundedNative,
20454
+ debitedNative: drain.drainedNative,
20455
+ reference: evidence.reference,
20456
+ finalization: {
20457
+ outcome: evidence.creditUnbacked ? `${evidence.outcome}_unbacked` : evidence.outcome,
20458
+ creditUnbacked: evidence.creditUnbacked
20459
+ }
20460
+ });
20461
+ if (!completed.replayed && this.receiptIssuer) {
20462
+ const receipt = this.receiptIssuer.issueDrain({
20463
+ creditId: drain.creditId,
20464
+ drainId: drain.drainId,
20465
+ event: "sent",
20466
+ method: drain.method,
20467
+ amountMicro: drain.amountMicro,
20468
+ balanceAfterMicro: drain.balanceAfterMicro,
20469
+ ledgerSeq: drain.ledgerSeq,
20470
+ callerPubkey: drain.callerPubkey
20471
+ });
20472
+ await this.creditLedger.appendDrainReceipt({
20473
+ creditId: drain.creditId,
20474
+ drainId: drain.drainId,
20475
+ receipt
20476
+ });
20477
+ }
20478
+ return !completed.replayed;
20479
+ });
20078
20480
  this.processedPayments = opts.processedPayments ?? new MemoryProcessedPaymentStore();
20079
20481
  this.jobManager = new JobManager(descriptor, {
20080
20482
  receiptIssuer: this.receiptIssuer,
@@ -20404,6 +20806,9 @@ var DVMServer = class {
20404
20806
  // DVM wired with an in-memory session store resolves to a rail with no
20405
20807
  // close-intent read and the verbs answer 503.
20406
20808
  tempoDrainRail: () => Promise.resolve(this.opts.mpp),
20809
+ ...this.opts.hostedSettlementReports ? {
20810
+ hostedSettlementReports: { store: this.opts.hostedSettlementReports }
20811
+ } : {},
20407
20812
  // Health-filtered, and read per request rather than captured: the
20408
20813
  // servicer picking a mint to buy a refund at wants the list that
20409
20814
  // excludes a mint currently failing its probe.
@@ -23277,6 +23682,8 @@ export {
23277
23682
  X402ExactSettlementServer,
23278
23683
  X402ExactSettlementEvidenceMissingError,
23279
23684
  HostedSettlementReportObligations,
23685
+ retryHostedTempoCloseReport,
23686
+ writeOffHostedTempoCloseReport,
23280
23687
  signHostingControl,
23281
23688
  signHostingClosure,
23282
23689
  jobCredentialGate,