WithdrawOnchainFunds.tsx raw

   1  import {
   2    AlertTriangleIcon,
   3    ChevronDownIcon,
   4    CopyIcon,
   5    ExternalLinkIcon,
   6    InfoIcon,
   7  } from "lucide-react";
   8  import React from "react";
   9  import { toast } from "sonner";
  10  import { AnchorReserveAlert } from "src/components/AnchorReserveAlert";
  11  import AppHeader from "src/components/AppHeader";
  12  import ExternalLink from "src/components/ExternalLink";
  13  import { FixedFloatButton } from "src/components/FixedFloatButton";
  14  import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount";
  15  import Loading from "src/components/Loading";
  16  import { MempoolAlert } from "src/components/MempoolAlert";
  17  import { Alert, AlertDescription, AlertTitle } from "src/components/ui/alert";
  18  import {
  19    AlertDialog,
  20    AlertDialogCancel,
  21    AlertDialogContent,
  22    AlertDialogDescription,
  23    AlertDialogFooter,
  24    AlertDialogHeader,
  25    AlertDialogTitle,
  26  } from "src/components/ui/alert-dialog";
  27  import { Button } from "src/components/ui/button";
  28  import { Checkbox } from "src/components/ui/checkbox";
  29  import { LoadingButton } from "src/components/ui/custom/loading-button";
  30  import { Input } from "src/components/ui/input";
  31  import { Label } from "src/components/ui/label";
  32  import { Separator } from "src/components/ui/separator";
  33  import { ONCHAIN_DUST_SATS } from "src/constants";
  34  import { useBalances } from "src/hooks/useBalances";
  35  import { useInfo } from "src/hooks/useInfo";
  36  import { useMempoolApi } from "src/hooks/useMempoolApi";
  37  
  38  import { copyToClipboard } from "src/lib/clipboard";
  39  import {
  40    RedeemOnchainFundsRequest,
  41    RedeemOnchainFundsResponse,
  42  } from "src/types";
  43  import { request } from "src/utils/request";
  44  
  45  export default function WithdrawOnchainFunds() {
  46    const { data: info } = useInfo();
  47    const { data: balances } = useBalances();
  48    const { data: recommendedFees, error: mempoolError } = useMempoolApi<{
  49      fastestFee: number;
  50      halfHourFee: number;
  51      economyFee: number;
  52      minimumFee: number;
  53    }>("/v1/fees/recommended");
  54    const [isLoading, setLoading] = React.useState(false);
  55    const [onchainAddress, setOnchainAddress] = React.useState("");
  56    const [amountSat, setAmountSat] = React.useState("");
  57    const [feeRate, setFeeRate] = React.useState("");
  58    const [sendAll, setSendAll] = React.useState(false);
  59    const [showAdvanced, setShowAdvanced] = React.useState(false);
  60    const [transactionId, setTransactionId] = React.useState("");
  61    const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false);
  62  
  63    React.useEffect(() => {
  64      if (mempoolError) {
  65        setShowAdvanced(true);
  66      }
  67    }, [mempoolError]);
  68  
  69    React.useEffect(() => {
  70      if (recommendedFees?.fastestFee) {
  71        setFeeRate(recommendedFees.fastestFee.toString());
  72      }
  73    }, [recommendedFees]);
  74  
  75    const copy = (text: string) => {
  76      copyToClipboard(text);
  77    };
  78  
  79    const redeemFunds = React.useCallback(async () => {
  80      setLoading(true);
  81      try {
  82        if (!onchainAddress) {
  83          throw new Error("No onchain address");
  84        }
  85        if (!feeRate) {
  86          throw new Error("No fee rate set");
  87        }
  88      } catch (error) {
  89        console.error(error);
  90        toast.error("Something went wrong", {
  91          description: "" + error,
  92        });
  93        setLoading(false);
  94        return;
  95      }
  96  
  97      try {
  98        const payload: RedeemOnchainFundsRequest = {
  99          toAddress: onchainAddress,
 100          amountSat: +amountSat,
 101          sendAll,
 102          feeRate: +feeRate,
 103        };
 104        const response = await request<RedeemOnchainFundsResponse>(
 105          "/api/wallet/redeem-onchain-funds",
 106          {
 107            method: "POST",
 108            headers: {
 109              "Content-Type": "application/json",
 110            },
 111            body: JSON.stringify(payload),
 112          }
 113        );
 114        console.info("Redeemed onchain funds", response);
 115        if (!response?.txId) {
 116          throw new Error("No address in response");
 117        }
 118        setTransactionId(response.txId);
 119      } catch (error) {
 120        console.error(error);
 121        toast.error("Failed to redeem onchain funds", {
 122          description: "" + error,
 123        });
 124      }
 125      setLoading(false);
 126    }, [amountSat, feeRate, onchainAddress, sendAll]);
 127  
 128    if (transactionId) {
 129      return (
 130        <div className="grid gap-5">
 131          <AppHeader
 132            pageTitle="Withdrawal Transaction Broadcasted"
 133            title="Withdrawal Transaction Broadcasted"
 134            description={
 135              "You will receive the funds at the destination after the transaction is confirmed"
 136            }
 137          />
 138          <p className="text-foreground">Withdrawal Transaction Id</p>
 139          <div className="flex items-center justify-between gap-4 max-w-sm">
 140            <p className="break-all font-semibold">{transactionId}</p>
 141            <CopyIcon
 142              className="cursor-pointer text-muted-foreground size-4 shrink-0"
 143              onClick={() => {
 144                copy(transactionId);
 145              }}
 146            />
 147          </div>
 148          <ExternalLink
 149            to={`${info?.mempoolUrl}/tx/${transactionId}`}
 150            className="underline flex items-center mt-2"
 151          >
 152            View on Mempool
 153            <ExternalLinkIcon className="size-4 ml-2" />
 154          </ExternalLink>
 155          <p>Your on-chain balance in Alby Hub may take some time to update.</p>
 156        </div>
 157      );
 158    }
 159  
 160    if (!info || !balances || (!recommendedFees && !mempoolError)) {
 161      return <Loading />;
 162    }
 163  
 164    if (balances.onchain.spendableSat <= ONCHAIN_DUST_SATS) {
 165      return (
 166        <p>
 167          You currently don't have enough sats to pay for an onchain transaction.
 168        </p>
 169      );
 170    }
 171  
 172    return (
 173      <div className="grid gap-5">
 174        <AppHeader
 175          pageTitle="Withdraw On-Chain Balance"
 176          title="Withdraw On-Chain Balance"
 177          description="Withdraw your onchain funds to another bitcoin wallet"
 178        />
 179  
 180        <div className="max-w-lg">
 181          <p>
 182            Your on-chain balance will be withdrawn to the onchain bitcoin wallet
 183            address you specify below.
 184          </p>
 185          <form
 186            onSubmit={(e) => {
 187              e.preventDefault();
 188              setConfirmDialogOpen(true);
 189            }}
 190            className="grid gap-5 mt-4"
 191          >
 192            <div>
 193              <div className="grid gap-2">
 194                <Label htmlFor="amount">Amount</Label>
 195                <div className="flex justify-between items-center">
 196                  <p className="text-sm text-muted-foreground sensitive slashed-zero">
 197                    Current onchain balance:{" "}
 198                    <FormattedBitcoinAmount
 199                      amountMsat={balances.onchain.spendableSat * 1000}
 200                    />
 201                  </p>
 202                  <div className="flex items-center gap-1">
 203                    <Checkbox
 204                      id="send-all"
 205                      onCheckedChange={() => setSendAll(!sendAll)}
 206                    />
 207                    <Label htmlFor="send-all" className="text-xs cursor-pointer">
 208                      Send All
 209                    </Label>
 210                  </div>
 211                </div>
 212                {!sendAll && (
 213                  <Input
 214                    id="amount"
 215                    type="number"
 216                    value={amountSat}
 217                    required
 218                    onChange={(e) => {
 219                      setAmountSat(e.target.value);
 220                    }}
 221                  />
 222                )}
 223              </div>
 224              <MempoolAlert className="mt-4" />
 225              {sendAll && (
 226                <Alert className="mt-4" variant="warning">
 227                  <AlertTriangleIcon />
 228                  <AlertTitle>Entire wallet balance will be sent</AlertTitle>
 229                  <AlertDescription>
 230                    Your entire wallet balance will be sent minus onchain
 231                    transaction fees. The exact amount cannot be determined until
 232                    the payment is made.
 233                  </AlertDescription>
 234                </Alert>
 235              )}
 236              <AnchorReserveAlert
 237                amountSat={sendAll ? balances.onchain.spendableSat : +amountSat}
 238                className="mt-4"
 239              />
 240            </div>
 241            <div className="grid gap-2">
 242              <Label htmlFor="onchain-address">Onchain Address</Label>
 243              <Input
 244                id="onchain-address"
 245                type="text"
 246                value={onchainAddress}
 247                required
 248                onChange={(e) => {
 249                  setOnchainAddress(e.target.value);
 250                }}
 251              />
 252              <p className="text-sm text-muted-foreground">
 253                Please double-check the destination address. This transaction
 254                cannot be reversed.
 255              </p>
 256            </div>
 257            {(info?.backendType === "LDK" || info?.backendType === "LND") && (
 258              <>
 259                {showAdvanced && (
 260                  <div className="grid gap-2">
 261                    <Label htmlFor="fee-rate">Fee Rate (Sat/vB)</Label>
 262                    {mempoolError && (
 263                      <div className="text-muted-foreground text-xs flex gap-1 items-center">
 264                        <AlertTriangleIcon className="h-3 w-3" />
 265                        Failed to fetch fee estimates. Try refreshing the page.
 266                      </div>
 267                    )}
 268                    <Input
 269                      id="fee-rate"
 270                      type="number"
 271                      value={feeRate}
 272                      step={1}
 273                      required
 274                      min={recommendedFees?.minimumFee || 1}
 275                      onChange={(e) => {
 276                        setFeeRate(e.target.value);
 277                      }}
 278                    />
 279                    {recommendedFees && (
 280                      <div className="flex items-center mt-2 gap-4">
 281                        <Button
 282                          variant="positive"
 283                          className="rounded-full"
 284                          type="button"
 285                          onClick={() =>
 286                            setFeeRate(recommendedFees.economyFee.toString())
 287                          }
 288                        >
 289                          Low priority: {recommendedFees.economyFee}
 290                        </Button>{" "}
 291                        <Button
 292                          variant="positive"
 293                          className="rounded-full"
 294                          type="button"
 295                          onClick={() =>
 296                            setFeeRate(recommendedFees.fastestFee.toString())
 297                          }
 298                        >
 299                          High priority: {recommendedFees.fastestFee}
 300                        </Button>{" "}
 301                        <ExternalLink
 302                          to={info?.mempoolUrl}
 303                          className="text-sm text-muted-foreground underline flex items-center gap-2"
 304                        >
 305                          View on Mempool
 306                          <ExternalLinkIcon className="w-4 h-4" />
 307                        </ExternalLink>
 308                      </div>
 309                    )}
 310                  </div>
 311                )}
 312                {!showAdvanced && (
 313                  <Button
 314                    type="button"
 315                    variant="link"
 316                    className="text-muted-foreground text-xs"
 317                    onClick={() => setShowAdvanced((current) => !current)}
 318                  >
 319                    <ChevronDownIcon />
 320                    Advanced Options
 321                  </Button>
 322                )}
 323              </>
 324            )}
 325  
 326            <div>
 327              <AlertDialog
 328                onOpenChange={setConfirmDialogOpen}
 329                open={confirmDialogOpen}
 330              >
 331                <Button className="w-full">Withdraw</Button>
 332                {feeRate && (
 333                  <div className="mt-2 text-muted-foreground text-sm flex gap-1 items-center justify-center">
 334                    <InfoIcon className="h-4 w-4" />
 335                    On-chain payment will be made with{" "}
 336                    <span className="font-semibold">{feeRate} sat/vB</span> fee
 337                  </div>
 338                )}
 339  
 340                <AlertDialogContent>
 341                  <AlertDialogHeader>
 342                    <AlertDialogTitle>
 343                      Confirm Onchain Transaction
 344                    </AlertDialogTitle>
 345                    <AlertDialogDescription>
 346                      <div>
 347                        <p>Please confirm your payment to</p>
 348                        <p className="font-bold max-w-md break-anywhere">
 349                          {onchainAddress}
 350                        </p>
 351                        <p className="mt-4">
 352                          Amount:{" "}
 353                          <span className="font-bold slashed-zero">
 354                            {sendAll ? (
 355                              "entire on-chain balance"
 356                            ) : (
 357                              <>
 358                                <FormattedBitcoinAmount
 359                                  amountMsat={+amountSat * 1000}
 360                                />
 361                              </>
 362                            )}
 363                          </span>
 364                        </p>
 365                        {feeRate && (
 366                          <p className="mt-4">
 367                            Fee Rate:{" "}
 368                            <span className="font-bold slashed-zero">
 369                              {feeRate}
 370                            </span>{" "}
 371                            sat/vB
 372                          </p>
 373                        )}
 374                      </div>
 375                    </AlertDialogDescription>
 376                  </AlertDialogHeader>
 377                  <AlertDialogFooter>
 378                    <AlertDialogCancel>Cancel</AlertDialogCancel>
 379  
 380                    <LoadingButton
 381                      loading={isLoading}
 382                      onClick={() => redeemFunds()}
 383                    >
 384                      Confirm
 385                    </LoadingButton>
 386                  </AlertDialogFooter>
 387                </AlertDialogContent>
 388              </AlertDialog>
 389  
 390              <Separator className="my-4" />
 391              <FixedFloatButton from="BTC" className="w-full" variant="secondary">
 392                <ExternalLinkIcon className="size-4" />
 393                Withdraw to other Cryptocurrency
 394              </FixedFloatButton>
 395            </div>
 396          </form>
 397        </div>
 398      </div>
 399    );
 400  }
 401