@dvmkit/sdk 0.8.0 → 0.9.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-5K5E6ME6.js → chunk-4VV7WACY.js} +1 -1
  2. package/dist/{chunk-GSPV3UIM.js → chunk-73C4JN2E.js} +632 -262
  3. package/dist/{chunk-2I3OHMTY.js → chunk-BAQRUYRL.js} +3 -3
  4. package/dist/{chunk-ATXDKX6F.js → chunk-GAZBBMAA.js} +1 -1
  5. package/dist/{chunk-QPXI7YPH.js → chunk-GODOH2AF.js} +3 -3
  6. package/dist/{chunk-5CQKSLAU.js → chunk-HC4I7LAV.js} +1 -1
  7. package/dist/{chunk-6FEHRZO7.js → chunk-IBX2ORBV.js} +1 -1
  8. package/dist/{chunk-TFYY5APM.js → chunk-IYQQXEHV.js} +3 -3
  9. package/dist/{chunk-X3JEME6P.js → chunk-K2MSIKMM.js} +3 -3
  10. package/dist/{chunk-55NKZQOC.js → chunk-KGL3YMZA.js} +1 -1
  11. package/dist/{chunk-YPSJU3OH.js → chunk-LCCG5HQM.js} +2 -2
  12. package/dist/{chunk-GB5XPAA5.js → chunk-LFVIZH2G.js} +361 -44
  13. package/dist/{chunk-RTFBVXCN.js → chunk-LW7KZQHG.js} +4 -4
  14. package/dist/{chunk-DZQYQN2X.js → chunk-OIP44KBB.js} +12 -0
  15. package/dist/{chunk-P5WEZXJ2.js → chunk-Q3VYRU5R.js} +60 -3
  16. package/dist/{chunk-BL6LRWXD.js → chunk-QSZW2I5Y.js} +1 -1
  17. package/dist/{chunk-I6PHA54S.js → chunk-TFDR2YP2.js} +3 -3
  18. package/dist/{chunk-IFWUWHTW.js → chunk-TRY7BJA4.js} +1 -1
  19. package/dist/{chunk-PRSKC7AD.js → chunk-WB3E7KE7.js} +3 -3
  20. package/dist/{chunk-XXLUIXPO.js → chunk-WLPLHXNP.js} +1 -1
  21. package/dist/{chunk-VYP6ADCS.js → chunk-YYRY32NR.js} +24 -8
  22. package/dist/{chunk-EBCLWOKF.js → chunk-ZBQKXAYK.js} +17 -4
  23. package/dist/{content-retention-D2K4MKNO.d.ts → content-retention-CVBYehRS.d.ts} +1 -1
  24. package/dist/{credit-ledger-AOU6S34N.js → credit-ledger-CZQ56DYI.js} +4 -4
  25. package/dist/{credit-menu-C-8g2cHT.d.ts → credit-menu-QntEhZJS.d.ts} +4 -4
  26. package/dist/{fx-Ql4nvdbh.d.ts → fx-CMyNFenZ.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 +6 -6
  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-DCvJQUX0.d.ts → job-store-qOD-FrQF.d.ts} +1 -1
  34. package/dist/{lightning-backend-CY5ML3km.d.ts → lightning-backend-DtdG6UT5.d.ts} +1 -1
  35. package/dist/{memory-credit-ledger-4HWYINKJ.js → memory-credit-ledger-EMN7G3MO.js} +5 -5
  36. package/dist/{mpp-setup-2BKTPEK6.js → mpp-setup-VW53KFPO.js} +5 -3
  37. package/dist/{payout-reporter-HQSIIBFL.js → payout-reporter-TZX2S7KG.js} +2 -2
  38. package/dist/{postgres-consumed-credential-store-R7DNU6CM.js → postgres-consumed-credential-store-V464OAJB.js} +3 -3
  39. package/dist/{postgres-job-store-6VGFL6XE.js → postgres-job-store-27VOCSPO.js} +2 -2
  40. package/dist/{postgres-replay-store-SY26NUCI.js → postgres-replay-store-7GJFAQXY.js} +2 -2
  41. package/dist/{pricing-H2RA4X2R.js → pricing-NNMBNP7N.js} +3 -3
  42. package/dist/{revenue-reporter-X52GPZ5U.js → revenue-reporter-4EAVPWXS.js} +2 -2
  43. package/dist/server/index.d.ts +14 -10
  44. package/dist/server/index.js +49 -45
  45. package/dist/{step-cache-BOWAn_BH.d.ts → step-cache-B7svmPz3.d.ts} +157 -8
  46. package/dist/{tempo-charge-store-NKXJ2AXE.js → tempo-charge-store-2RPOJBM2.js} +2 -2
  47. package/dist/{tempo-session-store-ZSDQIWSU.js → tempo-session-store-LI3Q3A3U.js} +3 -3
  48. package/dist/testing/index.d.ts +3 -3
  49. package/dist/testing/index.js +7 -7
  50. package/dist/{usd-MAtMp3DB.d.ts → usd-D3izFz71.d.ts} +1 -1
  51. package/dist/{x402-Q4S36NHQ.js → x402-SL36LSOD.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-GB5XPAA5.js";
2
+ MemoryProcessedPaymentStore,
3
+ PostgresProcessedPaymentStore,
4
+ ProcessedPaymentReplayError
5
+ } from "./chunk-XRLKGCAW.js";
11
6
  import {
12
7
  x402EarnedCeiling
13
- } from "./chunk-X3JEME6P.js";
8
+ } from "./chunk-K2MSIKMM.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-ATXDKX6F.js";
17
+ } from "./chunk-GAZBBMAA.js";
23
18
  import {
24
19
  MemoryCreditLedger
25
- } from "./chunk-EBCLWOKF.js";
20
+ } from "./chunk-ZBQKXAYK.js";
26
21
  import {
27
22
  PostgresJobStore
28
- } from "./chunk-QPXI7YPH.js";
23
+ } from "./chunk-GODOH2AF.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-5K5E6ME6.js";
34
+ } from "./chunk-4VV7WACY.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-I6PHA54S.js";
43
+ } from "./chunk-TFDR2YP2.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-RTFBVXCN.js";
83
+ } from "./chunk-LW7KZQHG.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-IFWUWHTW.js";
117
- import {
118
- loggers,
119
- pinDimension,
120
- withSpan,
121
- withTraceContext
122
- } from "./chunk-BL6LRWXD.js";
111
+ } from "./chunk-TRY7BJA4.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-5CQKSLAU.js";
128
+ } from "./chunk-HC4I7LAV.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-VYP6ADCS.js";
144
+ } from "./chunk-YYRY32NR.js";
145
+ import {
146
+ withSdkInitLock
147
+ } from "./chunk-COYKJRMM.js";
156
148
  import {
157
149
  withPostgresRecoveryContext
158
- } from "./chunk-6FEHRZO7.js";
150
+ } from "./chunk-IBX2ORBV.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-DZQYQN2X.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-LFVIZH2G.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-QSZW2I5Y.js";
172
172
  import {
173
- withSdkInitLock
174
- } from "./chunk-COYKJRMM.js";
173
+ createDiagnosticCapabilityAuthority,
174
+ diagnosticConsole
175
+ } from "./chunk-OIP44KBB.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-Q4S36NHQ.js");
3887
+ const { verifyX402Payment } = await import("./x402-SL36LSOD.js");
3887
3888
  const receipt = await withPaymentStage("verifier_broadcast", () => verifyX402Payment(
3888
3889
  x402Payment,
3889
3890
  x402Config,
@@ -4896,7 +4897,7 @@ async function verifyIncomingPaymentInScope(body, opts, snapshot) {
4896
4897
  snapshot
4897
4898
  });
4898
4899
  }
