SwapInStatus.tsx raw

   1  import {
   2    CircleAlertIcon,
   3    CircleCheckIcon,
   4    CircleHelpIcon,
   5    CircleXIcon,
   6    CopyIcon,
   7    ExternalLinkIcon,
   8  } from "lucide-react";
   9  import React, { useEffect, useState } from "react";
  10  import { useParams, useSearchParams } from "react-router";
  11  import { toast } from "sonner";
  12  import AppHeader from "src/components/AppHeader";
  13  import ExternalLink from "src/components/ExternalLink";
  14  import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount";
  15  import FormattedFiatAmount from "src/components/FormattedFiatAmount";
  16  import Loading from "src/components/Loading";
  17  import LottieLoading from "src/components/LottieLoading";
  18  import QRCode from "src/components/QRCode";
  19  import { Button } from "src/components/ui/button";
  20  import {
  21    Card,
  22    CardContent,
  23    CardDescription,
  24    CardHeader,
  25    CardTitle,
  26  } from "src/components/ui/card";
  27  import { ExternalLinkButton } from "src/components/ui/custom/external-link-button";
  28  import {
  29    Tooltip,
  30    TooltipContent,
  31    TooltipProvider,
  32    TooltipTrigger,
  33  } from "src/components/ui/tooltip";
  34  import { useInfo } from "src/hooks/useInfo";
  35  import { useSwap } from "src/hooks/useSwaps";
  36  import { useSyncWallet } from "src/hooks/useSyncWallet";
  37  import { copyToClipboard } from "src/lib/clipboard";
  38  import {
  39    RedeemOnchainFundsRequest,
  40    RedeemOnchainFundsResponse,
  41    SwapIn,
  42  } from "src/types";
  43  import { request } from "src/utils/request";
  44  
  45  export default function SwapInStatus() {
  46    const { data: info } = useInfo();
  47    useSyncWallet(); // ensure funds show up on node page after swap completes
  48    const { swapId } = useParams() as { swapId: string };
  49    const { data: swap } = useSwap<SwapIn>(swapId, true);
  50  
  51    const [isPaying, setPaying] = useState(false);
  52    const [searchParams] = useSearchParams();
  53  
  54    const isInternalSwap = searchParams.has("internal", "true");
  55    const [, setPaidWithAlbyHub] = React.useState(false);
  56  
  57    useEffect(() => {
  58      if (isPaying && swap?.lockupTxId) {
  59        setPaying(false);
  60      }
  61    }, [isPaying, swap?.lockupTxId]);
  62  
  63    const payWithAlbyHub = React.useCallback(() => {
  64      (async () => {
  65        setPaying(true);
  66        try {
  67          if (!swap) {
  68            throw new Error("swap not loaded");
  69          }
  70          const payload: RedeemOnchainFundsRequest = {
  71            toAddress: swap.lockupAddress,
  72            amountSat: swap.sendAmountSat,
  73          };
  74          const response = await request<RedeemOnchainFundsResponse>(
  75            "/api/wallet/redeem-onchain-funds",
  76            {
  77              method: "POST",
  78              headers: {
  79                "Content-Type": "application/json",
  80              },
  81              body: JSON.stringify(payload),
  82            }
  83          );
  84          if (!response?.txId) {
  85            throw new Error("No address in response");
  86          }
  87          console.info("Redeemed onchain funds", response);
  88        } catch (error) {
  89          console.error(error);
  90          toast.error("Failed to redeem onchain funds", {
  91            description: "" + error,
  92          });
  93          setPaying(false);
  94        }
  95      })();
  96    }, [swap]);
  97  
  98    React.useEffect(() => {
  99      // only auto-redeem while the swap is still awaiting its on-chain deposit,
 100      // otherwise a refresh/revisit of ?internal=true would submit a second
 101      // redeem request for an already-funded swap
 102      if (
 103        isInternalSwap &&
 104        swap &&
 105        swap.state === "PENDING" &&
 106        !swap.lockupTxId
 107      ) {
 108        setPaidWithAlbyHub((current) => {
 109          if (current) {
 110            return current;
 111          }
 112          setTimeout(() => {
 113            payWithAlbyHub();
 114          }, 1);
 115          return true;
 116        });
 117      }
 118    }, [isInternalSwap, payWithAlbyHub, swap]);
 119  
 120    if (!swap) {
 121      return <Loading />;
 122    }
 123  
 124    const copyPaymentHash = () => {
 125      copyToClipboard(swap.paymentHash);
 126    };
 127  
 128    const copyAddress = () => {
 129      copyToClipboard(swap.lockupAddress);
 130    };
 131  
 132    const copyAmount = () => {
 133      copyToClipboard(swap.sendAmountSat.toString());
 134    };
 135  
 136    const swapStatus = swap.state;
 137    const statusText = {
 138      SUCCESS: "Swap Successful",
 139      FAILED: "Swap Failed",
 140      REFUNDED: "Swap Refunded",
 141      PENDING: swap.lockupTxId
 142        ? "Waiting for confirmation"
 143        : isInternalSwap
 144          ? "Depositing on-chain funds"
 145          : "Waiting for deposit",
 146    };
 147  
 148    return (
 149      <div className="grid gap-5">
 150        <AppHeader pageTitle="Swap In" title="Swap In" />
 151        <div className="w-full max-w-lg">
 152          <Card className="w-full md:max-w-xs">
 153            <CardHeader>
 154              <CardTitle className="flex justify-center">
 155                {swapStatus === "PENDING" && <Loading className="w-4 h-4 mr-2" />}
 156                {statusText[swapStatus]}
 157              </CardTitle>
 158              <CardDescription className="flex items-center justify-center gap-2 text-muted-foreground text-sm">
 159                Swap ID: {swap.id}{" "}
 160                <CopyIcon
 161                  className="cursor-pointer text-muted-foreground size-4"
 162                  onClick={() => {
 163                    copyToClipboard(swap.id);
 164                  }}
 165                />
 166              </CardDescription>
 167            </CardHeader>
 168            <CardContent className="flex flex-col items-center gap-4">
 169              {swapStatus === "SUCCESS" ? (
 170                <>
 171                  <CircleCheckIcon className="w-60 h-60" />
 172                  <div className="flex flex-col gap-2 items-center">
 173                    <p className="text-xl font-bold slashed-zero text-center">
 174                      <FormattedBitcoinAmount
 175                        amountMsat={(swap.receiveAmountSat as number) * 1000}
 176                      />
 177                    </p>
 178                    <FormattedFiatAmount
 179                      amountSat={swap.receiveAmountSat as number}
 180                    />
 181                  </div>
 182                  <Button onClick={copyPaymentHash} variant="outline">
 183                    <CopyIcon />
 184                    Copy Payment Hash
 185                  </Button>
 186                </>
 187              ) : (
 188                <>
 189                  {(swapStatus === "REFUNDED" || swapStatus === "FAILED") && (
 190                    <CircleXIcon className="w-60 h-60" />
 191                  )}
 192                  {swapStatus === "PENDING" &&
 193                    (swap.lockupTxId || isInternalSwap ? (
 194                      <LottieLoading />
 195                    ) : (
 196                      <QRCode
 197                        value={`bitcoin:${swap.lockupAddress}?amount=${swap.sendAmountSat / 100_000_000}`}
 198                      />
 199                    ))}
 200                  <div className="flex flex-col gap-2 items-center">
 201                    <div className="flex items-center gap-2">
 202                      <p className="text-xl font-bold slashed-zero text-center">
 203                        <FormattedBitcoinAmount
 204                          amountMsat={swap.sendAmountSat * 1000}
 205                        />
 206                      </p>
 207                      {!swap.lockupTxId && !isInternalSwap && (
 208                        <CopyIcon
 209                          className="cursor-pointer text-muted-foreground size-4 shrink-0"
 210                          onClick={copyAmount}
 211                        />
 212                      )}
 213                    </div>
 214                    <FormattedFiatAmount amountSat={swap.sendAmountSat} />
 215                  </div>
 216                  {!swap.lockupTxId && !isInternalSwap && (
 217                    <div className="flex w-full flex-col gap-3">
 218                      {swap.state !== "FAILED" && (
 219                        <Button
 220                          className="w-full"
 221                          onClick={copyAddress}
 222                          variant="outline"
 223                        >
 224                          <CopyIcon />
 225                          Copy Address
 226                        </Button>
 227                      )}
 228                      {swap.state === "PENDING" && (
 229                        <ExternalLinkButton
 230                          to={`bitcoin:${swap.lockupAddress}?amount=${swap.sendAmountSat / 100_000_000}`}
 231                          variant="secondary"
 232                          className="w-full"
 233                        >
 234                          Open in External Wallet
 235                          <ExternalLinkIcon />
 236                        </ExternalLinkButton>
 237                      )}
 238                    </div>
 239                  )}
 240                </>
 241              )}
 242              {/* We only show status screen once bitcoin is locked up */}
 243              {swap.lockupTxId ? (
 244                <div className="flex flex-col justify-start gap-2 w-full mt-2">
 245                  {swapStatus === "SUCCESS" && (
 246                    <>
 247                      <div className="flex items-center text-muted-foreground text-sm">
 248                        <CircleCheckIcon className="w-5 h-5 mr-2 text-green-600 dark:text-emerald-500" />
 249                        Funds received via lightning
 250                      </div>
 251                      <Divider color="border-green-600 dark:border-emerald-500" />
 252                      <div className="flex items-center text-muted-foreground text-sm">
 253                        <CircleCheckIcon className="w-5 h-5 mr-2 text-green-600 dark:text-emerald-500" />
 254                        <div className="flex items-center gap-2">
 255                          <p>Onchain deposit confirmed</p>
 256                          <ExternalLink
 257                            to={`${info?.mempoolUrl}/tx/${swap.lockupTxId}`}
 258                            className="flex items-center underline text-foreground"
 259                          >
 260                            View
 261                          </ExternalLink>
 262                        </div>
 263                      </div>
 264                      <Divider color="border-green-600 dark:border-emerald-500" />
 265                    </>
 266                  )}
 267                  {swapStatus === "PENDING" && (
 268                    <>
 269                      <div className="flex items-center text-muted-foreground text-sm">
 270                        <Loading className="w-5 h-5 mr-2" />
 271                        <div className="flex items-center gap-2">
 272                          <p>Waiting for 1 on-chain confirmation...</p>
 273                          <ExternalLink
 274                            to={`${info?.mempoolUrl}/tx/${swap.lockupTxId}`}
 275                            className="flex items-center underline text-foreground"
 276                          >
 277                            View
 278                          </ExternalLink>
 279                        </div>
 280                      </div>
 281                      <Divider color="border-green-600 dark:border-emerald-500" />
 282                    </>
 283                  )}
 284                  {swapStatus === "REFUNDED" && (
 285                    <>
 286                      <div className="flex items-center text-muted-foreground text-sm">
 287                        <CircleCheckIcon className="w-5 h-5 mr-2 text-green-600 dark:text-emerald-500" />
 288                        <div className="flex items-center gap-2">
 289                          <p>Refund initiated</p>
 290                          <ExternalLink
 291                            to={`${info?.mempoolUrl}/tx/${swap.claimTxId}`}
 292                            className="flex items-center underline text-foreground"
 293                          >
 294                            View
 295                          </ExternalLink>
 296                        </div>
 297                      </div>
 298                      <Divider color="border-green-600 dark:border-emerald-500" />
 299                    </>
 300                  )}
 301                  {(swapStatus === "FAILED" || swapStatus === "REFUNDED") && (
 302                    <>
 303                      <div className="flex items-center text-muted-foreground text-sm">
 304                        <CircleAlertIcon className="w-5 h-5 mr-2 text-red-500" />
 305                        <TooltipProvider>
 306                          <Tooltip>
 307                            <TooltipTrigger>
 308                              <div className="flex items-center gap-2">
 309                                <p>Onchain deposit failed</p>
 310                                <ExternalLink
 311                                  to={`${info?.mempoolUrl}/tx/${swap.lockupTxId}`}
 312                                  className="flex items-center underline text-foreground"
 313                                >
 314                                  View
 315                                </ExternalLink>
 316                                <CircleHelpIcon className="h-4 w-4 text-muted-foreground" />
 317                              </div>
 318                            </TooltipTrigger>
 319                            <TooltipContent>
 320                              Deposit usually fails when there is an amount
 321                              mismatch or if Boltz failed to send the lightning
 322                              payment to your node.
 323                              {swapStatus !== "REFUNDED" &&
 324                                " You can use the Swap Refund button in Settings -> Debug Tools to claim the locked up bitcoin."}
 325                            </TooltipContent>
 326                          </Tooltip>
 327                        </TooltipProvider>
 328                      </div>
 329                      <Divider color="border-red-500" />
 330                    </>
 331                  )}
 332                  <div className="flex items-center text-muted-foreground text-sm">
 333                    <CircleCheckIcon className="w-5 h-5 mr-2 text-green-600 dark:text-emerald-500" />
 334                    Swap initiated
 335                  </div>
 336                </div>
 337              ) : swapStatus === "FAILED" ? (
 338                <>
 339                  <div className="flex items-center text-muted-foreground text-sm">
 340                    <CircleAlertIcon className="w-5 h-5 mr-2 text-red-500" />
 341                    <TooltipProvider>
 342                      <Tooltip>
 343                        <TooltipTrigger>
 344                          <div className="flex items-center gap-2">
 345                            <p>Onchain deposit failed</p>
 346                            <ExternalLink
 347                              to={`${info?.mempoolUrl}/address/${swap.lockupAddress}`}
 348                              className="flex items-center underline text-foreground"
 349                            >
 350                              View
 351                            </ExternalLink>
 352                            <CircleHelpIcon className="h-4 w-4 text-muted-foreground" />
 353                          </div>
 354                        </TooltipTrigger>
 355                        <TooltipContent>
 356                          Deposit usually fails when there is an amount mismatch
 357                          or if Boltz failed to send the lightning payment to your
 358                          node. You can use the Swap Refund button in Settings{" "}
 359                          {"->"} Debug Tools to claim the locked up bitcoin.
 360                        </TooltipContent>
 361                      </Tooltip>
 362                    </TooltipProvider>
 363                  </div>
 364                </>
 365              ) : null}
 366            </CardContent>
 367          </Card>
 368        </div>
 369      </div>
 370    );
 371  }
 372  
 373  const Divider = ({ color }: { color: string }) => (
 374    <div className={`ml-2.25 py-1 border-l ${color}`}></div>
 375  );
 376