AutoSwap.tsx raw

   1  import {
   2    ArrowDownUpIcon,
   3    ClipboardPasteIcon,
   4    ClockIcon,
   5    CopyIcon,
   6    MoveRightIcon,
   7    XCircleIcon,
   8  } from "lucide-react";
   9  import { useState } from "react";
  10  import { toast } from "sonner";
  11  import AppHeader from "src/components/AppHeader";
  12  import { CurrencyInputField } from "src/components/CurrencyInputField";
  13  import ExternalLink from "src/components/ExternalLink";
  14  import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount";
  15  import Loading from "src/components/Loading";
  16  import PasswordInput from "src/components/password/PasswordInput";
  17  import ResponsiveLinkButton from "src/components/ResponsiveLinkButton";
  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 { LoadingButton } from "src/components/ui/custom/loading-button";
  29  import { Input } from "src/components/ui/input";
  30  import { Label } from "src/components/ui/label";
  31  import { RadioGroup, RadioGroupItem } from "src/components/ui/radio-group";
  32  import { useAutoSwapsConfig, useSwapInfo } from "src/hooks/useSwaps";
  33  import { copyToClipboard } from "src/lib/clipboard";
  34  import { AutoSwapConfig, AutoSwapRequest } from "src/types";
  35  import { request } from "src/utils/request";
  36  
  37  export default function AutoSwap() {
  38    const { data: swapConfig } = useAutoSwapsConfig();
  39  
  40    if (!swapConfig) {
  41      return <Loading />;
  42    }
  43  
  44    return (
  45      <div className="grid gap-5">
  46        <AppHeader
  47          pageTitle="Auto Swap Out"
  48          title="Auto Swap Out"
  49          contentRight={
  50            <ResponsiveLinkButton
  51              to="/wallet/swap"
  52              variant="outline"
  53              icon={ArrowDownUpIcon}
  54              text="Swap"
  55            />
  56          }
  57        />
  58        <div className="w-full lg:max-w-lg min-w-0">
  59          {swapConfig.enabled ? (
  60            <ActiveSwapOutConfig swapConfig={swapConfig} />
  61          ) : (
  62            <AutoSwapOutForm />
  63          )}
  64        </div>
  65      </div>
  66    );
  67  }
  68  
  69  function AutoSwapOutForm() {
  70    const { mutate } = useAutoSwapsConfig();
  71    const { data: swapInfo } = useSwapInfo("out");
  72  
  73    const [isInternalSwap, setInternalSwap] = useState(true);
  74    const [balanceThresholdSat, setBalanceThresholdSat] = useState("");
  75    const [swapAmountSat, setSwapAmountSat] = useState("");
  76    const [destination, setDestination] = useState("");
  77    const [externalType, setExternalType] = useState<"address" | "xpub">(
  78      "address"
  79    );
  80    const [unlockPassword, setUnlockPassword] = useState("");
  81    const [showUnlockPasswordDialog, setShowUnlockPasswordDialog] =
  82      useState(false);
  83    const [loading, setLoading] = useState(false);
  84  
  85    const onSubmit = async (e: React.FormEvent) => {
  86      e.preventDefault();
  87  
  88      if (Number(swapAmountSat) > Number(balanceThresholdSat)) {
  89        toast.info(
  90          "Balance threshold must be greater than or equal to swap amount"
  91        );
  92        return;
  93      }
  94  
  95      if (externalType === "xpub" && !isInternalSwap) {
  96        setShowUnlockPasswordDialog(true);
  97        return;
  98      }
  99  
 100      await submitAutoSwap();
 101    };
 102  
 103    const onConfirmUnlockPassword = async (e: React.FormEvent) => {
 104      e.preventDefault();
 105      await submitAutoSwap(unlockPassword);
 106    };
 107  
 108    const submitAutoSwap = async (password?: string) => {
 109      try {
 110        setLoading(true);
 111        const payload: AutoSwapRequest = {
 112          swapAmountSat: parseInt(swapAmountSat),
 113          balanceThresholdSat: parseInt(balanceThresholdSat),
 114          destination,
 115          destinationType: !isInternalSwap ? externalType : undefined,
 116          unlockPassword: password,
 117        };
 118        await request("/api/autoswap", {
 119          method: "POST",
 120          headers: {
 121            "Content-Type": "application/json",
 122          },
 123          body: JSON.stringify(payload),
 124        });
 125        setUnlockPassword("");
 126        setShowUnlockPasswordDialog(false);
 127        toast("Auto swap enabled successfully");
 128        await mutate();
 129      } catch (error) {
 130        toast("Failed to save auto swap settings", {
 131          description: (error as Error).message,
 132        });
 133      } finally {
 134        setLoading(false);
 135      }
 136    };
 137  
 138    const paste = async () => {
 139      const text = await navigator.clipboard.readText();
 140      setDestination(text.trim());
 141    };
 142  
 143    if (!swapInfo) {
 144      return <Loading />;
 145    }
 146  
 147    return (
 148      <>
 149        <form onSubmit={onSubmit} className="flex flex-col gap-6">
 150          <div>
 151            <h2 className="font-medium text-foreground flex items-center gap-1">
 152              Lightning <MoveRightIcon /> On-chain
 153            </h2>
 154            <p className="mt-1 text-muted-foreground">
 155              Setup automatic swap of lightning funds into your on-chain balance
 156              every time a set threshold is reached.
 157            </p>
 158            <p className="mt-2 text-muted-foreground flex gap-2 items-center text-sm">
 159              <ClockIcon className="w-4 h-4" />
 160              Swaps will be made once per hour
 161            </p>
 162          </div>
 163  
 164          <CurrencyInputField
 165            label="Lightning balance threshold"
 166            valueSat={balanceThresholdSat}
 167            onValueSatChange={setBalanceThresholdSat}
 168            minSat={Number(swapAmountSat) || undefined}
 169            required
 170            description="Swap out as soon as this amount is reached"
 171          />
 172  
 173          <CurrencyInputField
 174            label="Swap amount"
 175            valueSat={swapAmountSat}
 176            onValueSatChange={setSwapAmountSat}
 177            minSat={swapInfo.minAmountSat}
 178            maxSat={swapInfo.maxAmountSat}
 179            required
 180            contextRows={[
 181              {
 182                label: "Minimum",
 183                amountSat: swapInfo.minAmountSat,
 184              },
 185            ]}
 186          />
 187          <div className="flex flex-col gap-4">
 188            <Label>Swap to</Label>
 189            <RadioGroup
 190              value={isInternalSwap ? "internal" : "external"}
 191              onValueChange={() => {
 192                setDestination("");
 193                setInternalSwap(!isInternalSwap);
 194              }}
 195              className="flex gap-4 flex-row"
 196            >
 197              <div className="flex items-start space-x-2 mb-2">
 198                <RadioGroupItem
 199                  value="internal"
 200                  id="internal"
 201                  className="shrink-0"
 202                />
 203                <Label htmlFor="internal" className="cursor-pointer">
 204                  On-chain balance
 205                </Label>
 206              </div>
 207              <div className="flex items-start space-x-2">
 208                <RadioGroupItem
 209                  value="external"
 210                  id="external"
 211                  className="shrink-0"
 212                />
 213                <Label htmlFor="external" className="cursor-pointer">
 214                  External on-chain wallet
 215                </Label>
 216              </div>
 217            </RadioGroup>
 218          </div>
 219          {!isInternalSwap && (
 220            <div className="grid gap-4">
 221              <div className="flex flex-col gap-3">
 222                <Label>Destination Type</Label>
 223                <RadioGroup
 224                  value={externalType}
 225                  onValueChange={(value) => {
 226                    setExternalType(value as "address" | "xpub");
 227                    setDestination("");
 228                  }}
 229                  className="flex gap-4 flex-row"
 230                >
 231                  <div className="flex items-start space-x-2">
 232                    <RadioGroupItem
 233                      value="address"
 234                      id="address"
 235                      className="shrink-0"
 236                    />
 237                    <div className="grid gap-1.5">
 238                      <Label htmlFor="address" className="cursor-pointer">
 239                        Single Address
 240                      </Label>
 241                      <p className="text-xs text-muted-foreground">
 242                        Send to the same address each time
 243                      </p>
 244                    </div>
 245                  </div>
 246                  <div className="flex items-start space-x-2">
 247                    <RadioGroupItem value="xpub" id="xpub" className="shrink-0" />
 248                    <div className="grid gap-1.5">
 249                      <Label htmlFor="xpub" className="cursor-pointer">
 250                        XPUB
 251                      </Label>
 252                      <p className="text-xs text-muted-foreground">
 253                        Generate new addresses from extended public key
 254                      </p>
 255                    </div>
 256                  </div>
 257                </RadioGroup>
 258              </div>
 259              <div className="grid gap-1.5">
 260                <Label>
 261                  {externalType === "address"
 262                    ? "Receiving on-chain address"
 263                    : "Extended Public Key (XPUB)"}
 264                </Label>
 265                <div className="flex gap-2">
 266                  <Input
 267                    placeholder={
 268                      externalType === "address" ? "bc1..." : "xpub..."
 269                    }
 270                    value={destination}
 271                    onChange={(e) => setDestination(e.target.value)}
 272                    required
 273                  />
 274                  <Button
 275                    type="button"
 276                    variant="outline"
 277                    className="px-2"
 278                    onClick={paste}
 279                  >
 280                    <ClipboardPasteIcon className="w-4 h-4" />
 281                  </Button>
 282                </div>
 283                <p className="text-xs text-muted-foreground">
 284                  {externalType === "address"
 285                    ? "Enter a Bitcoin address to receive swapped funds"
 286                    : "Enter an XPUB to automatically generate new addresses for each swap. You will be asked to enter your unlock password to encrypt it safely"}
 287                </p>
 288              </div>
 289            </div>
 290          )}
 291  
 292          <div className="flex items-center justify-between border-t pt-4">
 293            <Label>Fee</Label>
 294            <p className="text-muted-foreground text-sm">
 295              {swapInfo.albyServiceFee + swapInfo.boltzServiceFee}% + on-chain
 296              fees
 297            </p>
 298          </div>
 299          <div className="grid gap-1">
 300            <LoadingButton className="w-full" loading={loading}>
 301              Begin Auto Swap
 302            </LoadingButton>
 303            <p className="text-xs text-muted-foreground text-right">
 304              powered by{" "}
 305              <ExternalLink
 306                to="https://boltz.exchange"
 307                className="font-medium text-foreground"
 308              >
 309                boltz.exchange
 310              </ExternalLink>
 311            </p>
 312          </div>
 313        </form>
 314        <AlertDialog
 315          open={showUnlockPasswordDialog}
 316          onOpenChange={(open) => {
 317            setShowUnlockPasswordDialog(open);
 318            if (!open) {
 319              setUnlockPassword("");
 320            }
 321          }}
 322        >
 323          <AlertDialogContent>
 324            <form onSubmit={onConfirmUnlockPassword}>
 325              <AlertDialogHeader>
 326                <AlertDialogTitle>Confirm Auto Swap Setup</AlertDialogTitle>
 327                <AlertDialogDescription>
 328                  <div className="flex flex-col gap-4">
 329                    <p>
 330                      Please enter your unlock password to encrypt and securely
 331                      store the XPUB.
 332                    </p>
 333                    <div className="grid gap-1.5">
 334                      <Label htmlFor="unlockPassword">Unlock Password</Label>
 335                      <PasswordInput
 336                        id="unlockPassword"
 337                        onChange={setUnlockPassword}
 338                        autoFocus
 339                        value={unlockPassword}
 340                      />
 341                    </div>
 342                  </div>
 343                </AlertDialogDescription>
 344              </AlertDialogHeader>
 345              <AlertDialogFooter className="mt-3">
 346                <AlertDialogCancel>Cancel</AlertDialogCancel>
 347                <Button type="submit" disabled={!unlockPassword || loading}>
 348                  Confirm
 349                </Button>
 350              </AlertDialogFooter>
 351            </form>
 352          </AlertDialogContent>
 353        </AlertDialog>
 354      </>
 355    );
 356  }
 357  
 358  function ActiveSwapOutConfig({ swapConfig }: { swapConfig: AutoSwapConfig }) {
 359    const { mutate } = useAutoSwapsConfig();
 360    const { data: swapInfo } = useSwapInfo("out");
 361  
 362    const [loading, setLoading] = useState(false);
 363  
 364    const onDeactivate = async () => {
 365      try {
 366        setLoading(true);
 367        await request(`/api/autoswap`, {
 368          method: "DELETE",
 369          headers: {
 370            "Content-Type": "application/json",
 371          },
 372        });
 373        toast("Deactivated auto swap successfully");
 374        await mutate();
 375      } catch (error) {
 376        toast.error("Deactivating auto swaps failed", {
 377          description: (error as Error).message,
 378        });
 379      } finally {
 380        setLoading(false);
 381      }
 382    };
 383  
 384    return (
 385      <>
 386        <h2 className="font-medium text-foreground flex items-center gap-1">
 387          Active Lightning <MoveRightIcon /> On-chain Swap
 388        </h2>
 389        <p className="mt-1 text-muted-foreground">
 390          Alby Hub will try to perform a swap every time the balance reaches the
 391          threshold.
 392        </p>
 393        <p className="mt-2 text-muted-foreground flex gap-2 items-center text-sm">
 394          <ClockIcon className="w-4 h-4" />
 395          Swaps will be made once per hour
 396        </p>
 397  
 398        <div className="my-6 space-y-4 text-sm">
 399          <div className="flex justify-between items-center gap-2">
 400            <span className="font-medium">Type</span>
 401            <span className="truncate text-muted-foreground text-right">
 402              Lightning to On-chain
 403            </span>
 404          </div>
 405          <div className="flex justify-between items-center gap-2">
 406            <div className="font-medium">Destination</div>
 407            <div className="flex min-w-0 items-center justify-end gap-2 text-muted-foreground">
 408              <div className="truncate text-right">
 409                {swapConfig.destination || "On-chain Balance"}
 410              </div>
 411              {swapConfig.destination && (
 412                <CopyIcon
 413                  className="cursor-pointer size-4 shrink-0"
 414                  onClick={() => copyToClipboard(swapConfig.destination)}
 415                />
 416              )}
 417            </div>
 418          </div>
 419          <div className="flex justify-between items-center gap-2">
 420            <span className="font-medium truncate">
 421              Lightning balance threshold
 422            </span>
 423            <span className="shrink-0 text-muted-foreground text-right">
 424              <FormattedBitcoinAmount
 425                amountMsat={swapConfig.balanceThresholdSat * 1000}
 426              />
 427            </span>
 428          </div>
 429          <div className="flex justify-between items-center gap-2">
 430            <span className="font-medium truncate">Swap amount</span>
 431            <span className="shrink-0 text-muted-foreground text-right">
 432              <FormattedBitcoinAmount
 433                amountMsat={swapConfig.swapAmountSat * 1000}
 434              />
 435            </span>
 436          </div>
 437          <div className="flex justify-between items-center gap-2">
 438            <span className="font-medium">Fee</span>
 439            {swapInfo ? (
 440              <span className="truncate text-muted-foreground text-right">
 441                {swapInfo.albyServiceFee + swapInfo.boltzServiceFee}% + on-chain
 442                fees
 443              </span>
 444            ) : (
 445              <Loading className="w-4 h-4" />
 446            )}
 447          </div>
 448        </div>
 449        <Button onClick={onDeactivate} disabled={loading} variant="outline">
 450          <XCircleIcon />
 451          Deactivate Auto Swap
 452        </Button>
 453      </>
 454    );
 455  }
 456