@dvmkit/sdk 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/{chunk-4VV7WACY.js → chunk-2XQLL6N6.js} +1 -1
  2. package/dist/{chunk-LCCG5HQM.js → chunk-3Q6FTZDB.js} +2 -2
  3. package/dist/{chunk-TRY7BJA4.js → chunk-3R7NQA3Z.js} +1 -1
  4. package/dist/{chunk-KGL3YMZA.js → chunk-AJ6UHXUA.js} +1 -1
  5. package/dist/{chunk-BAQRUYRL.js → chunk-EWZFANHJ.js} +1 -1
  6. package/dist/{chunk-K2MSIKMM.js → chunk-FFXXGCVG.js} +1 -1
  7. package/dist/{chunk-WLPLHXNP.js → chunk-JNECYURT.js} +1 -1
  8. package/dist/{chunk-TFDR2YP2.js → chunk-KFCEXCZM.js} +1 -1
  9. package/dist/{chunk-IYQQXEHV.js → chunk-LDIPVPKI.js} +1 -1
  10. package/dist/{chunk-QSZW2I5Y.js → chunk-M3T5JYDJ.js} +15 -8
  11. package/dist/{chunk-Q3VYRU5R.js → chunk-MB6R4XPZ.js} +1 -1
  12. package/dist/{chunk-IBX2ORBV.js → chunk-MMGSYMIC.js} +1 -1
  13. package/dist/{chunk-LFVIZH2G.js → chunk-MU3IE434.js} +278 -152
  14. package/dist/{chunk-OIP44KBB.js → chunk-QGNXYVBO.js} +47 -12
  15. package/dist/{chunk-73C4JN2E.js → chunk-QNKK7SPY.js} +273 -54
  16. package/dist/{chunk-YYRY32NR.js → chunk-STJBBVNL.js} +2 -2
  17. package/dist/{chunk-WB3E7KE7.js → chunk-VMHYGKWT.js} +1 -1
  18. package/dist/{chunk-GAZBBMAA.js → chunk-VTHYQCDB.js} +1 -1
  19. package/dist/{chunk-LW7KZQHG.js → chunk-VUGEEMLT.js} +1 -1
  20. package/dist/{chunk-HC4I7LAV.js → chunk-W3VGCNG6.js} +1 -1
  21. package/dist/{chunk-GODOH2AF.js → chunk-Y6DO5WSO.js} +1 -1
  22. package/dist/{chunk-ZBQKXAYK.js → chunk-YOSZIWC3.js} +1 -1
  23. package/dist/{content-retention-CVBYehRS.d.ts → content-retention-CrILCNxk.d.ts} +1 -1
  24. package/dist/{credit-ledger-CZQ56DYI.js → credit-ledger-4PZNAMDT.js} +3 -3
  25. package/dist/{credit-menu-QntEhZJS.d.ts → credit-menu-VwX_6KM7.d.ts} +4 -4
  26. package/dist/{fx-CMyNFenZ.d.ts → fx-BM5h4o5S.d.ts} +1 -1
  27. package/dist/index.d.ts +5 -5
  28. package/dist/index.js +4 -4
  29. package/dist/internal/caller.d.ts +7 -7
  30. package/dist/internal/caller.js +7 -7
  31. package/dist/internal/server.d.ts +7 -7
  32. package/dist/internal/server.js +20 -20
  33. package/dist/{job-store-qOD-FrQF.d.ts → job-store-92v8e1LQ.d.ts} +1 -1
  34. package/dist/{lightning-backend-DtdG6UT5.d.ts → lightning-backend-9oky_wJi.d.ts} +1 -1
  35. package/dist/{memory-credit-ledger-EMN7G3MO.js → memory-credit-ledger-RP7DT5IY.js} +4 -4
  36. package/dist/{mpp-setup-VW53KFPO.js → mpp-setup-FURD45LI.js} +3 -3
  37. package/dist/{payout-reporter-TZX2S7KG.js → payout-reporter-HVJ6WUF3.js} +2 -2
  38. package/dist/{postgres-consumed-credential-store-V464OAJB.js → postgres-consumed-credential-store-WO6OCFXZ.js} +1 -1
  39. package/dist/{postgres-job-store-27VOCSPO.js → postgres-job-store-FSRUPYH2.js} +2 -2
  40. package/dist/{postgres-replay-store-7GJFAQXY.js → postgres-replay-store-XXJSA457.js} +2 -2
  41. package/dist/{pricing-NNMBNP7N.js → pricing-X4XU5HXY.js} +3 -3
  42. package/dist/{revenue-reporter-4EAVPWXS.js → revenue-reporter-EV3K5L6B.js} +2 -2
  43. package/dist/server/index.d.ts +74 -11
  44. package/dist/server/index.js +65 -27
  45. package/dist/{step-cache-B7svmPz3.d.ts → step-cache--7yA5Ihr.d.ts} +48 -6
  46. package/dist/{tempo-charge-store-2RPOJBM2.js → tempo-charge-store-5JOIYUIH.js} +2 -2
  47. package/dist/{tempo-session-store-LI3Q3A3U.js → tempo-session-store-S6QMLQUA.js} +3 -3
  48. package/dist/testing/index.d.ts +3 -3
  49. package/dist/testing/index.js +6 -6
  50. package/dist/{usd-D3izFz71.d.ts → usd-C7lvLvLk.d.ts} +1 -1
  51. package/dist/{x402-SL36LSOD.js → x402-EN5LSOVM.js} +3 -3
  52. package/package.json +1 -1
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  loggers
3
- } from "./chunk-QSZW2I5Y.js";
3
+ } from "./chunk-M3T5JYDJ.js";
4
4
  import {
5
5
  diagnosticConsole
6
- } from "./chunk-OIP44KBB.js";
6
+ } from "./chunk-QGNXYVBO.js";
7
7
 
