ReceiveOnchain.tsx raw

   1  import { useEffect, useState } from "react";
   2  import { useNavigate } from "react-router";
   3  import { toast } from "sonner";
   4  import AppHeader from "src/components/AppHeader";
   5  import { CurrencyInputField } from "src/components/CurrencyInputField";
   6  import { FixedFloatSwapInFlow } from "src/components/FixedFloatSwapInFlow";
   7  import Loading from "src/components/Loading";
   8  import LowReceivingCapacityAlert from "src/components/LowReceivingCapacityAlert";
   9  import { MempoolAlert } from "src/components/MempoolAlert";
  10  import { LoadingButton } from "src/components/ui/custom/loading-button";
  11  import { Label } from "src/components/ui/label";
  12  import { RadioGroup, RadioGroupItem } from "src/components/ui/radio-group";
  13  import { useBalances } from "src/hooks/useBalances";
  14  import { useInfo } from "src/hooks/useInfo";
  15  import { useMempoolApi } from "src/hooks/useMempoolApi";
  16  import { useSwapInfo } from "src/hooks/useSwaps";
  17  import {
  18    CreateInvoiceRequest,
  19    InitiateSwapRequest,
  20    SwapResponse,
  21    Transaction,
  22  } from "src/types";
  23  import { openLink } from "src/utils/openLink";
  24  import { request } from "src/utils/request";
  25  
  26  export default function ReceiveOnchain() {
  27    const { data: info, hasChannelManagement } = useInfo();
  28    const { data: balances } = useBalances();
  29    const { data: swapInfo } = useSwapInfo("in");
  30    const { data: recommendedFees, error: mempoolError } = useMempoolApi<{
  31      fastestFee: number;
  32      halfHourFee: number;
  33      economyFee: number;
  34      minimumFee: number;
  35    }>("/v1/fees/recommended");
  36    const navigate = useNavigate();
  37  
  38    const [swapFrom, setSwapFrom] = useState<"bitcoin" | "crypto">("bitcoin");
  39    const [swapAmountSat, setSwapAmountSat] = useState("");
  40    const [loading, setLoading] = useState(false);
  41    const [feeRate, setFeeRate] = useState("");
  42    const [cryptoTransaction, setCryptoTransaction] =
  43      useState<Transaction | null>(null);
  44  
  45    useEffect(() => {
  46      if (recommendedFees?.fastestFee) {
  47        setFeeRate(recommendedFees.fastestFee.toString());
  48      }
  49    }, [recommendedFees]);
  50  
  51    const onSubmit = async (e: React.FormEvent) => {
  52      e.preventDefault();
  53  
  54      try {
  55        setLoading(true);
  56        if (swapFrom === "crypto") {
  57          const tx = await request<Transaction>("/api/invoices", {
  58            method: "POST",
  59            headers: {
  60              "Content-Type": "application/json",
  61            },
  62            body: JSON.stringify({
  63              amountMsat: (parseInt(swapAmountSat) || 0) * 1000,
  64              description: "Fixed Float swap",
  65            } as CreateInvoiceRequest),
  66          });
  67          if (!tx?.invoice) {
  68            throw new Error("Failed to create invoice");
  69          }
  70          setCryptoTransaction(tx);
  71          openLink(
  72            `https://ff.io/?to=BTCLN&address=${encodeURIComponent(tx.invoice)}&ref=qnnjvywb`
  73          );
  74          toast("Initiated swap");
  75          return;
  76        }
  77  
  78        const payload: InitiateSwapRequest = {
  79          swapAmountSat: parseInt(swapAmountSat),
  80        };
  81        const swapInResponse = await request<SwapResponse>("/api/swaps/in", {
  82          method: "POST",
  83          headers: {
  84            "Content-Type": "application/json",
  85          },
  86          body: JSON.stringify(payload),
  87        });
  88        if (!swapInResponse) {
  89          throw new Error("Error swapping in");
  90        }
  91        navigate(`/wallet/swap/in/status/${swapInResponse.swapId}`, {
  92          replace: true,
  93        });
  94        toast("Initiated swap");
  95      } catch (error) {
  96        toast.error("Failed to initiate swap", {
  97          description: (error as Error).message,
  98        });
  99      } finally {
 100        setLoading(false);
 101      }
 102    };
 103  
 104    if (!info || !balances || !swapInfo || (!recommendedFees && !mempoolError)) {
 105      return <Loading />;
 106    }
 107  
 108    const isCryptoReceiveState =
 109      swapFrom === "crypto" && cryptoTransaction !== null;
 110  
 111    return (
 112      <div className="grid gap-5">
 113        <AppHeader
 114          pageTitle="Receive from On-chain"
 115          title="Receive from On-chain"
 116        />
 117        <div className="w-full max-w-lg grid gap-6">
 118          <MempoolAlert />
 119          <form onSubmit={onSubmit} className="flex flex-col gap-6">
 120            {!isCryptoReceiveState && (
 121              <>
 122                {hasChannelManagement &&
 123                  parseInt(swapAmountSat || "0") * 1000 >=
 124                    0.8 * balances.lightning.totalReceivableMsat && (
 125                    <LowReceivingCapacityAlert />
 126                  )}
 127                <CurrencyInputField
 128                  label="Amount"
 129                  autoFocus
 130                  valueSat={swapAmountSat}
 131                  onValueSatChange={setSwapAmountSat}
 132                  minSat={
 133                    swapFrom === "bitcoin" ? swapInfo.minAmountSat : undefined
 134                  }
 135                  maxSat={
 136                    swapFrom === "bitcoin"
 137                      ? hasChannelManagement
 138                        ? Math.min(
 139                            swapInfo.maxAmountSat,
 140                            balances.lightning.totalReceivableSat * 0.99
 141                          )
 142                        : swapInfo.maxAmountSat
 143                      : hasChannelManagement
 144                        ? balances.lightning.totalReceivableSat * 0.99
 145                        : undefined
 146                  }
 147                  required
 148                  contextRows={
 149                    hasChannelManagement
 150                      ? [
 151                          {
 152                            label: "Receive limit",
 153                            amountSat: balances.lightning.totalReceivableSat,
 154                          },
 155                        ]
 156                      : undefined
 157                  }
 158                />
 159                <div className="flex flex-col gap-4">
 160                  <Label>Swap from</Label>
 161                  <RadioGroup
 162                    defaultValue="bitcoin"
 163                    value={swapFrom}
 164                    onValueChange={(value) => {
 165                      setSwapFrom(value as "bitcoin" | "crypto");
 166                    }}
 167                    className="flex gap-4 flex-row"
 168                  >
 169                    <div className="flex items-start space-x-2 mb-2">
 170                      <RadioGroupItem
 171                        value="bitcoin"
 172                        id="bitcoin"
 173                        className="shrink-0"
 174                      />
 175                      <Label htmlFor="bitcoin" className="cursor-pointer">
 176                        Bitcoin
 177                      </Label>
 178                    </div>
 179                    <div className="flex items-start space-x-2">
 180                      <RadioGroupItem
 181                        value="crypto"
 182                        id="crypto"
 183                        className="shrink-0"
 184                      />
 185                      <Label htmlFor="crypto" className="cursor-pointer">
 186                        Other Cryptocurrency
 187                      </Label>
 188                    </div>
 189                  </RadioGroup>
 190                </div>
 191              </>
 192            )}
 193  
 194            {swapFrom === "bitcoin" ? (
 195              <BitcoinSwapFlow
 196                feeRate={feeRate}
 197                loading={loading}
 198                swapFee={swapInfo.albyServiceFee + swapInfo.boltzServiceFee}
 199              />
 200            ) : (
 201              <FixedFloatSwapInFlow
 202                loading={loading}
 203                transaction={cryptoTransaction}
 204                resetLabel="Receive Another Payment"
 205                onReset={() => {
 206                  setCryptoTransaction(null);
 207                  setSwapAmountSat("");
 208                }}
 209              />
 210            )}
 211          </form>
 212        </div>
 213      </div>
 214    );
 215  }
 216  
 217  function BitcoinSwapFlow({
 218    feeRate,
 219    loading,
 220    swapFee,
 221  }: {
 222    feeRate: string;
 223    loading: boolean;
 224    swapFee: number;
 225  }) {
 226    return (
 227      <>
 228        <div className="border-t pt-4 text-sm grid gap-2">
 229          <div className="flex items-center justify-between">
 230            <Label>On-chain Fee</Label>
 231            {feeRate ? (
 232              <p className="text-muted-foreground">{feeRate} sat/vB</p>
 233            ) : (
 234              <Loading className="w-4 h-4" />
 235            )}
 236          </div>
 237          <div className="flex items-center justify-between">
 238            <Label>Swap Fee</Label>
 239            <p className="text-muted-foreground">{swapFee}%</p>
 240          </div>
 241        </div>
 242  
 243        <LoadingButton className="w-full" loading={loading}>
 244          Continue
 245        </LoadingButton>
 246      </>
 247    );
 248  }
 249