From aaebdb6de30436d3eb68e97824c735c05fa2d364 Mon Sep 17 00:00:00 2001 From: Evan Kaloudis Date: Tue, 16 Jun 2026 16:33:10 -0400 Subject: [PATCH] feat: BalancePane: surface pending close balance alongside pending open --- backends/LdkNode.ts | 32 ++++++++++++--- stores/BalanceStore.ts | 11 ++++++ stores/ChannelsStore.ts | 11 +++++- stores/Stores.ts | 4 +- views/Wallet/BalancePane.tsx | 75 +++++++++++++++++++++++------------- 5 files changed, 97 insertions(+), 36 deletions(-) diff --git a/backends/LdkNode.ts b/backends/LdkNode.ts index bbdc840ab..30be81775 100644 --- a/backends/LdkNode.ts +++ b/backends/LdkNode.ts @@ -347,9 +347,6 @@ export default class LdkNode { pendingCloseBalance = pendingCloseBalance.plus(sb.amountSatoshis); } - const totalPendingBalance = - pendingOpenBalance.plus(pendingCloseBalance); - return { balance: localBalance.toString(), local_balance: { @@ -360,10 +357,10 @@ export default class LdkNode { sat: remoteBalance.toString(), msat: remoteBalance.times(1000).toString() }, - pending_open_balance: totalPendingBalance.toString(), + pending_open_balance: pendingOpenBalance.toString(), pending_open_local_balance: { - sat: totalPendingBalance.toString(), - msat: totalPendingBalance.times(1000).toString() + sat: pendingOpenBalance.toString(), + msat: pendingOpenBalance.times(1000).toString() } }; }; @@ -686,7 +683,30 @@ export default class LdkNode { } } + // total_limbo_balance: sats in close-side limbo (cooperative closes + // awaiting confirmation + force-close timelocks + waiting closes). + // Matches lnrpc.PendingChannelsResponse.total_limbo_balance semantics + // so ChannelsStore can read it uniformly across backends. + let totalLimboBalance = new BigNumber(0); + for (const lb of balances.lightningBalances) { + if (lb.type === 'claimableOnChannelClose') continue; + if (activeChannelIds.has(lb.channelId)) continue; + totalLimboBalance = totalLimboBalance.plus(lb.amountSatoshis); + } + for (const sb of balances.pendingBalancesFromChannelClosures) { + if (sb.channelId && activeChannelIds.has(sb.channelId)) continue; + if ( + sb.type === 'awaitingThresholdConfirmations' && + sb.confirmationHeight != null && + sb.confirmationHeight <= currentBlockHeight + ) { + continue; + } + totalLimboBalance = totalLimboBalance.plus(sb.amountSatoshis); + } + return { + total_limbo_balance: totalLimboBalance.toString(), pending_open_channels: pendingOpen.map((channel) => ({ channel: this.formatChannel(channel) })), diff --git a/stores/BalanceStore.ts b/stores/BalanceStore.ts index 9a119e1be..568ce5a7b 100644 --- a/stores/BalanceStore.ts +++ b/stores/BalanceStore.ts @@ -14,6 +14,11 @@ export default class BalanceStore { @observable public loadingLightningBalance = false; @observable public error = false; @observable public pendingOpenBalance: number | string | any; + // Sats locked up in pending close, force close, and waiting close + // channels — i.e. funds in close-side limbo. Mirrors pendingOpenBalance + // semantics but for the closing side. Populated by ChannelsStore from + // PendingChannelsResponse.total_limbo_balance (or the LDK-node analogue). + @observable public pendingCloseBalance: number | string | any; @observable public lightningBalance: number | string; @observable public otherAccounts: any = {}; settingsStore: SettingsStore; @@ -50,10 +55,16 @@ export default class BalanceStore { private resetLightningBalance = () => { this.pendingOpenBalance = 0; + this.pendingCloseBalance = 0; this.lightningBalance = 0; this.loadingLightningBalance = false; }; + @action + public setPendingCloseBalance = (value: number | string) => { + this.pendingCloseBalance = Number(value || 0); + }; + @action private balanceError = () => { this.error = true; diff --git a/stores/ChannelsStore.ts b/stores/ChannelsStore.ts index 85e4e1d9a..d58d256f8 100644 --- a/stores/ChannelsStore.ts +++ b/stores/ChannelsStore.ts @@ -13,6 +13,7 @@ import Peer from '../models/Peer'; import OpenChannelRequest from '../models/OpenChannelRequest'; import CloseChannelRequest from '../models/CloseChannelRequest'; +import BalanceStore from './BalanceStore'; import SettingsStore from './SettingsStore'; import BackendUtils from '../utils/BackendUtils'; @@ -115,9 +116,11 @@ export default class ChannelsStore { @observable public haveAnnouncedChannels = false; settingsStore: SettingsStore; + balanceStore: BalanceStore; - constructor(settingsStore: SettingsStore) { + constructor(settingsStore: SettingsStore, balanceStore: BalanceStore) { this.settingsStore = settingsStore; + this.balanceStore = balanceStore; reaction( () => this.channelRequest, @@ -598,6 +601,12 @@ export default class ChannelsStore { .concat(pendingCloseChannels) .concat(forceCloseChannels) .concat(waitCloseChannels); + // Push close-side limbo (cooperative close + force-close + // timelocks + waiting-close confirmations) to BalanceStore + // so BalancePane can render it next to pendingOpenBalance. + this.balanceStore.setPendingCloseBalance( + data.total_limbo_balance || 0 + ); }) ); } diff --git a/stores/Stores.ts b/stores/Stores.ts index 73807901c..9487dc17b 100644 --- a/stores/Stores.ts +++ b/stores/Stores.ts @@ -34,7 +34,8 @@ export const connectivityStore = new ConnectivityStore(settingsStore); export const modalStore = new ModalStore(); export const offersStore = new OffersStore(); export const fiatStore = new FiatStore(settingsStore); -export const channelsStore = new ChannelsStore(settingsStore); +export const balanceStore = new BalanceStore(settingsStore); +export const channelsStore = new ChannelsStore(settingsStore, balanceStore); export const nodeInfoStore = new NodeInfoStore(channelsStore, settingsStore); export const alertStore = new AlertStore(settingsStore, nodeInfoStore); export const lspStore = new LSPStore( @@ -52,7 +53,6 @@ export const invoicesStore = new InvoicesStore( channelsStore, nodeInfoStore ); -export const balanceStore = new BalanceStore(settingsStore); export const transactionsStore = new TransactionsStore( settingsStore, nodeInfoStore, diff --git a/views/Wallet/BalancePane.tsx b/views/Wallet/BalancePane.tsx index c3a8519a5..4075f6e4e 100644 --- a/views/Wallet/BalancePane.tsx +++ b/views/Wallet/BalancePane.tsx @@ -182,13 +182,19 @@ export default class BalancePane extends React.PureComponent< totalBlockchainBalance, unconfirmedBlockchainBalance, lightningBalance, - pendingOpenBalance + pendingOpenBalance, + pendingCloseBalance } = BalanceStore; const cashuBalance = CashuStore.totalBalanceSats; const cashuOfflinePendingBalance = CashuStore.offlinePendingBalance; const { implementation, settings, lndFolderMissing } = SettingsStore; - const pendingUnconfirmedBalance = new BigNumber(pendingOpenBalance) + const lightningPendingTotal = new BigNumber(pendingOpenBalance || 0) + .plus(pendingCloseBalance || 0) + .toNumber(); + const hasLightningPending = lightningPendingTotal > 0; + + const pendingUnconfirmedBalance = new BigNumber(lightningPendingTotal) .plus(unconfirmedBlockchainBalance) .toNumber() .toFixed(3); @@ -206,40 +212,55 @@ export default class BalancePane extends React.PureComponent< jumboText toggleable /> - {!(pendingOpenBalance > 0) && ( + {!hasLightningPending && ( )} - {pendingOpenBalance > 0 ? ( - <> - - - this.handlePendingPress('lightning') - } - /> - - - - - - ) : null} + {pendingOpenBalance > 0 && ( + + + this.handlePendingPress('lightning') + } + /> + + )} + {pendingCloseBalance > 0 && ( + + + this.handlePendingPress('lightning') + } + /> + + )} + {hasLightningPending && ( + + + + )} ); const BalanceViewCombined = () => { const hasOnchainPending = Boolean( Number(unconfirmedBlockchainBalance ?? 0) || - Number(pendingOpenBalance ?? 0) + Number(pendingOpenBalance ?? 0) || + Number(pendingCloseBalance ?? 0) ); const hasAnyPending = hasOnchainPending; const renderPendingBalance = (sats: string | number) => (