RebalanceChannelDialogContent.tsx raw

   1  import { AlertTriangleIcon, ExternalLinkIcon } from "lucide-react";
   2  import React from "react";
   3  import { toast } from "sonner";
   4  import ExternalLink from "src/components/ExternalLink";
   5  import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount";
   6  import Loading from "src/components/Loading";
   7  import { Alert, AlertDescription, AlertTitle } from "src/components/ui/alert";
   8  import { LoadingButton } from "src/components/ui/custom/loading-button";
   9  import { Input } from "src/components/ui/input";
  10  import { Label } from "src/components/ui/label";
  11  import { useBalances } from "src/hooks/useBalances";
  12  import { useChannels } from "src/hooks/useChannels";
  13  import { request } from "src/utils/request";
  14  import {
  15    AlertDialogCancel,
  16    AlertDialogContent,
  17    AlertDialogDescription,
  18    AlertDialogFooter,
  19    AlertDialogHeader,
  20    AlertDialogTitle,
  21  } from "./ui/alert-dialog";
  22  
  23  type Props = {
  24    receiveThroughNodePubkey: string;
  25    closeDialog(): void;
  26  };
  27  
  28  export function RebalanceChannelDialogContent({
  29    receiveThroughNodePubkey,
  30    closeDialog,
  31  }: Props) {
  32    const [amountSat, setAmountSat] = React.useState("");
  33    const { data: channels, mutate: reloadChannels } = useChannels();
  34    const { mutate: reloadBalances } = useBalances();
  35    const [isRebalancing, setRebalancing] = React.useState(false);
  36  
  37    const inputRef = React.useRef<HTMLInputElement>(null);
  38  
  39    React.useEffect(() => {
  40      setTimeout(() => {
  41        // for some reason `autoFocus` is not working on this input
  42        inputRef.current?.focus();
  43      }, 100);
  44    }, [inputRef]);
  45  
  46    if (!channels) {
  47      return <Loading />;
  48    }
  49  
  50    async function handleSubmit(e: React.FormEvent) {
  51      e.preventDefault();
  52      setRebalancing(true);
  53      try {
  54        if (!channels) {
  55          throw new Error("channels not loaded");
  56        }
  57  
  58        const response = await request<{ totalFeeSat: number }>(
  59          `/api/channels/rebalance`,
  60          {
  61            method: "POST",
  62            headers: {
  63              "Content-Type": "application/json",
  64            },
  65            body: JSON.stringify({
  66              receiveThroughNodePubkey,
  67              amountSat: parseInt(amountSat),
  68            }),
  69          }
  70        );
  71        if (!response) {
  72          throw new Error("No rebalance response received");
  73        }
  74  
  75        await Promise.all([reloadChannels(), reloadBalances()]);
  76        toast(
  77          "Successfully rebalanced channels. Total fee: " +
  78            response.totalFeeSat +
  79            " sats"
  80        );
  81        closeDialog();
  82      } catch (error) {
  83        console.error(error);
  84        toast.error("" + error);
  85      }
  86      setRebalancing(false);
  87    }
  88  
  89    return (
  90      <AlertDialogContent>
  91        <form onSubmit={handleSubmit}>
  92          <AlertDialogHeader>
  93            <AlertDialogTitle>Rebalance In</AlertDialogTitle>
  94            <AlertDialogDescription>
  95              <p className="mb-4">
  96                Rebalance funds from other channels into this channel.
  97              </p>
  98              <Label htmlFor="fee" className="block mb-2">
  99                Rebalance amount (sats)
 100              </Label>
 101              <Input
 102                ref={inputRef}
 103                id="amount"
 104                name="amount"
 105                type="number"
 106                required
 107                autoFocus
 108                min={Math.max(
 109                  10000,
 110                  Math.floor(
 111                    Math.min(
 112                      ...channels
 113                        .filter(
 114                          (channel) =>
 115                            channel.remotePubkey === receiveThroughNodePubkey
 116                        )
 117                        .map((channel) => channel.localSpendableBalanceSat)
 118                    ) + 1
 119                  )
 120                )}
 121                max={Math.floor(
 122                  Math.max(
 123                    ...channels
 124                      .filter(
 125                        (channel) =>
 126                          channel.remotePubkey === receiveThroughNodePubkey
 127                      )
 128                      .map((channel) => channel.remoteBalanceSat)
 129                  )
 130                )}
 131                value={amountSat}
 132                onChange={(e) => {
 133                  setAmountSat(e.target.value.trim());
 134                }}
 135              />
 136              <p className="mt-2 text-xs text-muted-foreground">
 137                Fee: 0.5%
 138                {!!amountSat && (
 139                  <>
 140                    &nbsp;(
 141                    <FormattedBitcoinAmount
 142                      amountMsat={Math.floor(
 143                        parseInt(amountSat || "0") * 0.003 * 1000
 144                      )}
 145                    />
 146                    )
 147                  </>
 148                )}{" "}
 149                + routing fees
 150              </p>
 151              <ExternalLink
 152                to="https://guides.getalby.com/user-guide/alby-hub/faq/can-i-rebalance-funds-from-one-of-my-channels-to-another"
 153                className="underline flex items-center mt-4"
 154              >
 155                Learn more about rebalancing between channels
 156                <ExternalLinkIcon className="size-4 ml-2" />
 157              </ExternalLink>
 158              <Alert className="mt-2">
 159                <AlertTriangleIcon className="h-4 w-4" />
 160                <AlertTitle>Rebalancing is in beta</AlertTitle>
 161                <AlertDescription>
 162                  Funds may be rebalanced out of unexpected channels.
 163                </AlertDescription>
 164              </Alert>
 165              {channels.filter(
 166                (channel) => channel.remotePubkey === receiveThroughNodePubkey
 167              ).length > 1 && (
 168                <Alert className="mt-2">
 169                  <AlertTriangleIcon className="h-4 w-4" />
 170                  <AlertTitle>
 171                    Multiple channels with same counterparty
 172                  </AlertTitle>
 173                  <AlertDescription>
 174                    Funds may be rebalanced to an unexpected channel.
 175                  </AlertDescription>
 176                </Alert>
 177              )}
 178              {channels.some(
 179                (channel) =>
 180                  channel.remotePubkey !== receiveThroughNodePubkey &&
 181                  channel.localSpendableBalanceMsat <
 182                    (channels.find(
 183                      (other) => other.remotePubkey === receiveThroughNodePubkey
 184                    )?.localSpendableBalanceMsat || 0)
 185              ) && (
 186                <Alert className="mt-2">
 187                  <AlertTriangleIcon className="h-4 w-4" />
 188                  <AlertTitle>
 189                    You have another channel with less funds
 190                  </AlertTitle>
 191                  <AlertDescription>
 192                    Consider choosing a channel with less local balance to
 193                    rebalance into.
 194                  </AlertDescription>
 195                </Alert>
 196              )}
 197            </AlertDialogDescription>
 198          </AlertDialogHeader>
 199          <AlertDialogFooter className="mt-4">
 200            <AlertDialogCancel>Cancel</AlertDialogCancel>
 201            <LoadingButton loading={isRebalancing}>Confirm</LoadingButton>
 202          </AlertDialogFooter>
 203        </form>
 204      </AlertDialogContent>
 205    );
 206  }
 207