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