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