4899
- const { verifyX402Payment } = await import("./x402-Q4S36NHQ.js");
4900
+ const { verifyX402Payment } = await import("./x402-SL36LSOD.js");
4900
4901
  const receipt = await withPaymentStage("verifier_broadcast", () => verifyX402Payment(
4901
4902
  body.content.x402_payment,
4902
4903
  opts.x402Config,
@@ -5327,7 +5328,7 @@ async function processIncomingPaymentInScope(job, body, opts) {
5327
5328
  try {
5328
5329
  const requiredMsats = job.pendingPaymentMsats ?? 0;
5329
5330
  const requiredUsdcMicro = job.pendingX402AmountUsdcMicro !== void 0 ? BigInt(job.pendingX402AmountUsdcMicro) : BigInt(msatsToUsdc(requiredMsats, rate));
5330
- const { verifyX402Payment } = await import("./x402-Q4S36NHQ.js");
5331
+ const { verifyX402Payment } = await import("./x402-SL36LSOD.js");
5331
5332
  const receipt = await withPaymentStage("verifier_broadcast", () => verifyX402Payment(
5332
5333
  body.content.x402_payment,
5333
5334
  opts.x402Config,
@@ -6953,6 +6954,10 @@ async function completeTempoChannelDrain(args) {
6953
6954
  tx_hash: args.reference,
6954
6955
  channel_id: args.channelId,
6955
6956
  ...settlementEvidence,
6957
+ ...args.finalization && {
6958
+ terminal_outcome: args.finalization.outcome,
6959
+ credit_unbacked: args.finalization.creditUnbacked
6960
+ },
6956
6961
  ...args.repaired ? { repaired: true, repair_kind: "tempo_close_reconciled" } : {}
6957
6962
  },
6958
6963
  tx: innerTx
@@ -7231,14 +7236,14 @@ async function handleDrain(c, deps, args) {
7231
7236
  404
7232
7237
  );
7233
7238
  }
7234
- if (credit.status === "unbacked") {
7239
+ let existing = await deps.ledger.getDrain({ creditId, drainId: drain.drain_id });
7240
+ if (credit.status === "unbacked" && existing?.status !== "sent") {
7235
7241
  const rejected2 = await burnNonce(c, deps, args.envelope);
7236
7242
  if (rejected2) return rejected2;
7237
7243
  return jsonPaymentError("credit_unbacked", 409, {
7238
7244
  message: `Credit ${creditId} is terminal because its Tempo backing finalized before the DVM was protected.`
7239
7245
  });
7240
7246
  }
7241
- let existing = await deps.ledger.getDrain({ creditId, drainId: drain.drain_id });
7242
7247
  if (existing) {
7243
7248
  const tempoDiscovery = existing.method === "tempo" && existing.status === "pending" && drain.method === "tempo" && !!credit.tempoChannelId && !c.req.header("Authorization");
7244
7249
  if (!tempoDiscovery) {
@@ -7254,6 +7259,17 @@ async function handleDrain(c, deps, args) {
7254
7259
  const released = await releaseTempoDrain(c, deps, existing, credit.tempoChannelId);
7255
7260
  return respondDrain(c, deps, { record: released, credit });
7256
7261
  }
7262
+ if (existing.status === "exit_required") {
7263
+ const mpp = (await deps.railOpts(c)).mpp;
7264
+ if (mpp && credit.tempoChannelId) {
7265
+ const recovered = await recoverTempoDrain(deps, mpp, existing, credit.tempoChannelId);
7266
+ if (recovered) {
7267
+ if (isHttpResponse(recovered)) return recovered;
7268
+ return respondDrain(c, deps, { record: recovered, credit });
7269
+ }
7270
+ }
7271
+ return tempoExitRequiredResponse(existing);
7272
+ }
7257
7273
  if (existing.method === "tempo" && existing.status === "pending" && credit.tempoChannelId) {
7258
7274
  if (credit.balanceMicro !== credit.availableMicro) {
7259
7275
  const released = await releaseTempoDrain(c, deps, existing, credit.tempoChannelId);
@@ -7487,17 +7503,17 @@ async function completeTempoDrain(c, deps, record, expectedChannelId, credit) {
7487
7503
  await beginClose(expectedChannelId, record.drainId);
7488
7504
  recoveryVersion = await deps.tempoSettlementReadiness?.captureRecoveryVersion();
7489
7505
  if (deps.hostedSettlementReports) {
7490
- const reserved = await deps.hostedSettlementReports.reserveTempo(
7506
+ hostedObligationId = await reserveHostedTempoClose(
7507
+ deps.hostedSettlementReports,
7508
+ rail.mpp,
7509
+ record,
7491
7510
  expectedChannelId,
7492
- "close",
7493
- record.drainId
7511
+ deps.ledger
7494
7512
  );
7495
- if (reserved.replayed) throw new Error("Hosted Tempo close already has an original effect attempt");
7496
- hostedObligationId = reserved.id;
7497
7513
  if (!rail.mpp?.permitHostedTempoClose) {
7498
7514
  throw new Error("Hosted Tempo close credential guard is unavailable");
7499
7515
  }
7500
- rail.mpp.permitHostedTempoClose(rail.mppCredential, reserved.id);
7516
+ rail.mpp.permitHostedTempoClose(rail.mppCredential, hostedObligationId);
7501
7517
  }
7502
7518
  }
7503
7519
  });
@@ -7522,6 +7538,22 @@ async function completeTempoDrain(c, deps, record, expectedChannelId, credit) {
7522
7538
  }
7523
7539
  const wrongChannel = tempoWrongChannelRefusal(error, record);
7524
7540
  if (wrongChannel) return wrongChannel;
7541
+ if (error instanceof HostedTempoCloseAttemptsExhaustedError || error instanceof TempoCloseDescriptorError) {
7542
+ const result = deps.ledger.requireTempoDrainExit ? await deps.ledger.requireTempoDrainExit({ creditId: record.creditId, drainId: record.drainId }) : { drain: record, replayed: false };
7543
+ if (!result.replayed && error instanceof TempoCloseDescriptorError) {
7544
+ loggers.tempo.warn("tempo_session_close_descriptor_refused", {
7545
+ credit_id: record.creditId,
7546
+ drain_id: record.drainId,
7547
+ channel_id: expectedChannelId,
7548
+ amount_at_stake_native: record.drainedNative?.toString() ?? null,
7549
+ amount_at_stake_micro: record.amountMicro,
7550
+ currency: record.currency,
7551
+ error: error.message
7552
+ });
7553
+ }
7554
+ if (result.drain.status === "sent") return respondDrain(c, deps, { record: result.drain, credit });
7555
+ return tempoExitRequiredResponse(result.drain);
7556
+ }
7525
7557
  const refused = tempoFloorRefusal(error, record);
7526
7558
  if (refused) return refused;
7527
7559
  if (tempoCloseCredentialNeedsRefresh(error)) {
@@ -7568,6 +7600,69 @@ async function completeTempoDrain(c, deps, record, expectedChannelId, credit) {
7568
7600
  });
7569
7601
  }
7570
7602
  }
7603
+ var HOSTED_TEMPO_CLOSE_STALE_MS = 3e5;
7604
+ var HOSTED_TEMPO_CLOSE_MAX_RELEASES = 3;
7605
+ var HOSTED_TEMPO_CLOSE_IN_FLIGHT = "Hosted Tempo close already has an original effect attempt";
7606
+ var HostedTempoCloseAttemptsExhaustedError = class extends Error {
7607
+ };
7608
+ async function reserveHostedTempoClose(reports, mpp, record, channelId, ledger) {
7609
+ const pending = await reports.pendingTempoClose(channelId, record.drainId);
7610
+ if (!pending) {
7611
+ const reserved = await reports.reserveTempo(channelId, "close", record.drainId);
7612
+ if (reserved.replayed) throw new Error(HOSTED_TEMPO_CLOSE_IN_FLIGHT);
7613
+ return reserved.id;
7614
+ }
7615
+ if (pending.state === "exhausted") throw new HostedTempoCloseAttemptsExhaustedError();
7616
+ if (pending.state === "unmatched") throw new Error("Hosted Tempo close report requires an operator retry");
7617
+ if (pending.txHash || !mpp.getTempoSessionDrainState) throw new Error(HOSTED_TEMPO_CLOSE_IN_FLIGHT);
7618
+ const state = await mpp.getTempoSessionDrainState(channelId);
7619
+ if (state.finalized || state.deposit === 0n || state.closeRequestedAt !== 0n) {
7620
+ throw new Error(HOSTED_TEMPO_CLOSE_IN_FLIGHT);
7621
+ }
7622
+ const result = await reports.releaseStaleTempoClose(pending.id, channelId, record.drainId, {
7623
+ staleAfterMs: HOSTED_TEMPO_CLOSE_STALE_MS,
7624
+ maxReleases: HOSTED_TEMPO_CLOSE_MAX_RELEASES,
7625
+ onExhausted: async (tx) => {
7626
+ await ledger.requireTempoDrainExit?.({ creditId: record.creditId, drainId: record.drainId, tx });
7627
+ }
7628
+ });
7629
+ if (result.outcome === "in_flight") throw new Error(HOSTED_TEMPO_CLOSE_IN_FLIGHT);
7630
+ if (result.outcome === "exhausted") {
7631
+ loggers.tempo.warn("tempo_session_close_attempts_exhausted", {
7632
+ credit_id: record.creditId,
7633
+ drain_id: record.drainId,
7634
+ channel_id: channelId,
7635
+ obligation_id: pending.id,
7636
+ attempt: result.releases + 1,
7637
+ amount_at_stake_native: await reports.tempoCloseAmountAtStake(pending.id)
7638
+ });
7639
+ throw new HostedTempoCloseAttemptsExhaustedError(
7640
+ `Tempo drain ${record.drainId} exhausted its cooperative close attempts`
7641
+ );
7642
+ }
7643
+ loggers.tempo.warn("tempo_session_close_reservation_released", {
7644
+ credit_id: record.creditId,
7645
+ drain_id: record.drainId,
7646
+ channel_id: channelId,
7647
+ obligation_id: pending.id,
7648
+ attempt: result.releases + 1
7649
+ });
7650
+ return result.id;
7651
+ }
7652
+ function tempoExitRequiredResponse(record) {
7653
+ const terminal = record.status === "exit_required";
7654
+ return jsonPaymentError("tempo_channel_exit_required", 409, {
7655
+ message: terminal ? `Tempo drain ${record.drainId} cannot cooperatively close this channel.` : `This Tempo close credential cannot complete drain ${record.drainId}.`,
7656
+ display: "Your funds remain in the channel. Use the timed on-chain exit to recover them.",
7657
+ 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.",
7658
+ extra: {
7659
+ drain_registered: true,
7660
+ drain_id: record.drainId,
7661
+ channel_id: record.channelId,
7662
+ status: record.status
7663
+ }
7664
+ });
7665
+ }
7571
7666
  function tempoCloseCredentialNeedsRefresh(error) {
7572
7667
  return error instanceof MppxErrors.InvalidChallengeError || error instanceof MppxErrors.PaymentExpiredError;
7573
7668
  }
@@ -8059,6 +8154,11 @@ function drainCopy(record) {
8059
8154
  display: "The refund was sent to the payout destination you provided.",
8060
8155
  hint: "See `sent` for the settlement reference (e.g. the on-chain transaction hash)."
8061
8156
  };
8157
+ case "exit_required":
8158
+ return {
8159
+ display: "This drain cannot cooperatively close the channel. Your funds remain in the channel.",
8160
+ hint: "Use the channel's timed on-chain exit. A corrected credential does not reopen this drain."
8161
+ };
8062
8162
  case "released":
8063
8163
  return {
8064
8164
  display: "The channel could not complete this refund, so the service restored the amount to your prepaid balance.",
@@ -9536,172 +9636,6 @@ function safeError(err) {
9536
9636
  return "settlement_transport_error";
9537
9637
  }
9538
9638
 
9539
- // src/sdk/server/hosting-control.ts
9540
- import { createHmac, randomUUID as randomUUID4, timingSafeEqual } from "crypto";
9541
-
9542
- // src/sdk/server/hosting-cleanup-fence.ts
9543
- var HostingCleanupFence = class {
9544
- constructor(pool, dvmId, scope) {
9545
- this.pool = pool;
9546
- this.dvmId = dvmId;
9547
- this.scope = scope;
9548
- }
9549
- pool;
9550
- dvmId;
9551
- scope;
9552
- async assertNewIntent(tx, intent) {
9553
- const continuation = intent?.admissionId || intent?.paymentSeq !== void 0 || intent?.invoicePaymentHash || intent?.invoiceRepairPaymentHash || intent?.creditId && intent.fundId && intent.callerPubkey;
9554
- const { rows: rows2 } = await tx.query(
9555
- `SELECT state, closed_at_ms, closed_at_us FROM sdk_hosting_control
9556
- WHERE dvm_id=$1 AND scope=$2 ${continuation ? "" : "FOR UPDATE"}`,
9557
- [this.dvmId, this.scope]
9558
- );
9559
- const control = rows2[0];
9560
- if (!control) throw new Error("Hosted obligation fence is unavailable");
9561
- if (control.state === "closed" && (control.closed_at_ms === null || control.closed_at_us === null)) {
9562
- throw new Error("Hosted closure cutoff is unavailable");
9563
- }
9564
- if (control.state !== "closed" && !continuation) return;
9565
- if (intent?.invoicePaymentHash || intent?.invoiceRepairPaymentHash) {
9566
- const repair = !!intent.invoiceRepairPaymentHash;
9567
- const invoice = await tx.query(
9568
- `SELECT i.payment_hash FROM credit_invoices i
9569
- LEFT JOIN sdk_hosting_admissions a ON a.id=i.hosting_admission_id
9570
- WHERE i.payment_hash=$1 AND i.caller_pubkey=$4 AND i.amount_micro=$5
9571
- AND (($10::boolean AND $3=i.payment_hash AND i.status IN ('blocked','written_off'))
9572
- OR (NOT $10::boolean AND i.credit_id=$2 AND i.fund_id=$3 AND i.status='pending'))
9573
- AND ((i.hosting_admission_id IS NULL AND
9574
- ($9::bigint IS NULL OR i.created_at_db_us <= $9)) OR
9575
- (a.dvm_id=$6 AND a.scope=$7 AND a.credit_id=i.credit_id
9576
- AND a.fund_id=i.fund_id AND a.caller_pubkey=i.caller_pubkey
9577
- AND a.outcome IN ('pending','accepted','unresolved')
9578
- AND ($8::bigint IS NULL OR a.admitted_at_ms <= $8)))`,
9579
- [
9580
- intent.invoicePaymentHash ?? intent.invoiceRepairPaymentHash,
9581
- intent.creditId,
9582
- intent.fundId,
9583
- intent.callerPubkey,
9584
- intent.amountMicro,
9585
- this.dvmId,
9586
- this.scope,
9587
- control.closed_at_ms,
9588
- control.closed_at_us,
9589
- repair
9590
- ]
9591
- );
9592
- if (invoice.rows[0]) return;
9593
- throw new Error("Hosted invoice has no original payable admission");
9594
- }
9595
- if (intent?.paymentSeq !== void 0 && intent.jobId) {
9596
- const payment = await tx.query(
9597
- `SELECT m.job_id FROM job_messages m JOIN jobs j ON j.id=m.job_id
9598
- WHERE m.job_id=$1 AND m.seq=$2 AND m.status='pending-verification'
9599
- AND m.created_at_ms IS NOT NULL
9600
- AND ($3::bigint IS NULL OR m.created_at_ms <= $3) AND j.dvm_id=$4
9601
- AND ($6::text IS NULL OR m.data->'content'->'tempo_credential'->'challenge'->>'id'=$6)
9602
- AND EXISTS (SELECT 1 FROM sdk_hosting_admissions a
9603
- WHERE a.dvm_id=$4 AND a.scope=$5 AND a.job_id=m.job_id AND a.outcome <> 'failed')`,
9604
- [
9605
- intent.jobId,
9606
- intent.paymentSeq,
9607
- control.closed_at_ms,
9608
- this.dvmId,
9609
- this.scope,
9610
- intent.challengeId ?? null
9611
- ]
9612
- );
9613
- if (payment.rows[0]) return;
9614
- throw new Error("Hosted payment has no pre-closure inbound intent");
9615
- }
9616
- if (!intent?.admissionId && !intent?.jobId && !(intent?.creditId && intent.fundId && intent.callerPubkey) || !intent?.jobId && !(intent?.creditId && intent.fundId)) {
9617
- throw new Error("Hosted obligations are closed to new intents");
9618
- }
9619
- const accepted = await tx.query(
9620
- `SELECT id FROM sdk_hosting_admissions
9621
- WHERE ($1::text IS NULL OR id=$1) AND dvm_id=$2 AND scope=$3
9622
- AND ($4::bigint IS NULL OR admitted_at_ms <= $4)
9623
- AND (outcome IN ('pending', 'unresolved') OR ($11::boolean AND outcome='accepted'))
9624
- AND ($5::text IS NULL OR job_id=$5)
9625
- AND ($6::text IS NULL OR credit_id=$6)
9626
- AND ($7::text IS NULL OR fund_id=$7)
9627
- AND ($8::text IS NULL OR caller_pubkey=$8)
9628
- AND ($9::text IS NULL OR funding_commitment=$9)
9629
- AND ($10::text IS NULL OR payment_artifact_hash=$10)`,
9630
- [
9631
- intent.admissionId ?? null,
9632
- this.dvmId,
9633
- this.scope,
9634
- control.closed_at_ms,
9635
- intent.jobId ?? null,
9636
- intent.creditId ?? null,
9637
- intent.fundId ?? null,
9638
- intent.callerPubkey ?? null,
9639
- intent.fundingCommitment ?? null,
9640
- intent.paymentArtifactHash ?? null,
9641
- control.state !== "closed"
9642
- ]
9643
- );
9644
- if (!accepted.rows[0]) throw new Error("Hosted obligation has no pre-closure admission");
9645
- }
9646
- async withNewIntent(intent, operation) {
9647
- return this.withNewIntentOn(this.pool, intent, operation);
9648
- }
9649
- async assertFundingReservation(tx, reservation) {
9650
- await this.assertNewIntent(tx, {
9651
- admissionId: reservation.admissionId,
9652
- creditId: reservation.creditId,
9653
- fundId: reservation.fundId,
9654
- callerPubkey: reservation.callerPubkey,
9655
- fundingCommitment: reservation.commitment,
9656
- paymentArtifactHash: reservation.paymentArtifactHash
9657
- });
9658
- const verified = await tx.query(
9659
- `SELECT id FROM sdk_hosting_admissions
9660
- WHERE id=$1 AND dvm_id=$2 AND scope=$3 AND credit_id=$4 AND fund_id=$5
9661
- AND caller_pubkey=$6 AND funding_commitment=$7 AND payment_artifact_hash=$8
9662
- AND outcome IN ('pending','accepted','unresolved')`,
9663
- [
9664
- reservation.admissionId,
9665
- this.dvmId,
9666
- this.scope,
9667
- reservation.creditId,
9668
- reservation.fundId,
9669
- reservation.callerPubkey,
9670
- reservation.commitment,
9671
- reservation.paymentArtifactHash
9672
- ]
9673
- );
9674
- if (!verified.rows[0]) throw new Error("Hosted funding has no authentic admission");
9675
- }
9676
- async fundingAdmission(tx, creditId, fundId, callerPubkey) {
9677
- await this.assertNewIntent(tx, { creditId, fundId, callerPubkey });
9678
- const { rows: rows2 } = await tx.query(
9679
- `SELECT id FROM sdk_hosting_admissions
9680
- WHERE dvm_id=$1 AND scope=$2 AND credit_id=$3 AND fund_id=$4
9681
- AND caller_pubkey=$5 AND outcome IN ('pending','unresolved')
9682
- ORDER BY admitted_at_ms,id LIMIT 2`,
9683
- [this.dvmId, this.scope, creditId, fundId, callerPubkey]
9684
- );
9685
- if (rows2.length !== 1) throw new Error("Hosted invoice funding admission is missing or ambiguous");
9686
- return rows2[0].id;
9687
- }
9688
- async withNewIntentOn(db, intent, operation) {
9689
- const tx = await db.connect();
9690
- try {
9691
- await tx.query("BEGIN");
9692
- await this.assertNewIntent(tx, intent);
9693
- const result = await operation(tx);
9694
- await tx.query("COMMIT");
9695
- return result;
9696
- } catch (error) {
9697
- await tx.query("ROLLBACK").catch(() => void 0);
9698
- throw error;
9699
- } finally {
9700
- tx.release();
9701
- }
9702
- }
9703
- };
9704
-
9705
9639
  // src/sdk/server/hosting-settlement-report-obligations.ts
9706
9640
  import { createHash as createHash6, randomUUID as randomUUID3 } from "crypto";
9707
9641
  import { isDeepStrictEqual as isDeepStrictEqual2 } from "util";
@@ -9925,7 +9859,8 @@ var HostedSettlementReportObligations = class {
9925
9859
  operation TEXT NOT NULL CHECK (operation IN ('claim','refund','settle','close')),
9926
9860
  network TEXT NOT NULL, contract TEXT NOT NULL, channel_id TEXT NOT NULL,
9927
9861
  original JSONB NOT NULL,
9928
- state TEXT NOT NULL CHECK (state IN ('reserved','observed','report_queued','delivered','no_report')),
9862
+ state TEXT NOT NULL CHECK (state IN ('reserved','observed','report_queued','delivered','no_report','exhausted','unmatched')),
9863
+ close_search_before BIGINT, close_write_off_note TEXT,
9929
9864
  tx_hash TEXT, block_number BIGINT, block_hash TEXT, delta_native NUMERIC(78,0),
9930
9865
  payout_id TEXT, report_id TEXT, report_payload JSONB, delivered_at_ms BIGINT,
9931
9866
  created_at_ms BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint,
@@ -9938,6 +9873,29 @@ var HostedSettlementReportObligations = class {
9938
9873
  ON sdk_hosted_settlement_report_obligations (report_id)
9939
9874
  WHERE report_id IS NOT NULL;
9940
9875
  `);
9876
+ const columns = await this.pool.query(
9877
+ `SELECT attname FROM pg_attribute
9878
+ WHERE attrelid='sdk_hosted_settlement_report_obligations'::regclass AND NOT attisdropped`
9879
+ );
9880
+ for (const [name, type] of [["close_search_before", "BIGINT"], ["close_write_off_note", "TEXT"]]) {
9881
+ if (!columns.rows.some((column) => column.attname === name)) {
9882
+ await this.pool.query(`ALTER TABLE sdk_hosted_settlement_report_obligations ADD COLUMN ${name} ${type}`);
9883
+ }
9884
+ }
9885
+ const constraint = await this.pool.query(
9886
+ `SELECT pg_get_constraintdef(oid) AS definition FROM pg_constraint
9887
+ WHERE conrelid='sdk_hosted_settlement_report_obligations'::regclass
9888
+ AND conname='sdk_hosted_settlement_report_obligations_state_check'`
9889
+ );
9890
+ const states = ["reserved", "observed", "report_queued", "delivered", "no_report", "exhausted", "unmatched"];
9891
+ const expected = `CHECK ((state = ANY (ARRAY[${states.map((state) => `'${state}'::text`).join(", ")}])))`;
9892
+ if (constraint.rows[0]?.definition !== expected) {
9893
+ await this.pool.query(`ALTER TABLE sdk_hosted_settlement_report_obligations
9894
+ DROP CONSTRAINT IF EXISTS sdk_hosted_settlement_report_obligations_state_check;
9895
+ ALTER TABLE sdk_hosted_settlement_report_obligations
9896
+ ADD CONSTRAINT sdk_hosted_settlement_report_obligations_state_check
9897
+ CHECK (state IN ('reserved','observed','report_queued','delivered','no_report','exhausted','unmatched'))`);
9898
+ }
9941
9899
  await this.pool.query(
9942
9900
  `INSERT INTO sdk_hosted_settlement_report_history (singleton,complete)
9943
9901
  VALUES (TRUE,$1) ON CONFLICT (singleton) DO NOTHING`,
@@ -10037,53 +9995,169 @@ var HostedSettlementReportObligations = class {
10037
9995
  });
10038
9996
  }
10039
9997
  async reserveTempo(channelId, operation, drainId) {
10040
- const config = this.tempo;
10041
- if (!config || !hex(channelId, 32)) throw new Error("Hosted Tempo settlement configuration is unavailable");
9998
+ return transaction(this.pool, (tx) => this.reserveTempoIn(tx, channelId, operation, drainId));
9999
+ }
10000
+ /**
10001
+ * Retire a close reservation that never recorded a transaction and reserve
10002
+ * the drain's next attempt in one transaction. At the retry cap the final
10003
+ * row becomes terminal `exhausted` and remains claimable by a late landing.
10004
+ * A close from the retired attempt that lands late is the same channel's close: `recordSubmitted` on the retired row
10005
+ * writes it onto the successor, and recovery attaches it there if that
10006
+ * attempt never got to write, so it is reported once.
10007
+ *
10008
+ * The caller has just read the channel open on chain. The age bound only
10009
+ * spares a reservation young enough to be inside its first receipt wait; an
10010
+ * attempt slowed past it by a degraded RPC can still be running.
10011
+ */
10012
+ async releaseStaleTempoClose(id, channelId, drainId, limits) {
10042
10013
  return transaction(this.pool, async (tx) => {
10043
- const state = await tx.query(
10044
- "SELECT state_json FROM mpp_tempo_channels WHERE channel_key=$1",
10045
- [channelId.toLowerCase()]
10014
+ await this.lockIdentity(tx);
10015
+ const row = await this.lockRow(tx, id);
10016
+ if (row.rail !== "tempo" || row.operation !== "close" || row.channel_id !== channelId.toLowerCase() || row.original.drainId !== drainId || row.state !== "reserved" || row.tx_hash) {
10017
+ return { outcome: "in_flight" };
10018
+ }
10019
+ const age = await tx.query(
10020
+ `SELECT updated_at_ms <= FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint - $2 AS stale
10021
+ FROM sdk_hosted_settlement_report_obligations WHERE id=$1`,
10022
+ [id, limits.staleAfterMs]
10046
10023
  );
10047
- if (!state.rows[0]) throw new Error("Hosted Tempo channel is not durable");
10048
- const identity = await this.lockIdentity(tx);
10049
- const parsed = parseStoreValue(state.rows[0].state_json);
10050
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Hosted Tempo state is invalid");
10051
- const row = parsed;
10052
- if (row.chainId !== Number(config.network.split(":")[1]) || String(row.escrowContract).toLowerCase() !== config.escrow.toLowerCase() || String(row.payee).toLowerCase() !== config.payee.toLowerCase() || String(row.token).toLowerCase() !== config.token.toLowerCase()) {
10053
- throw new Error("Hosted Tempo channel has another chain or recipient");
10054
- }
10055
- if (operation === "close") {
10056
- if (!drainId) throw new Error("Hosted Tempo close requires the original drain");
10057
- const close = await tx.query(
10058
- "SELECT drain_id FROM mpp_tempo_close_intents WHERE channel_id=$1",
10059
- [channelId.toLowerCase()]
10024
+ if (!age.rows[0]?.stale) return { outcome: "in_flight" };
10025
+ const released = Number((await tx.query(
10026
+ `SELECT COUNT(*)::text AS n FROM sdk_hosted_settlement_report_obligations
10027
+ WHERE dvm_id=$1 AND scope=$2 AND rail='tempo' AND operation='close' AND channel_id=$3
10028
+ AND original->>'drainId'=$4 AND state='no_report' AND tx_hash IS NULL`,
10029
+ [this.dvmId, this.scope, channelId.toLowerCase(), drainId]
10030
+ )).rows[0].n);
10031
+ if (released >= limits.maxReleases) {
10032
+ await tx.query(
10033
+ `UPDATE sdk_hosted_settlement_report_obligations SET state='exhausted',
10034
+ updated_at_ms=FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint WHERE id=$1`,
10035
+ [id]
10060
10036
  );
10061
- if (close.rows[0]?.drain_id !== drainId) throw new Error("Hosted Tempo close has no matching drain");
10062
- }
10063
- const highestVoucherAmount = nativeAmount(row.highestVoucherAmount);
10064
- const settledOnChain = nativeAmount(row.settledOnChain);
10065
- const spent = nativeAmount(row.spent);
10066
- if (BigInt(settledOnChain) > BigInt(highestVoucherAmount) || BigInt(spent) > BigInt(highestVoucherAmount)) {
10067
- throw new Error("Hosted Tempo settlement source has inconsistent amounts");
10068
- }
10069
- const original = operation === "settle" ? {
10070
- highestVoucherAmount,
10071
- settledOnChain,
10072
- spent
10073
- } : { drainId, highestVoucherAmount, settledOnChain, spent };
10074
- return this.insert(
10075
- tx,
10076
- identity,
10077
- "tempo",
10078
- operation,
10079
- config.network,
10080
- config.escrow,
10081
- channelId.toLowerCase(),
10082
- original,
10083
- operation === "close" ? [drainId] : [highestVoucherAmount]
10037
+ await limits.onExhausted?.(tx);
10038
+ return { outcome: "exhausted", releases: released };
10039
+ }
10040
+ await tx.query(
10041
+ `UPDATE sdk_hosted_settlement_report_obligations SET state='no_report',
10042
+ updated_at_ms=FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint WHERE id=$1`,
10043
+ [id]
10044
+ );
10045
+ const next = await this.reserveTempoIn(tx, channelId, "close", drainId);
10046
+ return { outcome: "reserved", id: next.id, releases: released + 1 };
10047
+ });
10048
+ }
10049
+ /**
10050
+ * Resolve an unsubmitted close reservation whose channel the payer exited
10051
+ * on its own: no cooperative close of ours can land on a finalized channel.
10052
+ */
10053
+ async resolveTempoCloseExit(id) {
10054
+ const result = await this.pool.query(
10055
+ `UPDATE sdk_hosted_settlement_report_obligations SET state='no_report',
10056
+ updated_at_ms=FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint
10057
+ WHERE id=$1 AND dvm_id=$2 AND scope=$3 AND rail='tempo' AND operation='close'
10058
+ AND state IN ('reserved','exhausted') AND tx_hash IS NULL`,
10059
+ [id, this.dvmId, this.scope]
10060
+ );
10061
+ return result.rowCount === 1;
10062
+ }
10063
+ async tempoCloseAmountAtStake(id) {
10064
+ const row = await this.lockRow(this.pool, id);
10065
+ if (row.rail !== "tempo" || row.operation !== "close") throw new Error("Not a Tempo close obligation");
10066
+ return (BigInt(String(row.original.highestVoucherAmount)) - BigInt(String(row.original.settledOnChain))).toString();
10067
+ }
10068
+ /** Stop automatic searches without declaring a possibly missing payout discharged. */
10069
+ async markTempoCloseUnmatched(id) {
10070
+ const result = await this.pool.query(
10071
+ `UPDATE sdk_hosted_settlement_report_obligations SET state='unmatched',
10072
+ updated_at_ms=FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint
10073
+ WHERE id=$1 AND dvm_id=$2 AND scope=$3 AND rail='tempo' AND operation='close'
10074
+ AND state IN ('reserved','exhausted') AND tx_hash IS NULL`,
10075
+ [id, this.dvmId, this.scope]
10076
+ );
10077
+ return result.rowCount === 1;
10078
+ }
10079
+ async writeOffTempoClose(id, note) {
10080
+ const result = await this.pool.query(
10081
+ `UPDATE sdk_hosted_settlement_report_obligations SET state='no_report', close_write_off_note=$4,
10082
+ updated_at_ms=FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint
10083
+ WHERE id=$1 AND dvm_id=$2 AND scope=$3 AND rail='tempo' AND operation='close'
10084
+ AND state='unmatched' AND tx_hash IS NULL RETURNING channel_id,original`,
10085
+ [id, this.dvmId, this.scope, note]
10086
+ );
10087
+ const row = result.rows[0];
10088
+ return row && { channelId: row.channel_id, amount: (BigInt(String(row.original.highestVoucherAmount)) - BigInt(String(row.original.settledOnChain))).toString() };
10089
+ }
10090
+ async reopenTempoClose(id) {
10091
+ return transaction(this.pool, async (tx) => {
10092
+ const owned = await tx.query(
10093
+ "SELECT id FROM sdk_hosted_settlement_report_obligations WHERE id=$1 AND dvm_id=$2 AND scope=$3",
10094
+ [id, this.dvmId, this.scope]
10084
10095
  );
10096
+ if (!owned.rowCount) return false;
10097
+ await this.lockIdentity(tx);
10098
+ const result = await tx.query(
10099
+ `UPDATE sdk_hosted_settlement_report_obligations target
10100
+ SET state='reserved', close_search_before=NULL,
10101
+ updated_at_ms=FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint
10102
+ WHERE id=$1 AND dvm_id=$2 AND scope=$3 AND rail='tempo' AND operation='close'
10103
+ AND state IN ('unmatched','no_report') AND tx_hash IS NULL
10104
+ AND NOT EXISTS (
10105
+ SELECT 1 FROM sdk_hosted_settlement_report_obligations other
10106
+ WHERE other.dvm_id=target.dvm_id AND other.scope=target.scope
10107
+ AND other.rail='tempo' AND other.operation='close' AND other.channel_id=target.channel_id
10108
+ AND other.id<>target.id AND (other.state<>'no_report' OR other.tx_hash IS NOT NULL)
10109
+ )`,
10110
+ [id, this.dvmId, this.scope]
10111
+ );
10112
+ return result.rowCount === 1;
10085
10113
  });
10086
10114
  }
10115
+ async reserveTempoIn(tx, channelId, operation, drainId) {
10116
+ const config = this.tempo;
10117
+ if (!config || !hex(channelId, 32)) throw new Error("Hosted Tempo settlement configuration is unavailable");
10118
+ const state = await tx.query(
10119
+ "SELECT state_json FROM mpp_tempo_channels WHERE channel_key=$1",
10120
+ [channelId.toLowerCase()]
10121
+ );
10122
+ if (!state.rows[0]) throw new Error("Hosted Tempo channel is not durable");
10123
+ const identity = await this.lockIdentity(tx);
10124
+ const parsed = parseStoreValue(state.rows[0].state_json);
10125
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Hosted Tempo state is invalid");
10126
+ const row = parsed;
10127
+ if (row.chainId !== Number(config.network.split(":")[1]) || String(row.escrowContract).toLowerCase() !== config.escrow.toLowerCase() || String(row.payee).toLowerCase() !== config.payee.toLowerCase() || String(row.token).toLowerCase() !== config.token.toLowerCase()) {
10128
+ throw new Error("Hosted Tempo channel has another chain or recipient");
10129
+ }
10130
+ if (operation === "close") {
10131
+ if (!drainId) throw new Error("Hosted Tempo close requires the original drain");
10132
+ const close = await tx.query(
10133
+ "SELECT drain_id FROM mpp_tempo_close_intents WHERE channel_id=$1",
10134
+ [channelId.toLowerCase()]
10135
+ );
10136
+ if (close.rows[0]?.drain_id !== drainId) throw new Error("Hosted Tempo close has no matching drain");
10137
+ }
10138
+ const highestVoucherAmount = nativeAmount(row.highestVoucherAmount);
10139
+ const settledOnChain = nativeAmount(row.settledOnChain);
10140
+ const spent = nativeAmount(row.spent);
10141
+ if (BigInt(settledOnChain) > BigInt(highestVoucherAmount) || BigInt(spent) > BigInt(highestVoucherAmount)) {
10142
+ throw new Error("Hosted Tempo settlement source has inconsistent amounts");
10143
+ }
10144
+ const original = operation === "settle" ? {
10145
+ highestVoucherAmount,
10146
+ settledOnChain,
10147
+ spent
10148
+ } : { drainId, highestVoucherAmount, settledOnChain, spent };
10149
+ return this.insert(
10150
+ tx,
10151
+ identity,
10152
+ "tempo",
10153
+ operation,
10154
+ config.network,
10155
+ config.escrow,
10156
+ channelId.toLowerCase(),
10157
+ original,
10158
+ operation === "close" ? [drainId] : [highestVoucherAmount]
10159
+ );
10160
+ }
10087
10161
  async findPendingTempo(channelId, operation) {
10088
10162
  const result = await this.pool.query(
10089
10163
  `SELECT * FROM sdk_hosted_settlement_report_obligations
@@ -10096,14 +10170,33 @@ var HostedSettlementReportObligations = class {
10096
10170
  /** The original close obligation reserved for this drain, while its report is outstanding. */
10097
10171
  async pendingTempoClose(channelId, drainId) {
10098
10172
  const result = await this.pool.query(
10099
- `SELECT id,tx_hash,created_at_ms FROM sdk_hosted_settlement_report_obligations
10173
+ `SELECT id,state,tx_hash,created_at_ms,close_search_before FROM sdk_hosted_settlement_report_obligations
10100
10174
  WHERE dvm_id=$1 AND scope=$2 AND rail='tempo' AND operation='close' AND channel_id=$3
10101
10175
  AND original->>'drainId'=$4 AND state NOT IN ('delivered','no_report')
10102
10176
  ORDER BY created_at_ms DESC LIMIT 1`,
10103
10177
  [this.dvmId, this.scope, channelId.toLowerCase(), drainId]
10104
10178
  );
10105
10179
  const row = result.rows[0];
10106
- return row && { id: row.id, txHash: row.tx_hash, reservedAtMs: Number(row.created_at_ms) };
10180
+ return row && {
10181
+ id: row.id,
10182
+ state: row.state,
10183
+ txHash: row.tx_hash,
10184
+ reservedAtMs: Number(row.created_at_ms),
10185
+ searchBefore: searchCursor(row.close_search_before)
10186
+ };
10187
+ }
10188
+ /**
10189
+ * Where the next search for this close obligation's transaction resumes. It
10190
+ * lives on the obligation, not the drain's close intent, because releasing
10191
+ * the drain deletes the intent while the obligation still needs the search.
10192
+ */
10193
+ async recordTempoCloseSearch(id, resumeAt) {
10194
+ await this.pool.query(
10195
+ `UPDATE sdk_hosted_settlement_report_obligations
10196
+ SET close_search_before = LEAST(COALESCE(close_search_before, $4), $4)
10197
+ WHERE id=$1 AND dvm_id=$2 AND scope=$3 AND rail='tempo' AND operation='close'`,
10198
+ [id, this.dvmId, this.scope, resumeAt.toString()]
10199
+ );
10107
10200
  }
10108
10201
  async submittedIds(rail, operation, txHash, batchId) {
10109
10202
  if (!hex(txHash, 32)) throw new Error("Hosted effect has no exact transaction hash");
@@ -10211,17 +10304,68 @@ var HostedSettlementReportObligations = class {
10211
10304
  if (!hex(txHash, 32)) throw new Error("Hosted settlement has no transaction hash");
10212
10305
  const write = this.x402SettlementStore ? this.x402Transaction.bind(this) : (operation) => transaction(this.pool, operation);
10213
10306
  await write(async (tx) => {
10214
- const row = await this.lockRow(tx, id);
10307
+ let row = await this.lockRow(tx, id);
10308
+ if (row.state === "no_report" && !row.tx_hash) {
10309
+ row = await this.successorOfRetiredClose(tx, row);
10310
+ }
10215
10311
  if (row.tx_hash && row.tx_hash.toLowerCase() !== txHash.toLowerCase()) {
10216
10312
  throw new Error("Hosted settlement transaction replay conflicts");
10217
10313
  }
10218
10314
  await tx.query(
10219
10315
  `UPDATE sdk_hosted_settlement_report_obligations SET tx_hash=COALESCE(tx_hash,$2),
10316
+ state=CASE WHEN state IN ('exhausted','unmatched') THEN 'reserved' ELSE state END,
10220
10317
  updated_at_ms=FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint WHERE id=$1`,
10221
- [id, txHash.toLowerCase()]
10318
+ [row.id, txHash.toLowerCase()]
10222
10319
  );
10223
10320
  });
10224
10321
  }
10322
+ /**
10323
+ * Close reservations that never recorded a transaction, oldest first. Each
10324
+ * one owes a payout report if its close lands. Exhausted attempts remain
10325
+ * observable for late landings; unmatched searches require an explicit retry.
10326
+ */
10327
+ async unsubmittedTempoCloses(limit = 100) {
10328
+ const result = await this.pool.query(
10329
+ `SELECT id,channel_id,original->>'drainId' AS drain_id,created_at_ms,close_search_before
10330
+ FROM sdk_hosted_settlement_report_obligations
10331
+ WHERE dvm_id=$1 AND scope=$2 AND rail='tempo' AND operation='close'
10332
+ AND state IN ('reserved','exhausted') AND tx_hash IS NULL
10333
+ ORDER BY created_at_ms,id LIMIT $3`,
10334
+ [this.dvmId, this.scope, limit]
10335
+ );
10336
+ return result.rows.flatMap((row) => row.drain_id === null ? [] : [{
10337
+ id: row.id,
10338
+ channelId: row.channel_id,
10339
+ drainId: row.drain_id,
10340
+ reservedAtMs: Number(row.created_at_ms),
10341
+ searchBefore: searchCursor(row.close_search_before)
10342
+ }]);
10343
+ }
10344
+ /**
10345
+ * A retired close reservation's attempt can still be running when its
10346
+ * successor is reserved, and its close can land. The close is the same
10347
+ * channel's, so it belongs to the drain's outstanding obligation; writing it
10348
+ * onto the retired row would leave that obligation without a transaction and
10349
+ * the payout unreported.
10350
+ */
10351
+ async successorOfRetiredClose(tx, retired) {
10352
+ const drainId = retired.original.drainId;
10353
+ if (retired.rail !== "tempo" || retired.operation !== "close" || typeof drainId !== "string") {
10354
+ throw new Error("Hosted settlement obligation was resolved without a transaction");
10355
+ }
10356
+ const successor = await tx.query(
10357
+ `SELECT id FROM sdk_hosted_settlement_report_obligations
10358
+ WHERE dvm_id=$1 AND scope=$2 AND rail='tempo' AND operation='close' AND channel_id=$3
10359
+ AND original->>'drainId'=$4 AND state NOT IN ('delivered','no_report')
10360
+ ORDER BY created_at_ms DESC LIMIT 1`,
10361
+ [this.dvmId, this.scope, retired.channel_id, drainId]
10362
+ );
10363
+ if (!successor.rows[0]) {
10364
+ throw new Error("Hosted Tempo close was retired and the drain has no outstanding obligation");
10365
+ }
10366
+ const locked = await this.lockRow(tx, successor.rows[0].id);
10367
+ return locked.state === "no_report" && !locked.tx_hash ? this.successorOfRetiredClose(tx, locked) : locked;
10368
+ }
10225
10369
  async recoveryCandidates() {
10226
10370
  const result = await this.pool.query(
10227
10371
  `SELECT * FROM sdk_hosted_settlement_report_obligations
@@ -10683,6 +10827,191 @@ var HostedSettlementReportObligations = class {
10683
10827
  return { id: row.id, replayed: true };
10684
10828
  }
10685
10829
  };
10830
+ async function retryHostedTempoCloseReport(args) {
10831
+ const obligations = new HostedSettlementReportObligations(args.db, args.dvmId, args.scope);
10832
+ return obligations.reopenTempoClose(args.obligationId);
10833
+ }
10834
+ async function writeOffHostedTempoCloseReport(args) {
10835
+ const note = args.note.trim();
10836
+ if (!note || note.length > 2e3) throw new Error("A write-off requires a note of 1 to 2000 characters");
10837
+ const row = await new HostedSettlementReportObligations(args.db, args.dvmId, args.scope).writeOffTempoClose(args.obligationId, note);
10838
+ if (!row) return false;
10839
+ loggers.tempo.warn("tempo_session_close_report_written_off", {
10840
+ channel_id: row.channelId,
10841
+ obligation_id: args.obligationId,
10842
+ amount_forgone_native: row.amount
10843
+ });
10844
+ return true;
10845
+ }
10846
+ function searchCursor(value) {
10847
+ return value === null ? null : BigInt(value);
10848
+ }
10849
+
10850
+ // src/sdk/server/hosting-control.ts
10851
+ import { createHmac, randomUUID as randomUUID4, timingSafeEqual } from "crypto";
10852
+
10853
+ // src/sdk/server/hosting-cleanup-fence.ts
10854
+ var HostingCleanupFence = class {
10855
+ constructor(pool, dvmId, scope) {
10856
+ this.pool = pool;
10857
+ this.dvmId = dvmId;
10858
+ this.scope = scope;
10859
+ }
10860
+ pool;
10861
+ dvmId;
10862
+ scope;
10863
+ async assertNewIntent(tx, intent) {
10864
+ const continuation = intent?.admissionId || intent?.paymentSeq !== void 0 || intent?.invoicePaymentHash || intent?.invoiceRepairPaymentHash || intent?.creditId && intent.fundId && intent.callerPubkey;
10865
+ const { rows: rows2 } = await tx.query(
10866
+ `SELECT state, closed_at_ms, closed_at_us FROM sdk_hosting_control
10867
+ WHERE dvm_id=$1 AND scope=$2 ${continuation ? "" : "FOR UPDATE"}`,
10868
+ [this.dvmId, this.scope]
10869
+ );
10870
+ const control = rows2[0];
10871
+ if (!control) throw new Error("Hosted obligation fence is unavailable");
10872
+ if (control.state === "closed" && (control.closed_at_ms === null || control.closed_at_us === null)) {
10873
+ throw new Error("Hosted closure cutoff is unavailable");
10874
+ }
10875
+ if (control.state !== "closed" && !continuation) return;
10876
+ if (intent?.invoicePaymentHash || intent?.invoiceRepairPaymentHash) {
10877
+ const repair = !!intent.invoiceRepairPaymentHash;
10878
+ const invoice = await tx.query(
10879
+ `SELECT i.payment_hash FROM credit_invoices i
10880
+ LEFT JOIN sdk_hosting_admissions a ON a.id=i.hosting_admission_id
10881
+ WHERE i.payment_hash=$1 AND i.caller_pubkey=$4 AND i.amount_micro=$5
10882
+ AND (($10::boolean AND $3=i.payment_hash AND i.status IN ('blocked','written_off'))
10883
+ OR (NOT $10::boolean AND i.credit_id=$2 AND i.fund_id=$3 AND i.status='pending'))
10884
+ AND ((i.hosting_admission_id IS NULL AND
10885
+ ($9::bigint IS NULL OR i.created_at_db_us <= $9)) OR
10886
+ (a.dvm_id=$6 AND a.scope=$7 AND a.credit_id=i.credit_id
10887
+ AND a.fund_id=i.fund_id AND a.caller_pubkey=i.caller_pubkey
10888
+ AND a.outcome IN ('pending','accepted','unresolved')
10889
+ AND ($8::bigint IS NULL OR a.admitted_at_ms <= $8)))`,
10890
+ [
10891
+ intent.invoicePaymentHash ?? intent.invoiceRepairPaymentHash,
10892
+ intent.creditId,
10893
+ intent.fundId,
10894
+ intent.callerPubkey,
10895
+ intent.amountMicro,
10896
+ this.dvmId,
10897
+ this.scope,
10898
+ control.closed_at_ms,
10899
+ control.closed_at_us,
10900
+ repair
10901
+ ]
10902
+ );
10903
+ if (invoice.rows[0]) return;
10904
+ throw new Error("Hosted invoice has no original payable admission");
10905
+ }
10906
+ if (intent?.paymentSeq !== void 0 && intent.jobId) {
10907
+ const payment = await tx.query(
10908
+ `SELECT m.job_id FROM job_messages m JOIN jobs j ON j.id=m.job_id
10909
+ WHERE m.job_id=$1 AND m.seq=$2 AND m.status='pending-verification'
10910
+ AND m.created_at_ms IS NOT NULL
10911
+ AND ($3::bigint IS NULL OR m.created_at_ms <= $3) AND j.dvm_id=$4
10912
+ AND ($6::text IS NULL OR m.data->'content'->'tempo_credential'->'challenge'->>'id'=$6)
10913
+ AND EXISTS (SELECT 1 FROM sdk_hosting_admissions a
10914
+ WHERE a.dvm_id=$4 AND a.scope=$5 AND a.job_id=m.job_id AND a.outcome <> 'failed')`,
10915
+ [
10916
+ intent.jobId,
10917
+ intent.paymentSeq,
10918
+ control.closed_at_ms,
10919
+ this.dvmId,
10920
+ this.scope,
10921
+ intent.challengeId ?? null
10922
+ ]
10923
+ );
10924
+ if (payment.rows[0]) return;
10925
+ throw new Error("Hosted payment has no pre-closure inbound intent");
10926
+ }
10927
+ if (!intent?.admissionId && !intent?.jobId && !(intent?.creditId && intent.fundId && intent.callerPubkey) || !intent?.jobId && !(intent?.creditId && intent.fundId)) {
10928
+ throw new Error("Hosted obligations are closed to new intents");
10929
+ }
10930
+ const accepted = await tx.query(
10931
+ `SELECT id FROM sdk_hosting_admissions
10932
+ WHERE ($1::text IS NULL OR id=$1) AND dvm_id=$2 AND scope=$3
10933
+ AND ($4::bigint IS NULL OR admitted_at_ms <= $4)
10934
+ AND (outcome IN ('pending', 'unresolved') OR ($11::boolean AND outcome='accepted'))
10935
+ AND ($5::text IS NULL OR job_id=$5)
10936
+ AND ($6::text IS NULL OR credit_id=$6)
10937
+ AND ($7::text IS NULL OR fund_id=$7)
10938
+ AND ($8::text IS NULL OR caller_pubkey=$8)
10939
+ AND ($9::text IS NULL OR funding_commitment=$9)
10940
+ AND ($10::text IS NULL OR payment_artifact_hash=$10)`,
10941
+ [
10942
+ intent.admissionId ?? null,
10943
+ this.dvmId,
10944
+ this.scope,
10945
+ control.closed_at_ms,
10946
+ intent.jobId ?? null,
10947
+ intent.creditId ?? null,
10948
+ intent.fundId ?? null,
10949
+ intent.callerPubkey ?? null,
10950
+ intent.fundingCommitment ?? null,
10951
+ intent.paymentArtifactHash ?? null,
10952
+ control.state !== "closed"
10953
+ ]
10954
+ );
10955
+ if (!accepted.rows[0]) throw new Error("Hosted obligation has no pre-closure admission");
10956
+ }
10957
+ async withNewIntent(intent, operation) {
10958
+ return this.withNewIntentOn(this.pool, intent, operation);
10959
+ }
10960
+ async assertFundingReservation(tx, reservation) {
10961
+ await this.assertNewIntent(tx, {
10962
+ admissionId: reservation.admissionId,
10963
+ creditId: reservation.creditId,
10964
+ fundId: reservation.fundId,
10965
+ callerPubkey: reservation.callerPubkey,
10966
+ fundingCommitment: reservation.commitment,
10967
+ paymentArtifactHash: reservation.paymentArtifactHash
10968
+ });
10969
+ const verified = await tx.query(
10970
+ `SELECT id FROM sdk_hosting_admissions
10971
+ WHERE id=$1 AND dvm_id=$2 AND scope=$3 AND credit_id=$4 AND fund_id=$5
10972
+ AND caller_pubkey=$6 AND funding_commitment=$7 AND payment_artifact_hash=$8
10973
+ AND outcome IN ('pending','accepted','unresolved')`,
10974
+ [
10975
+ reservation.admissionId,
10976
+ this.dvmId,
10977
+ this.scope,
10978
+ reservation.creditId,
10979
+ reservation.fundId,
10980
+ reservation.callerPubkey,
10981
+ reservation.commitment,
10982
+ reservation.paymentArtifactHash
10983
+ ]
10984
+ );
10985
+ if (!verified.rows[0]) throw new Error("Hosted funding has no authentic admission");
10986
+ }
10987
+ async fundingAdmission(tx, creditId, fundId, callerPubkey) {
10988
+ await this.assertNewIntent(tx, { creditId, fundId, callerPubkey });
10989
+ const { rows: rows2 } = await tx.query(
10990
+ `SELECT id FROM sdk_hosting_admissions
10991
+ WHERE dvm_id=$1 AND scope=$2 AND credit_id=$3 AND fund_id=$4
10992
+ AND caller_pubkey=$5 AND outcome IN ('pending','unresolved')
10993
+ ORDER BY admitted_at_ms,id LIMIT 2`,
10994
+ [this.dvmId, this.scope, creditId, fundId, callerPubkey]
10995
+ );
10996
+ if (rows2.length !== 1) throw new Error("Hosted invoice funding admission is missing or ambiguous");
10997
+ return rows2[0].id;
10998
+ }
10999
+ async withNewIntentOn(db, intent, operation) {
11000
+ const tx = await db.connect();
11001
+ try {
11002
+ await tx.query("BEGIN");
11003
+ await this.assertNewIntent(tx, intent);
11004
+ const result = await operation(tx);
11005
+ await tx.query("COMMIT");
11006
+ return result;
11007
+ } catch (error) {
11008
+ await tx.query("ROLLBACK").catch(() => void 0);
11009
+ throw error;
11010
+ } finally {
11011
+ tx.release();
11012
+ }
11013
+ }
11014
+ };
10686
11015
 
10687
11016
  // src/sdk/server/hosting-channel-evidence.ts
10688
11017
  import { createHash as createHash7 } from "crypto";
@@ -18168,7 +18497,7 @@ async function requireWedgedTempoDrain(c, opts, args, admitBooked) {
18168
18497
  )
18169
18498
  };
18170
18499
  }
18171
- if (record.status !== "pending" && !(admitBooked && record.status === "sent")) {
18500
+ if (record.status !== "pending" && !(admitBooked && (record.status === "sent" || record.status === "exit_required"))) {
18172
18501
  return { response: tempoNotWedgedRefusal(c, args, record.status) };
18173
18502
  }
18174
18503
  return { record, channelId: record.channelId };
@@ -19584,7 +19913,7 @@ async function createDVMServer(descriptor, opts) {
19584
19913
  }
19585
19914
  }
19586
19915
  if (!opts.onJobCompleted && platformToken && platformUrl && dvmId && opts.db) {
19587
- const { RevenueReporter } = await import("./revenue-reporter-X52GPZ5U.js");
19916
+ const { RevenueReporter } = await import("./revenue-reporter-4EAVPWXS.js");
19588
19917
  const reporter = new RevenueReporter(
19589
19918
  opts.db,
19590
19919
  platformUrl,
@@ -19594,7 +19923,7 @@ async function createDVMServer(descriptor, opts) {
19594
19923
  revenueReporter = reporter;
19595
19924
  await reporter.init();
19596
19925
  reporter.startRetryLoop();
19597
- const { MemoryX402BatchStore, PayoutReporter } = await import("./payout-reporter-HQSIIBFL.js");
19926
+ const { MemoryX402BatchStore, PayoutReporter } = await import("./payout-reporter-TZX2S7KG.js");
19598
19927
  payoutReporter = new PayoutReporter({
19599
19928
  dvmId,
19600
19929
  db: opts.db,
@@ -19895,7 +20224,43 @@ var DVMServer = class {
19895
20224
  dvmId: this.receiptDvmId()
19896
20225
  }) : void 0;
19897
20226
  this.creditLedger = opts.creditLedger ?? new MemoryCreditLedger();
19898
- this.opts.mpp?.useTempoCreditLedger?.(this.creditLedger);
20227
+ this.opts.mpp?.useTempoCreditLedger?.(this.creditLedger, async (drain, evidence) => {
20228
+ const completed = await completeTempoChannelDrain({
20229
+ deps: { ledger: this.creditLedger, drainReport: {
20230
+ db: this.opts.db,
20231
+ dvmId: this.opts.dvmId,
20232
+ enqueueCreditDrain: this.opts.enqueueCreditDrain
20233
+ } },
20234
+ creditId: drain.creditId,
20235
+ drainId: drain.drainId,
20236
+ channelId: drain.channelId,
20237
+ refundedNative: evidence.refundedNative,
20238
+ debitedNative: drain.drainedNative,
20239
+ reference: evidence.reference,
20240
+ finalization: {
20241
+ outcome: evidence.creditUnbacked ? `${evidence.outcome}_unbacked` : evidence.outcome,
20242
+ creditUnbacked: evidence.creditUnbacked
20243
+ }
20244
+ });
20245
+ if (!completed.replayed && this.receiptIssuer) {
20246
+ const receipt = this.receiptIssuer.issueDrain({
20247
+ creditId: drain.creditId,
20248
+ drainId: drain.drainId,
20249
+ event: "sent",
20250
+ method: drain.method,
20251
+ amountMicro: drain.amountMicro,
20252
+ balanceAfterMicro: drain.balanceAfterMicro,
20253
+ ledgerSeq: drain.ledgerSeq,
20254
+ callerPubkey: drain.callerPubkey
20255
+ });
20256
+ await this.creditLedger.appendDrainReceipt({
20257
+ creditId: drain.creditId,
20258
+ drainId: drain.drainId,
20259
+ receipt
20260
+ });
20261
+ }
20262
+ return !completed.replayed;
20263
+ });
19899
20264
  this.processedPayments = opts.processedPayments ?? new MemoryProcessedPaymentStore();
19900
20265
  this.jobManager = new JobManager(descriptor, {
19901
20266
  receiptIssuer: this.receiptIssuer,
@@ -20051,6 +20416,9 @@ var DVMServer = class {
20051
20416
  if (!certificateOnly && await control.close(command, support) === "conflict") {
20052
20417
  return c.json({ error: "hosting_closure_conflict" }, 409);
20053
20418
  }
20419
+ await this.opts.mpp?.reconcileTempoSessionCloses?.().catch(() => {
20420
+ diagnosticConsole.warn({ level: "tempo_routine_sweep_failed", dvm_id: this.opts.dvmId, error: void 0 });
20421
+ });
20054
20422
  const evidence = await control.certificate({ ...command, action: "certificate" }, support);
20055
20423
  if (!evidence) return c.json({ error: "hosting_closure_stale" }, 409);
20056
20424
  return c.json(evidence);
@@ -23095,6 +23463,8 @@ export {
23095
23463
  X402ExactSettlementServer,
23096
23464
  X402ExactSettlementEvidenceMissingError,
23097
23465
  HostedSettlementReportObligations,
23466
+ retryHostedTempoCloseReport,
23467
+ writeOffHostedTempoCloseReport,
23098
23468
  signHostingControl,
23099
23469
  signHostingClosure,
23100
23470
  jobCredentialGate,