@dvmkit/sdk 0.7.0-rc.9 → 0.8.1

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 (57) hide show
  1. package/NOTICE +1 -1
  2. package/README.md +57 -261
  3. package/dist/{chunk-5CSR33B5.js → chunk-2U6LAASN.js} +1 -1
  4. package/dist/chunk-66HGCPBU.js +25 -0
  5. package/dist/{chunk-QM2OS3NJ.js → chunk-7PM4R4GD.js} +1 -1
  6. package/dist/{chunk-YE53E4BT.js → chunk-AGE5XZKE.js} +1 -1
  7. package/dist/{chunk-RJFBBEWQ.js → chunk-ALWVJ2RB.js} +1 -1
  8. package/dist/{chunk-FYETJDBB.js → chunk-AVV5SYGO.js} +2 -2
  9. package/dist/{chunk-5PLQOC4B.js → chunk-BFGQG4WY.js} +9 -1
  10. package/dist/{chunk-IOKEKMIU.js → chunk-FM6QAGJC.js} +2 -2
  11. package/dist/{chunk-KXWROQGK.js → chunk-FUJ36YDV.js} +1 -24
  12. package/dist/{chunk-6JZDFYQL.js → chunk-G3MPHW5I.js} +1 -1
  13. package/dist/{chunk-2HAFZ5GC.js → chunk-IHQUBATH.js} +2 -2
  14. package/dist/{chunk-YS6PWVCZ.js → chunk-ILBSZMJL.js} +1 -1
  15. package/dist/{chunk-RGSWVNQG.js → chunk-IOQIXRGM.js} +2 -43
  16. package/dist/{chunk-YNHXOONR.js → chunk-J5J63WDC.js} +1 -1
  17. package/dist/{chunk-G7IXOVKV.js → chunk-JIZ4ZIXX.js} +331 -102
  18. package/dist/{chunk-ZLWPEIE2.js → chunk-NA2KOR6G.js} +1 -1
  19. package/dist/{chunk-2DE32RAW.js → chunk-NU5RWJ5B.js} +1 -1
  20. package/dist/chunk-OP7VCGQR.js +47 -0
  21. package/dist/{chunk-QN3CQOE2.js → chunk-QKE5NDPF.js} +368 -7
  22. package/dist/{chunk-7RZEJJTN.js → chunk-RSNC5T6C.js} +2 -2
  23. package/dist/{chunk-2GTLF74N.js → chunk-UDJNZ3UG.js} +1 -1
  24. package/dist/{chunk-WGK5KZ6S.js → chunk-UVS7MRJX.js} +3 -3
  25. package/dist/{chunk-HU6ZMQDN.js → chunk-UWGBI5WK.js} +58 -1
  26. package/dist/{chunk-OFSOLHRX.js → chunk-WMGXJQI3.js} +1 -1
  27. package/dist/{chunk-5TIFEMHJ.js → chunk-ZEPJF4JC.js} +2 -2
  28. package/dist/{content-retention-U7dJM7Ph.d.ts → content-retention-B7ddtJ2y.d.ts} +1 -1
  29. package/dist/{credit-ledger-F2LWQU6N.js → credit-ledger-KIQIE6I6.js} +3 -3
  30. package/dist/{credit-menu-CUJdCfpt.d.ts → credit-menu-BH3g9GQa.d.ts} +5 -5
  31. package/dist/{fx-BvG2Wnjk.d.ts → fx-DN26_J7o.d.ts} +1 -1
  32. package/dist/index.d.ts +5 -5
  33. package/dist/index.js +4 -4
  34. package/dist/internal/caller.d.ts +10 -8
  35. package/dist/internal/caller.js +12 -9
  36. package/dist/internal/server.d.ts +7 -7
  37. package/dist/internal/server.js +22 -20
  38. package/dist/{job-store-DjgN2AOk.d.ts → job-store-Cq21JT37.d.ts} +1 -1
  39. package/dist/{lightning-backend-DeasHNOP.d.ts → lightning-backend-S8F8nf8q.d.ts} +1 -1
  40. package/dist/{memory-credit-ledger-XOOWJTBZ.js → memory-credit-ledger-ATYFAYBG.js} +4 -4
  41. package/dist/{mpp-setup-KGX6PLAF.js → mpp-setup-ZTORW3VF.js} +4 -2
  42. package/dist/{payout-reporter-W3CCLYCN.js → payout-reporter-NN6K2IVQ.js} +2 -2
  43. package/dist/{postgres-consumed-credential-store-LUJ5CUJS.js → postgres-consumed-credential-store-XJTE5PCA.js} +1 -1
  44. package/dist/{postgres-job-store-IX7IUXF4.js → postgres-job-store-AR6ZJUQV.js} +2 -2
  45. package/dist/{postgres-replay-store-Q6MEQDWZ.js → postgres-replay-store-33K33QV7.js} +2 -2
  46. package/dist/{pricing-ZUEFO3CM.js → pricing-G5IN5YJE.js} +3 -3
  47. package/dist/{revenue-reporter-NRS2FANA.js → revenue-reporter-KDUZLD5F.js} +2 -2
  48. package/dist/server/index.d.ts +10 -10
  49. package/dist/server/index.js +30 -28
  50. package/dist/{step-cache-C9JRSFhM.d.ts → step-cache-CR12EZ1Q.d.ts} +136 -4
  51. package/dist/{tempo-charge-store-NZJTJCUO.js → tempo-charge-store-XEGJYM3G.js} +2 -2
  52. package/dist/{tempo-session-store-ENFDLDKC.js → tempo-session-store-3WTDOO7H.js} +3 -3
  53. package/dist/testing/index.d.ts +3 -3
  54. package/dist/testing/index.js +8 -6
  55. package/dist/{usd-DXDkHn_8.d.ts → usd-s_VZ_eS7.d.ts} +1 -1
  56. package/dist/{x402-T7WVKDD4.js → x402-HQ5GRQZU.js} +6 -5
  57. package/package.json +18 -3
