@dvmkit/sdk 0.10.0 → 0.11.0-rc.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/dist/{chunk-3Q6FTZDB.js → chunk-3YFFZLW7.js} +2 -2
  2. package/dist/{chunk-YOSZIWC3.js → chunk-55DVLBCD.js} +135 -8
  3. package/dist/{chunk-VMHYGKWT.js → chunk-566CFXGU.js} +9 -1
  4. package/dist/chunk-5H7P3ORL.js +92 -0
  5. package/dist/{chunk-3R7NQA3Z.js → chunk-7KQLPVV7.js} +1 -1
  6. package/dist/{chunk-QNKK7SPY.js → chunk-AIQGF52I.js} +350 -71
  7. package/dist/{chunk-JNECYURT.js → chunk-D4FFS3IS.js} +1 -1
  8. package/dist/{chunk-VTHYQCDB.js → chunk-E7ZBCT5U.js} +1 -1
  9. package/dist/{chunk-MMGSYMIC.js → chunk-EP7OVWI5.js} +1 -1
  10. package/dist/{chunk-LDIPVPKI.js → chunk-FFRTAQUW.js} +1 -1
  11. package/dist/chunk-H3GGFEE5.js +57 -0
  12. package/dist/{chunk-M3T5JYDJ.js → chunk-HNXMWHLU.js} +1 -1
  13. package/dist/{chunk-Y6DO5WSO.js → chunk-HQPQ4PFI.js} +43 -21
  14. package/dist/{chunk-UKD3R2KU.js → chunk-HW2UCOOC.js} +4 -55
  15. package/dist/{chunk-FFXXGCVG.js → chunk-ITRSITFP.js} +1 -1
  16. package/dist/{chunk-MU3IE434.js → chunk-JLT2YIBS.js} +267 -52
  17. package/dist/{chunk-STJBBVNL.js → chunk-K2ZNIQZP.js} +458 -69
  18. package/dist/{chunk-KFCEXCZM.js → chunk-KJZVTWMX.js} +1 -1
  19. package/dist/{chunk-W3VGCNG6.js → chunk-LDKV4UHI.js} +1 -1
  20. package/dist/{chunk-AJ6UHXUA.js → chunk-M4NFYEEY.js} +1 -1
  21. package/dist/{chunk-MB6R4XPZ.js → chunk-NS2OMIU7.js} +15 -3
  22. package/dist/{chunk-VUGEEMLT.js → chunk-RPUFWIT4.js} +11 -89
  23. package/dist/{chunk-EWZFANHJ.js → chunk-VWIMQ5H5.js} +1 -1
  24. package/dist/{chunk-2XQLL6N6.js → chunk-WFCVS6QB.js} +2 -2
  25. package/dist/{chunk-QGNXYVBO.js → chunk-Z4FOPROB.js} +17 -0
  26. package/dist/{content-retention-CrILCNxk.d.ts → content-retention-ySTD1wwZ.d.ts} +1 -1
  27. package/dist/{credit-ledger-4PZNAMDT.js → credit-ledger-4CBYLSLY.js} +6 -3
  28. package/dist/{credit-menu-VwX_6KM7.d.ts → credit-menu-BYySnZBk.d.ts} +6 -4
  29. package/dist/{fx-BM5h4o5S.d.ts → fx-DVTNs9q1.d.ts} +1 -1
  30. package/dist/index.d.ts +5 -5
  31. package/dist/index.js +6 -5
  32. package/dist/internal/caller.d.ts +6 -6
  33. package/dist/internal/caller.js +26 -22
  34. package/dist/internal/server.d.ts +7 -7
  35. package/dist/internal/server.js +24 -22
  36. package/dist/{job-store-92v8e1LQ.d.ts → job-store-DRwXCJDI.d.ts} +1 -1
  37. package/dist/{lightning-backend-9oky_wJi.d.ts → lightning-backend-BX5YvjnM.d.ts} +1 -1
  38. package/dist/memory-credit-ledger-DKCN7FY2.js +14 -0
  39. package/dist/{mpp-setup-FURD45LI.js → mpp-setup-JU3MT7QX.js} +3 -3
  40. package/dist/{payout-reporter-HVJ6WUF3.js → payout-reporter-NXWQVKXW.js} +2 -2
  41. package/dist/{postgres-consumed-credential-store-WO6OCFXZ.js → postgres-consumed-credential-store-WXJLM6J7.js} +1 -1
  42. package/dist/{postgres-job-store-FSRUPYH2.js → postgres-job-store-WIIVRZPD.js} +2 -2
  43. package/dist/{postgres-replay-store-XXJSA457.js → postgres-replay-store-QJ4FG6AR.js} +2 -2
  44. package/dist/{pricing-X4XU5HXY.js → pricing-KCLENT2C.js} +3 -3
  45. package/dist/{revenue-reporter-EV3K5L6B.js → revenue-reporter-HD4GMXGD.js} +2 -2
  46. package/dist/server/index.d.ts +29 -12
  47. package/dist/server/index.js +31 -29
  48. package/dist/{step-cache--7yA5Ihr.d.ts → step-cache-d-VKen4K.d.ts} +147 -13
  49. package/dist/{tempo-charge-store-5JOIYUIH.js → tempo-charge-store-7LTUAQHQ.js} +2 -2
  50. package/dist/{tempo-session-store-S6QMLQUA.js → tempo-session-store-LYAIDR2B.js} +3 -3
  51. package/dist/testing/index.d.ts +3 -3
  52. package/dist/testing/index.js +6 -6
  53. package/dist/{usd-C7lvLvLk.d.ts → usd-DSroMJ--.d.ts} +1 -1
  54. package/dist/{x402-EN5LSOVM.js → x402-RUJ4LUKF.js} +3 -3
  55. package/package.json +1 -1
  56. package/recipes/postgres/financial-export-v1.sql +19 -5
  57. package/dist/memory-credit-ledger-RP7DT5IY.js +0 -11
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  loggers
3
- } from "./chunk-M3T5JYDJ.js";
3
+ } from "./chunk-HNXMWHLU.js";
4
4
  import {
5
5
  diagnosticConsole
6
- } from "./chunk-QGNXYVBO.js";
6
+ } from "./chunk-Z4FOPROB.js";
7
7
 
