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