@@ -7,10 +7,10 @@ import {
7
7
  installHostedTempoCloseGuard,
8
8
  isTempoInsufficientFundsError,
9
9
  resolveTempoNetwork
10
- } from "./chunk-QN3CQOE2.js";
10
+ } from "./chunk-QKE5NDPF.js";
11
11
  import {
12
12
  x402EarnedCeiling
13
- } from "./chunk-YNHXOONR.js";
13
+ } from "./chunk-J5J63WDC.js";
14
14
  import {
15
15
  createX402FacilitatorAuthHeaders,
16
16
  createX402FacilitatorClient,
@@ -19,13 +19,13 @@ import {
19
19
  verifyWithFacilitator,
20
20
  withPaymentDiagnostics,
21
21
  withPaymentStage
22
- } from "./chunk-FYETJDBB.js";
22
+ } from "./chunk-AVV5SYGO.js";
23
23
  import {
24
24
  MemoryCreditLedger
25
- } from "./chunk-YE53E4BT.js";
25
+ } from "./chunk-AGE5XZKE.js";
26
26
  import {
27
27
  PostgresJobStore
28
- } from "./chunk-6JZDFYQL.js";
28
+ } from "./chunk-G3MPHW5I.js";
29
29
  import {
30
30
  parseStoreValue
31
31
  } from "./chunk-YG7G4DPZ.js";
@@ -36,7 +36,7 @@ import {
36
36
  humanizeZodIssues,
37
37
  signedRequestInput,
38
38
  zodIssuesOf
39
- } from "./chunk-YS6PWVCZ.js";
39
+ } from "./chunk-ILBSZMJL.js";
40
40
  import {
41
41
  MemoryJobStore,
42
42
  UNCAPPABLE_ASK_TOTAL,
@@ -45,7 +45,7 @@ import {
45
45
  isJobRetentionStore,
46
46
  isReceiptIssuingStore,
47
47
  isStreamableJobStore
48
- } from "./chunk-2HAFZ5GC.js";
48
+ } from "./chunk-IHQUBATH.js";
49
49
  import {
50
50
  ContentHoldError,
51
51
  JobCancelledError,
@@ -85,7 +85,7 @@ import {
85
85
  signReceipt,
86
86
  toLockPubkey,
87
87
  verifyChallenge
88
- } from "./chunk-5TIFEMHJ.js";
88
+ } from "./chunk-ZEPJF4JC.js";
89
89
  import {
90
90
  X402_BATCH_SETTLEMENT_SCHEME,
91
91
  X402_DEFAULT_FACILITATOR,
@@ -112,12 +112,14 @@ import {
112
112
  isZodSchema
113
113
  } from "./chunk-UKD3R2KU.js";
114
114
  import {
115
- createConsoleLogger,
115
+ createConsoleLogger
116
+ } from "./chunk-OP7VCGQR.js";
117
+ import {
116
118
  loggers,
117
119
  pinDimension,
118
120
  withSpan,
119
121
  withTraceContext
120
- } from "./chunk-RGSWVNQG.js";
122
+ } from "./chunk-IOQIXRGM.js";
121
123
  import {
122
124
  StepCache
123
125
  } from "./chunk-BQJIFRAZ.js";
