SwapInStatus.tsx raw
1 import {
2 CircleAlertIcon,
3 CircleCheckIcon,
4 CircleHelpIcon,
5 CircleXIcon,
6 CopyIcon,
7 ExternalLinkIcon,
8 } from "lucide-react";
9 import React, { useEffect, useState } from "react";
10 import { useParams, useSearchParams } from "react-router";
11 import { toast } from "sonner";
12 import AppHeader from "src/components/AppHeader";
13 import ExternalLink from "src/components/ExternalLink";
14 import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount";
15 import FormattedFiatAmount from "src/components/FormattedFiatAmount";
16 import Loading from "src/components/Loading";
17 import LottieLoading from "src/components/LottieLoading";
18 import QRCode from "src/components/QRCode";
19 import { Button } from "src/components/ui/button";
20 import {
21 Card,
22 CardContent,
23 CardDescription,
24 CardHeader,
25 CardTitle,
26 } from "src/components/ui/card";
27 import { ExternalLinkButton } from "src/components/ui/custom/external-link-button";
28 import {
29 Tooltip,
30 TooltipContent,
31 TooltipProvider,
32 TooltipTrigger,
33 } from "src/components/ui/tooltip";
34 import { useInfo } from "src/hooks/useInfo";
35 import { useSwap } from "src/hooks/useSwaps";
36 import { useSyncWallet } from "src/hooks/useSyncWallet";
37 import { copyToClipboard } from "src/lib/clipboard";
38 import {
39 RedeemOnchainFundsRequest,
40 RedeemOnchainFundsResponse,
41 SwapIn,
42 } from "src/types";
43 import { request } from "src/utils/request";
44
45 export default function SwapInStatus() {
46 const { data: info } = useInfo();
47 useSyncWallet(); // ensure funds show up on node page after swap completes
48 const { swapId } = useParams() as { swapId: string };
49 const { data: swap } = useSwap<SwapIn>(swapId, true);
50
51 const [isPaying, setPaying] = useState(false);
52 const [searchParams] = useSearchParams();
53
54 const isInternalSwap = searchParams.has("internal", "true");
55 const [, setPaidWithAlbyHub] = React.useState(false);
56
57 useEffect(() => {
58 if (isPaying && swap?.lockupTxId) {
59 setPaying(false);
60 }
61 }, [isPaying, swap?.lockupTxId]);
62
63 const payWithAlbyHub = React.useCallback(() => {
64 (async () => {
65 setPaying(true);
66 try {
67 if (!swap) {
68 throw new Error("swap not loaded");
69 }
70 const payload: RedeemOnchainFundsRequest = {
71 toAddress: swap.lockupAddress,
72 amountSat: swap.sendAmountSat,
73 };
74 const response = await request<RedeemOnchainFundsResponse>(
75 "/api/wallet/redeem-onchain-funds",
76 {
77 method: "POST",
78 headers: {
79 "Content-Type": "application/json",
80 },
81 body: JSON.stringify(payload),
82 }
83 );
84 if (!response?.txId) {
85 throw new Error("No address in response");
86 }
87 console.info("Redeemed onchain funds", response);
88 } catch (error) {
89 console.error(error);
90 toast.error("Failed to redeem onchain funds", {
91 description: "" + error,
92 });
93 setPaying(false);
94 }
95 })();
96 }, [swap]);
97
98 React.useEffect(() => {
99 // only auto-redeem while the swap is still awaiting its on-chain deposit,
100 // otherwise a refresh/revisit of ?internal=true would submit a second
101 // redeem request for an already-funded swap
102 if (
103 isInternalSwap &&
104 swap &&
105 swap.state === "PENDING" &&
106 !swap.lockupTxId
107 ) {
108 setPaidWithAlbyHub((current) => {
109 if (current) {
110 return current;
111 }
112 setTimeout(() => {
113 payWithAlbyHub();
114 }, 1);
115 return true;
116 });
117 }
118 }, [isInternalSwap, payWithAlbyHub, swap]);
119
120 if (!swap) {
121 return <Loading />;
122 }
123
124 const copyPaymentHash = () => {
125 copyToClipboard(swap.paymentHash);
126 };
127
128 const copyAddress = () => {
129 copyToClipboard(swap.lockupAddress);
130 };
131
132 const copyAmount = () => {
133 copyToClipboard(swap.sendAmountSat.toString());
134 };
135
136 const swapStatus = swap.state;
137 const statusText = {
138 SUCCESS: "Swap Successful",
139 FAILED: "Swap Failed",
140 REFUNDED: "Swap Refunded",
141 PENDING: swap.lockupTxId
142 ? "Waiting for confirmation"
143 : isInternalSwap
144 ? "Depositing on-chain funds"
145 : "Waiting for deposit",
146 };
147
148 return (
149 <div className="grid gap-5">
150 <AppHeader pageTitle="Swap In" title="Swap In" />
151 <div className="w-full max-w-lg">
152 <Card className="w-full md:max-w-xs">
153 <CardHeader>
154 <CardTitle className="flex justify-center">
155 {swapStatus === "PENDING" && <Loading className="w-4 h-4 mr-2" />}
156 {statusText[swapStatus]}
157 </CardTitle>
158 <CardDescription className="flex items-center justify-center gap-2 text-muted-foreground text-sm">
159 Swap ID: {swap.id}{" "}
160 <CopyIcon
161 className="cursor-pointer text-muted-foreground size-4"
162 onClick={() => {
163 copyToClipboard(swap.id);
164 }}
165 />
166 </CardDescription>
167 </CardHeader>
168 <CardContent className="flex flex-col items-center gap-4">
169 {swapStatus === "SUCCESS" ? (
170 <>
171 <CircleCheckIcon className="w-60 h-60" />
172 <div className="flex flex-col gap-2 items-center">
173 <p className="text-xl font-bold slashed-zero text-center">
174 <FormattedBitcoinAmount
175 amountMsat={(swap.receiveAmountSat as number) * 1000}
176 />
177 </p>
178 <FormattedFiatAmount
179 amountSat={swap.receiveAmountSat as number}
180 />
181 </div>
182 <Button onClick={copyPaymentHash} variant="outline">
183 <CopyIcon />
184 Copy Payment Hash
185 </Button>
186 </>
187 ) : (
188 <>
189 {(swapStatus === "REFUNDED" || swapStatus === "FAILED") && (
190 <CircleXIcon className="w-60 h-60" />
191 )}
192 {swapStatus === "PENDING" &&
193 (swap.lockupTxId || isInternalSwap ? (
194 <LottieLoading />
195 ) : (
196 <QRCode
197 value={`bitcoin:${swap.lockupAddress}?amount=${swap.sendAmountSat / 100_000_000}`}
198 />
199 ))}
200 <div className="flex flex-col gap-2 items-center">
201 <div className="flex items-center gap-2">
202 <p className="text-xl font-bold slashed-zero text-center">
203 <FormattedBitcoinAmount
204 amountMsat={swap.sendAmountSat * 1000}
205 />
206 </p>
207 {!swap.lockupTxId && !isInternalSwap && (
208 <CopyIcon
209 className="cursor-pointer text-muted-foreground size-4 shrink-0"
210 onClick={copyAmount}
211 />
212 )}
213 </div>
214 <FormattedFiatAmount amountSat={swap.sendAmountSat} />
215 </div>
216 {!swap.lockupTxId && !isInternalSwap && (
217 <div className="flex w-full flex-col gap-3">
218 {swap.state !== "FAILED" && (
219 <Button
220 className="w-full"
221 onClick={copyAddress}
222 variant="outline"
223 >
224 <CopyIcon />
225 Copy Address
226 </Button>
227 )}
228 {swap.state === "PENDING" && (
229 <ExternalLinkButton
230 to={`bitcoin:${swap.lockupAddress}?amount=${swap.sendAmountSat / 100_000_000}`}
231 variant="secondary"
232 className="w-full"
233 >
234 Open in External Wallet
235 <ExternalLinkIcon />
236 </ExternalLinkButton>
237 )}
238 </div>
239 )}
240 </>
241 )}
242 {/* We only show status screen once bitcoin is locked up */}
243 {swap.lockupTxId ? (
244 <div className="flex flex-col justify-start gap-2 w-full mt-2">
245 {swapStatus === "SUCCESS" && (
246 <>
247 <div className="flex items-center text-muted-foreground text-sm">
248 <CircleCheckIcon className="w-5 h-5 mr-2 text-green-600 dark:text-emerald-500" />
249 Funds received via lightning
250 </div>
251 <Divider color="border-green-600 dark:border-emerald-500" />
252 <div className="flex items-center text-muted-foreground text-sm">
253 <CircleCheckIcon className="w-5 h-5 mr-2 text-green-600 dark:text-emerald-500" />
254 <div className="flex items-center gap-2">
255 <p>Onchain deposit confirmed</p>
256 <ExternalLink
257 to={`${info?.mempoolUrl}/tx/${swap.lockupTxId}`}
258 className="flex items-center underline text-foreground"
259 >
260 View
261 </ExternalLink>
262 </div>
263 </div>
264 <Divider color="border-green-600 dark:border-emerald-500" />
265 </>
266 )}
267 {swapStatus === "PENDING" && (
268 <>
269 <div className="flex items-center text-muted-foreground text-sm">
270 <Loading className="w-5 h-5 mr-2" />
271 <div className="flex items-center gap-2">
272 <p>Waiting for 1 on-chain confirmation...</p>
273 <ExternalLink
274 to={`${info?.mempoolUrl}/tx/${swap.lockupTxId}`}
275 className="flex items-center underline text-foreground"
276 >
277 View
278 </ExternalLink>
279 </div>
280 </div>
281 <Divider color="border-green-600 dark:border-emerald-500" />
282 </>
283 )}
284 {swapStatus === "REFUNDED" && (
285 <>
286 <div className="flex items-center text-muted-foreground text-sm">
287 <CircleCheckIcon className="w-5 h-5 mr-2 text-green-600 dark:text-emerald-500" />
288 <div className="flex items-center gap-2">
289 <p>Refund initiated</p>
290 <ExternalLink
291 to={`${info?.mempoolUrl}/tx/${swap.claimTxId}`}
292 className="flex items-center underline text-foreground"
293 >
294 View
295 </ExternalLink>
296 </div>
297 </div>
298 <Divider color="border-green-600 dark:border-emerald-500" />
299 </>
300 )}
301 {(swapStatus === "FAILED" || swapStatus === "REFUNDED") && (
302 <>
303 <div className="flex items-center text-muted-foreground text-sm">
304 <CircleAlertIcon className="w-5 h-5 mr-2 text-red-500" />
305 <TooltipProvider>
306 <Tooltip>
307 <TooltipTrigger>
308 <div className="flex items-center gap-2">
309 <p>Onchain deposit failed</p>
310 <ExternalLink
311 to={`${info?.mempoolUrl}/tx/${swap.lockupTxId}`}
312 className="flex items-center underline text-foreground"
313 >
314 View
315 </ExternalLink>
316 <CircleHelpIcon className="h-4 w-4 text-muted-foreground" />
317 </div>
318 </TooltipTrigger>
319 <TooltipContent>
320 Deposit usually fails when there is an amount
321 mismatch or if Boltz failed to send the lightning
322 payment to your node.
323 {swapStatus !== "REFUNDED" &&
324 " You can use the Swap Refund button in Settings -> Debug Tools to claim the locked up bitcoin."}
325 </TooltipContent>
326 </Tooltip>
327 </TooltipProvider>
328 </div>
329 <Divider color="border-red-500" />
330 </>
331 )}
332 <div className="flex items-center text-muted-foreground text-sm">
333 <CircleCheckIcon className="w-5 h-5 mr-2 text-green-600 dark:text-emerald-500" />
334 Swap initiated
335 </div>
336 </div>
337 ) : swapStatus === "FAILED" ? (
338 <>
339 <div className="flex items-center text-muted-foreground text-sm">
340 <CircleAlertIcon className="w-5 h-5 mr-2 text-red-500" />
341 <TooltipProvider>
342 <Tooltip>
343 <TooltipTrigger>
344 <div className="flex items-center gap-2">
345 <p>Onchain deposit failed</p>
346 <ExternalLink
347 to={`${info?.mempoolUrl}/address/${swap.lockupAddress}`}
348 className="flex items-center underline text-foreground"
349 >
350 View
351 </ExternalLink>
352 <CircleHelpIcon className="h-4 w-4 text-muted-foreground" />
353 </div>
354 </TooltipTrigger>
355 <TooltipContent>
356 Deposit usually fails when there is an amount mismatch
357 or if Boltz failed to send the lightning payment to your
358 node. You can use the Swap Refund button in Settings{" "}
359 {"->"} Debug Tools to claim the locked up bitcoin.
360 </TooltipContent>
361 </Tooltip>
362 </TooltipProvider>
363 </div>
364 </>
365 ) : null}
366 </CardContent>
367 </Card>
368 </div>
369 </div>
370 );
371 }
372
373 const Divider = ({ color }: { color: string }) => (
374 <div className={`ml-2.25 py-1 border-l ${color}`}></div>
375 );
376