Onchain.tsx raw

   1  import {
   2    AlertTriangleIcon,
   3    ExternalLinkIcon,
   4    InfoIcon,
   5    PencilIcon,
   6    XIcon,
   7  } from "lucide-react";
   8  import React from "react";
   9  import { Link, useLocation, useNavigate } from "react-router";
  10  import { toast } from "sonner";
  11  import { AnchorReserveAlert } from "src/components/AnchorReserveAlert";
  12  import AppHeader from "src/components/AppHeader";
  13  import { CurrencyInputField } from "src/components/CurrencyInputField";
  14  import ExternalLink from "src/components/ExternalLink";
  15  import { InsufficientLightningBalanceAlert } from "src/components/InsufficientLightningBalanceAlert";
  16  import Loading from "src/components/Loading";
  17  import { MempoolAlert } from "src/components/MempoolAlert";
  18  import { Alert, AlertDescription, AlertTitle } from "src/components/ui/alert";
  19  import { Button } from "src/components/ui/button";
  20  import { LinkButton } from "src/components/ui/custom/link-button";
  21  import { LoadingButton } from "src/components/ui/custom/loading-button";
  22  import { Input } from "src/components/ui/input";
  23  import { Label } from "src/components/ui/label";
  24  import { Switch } from "src/components/ui/switch";
  25  import { ONCHAIN_DUST_SATS } from "src/constants";
  26  import { useBalances } from "src/hooks/useBalances";
  27  import { useInfo } from "src/hooks/useInfo";
  28  import { useMempoolApi } from "src/hooks/useMempoolApi";
  29  import { useSwapInfo } from "src/hooks/useSwaps";
  30  import {
  31    InitiateSwapRequest,
  32    RedeemOnchainFundsRequest,
  33    RedeemOnchainFundsResponse,
  34    SwapResponse,
  35  } from "src/types";
  36  import { request } from "src/utils/request";
  37  
  38  export default function Onchain() {
  39    const { state } = useLocation();
  40    const navigate = useNavigate();
  41    const [isSwap, setSwap] = React.useState(false);
  42    const address = state?.args?.address as string;
  43    const initialAmountSat = (state?.args?.amountSat as string | undefined) ?? "";
  44    const [amountSat, setAmountSat] = React.useState(initialAmountSat);
  45  
  46    React.useEffect(() => {
  47      if (!address) {
  48        navigate("/wallet/send");
  49      }
  50    }, [navigate, address]);
  51  
  52    if (!address) {
  53      return <Loading />;
  54    }
  55  
  56    return (
  57      <div className="grid gap-4">
  58        <AppHeader pageTitle="Send to On-chain" title="Send to On-chain" />
  59        <div className="grid gap-6 md:max-w-lg">
  60          <MempoolAlert />
  61          <div className="grid gap-2">
  62            <div className="text-sm font-medium">Recipient</div>
  63            <div className="flex items-center justify-between">
  64              <div className="flex flex-wrap gap-2 items-center font-mono text-sm">
  65                {address.match(/.{1,4}/g)?.map((word, index) => {
  66                  if (index % 2 === 0) {
  67                    return (
  68                      <span key={index} className="text-foreground">
  69                        {word}
  70                      </span>
  71                    );
  72                  } else {
  73                    return (
  74                      <span key={index} className="text-muted-foreground">
  75                        {word}
  76                      </span>
  77                    );
  78                  }
  79                })}
  80              </div>
  81              <Link to="/wallet/send">
  82                <XIcon className="w-4 h-4 cursor-pointer text-muted-foreground" />
  83              </Link>
  84            </div>
  85          </div>
  86          {isSwap ? (
  87            <SwapForm
  88              address={address}
  89              setSwap={setSwap}
  90              amountSat={amountSat}
  91              setAmountSat={setAmountSat}
  92            />
  93          ) : (
  94            <OnchainForm
  95              address={address}
  96              setSwap={setSwap}
  97              amountSat={amountSat}
  98              setAmountSat={setAmountSat}
  99            />
 100          )}
 101        </div>
 102      </div>
 103    );
 104  }
 105  
 106  function OnchainForm({
 107    address,
 108    setSwap,
 109    amountSat,
 110    setAmountSat,
 111  }: {
 112    address: string;
 113    amountSat: string;
 114    setAmountSat: React.Dispatch<React.SetStateAction<string>>;
 115    setSwap: React.Dispatch<React.SetStateAction<boolean>>;
 116  }) {
 117    const navigate = useNavigate();
 118    const { data: info } = useInfo();
 119    const { data: balances } = useBalances();
 120    const { data: recommendedFees, error: mempoolError } = useMempoolApi<{
 121      fastestFee: number;
 122      halfHourFee: number;
 123      economyFee: number;
 124      minimumFee: number;
 125    }>("/v1/fees/recommended");
 126  
 127    const [feeRate, setFeeRate] = React.useState("");
 128    const [isLoading, setLoading] = React.useState(false);
 129    const [editFee, setEditFee] = React.useState(false);
 130  
 131    React.useEffect(() => {
 132      if (recommendedFees?.fastestFee) {
 133        setFeeRate(recommendedFees.fastestFee.toString());
 134      }
 135    }, [recommendedFees]);
 136  
 137    const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
 138      event.preventDefault();
 139      try {
 140        if (!balances) {
 141          return;
 142        }
 143        if (balances.onchain.spendableSat <= ONCHAIN_DUST_SATS) {
 144          throw new Error(
 145            "You currently don't have enough sats to pay for an on-chain transaction. Consider swapping from Lightning Balance."
 146          );
 147        }
 148        setLoading(true);
 149        const payload: RedeemOnchainFundsRequest = {
 150          toAddress: address,
 151          amountSat: +amountSat,
 152          feeRate: +feeRate,
 153        };
 154        const response = await request<RedeemOnchainFundsResponse>(
 155          "/api/wallet/redeem-onchain-funds",
 156          {
 157            method: "POST",
 158            headers: {
 159              "Content-Type": "application/json",
 160            },
 161            body: JSON.stringify(payload),
 162          }
 163        );
 164        if (!response?.txId) {
 165          throw new Error("No address in response");
 166        }
 167        navigate(`/wallet/send/onchain-success`, {
 168          state: {
 169            amountSat: +amountSat,
 170            txId: response.txId,
 171          },
 172          replace: true,
 173        });
 174        toast("Successfully broadcasted transaction");
 175      } catch (e) {
 176        toast.error("Failed to send payment", {
 177          description: "" + e,
 178        });
 179        console.error(e);
 180      } finally {
 181        setLoading(false);
 182      }
 183    };
 184  
 185    if (!info || !balances || (!recommendedFees && !mempoolError)) {
 186      return <Loading />;
 187    }
 188  
 189    return (
 190      <form onSubmit={onSubmit} className="grid gap-6">
 191        <CurrencyInputField
 192          id="amount"
 193          valueSat={amountSat}
 194          onValueSatChange={setAmountSat}
 195          minSat={ONCHAIN_DUST_SATS}
 196          maxSat={balances.onchain.spendableSat}
 197          required
 198          autoFocus
 199          contextRows={[
 200            {
 201              label: "On-chain available",
 202              amountSat: balances.onchain.spendableSat,
 203            },
 204          ]}
 205        />
 206        <div className="flex items-center justify-between">
 207          <Label htmlFor="swap" className="cursor-pointer">
 208            Swap from Lightning Balance
 209          </Label>
 210          <Switch id="swap" onCheckedChange={setSwap} />
 211        </div>
 212        <div className="grid gap-2 text-sm border-t pt-6">
 213          {!editFee ? (
 214            <div className="flex items-center justify-between">
 215              <p className="text-muted-foreground">On-chain Fee Rate</p>
 216              <div
 217                className="flex items-center gap-2 cursor-pointer"
 218                onClick={() => setEditFee(true)}
 219              >
 220                {feeRate ? (
 221                  <p>{feeRate} sat/vB</p>
 222                ) : (
 223                  <Loading className="w-4 h-4" />
 224                )}
 225                <PencilIcon className="w-4 h-4" />
 226              </div>
 227            </div>
 228          ) : (
 229            <div className="grid gap-2">
 230              <Label htmlFor="fee-rate">Fee Rate (Sat/vB)</Label>
 231              {mempoolError && (
 232                <div className="text-muted-foreground text-xs flex gap-1 items-center">
 233                  <AlertTriangleIcon className="h-3 w-3" />
 234                  Failed to fetch fee estimates. Try refreshing the page.
 235                </div>
 236              )}
 237              <Input
 238                id="fee-rate"
 239                type="number"
 240                value={feeRate}
 241                step={1}
 242                required
 243                min={recommendedFees?.minimumFee || 1}
 244                onChange={(e) => {
 245                  setFeeRate(e.target.value);
 246                }}
 247              />
 248              {recommendedFees && (
 249                <div className="flex items-center mt-2 gap-4">
 250                  <Button
 251                    variant="positive"
 252                    className="rounded-full"
 253                    type="button"
 254                    onClick={() =>
 255                      setFeeRate(recommendedFees.economyFee.toString())
 256                    }
 257                  >
 258                    Low priority: {recommendedFees.economyFee}
 259                  </Button>{" "}
 260                  <Button
 261                    variant="positive"
 262                    className="rounded-full"
 263                    type="button"
 264                    onClick={() =>
 265                      setFeeRate(recommendedFees.fastestFee.toString())
 266                    }
 267                  >
 268                    High priority: {recommendedFees.fastestFee}
 269                  </Button>{" "}
 270                  <ExternalLink
 271                    to={info?.mempoolUrl}
 272                    className="text-muted-foreground underline flex items-center gap-2"
 273                  >
 274                    View on Mempool
 275                    <ExternalLinkIcon className="w-4 h-4" />
 276                  </ExternalLink>
 277                </div>
 278              )}
 279            </div>
 280          )}
 281        </div>
 282        {amountSat && +amountSat < 10_000 && (
 283          <Alert>
 284            <InfoIcon className="h-4 w-4" />
 285            <AlertTitle>Amount not ideal for On-chain transaction</AlertTitle>
 286            <AlertDescription>
 287              Small amounts can become unspendable when mempool fees increase.
 288              Consider using Lightning instead.
 289            </AlertDescription>
 290          </Alert>
 291        )}
 292        <AnchorReserveAlert amountSat={+amountSat} />
 293        <div className="flex gap-2">
 294          <LinkButton to="/wallet/send" variant="outline">
 295            Back
 296          </LinkButton>
 297          <LoadingButton loading={isLoading} type="submit" className="flex-1">
 298            Send
 299          </LoadingButton>
 300        </div>
 301      </form>
 302    );
 303  }
 304  
 305  function SwapForm({
 306    address,
 307    setSwap,
 308    amountSat,
 309    setAmountSat,
 310  }: {
 311    address: string;
 312    amountSat: string;
 313    setAmountSat: React.Dispatch<React.SetStateAction<string>>;
 314    setSwap: React.Dispatch<React.SetStateAction<boolean>>;
 315  }) {
 316    const navigate = useNavigate();
 317    const { data: balances } = useBalances();
 318    const { data: swapInfo } = useSwapInfo("out");
 319  
 320    const [isLoading, setLoading] = React.useState(false);
 321  
 322    const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
 323      event.preventDefault();
 324      try {
 325        setLoading(true);
 326        const payload: InitiateSwapRequest = {
 327          swapAmountSat: +amountSat,
 328          destination: address,
 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 (e) {
 343        console.error(e);
 344        toast.error("Failed to send payment", {
 345          description: "" + e,
 346        });
 347      } finally {
 348        setLoading(false);
 349      }
 350    };
 351    const { data: recommendedFees } = useMempoolApi<{
 352      fastestFee: number;
 353    }>("/v1/fees/recommended");
 354  
 355    if (!balances || !swapInfo) {
 356      return <Loading />;
 357    }
 358  
 359    return (
 360      <form onSubmit={onSubmit} className="grid gap-6">
 361        <CurrencyInputField
 362          id="amount"
 363          valueSat={amountSat}
 364          onValueSatChange={setAmountSat}
 365          minSat={swapInfo.minAmountSat}
 366          maxSat={Math.min(
 367            swapInfo.maxAmountSat,
 368            balances.lightning.totalSpendableSat
 369          )}
 370          required
 371          autoFocus
 372          contextRows={[
 373            {
 374              label: "Lightning balance",
 375              amountSat: balances.lightning.totalSpendableSat,
 376            },
 377            {
 378              label: "Minimum",
 379              amountSat: swapInfo.minAmountSat,
 380            },
 381          ]}
 382        />
 383        <div className="flex items-center justify-between">
 384          <Label htmlFor="swap" className="cursor-pointer">
 385            Swap from Lightning Balance
 386          </Label>
 387          <Switch id="swap" checked onCheckedChange={setSwap} />
 388        </div>
 389        <div className="grid gap-2 text-sm border-t pt-6">
 390          <div className="flex items-center justify-between">
 391            <p className="text-muted-foreground">On-chain Fee Rate</p>
 392            <p>
 393              {recommendedFees?.fastestFee ? (
 394                <p>{recommendedFees?.fastestFee} sat/vB</p>
 395              ) : (
 396                <Loading className="w-4 h-4" />
 397              )}
 398            </p>
 399          </div>
 400          <div className="flex items-center justify-between">
 401            <p className="text-muted-foreground">Swap Fee</p>
 402            <p>{swapInfo.albyServiceFee + swapInfo.boltzServiceFee}%</p>
 403          </div>
 404        </div>
 405        <InsufficientLightningBalanceAlert amountSat={+amountSat} />
 406        <div className="flex gap-2">
 407          <LinkButton to="/wallet/send" variant="outline">
 408            Back
 409          </LinkButton>
 410          <LoadingButton loading={isLoading} type="submit" className="flex-1">
 411            Send
 412          </LoadingButton>
 413        </div>
 414      </form>
 415    );
 416  }
 417