@@ -134,7 +136,7 @@ import {
134
136
  roundUsd,
135
137
  satsToFiat,
136
138
  validateCurrency
137
- } from "./chunk-5CSR33B5.js";
139
+ } from "./chunk-2U6LAASN.js";
138
140
  import {
139
141
  CreditLedger,
140
142
  CreditLedgerError,
@@ -150,10 +152,10 @@ import {
150
152
  isNonChannelBitcoinRail,
151
153
  netOwedSats,
152
154
  x402SettlementPending
153
- } from "./chunk-IOKEKMIU.js";
155
+ } from "./chunk-FM6QAGJC.js";
154
156
  import {
155
157
  withPostgresRecoveryContext
156
- } from "./chunk-QM2OS3NJ.js";
158
+ } from "./chunk-7PM4R4GD.js";
157
159
  import {
158
160
  DvmError,
159
161
  x402NetworkByCaip2
@@ -161,7 +163,7 @@ import {
161
163
  import {
162
164
  createDiagnosticCapabilityAuthority,
163
165
  diagnosticConsole
164
- } from "./chunk-5PLQOC4B.js";
166
+ } from "./chunk-BFGQG4WY.js";
165
167
  import {
166
168
  MemoryProcessedPaymentStore,
167
169
  PostgresProcessedPaymentStore,
@@ -3118,6 +3120,10 @@ function decodeBatchPayment(header) {
3118
3120
  if (!isBatchSettlementDepositPayload2(parsed.payload) && !isBatchSettlementVoucherPayload(parsed.payload) && !isBatchSettlementRefundPayload2(parsed.payload)) {
3119
3121
  throw new Error("invalid batch-settlement payload");
3120
3122
  }
3123
+ const voucher = parsed.payload.voucher;
3124
+ if (typeof voucher.channelId !== "string" || typeof voucher.maxClaimableAmount !== "string" || typeof voucher.signature !== "string") {
3125
+ throw new Error("invalid batch-settlement voucher");
3126
+ }
3121
3127
  return parsed;
3122
3128
  }
3123
3129
  function batchVoucher(payload) {
@@ -3877,7 +3883,7 @@ async function verifyUpfrontPaymentInScope(opts) {
3877
3883
  x402Requirements
3878
3884
  });
3879
3885
  }
3880
- const { verifyX402Payment } = await import("./x402-T7WVKDD4.js");
3886
+ const { verifyX402Payment } = await import("./x402-HQ5GRQZU.js");
3881
3887
  const receipt = await withPaymentStage("verifier_broadcast", () => verifyX402Payment(
3882
3888
  x402Payment,
3883
3889
  x402Config,
@@ -4040,6 +4046,30 @@ async function verifyUpfrontPaymentInScope(opts) {
4040
4046
  }
4041
4047
  const expectedAmount = opts.priceFiat?.currency === "usd" ? fiatMicroToMethodAmount(opts.priceFiat.amountMicro, cred.challenge.method) : msatsToMethodAmount(requiredMsats, cred.challenge.method, rate);
4042
4048
  const sessionBasis = mppSessionBasis(cred);
4049
+ const replayRealm = mpp.realm ?? cred.challenge.realm;
4050
+ if (consumedCredentialStore && await withPaymentStage("replay_store", () => consumedCredentialStore.has(replayRealm, cred.challenge.id))) {
4051
+ const mppChallenges = await issueUpfrontChallenges(
4052
+ mpp,
4053
+ requiredMsats,
4054
+ resourcePath ?? "/v1/job",
4055
+ rate,
4056
+ sessionIssuable,
4057
+ opts.fundOnly?.allowOneShotStablecoin,
4058
+ opts.priceFiat
4059
+ );
4060
+ return {
4061
+ paidMsats: 0,
4062
+ receivedProofs: [],
4063
+ error: {
4064
+ body: paymentErrorBody("payment_invalid", {
4065
+ message: "MPP credential already used"
4066
+ }),
4067
+ status: 402,
4068
+ mppChallenges,
4069
+ ...x402Requirements && { x402Requirements }
4070
+ }
4071
+ };
4072
+ }
4043
4073
  let tempoReceipt;
4044
4074
  const broadcast = async () => {
4045
4075
  const receipt = await loggers.tempo.span(
@@ -4157,35 +4187,12 @@ async function verifyUpfrontPaymentInScope(opts) {
4157
4187
  }
4158
4188
  };
4159
4189
  }
4160
- if (consumedCredentialStore) {
4161
- const realm = mpp.realm;
4162
- const id = cred.challenge.id;
4163
- if (await withPaymentStage("replay_store", () => consumedCredentialStore.has(realm, id))) {
4164
- const mppChallenges = await issueUpfrontChallenges(
4165
- mpp,
4166
- requiredMsats,
4167
- resourcePath ?? "/v1/job",
4168
- rate,
4169
- sessionIssuable,
4170
- opts.fundOnly?.allowOneShotStablecoin,
4171
- opts.priceFiat
4172
- );
4173
- return {
4174
- paidMsats: 0,
4175
- receivedProofs: [],
4176
- error: {
4177
- body: paymentErrorBody("payment_invalid", {
4178
- message: "MPP credential already used"
4179
- }),
4180
- status: 402,
4181
- mppChallenges,
4182
- ...x402Requirements && { x402Requirements }
4183
- }
4184
- };
4185
- }
4186
- if (!sessionBasis) {
4187
- await withPaymentStage("replay_store", () => consumedCredentialStore.mark(realm, id, CONSUMED_CREDENTIAL_TTL_MS / 1e3));
4188
- }
4190
+ if (consumedCredentialStore && !sessionBasis) {
4191
+ await withPaymentStage("replay_store", () => consumedCredentialStore.mark(
4192
+ replayRealm,
4193
+ cred.challenge.id,
4194
+ CONSUMED_CREDENTIAL_TTL_MS / 1e3
4195
+ ));
4189
4196
  }
4190
4197
  if (ledgerCtx) {
4191
4198
  try {
@@ -4208,10 +4215,18 @@ async function verifyUpfrontPaymentInScope(opts) {
4208
4215
  }
4209
4216
  if (sessionBasis && consumedCredentialStore) {
4210
4217
  await withPaymentStage("replay_store", () => consumedCredentialStore.mark(
4211
- mpp.realm,
4218
+ replayRealm,
4212
4219
  cred.challenge.id,
4213
4220
  CONSUMED_CREDENTIAL_TTL_MS / 1e3
4214
- ));
4221
+ )).catch((error) => {
4222
+ diagnosticConsole.warn({
4223
+ level: "warn",
4224
+ event: "tempo_replay_mark_failed",
4225
+ phase: "upfront",
4226
+ rail: "tempo",
4227
+ ...paymentFailureFields(error)
4228
+ });
4229
+ });
4215
4230
  }
4216
4231
  if (credited) {
4217
4232
  await mpp.completeHostedTempoCharge?.(cred.challenge.id).catch((error) => {
@@ -4420,6 +4435,22 @@ async function verifyTempoSessionManagementCredential(args) {
4420
4435
  throw new Error("Hosted Tempo close requires non-mutating credential validation");
4421
4436
  }
4422
4437
  await args.mpp.validateCredential(credential, verifyOptions);
4438
+ if (args.action === "close") {
4439
+ if (!args.mpp.assertTempoSessionCloseVoucher) {
4440
+ throw new Error("Hosted Tempo close requires stored-channel voucher validation");
4441
+ }
4442
+ const { cumulativeAmount, signature } = credential.payload;
4443
+ if (typeof cumulativeAmount !== "string" || typeof signature !== "string") {
4444
+ throw new Error("Expected a Tempo session close voucher");
4445
+ }
4446
+ await args.mpp.assertTempoSessionCloseVoucher({
4447
+ channelId: payload.channelId,
4448
+ cumulativeAmount: BigInt(cumulativeAmount),
4449
+ signature,
4450
+ descriptor: credential.payload.descriptor,
4451
+ request: credential.challenge.request
4452
+ });
4453
+ }
4423
4454
  }
4424
4455
  await args.onBound?.();
4425
4456
  const receipt = await args.mpp.broadcastCredential(credential, verifyOptions);
@@ -4865,7 +4896,7 @@ async function verifyIncomingPaymentInScope(body, opts, snapshot) {
4865
4896
  snapshot
4866
4897
  });
4867
4898
  }
4868
- const { verifyX402Payment } = await import("./x402-T7WVKDD4.js");
4899
+ const { verifyX402Payment } = await import("./x402-HQ5GRQZU.js");
4869
4900
  const receipt = await withPaymentStage("verifier_broadcast", () => verifyX402Payment(
4870
4901
  body.content.x402_payment,
4871
4902
  opts.x402Config,
@@ -5296,7 +5327,7 @@ async function processIncomingPaymentInScope(job, body, opts) {
5296
5327
  try {
5297
5328
  const requiredMsats = job.pendingPaymentMsats ?? 0;
5298
5329
  const requiredUsdcMicro = job.pendingX402AmountUsdcMicro !== void 0 ? BigInt(job.pendingX402AmountUsdcMicro) : BigInt(msatsToUsdc(requiredMsats, rate));
5299
- const { verifyX402Payment } = await import("./x402-T7WVKDD4.js");
5330
+ const { verifyX402Payment } = await import("./x402-HQ5GRQZU.js");
5300
5331
  const receipt = await withPaymentStage("verifier_broadcast", () => verifyX402Payment(
5301
5332
  body.content.x402_payment,
5302
5333
  opts.x402Config,
@@ -7456,17 +7487,16 @@ async function completeTempoDrain(c, deps, record, expectedChannelId, credit) {
7456
7487
  await beginClose(expectedChannelId, record.drainId);
7457
7488
  recoveryVersion = await deps.tempoSettlementReadiness?.captureRecoveryVersion();
7458
7489
  if (deps.hostedSettlementReports) {
7459
- const reserved = await deps.hostedSettlementReports.reserveTempo(
7460
- expectedChannelId,
7461
- "close",
7462
- record.drainId
7490
+ hostedObligationId = await reserveHostedTempoClose(
7491
+ deps.hostedSettlementReports,
7492
+ rail.mpp,
7493
+ record,
7494
+ expectedChannelId
7463
7495
  );
7464
- if (reserved.replayed) throw new Error("Hosted Tempo close already has an original effect attempt");
7465
- hostedObligationId = reserved.id;
7466
7496
  if (!rail.mpp?.permitHostedTempoClose) {
7467
7497
  throw new Error("Hosted Tempo close credential guard is unavailable");
7468
7498
  }
7469
- rail.mpp.permitHostedTempoClose(rail.mppCredential, reserved.id);
7499
+ rail.mpp.permitHostedTempoClose(rail.mppCredential, hostedObligationId);
7470
7500
  }
7471
7501
  }
7472
7502
  });
@@ -7491,6 +7521,14 @@ async function completeTempoDrain(c, deps, record, expectedChannelId, credit) {
7491
7521
  }
7492
7522
  const wrongChannel = tempoWrongChannelRefusal(error, record);
7493
7523
  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
+ });
7531
+ }
7494
7532
  const refused = tempoFloorRefusal(error, record);
7495
7533
  if (refused) return refused;
7496
7534
  if (tempoCloseCredentialNeedsRefresh(error)) {
@@ -7537,6 +7575,49 @@ async function completeTempoDrain(c, deps, record, expectedChannelId, credit) {
7537
7575
  });
7538
7576
  }
7539
7577
  }
7578
+ var HOSTED_TEMPO_CLOSE_STALE_MS = 3e5;
7579
+ var HOSTED_TEMPO_CLOSE_MAX_RELEASES = 3;
7580
+ var HOSTED_TEMPO_CLOSE_IN_FLIGHT = "Hosted Tempo close already has an original effect attempt";
7581
+ var HostedTempoCloseAttemptsExhaustedError = class extends Error {
7582
+ };
7583
+ async function reserveHostedTempoClose(reports, mpp, record, channelId) {
7584
+ const pending = await reports.pendingTempoClose(channelId, record.drainId);
7585
+ if (!pending) {
7586
+ const reserved = await reports.reserveTempo(channelId, "close", record.drainId);
7587
+ if (reserved.replayed) throw new Error(HOSTED_TEMPO_CLOSE_IN_FLIGHT);
7588
+ return reserved.id;
7589
+ }
7590
+ if (pending.txHash || !mpp.getTempoSessionDrainState) throw new Error(HOSTED_TEMPO_CLOSE_IN_FLIGHT);
7591
+ const state = await mpp.getTempoSessionDrainState(channelId);
7592
+ if (state.finalized || state.deposit === 0n || state.closeRequestedAt !== 0n) {
7593
+ throw new Error(HOSTED_TEMPO_CLOSE_IN_FLIGHT);
7594
+ }
7595
+ const result = await reports.releaseStaleTempoClose(pending.id, channelId, record.drainId, {
7596
+ staleAfterMs: HOSTED_TEMPO_CLOSE_STALE_MS,
7597
+ maxReleases: HOSTED_TEMPO_CLOSE_MAX_RELEASES
7598
+ });
7599
+ if (result.outcome === "in_flight") throw new Error(HOSTED_TEMPO_CLOSE_IN_FLIGHT);
7600
+ if (result.outcome === "exhausted") {
7601
+ loggers.tempo.warn("tempo_session_close_attempts_exhausted", {
7602
+ credit_id: record.creditId,
7603
+ drain_id: record.drainId,
7604
+ channel_id: channelId,
7605
+ obligation_id: pending.id,
7606
+ attempt: result.releases + 1
7607
+ });
7608
+ throw new HostedTempoCloseAttemptsExhaustedError(
7609
+ `Tempo drain ${record.drainId} exhausted its cooperative close attempts`
7610
+ );
7611
+ }
7612
+ loggers.tempo.warn("tempo_session_close_reservation_released", {
7613
+ credit_id: record.creditId,
7614
+ drain_id: record.drainId,
7615
+ channel_id: channelId,
7616
+ obligation_id: pending.id,
7617
+ attempt: result.releases + 1
7618
+ });
7619
+ return result.id;
7620
+ }
7540
7621
  function tempoCloseCredentialNeedsRefresh(error) {
7541
7622
  return error instanceof MppxErrors.InvalidChallengeError || error instanceof MppxErrors.PaymentExpiredError;
7542
7623
  }
@@ -9900,6 +9981,8 @@ var HostedSettlementReportObligations = class {
9900
9981
  created_at_ms BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint,
9901
9982
  updated_at_ms BIGINT NOT NULL DEFAULT FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint
9902
9983
  );
9984
+ ALTER TABLE sdk_hosted_settlement_report_obligations
9985
+ ADD COLUMN IF NOT EXISTS close_search_before BIGINT;
9903
9986
  CREATE INDEX IF NOT EXISTS sdk_hosted_settlement_report_pending
9904
9987
  ON sdk_hosted_settlement_report_obligations (dvm_id,scope,state)
9905
9988
  WHERE state NOT IN ('delivered','no_report');
@@ -10006,53 +10089,109 @@ var HostedSettlementReportObligations = class {
10006
10089
  });
10007
10090
  }
10008
10091
  async reserveTempo(channelId, operation, drainId) {
10009
- const config = this.tempo;
10010
- if (!config || !hex(channelId, 32)) throw new Error("Hosted Tempo settlement configuration is unavailable");
10092
+ return transaction(this.pool, (tx) => this.reserveTempoIn(tx, channelId, operation, drainId));
10093
+ }
10094
+ /**
10095
+ * 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
10099
+ * writes it onto the successor, and recovery attaches it there if that
10100
+ * attempt never got to write, so it is reported once.
10101
+ *
10102
+ * The caller has just read the channel open on chain. The age bound only
10103
+ * spares a reservation young enough to be inside its first receipt wait; an
10104
+ * attempt slowed past it by a degraded RPC can still be running.
10105
+ */
10106
+ async releaseStaleTempoClose(id, channelId, drainId, limits) {
10011
10107
  return transaction(this.pool, async (tx) => {
10012
- const state = await tx.query(
10013
- "SELECT state_json FROM mpp_tempo_channels WHERE channel_key=$1",
10014
- [channelId.toLowerCase()]
10108
+ await this.lockIdentity(tx);
10109
+ const row = await this.lockRow(tx, id);
10110
+ if (row.rail !== "tempo" || row.operation !== "close" || row.channel_id !== channelId.toLowerCase() || row.original.drainId !== drainId || row.state !== "reserved" || row.tx_hash) {
10111
+ return { outcome: "in_flight" };
10112
+ }
10113
+ const age = await tx.query(
10114
+ `SELECT updated_at_ms <= FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint - $2 AS stale
10115
+ FROM sdk_hosted_settlement_report_obligations WHERE id=$1`,
10116
+ [id, limits.staleAfterMs]
10015
10117
  );
10016
- if (!state.rows[0]) throw new Error("Hosted Tempo channel is not durable");
10017
- const identity = await this.lockIdentity(tx);
10018
- const parsed = parseStoreValue(state.rows[0].state_json);
10019
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Hosted Tempo state is invalid");
10020
- const row = parsed;
10021
- 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()) {
10022
- throw new Error("Hosted Tempo channel has another chain or recipient");
10023
- }
10024
- if (operation === "close") {
10025
- if (!drainId) throw new Error("Hosted Tempo close requires the original drain");
10026
- const close = await tx.query(
10027
- "SELECT drain_id FROM mpp_tempo_close_intents WHERE channel_id=$1",
10028
- [channelId.toLowerCase()]
10029
- );
10030
- if (close.rows[0]?.drain_id !== drainId) throw new Error("Hosted Tempo close has no matching drain");
10031
- }
10032
- const highestVoucherAmount = nativeAmount(row.highestVoucherAmount);
10033
- const settledOnChain = nativeAmount(row.settledOnChain);
10034
- const spent = nativeAmount(row.spent);
10035
- if (BigInt(settledOnChain) > BigInt(highestVoucherAmount) || BigInt(spent) > BigInt(highestVoucherAmount)) {
10036
- throw new Error("Hosted Tempo settlement source has inconsistent amounts");
10037
- }
10038
- const original = operation === "settle" ? {
10039
- highestVoucherAmount,
10040
- settledOnChain,
10041
- spent
10042
- } : { drainId, highestVoucherAmount, settledOnChain, spent };
10043
- return this.insert(
10044
- tx,
10045
- identity,
10046
- "tempo",
10047
- operation,
10048
- config.network,
10049
- config.escrow,
10050
- channelId.toLowerCase(),
10051
- original,
10052
- operation === "close" ? [drainId] : [highestVoucherAmount]
10118
+ if (!age.rows[0]?.stale) return { outcome: "in_flight" };
10119
+ const released = Number((await tx.query(
10120
+ `SELECT COUNT(*)::text AS n FROM sdk_hosted_settlement_report_obligations
10121
+ WHERE dvm_id=$1 AND scope=$2 AND rail='tempo' AND operation='close' AND channel_id=$3
10122
+ AND original->>'drainId'=$4 AND state='no_report' AND tx_hash IS NULL`,
10123
+ [this.dvmId, this.scope, channelId.toLowerCase(), drainId]
10124
+ )).rows[0].n);
10125
+ if (released >= limits.maxReleases) return { outcome: "exhausted", releases: released };
10126
+ await tx.query(
10127
+ `UPDATE sdk_hosted_settlement_report_obligations SET state='no_report',
10128
+ updated_at_ms=FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint WHERE id=$1`,
10129
+ [id]
10053
10130
  );
10131
+ const next = await this.reserveTempoIn(tx, channelId, "close", drainId);
10132
+ return { outcome: "reserved", id: next.id, releases: released + 1 };
10054
10133
  });
10055
10134
  }
10135
+ /**
10136
+ * Resolve an unsubmitted close reservation whose channel the payer exited
10137
+ * on its own: no cooperative close of ours can land on a finalized channel.
10138
+ */
10139
+ async resolveTempoCloseExit(id) {
10140
+ const result = await this.pool.query(
10141
+ `UPDATE sdk_hosted_settlement_report_obligations SET state='no_report',
10142
+ updated_at_ms=FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint
10143
+ 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`,
10145
+ [id, this.dvmId, this.scope]
10146
+ );
10147
+ return result.rowCount === 1;
10148
+ }
10149
+ async reserveTempoIn(tx, channelId, operation, drainId) {
10150
+ const config = this.tempo;
10151
+ if (!config || !hex(channelId, 32)) throw new Error("Hosted Tempo settlement configuration is unavailable");
10152
+ const state = await tx.query(
10153
+ "SELECT state_json FROM mpp_tempo_channels WHERE channel_key=$1",
10154
+ [channelId.toLowerCase()]
10155
+ );
10156
+ if (!state.rows[0]) throw new Error("Hosted Tempo channel is not durable");
10157
+ const identity = await this.lockIdentity(tx);
10158
+ const parsed = parseStoreValue(state.rows[0].state_json);
10159
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Hosted Tempo state is invalid");
10160
+ const row = parsed;
10161
+ 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()) {
10162
+ throw new Error("Hosted Tempo channel has another chain or recipient");
10163
+ }
10164
+ if (operation === "close") {
10165
+ if (!drainId) throw new Error("Hosted Tempo close requires the original drain");
10166
+ const close = await tx.query(
10167
+ "SELECT drain_id FROM mpp_tempo_close_intents WHERE channel_id=$1",
10168
+ [channelId.toLowerCase()]
10169
+ );
10170
+ if (close.rows[0]?.drain_id !== drainId) throw new Error("Hosted Tempo close has no matching drain");
10171
+ }
10172
+ const highestVoucherAmount = nativeAmount(row.highestVoucherAmount);
10173
+ const settledOnChain = nativeAmount(row.settledOnChain);
10174
+ const spent = nativeAmount(row.spent);
10175
+ if (BigInt(settledOnChain) > BigInt(highestVoucherAmount) || BigInt(spent) > BigInt(highestVoucherAmount)) {
10176
+ throw new Error("Hosted Tempo settlement source has inconsistent amounts");
10177
+ }
10178
+ const original = operation === "settle" ? {
10179
+ highestVoucherAmount,
10180
+ settledOnChain,
10181
+ spent
10182
+ } : { drainId, highestVoucherAmount, settledOnChain, spent };
10183
+ return this.insert(
10184
+ tx,
10185
+ identity,
10186
+ "tempo",
10187
+ operation,
10188
+ config.network,
10189
+ config.escrow,
10190
+ channelId.toLowerCase(),
10191
+ original,
10192
+ operation === "close" ? [drainId] : [highestVoucherAmount]
10193
+ );
10194
+ }
10056
10195
  async findPendingTempo(channelId, operation) {
10057
10196
  const result = await this.pool.query(
10058
10197
  `SELECT * FROM sdk_hosted_settlement_report_obligations
@@ -10062,6 +10201,36 @@ var HostedSettlementReportObligations = class {
10062
10201
  );
10063
10202
  return result.rows[0];
10064
10203
  }
10204
+ /** The original close obligation reserved for this drain, while its report is outstanding. */
10205
+ async pendingTempoClose(channelId, drainId) {
10206
+ const result = await this.pool.query(
10207
+ `SELECT id,tx_hash,created_at_ms,close_search_before FROM sdk_hosted_settlement_report_obligations
10208
+ WHERE dvm_id=$1 AND scope=$2 AND rail='tempo' AND operation='close' AND channel_id=$3
10209
+ AND original->>'drainId'=$4 AND state NOT IN ('delivered','no_report')
10210
+ ORDER BY created_at_ms DESC LIMIT 1`,
10211
+ [this.dvmId, this.scope, channelId.toLowerCase(), drainId]
10212
+ );
10213
+ const row = result.rows[0];
10214
+ return row && {
10215
+ id: row.id,
10216
+ txHash: row.tx_hash,
10217
+ reservedAtMs: Number(row.created_at_ms),
10218
+ searchBefore: searchCursor(row.close_search_before)
10219
+ };
10220
+ }
10221
+ /**
10222
+ * Where the next search for this close obligation's transaction resumes. It
10223
+ * lives on the obligation, not the drain's close intent, because releasing
10224
+ * the drain deletes the intent while the obligation still needs the search.
10225
+ */
10226
+ async recordTempoCloseSearch(id, resumeAt) {
10227
+ await this.pool.query(
10228
+ `UPDATE sdk_hosted_settlement_report_obligations
10229
+ SET close_search_before = LEAST(COALESCE(close_search_before, $4), $4)
10230
+ WHERE id=$1 AND dvm_id=$2 AND scope=$3 AND rail='tempo' AND operation='close'`,
10231
+ [id, this.dvmId, this.scope, resumeAt.toString()]
10232
+ );
10233
+ }
10065
10234
  async submittedIds(rail, operation, txHash, batchId) {
10066
10235
  if (!hex(txHash, 32)) throw new Error("Hosted effect has no exact transaction hash");
10067
10236
  const result = await this.pool.query(
@@ -10168,17 +10337,67 @@ var HostedSettlementReportObligations = class {
10168
10337
  if (!hex(txHash, 32)) throw new Error("Hosted settlement has no transaction hash");
10169
10338
  const write = this.x402SettlementStore ? this.x402Transaction.bind(this) : (operation) => transaction(this.pool, operation);
10170
10339
  await write(async (tx) => {
10171
- const row = await this.lockRow(tx, id);
10340
+ let row = await this.lockRow(tx, id);
10341
+ if (row.state === "no_report" && !row.tx_hash) {
10342
+ row = await this.successorOfRetiredClose(tx, row);
10343
+ }
10172
10344
  if (row.tx_hash && row.tx_hash.toLowerCase() !== txHash.toLowerCase()) {
10173
10345
  throw new Error("Hosted settlement transaction replay conflicts");
10174
10346
  }
10175
10347
  await tx.query(
10176
10348
  `UPDATE sdk_hosted_settlement_report_obligations SET tx_hash=COALESCE(tx_hash,$2),
10177
10349
  updated_at_ms=FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::bigint WHERE id=$1`,
10178
- [id, txHash.toLowerCase()]
10350
+ [row.id, txHash.toLowerCase()]
10179
10351
  );
10180
10352
  });
10181
10353
  }
10354
+ /**
10355
+ * 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.
10358
+ */
10359
+ async unsubmittedTempoCloses(limit = 100) {
10360
+ const result = await this.pool.query(
10361
+ `SELECT id,channel_id,original->>'drainId' AS drain_id,created_at_ms,close_search_before
10362
+ FROM sdk_hosted_settlement_report_obligations
10363
+ WHERE dvm_id=$1 AND scope=$2 AND rail='tempo' AND operation='close'
10364
+ AND state='reserved' AND tx_hash IS NULL
10365
+ ORDER BY created_at_ms,id LIMIT $3`,
10366
+ [this.dvmId, this.scope, limit]
10367
+ );
10368
+ return result.rows.flatMap((row) => row.drain_id === null ? [] : [{
10369
+ id: row.id,
10370
+ channelId: row.channel_id,
10371
+ drainId: row.drain_id,
10372
+ reservedAtMs: Number(row.created_at_ms),
10373
+ searchBefore: searchCursor(row.close_search_before)
10374
+ }]);
10375
+ }
10376
+ /**
10377
+ * A retired close reservation's attempt can still be running when its
10378
+ * successor is reserved, and its close can land. The close is the same
10379
+ * channel's, so it belongs to the drain's outstanding obligation; writing it
10380
+ * onto the retired row would leave that obligation without a transaction and
10381
+ * the payout unreported.
10382
+ */
10383
+ async successorOfRetiredClose(tx, retired) {
10384
+ const drainId = retired.original.drainId;
10385
+ if (retired.rail !== "tempo" || retired.operation !== "close" || typeof drainId !== "string") {
10386
+ throw new Error("Hosted settlement obligation was resolved without a transaction");
10387
+ }
10388
+ const successor = await tx.query(
10389
+ `SELECT id FROM sdk_hosted_settlement_report_obligations
10390
+ WHERE dvm_id=$1 AND scope=$2 AND rail='tempo' AND operation='close' AND channel_id=$3
10391
+ AND original->>'drainId'=$4 AND state NOT IN ('delivered','no_report')
10392
+ ORDER BY created_at_ms DESC LIMIT 1`,
10393
+ [this.dvmId, this.scope, retired.channel_id, drainId]
10394
+ );
10395
+ if (!successor.rows[0]) {
10396
+ throw new Error("Hosted Tempo close was retired and the drain has no outstanding obligation");
10397
+ }
10398
+ const locked = await this.lockRow(tx, successor.rows[0].id);
10399
+ return locked.state === "no_report" && !locked.tx_hash ? this.successorOfRetiredClose(tx, locked) : locked;
10400
+ }
10182
10401
  async recoveryCandidates() {
10183
10402
  const result = await this.pool.query(
10184
10403
  `SELECT * FROM sdk_hosted_settlement_report_obligations
@@ -10201,6 +10420,10 @@ var HostedSettlementReportObligations = class {
10201
10420
  }));
10202
10421
  }
10203
10422
  async recoverOriginalX402Refunds() {
10423
+ const table = await this.pool.query(
10424
+ "SELECT to_regclass('x402_batch_settlements') IS NOT NULL AS present"
10425
+ );
10426
+ if (!table.rows[0]?.present) return [];
10204
10427
  const rows2 = await this.pool.query(
10205
10428
  `SELECT obligation.id,settlement.settle_response AS response
10206
10429
  FROM sdk_hosted_settlement_report_obligations obligation
@@ -10636,6 +10859,9 @@ var HostedSettlementReportObligations = class {
10636
10859
  return { id: row.id, replayed: true };
10637
10860
  }
10638
10861
  };
10862
+ function searchCursor(value) {
10863
+ return value === null ? null : BigInt(value);
10864
+ }
10639
10865
 
10640
10866
  // src/sdk/server/hosting-channel-evidence.ts
10641
10867
  import { createHash as createHash7 } from "crypto";
@@ -19537,7 +19763,7 @@ async function createDVMServer(descriptor, opts) {
19537
19763
  }
19538
19764
  }
19539
19765
  if (!opts.onJobCompleted && platformToken && platformUrl && dvmId && opts.db) {
19540
- const { RevenueReporter } = await import("./revenue-reporter-NRS2FANA.js");
19766
+ const { RevenueReporter } = await import("./revenue-reporter-KDUZLD5F.js");
19541
19767
  const reporter = new RevenueReporter(
19542
19768
  opts.db,
19543
19769
  platformUrl,
@@ -19547,7 +19773,7 @@ async function createDVMServer(descriptor, opts) {
19547
19773
  revenueReporter = reporter;
19548
19774
  await reporter.init();
19549
19775
  reporter.startRetryLoop();
19550
- const { MemoryX402BatchStore, PayoutReporter } = await import("./payout-reporter-W3CCLYCN.js");
19776
+ const { MemoryX402BatchStore, PayoutReporter } = await import("./payout-reporter-NN6K2IVQ.js");
19551
19777
  payoutReporter = new PayoutReporter({
19552
19778
  dvmId,
19553
19779
  db: opts.db,
@@ -20004,6 +20230,9 @@ var DVMServer = class {
20004
20230
  if (!certificateOnly && await control.close(command, support) === "conflict") {
20005
20231
  return c.json({ error: "hosting_closure_conflict" }, 409);
20006
20232
  }
20233
+ await this.opts.mpp?.reconcileTempoSessionCloses?.().catch(() => {
20234
+ diagnosticConsole.warn({ level: "tempo_routine_sweep_failed", dvm_id: this.opts.dvmId, error: void 0 });
20235
+ });
20007
20236
  const evidence = await control.certificate({ ...command, action: "certificate" }, support);
20008
20237
  if (!evidence) return c.json({ error: "hosting_closure_stale" }, 409);
20009
20238
  return c.json(evidence);
@@ -20905,7 +21134,7 @@ var DVMServer = class {
20905
21134
  // sees only the shortened list cannot tell a DVM that never did
20906
21135
  // sessions from one whose observer is down for a minute.
20907
21136
  ...withheld.length && { withheld },
20908
- realm: this.opts.mpp.realm,
21137
+ ...this.opts.mpp.realm && { realm: this.opts.mpp.realm },
20909
21138
  ...this.opts.mpp.tempoConfiguration && {
20910
21139
  recipient: this.opts.mpp.tempoConfiguration.recipient,
20911
21140
  payment_token: this.opts.mpp.tempoConfiguration.paymentToken,
@@ -4,7 +4,7 @@ import {
4
4
  } from "./chunk-YG7G4DPZ.js";
5
5
  import {
6
6
  diagnosticConsole
7
- } from "./chunk-5PLQOC4B.js";
7
+ } from "./chunk-BFGQG4WY.js";
8
8
  import {
9
9
  withSdkInitLock
10
10
  } from "./chunk-COYKJRMM.js";