WithdrawOnchainFunds.tsx raw
1 import {
2 AlertTriangleIcon,
3 ChevronDownIcon,
4 CopyIcon,
5 ExternalLinkIcon,
6 InfoIcon,
7 } from "lucide-react";
8 import React from "react";
9 import { toast } from "sonner";
10 import { AnchorReserveAlert } from "src/components/AnchorReserveAlert";
11 import AppHeader from "src/components/AppHeader";
12 import ExternalLink from "src/components/ExternalLink";
13 import { FixedFloatButton } from "src/components/FixedFloatButton";
14 import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount";
15 import Loading from "src/components/Loading";
16 import { MempoolAlert } from "src/components/MempoolAlert";
17 import { Alert, AlertDescription, AlertTitle } from "src/components/ui/alert";
18 import {
19 AlertDialog,
20 AlertDialogCancel,
21 AlertDialogContent,
22 AlertDialogDescription,
23 AlertDialogFooter,
24 AlertDialogHeader,
25 AlertDialogTitle,
26 } from "src/components/ui/alert-dialog";
27 import { Button } from "src/components/ui/button";
28 import { Checkbox } from "src/components/ui/checkbox";
29 import { LoadingButton } from "src/components/ui/custom/loading-button";
30 import { Input } from "src/components/ui/input";
31 import { Label } from "src/components/ui/label";
32 import { Separator } from "src/components/ui/separator";
33 import { ONCHAIN_DUST_SATS } from "src/constants";
34 import { useBalances } from "src/hooks/useBalances";
35 import { useInfo } from "src/hooks/useInfo";
36 import { useMempoolApi } from "src/hooks/useMempoolApi";
37
38 import { copyToClipboard } from "src/lib/clipboard";
39 import {
40 RedeemOnchainFundsRequest,
41 RedeemOnchainFundsResponse,
42 } from "src/types";
43 import { request } from "src/utils/request";
44
45 export default function WithdrawOnchainFunds() {
46 const { data: info } = useInfo();
47 const { data: balances } = useBalances();
48 const { data: recommendedFees, error: mempoolError } = useMempoolApi<{
49 fastestFee: number;
50 halfHourFee: number;
51 economyFee: number;
52 minimumFee: number;
53 }>("/v1/fees/recommended");
54 const [isLoading, setLoading] = React.useState(false);
55 const [onchainAddress, setOnchainAddress] = React.useState("");
56 const [amountSat, setAmountSat] = React.useState("");
57 const [feeRate, setFeeRate] = React.useState("");
58 const [sendAll, setSendAll] = React.useState(false);
59 const [showAdvanced, setShowAdvanced] = React.useState(false);
60 const [transactionId, setTransactionId] = React.useState("");
61 const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false);
62
63 React.useEffect(() => {
64 if (mempoolError) {
65 setShowAdvanced(true);
66 }
67 }, [mempoolError]);
68
69 React.useEffect(() => {
70 if (recommendedFees?.fastestFee) {
71 setFeeRate(recommendedFees.fastestFee.toString());
72 }
73 }, [recommendedFees]);
74
75 const copy = (text: string) => {
76 copyToClipboard(text);
77 };
78
79 const redeemFunds = React.useCallback(async () => {
80 setLoading(true);
81 try {
82 if (!onchainAddress) {
83 throw new Error("No onchain address");
84 }
85 if (!feeRate) {
86 throw new Error("No fee rate set");
87 }
88 } catch (error) {
89 console.error(error);
90 toast.error("Something went wrong", {
91 description: "" + error,
92 });
93 setLoading(false);
94 return;
95 }
96
97 try {
98 const payload: RedeemOnchainFundsRequest = {
99 toAddress: onchainAddress,
100 amountSat: +amountSat,
101 sendAll,
102 feeRate: +feeRate,
103 };
104 const response = await request<RedeemOnchainFundsResponse>(
105 "/api/wallet/redeem-onchain-funds",
106 {
107 method: "POST",
108 headers: {
109 "Content-Type": "application/json",
110 },
111 body: JSON.stringify(payload),
112 }
113 );
114 console.info("Redeemed onchain funds", response);
115 if (!response?.txId) {
116 throw new Error("No address in response");
117 }
118 setTransactionId(response.txId);
119 } catch (error) {
120 console.error(error);
121 toast.error("Failed to redeem onchain funds", {
122 description: "" + error,
123 });
124 }
125 setLoading(false);
126 }, [amountSat, feeRate, onchainAddress, sendAll]);
127
128 if (transactionId) {
129 return (
130 <div className="grid gap-5">
131 <AppHeader
132 pageTitle="Withdrawal Transaction Broadcasted"
133 title="Withdrawal Transaction Broadcasted"
134 description={
135 "You will receive the funds at the destination after the transaction is confirmed"
136 }
137 />
138 <p className="text-foreground">Withdrawal Transaction Id</p>
139 <div className="flex items-center justify-between gap-4 max-w-sm">
140 <p className="break-all font-semibold">{transactionId}</p>
141 <CopyIcon
142 className="cursor-pointer text-muted-foreground size-4 shrink-0"
143 onClick={() => {
144 copy(transactionId);
145 }}
146 />
147 </div>
148 <ExternalLink
149 to={`${info?.mempoolUrl}/tx/${transactionId}`}
150 className="underline flex items-center mt-2"
151 >
152 View on Mempool
153 <ExternalLinkIcon className="size-4 ml-2" />
154 </ExternalLink>
155 <p>Your on-chain balance in Alby Hub may take some time to update.</p>
156 </div>
157 );
158 }
159
160 if (!info || !balances || (!recommendedFees && !mempoolError)) {
161 return <Loading />;
162 }
163
164 if (balances.onchain.spendableSat <= ONCHAIN_DUST_SATS) {
165 return (
166 <p>
167 You currently don't have enough sats to pay for an onchain transaction.
168 </p>
169 );
170 }
171
172 return (
173 <div className="grid gap-5">
174 <AppHeader
175 pageTitle="Withdraw On-Chain Balance"
176 title="Withdraw On-Chain Balance"
177 description="Withdraw your onchain funds to another bitcoin wallet"
178 />
179
180 <div className="max-w-lg">
181 <p>
182 Your on-chain balance will be withdrawn to the onchain bitcoin wallet
183 address you specify below.
184 </p>
185 <form
186 onSubmit={(e) => {
187 e.preventDefault();
188 setConfirmDialogOpen(true);
189 }}
190 className="grid gap-5 mt-4"
191 >
192 <div>
193 <div className="grid gap-2">
194 <Label htmlFor="amount">Amount</Label>
195 <div className="flex justify-between items-center">
196 <p className="text-sm text-muted-foreground sensitive slashed-zero">
197 Current onchain balance:{" "}
198 <FormattedBitcoinAmount
199 amountMsat={balances.onchain.spendableSat * 1000}
200 />
201 </p>
202 <div className="flex items-center gap-1">
203 <Checkbox
204 id="send-all"
205 onCheckedChange={() => setSendAll(!sendAll)}
206 />
207 <Label htmlFor="send-all" className="text-xs cursor-pointer">
208 Send All
209 </Label>
210 </div>
211 </div>
212 {!sendAll && (
213 <Input
214 id="amount"
215 type="number"
216 value={amountSat}
217 required
218 onChange={(e) => {
219 setAmountSat(e.target.value);
220 }}
221 />
222 )}
223 </div>
224 <MempoolAlert className="mt-4" />
225 {sendAll && (
226 <Alert className="mt-4" variant="warning">
227 <AlertTriangleIcon />
228 <AlertTitle>Entire wallet balance will be sent</AlertTitle>
229 <AlertDescription>
230 Your entire wallet balance will be sent minus onchain
231 transaction fees. The exact amount cannot be determined until
232 the payment is made.
233 </AlertDescription>
234 </Alert>
235 )}
236 <AnchorReserveAlert
237 amountSat={sendAll ? balances.onchain.spendableSat : +amountSat}
238 className="mt-4"
239 />
240 </div>
241 <div className="grid gap-2">
242 <Label htmlFor="onchain-address">Onchain Address</Label>
243 <Input
244 id="onchain-address"
245 type="text"
246 value={onchainAddress}
247 required
248 onChange={(e) => {
249 setOnchainAddress(e.target.value);
250 }}
251 />
252 <p className="text-sm text-muted-foreground">
253 Please double-check the destination address. This transaction
254 cannot be reversed.
255 </p>
256 </div>
257 {(info?.backendType === "LDK" || info?.backendType === "LND") && (
258 <>
259 {showAdvanced && (
260 <div className="grid gap-2">
261 <Label htmlFor="fee-rate">Fee Rate (Sat/vB)</Label>
262 {mempoolError && (
263 <div className="text-muted-foreground text-xs flex gap-1 items-center">
264 <AlertTriangleIcon className="h-3 w-3" />
265 Failed to fetch fee estimates. Try refreshing the page.
266 </div>
267 )}
268 <Input
269 id="fee-rate"
270 type="number"
271 value={feeRate}
272 step={1}
273 required
274 min={recommendedFees?.minimumFee || 1}
275 onChange={(e) => {
276 setFeeRate(e.target.value);
277 }}
278 />
279 {recommendedFees && (
280 <div className="flex items-center mt-2 gap-4">
281 <Button
282 variant="positive"
283 className="rounded-full"
284 type="button"
285 onClick={() =>
286 setFeeRate(recommendedFees.economyFee.toString())
287 }
288 >
289 Low priority: {recommendedFees.economyFee}
290 </Button>{" "}
291 <Button
292 variant="positive"
293 className="rounded-full"
294 type="button"
295 onClick={() =>
296 setFeeRate(recommendedFees.fastestFee.toString())
297 }
298 >
299 High priority: {recommendedFees.fastestFee}
300 </Button>{" "}
301 <ExternalLink
302 to={info?.mempoolUrl}
303 className="text-sm text-muted-foreground underline flex items-center gap-2"
304 >
305 View on Mempool
306 <ExternalLinkIcon className="w-4 h-4" />
307 </ExternalLink>
308 </div>
309 )}
310 </div>
311 )}
312 {!showAdvanced && (
313 <Button
314 type="button"
315 variant="link"
316 className="text-muted-foreground text-xs"
317 onClick={() => setShowAdvanced((current) => !current)}
318 >
319 <ChevronDownIcon />
320 Advanced Options
321 </Button>
322 )}
323 </>
324 )}
325
326 <div>
327 <AlertDialog
328 onOpenChange={setConfirmDialogOpen}
329 open={confirmDialogOpen}
330 >
331 <Button className="w-full">Withdraw</Button>
332 {feeRate && (
333 <div className="mt-2 text-muted-foreground text-sm flex gap-1 items-center justify-center">
334 <InfoIcon className="h-4 w-4" />
335 On-chain payment will be made with{" "}
336 <span className="font-semibold">{feeRate} sat/vB</span> fee
337 </div>
338 )}
339
340 <AlertDialogContent>
341 <AlertDialogHeader>
342 <AlertDialogTitle>
343 Confirm Onchain Transaction
344 </AlertDialogTitle>
345 <AlertDialogDescription>
346 <div>
347 <p>Please confirm your payment to</p>
348 <p className="font-bold max-w-md break-anywhere">
349 {onchainAddress}
350 </p>
351 <p className="mt-4">
352 Amount:{" "}
353 <span className="font-bold slashed-zero">
354 {sendAll ? (
355 "entire on-chain balance"
356 ) : (
357 <>
358 <FormattedBitcoinAmount
359 amountMsat={+amountSat * 1000}
360 />
361 </>
362 )}
363 </span>
364 </p>
365 {feeRate && (
366 <p className="mt-4">
367 Fee Rate:{" "}
368 <span className="font-bold slashed-zero">
369 {feeRate}
370 </span>{" "}
371 sat/vB
372 </p>
373 )}
374 </div>
375 </AlertDialogDescription>
376 </AlertDialogHeader>
377 <AlertDialogFooter>
378 <AlertDialogCancel>Cancel</AlertDialogCancel>
379
380 <LoadingButton
381 loading={isLoading}
382 onClick={() => redeemFunds()}
383 >
384 Confirm
385 </LoadingButton>
386 </AlertDialogFooter>
387 </AlertDialogContent>
388 </AlertDialog>
389
390 <Separator className="my-4" />
391 <FixedFloatButton from="BTC" className="w-full" variant="secondary">
392 <ExternalLinkIcon className="size-4" />
393 Withdraw to other Cryptocurrency
394 </FixedFloatButton>
395 </div>
396 </form>
397 </div>
398 </div>
399 );
400 }
401