index.tsx raw

   1  import {
   2    ClipboardPasteIcon,
   3    ExternalLinkIcon,
   4    MoveRightIcon,
   5    RefreshCwIcon,
   6  } from "lucide-react";
   7  import { useEffect, useState } from "react";
   8  import { useNavigate, useSearchParams } from "react-router";
   9  import { toast } from "sonner";
  10  import { AnchorReserveAlert } from "src/components/AnchorReserveAlert";
  11  import AppHeader from "src/components/AppHeader";
  12  import { CurrencyInputField } from "src/components/CurrencyInputField";
  13  import { FixedFloatButton } from "src/components/FixedFloatButton";
  14  import { FixedFloatSwapInFlow } from "src/components/FixedFloatSwapInFlow";
  15  import Loading from "src/components/Loading";
  16  import LowReceivingCapacityAlert from "src/components/LowReceivingCapacityAlert";
  17  import ResponsiveLinkButton from "src/components/ResponsiveLinkButton";
  18  import { Button } from "src/components/ui/button";
  19  import { LoadingButton } from "src/components/ui/custom/loading-button";
  20  import { Input } from "src/components/ui/input";
  21  import { Label } from "src/components/ui/label";
  22  import { RadioGroup, RadioGroupItem } from "src/components/ui/radio-group";
  23  import { Separator } from "src/components/ui/separator";
  24  import {
  25    Tabs,
  26    TabsContent,
  27    TabsList,
  28    TabsTrigger,
  29  } from "src/components/ui/tabs";
  30  import { useBalances } from "src/hooks/useBalances";
  31  import { useInfo } from "src/hooks/useInfo";
  32  import { useSwapInfo } from "src/hooks/useSwaps";
  33  import {
  34    CreateInvoiceRequest,
  35    InitiateSwapRequest,
  36    SwapResponse,
  37    Transaction,
  38  } from "src/types";
  39  import { openLink } from "src/utils/openLink";
  40  import { request } from "src/utils/request";
  41  
  42  export default function Swap() {
  43    const [searchParams, setSearchParams] = useSearchParams();
  44    const [tab, setTab] = useState(searchParams.get("type") || "in");
  45  
  46    useEffect(() => {
  47      const newTabValue = searchParams.get("type");
  48      if (newTabValue) {
  49        setTab(newTabValue);
  50        setSearchParams({}, { replace: true });
  51      }
  52    }, [searchParams, setSearchParams]);
  53  
  54    return (
  55      <div className="grid gap-5">
  56        <AppHeader
  57          pageTitle="Swap"
  58          title="Swap"
  59          contentRight={
  60            tab === "out" && (
  61              <ResponsiveLinkButton
  62                to="/wallet/swap/auto"
  63                variant="outline"
  64                icon={RefreshCwIcon}
  65                text="Auto Swap"
  66              />
  67            )
  68          }
  69        />
  70        <Tabs value={tab} onValueChange={setTab} className="w-full max-w-lg">
  71          <TabsList className="w-full mb-4">
  72            <TabsTrigger value="in" className="flex gap-2 items-center w-full">
  73              Swap In
  74            </TabsTrigger>
  75            <TabsTrigger value="out" className="flex gap-2 items-center w-full">
  76              Swap Out
  77            </TabsTrigger>
  78          </TabsList>
  79          <TabsContent value="in">
  80            <SwapInForm />
  81          </TabsContent>
  82          <TabsContent value="out">
  83            <SwapOutForm />
  84          </TabsContent>
  85        </Tabs>
  86      </div>
  87    );
  88  }
  89  
  90  function SwapInForm() {
  91    const [swapFrom, setSwapFrom] = useState<"internal" | "external" | "crypto">(
  92      "external"
  93    );
  94    const { data: info, hasChannelManagement } = useInfo();
  95    const { data: balances } = useBalances();
  96    const { data: swapInfo } = useSwapInfo("in");
  97    const navigate = useNavigate();
  98  
  99    const [swapAmountSat, setSwapAmountSat] = useState("");
 100    const [loading, setLoading] = useState(false);
 101    const [cryptoTransaction, setCryptoTransaction] =
 102      useState<Transaction | null>(null);
 103  
 104    const onSubmit = async (e: React.FormEvent) => {
 105      e.preventDefault();
 106  
 107      try {
 108        setLoading(true);
 109        if (swapFrom === "crypto") {
 110          const tx = await request<Transaction>("/api/invoices", {
 111            method: "POST",
 112            headers: {
 113              "Content-Type": "application/json",
 114            },
 115            body: JSON.stringify({
 116              amountMsat: (parseInt(swapAmountSat) || 0) * 1000,
 117              description: "Fixed Float swap",
 118            } as CreateInvoiceRequest),
 119          });
 120          if (!tx?.invoice) {
 121            throw new Error("Failed to create invoice");
 122          }
 123          setCryptoTransaction(tx);
 124          openLink(
 125            `https://ff.io/?to=BTCLN&address=${encodeURIComponent(tx.invoice)}&ref=qnnjvywb`
 126          );
 127          toast("Initiated swap");
 128          return;
 129        }
 130  
 131        const payload: InitiateSwapRequest = {
 132          swapAmountSat: parseInt(swapAmountSat),
 133        };
 134        const swapInResponse = await request<SwapResponse>("/api/swaps/in", {
 135          method: "POST",
 136          headers: {
 137            "Content-Type": "application/json",
 138          },
 139          body: JSON.stringify(payload),
 140        });
 141        if (!swapInResponse) {
 142          throw new Error("Error swapping in");
 143        }
 144        navigate(
 145          `/wallet/swap/in/status/${swapInResponse.swapId}${swapFrom === "internal" ? "?internal=true" : ""}`
 146        );
 147        toast("Initiated swap");
 148      } catch (error) {
 149        toast.error("Failed to initiate swap", {
 150          description: (error as Error).message,
 151        });
 152      } finally {
 153        setLoading(false);
 154      }
 155    };
 156  
 157    if (!info || !balances || !swapInfo) {
 158      return <Loading />;
 159    }
 160  
 161    const spendableOnchainBalance = balances.onchain.spendableSat;
 162    const isInternalSwap = swapFrom === "internal";
 163    const isCryptoSwappingState =
 164      swapFrom === "crypto" && cryptoTransaction !== null;
 165  
 166    return (
 167      <form onSubmit={onSubmit} className="flex flex-col gap-6">
 168        {!isCryptoSwappingState && (
 169          <>
 170            <div>
 171              <h2 className="font-medium text-foreground flex items-center gap-1">
 172                On-chain <MoveRightIcon /> Lightning
 173              </h2>
 174              <p className="mt-1 text-muted-foreground">
 175                Swap on-chain funds into your lightning balance.
 176              </p>
 177            </div>
 178            <div className="grid gap-1.5">
 179              {hasChannelManagement &&
 180                parseInt(swapAmountSat || "0") * 1000 >=
 181                  0.8 * balances.lightning.totalReceivableMsat && (
 182                  <div className="mb-4">
 183                    <LowReceivingCapacityAlert />
 184                  </div>
 185                )}
 186  
 187              {isInternalSwap && (
 188                <AnchorReserveAlert amountSat={+swapAmountSat} />
 189              )}
 190              <CurrencyInputField
 191                label="Swap amount"
 192                autoFocus
 193                valueSat={swapAmountSat}
 194                onValueSatChange={setSwapAmountSat}
 195                minSat={swapFrom !== "crypto" ? swapInfo.minAmountSat : undefined}
 196                maxSat={
 197                  swapFrom === "crypto"
 198                    ? hasChannelManagement
 199                      ? balances.lightning.totalReceivableSat * 0.99
 200                      : undefined
 201                    : Math.min(
 202                        swapInfo.maxAmountSat,
 203                        ...(isInternalSwap ? [spendableOnchainBalance] : []),
 204                        ...(hasChannelManagement
 205                          ? [balances.lightning.totalReceivableSat * 0.99]
 206                          : [])
 207                      )
 208                }
 209                required
 210                contextRows={[
 211                  ...(isInternalSwap
 212                    ? [
 213                        {
 214                          label: "Available on-chain",
 215                          amountSat: spendableOnchainBalance,
 216                        },
 217                      ]
 218                    : []),
 219                  ...(hasChannelManagement
 220                    ? [
 221                        {
 222                          label: "Receive limit",
 223                          amountSat: balances.lightning.totalReceivableSat,
 224                        },
 225                      ]
 226                    : []),
 227                ]}
 228              />
 229            </div>
 230            <div className="flex flex-col gap-4">
 231              <Label>Swap from</Label>
 232              <RadioGroup
 233                defaultValue="external"
 234                value={swapFrom}
 235                onValueChange={(value: "internal" | "external" | "crypto") => {
 236                  setSwapFrom(value);
 237                }}
 238                className="flex gap-4 flex-wrap"
 239              >
 240                <div className="flex items-start space-x-2 mb-2">
 241                  <RadioGroupItem
 242                    value="internal"
 243                    id="internal"
 244                    className="shrink-0"
 245                  />
 246                  <Label htmlFor="internal" className="cursor-pointer">
 247                    On-chain balance
 248                  </Label>
 249                </div>
 250                <div className="flex items-start space-x-2">
 251                  <RadioGroupItem
 252                    value="external"
 253                    id="external"
 254                    className="shrink-0"
 255                  />
 256                  <Label htmlFor="external" className="cursor-pointer">
 257                    External on-chain wallet
 258                  </Label>
 259                </div>
 260                <div className="flex items-start space-x-2">
 261                  <RadioGroupItem
 262                    value="crypto"
 263                    id="crypto"
 264                    className="shrink-0"
 265                  />
 266                  <Label htmlFor="crypto" className="cursor-pointer">
 267                    Other Cryptocurrency
 268                  </Label>
 269                </div>
 270              </RadioGroup>
 271            </div>
 272          </>
 273        )}
 274  
 275        {swapFrom === "crypto" ? (
 276          <FixedFloatSwapInFlow
 277            loading={loading}
 278            transaction={cryptoTransaction}
 279            resetLabel="Swap Another Amount"
 280            onReset={() => {
 281              setCryptoTransaction(null);
 282              setSwapAmountSat("");
 283            }}
 284          />
 285        ) : (
 286          <>
 287            <div className="flex items-center justify-between border-t pt-4">
 288              <Label>Fee</Label>
 289              <p className="text-muted-foreground text-sm">
 290                {swapInfo.albyServiceFee + swapInfo.boltzServiceFee}% + on-chain
 291                fees
 292              </p>
 293            </div>
 294            <div className="grid gap-2">
 295              <LoadingButton className="w-full" loading={loading}>
 296                Swap In
 297              </LoadingButton>
 298              <p className="text-xs text-muted-foreground text-center">
 299                powered by{" "}
 300                <span className="font-medium text-foreground">
 301                  boltz.exchange
 302                </span>
 303              </p>
 304            </div>
 305          </>
 306        )}
 307      </form>
 308    );
 309  }
 310  
 311  function SwapOutForm() {
 312    const { data: swapInfo } = useSwapInfo("out");
 313    const navigate = useNavigate();
 314    const { data: balances } = useBalances();
 315  
 316    const [isInternalSwap, setInternalSwap] = useState(true);
 317    const [swapAmountSat, setSwapAmountSat] = useState("");
 318    const [destination, setDestination] = useState("");
 319    const [loading, setLoading] = useState(false);
 320  
 321    const onSubmit = async (e: React.FormEvent) => {
 322      e.preventDefault();
 323  
 324      try {
 325        setLoading(true);
 326        const payload: InitiateSwapRequest = {
 327          swapAmountSat: parseInt(swapAmountSat),
 328          destination,
 329        };
 330        const swapOutResponse = await request<SwapResponse>("/api/swaps/out", {
 331          method: "POST",
 332          headers: {
 333            "Content-Type": "application/json",
 334          },
 335          body: JSON.stringify(payload),
 336        });
 337        if (!swapOutResponse) {
 338          throw new Error("Error swapping out");
 339        }
 340        navigate(`/wallet/swap/out/status/${swapOutResponse.swapId}`);
 341        toast("Initiated swap");
 342      } catch (error) {
 343        toast.error("Failed to initiate swap", {
 344          description: (error as Error).message,
 345        });
 346      } finally {
 347        setLoading(false);
 348      }
 349    };
 350  
 351    const paste = async () => {
 352      const text = await navigator.clipboard.readText();
 353      setDestination(text.trim());
 354    };
 355  
 356    if (!balances || !swapInfo) {
 357      return <Loading />;
 358    }
 359  
 360    return (
 361      <form onSubmit={onSubmit} className="flex flex-col gap-6">
 362        <div>
 363          <h2 className="font-medium text-foreground flex items-center gap-1">
 364            Lightning <MoveRightIcon /> On-chain
 365          </h2>
 366          <p className="mt-1 text-muted-foreground">
 367            Swap bitcoin lightning into your on-chain balance.
 368          </p>
 369        </div>
 370        <div className="grid gap-1.5">
 371          <CurrencyInputField
 372            label="Swap amount"
 373            autoFocus
 374            valueSat={swapAmountSat}
 375            onValueSatChange={setSwapAmountSat}
 376            minSat={swapInfo.minAmountSat}
 377            maxSat={Math.min(
 378              swapInfo.maxAmountSat,
 379              balances.lightning.totalSpendableSat
 380            )}
 381            required
 382            contextRows={[
 383              {
 384                label: "Lightning balance",
 385                amountSat: balances.lightning.totalSpendableSat,
 386              },
 387              {
 388                label: "Minimum",
 389                amountSat: swapInfo.minAmountSat,
 390              },
 391            ]}
 392          />
 393        </div>
 394        <div className="flex flex-col gap-4">
 395          <Label>Swap to</Label>
 396          <RadioGroup
 397            defaultValue="normal"
 398            value={isInternalSwap ? "internal" : "external"}
 399            onValueChange={() => {
 400              setDestination("");
 401              setInternalSwap(!isInternalSwap);
 402            }}
 403            className="flex gap-4 flex-row"
 404          >
 405            <div className="flex items-start space-x-2 mb-2">
 406              <RadioGroupItem
 407                value="internal"
 408                id="internal"
 409                className="shrink-0"
 410              />
 411              <Label htmlFor="internal" className="cursor-pointer">
 412                On-chain balance
 413              </Label>
 414            </div>
 415            <div className="flex items-start space-x-2">
 416              <RadioGroupItem
 417                value="external"
 418                id="external"
 419                className="shrink-0"
 420              />
 421              <Label htmlFor="external" className="cursor-pointer">
 422                External on-chain wallet
 423              </Label>
 424            </div>
 425          </RadioGroup>
 426        </div>
 427        {!isInternalSwap && (
 428          <div className="grid gap-1.5">
 429            <Label>Receiving on-chain address</Label>
 430            <div className="flex gap-2">
 431              <Input
 432                placeholder="bc1..."
 433                value={destination}
 434                onChange={(e) => setDestination(e.target.value)}
 435                required
 436              />
 437              <Button
 438                type="button"
 439                variant="outline"
 440                className="px-2"
 441                onClick={paste}
 442              >
 443                <ClipboardPasteIcon className="w-4 h-4" />
 444              </Button>
 445            </div>
 446          </div>
 447        )}
 448  
 449        <div className="flex items-center justify-between border-t pt-4">
 450          <Label>Fee</Label>
 451          <p className="text-muted-foreground text-sm">
 452            {swapInfo.albyServiceFee + swapInfo.boltzServiceFee}% + on-chain fees
 453          </p>
 454        </div>
 455        <div className="grid gap-2">
 456          <LoadingButton className="w-full" loading={loading}>
 457            Swap Out
 458          </LoadingButton>
 459          <p className="text-xs text-muted-foreground text-center">
 460            powered by{" "}
 461            <span className="font-medium text-foreground">boltz.exchange</span>
 462          </p>
 463        </div>
 464        <Separator className="my-2" />
 465        <FixedFloatButton from="BTCLN" className="w-full" variant="secondary">
 466          <ExternalLinkIcon className="size-4" />
 467          Swap out to other Cryptocurrency
 468        </FixedFloatButton>
 469      </form>
 470    );
 471  }
 472