IncreaseIncomingCapacity.tsx raw

   1  import { ChevronDownIcon, 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 { Button } from "src/components/ui/button";
  15  import { Checkbox } from "src/components/ui/checkbox";
  16  import { ExternalLinkButton } from "src/components/ui/custom/external-link-button";
  17  import { LinkButton } from "src/components/ui/custom/link-button";
  18  import { Input } from "src/components/ui/input";
  19  import { Label } from "src/components/ui/label";
  20  import {
  21    Select,
  22    SelectContent,
  23    SelectItem,
  24    SelectTrigger,
  25    SelectValue,
  26  } from "src/components/ui/select";
  27  import {
  28    Tooltip,
  29    TooltipContent,
  30    TooltipProvider,
  31    TooltipTrigger,
  32  } from "src/components/ui/tooltip";
  33  import { useChannelPeerSuggestions } from "src/hooks/useChannelPeerSuggestions";
  34  import { useChannels } from "src/hooks/useChannels";
  35  import { useInfo } from "src/hooks/useInfo";
  36  import { cn } from "src/lib/utils";
  37  import useChannelOrderStore from "src/state/ChannelOrderStore";
  38  import {
  39    Channel,
  40    LightningOrder,
  41    Network,
  42    NewChannelOrder,
  43    RecommendedChannelPeer,
  44  } from "src/types";
  45  
  46  import LightningNetworkDarkSVG from "public/images/illustrations/lightning-network-dark.svg";
  47  import LightningNetworkLightSVG from "public/images/illustrations/lightning-network-light.svg";
  48  import { LSPTermsDialog } from "src/components/channels/LSPTermsDialog";
  49  import FormattedFiatAmount from "src/components/FormattedFiatAmount";
  50  
  51  function getPeerKey(peer: RecommendedChannelPeer) {
  52    return JSON.stringify(peer);
  53  }
  54  
  55  export default function IncreaseIncomingCapacity() {
  56    const { data: info } = useInfo();
  57    const { data: channels } = useChannels();
  58  
  59    if (!info?.network || !channels) {
  60      return <Loading />;
  61    }
  62  
  63    return <NewChannelInternal network={info.network} channels={channels} />;
  64  }
  65  
  66  function NewChannelInternal({
  67    network,
  68    channels,
  69  }: {
  70    network: Network;
  71    channels: Channel[];
  72  }) {
  73    const { data: _channelPeerSuggestions, error: channelPeerSuggestionsError } =
  74      useChannelPeerSuggestions();
  75    const navigate = useNavigate();
  76  
  77    const presetAmounts = [1_000_000, 2_000_000, 3_000_000];
  78  
  79    const [order, setOrder] = React.useState<Partial<LightningOrder>>({
  80      paymentMethod: "lightning",
  81      status: "pay",
  82      amountSat: presetAmounts[0].toString(),
  83      prevChannelIds: channels.map((channel) => channel.id),
  84      isPublic: !!channels.length && channels.every((channel) => channel.public),
  85    });
  86  
  87    const [showAdvanced, setShowAdvanced] = React.useState(false);
  88  
  89    const [selectedPeer, setSelectedPeer] = React.useState<
  90      RecommendedChannelPeer | undefined
  91    >();
  92  
  93    React.useEffect(() => {
  94      if (channelPeerSuggestionsError) {
  95        toast.error("Failed to load channel suggestions");
  96        navigate("/channels/outgoing");
  97      }
  98    }, [channelPeerSuggestionsError, navigate]);
  99  
 100    const channelPeerSuggestions = React.useMemo(() => {
 101      return _channelPeerSuggestions
 102        ? [
 103            ..._channelPeerSuggestions.filter(
 104              (peer) =>
 105                peer.paymentMethod === "lightning" && peer.type === "LSPS1"
 106            ),
 107          ]
 108        : undefined;
 109    }, [_channelPeerSuggestions]);
 110  
 111    function setPublic(isPublic: boolean) {
 112      setOrder((current) => ({
 113        ...current,
 114        isPublic,
 115      }));
 116    }
 117  
 118    const setAmountSat = React.useCallback((amountSat: string) => {
 119      setOrder((current) => ({
 120        ...current,
 121        amountSat,
 122      }));
 123    }, []);
 124  
 125    React.useEffect(() => {
 126      if (!channelPeerSuggestions) {
 127        return;
 128      }
 129      const recommendedPeer = channelPeerSuggestions.find(
 130        (peer) =>
 131          peer.network === network && peer.paymentMethod === order.paymentMethod
 132      );
 133  
 134      setSelectedPeer(recommendedPeer);
 135    }, [network, order.paymentMethod, channelPeerSuggestions]);
 136  
 137    React.useEffect(() => {
 138      if (selectedPeer) {
 139        if (
 140          selectedPeer.paymentMethod === "lightning" &&
 141          order.paymentMethod === "lightning"
 142        ) {
 143          setOrder((current) => ({
 144            ...current,
 145            lspType: selectedPeer.type,
 146            lspIdentifier: selectedPeer.identifier,
 147            ...(!selectedPeer.publicChannelsAllowed && { isPublic: false }),
 148          }));
 149        }
 150      }
 151    }, [order.paymentMethod, selectedPeer]);
 152  
 153    // find the best channel partner
 154    const okPartners = channelPeerSuggestions?.filter(
 155      (partner) =>
 156        parseInt(order.amountSat || "0") >= partner.minimumChannelSizeSat &&
 157        parseInt(order.amountSat || "0") <= partner.maximumChannelSizeSat &&
 158        partner.network === network &&
 159        partner.paymentMethod === "lightning" &&
 160        partner.type === "LSPS1" &&
 161        partner.pubkey &&
 162        (partner.publicChannelsAllowed || !order.isPublic) &&
 163        !channels.some((channel) => channel.remotePubkey === partner.pubkey)
 164    );
 165  
 166    const bestPartner = okPartners?.[0];
 167  
 168    function onSubmit(e: FormEvent) {
 169      e.preventDefault();
 170      try {
 171        const nextOrder: Partial<LightningOrder> = { ...order };
 172  
 173        if (!showAdvanced) {
 174          if (!channelPeerSuggestions) {
 175            throw new Error("Channel Peer suggestions not loaded");
 176          }
 177          if (!channels) {
 178            throw new Error("Channels not loaded");
 179          }
 180          const amountSat = parseInt(order.amountSat || "0");
 181          if (!amountSat) {
 182            throw new Error("No amount set");
 183          }
 184  
 185          if (!bestPartner) {
 186            toast.error("No channel partner found", {
 187              description:
 188                "No ideal channel partner found. Please choose from the advanced options to continue",
 189            });
 190            return;
 191          }
 192          nextOrder.paymentMethod = "lightning";
 193          if (bestPartner.paymentMethod !== "lightning") {
 194            throw new Error("Unexpected partner payment method");
 195          }
 196          nextOrder.lspType = bestPartner.type;
 197          nextOrder.lspIdentifier = bestPartner.identifier;
 198        }
 199  
 200        useChannelOrderStore.getState().setOrder(nextOrder as NewChannelOrder);
 201        navigate("/channels/order");
 202      } catch (error) {
 203        toast.error("Something went wrong", {
 204          description: "" + error,
 205        });
 206        console.error(error);
 207      }
 208    }
 209  
 210    if (!channelPeerSuggestions) {
 211      return <Loading />;
 212    }
 213  
 214    const selectedPartner = showAdvanced ? selectedPeer : bestPartner;
 215  
 216    const estimatedChannelPrice =
 217      selectedPartner?.paymentMethod === "lightning"
 218        ? order.amountSat === "1000000"
 219          ? selectedPartner["feeTotalSat1m"]
 220          : order.amountSat === "2000000"
 221            ? selectedPartner["feeTotalSat2m"]
 222            : order.amountSat === "3000000"
 223              ? selectedPartner["feeTotalSat3m"]
 224              : undefined
 225        : undefined;
 226  
 227    return (
 228      <>
 229        <AppHeader
 230          pageTitle="Open Channel with Lightning"
 231          title="Open Channel with Lightning"
 232          description="Purchase a channel that allows you to receive payments"
 233          contentRight={
 234            <div className="flex items-end">
 235              <Link
 236                to="/channels/outgoing"
 237                className="underline break-anywhere text-sm"
 238              >
 239                Open Channel with On-Chain
 240              </Link>
 241            </div>
 242          }
 243        />
 244        <div className="md:max-w-md max-w-full flex flex-col gap-5 flex-1">
 245          <img
 246            src={LightningNetworkDarkSVG}
 247            className="w-full hidden dark:block"
 248          />
 249          <img src={LightningNetworkLightSVG} className="w-full dark:hidden" />
 250          <p className="text-muted-foreground">
 251            Alby Hub works with selected service providers (LSPs) which provide
 252            the best network connectivity and liquidity to receive payments.{" "}
 253            <ExternalLink
 254              className="underline"
 255              to="https://guides.getalby.com/user-guide/alby-hub/faq/how-to-open-a-payment-channel"
 256            >
 257              Learn more
 258            </ExternalLink>
 259          </p>
 260          <form onSubmit={onSubmit} className="flex flex-col gap-5 flex-1">
 261            <div className="grid gap-1.5">
 262              <TooltipProvider>
 263                <Tooltip>
 264                  <TooltipTrigger type="button">
 265                    <div className="flex flex-row gap-2 items-center justify-start text-sm">
 266                      <Label htmlFor="amount">
 267                        Increase receiving capacity (sats)
 268                      </Label>
 269                      <InfoIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
 270                    </div>
 271                  </TooltipTrigger>
 272                  <TooltipContent>
 273                    Configure the amount of receiving capacity you need. You will
 274                    only pay for the liquidity fee which will be shown in the next
 275                    step.
 276                  </TooltipContent>
 277                </Tooltip>
 278              </TooltipProvider>
 279  
 280              {order.amountSat && +order.amountSat < 200_000 && (
 281                <p className="text-muted-foreground text-xs">
 282                  For a smooth experience consider a opening a channel of{" "}
 283                  <FormattedBitcoinAmount amountMsat={200_000 * 1000} /> in size
 284                  or more.{" "}
 285                  <ExternalLink
 286                    to="https://guides.getalby.com/user-guide/alby-hub/node"
 287                    className="underline"
 288                  >
 289                    Learn more
 290                  </ExternalLink>
 291                </p>
 292              )}
 293              <Input
 294                id="amount"
 295                type="number"
 296                required
 297                min={
 298                  showAdvanced
 299                    ? selectedPeer?.minimumChannelSizeSat || 100000
 300                    : undefined
 301                }
 302                value={order.amountSat}
 303                onChange={(e) => {
 304                  setAmountSat(e.target.value.trim());
 305                }}
 306              />
 307              <div className="grid grid-cols-3 gap-1.5 text-xs">
 308                {presetAmounts.map((presetAmountSat) => (
 309                  <div
 310                    key={presetAmountSat}
 311                    className={cn(
 312                      "text-center border rounded p-2 cursor-pointer hover:border-muted-foreground",
 313                      +(order.amountSat || "0") === presetAmountSat &&
 314                        "border-primary hover:border-primary"
 315                    )}
 316                    onClick={() => setAmountSat(presetAmountSat.toString())}
 317                  >
 318                    <FormattedBitcoinAmount amountMsat={presetAmountSat * 1000} />
 319                    <FormattedFiatAmount
 320                      amountSat={presetAmountSat}
 321                      showApprox
 322                      className="text-xs"
 323                    />
 324                  </div>
 325                ))}
 326              </div>
 327              {estimatedChannelPrice && (
 328                <span className="text-muted-foreground text-xs">
 329                  {" "}
 330                  Estimated channel price:{" "}
 331                  <span className="font-semibold">
 332                    <FormattedBitcoinAmount
 333                      amountMsat={estimatedChannelPrice * 1000}
 334                    />
 335                  </span>
 336                </span>
 337              )}
 338              {selectedPartner?.paymentMethod === "lightning" && (
 339                <div className="flex justify-between items-center">
 340                  <p className="text-sm">
 341                    You will receive a channel from{" "}
 342                    <span className="font-medium">{selectedPartner.name}</span>
 343                    .{" "}
 344                  </p>
 345                  <LSPTermsDialog
 346                    contactUrl={selectedPartner.contactUrl}
 347                    description={selectedPartner.description}
 348                    name={selectedPartner.name}
 349                    terms={selectedPartner.terms}
 350                    maximumChannelExpiryBlocks={
 351                      selectedPartner.maximumChannelExpiryBlocks
 352                    }
 353                    trigger={<p className="text-xs underline">View Terms</p>}
 354                  />
 355                </div>
 356              )}
 357            </div>
 358            {showAdvanced && (
 359              <>
 360                <div className="flex flex-col gap-3">
 361                  {selectedPeer && (
 362                    <div className="grid gap-1.5">
 363                      <Label>Choose your channel peer:</Label>
 364                      <Select
 365                        value={getPeerKey(selectedPeer)}
 366                        onValueChange={(value) =>
 367                          setSelectedPeer(
 368                            channelPeerSuggestions.find(
 369                              (x) => getPeerKey(x) === value
 370                            )
 371                          )
 372                        }
 373                      >
 374                        <SelectTrigger>
 375                          <SelectValue placeholder="Select channel peer" />
 376                        </SelectTrigger>
 377                        <SelectContent>
 378                          {channelPeerSuggestions
 379                            .filter(
 380                              (peer) =>
 381                                peer.network === network &&
 382                                peer.paymentMethod === order.paymentMethod
 383                            )
 384                            .map((peer) => (
 385                              <SelectItem
 386                                value={getPeerKey(peer)}
 387                                key={getPeerKey(peer)}
 388                              >
 389                                <div className="flex items-center gap-3">
 390                                  <div className="flex items-center gap-3">
 391                                    {peer.name !== "Custom" && (
 392                                      <img
 393                                        src={peer.image}
 394                                        className="size-8 object-contain"
 395                                      />
 396                                    )}
 397                                    <div>
 398                                      {peer.name}
 399                                      <span className="ml-4 text-xs text-muted-foreground slashed-zero">
 400                                        Min.{" "}
 401                                        <FormattedBitcoinAmount
 402                                          amountMsat={
 403                                            peer.minimumChannelSizeSat * 1000
 404                                          }
 405                                        />
 406                                        <span className="mr-5" />
 407                                        Max.{" "}
 408                                        <FormattedBitcoinAmount
 409                                          amountMsat={
 410                                            peer.maximumChannelSizeSat * 1000
 411                                          }
 412                                        />
 413                                      </span>
 414                                    </div>
 415                                  </div>
 416                                </div>
 417                              </SelectItem>
 418                            ))}
 419                        </SelectContent>
 420                      </Select>
 421                      {selectedPeer.name === "Custom" && (
 422                        <>
 423                          <div className="grid gap-1.5"></div>
 424                        </>
 425                      )}
 426                    </div>
 427                  )}
 428                </div>
 429                {order.paymentMethod === "lightning" && (
 430                  <NewChannelLightning order={order} setOrder={setOrder} />
 431                )}
 432  
 433                <div className="mt-2 flex items-top space-x-2">
 434                  <Checkbox
 435                    id="public-channel"
 436                    checked={order.isPublic}
 437                    onCheckedChange={() => setPublic(!order.isPublic)}
 438                    className="mr-2"
 439                    disabled={selectedPeer && !selectedPeer.publicChannelsAllowed}
 440                    title={
 441                      selectedPeer && !selectedPeer.publicChannelsAllowed
 442                        ? "This channel partner does not support public channels."
 443                        : undefined
 444                    }
 445                  />
 446                  <div className="grid gap-1.5 leading-none">
 447                    <Label htmlFor="public-channel" className="cursor-pointer">
 448                      Public Channel
 449                    </Label>
 450                    <p className="text-xs text-muted-foreground">
 451                      Not recommended for most users.{" "}
 452                      <ExternalLink
 453                        className="underline"
 454                        to="https://guides.getalby.com/user-guide/alby-hub/faq/should-i-open-a-private-or-public-channel"
 455                      >
 456                        Learn more
 457                      </ExternalLink>
 458                    </p>
 459                  </div>
 460                </div>
 461              </>
 462            )}
 463  
 464            {!showAdvanced && (
 465              <Button
 466                type="button"
 467                variant="link"
 468                className="text-muted-foreground text-xs"
 469                onClick={() => setShowAdvanced((current) => !current)}
 470              >
 471                <ChevronDownIcon />
 472                Advanced Options
 473              </Button>
 474            )}
 475            <MempoolAlert />
 476            <SwapAlert swapType="out" />
 477            {channels?.some((channel) => channel.public !== !!order.isPublic) && (
 478              <ChannelPublicPrivateAlert />
 479            )}
 480            {selectedPeer?.note && <ChannelPeerNote peer={selectedPeer} />}
 481            {showAdvanced && (
 482              <DuplicateChannelAlert
 483                pubkey={selectedPeer?.pubkey}
 484                name={selectedPeer?.name}
 485              />
 486            )}
 487            <Button size="lg">Review Order</Button>
 488          </form>
 489  
 490          <div className="flex-1 flex flex-col justify-end gap-2">
 491            <p className="text-sm text-muted-foreground text-center">
 492              Other options
 493            </p>
 494            <LinkButton
 495              to="/channels/outgoing"
 496              className="w-full"
 497              variant="secondary"
 498            >
 499              Increase Lightning Balance
 500            </LinkButton>
 501            <ExternalLinkButton
 502              to="https://www.getalby.com/topup"
 503              className="w-full"
 504              variant="secondary"
 505            >
 506              Buy Bitcoin
 507            </ExternalLinkButton>
 508          </div>
 509        </div>
 510      </>
 511    );
 512  }
 513  
 514  type NewChannelLightningProps = {
 515    order: Partial<LightningOrder>;
 516    setOrder(order: Partial<LightningOrder>): void;
 517  };
 518  
 519  function NewChannelLightning(props: NewChannelLightningProps) {
 520    if (props.order.paymentMethod !== "lightning") {
 521      throw new Error("unexpected payment method");
 522    }
 523    return null;
 524  }
 525