Channels.tsx raw
1 import dayjs from "dayjs";
2 import {
3 AlertTriangleIcon,
4 ArrowDownUpIcon,
5 ArrowRightIcon,
6 CopyIcon,
7 HeartIcon,
8 InfoIcon,
9 LinkIcon,
10 Settings2Icon,
11 UnplugIcon,
12 ZapIcon,
13 } from "lucide-react";
14 import React from "react";
15 import { Link, useNavigate } from "react-router";
16 import AppHeader from "src/components/AppHeader.tsx";
17 import { ChannelsCards } from "src/components/channels/ChannelsCards.tsx";
18 import { ChannelsTable } from "src/components/channels/ChannelsTable.tsx";
19 import { HealthCheckAlert } from "src/components/channels/HealthcheckAlert";
20 import { LDKChannelMonitorSizeAlert } from "src/components/channels/LDKChannelMonitorSizeAlert";
21 import { LDKChannelWithoutPeerAlert } from "src/components/channels/LDKChannelWithoutPeerAlert";
22 import EmptyState from "src/components/EmptyState.tsx";
23 import ExternalLink from "src/components/ExternalLink";
24 import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount";
25 import FormattedFiatAmount from "src/components/FormattedFiatAmount";
26 import LowReceivingCapacityAlert from "src/components/LowReceivingCapacityAlert";
27 import { PendingClosedChannelsAlert } from "src/components/PendingClosedChannelsAlert";
28 import ResponsiveButton from "src/components/ResponsiveButton";
29 import {
30 Card,
31 CardContent,
32 CardHeader,
33 CardTitle,
34 } from "src/components/ui/card.tsx";
35 import CircleProgress from "src/components/ui/custom/circle-progress";
36 import { LinkButton } from "src/components/ui/custom/link-button";
37 import {
38 DropdownMenu,
39 DropdownMenuContent,
40 DropdownMenuGroup,
41 DropdownMenuItem,
42 DropdownMenuLabel,
43 DropdownMenuSeparator,
44 DropdownMenuTrigger,
45 } from "src/components/ui/dropdown-menu.tsx";
46 import {
47 Tooltip,
48 TooltipContent,
49 TooltipProvider,
50 TooltipTrigger,
51 } from "src/components/ui/tooltip.tsx";
52 import { ProDropdownMenuItem } from "src/components/UpgradeDialog";
53 import { ONCHAIN_DUST_SATS } from "src/constants.ts";
54 import { useBalances } from "src/hooks/useBalances.ts";
55 import { useChannels } from "src/hooks/useChannels";
56 import { useInfo } from "src/hooks/useInfo";
57 import { useNodeConnectionInfo } from "src/hooks/useNodeConnectionInfo.ts";
58 import { useSyncWallet } from "src/hooks/useSyncWallet.ts";
59 import { copyToClipboard } from "src/lib/clipboard.ts";
60 import { cn } from "src/lib/utils.ts";
61 import {
62 Channel,
63 LongUnconfirmedZeroConfChannel,
64 MempoolTransaction,
65 } from "src/types";
66 import { request } from "src/utils/request";
67
68 export default function Channels() {
69 useSyncWallet();
70 const { data: channels } = useChannels();
71 const { data: nodeConnectionInfo } = useNodeConnectionInfo();
72 const { data: info, hasChannelManagement } = useInfo();
73 const { data: balances } = useBalances(true);
74 const navigate = useNavigate();
75 const [longUnconfirmedZeroConfChannels, setLongUnconfirmedZeroConfChannels] =
76 React.useState<LongUnconfirmedZeroConfChannel[]>([]);
77
78 const nodeHealth = channels ? getNodeHealth(channels) : 0;
79
80 const findUnconfirmedChannels = React.useCallback(async () => {
81 if (!channels) {
82 return [];
83 }
84
85 const _longUnconfirmedZeroConfChannels: LongUnconfirmedZeroConfChannel[] =
86 [];
87 for (const channel of channels) {
88 // only check for unconfirmed 0-conf active channels
89 if (
90 channel.status !== "online" ||
91 channel.confirmationsRequired !== 0 ||
92 !!channel.confirmations
93 ) {
94 continue;
95 }
96 try {
97 const mempoolTransactionResponse = await request<MempoolTransaction>(
98 `/api/mempool?endpoint=/tx/${channel.fundingTxId}`
99 );
100 if (!mempoolTransactionResponse) {
101 throw new Error("No response");
102 }
103 if (mempoolTransactionResponse.status.confirmed) {
104 continue;
105 }
106 // see if the transaction is in the mempool, and has been for how long
107 const unconfirmedTransactionTimeResponse = await request<number[]>(
108 `/api/mempool?endpoint=/v1/transaction-times?txId[]=${channel.fundingTxId}`
109 );
110 if (!unconfirmedTransactionTimeResponse?.length) {
111 throw new Error("No response");
112 }
113
114 const timestamp = unconfirmedTransactionTimeResponse[0];
115 const unconfirmedHours = dayjs().diff(timestamp * 1000, "hours");
116 if (unconfirmedHours === 0) {
117 // channel has been unconfirmed for less than an hour
118 continue;
119 }
120
121 _longUnconfirmedZeroConfChannels.push({
122 id: channel.id,
123 message: "Unconfirmed for " + unconfirmedHours + " hours",
124 });
125 } catch {
126 _longUnconfirmedZeroConfChannels.push({
127 id: channel.id,
128 message: "Channel transaction not in the mempool yet",
129 });
130 }
131 }
132 setLongUnconfirmedZeroConfChannels(_longUnconfirmedZeroConfChannels);
133 }, [channels]);
134
135 React.useEffect(() => {
136 findUnconfirmedChannels();
137 }, [findUnconfirmedChannels]);
138
139 return (
140 <>
141 <AppHeader
142 title="Node"
143 pageTitle="Node"
144 contentRight={
145 hasChannelManagement && (
146 <div className="flex gap-3 items-center justify-center">
147 <DropdownMenu modal={false}>
148 <ResponsiveButton
149 asChild
150 icon={Settings2Icon}
151 text="Advanced"
152 variant="secondary"
153 >
154 <DropdownMenuTrigger />
155 </ResponsiveButton>
156 <DropdownMenuContent className="w-56">
157 <DropdownMenuGroup>
158 <DropdownMenuLabel>Node</DropdownMenuLabel>
159 <DropdownMenuItem>
160 <div
161 className="flex flex-row gap-2 items-center w-full cursor-pointer"
162 onClick={() => {
163 if (!nodeConnectionInfo) {
164 return;
165 }
166 copyToClipboard(nodeConnectionInfo.pubkey);
167 }}
168 >
169 <div>Public key</div>
170 <div className="overflow-hidden text-ellipsis flex-1 text-muted-foreground text-xs">
171 {nodeConnectionInfo?.pubkey || "Loading..."}
172 </div>
173 {nodeConnectionInfo && (
174 <CopyIcon className="shrink-0 size-4" />
175 )}
176 </div>
177 </DropdownMenuItem>
178 {nodeConnectionInfo?.address &&
179 nodeConnectionInfo?.port && (
180 <DropdownMenuItem>
181 <div
182 className="flex flex-row gap-2 items-center w-full cursor-pointer"
183 onClick={() => {
184 const connectionAddress = `${nodeConnectionInfo.pubkey}@${nodeConnectionInfo.address}:${nodeConnectionInfo.port}`;
185 copyToClipboard(connectionAddress);
186 }}
187 >
188 <div>URI</div>
189 <div className="overflow-hidden text-ellipsis flex-1 text-muted-foreground text-xs">
190 {nodeConnectionInfo.pubkey.substring(0, 6)}...@
191 {nodeConnectionInfo.address}:
192 {nodeConnectionInfo.port}
193 </div>
194 <CopyIcon className="shrink-0 size-4" />
195 </div>
196 </DropdownMenuItem>
197 )}
198 </DropdownMenuGroup>
199 <DropdownMenuSeparator />
200 <DropdownMenuGroup>
201 <DropdownMenuLabel>On-Chain</DropdownMenuLabel>
202 <DropdownMenuItem asChild>
203 <Link
204 to="/channels/onchain/buy-bitcoin"
205 className="w-full"
206 >
207 Buy
208 </Link>
209 </DropdownMenuItem>
210 <DropdownMenuItem asChild>
211 <Link
212 to="/channels/onchain/deposit-bitcoin"
213 className="w-full"
214 >
215 Deposit
216 </Link>
217 </DropdownMenuItem>
218 {(balances?.onchain.spendableSat || 0) >
219 ONCHAIN_DUST_SATS && (
220 <DropdownMenuItem
221 onClick={() => navigate("/wallet/withdraw")}
222 >
223 Withdraw
224 </DropdownMenuItem>
225 )}
226 </DropdownMenuGroup>
227 <DropdownMenuSeparator />
228 {hasChannelManagement && (
229 <DropdownMenuGroup>
230 <DropdownMenuLabel>Swaps</DropdownMenuLabel>
231 <DropdownMenuItem
232 onClick={() => navigate("/wallet/swap?type=in")}
233 className="cursor-pointer"
234 >
235 <div className="mr-2 text-muted-foreground flex flex-row items-center">
236 <LinkIcon className="size-4" />
237 <ArrowRightIcon className="size-4" />
238 <ZapIcon className="size-4" />
239 </div>
240 Swap in
241 </DropdownMenuItem>
242 <DropdownMenuItem
243 onClick={() => navigate("/wallet/swap?type=out")}
244 className="cursor-pointer"
245 >
246 <div className="mr-2 text-muted-foreground flex flex-row items-center">
247 <ZapIcon className="size-4" />
248 <ArrowRightIcon className="size-4" />
249 <LinkIcon className="size-4" />
250 </div>
251 Swap out
252 </DropdownMenuItem>
253 </DropdownMenuGroup>
254 )}
255 <DropdownMenuSeparator />
256 <DropdownMenuGroup>
257 <DropdownMenuLabel>Management</DropdownMenuLabel>
258 <DropdownMenuItem>
259 <Link className="w-full" to="/peers">
260 Connected Peers
261 </Link>
262 </DropdownMenuItem>
263 <DropdownMenuItem>
264 <Link className="w-full" to="/wallet/sign-message">
265 Sign Message
266 </Link>
267 </DropdownMenuItem>
268 {info?.backendType === "LDK" && (
269 <ProDropdownMenuItem
270 onClick={() => navigate("/wallet/node-alias")}
271 >
272 Set Node Alias
273 </ProDropdownMenuItem>
274 )}
275 </DropdownMenuGroup>
276 </DropdownMenuContent>
277 </DropdownMenu>
278
279 <LinkButton
280 to="/wallet/swap"
281 variant="secondary"
282 className="hidden sm:flex"
283 >
284 <ArrowDownUpIcon />
285 Swap
286 </LinkButton>
287 <LinkButton to="/channels/incoming">Open Channel</LinkButton>
288 <TooltipProvider>
289 <Tooltip>
290 <TooltipTrigger asChild>
291 <ExternalLink to="https://guides.getalby.com/user-guide/alby-hub/node/node-health">
292 <CircleProgress
293 value={nodeHealth}
294 className="w-9 h-9 relative"
295 >
296 {nodeHealth === 100 && (
297 <div className="absolute w-full h-full opacity-20">
298 <div className="absolute w-full h-full bg-primary animate-pulse" />
299 </div>
300 )}
301 <HeartIcon
302 className="size-4"
303 stroke={"var(--color-primary)"}
304 strokeWidth={3}
305 fill={
306 nodeHealth === 100
307 ? "var(--color-primary)"
308 : "transparent"
309 }
310 />
311 </CircleProgress>
312 </ExternalLink>
313 </TooltipTrigger>
314 <TooltipContent>Node health: {nodeHealth}%</TooltipContent>
315 </Tooltip>
316 </TooltipProvider>
317 </div>
318 )
319 }
320 ></AppHeader>
321
322 <HealthCheckAlert />
323
324 {hasChannelManagement && (
325 <>
326 {!!channels?.length && (
327 <>
328 {/* If all channels have less than 20% incoming capacity, show a warning */}
329 {!(
330 info?.jitChannelsEnabled && info?.jitChannelsLiquiditySource
331 ) &&
332 channels?.every(
333 (channel) =>
334 channel.remoteBalanceMsat <
335 (channel.localBalanceMsat + channel.remoteBalanceMsat) * 0.2
336 ) && <LowReceivingCapacityAlert />}
337 </>
338 )}
339
340 <div
341 className={cn(
342 "flex flex-col sm:flex-row flex-wrap gap-3 slashed-zero"
343 )}
344 >
345 <Card className="flex flex-1 sm:flex-2 flex-col">
346 <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
347 <CardTitle className="font-semibold text-2xl">
348 Lightning
349 </CardTitle>
350 <ZapIcon className="h-6 w-6 text-muted-foreground" />
351 </CardHeader>
352
353 <CardContent className="flex flex-col sm:flex-row pl-0 flex-wrap">
354 <div className="flex flex-col flex-1">
355 <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-1 pr-0">
356 <CardTitle className="text-sm font-medium">
357 <TooltipProvider>
358 <Tooltip>
359 <TooltipTrigger>
360 <div className="flex flex-row gap-1 items-center justify-start text-sm font-medium">
361 Balance
362 <InfoIcon className="h-3 w-3 shrink-0 text-muted-foreground" />
363 </div>
364 </TooltipTrigger>
365 <TooltipContent>
366 Your lightning balance is the spendable funds on
367 your side of your channels. Your total channel
368 balance is{" "}
369 <FormattedBitcoinAmount
370 amountMsat={
371 channels
372 ?.map((channel) => channel.localBalanceMsat)
373 .reduce((a, b) => a + b, 0) || 0
374 }
375 />{" "}
376 which includes{" "}
377 <FormattedBitcoinAmount
378 amountMsat={
379 channels
380 ?.map((channel) =>
381 Math.min(
382 channel.localBalanceMsat,
383 channel.unspendablePunishmentReserveSat *
384 1000
385 )
386 )
387 .reduce((a, b) => a + b, 0) || 0
388 }
389 />{" "}
390 reserved in your channels which cannot be spent.
391 </TooltipContent>
392 </Tooltip>
393 </TooltipProvider>
394 </CardTitle>
395 </CardHeader>
396 <CardContent className="grow pb-0">
397 {!balances && (
398 <div>
399 <div className="animate-pulse d-inline ">
400 <div className="h-2.5 bg-primary rounded-full w-12 my-2"></div>
401 </div>
402 </div>
403 )}
404 {balances && (
405 <>
406 <div className="text-xl font-medium balance sensitive mb-1">
407 <FormattedBitcoinAmount
408 amountMsat={balances.lightning.totalSpendableMsat}
409 />
410 </div>
411 <FormattedFiatAmount
412 amountSat={balances.lightning.totalSpendableSat}
413 />
414 </>
415 )}
416 </CardContent>
417 </div>
418 <div className="flex flex-col flex-1">
419 <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-1 pr-0">
420 <CardTitle className="text-sm font-medium">
421 <TooltipProvider>
422 <Tooltip>
423 <TooltipTrigger>
424 <div className="flex flex-row gap-1 items-center justify-start text-sm font-medium">
425 Receive Limit
426 <InfoIcon className="h-3 w-3 shrink-0 text-muted-foreground" />
427 </div>
428 </TooltipTrigger>
429 <TooltipContent>
430 Your receiving limit is the funds owned by your
431 channel partner, which will be moved to your side
432 when you receive lightning payments.
433 </TooltipContent>
434 </Tooltip>
435 </TooltipProvider>
436 </CardTitle>
437 </CardHeader>
438 <CardContent className="grow pb-0">
439 {balances && (
440 <>
441 <div className="text-xl font-medium balance sensitive mb-1">
442 <FormattedBitcoinAmount
443 amountMsat={balances.lightning.totalReceivableMsat}
444 />
445 </div>
446 <FormattedFiatAmount
447 amountSat={balances.lightning.totalReceivableSat}
448 />
449 </>
450 )}
451 </CardContent>
452 </div>
453 </CardContent>
454 </Card>
455 <Card className="flex flex-1 flex-col">
456 <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
457 <CardTitle className="text-2xl font-semibold">
458 On-Chain
459 </CardTitle>
460 <LinkIcon className="h-6 w-6 text-muted-foreground" />
461 </CardHeader>
462 <CardContent className="grow">
463 <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-1 pl-0">
464 <CardTitle className="text-sm font-medium">
465 <TooltipProvider>
466 <Tooltip>
467 <TooltipTrigger>
468 <div className="flex flex-row gap-1 items-center text-sm font-medium">
469 Balance
470 <InfoIcon className="h-3 w-3 shrink-0 text-muted-foreground" />
471 </div>
472 </TooltipTrigger>
473 <TooltipContent>
474 Your on-chain balance can be used to open new outgoing
475 lightning channels and to ensure channels can be
476 closed when required. When channels are closed, funds
477 on your side of your channel will be returned to your
478 on-chain balance.
479 </TooltipContent>
480 </Tooltip>
481 </TooltipProvider>
482 </CardTitle>
483 </CardHeader>
484 {!balances && (
485 <div>
486 <div className="animate-pulse d-inline ">
487 <div className="h-2.5 bg-primary rounded-full w-12 my-2"></div>
488 </div>
489 </div>
490 )}
491 <div>
492 {balances && (
493 <>
494 <div className="mb-1">
495 <span className="mr-1 text-xl font-medium balance sensitive">
496 <FormattedBitcoinAmount
497 amountMsat={balances.onchain.spendableSat * 1000}
498 />
499 </span>
500 {!!channels?.length &&
501 balances.onchain.reservedSat +
502 balances.onchain.spendableSat <
503 25_000 && (
504 <TooltipProvider>
505 <Tooltip>
506 <TooltipTrigger>
507 <AlertTriangleIcon className="size-3 text-muted-foreground" />
508 </TooltipTrigger>
509 <TooltipContent>
510 You have insufficient funds in reserve to
511 close channels or bump on-chain transactions
512 and currently rely on the counterparty. It is
513 recommended to deposit at least{" "}
514 <FormattedBitcoinAmount
515 amountMsat={25_000 * 1000}
516 />{" "}
517 to your on-chain balance.
518 </TooltipContent>
519 </Tooltip>
520 </TooltipProvider>
521 )}
522 </div>
523 <FormattedFiatAmount
524 amountSat={balances.onchain.spendableSat}
525 className="mb-1"
526 />
527 {balances.onchain.totalSat >
528 balances.onchain.spendableSat && (
529 <p className="text-xs text-muted-foreground animate-pulse">
530 +
531 <FormattedBitcoinAmount
532 amountMsat={
533 (balances.onchain.totalSat -
534 balances.onchain.spendableSat) *
535 1000
536 }
537 />{" "}
538 incoming
539 </p>
540 )}
541 </>
542 )}
543 </div>
544 </CardContent>
545 </Card>
546 </div>
547
548 {balances && (
549 <PendingClosedChannelsAlert balance={balances.onchain} />
550 )}
551
552 {channels && channels.length === 0 && (
553 <EmptyState
554 icon={UnplugIcon}
555 title="No Channels Available"
556 description="Connect to the Lightning Network by establishing your first channel and start transacting."
557 variant="dashed"
558 buttonText="Open Channel"
559 buttonLink="/channels/incoming"
560 />
561 )}
562
563 <LDKChannelMonitorSizeAlert />
564 <LDKChannelWithoutPeerAlert />
565
566 <ChannelsTable
567 channels={channels}
568 longUnconfirmedZeroConfChannels={longUnconfirmedZeroConfChannels}
569 />
570 <ChannelsCards
571 channels={channels}
572 longUnconfirmedZeroConfChannels={longUnconfirmedZeroConfChannels}
573 />
574 </>
575 )}
576 </>
577 );
578 }
579
580 function getNodeHealth(channels: Channel[]) {
581 const totalChannelCapacitySat = channels
582 .map(
583 (channel) => (channel.localBalanceMsat + channel.remoteBalanceMsat) / 1000
584 )
585 .reduce((a, b) => a + b, 0);
586 const averageChannelBalance =
587 channels
588 .map((channel) => {
589 const totalBalanceMsat =
590 channel.localBalanceMsat + channel.remoteBalanceMsat;
591 const expectedBalanceMsat = totalBalanceMsat / 2;
592 const actualBalance =
593 Math.min(channel.localBalanceMsat, channel.remoteBalanceMsat) /
594 expectedBalanceMsat;
595 return actualBalance;
596 })
597 .reduce((a, b) => a + b, 0) / (channels.length || 1);
598
599 const numUniqueChannelPartners = new Set(
600 channels.map((channel) => channel.remotePubkey)
601 ).size;
602
603 const nodeHealth = Math.ceil(
604 numUniqueChannelPartners *
605 (100 / 2) * // 2 or more channels is great
606 (Math.min(totalChannelCapacitySat, 1_000_000) / 1_000_000) * // 1 million sats or more is great
607 (0.9 + averageChannelBalance * 0.1) // +10% for perfectly balanced channels
608 );
609
610 if (nodeHealth > 95) {
611 // prevent OCD
612 return 100;
613 }
614
615 return nodeHealth;
616 }
617