8
8
  // src/sdk/server/mpp-setup.ts
9
9
  import { createHash, randomUUID } from "crypto";
@@ -858,31 +858,186 @@ function attachTempoSessionRuntime(mppx, config, chainOpOverrides = {}) {
858
858
  const getClient = config?.getClient;
859
859
  const listActive = store?.listActive;
860
860
  if (!config || !getClient || !store) return mppx;
861
- const terminalizeLostBacking = async (channel, chainState) => {
862
- if (chainState.deposit !== 0n) return;
863
- if (!creditLedger) return;
861
+ const retainRefundEvidence = async (drain, refundedNative, payerExit) => {
862
+ try {
863
+ if (payerExit) {
864
+ if (refundedNative > 0n) {
865
+ if (!creditLedger?.recordTempoDrainPayment) throw new Error("Tempo drain payment evidence persistence is unavailable");
866
+ await creditLedger.recordTempoDrainPayment({ creditId: drain.creditId, drainId: drain.drainId });
867
+ }
868
+ } else {
869
+ if (!creditLedger?.recordTempoDrainRefund) throw new Error("Tempo drain refund evidence persistence is unavailable");
870
+ await creditLedger.recordTempoDrainRefund({ creditId: drain.creditId, drainId: drain.drainId, refundedNative });
871
+ }
872
+ } catch (error) {
873
+ loggers.tempo.warn("tempo_drain_refund_evidence_write_failed", { amount_micro: drain.amountMicro });
874
+ throw error;
875
+ }
876
+ };
877
+ const nextDrainCursor = (page, after) => {
878
+ const last = page.at(-1);
879
+ if (page.length < 100 || !last) return void 0;
880
+ const next = { createdAt: last.createdAt, creditId: last.creditId, drainId: last.drainId };
881
+ if (after && !(next.createdAt > after.createdAt || next.createdAt === after.createdAt && (next.creditId > after.creditId || next.creditId === after.creditId && next.drainId > after.drainId))) {
882
+ throw new Error("Tempo drain pagination cursor did not advance");
883
+ }
884
+ return next;
885
+ };
886
+ const resolveChannelDrainProof = async (channel, chainState, proof) => {
887
+ const ledger = creditLedger;
888
+ const key = channel.channelId.toLowerCase();
889
+ const matches = [];
890
+ let after;
891
+ const createdBeforeMs = Date.now();
892
+ do {
893
+ const page = await ledger.listChannelDrains({
894
+ limit: 100,
895
+ after,
896
+ createdBeforeMs,
897
+ rail: "tempo",
898
+ includeWrittenOff: true
899
+ });
900
+ const next = nextDrainCursor(page, after);
901
+ for (const drain of page) {
902
+ if (drain.method !== "tempo" || drain.channelId?.toLowerCase() !== key || drain.status !== "pending" && drain.status !== "exit_required" || drain.operatorDecision) continue;
903
+ const credit = await ledger.get(drain.creditId);
904
+ if (credit?.tempoChannelId?.toLowerCase() !== key) throw new Error("Tempo drain channel does not match its credit");
905
+ matches.push(drain);
906
+ }
907
+ after = next;
908
+ } while (after);
909
+ if (!matches.length) return;
910
+ const intent = await store.getClose?.(key);
911
+ const matching = intent ? matches.filter((drain) => intent.channelId.toLowerCase() === key && drain.drainId === intent.drainId) : matches;
912
+ if (matching.length !== 1) throw new Error("Tempo close proof has no unique matching open drain");
913
+ if (proof.outcome === "ours") {
914
+ return { drain: matching[0], refundedNative: proof.receipt.refundedToPayer, payerExit: false };
915
+ } else {
916
+ const stored = await store.get(key);
917
+ const deposit = isStateRecord(stored) && typeof stored.depositBeforeFinalization === "bigint" ? stored.depositBeforeFinalization : channel.deposit;
918
+ return { drain: matching[0], refundedNative: deposit - maxBigInt(channel.settledOnChain, chainState.settled), payerExit: true };
919
+ }
920
+ };
921
+ const protectFinalizedBacking = async (channel, chainState, proof, classifying = true) => {
922
+ await store.update(channel.channelId.toLowerCase(), (value) => {
923
+ const active = readTempoSessionState(value);
924
+ if (!active || !isStateRecord(value)) throw new Error("Tempo finalized channel state is unavailable");
925
+ return { op: "set", value: {
926
+ ...value,
927
+ finalized: true,
928
+ deposit: 0n,
929
+ closeRequestedAt: 0n,
930
+ creditLossPending: classifying || !active.finalized || value.creditLossPending === true,
931
+ ...!active.finalized && { depositBeforeFinalization: active.deposit },
932
+ settledOnChain: maxBigInt(active.settledOnChain, chainState.settled),
933
+ // Publish positive payment and finalization together. The operator's
934
+ // channel lock then sees payment even if the drain writer fails.
935
+ ...proof && proof.refundedNative > 0n && { tempoDrainPaymentObservation: {
936
+ creditId: proof.drain.creditId,
937
+ drainId: proof.drain.drainId
938
+ } }
939
+ }, result: void 0 };
940
+ });
941
+ };
942
+ const deferHostedCloseSearch = async (channel) => {
943
+ const obligations = config.hostedObligations;
944
+ if (!obligations) return false;
945
+ const key = channel.channelId.toLowerCase();
946
+ const intent = await store.getClose?.(key);
947
+ if (isTempoCloseTransaction(intent?.reference)) return false;
948
+ const pending = await obligations.findPendingTempo(key, "close");
949
+ if (pending?.state !== "unmatched" || isTempoCloseTransaction(pending.tx_hash) || pending.rail !== "tempo" || pending.operation !== "close" || pending.channel_id.toLowerCase() !== key || pending.network !== `eip155:${channel.chainId}` || pending.contract.toLowerCase() !== channel.escrowContract.toLowerCase() || typeof pending.original.drainId !== "string" || !pending.original.drainId) return false;
950
+ if (intent && intent.drainId !== pending.original.drainId) return false;
951
+ return !isTempoCloseTransaction(await obligations.tempoCloseTransaction(key, pending.original.drainId));
952
+ };
953
+ const terminalizeLostBacking = async (channel, chainState, proof) => {
954
+ if (chainState.deposit !== 0n || !creditLedger) return;
864
955
  const ledger = creditLedger;
956
+ const key = channel.channelId.toLowerCase();
957
+ let resolved = proof;
958
+ const headroom = channel.highestVoucherAmount - maxBigInt(channel.settledOnChain, chainState.settled);
959
+ if (!resolved && headroom > 0n) {
960
+ try {
961
+ const stored = await store.get(key);
962
+ const intent = await store.getClose?.(key);
963
+ const reference = intent && (isTempoCloseTransaction(intent.reference) ? intent.reference : await config.hostedObligations?.tempoCloseTransaction(key, intent.drainId));
964
+ if (isTempoCloseTransaction(reference)) {
965
+ const client = await getClient({ chainId: channel.chainId });
966
+ resolved = { outcome: "ours", receipt: await chainOps.readChannelClose(client, channel.channelId, reference) };
967
+ } else if (!await deferHostedCloseSearch(channel)) {
968
+ const client = await getClient({ chainId: channel.chainId });
969
+ const snapshot = isStateRecord(stored) ? stored : {};
970
+ const before = typeof snapshot.creditLossSearchBefore === "bigint" ? snapshot.creditLossSearchBefore : void 0;
971
+ const createdAt = channel.createdAt ? Date.parse(channel.createdAt) : 0;
972
+ const search = await chainOps.findChannelClose(
973
+ client,
974
+ channel.channelId,
975
+ channel.escrowContract,
976
+ Number.isFinite(createdAt) ? createdAt : 0,
977
+ before,
978
+ Date.now() + TEMPO_CLOSE_POLL_SEARCH_BUDGET_MS
979
+ );
980
+ if (search.outcome === "closed") {
981
+ resolved = cooperativeCloseSenders(stored, config.account.address).has(search.sender.toLowerCase()) ? { outcome: "ours", receipt: await chainOps.readChannelClose(client, channel.channelId, search.txHash) } : { outcome: "exit" };
982
+ } else if (search.outcome === "exit_requested") {
983
+ resolved = { outcome: "exit" };
984
+ } else if (search.outcome === "paused") {
985
+ await store.update(key, (value) => isStateRecord(value) ? { op: "set", value: { ...value, creditLossSearchBefore: search.resumeAt }, result: void 0 } : { op: "noop", result: void 0 });
986
+ } else if (before !== void 0) {
987
+ await store.update(key, (value) => isStateRecord(value) ? { op: "set", value: Object.fromEntries(Object.entries(value).filter(([field]) => field !== "creditLossSearchBefore")), result: void 0 } : { op: "noop", result: void 0 });
988
+ }
989
+ }
990
+ } catch (error) {
991
+ loggers.tempo.warn("tempo_session_close_reconcile_failed", {
992
+ channel_id: key,
993
+ error: error instanceof Error ? error.message : String(error)
994
+ });
995
+ }
996
+ }
997
+ const pending = !resolved && headroom > 0n;
998
+ let drainProof;
999
+ try {
1000
+ if (resolved) drainProof = await resolveChannelDrainProof(channel, chainState, resolved);
1001
+ } finally {
1002
+ await protectFinalizedBacking(channel, chainState, drainProof);
1003
+ }
1004
+ if (drainProof) await retainRefundEvidence(drainProof.drain, drainProof.refundedNative, drainProof.payerExit);
1005
+ if (pending) return;
1006
+ const refundedNative = resolved?.outcome === "ours" ? resolved.receipt.refundedToPayer : 0n;
865
1007
  const retire = async (lifecycle, tx) => {
866
- const settledOnChain = maxBigInt(lifecycle.settledOnChain, chainState.settled);
867
- if (lifecycle.highestVoucherAmount <= settledOnChain) return;
868
- await ledger.terminalizeTempoCredits(
869
- {
870
- channelId: channel.channelId,
871
- settledOnChainNative: settledOnChain,
872
- highestVoucherNative: lifecycle.highestVoucherAmount,
873
- consumedNative: lifecycle.spent
874
- },
875
- tx
1008
+ const settledOnChainNative = maxBigInt(
1009
+ maxBigInt(lifecycle.settledOnChain, chainState.settled),
1010
+ resolved?.outcome === "ours" ? resolved.receipt.settledToPayee : 0n
876
1011
  );
1012
+ if (!resolved && lifecycle.highestVoucherAmount > settledOnChainNative) return;
1013
+ const lossNative = maxBigInt(0n, (resolved?.outcome === "exit" ? lifecycle.spent : lifecycle.highestVoucherAmount) - settledOnChainNative - refundedNative);
1014
+ const closeAware = ledger.tempoCreditLossEvidenceVersion === 1;
1015
+ if (lossNative > 0n && !closeAware) return;
1016
+ const candidates2 = lossNative > 0n ? await ledger.terminalizeTempoCredits({
1017
+ channelId: key,
1018
+ settledOnChainNative,
1019
+ highestVoucherNative: lifecycle.highestVoucherAmount,
1020
+ consumedNative: lifecycle.spent,
1021
+ ...resolved?.outcome === "ours" && { closeKind: "cooperative_close", refundedToPayerNative: refundedNative },
1022
+ ...resolved?.outcome === "exit" && { closeKind: "payer_exit" }
1023
+ }, tx) : closeAware ? await ledger.listTempoCreditLosses({ channelId: key, limit: 1e3 }, tx) : void 0;
1024
+ return { settledOnChainNative, refundedNative, lossNative, losses: candidates2 };
877
1025
  };
878
- if (store.withFinalizedCreditLoss) {
879
- await store.withFinalizedCreditLoss(
880
- channel.channelId,
881
- (tx, lifecycle) => retire(lifecycle, tx)
882
- );
1026
+ const result = store.withFinalizedCreditLoss ? await store.withFinalizedCreditLoss(key, (tx, lifecycle) => retire(lifecycle, tx)) : await retire(channel);
1027
+ if (!result) return;
1028
+ let candidates = result.losses;
1029
+ try {
1030
+ candidates ??= await ledger.listTempoCreditLosses({ channelId: key, limit: 1e3 });
1031
+ } catch (error) {
1032
+ loggers.tempo.warn("tempo_session_close_reconcile_failed", {
1033
+ channel_id: key,
1034
+ error: error instanceof Error ? error.message : String(error)
1035
+ });
883
1036
  return;
884
1037
  }
885
- await retire(channel);
1038
+ const losses = candidates.filter((loss) => loss.channelId.toLowerCase() === key);
1039
+ await store.update(key, (value) => isStateRecord(value) ? { op: "set", value: { ...value, creditLossPending: false }, result: void 0 } : { op: "noop", result: void 0 });
1040
+ return { ...result, losses };
886
1041
  };
887
1042
  const getTempoSessionDrainState = async (channelId) => {
888
1043
  const normalized = channelId.toLowerCase();
@@ -1074,11 +1229,19 @@ function attachTempoSessionRuntime(mppx, config, chainOpOverrides = {}) {
1074
1229
  [channel.channelId],
1075
1230
  channel.escrowContract
1076
1231
  );
1077
- await terminalizeLostBacking(channel, chainState);
1232
+ const backing = await terminalizeLostBacking(channel, chainState);
1078
1233
  const chainFinalized = await writeBackChainState(channel, chainState);
1079
- const settledOnChain = maxBigInt(channel.settledOnChain, chainState.settled);
1234
+ const settledOnChain = maxBigInt(
1235
+ maxBigInt(channel.settledOnChain, chainState.settled),
1236
+ backing?.settledOnChainNative ?? 0n
1237
+ );
1080
1238
  if (chainFinalized) {
1081
- const owed = channel.highestVoucherAmount - settledOnChain;
1239
+ if (!backing && creditLedger) return {
1240
+ outcome: "retryable_failure",
1241
+ channelId: normalized,
1242
+ error: "Finalized channel loss evidence or ledger capability is unavailable; retry reconciliation"
1243
+ };
1244
+ const owed = backing?.lossNative ?? channel.highestVoucherAmount - settledOnChain;
1082
1245
  return owed > 0n ? {
1083
1246
  outcome: "finalized_before_protection",
1084
1247
  channelId: normalized,
@@ -1160,10 +1323,22 @@ function attachTempoSessionRuntime(mppx, config, chainOpOverrides = {}) {
1160
1323
  for (const row of rows) {
1161
1324
  const state = readTempoSessionState(row.state);
1162
1325
  if (!state) continue;
1163
- const groupKey = `${state.chainId}:${state.escrowContract.toLowerCase()}`;
1164
- const group = groups.get(groupKey) ?? [];
1165
- group.push(state);
1166
- groups.set(groupKey, group);
1326
+ try {
1327
+ if (creditLedger && state.finalized && state.highestVoucherAmount > state.settledOnChain && await deferHostedCloseSearch(state)) {
1328
+ inFlight += 1;
1329
+ continue;
1330
+ }
1331
+ const groupKey = `${state.chainId}:${state.escrowContract.toLowerCase()}`;
1332
+ const group = groups.get(groupKey) ?? [];
1333
+ group.push(state);
1334
+ groups.set(groupKey, group);
1335
+ } catch (error) {
1336
+ inFlight += 1;
1337
+ loggers.tempo.warn("tempo_session_close_reconcile_failed", {
1338
+ channel_id: state.channelId,
1339
+ error: error instanceof Error ? error.message : String(error)
1340
+ });
1341
+ }
1167
1342
  }
1168
1343
  for (const groupedChannels of groups.values()) {
1169
1344
  for (let offset = 0; offset < groupedChannels.length; offset += 100) {
@@ -1176,26 +1351,41 @@ function attachTempoSessionRuntime(mppx, config, chainOpOverrides = {}) {
1176
1351
  first.escrowContract
1177
1352
  );
1178
1353
  for (const [index, channel] of channels.entries()) {
1179
- const chainState = chainStates[index];
1180
- await terminalizeLostBacking(channel, chainState);
1181
- const chainFinalized = await writeBackChainState(channel, chainState);
1182
- const forcedClose = !chainFinalized && BigInt(chainState.closeRequestedAt) !== 0n;
1183
- if (forcedClose) closeRequested += 1;
1184
- if (!tempoSettlementDue({
1185
- channel,
1186
- chainSettled: chainState.settled,
1187
- forcedClose,
1188
- intervalMs: config.settlementSchedule?.intervalMs
1189
- })) {
1190
- continue;
1354
+ try {
1355
+ const chainState = chainStates[index];
1356
+ await terminalizeLostBacking(channel, chainState);
1357
+ const chainFinalized = await writeBackChainState(channel, chainState);
1358
+ const forcedClose = !chainFinalized && BigInt(chainState.closeRequestedAt) !== 0n;
1359
+ if (forcedClose) closeRequested += 1;
1360
+ if (!tempoSettlementDue({
1361
+ channel,
1362
+ chainSettled: chainState.settled,
1363
+ forcedClose,
1364
+ intervalMs: config.settlementSchedule?.intervalMs
1365
+ })) {
1366
+ continue;
1367
+ }
1368
+ const result = await settleChannel(channel.channelId, client, chainState.settled, forcedClose);
1369
+ if (result.outcome === "settled") settled += 1;
1370
+ else if (result.outcome === "retryable_failure") inFlight += 1;
1371
+ } catch (error) {
1372
+ inFlight += 1;
1373
+ loggers.tempo.warn("tempo_session_close_reconcile_failed", {
1374
+ channel_id: channel.channelId,
1375
+ error: error instanceof Error ? error.message : String(error)
1376
+ });
1191
1377
  }
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;
1195
1378
  }
1196
1379
  }
1197
1380
  }
1198
- await reconcileTempoSessionCloses();
1381
+ try {
1382
+ await reconcileTempoSessionCloses();
1383
+ } catch (error) {
1384
+ inFlight += 1;
1385
+ loggers.tempo.warn("tempo_session_close_reconcile_failed", {
1386
+ error: error instanceof Error ? error.message : String(error)
1387
+ });
1388
+ }
1199
1389
  return { scanned: rows.length, closeRequested, settled, inFlight };
1200
1390
  };
1201
1391
  const recoverTempoSessionClose = async (channelId, drainId, creditId) => {
@@ -1228,6 +1418,7 @@ function attachTempoSessionRuntime(mppx, config, chainOpOverrides = {}) {
1228
1418
  finalized = chainState.deposit === 0n;
1229
1419
  }
1230
1420
  if (!finalized) return null;
1421
+ if (current.finalized) await terminalizeLostBacking(current, { deposit: 0n, settled: current.settledOnChain });
1231
1422
  const pending = await config.hostedObligations?.pendingTempoClose(channelId, drainId);
1232
1423
  const selfHostedReport = !config.hostedObligations && !!config.onSessionSettlement && !!store.claimCloseReference && !!store.getCloseRecovery;
1233
1424
  let reference = pending?.txHash ?? void 0;
@@ -1388,6 +1579,7 @@ function attachTempoSessionRuntime(mppx, config, chainOpOverrides = {}) {
1388
1579
  await terminalizeLostBacking(current, chainState);
1389
1580
  if (!await writeBackChainState(current, chainState)) continue;
1390
1581
  }
1582
+ if (current.finalized) await terminalizeLostBacking(current, { deposit: 0n, settled: current.settledOnChain });
1391
1583
  const found = await findFinalizedClose(current, stored, row.drainId, row.reservedAtMs, {
1392
1584
  from: row.searchBefore,
1393
1585
  obligationId: row.id,
@@ -1421,9 +1613,8 @@ function attachTempoSessionRuntime(mppx, config, chainOpOverrides = {}) {
1421
1613
  let receipt = isTempoCloseTransaction(txHash) ? await chainOps.readChannelClose(client, current.channelId, txHash) : void 0;
1422
1614
  const chainState = receipt ? { deposit: 0n, settled: receipt.settledToPayee, closeRequestedAt: 0 } : (await chainOps.getChannelStatesBatch(client, [current.channelId], current.escrowContract))[0];
1423
1615
  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;
1616
+ await protectFinalizedBacking(current, chainState, receipt ? { drain, refundedNative: receipt.refundedToPayer } : void 0, receipt !== void 0);
1617
+ let found = receipt && isTempoCloseTransaction(txHash) ? { outcome: "ours", txHash } : void 0;
1427
1618
  if (found?.outcome === "ours" && pending) {
1428
1619
  await config.hostedObligations.recordSubmitted(pending.id, found.txHash);
1429
1620
  }
@@ -1449,10 +1640,17 @@ function attachTempoSessionRuntime(mppx, config, chainOpOverrides = {}) {
1449
1640
  }
1450
1641
  let refundedNative;
1451
1642
  let reference;
1643
+ let backing;
1452
1644
  if (found.outcome === "ours") {
1453
1645
  reference = found.txHash;
1454
1646
  receipt ??= await chainOps.readChannelClose(client, current.channelId, found.txHash);
1455
1647
  refundedNative = receipt.refundedToPayer;
1648
+ await protectFinalizedBacking(current, chainState, { drain, refundedNative });
1649
+ await retainRefundEvidence(drain, refundedNative, false);
1650
+ backing = await terminalizeLostBacking(current, chainState, { outcome: "ours", receipt });
1651
+ if (!backing) {
1652
+ throw new Error("Tempo close-aware loss classification is unavailable; upgrade the ledger and retry");
1653
+ }
1456
1654
  if (!config.hostedObligations && config.onSessionSettlement && store.claimCloseReference) {
1457
1655
  const claimed = await store.claimCloseReference(drain.channelId, drain.drainId, found.txHash);
1458
1656
  if (claimed.claimed) {
@@ -1462,8 +1660,17 @@ function attachTempoSessionRuntime(mppx, config, chainOpOverrides = {}) {
1462
1660
  } else if (found.outcome === "exit") {
1463
1661
  const snapshot = stored;
1464
1662
  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");
1663
+ if (deposit <= 0n) {
1664
+ await terminalizeLostBacking(current, chainState, { outcome: "exit" });
1665
+ throw new TempoDrainRefundUnprovenError("collateral_missing", "The pre-exit collateral is unavailable");
1666
+ }
1466
1667
  refundedNative = deposit - maxBigInt(current.settledOnChain, chainState.settled);
1668
+ await protectFinalizedBacking(current, chainState, { drain, refundedNative });
1669
+ await retainRefundEvidence(drain, refundedNative, true);
1670
+ backing = await terminalizeLostBacking(current, chainState, { outcome: "exit" });
1671
+ if (!backing) {
1672
+ throw new Error("Tempo close-aware loss classification is unavailable; upgrade the ledger and retry");
1673
+ }
1467
1674
  reference = `channel:${current.channelId}`;
1468
1675
  } else {
1469
1676
  if (!config.hostedObligations && config.onSessionSettlement) {
@@ -1479,6 +1686,8 @@ function attachTempoSessionRuntime(mppx, config, chainOpOverrides = {}) {
1479
1686
  throw new TempoDrainRefundUnprovenError("refund_short", "Proven refund is below the drain debit");
1480
1687
  }
1481
1688
  const creditUnbacked = (await ledger.get(drain.creditId))?.status === "unbacked";
1689
+ const retainedLoss = backing?.losses.find((loss) => loss.creditId === drain.creditId);
1690
+ const builderLossNative = retainedLoss?.lossNative ?? (retainedLoss ? maxBigInt(0n, retainedLoss.highestVoucherNative - retainedLoss.settledOnChainNative - (retainedLoss.refundedToPayerNative ?? 0n)) : 0n);
1482
1691
  await store.confirmClose?.(drain.channelId, drain.drainId, reference);
1483
1692
  const changed = await finalizeDrain(drain, {
1484
1693
  reference,
@@ -1493,7 +1702,7 @@ function attachTempoSessionRuntime(mppx, config, chainOpOverrides = {}) {
1493
1702
  drain_id: drain.drainId,
1494
1703
  amount_at_stake_native: drain.drainedNative.toString(),
1495
1704
  refund_native: refundedNative.toString(),
1496
- builder_loss_native: (current.highestVoucherAmount > maxBigInt(current.settledOnChain, chainState.settled) ? current.highestVoucherAmount - maxBigInt(current.settledOnChain, chainState.settled) : 0n).toString()
1705
+ builder_loss_native: builderLossNative.toString()
1497
1706
  });
1498
1707
  }
1499
1708
  return { outcome: "completed" };
@@ -1501,6 +1710,11 @@ function attachTempoSessionRuntime(mppx, config, chainOpOverrides = {}) {
1501
1710
  let failure = error;
1502
1711
  if (error instanceof TempoDrainRefundUnprovenError) {
1503
1712
  try {
1713
+ try {
1714
+ await ledger.recordTempoDrainHold?.({ creditId: drain.creditId, drainId: drain.drainId, reason: error.reason });
1715
+ } catch {
1716
+ loggers.tempo.warn("tempo_drain_hold_reason_write_failed", { amount_micro: drain.amountMicro });
1717
+ }
1504
1718
  await store.update(drain.channelId, (current) => {
1505
1719
  if (!isStateRecord(current)) throw new Error("Channel history is unavailable");
1506
1720
  const key = JSON.stringify([drain.creditId, drain.drainId, error.reason]);
@@ -1536,20 +1750,21 @@ function attachTempoSessionRuntime(mppx, config, chainOpOverrides = {}) {
1536
1750
  const ledger = creditLedger;
1537
1751
  if (!ledger || !finalizeDrain) return;
1538
1752
  let after;
1753
+ const createdBeforeMs = Date.now();
1539
1754
  do {
1540
1755
  const page = await ledger.listChannelDrains({
1541
1756
  limit: 100,
1542
1757
  after,
1543
- createdBeforeMs: Date.now(),
1758
+ createdBeforeMs,
1544
1759
  rail: "tempo",
1545
1760
  includeResolved: true
1546
1761
  });
1762
+ const next = nextDrainCursor(page, after);
1547
1763
  for (const drain of page) {
1548
1764
  if (drain.status !== "exit_required" || !drain.channelId) continue;
1549
1765
  await reconcileFinalizedDrain(drain);
1550
1766
  }
1551
- const last = page.at(-1);
1552
- after = page.length === 100 && last ? { createdAt: last.createdAt, creditId: last.creditId, drainId: last.drainId } : void 0;
1767
+ after = next;
1553
1768
  } while (after);
1554
1769
  };
1555
1770
  const reconcileTempoSessionCloses = async () => {