8
8
  // src/sdk/server/mpp-setup.ts
9
9
  import { createHash, randomUUID } from "crypto";
@@ -663,6 +663,10 @@ function wrapMppx(mppx) {
663
663
  var TEMPO_CLOSE_LOG_WINDOW = 5000n;
664
664
  var TEMPO_CLOSE_LOG_SLACK_MS = 10 * 60 * 1e3;
665
665
  var TEMPO_CLOSE_LOG_MAX_WINDOWS = 500;
666
+ var TEMPO_CLOSE_SEARCH_BUDGET_MS = 3e4;
667
+ var TEMPO_CLOSE_POLL_SEARCH_BUDGET_MS = 1e4;
668
+ var TEMPO_CLOSE_SEARCH_TIMEOUT_MS = 6e4;
669
+ var TEMPO_CLOSE_CURSOR_SAVE_RESERVE_MS = 5e3;
666
670
  var DEFAULT_TEMPO_SESSION_CHAIN_OPS = {
667
671
  getChannelStatesBatch: TempoSession.Precompile.Chain.getChannelStatesBatch,
668
672
  settle: TempoSession.Server.settle,
@@ -673,7 +677,7 @@ var DEFAULT_TEMPO_SESSION_CHAIN_OPS = {
673
677
  channelId
674
678
  )
675
679
  ),
676
- findChannelClose: async (client, channelId, escrowContract, notBeforeMs, startBlock) => {
680
+ findChannelClose: async (client, channelId, escrowContract, notBeforeMs, startBlock, deadlineMs = Date.now() + TEMPO_CLOSE_SEARCH_BUDGET_MS) => {
677
681
  const { getBlock, getBlockNumber, getLogs, getTransaction } = await import("viem/actions");
678
682
  const eventNamed = (name) => {
679
683
  const event = TempoSession.Precompile.escrowAbi.find(
@@ -691,6 +695,8 @@ var DEFAULT_TEMPO_SESSION_CHAIN_OPS = {
691
695
  }
692
696
  let toBlock = startBlock ?? await getBlockNumber(client);
693
697
  for (let window = 0; window < TEMPO_CLOSE_LOG_MAX_WINDOWS; window += 1) {
698
+ const deadline = typeof deadlineMs === "function" ? deadlineMs() : deadlineMs;
699
+ if (Date.now() >= deadline) return { outcome: "paused", resumeAt: toBlock };
694
700
  const fromBlock = toBlock >= TEMPO_CLOSE_LOG_WINDOW ? toBlock - TEMPO_CLOSE_LOG_WINDOW + 1n : 0n;
695
701
  const range = { address: escrowContract, args: { channelId }, fromBlock, toBlock };
696
702
  const [closed, requested, cancelled] = await Promise.all([
@@ -727,6 +733,7 @@ var DEFAULT_TEMPO_SESSION_CHAIN_OPS = {
727
733
  function attachTempoSessionRuntime(mppx, config, chainOpOverrides = {}) {
728
734
  let creditLedger;
729
735
  let finalizeDrain;
736
+ const finalizedCloseSearches = /* @__PURE__ */ new Map();
730
737
  Object.assign(mppx, {
731
738
  useTempoCreditLedger: (ledger, finalizer) => {
732
739
  creditLedger = ledger;
@@ -942,18 +949,46 @@ function attachTempoSessionRuntime(mppx, config, chainOpOverrides = {}) {
942
949
  };
943
950
  const settlingHere = /* @__PURE__ */ new Set();
944
951
  const leaseMs = config.settlementSchedule?.leaseMs ?? TEMPO_SETTLEMENT_LEASE_MS;
945
- const claimSettlement = async (channelId) => {
946
- if (settlingHere.has(channelId)) return false;
952
+ const claimSettlement = async (channelId, chainSettled, forcedClose) => {
953
+ const inFlight = {
954
+ outcome: "retryable_failure",
955
+ channelId,
956
+ error: TEMPO_SETTLEMENT_IN_FLIGHT
957
+ };
958
+ if (settlingHere.has(channelId)) return inFlight;
947
959
  settlingHere.add(channelId);
948
960
  const leaseOwner = randomUUID();
949
961
  const leaseUntil = Date.now() + leaseMs;
950
962
  try {
951
- const claimed = await store.update(channelId, (current) => {
952
- if (!isStateRecord(current)) return { op: "noop", result: false };
963
+ const claim = await store.update(channelId, (current) => {
964
+ const channel = readTempoSessionState(current);
965
+ if (!channel || !isStateRecord(current)) {
966
+ return { op: "noop", result: { outcome: "unknown_channel", channelId } };
967
+ }
953
968
  const until = current[TEMPO_SETTLEMENT_LEASE_FIELD];
954
969
  const owner = current[TEMPO_SETTLEMENT_LEASE_OWNER_FIELD];
955
970
  if (typeof until === "number" && until > Date.now() && owner !== leaseOwner) {
956
- return { op: "noop", result: false };
971
+ return { op: "noop", result: inFlight };
972
+ }
973
+ const settledOnChain = maxBigInt(channel.settledOnChain, chainSettled);
974
+ if (channel.finalized && channel.highestVoucherAmount > settledOnChain) {
975
+ return {
976
+ op: "noop",
977
+ result: {
978
+ outcome: "finalized_before_protection",
979
+ channelId,
980
+ owed: channel.highestVoucherAmount - settledOnChain,
981
+ settledOnChain
982
+ }
983
+ };
984
+ }
985
+ if (channel.finalized || !tempoSettlementDue({
986
+ channel,
987
+ chainSettled: settledOnChain,
988
+ forcedClose,
989
+ intervalMs: config.settlementSchedule?.intervalMs
990
+ })) {
991
+ return { op: "noop", result: { outcome: "already_safe", channelId, settledOnChain } };
957
992
  }
958
993
  return {
959
994
  op: "set",
@@ -962,11 +997,11 @@ function attachTempoSessionRuntime(mppx, config, chainOpOverrides = {}) {
962
997
  [TEMPO_SETTLEMENT_LEASE_FIELD]: leaseUntil,
963
998
  [TEMPO_SETTLEMENT_LEASE_OWNER_FIELD]: leaseOwner
964
999
  },
965
- result: true
1000
+ result: { outcome: "claimed", channel }
966
1001
  };
967
1002
  });
968
- if (!claimed) settlingHere.delete(channelId);
969
- return claimed;
1003
+ if (claim.outcome !== "claimed") settlingHere.delete(channelId);
1004
+ return claim;
970
1005
  } catch (err) {
971
1006
  settlingHere.delete(channelId);
972
1007
  throw err;
@@ -988,8 +1023,10 @@ function attachTempoSessionRuntime(mppx, config, chainOpOverrides = {}) {
988
1023
  settlingHere.delete(channelId);
989
1024
  }
990
1025
  };
991
- const settleChannel = async (channel, client) => {
992
- if (!await claimSettlement(channel.channelId)) return false;
1026
+ const settleChannel = async (channelId, client, chainSettled, forcedClose) => {
1027
+ const claim = await claimSettlement(channelId, chainSettled, forcedClose);
1028
+ if (claim.outcome !== "claimed") return claim;
1029
+ const { channel } = claim;
993
1030
  try {
994
1031
  const recoveryVersion = await config.beforeSessionSettlement?.();
995
1032
  const reservation = await config.hostedObligations?.reserveTempo(channel.channelId, "settle");
@@ -1024,7 +1061,7 @@ function attachTempoSessionRuntime(mppx, config, chainOpOverrides = {}) {
1024
1061
  throw err;
1025
1062
  }
1026
1063
  await releaseSettlement(channel.channelId);
1027
- return true;
1064
+ return { outcome: "settled", channelId, settledAmount: channel.highestVoucherAmount };
1028
1065
  };
1029
1066
  const settleTempoSessionChannel = async (channelId) => {
1030
1067
  const normalized = channelId.toLowerCase();
@@ -1065,18 +1102,7 @@ function attachTempoSessionRuntime(mppx, config, chainOpOverrides = {}) {
1065
1102
  })) {
1066
1103
  return { outcome: "already_safe", channelId: normalized, settledOnChain };
1067
1104
  }
1068
- if (!await settleChannel(channel, client)) {
1069
- return {
1070
- outcome: "retryable_failure",
1071
- channelId: normalized,
1072
- error: TEMPO_SETTLEMENT_IN_FLIGHT
1073
- };
1074
- }
1075
- return {
1076
- outcome: "settled",
1077
- channelId: normalized,
1078
- settledAmount: channel.highestVoucherAmount
1079
- };
1105
+ return await settleChannel(normalized, client, chainState.settled, forcedClose);
1080
1106
  } catch (err) {
1081
1107
  return {
1082
1108
  outcome: "retryable_failure",
@@ -1163,18 +1189,29 @@ function attachTempoSessionRuntime(mppx, config, chainOpOverrides = {}) {
1163
1189
  })) {
1164
1190
  continue;
1165
1191
  }
1166
- if (await settleChannel(channel, client)) settled += 1;
1167
- else inFlight += 1;
1192
+ const result = await settleChannel(channel.channelId, client, chainState.settled, forcedClose);
1193
+ if (result.outcome === "settled") settled += 1;
1194
+ else if (result.outcome === "retryable_failure") inFlight += 1;
1168
1195
  }
1169
1196
  }
1170
1197
  }
1171
1198
  await reconcileTempoSessionCloses();
1172
1199
  return { scanned: rows.length, closeRequested, settled, inFlight };
1173
1200
  };
1174
- const recoverTempoSessionClose = async (channelId, drainId) => {
1201
+ const recoverTempoSessionClose = async (channelId, drainId, creditId) => {
1175
1202
  if (!store.getClose || !store.confirmClose) return null;
1176
1203
  const intent = await store.getClose(channelId);
1177
1204
  if (intent?.drainId !== drainId) return null;
1205
+ const drain = creditId ? await creditLedger?.getDrain({ creditId, drainId }) : void 0;
1206
+ if (drain && drain.channelId !== channelId) throw new Error("Tempo drain channel does not match its credit");
1207
+ if (drain?.status === "pending" || drain?.status === "exit_required") {
1208
+ const result = await reconcileFinalizedDrain(drain, true);
1209
+ if (result.outcome === "retryable_failure") throw result.error;
1210
+ if (result.outcome === "refund_unproven") return { outcome: "refund_unproven" };
1211
+ if (result.outcome === "open") return null;
1212
+ const completed = await creditLedger.getDrain({ creditId: drain.creditId, drainId });
1213
+ return completed?.status === "sent" && typeof completed.sentRef?.tx_hash === "string" ? completed.sentRef.tx_hash : null;
1214
+ }
1178
1215
  if (intent.reference) return intent.reference;
1179
1216
  const stored = await store.get(channelId);
1180
1217
  const current = readTempoSessionState(stored);
@@ -1199,13 +1236,21 @@ function attachTempoSessionRuntime(mppx, config, chainOpOverrides = {}) {
1199
1236
  const notBeforeMs = pending?.reservedAtMs ?? recovery?.updatedAtMs;
1200
1237
  if (notBeforeMs === void 0) throw new Error(`Tempo session close intent ${channelId} not found`);
1201
1238
  const obligations = config.hostedObligations;
1202
- const found = await findFinalizedClose(current, stored, drainId, notBeforeMs, pending ? {
1203
- from: pending.searchBefore,
1204
- save: (resumeAt) => obligations.recordTempoCloseSearch(pending.id, resumeAt)
1205
- } : {
1206
- from: recovery?.searchBefore ?? null,
1207
- save: (resumeAt) => store.recordCloseSearch?.(channelId, drainId, resumeAt)
1208
- });
1239
+ const found = await findFinalizedClose(
1240
+ current,
1241
+ stored,
1242
+ drainId,
1243
+ notBeforeMs,
1244
+ pending ? {
1245
+ from: pending.searchBefore,
1246
+ obligationId: pending.id,
1247
+ save: (resumeAt) => obligations.recordTempoCloseSearch(pending.id, resumeAt)
1248
+ } : {
1249
+ from: recovery?.searchBefore ?? null,
1250
+ save: (resumeAt) => store.recordCloseSearch?.(channelId, drainId, resumeAt)
1251
+ },
1252
+ TEMPO_CLOSE_POLL_SEARCH_BUDGET_MS
1253
+ );
1209
1254
  if (pending) {
1210
1255
  reference = await settleUnsubmittedClose(current.channelId, drainId, pending.id, found);
1211
1256
  } else if (found.outcome === "ours") {
@@ -1231,30 +1276,67 @@ function attachTempoSessionRuntime(mppx, config, chainOpOverrides = {}) {
1231
1276
  );
1232
1277
  return recovered.reference;
1233
1278
  };
1234
- const findFinalizedClose = async (current, stored, drainId, notBeforeMs, cursor) => {
1235
- const client = await getClient({ chainId: current.chainId });
1236
- const search = await chainOps.findChannelClose(
1237
- client,
1238
- current.channelId,
1239
- current.escrowContract,
1240
- notBeforeMs,
1241
- cursor.from ?? void 0
1242
- );
1243
- if (search.outcome === "paused") {
1244
- await cursor.save(search.resumeAt);
1245
- loggers.tempo.warn("tempo_session_close_recovery_search_paused", {
1246
- channel_id: current.channelId,
1247
- drain_id: drainId,
1248
- resume_block: search.resumeAt.toString()
1249
- });
1250
- throw new Error(`Tempo close search for channel ${current.channelId} continues on the next poll`);
1279
+ const findFinalizedClose = async (current, stored, drainId, notBeforeMs, cursor, budgetMs = TEMPO_CLOSE_SEARCH_BUDGET_MS) => {
1280
+ const key = cursor.obligationId ?? JSON.stringify([current.chainId, current.escrowContract, current.channelId, drainId, notBeforeMs]);
1281
+ const existing = finalizedCloseSearches.get(key);
1282
+ if (existing) {
1283
+ existing.deadline.atMs = Math.min(existing.deadline.atMs ?? Infinity, Date.now() + budgetMs);
1284
+ return existing.promise;
1251
1285
  }
1252
- if (search.outcome === "closed") {
1253
- return cooperativeCloseSenders(stored, config.account.address).has(search.sender.toLowerCase()) ? { outcome: "ours", txHash: search.txHash } : { outcome: "exit" };
1286
+ const deadline = {};
1287
+ const backstopDeadlineMs = Date.now() + TEMPO_CLOSE_SEARCH_TIMEOUT_MS;
1288
+ let expired = false;
1289
+ const assertActive = () => {
1290
+ if (expired) throw new Error("Tempo close search exceeded its deadline; retry this drain");
1291
+ };
1292
+ let timeout;
1293
+ const work = (async () => {
1294
+ const client = await getClient({ chainId: current.chainId });
1295
+ assertActive();
1296
+ deadline.atMs = Math.min(deadline.atMs ?? Infinity, Date.now() + budgetMs);
1297
+ const search2 = await chainOps.findChannelClose(
1298
+ client,
1299
+ current.channelId,
1300
+ current.escrowContract,
1301
+ notBeforeMs,
1302
+ cursor.from ?? void 0,
1303
+ () => Math.min(
1304
+ deadline.atMs ?? Infinity,
1305
+ backstopDeadlineMs - TEMPO_CLOSE_CURSOR_SAVE_RESERVE_MS
1306
+ )
1307
+ );
1308
+ assertActive();
1309
+ if (search2.outcome === "paused") {
1310
+ await cursor.save(search2.resumeAt);
1311
+ assertActive();
1312
+ loggers.tempo.warn("tempo_session_close_recovery_search_paused", {
1313
+ channel_id: current.channelId,
1314
+ drain_id: drainId,
1315
+ resume_block: search2.resumeAt.toString()
1316
+ });
1317
+ throw new Error(`Tempo close search for channel ${current.channelId} continues on the next poll`);
1318
+ }
1319
+ if (search2.outcome === "closed") {
1320
+ return cooperativeCloseSenders(stored, config.account.address).has(search2.sender.toLowerCase()) ? { outcome: "ours", txHash: search2.txHash } : { outcome: "exit" };
1321
+ }
1322
+ if (search2.outcome === "exit_requested") return { outcome: "exit" };
1323
+ if (search2.stoppedAt !== void 0) await cursor.save(search2.stoppedAt);
1324
+ assertActive();
1325
+ return { outcome: "none", terminal: search2.stoppedAt !== void 0 };
1326
+ })();
1327
+ const search = Promise.race([work, new Promise((_resolve, reject) => {
1328
+ timeout = setTimeout(() => {
1329
+ expired = true;
1330
+ reject(new Error("Tempo close search exceeded its deadline; retry this drain"));
1331
+ }, TEMPO_CLOSE_SEARCH_TIMEOUT_MS);
1332
+ })]);
1333
+ finalizedCloseSearches.set(key, { promise: search, deadline });
1334
+ try {
1335
+ return await search;
1336
+ } finally {
1337
+ clearTimeout(timeout);
1338
+ finalizedCloseSearches.delete(key);
1254
1339
  }
1255
- if (search.outcome === "exit_requested") return { outcome: "exit" };
1256
- if (search.stoppedAt !== void 0) await cursor.save(search.stoppedAt);
1257
- return { outcome: "none", terminal: search.stoppedAt !== void 0 };
1258
1340
  };
1259
1341
  const settleUnsubmittedClose = async (channelId, drainId, obligationId, found) => {
1260
1342
  const obligations = config.hostedObligations;
@@ -1291,6 +1373,11 @@ function attachTempoSessionRuntime(mppx, config, chainOpOverrides = {}) {
1291
1373
  const stored = await store.get(row.channelId);
1292
1374
  const current = readTempoSessionState(stored);
1293
1375
  if (!current) continue;
1376
+ const intent = await store.getClose?.(row.channelId);
1377
+ if (intent?.drainId === row.drainId && isTempoCloseTransaction(intent.reference)) {
1378
+ await obligations.recordSubmitted(row.id, intent.reference);
1379
+ continue;
1380
+ }
1294
1381
  if (!current.finalized) {
1295
1382
  const client = await getClient({ chainId: current.chainId });
1296
1383
  const [chainState] = await chainOps.getChannelStatesBatch(
@@ -1303,6 +1390,7 @@ function attachTempoSessionRuntime(mppx, config, chainOpOverrides = {}) {
1303
1390
  }
1304
1391
  const found = await findFinalizedClose(current, stored, row.drainId, row.reservedAtMs, {
1305
1392
  from: row.searchBefore,
1393
+ obligationId: row.id,
1306
1394
  save: (resumeAt) => obligations.recordTempoCloseSearch(row.id, resumeAt)
1307
1395
  });
1308
1396
  await settleUnsubmittedClose(current.channelId, row.drainId, row.id, found);
@@ -1316,6 +1404,134 @@ function attachTempoSessionRuntime(mppx, config, chainOpOverrides = {}) {
1316
1404
  }
1317
1405
  }
1318
1406
  };
1407
+ const reconcileFinalizedDrain = async (drain, recoverPending = false) => {
1408
+ const ledger = creditLedger;
1409
+ if (!ledger || !finalizeDrain || !drain.channelId) return { outcome: "open" };
1410
+ const searchBudgetMs = recoverPending ? TEMPO_CLOSE_POLL_SEARCH_BUDGET_MS : TEMPO_CLOSE_SEARCH_BUDGET_MS;
1411
+ try {
1412
+ const stored = await store.get(drain.channelId);
1413
+ const current = readTempoSessionState(stored);
1414
+ if (!current) throw new Error("Channel history is unavailable");
1415
+ const client = await getClient({ chainId: current.chainId });
1416
+ const recovery = await store.getCloseRecovery?.(drain.channelId);
1417
+ const pending = await config.hostedObligations?.pendingTempoClose(drain.channelId, drain.drainId);
1418
+ const intent = await store.getClose?.(drain.channelId);
1419
+ const recorded = intent?.drainId === drain.drainId && isTempoCloseTransaction(intent.reference) ? intent.reference : await config.hostedObligations?.tempoCloseTransaction(drain.channelId, drain.drainId);
1420
+ const txHash = recorded ?? pending?.txHash;
1421
+ let receipt = isTempoCloseTransaction(txHash) ? await chainOps.readChannelClose(client, current.channelId, txHash) : void 0;
1422
+ const chainState = receipt ? { deposit: 0n, settled: receipt.settledToPayee, closeRequestedAt: 0 } : (await chainOps.getChannelStatesBatch(client, [current.channelId], current.escrowContract))[0];
1423
+ if (!chainState || chainState.deposit !== 0n) return { outcome: "open" };
1424
+ await terminalizeLostBacking(current, chainState);
1425
+ await writeBackChainState(current, chainState);
1426
+ let found = receipt ? { outcome: "ours", txHash } : void 0;
1427
+ if (found?.outcome === "ours" && pending) {
1428
+ await config.hostedObligations.recordSubmitted(pending.id, found.txHash);
1429
+ }
1430
+ if (!found && pending && !pending.txHash) {
1431
+ if (pending.state === "unmatched") throw new TempoDrainRefundUnprovenError("close_history_unmatched", "Close history requires an operator retry");
1432
+ if (!recoverPending) return { outcome: "open" };
1433
+ found = await findFinalizedClose(current, stored, drain.drainId, pending.reservedAtMs, {
1434
+ from: pending.searchBefore,
1435
+ obligationId: pending.id,
1436
+ save: (resumeAt) => config.hostedObligations.recordTempoCloseSearch(pending.id, resumeAt)
1437
+ }, searchBudgetMs);
1438
+ await settleUnsubmittedClose(current.channelId, drain.drainId, pending.id, found);
1439
+ if (found.outcome === "none" && found.terminal) {
1440
+ throw new TempoDrainRefundUnprovenError("close_history_unmatched", "Close history requires an operator retry");
1441
+ }
1442
+ }
1443
+ found ??= pending?.txHash ? { outcome: "ours", txHash: pending.txHash } : await findFinalizedClose(current, stored, drain.drainId, drain.createdAt, {
1444
+ from: recovery?.searchBefore ?? null,
1445
+ save: (resumeAt) => store.recordCloseSearch?.(drain.channelId, drain.drainId, resumeAt)
1446
+ }, searchBudgetMs);
1447
+ if (found.outcome === "none" && !found.terminal) {
1448
+ throw new Error("Tempo close evidence is not yet available; retry this drain");
1449
+ }
1450
+ let refundedNative;
1451
+ let reference;
1452
+ if (found.outcome === "ours") {
1453
+ reference = found.txHash;
1454
+ receipt ??= await chainOps.readChannelClose(client, current.channelId, found.txHash);
1455
+ refundedNative = receipt.refundedToPayer;
1456
+ if (!config.hostedObligations && config.onSessionSettlement && store.claimCloseReference) {
1457
+ const claimed = await store.claimCloseReference(drain.channelId, drain.drainId, found.txHash);
1458
+ if (claimed.claimed) {
1459
+ await reportRecoveredClose(current, found.txHash, recovery?.settledBeforeClose ?? null, receipt.settledToPayee);
1460
+ }
1461
+ }
1462
+ } else if (found.outcome === "exit") {
1463
+ const snapshot = stored;
1464
+ const deposit = typeof snapshot.depositBeforeFinalization === "bigint" ? snapshot.depositBeforeFinalization : current.deposit;
1465
+ if (deposit <= 0n) throw new TempoDrainRefundUnprovenError("collateral_missing", "The pre-exit collateral is unavailable");
1466
+ refundedNative = deposit - maxBigInt(current.settledOnChain, chainState.settled);
1467
+ reference = `channel:${current.channelId}`;
1468
+ } else {
1469
+ if (!config.hostedObligations && config.onSessionSettlement) {
1470
+ loggers.tempo.warn("tempo_session_close_recovery_unmatched", {
1471
+ channel_id: current.channelId,
1472
+ drain_id: drain.drainId,
1473
+ amount_at_stake_native: (current.highestVoucherAmount - (recovery?.settledBeforeClose ?? current.settledOnChain)).toString()
1474
+ });
1475
+ }
1476
+ throw new TempoDrainRefundUnprovenError("close_evidence_missing", "Channel finalization has no matching refund evidence");
1477
+ }
1478
+ if (drain.drainedNative === null || refundedNative < BigInt(drain.drainedNative)) {
1479
+ throw new TempoDrainRefundUnprovenError("refund_short", "Proven refund is below the drain debit");
1480
+ }
1481
+ const creditUnbacked = (await ledger.get(drain.creditId))?.status === "unbacked";
1482
+ await store.confirmClose?.(drain.channelId, drain.drainId, reference);
1483
+ const changed = await finalizeDrain(drain, {
1484
+ reference,
1485
+ refundedNative,
1486
+ outcome: found.outcome === "ours" ? "cooperative_close" : "payer_exit",
1487
+ creditUnbacked
1488
+ });
1489
+ if (changed) {
1490
+ loggers.tempo.warn(creditUnbacked ? "tempo_session_drain_unbacked_resolved" : "tempo_session_drain_finalized", {
1491
+ channel_id: drain.channelId,
1492
+ credit_id: drain.creditId,
1493
+ drain_id: drain.drainId,
1494
+ amount_at_stake_native: drain.drainedNative.toString(),
1495
+ refund_native: refundedNative.toString(),
1496
+ builder_loss_native: (current.highestVoucherAmount > maxBigInt(current.settledOnChain, chainState.settled) ? current.highestVoucherAmount - maxBigInt(current.settledOnChain, chainState.settled) : 0n).toString()
1497
+ });
1498
+ }
1499
+ return { outcome: "completed" };
1500
+ } catch (error) {
1501
+ let failure = error;
1502
+ if (error instanceof TempoDrainRefundUnprovenError) {
1503
+ try {
1504
+ await store.update(drain.channelId, (current) => {
1505
+ if (!isStateRecord(current)) throw new Error("Channel history is unavailable");
1506
+ const key = JSON.stringify([drain.creditId, drain.drainId, error.reason]);
1507
+ const seen = Array.isArray(current.drainRefundWarnings) ? current.drainRefundWarnings : [];
1508
+ if (seen.includes(key)) return { op: "noop", result: false };
1509
+ loggers.tempo.warn("tempo_session_drain_refund_unproven", {
1510
+ channel_id: drain.channelId,
1511
+ credit_id: drain.creditId,
1512
+ drain_id: drain.drainId,
1513
+ amount_at_stake_native: drain.drainedNative?.toString() ?? null,
1514
+ amount_at_stake_micro: drain.amountMicro,
1515
+ currency: drain.currency,
1516
+ reason: error.reason,
1517
+ error: error.message
1518
+ });
1519
+ return { op: "set", value: { ...current, drainRefundWarnings: [...seen, key] }, result: true };
1520
+ });
1521
+ return { outcome: "refund_unproven" };
1522
+ } catch (claimError) {
1523
+ failure = claimError;
1524
+ }
1525
+ }
1526
+ loggers.tempo.warn("tempo_session_close_reconcile_failed", {
1527
+ channel_id: drain.channelId,
1528
+ credit_id: drain.creditId,
1529
+ drain_id: drain.drainId,
1530
+ error: failure instanceof Error ? failure.message : String(failure)
1531
+ });
1532
+ return { outcome: "retryable_failure", error: failure };
1533
+ }
1534
+ };
1319
1535
  const reconcileFinalizedDrains = async () => {
1320
1536
  const ledger = creditLedger;
1321
1537
  if (!ledger || !finalizeDrain) return;
@@ -1330,100 +1546,7 @@ function attachTempoSessionRuntime(mppx, config, chainOpOverrides = {}) {
1330
1546
  });
1331
1547
  for (const drain of page) {
1332
1548
  if (drain.status !== "exit_required" || !drain.channelId) continue;
1333
- try {
1334
- const stored = await store.get(drain.channelId);
1335
- const current = readTempoSessionState(stored);
1336
- if (!current) throw new Error("Channel history is unavailable");
1337
- const client = await getClient({ chainId: current.chainId });
1338
- const [chainState] = await chainOps.getChannelStatesBatch(client, [current.channelId], current.escrowContract);
1339
- if (!chainState || chainState.deposit !== 0n) continue;
1340
- await terminalizeLostBacking(current, chainState);
1341
- await writeBackChainState(current, chainState);
1342
- const pending = await config.hostedObligations?.pendingTempoClose(drain.channelId, drain.drainId);
1343
- if (pending && !pending.txHash) {
1344
- if (pending.state === "unmatched") throw new TempoDrainRefundUnprovenError("close_history_unmatched", "Close history requires an operator retry");
1345
- continue;
1346
- }
1347
- const recovery = await store.getCloseRecovery?.(drain.channelId);
1348
- const found = pending?.txHash ? { outcome: "ours", txHash: pending.txHash } : await findFinalizedClose(current, stored, drain.drainId, drain.createdAt, {
1349
- from: recovery?.searchBefore ?? null,
1350
- save: (resumeAt) => store.recordCloseSearch?.(drain.channelId, drain.drainId, resumeAt)
1351
- });
1352
- let refundedNative;
1353
- let reference;
1354
- if (found.outcome === "ours") {
1355
- reference = found.txHash;
1356
- const receipt = await chainOps.readChannelClose(client, current.channelId, found.txHash);
1357
- refundedNative = receipt.refundedToPayer;
1358
- if (!config.hostedObligations && config.onSessionSettlement && store.claimCloseReference) {
1359
- const claimed = await store.claimCloseReference(drain.channelId, drain.drainId, found.txHash);
1360
- if (claimed.claimed) {
1361
- await reportRecoveredClose(current, found.txHash, recovery?.settledBeforeClose ?? null, receipt.settledToPayee);
1362
- }
1363
- }
1364
- } else if (found.outcome === "exit") {
1365
- const snapshot = stored;
1366
- const deposit = typeof snapshot.depositBeforeFinalization === "bigint" ? snapshot.depositBeforeFinalization : current.deposit;
1367
- if (deposit <= 0n) throw new TempoDrainRefundUnprovenError("collateral_missing", "The pre-exit collateral is unavailable");
1368
- refundedNative = deposit - maxBigInt(current.settledOnChain, chainState.settled);
1369
- reference = `channel:${current.channelId}`;
1370
- } else {
1371
- throw new TempoDrainRefundUnprovenError("close_evidence_missing", "Channel finalization has no matching refund evidence");
1372
- }
1373
- if (drain.drainedNative === null || refundedNative < BigInt(drain.drainedNative)) {
1374
- throw new TempoDrainRefundUnprovenError("refund_short", "Proven refund is below the drain debit");
1375
- }
1376
- const creditUnbacked = (await ledger.get(drain.creditId))?.status === "unbacked";
1377
- await store.confirmClose?.(drain.channelId, drain.drainId, reference);
1378
- const changed = await finalizeDrain(drain, {
1379
- reference,
1380
- refundedNative,
1381
- outcome: found.outcome === "ours" ? "cooperative_close" : "payer_exit",
1382
- creditUnbacked
1383
- });
1384
- if (changed) {
1385
- loggers.tempo.warn(creditUnbacked ? "tempo_session_drain_unbacked_resolved" : "tempo_session_drain_finalized", {
1386
- channel_id: drain.channelId,
1387
- credit_id: drain.creditId,
1388
- drain_id: drain.drainId,
1389
- amount_at_stake_native: drain.drainedNative.toString(),
1390
- refund_native: refundedNative.toString(),
1391
- builder_loss_native: (current.highestVoucherAmount > maxBigInt(current.settledOnChain, chainState.settled) ? current.highestVoucherAmount - maxBigInt(current.settledOnChain, chainState.settled) : 0n).toString()
1392
- });
1393
- }
1394
- } catch (error) {
1395
- let failure = error;
1396
- if (error instanceof TempoDrainRefundUnprovenError) {
1397
- try {
1398
- await store.update(drain.channelId, (current) => {
1399
- if (!isStateRecord(current)) throw new Error("Channel history is unavailable");
1400
- const key = JSON.stringify([drain.creditId, drain.drainId, error.reason]);
1401
- const seen = Array.isArray(current.drainRefundWarnings) ? current.drainRefundWarnings : [];
1402
- if (seen.includes(key)) return { op: "noop", result: false };
1403
- loggers.tempo.warn("tempo_session_drain_refund_unproven", {
1404
- channel_id: drain.channelId,
1405
- credit_id: drain.creditId,
1406
- drain_id: drain.drainId,
1407
- amount_at_stake_native: drain.drainedNative?.toString() ?? null,
1408
- amount_at_stake_micro: drain.amountMicro,
1409
- currency: drain.currency,
1410
- reason: error.reason,
1411
- error: error.message
1412
- });
1413
- return { op: "set", value: { ...current, drainRefundWarnings: [...seen, key] }, result: true };
1414
- });
1415
- continue;
1416
- } catch (claimError) {
1417
- failure = claimError;
1418
- }
1419
- }
1420
- loggers.tempo.warn("tempo_session_close_reconcile_failed", {
1421
- channel_id: drain.channelId,
1422
- credit_id: drain.creditId,
1423
- drain_id: drain.drainId,
1424
- error: failure instanceof Error ? failure.message : String(failure)
1425
- });
1426
- }
1549
+ await reconcileFinalizedDrain(drain);
1427
1550
  }
1428
1551
  const last = page.at(-1);
1429
1552
  after = page.length === 100 && last ? { createdAt: last.createdAt, creditId: last.creditId, drainId: last.drainId } : void 0;
@@ -1553,6 +1676,9 @@ function advertisedMethods(mppx) {
1553
1676
  var _testing = {
1554
1677
  attachTempoSessionRuntime
1555
1678
  };
1679
+ function isTempoCloseTransaction(reference) {
1680
+ return typeof reference === "string" && /^0x[0-9a-fA-F]{64}$/.test(reference);
1681
+ }
1556
1682
 
1557
1683
  export {
1558
1684
  TEMPO_SETTLEMENT_DEFAULT_LOW_BALANCE_MICRO,