import dayjs from "dayjs"; import { AlertTriangleIcon, ArrowDownUpIcon, ArrowRightIcon, CopyIcon, HeartIcon, InfoIcon, LinkIcon, Settings2Icon, UnplugIcon, ZapIcon, } from "lucide-react"; import React from "react"; import { Link, useNavigate } from "react-router"; import AppHeader from "src/components/AppHeader.tsx"; import { ChannelsCards } from "src/components/channels/ChannelsCards.tsx"; import { ChannelsTable } from "src/components/channels/ChannelsTable.tsx"; import { HealthCheckAlert } from "src/components/channels/HealthcheckAlert"; import { LDKChannelMonitorSizeAlert } from "src/components/channels/LDKChannelMonitorSizeAlert"; import { LDKChannelWithoutPeerAlert } from "src/components/channels/LDKChannelWithoutPeerAlert"; import EmptyState from "src/components/EmptyState.tsx"; import ExternalLink from "src/components/ExternalLink"; import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount"; import FormattedFiatAmount from "src/components/FormattedFiatAmount"; import LowReceivingCapacityAlert from "src/components/LowReceivingCapacityAlert"; import { PendingClosedChannelsAlert } from "src/components/PendingClosedChannelsAlert"; import ResponsiveButton from "src/components/ResponsiveButton"; import { Card, CardContent, CardHeader, CardTitle, } from "src/components/ui/card.tsx"; import CircleProgress from "src/components/ui/custom/circle-progress"; import { LinkButton } from "src/components/ui/custom/link-button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "src/components/ui/dropdown-menu.tsx"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "src/components/ui/tooltip.tsx"; import { ProDropdownMenuItem } from "src/components/UpgradeDialog"; import { ONCHAIN_DUST_SATS } from "src/constants.ts"; import { useBalances } from "src/hooks/useBalances.ts"; import { useChannels } from "src/hooks/useChannels"; import { useInfo } from "src/hooks/useInfo"; import { useNodeConnectionInfo } from "src/hooks/useNodeConnectionInfo.ts"; import { useSyncWallet } from "src/hooks/useSyncWallet.ts"; import { copyToClipboard } from "src/lib/clipboard.ts"; import { cn } from "src/lib/utils.ts"; import { Channel, LongUnconfirmedZeroConfChannel, MempoolTransaction, } from "src/types"; import { request } from "src/utils/request"; export default function Channels() { useSyncWallet(); const { data: channels } = useChannels(); const { data: nodeConnectionInfo } = useNodeConnectionInfo(); const { data: info, hasChannelManagement } = useInfo(); const { data: balances } = useBalances(true); const navigate = useNavigate(); const [longUnconfirmedZeroConfChannels, setLongUnconfirmedZeroConfChannels] = React.useState([]); const nodeHealth = channels ? getNodeHealth(channels) : 0; const findUnconfirmedChannels = React.useCallback(async () => { if (!channels) { return []; } const _longUnconfirmedZeroConfChannels: LongUnconfirmedZeroConfChannel[] = []; for (const channel of channels) { // only check for unconfirmed 0-conf active channels if ( channel.status !== "online" || channel.confirmationsRequired !== 0 || !!channel.confirmations ) { continue; } try { const mempoolTransactionResponse = await request( `/api/mempool?endpoint=/tx/${channel.fundingTxId}` ); if (!mempoolTransactionResponse) { throw new Error("No response"); } if (mempoolTransactionResponse.status.confirmed) { continue; } // see if the transaction is in the mempool, and has been for how long const unconfirmedTransactionTimeResponse = await request( `/api/mempool?endpoint=/v1/transaction-times?txId[]=${channel.fundingTxId}` ); if (!unconfirmedTransactionTimeResponse?.length) { throw new Error("No response"); } const timestamp = unconfirmedTransactionTimeResponse[0]; const unconfirmedHours = dayjs().diff(timestamp * 1000, "hours"); if (unconfirmedHours === 0) { // channel has been unconfirmed for less than an hour continue; } _longUnconfirmedZeroConfChannels.push({ id: channel.id, message: "Unconfirmed for " + unconfirmedHours + " hours", }); } catch { _longUnconfirmedZeroConfChannels.push({ id: channel.id, message: "Channel transaction not in the mempool yet", }); } } setLongUnconfirmedZeroConfChannels(_longUnconfirmedZeroConfChannels); }, [channels]); React.useEffect(() => { findUnconfirmedChannels(); }, [findUnconfirmedChannels]); return ( <> Node
{ if (!nodeConnectionInfo) { return; } copyToClipboard(nodeConnectionInfo.pubkey); }} >
Public key
{nodeConnectionInfo?.pubkey || "Loading..."}
{nodeConnectionInfo && ( )}
{nodeConnectionInfo?.address && nodeConnectionInfo?.port && (
{ const connectionAddress = `${nodeConnectionInfo.pubkey}@${nodeConnectionInfo.address}:${nodeConnectionInfo.port}`; copyToClipboard(connectionAddress); }} >
URI
{nodeConnectionInfo.pubkey.substring(0, 6)}...@ {nodeConnectionInfo.address}: {nodeConnectionInfo.port}
)}
On-Chain Buy Deposit {(balances?.onchain.spendableSat || 0) > ONCHAIN_DUST_SATS && ( navigate("/wallet/withdraw")} > Withdraw )} {hasChannelManagement && ( Swaps navigate("/wallet/swap?type=in")} className="cursor-pointer" >
Swap in
navigate("/wallet/swap?type=out")} className="cursor-pointer" >
Swap out
)} Management Connected Peers Sign Message {info?.backendType === "LDK" && ( navigate("/wallet/node-alias")} > Set Node Alias )}
Swap Open Channel {nodeHealth === 100 && (
)} Node health: {nodeHealth}%
) } >
{hasChannelManagement && ( <> {!!channels?.length && ( <> {/* If all channels have less than 20% incoming capacity, show a warning */} {!( info?.jitChannelsEnabled && info?.jitChannelsLiquiditySource ) && channels?.every( (channel) => channel.remoteBalanceMsat < (channel.localBalanceMsat + channel.remoteBalanceMsat) * 0.2 ) && } )}
Lightning
Balance
Your lightning balance is the spendable funds on your side of your channels. Your total channel balance is{" "} channel.localBalanceMsat) .reduce((a, b) => a + b, 0) || 0 } />{" "} which includes{" "} Math.min( channel.localBalanceMsat, channel.unspendablePunishmentReserveSat * 1000 ) ) .reduce((a, b) => a + b, 0) || 0 } />{" "} reserved in your channels which cannot be spent.
{!balances && (
)} {balances && ( <>
)}
Receive Limit
Your receiving limit is the funds owned by your channel partner, which will be moved to your side when you receive lightning payments.
{balances && ( <>
)}
On-Chain
Balance
Your on-chain balance can be used to open new outgoing lightning channels and to ensure channels can be closed when required. When channels are closed, funds on your side of your channel will be returned to your on-chain balance.
{!balances && (
)}
{balances && ( <>
{!!channels?.length && balances.onchain.reservedSat + balances.onchain.spendableSat < 25_000 && ( You have insufficient funds in reserve to close channels or bump on-chain transactions and currently rely on the counterparty. It is recommended to deposit at least{" "} {" "} to your on-chain balance. )}
{balances.onchain.totalSat > balances.onchain.spendableSat && (

+ {" "} incoming

)} )}
{balances && ( )} {channels && channels.length === 0 && ( )} )} ); } function getNodeHealth(channels: Channel[]) { const totalChannelCapacitySat = channels .map( (channel) => (channel.localBalanceMsat + channel.remoteBalanceMsat) / 1000 ) .reduce((a, b) => a + b, 0); const averageChannelBalance = channels .map((channel) => { const totalBalanceMsat = channel.localBalanceMsat + channel.remoteBalanceMsat; const expectedBalanceMsat = totalBalanceMsat / 2; const actualBalance = Math.min(channel.localBalanceMsat, channel.remoteBalanceMsat) / expectedBalanceMsat; return actualBalance; }) .reduce((a, b) => a + b, 0) / (channels.length || 1); const numUniqueChannelPartners = new Set( channels.map((channel) => channel.remotePubkey) ).size; const nodeHealth = Math.ceil( numUniqueChannelPartners * (100 / 2) * // 2 or more channels is great (Math.min(totalChannelCapacitySat, 1_000_000) / 1_000_000) * // 1 million sats or more is great (0.9 + averageChannelBalance * 0.1) // +10% for perfectly balanced channels ); if (nodeHealth > 95) { // prevent OCD return 100; } return nodeHealth; }