IncreaseOutgoingCapacity.tsx raw

   1  import { InfoIcon } from "lucide-react";
   2  import React, { FormEvent } from "react";
   3  import { Link, useNavigate } from "react-router";
   4  import { toast } from "sonner";
   5  import AppHeader from "src/components/AppHeader";
   6  import { ChannelPeerNote } from "src/components/channels/ChannelPeerNote";
   7  import { ChannelPublicPrivateAlert } from "src/components/channels/ChannelPublicPrivateAlert";
   8  import { DuplicateChannelAlert } from "src/components/channels/DuplicateChannelAlert";
   9  import { SwapAlert } from "src/components/channels/SwapAlert";
  10  import ExternalLink from "src/components/ExternalLink";
  11  import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount";
  12  import Loading from "src/components/Loading";
  13  import { MempoolAlert } from "src/components/MempoolAlert";
  14  import { Alert, AlertDescription } from "src/components/ui/alert";
  15  import { Button } from "src/components/ui/button";
  16  import { Checkbox } from "src/components/ui/checkbox";
  17  import { ExternalLinkButton } from "src/components/ui/custom/external-link-button";
  18  import { LinkButton } from "src/components/ui/custom/link-button";
  19  import {
  20    Dialog,
  21    DialogContent,
  22    DialogDescription,
  23    DialogFooter,
  24    DialogHeader,
  25    DialogTitle,
  26  } from "src/components/ui/dialog";
  27  import { Input } from "src/components/ui/input";
  28  import { Label } from "src/components/ui/label";
  29  import {
  30    Select,
  31    SelectContent,
  32    SelectItem,
  33    SelectTrigger,
  34    SelectValue,
  35  } from "src/components/ui/select";
  36  import {
  37    Tooltip,
  38    TooltipContent,
  39    TooltipProvider,
  40    TooltipTrigger,
  41  } from "src/components/ui/tooltip";
  42  import { useBalances } from "src/hooks/useBalances";
  43  import { useChannelPeerSuggestions } from "src/hooks/useChannelPeerSuggestions";
  44  import { useChannels } from "src/hooks/useChannels";
  45  import { useInfo } from "src/hooks/useInfo";
  46  import { usePeers } from "src/hooks/usePeers";
  47  import { cn, formatAmount } from "src/lib/utils";
  48  import useChannelOrderStore from "src/state/ChannelOrderStore";
  49  import {
  50    Channel,
  51    Network,
  52    NewChannelOrder,
  53    OnchainOrder,
  54    RecommendedChannelPeer,
  55  } from "src/types";
  56  
  57  import LightningNetworkDarkSVG from "public/images/illustrations/lightning-network-dark.svg";
  58  import LightningNetworkLightSVG from "public/images/illustrations/lightning-network-light.svg";
  59  import { useNodeDetails } from "src/hooks/useNodeDetails";
  60  
  61  function getPeerKey(peer: RecommendedChannelPeer) {
  62    return JSON.stringify(peer);
  63  }
  64  
  65  export default function IncreaseOutgoingCapacity() {
  66    const { data: info } = useInfo();
  67    const { data: channels } = useChannels();
  68  
  69    if (!info?.network || !channels) {
  70      return <Loading />;
  71    }
  72  
  73    return <NewChannelInternal network={info.network} channels={channels} />;
  74  }
  75  
  76  function NewChannelInternal({
  77    network,
  78    channels,
  79  }: {
  80    network: Network;
  81    channels: Channel[];
  82  }) {
  83    const { data: _channelPeerSuggestions } = useChannelPeerSuggestions();
  84    const { data: balances } = useBalances();
  85  
  86    const navigate = useNavigate();
  87  
  88    const presetAmounts = [250_000, 500_000, 1_000_000];
  89  
  90    const [order, setOrder] = React.useState<Partial<OnchainOrder>>({
  91      paymentMethod: "onchain",
  92      status: "pay",
  93      amountSat: presetAmounts[0].toString(),
  94      isPublic: !!channels.length && channels.every((channel) => channel.public),
  95    });
  96  
  97    const [selectedPeer, setSelectedPeer] = React.useState<
  98      RecommendedChannelPeer | undefined
  99    >();
 100  
 101    const [showConfirmModal, setShowConfirmModal] = React.useState(false);
 102  
 103    const channelPeerSuggestions = React.useMemo(() => {
 104      const customOption: RecommendedChannelPeer = {
 105        name: "Custom",
 106        network,
 107        paymentMethod: "onchain",
 108        minimumChannelSizeSat: 0,
 109        maximumChannelSizeSat: 0,
 110        description: "",
 111        pubkey: "",
 112        host: "",
 113        image: "",
 114        note: "",
 115        publicChannelsAllowed: true,
 116      };
 117      return _channelPeerSuggestions
 118        ? [
 119            ..._channelPeerSuggestions.filter(
 120              (peer) => peer.paymentMethod !== "lightning"
 121            ),
 122            customOption,
 123          ]
 124        : [customOption];
 125    }, [_channelPeerSuggestions, network]);
 126  
 127    function setPublic(isPublic: boolean) {
 128      setOrder((current) => ({
 129        ...current,
 130        isPublic,
 131      }));
 132    }
 133  
 134    const setAmountSat = React.useCallback((amountSat: string) => {
 135      setOrder((current) => ({
 136        ...current,
 137        amountSat,
 138      }));
 139    }, []);
 140  
 141    React.useEffect(() => {
 142      if (!channelPeerSuggestions) {
 143        return;
 144      }
 145      const recommendedPeer = channelPeerSuggestions.find(
 146        (peer) =>
 147          peer.network === network && peer.paymentMethod === order.paymentMethod
 148      );
 149  
 150      setSelectedPeer(recommendedPeer);
 151    }, [network, order.paymentMethod, channelPeerSuggestions]);
 152  
 153    React.useEffect(() => {
 154      if (selectedPeer) {
 155        if (
 156          selectedPeer.paymentMethod === "onchain" &&
 157          order.paymentMethod === "onchain"
 158        ) {
 159          setOrder((current) => ({
 160            ...current,
 161            pubkey: selectedPeer.pubkey,
 162            host: selectedPeer.host,
 163            ...(!selectedPeer.publicChannelsAllowed && { isPublic: false }),
 164          }));
 165        }
 166      }
 167    }, [order.paymentMethod, selectedPeer]);
 168  
 169    function onSubmit(e: FormEvent) {
 170      e.preventDefault();
 171      setShowConfirmModal(true);
 172    }
 173  
 174    function handleConfirmSubmit() {
 175      try {
 176        if (!channels) {
 177          throw new Error("Channels not loaded");
 178        }
 179        if (
 180          channels.some(
 181            (channel) =>
 182              channel.status === "opening" &&
 183              channel.isOutbound &&
 184              !channel.confirmations
 185          )
 186        ) {
 187          throw new Error(
 188            "You already are opening a channel which has not been confirmed yet. Please wait for one block confirmation."
 189          );
 190        }
 191  
 192        useChannelOrderStore.getState().setOrder(order as NewChannelOrder);
 193        setShowConfirmModal(false);
 194        navigate("/channels/order");
 195      } catch (error) {
 196        toast.error("Something went wrong", {
 197          description: `${error}`,
 198        });
 199        setShowConfirmModal(false);
 200      }
 201    }
 202  
 203    if (!channelPeerSuggestions || !balances) {
 204      return <Loading />;
 205    }
 206  
 207    const openImmediately =
 208      order.amountSat &&
 209      order.paymentMethod === "onchain" &&
 210      +order.amountSat < balances.onchain.spendableSat;
 211  
 212    return (
 213      <>
 214        <AppHeader
 215          pageTitle="Open Channel with On-Chain"
 216          title="Open Channel with On-Chain"
 217          description="Funds used to open a channel minus fees will be added to your lightning balance"
 218          contentRight={
 219            <div className="flex items-end">
 220              <Link to="/channels/incoming" className="underline text-sm">
 221                Open Channel with Lightning
 222              </Link>
 223            </div>
 224          }
 225        />
 226        <div className="md:max-w-md max-w-full flex flex-col gap-5 flex-1">
 227          <img
 228            src={LightningNetworkDarkSVG}
 229            className="w-full hidden dark:block"
 230          />
 231          <img src={LightningNetworkLightSVG} className="w-full dark:hidden" />
 232          <p className="text-muted-foreground">
 233            Open a channel with on-chain funds. Both parties are free to close the
 234            channel at any time. However, by keeping more funds on your side of
 235            the channel and using it regularly, there is more chance the channel
 236            will stay open.{" "}
 237            <ExternalLink
 238              className="underline"
 239              to="https://guides.getalby.com/user-guide/alby-hub/node/advanced-increase-spending-balance-with-on-chain-bitcoin"
 240            >
 241              Learn more
 242            </ExternalLink>
 243            .
 244          </p>
 245          <form
 246            onSubmit={onSubmit}
 247            className="md:max-w-md max-w-full flex flex-col gap-5 flex-1"
 248          >
 249            <div className="grid gap-1.5">
 250              <TooltipProvider>
 251                <Tooltip>
 252                  <TooltipTrigger type="button">
 253                    <div className="flex flex-row gap-2 items-center justify-start text-sm">
 254                      <Label htmlFor="amount">
 255                        Increase lightning balance (sats)
 256                      </Label>
 257                      <InfoIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
 258                    </div>
 259                  </TooltipTrigger>
 260                  <TooltipContent>
 261                    Configure the amount of spending balance you need in your new
 262                    lightning channel. You will need to deposit on-chain bitcoin
 263                    to cover the entire channel size, plus on-chain fees.
 264                  </TooltipContent>
 265                </Tooltip>
 266              </TooltipProvider>
 267  
 268              {order.amountSat && +order.amountSat < 200_000 && (
 269                <p className="text-muted-foreground text-xs">
 270                  For a smooth experience consider a opening a channel of{" "}
 271                  <FormattedBitcoinAmount amountMsat={200_000 * 1000} /> in size
 272                  or more.{" "}
 273                  <ExternalLink
 274                    to="https://guides.getalby.com/user-guide/alby-hub/node"
 275                    className="underline"
 276                  >
 277                    Learn more
 278                  </ExternalLink>
 279                </p>
 280              )}
 281              <Input
 282                id="amount"
 283                type="number"
 284                required
 285                min={selectedPeer?.minimumChannelSizeSat || 100000}
 286                value={order.amountSat}
 287                onChange={(e) => {
 288                  setAmountSat(e.target.value.trim());
 289                }}
 290              />
 291              <div className="text-muted-foreground text-sm sensitive slashed-zero">
 292                Current on-chain balance:{" "}
 293                <FormattedBitcoinAmount
 294                  amountMsat={balances.onchain.spendableSat * 1000}
 295                />
 296              </div>
 297              <div className="grid grid-cols-3 gap-1.5 text-muted-foreground text-xs">
 298                {presetAmounts.map((presetAmountSat) => (
 299                  <div
 300                    key={presetAmountSat}
 301                    className={cn(
 302                      "text-center border rounded p-2 cursor-pointer hover:border-muted-foreground",
 303                      +(order.amountSat || "0") === presetAmountSat &&
 304                        "border-primary hover:border-primary"
 305                    )}
 306                    onClick={() => setAmountSat(presetAmountSat.toString())}
 307                  >
 308                    {formatAmount(presetAmountSat * 1000, 0)}
 309                  </div>
 310                ))}
 311              </div>
 312            </div>
 313            <>
 314              <div className="flex flex-col gap-3">
 315                {selectedPeer &&
 316                  order.paymentMethod === "onchain" &&
 317                  selectedPeer.pubkey === order.pubkey && (
 318                    <div className="grid gap-1.5">
 319                      <Label>Choose your channel peer:</Label>
 320                      <Select
 321                        value={getPeerKey(selectedPeer)}
 322                        onValueChange={(value) =>
 323                          setSelectedPeer(
 324                            channelPeerSuggestions.find(
 325                              (x) => getPeerKey(x) === value
 326                            )
 327                          )
 328                        }
 329                      >
 330                        <SelectTrigger>
 331                          <SelectValue placeholder="Select channel peer" />
 332                        </SelectTrigger>
 333                        <SelectContent>
 334                          {channelPeerSuggestions
 335                            .filter(
 336                              (peer) =>
 337                                peer.network === network &&
 338                                peer.paymentMethod === order.paymentMethod
 339                            )
 340                            .map((peer) => (
 341                              <SelectItem
 342                                value={getPeerKey(peer)}
 343                                key={getPeerKey(peer)}
 344                              >
 345                                <div className="flex items-center gap-3">
 346                                  <div className="flex items-center gap-3">
 347                                    {peer.name !== "Custom" && (
 348                                      <img
 349                                        src={peer.image}
 350                                        className="size-8 object-contain"
 351                                      />
 352                                    )}
 353                                    <div>
 354                                      {peer.name}
 355                                      {peer.minimumChannelSizeSat > 0 && (
 356                                        <span className="ml-4 text-xs text-muted-foreground slashed-zero">
 357                                          Min.{" "}
 358                                          <FormattedBitcoinAmount
 359                                            amountMsat={
 360                                              peer.minimumChannelSizeSat * 1000
 361                                            }
 362                                          />
 363                                        </span>
 364                                      )}
 365                                    </div>
 366                                  </div>
 367                                </div>
 368                              </SelectItem>
 369                            ))}
 370                        </SelectContent>
 371                      </Select>
 372                      {selectedPeer.name === "Custom" && (
 373                        <>
 374                          <div className="grid gap-1.5"></div>
 375                        </>
 376                      )}
 377                    </div>
 378                  )}
 379              </div>
 380              {order.paymentMethod === "onchain" && (
 381                <NewChannelOnchain
 382                  order={order}
 383                  setOrder={setOrder}
 384                  showCustomOptions={selectedPeer?.name === "Custom"}
 385                />
 386              )}
 387  
 388              <div className="mt-2 flex items-top space-x-2">
 389                <Checkbox
 390                  id="public-channel"
 391                  checked={order.isPublic}
 392                  onCheckedChange={() => setPublic(!order.isPublic)}
 393                  className="mr-2"
 394                  disabled={selectedPeer && !selectedPeer.publicChannelsAllowed}
 395                  title={
 396                    selectedPeer && !selectedPeer.publicChannelsAllowed
 397                      ? "This channel partner does not support public channels."
 398                      : undefined
 399                  }
 400                />
 401                <div className="grid gap-1.5 leading-none">
 402                  <Label htmlFor="public-channel" className="cursor-pointer">
 403                    Public Channel
 404                  </Label>
 405                  <p className="text-xs text-muted-foreground">
 406                    Not recommended for most users.{" "}
 407                    <ExternalLink
 408                      className="underline"
 409                      to="https://guides.getalby.com/user-guide/alby-hub/faq/should-i-open-a-private-or-public-channel"
 410                    >
 411                      Learn more
 412                    </ExternalLink>
 413                  </p>
 414                </div>
 415              </div>
 416            </>
 417            <MempoolAlert />
 418            <SwapAlert swapType="in" />
 419            {channels?.some((channel) => channel.public !== !!order.isPublic) && (
 420              <ChannelPublicPrivateAlert />
 421            )}
 422            {selectedPeer?.note && <ChannelPeerNote peer={selectedPeer} />}
 423            <DuplicateChannelAlert
 424              pubkey={order?.pubkey}
 425              name={selectedPeer?.name}
 426            />
 427            <Button size="lg">{openImmediately ? "Open Channel" : "Next"}</Button>
 428          </form>
 429  
 430          <div className="flex-1 flex flex-col justify-end items-center gap-4">
 431            <p className="mt-32 text-sm text-muted-foreground text-center">
 432              Other options
 433            </p>
 434            <LinkButton
 435              to="/channels/incoming"
 436              className="w-full"
 437              variant="secondary"
 438            >
 439              Increase Receiving Capacity
 440            </LinkButton>
 441            <ExternalLinkButton
 442              to="https://www.getalby.com/topup"
 443              className="w-full"
 444              variant="secondary"
 445            >
 446              Buy Bitcoin
 447            </ExternalLinkButton>
 448          </div>
 449        </div>
 450  
 451        {/* Confirmation Modal */}
 452        <Dialog open={showConfirmModal} onOpenChange={setShowConfirmModal}>
 453          <DialogContent className="sm:max-w-md">
 454            <DialogHeader>
 455              <DialogTitle>Confirm Channel Opening</DialogTitle>
 456              <DialogDescription>
 457                Are you sure you want to open a Lightning channel with the
 458                following details?
 459              </DialogDescription>
 460            </DialogHeader>
 461  
 462            <div className="space-y-4">
 463              <div className="grid grid-cols-2 gap-4 text-sm">
 464                <div>
 465                  <div className="font-medium text-muted-foreground">Peer</div>
 466                  <div>{selectedPeer?.name || "Custom"}</div>
 467                </div>
 468                <div>
 469                  <div className="font-medium text-muted-foreground">Amount</div>
 470                  <div>
 471                    <FormattedBitcoinAmount
 472                      amountMsat={parseInt(order.amountSat || "0") * 1000}
 473                    />
 474                  </div>
 475                </div>
 476                <div>
 477                  <div className="font-medium text-muted-foreground">
 478                    Channel Type
 479                  </div>
 480                  <div>{order.isPublic ? "Public" : "Private"}</div>
 481                </div>
 482                <div>
 483                  <div className="font-medium text-muted-foreground">
 484                    Payment Method
 485                  </div>
 486                  <div>On-chain</div>
 487                </div>
 488              </div>
 489  
 490              {selectedPeer?.name === "Custom" && order.pubkey && (
 491                <div className="text-sm">
 492                  <div className="font-medium text-muted-foreground">
 493                    Node Public Key
 494                  </div>
 495                  <div className="font-mono text-xs break-all bg-muted p-2 rounded">
 496                    {order.pubkey}
 497                  </div>
 498                </div>
 499              )}
 500  
 501              <Alert variant="warning">
 502                <InfoIcon />
 503                <AlertDescription>
 504                  <strong>Important:</strong> Opening a channel requires an
 505                  on-chain transaction and network fees. This action cannot be
 506                  undone. Please verify all details before proceeding.
 507                </AlertDescription>
 508              </Alert>
 509            </div>
 510  
 511            <DialogFooter className="gap-2">
 512              <Button
 513                variant="outline"
 514                onClick={() => setShowConfirmModal(false)}
 515              >
 516                Cancel
 517              </Button>
 518              <Button onClick={handleConfirmSubmit}>
 519                Confirm & Open Channel
 520              </Button>
 521            </DialogFooter>
 522          </DialogContent>
 523        </Dialog>
 524      </>
 525    );
 526  }
 527  
 528  type NewChannelOnchainProps = {
 529    order: Partial<OnchainOrder>;
 530    setOrder: React.Dispatch<React.SetStateAction<Partial<OnchainOrder>>>;
 531    showCustomOptions: boolean;
 532  };
 533  
 534  function NewChannelOnchain(props: NewChannelOnchainProps) {
 535    const { data: peers } = usePeers();
 536  
 537    if (props.order.paymentMethod !== "onchain") {
 538      throw new Error("unexpected payment method");
 539    }
 540    const { pubkey, host } = props.order;
 541    const { setOrder } = props;
 542    const isAlreadyPeered =
 543      pubkey && peers?.some((peer) => peer.nodeId === pubkey);
 544  
 545    function setPubkey(pubkey: string) {
 546      props.setOrder((current) => ({
 547        ...current,
 548        paymentMethod: "onchain",
 549        pubkey,
 550      }));
 551    }
 552    const setHost = React.useCallback(
 553      (host: string) => {
 554        setOrder((current) => ({
 555          ...current,
 556          paymentMethod: "onchain",
 557          host,
 558        }));
 559      },
 560      [setOrder]
 561    );
 562  
 563    const { data: nodeDetails } = useNodeDetails(pubkey);
 564  
 565    React.useEffect(() => {
 566      const socketAddress = nodeDetails?.sockets?.split(",")?.[0];
 567      if (socketAddress) {
 568        setHost(socketAddress);
 569      }
 570    }, [nodeDetails, setHost]);
 571  
 572    return (
 573      <>
 574        <div className="flex flex-col gap-5">
 575          {props.showCustomOptions && (
 576            <>
 577              <div className="grid gap-1.5">
 578                <Label htmlFor="pubkey">Peer</Label>
 579                <Input
 580                  id="pubkey"
 581                  type="text"
 582                  value={pubkey}
 583                  required
 584                  placeholder="Pubkey of the peer"
 585                  onChange={(e) => {
 586                    const parts = e.target.value.trim().split("@");
 587                    setPubkey(parts[0]);
 588                    if (parts.length > 1) {
 589                      setHost(parts[1]);
 590                    }
 591                  }}
 592                />
 593                {nodeDetails && (
 594                  <div className="ml-2 text-muted-foreground text-sm">
 595                    <span
 596                      className="mr-2"
 597                      style={{ color: `${nodeDetails.color}` }}
 598                    >
 599   600                    </span>
 601                    {nodeDetails.alias && (
 602                      <>
 603                        {nodeDetails.alias} ({nodeDetails.active_channel_count}{" "}
 604                        channels)
 605                      </>
 606                    )}
 607                  </div>
 608                )}
 609              </div>
 610  
 611              {!isAlreadyPeered && /*!nodeDetails && */ pubkey && (
 612                <div className="grid gap-1.5">
 613                  <Label htmlFor="host">Host:Port</Label>
 614                  <Input
 615                    id="host"
 616                    type="text"
 617                    value={host}
 618                    required
 619                    placeholder="0.0.0.0:9735 or [2600::]:9735"
 620                    onChange={(e) => {
 621                      setHost(e.target.value.trim());
 622                    }}
 623                  />
 624                </div>
 625              )}
 626            </>
 627          )}
 628        </div>
 629      </>
 630    );
 631  }
 632