AutoSwap.tsx raw
1 import {
2 ArrowDownUpIcon,
3 ClipboardPasteIcon,
4 ClockIcon,
5 CopyIcon,
6 MoveRightIcon,
7 XCircleIcon,
8 } from "lucide-react";
9 import { useState } from "react";
10 import { toast } from "sonner";
11 import AppHeader from "src/components/AppHeader";
12 import { CurrencyInputField } from "src/components/CurrencyInputField";
13 import ExternalLink from "src/components/ExternalLink";
14 import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount";
15 import Loading from "src/components/Loading";
16 import PasswordInput from "src/components/password/PasswordInput";
17 import ResponsiveLinkButton from "src/components/ResponsiveLinkButton";
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 { LoadingButton } from "src/components/ui/custom/loading-button";
29 import { Input } from "src/components/ui/input";
30 import { Label } from "src/components/ui/label";
31 import { RadioGroup, RadioGroupItem } from "src/components/ui/radio-group";
32 import { useAutoSwapsConfig, useSwapInfo } from "src/hooks/useSwaps";
33 import { copyToClipboard } from "src/lib/clipboard";
34 import { AutoSwapConfig, AutoSwapRequest } from "src/types";
35 import { request } from "src/utils/request";
36
37 export default function AutoSwap() {
38 const { data: swapConfig } = useAutoSwapsConfig();
39
40 if (!swapConfig) {
41 return <Loading />;
42 }
43
44 return (
45 <div className="grid gap-5">
46 <AppHeader
47 pageTitle="Auto Swap Out"
48 title="Auto Swap Out"
49 contentRight={
50 <ResponsiveLinkButton
51 to="/wallet/swap"
52 variant="outline"
53 icon={ArrowDownUpIcon}
54 text="Swap"
55 />
56 }
57 />
58 <div className="w-full lg:max-w-lg min-w-0">
59 {swapConfig.enabled ? (
60 <ActiveSwapOutConfig swapConfig={swapConfig} />
61 ) : (
62 <AutoSwapOutForm />
63 )}
64 </div>
65 </div>
66 );
67 }
68
69 function AutoSwapOutForm() {
70 const { mutate } = useAutoSwapsConfig();
71 const { data: swapInfo } = useSwapInfo("out");
72
73 const [isInternalSwap, setInternalSwap] = useState(true);
74 const [balanceThresholdSat, setBalanceThresholdSat] = useState("");
75 const [swapAmountSat, setSwapAmountSat] = useState("");
76 const [destination, setDestination] = useState("");
77 const [externalType, setExternalType] = useState<"address" | "xpub">(
78 "address"
79 );
80 const [unlockPassword, setUnlockPassword] = useState("");
81 const [showUnlockPasswordDialog, setShowUnlockPasswordDialog] =
82 useState(false);
83 const [loading, setLoading] = useState(false);
84
85 const onSubmit = async (e: React.FormEvent) => {
86 e.preventDefault();
87
88 if (Number(swapAmountSat) > Number(balanceThresholdSat)) {
89 toast.info(
90 "Balance threshold must be greater than or equal to swap amount"
91 );
92 return;
93 }
94
95 if (externalType === "xpub" && !isInternalSwap) {
96 setShowUnlockPasswordDialog(true);
97 return;
98 }
99
100 await submitAutoSwap();
101 };
102
103 const onConfirmUnlockPassword = async (e: React.FormEvent) => {
104 e.preventDefault();
105 await submitAutoSwap(unlockPassword);
106 };
107
108 const submitAutoSwap = async (password?: string) => {
109 try {
110 setLoading(true);
111 const payload: AutoSwapRequest = {
112 swapAmountSat: parseInt(swapAmountSat),
113 balanceThresholdSat: parseInt(balanceThresholdSat),
114 destination,
115 destinationType: !isInternalSwap ? externalType : undefined,
116 unlockPassword: password,
117 };
118 await request("/api/autoswap", {
119 method: "POST",
120 headers: {
121 "Content-Type": "application/json",
122 },
123 body: JSON.stringify(payload),
124 });
125 setUnlockPassword("");
126 setShowUnlockPasswordDialog(false);
127 toast("Auto swap enabled successfully");
128 await mutate();
129 } catch (error) {
130 toast("Failed to save auto swap settings", {
131 description: (error as Error).message,
132 });
133 } finally {
134 setLoading(false);
135 }
136 };
137
138 const paste = async () => {
139 const text = await navigator.clipboard.readText();
140 setDestination(text.trim());
141 };
142
143 if (!swapInfo) {
144 return <Loading />;
145 }
146
147 return (
148 <>
149 <form onSubmit={onSubmit} className="flex flex-col gap-6">
150 <div>
151 <h2 className="font-medium text-foreground flex items-center gap-1">
152 Lightning <MoveRightIcon /> On-chain
153 </h2>
154 <p className="mt-1 text-muted-foreground">
155 Setup automatic swap of lightning funds into your on-chain balance
156 every time a set threshold is reached.
157 </p>
158 <p className="mt-2 text-muted-foreground flex gap-2 items-center text-sm">
159 <ClockIcon className="w-4 h-4" />
160 Swaps will be made once per hour
161 </p>
162 </div>
163
164 <CurrencyInputField
165 label="Lightning balance threshold"
166 valueSat={balanceThresholdSat}
167 onValueSatChange={setBalanceThresholdSat}
168 minSat={Number(swapAmountSat) || undefined}
169 required
170 description="Swap out as soon as this amount is reached"
171 />
172
173 <CurrencyInputField
174 label="Swap amount"
175 valueSat={swapAmountSat}
176 onValueSatChange={setSwapAmountSat}
177 minSat={swapInfo.minAmountSat}
178 maxSat={swapInfo.maxAmountSat}
179 required
180 contextRows={[
181 {
182 label: "Minimum",
183 amountSat: swapInfo.minAmountSat,
184 },
185 ]}
186 />
187 <div className="flex flex-col gap-4">
188 <Label>Swap to</Label>
189 <RadioGroup
190 value={isInternalSwap ? "internal" : "external"}
191 onValueChange={() => {
192 setDestination("");
193 setInternalSwap(!isInternalSwap);
194 }}
195 className="flex gap-4 flex-row"
196 >
197 <div className="flex items-start space-x-2 mb-2">
198 <RadioGroupItem
199 value="internal"
200 id="internal"
201 className="shrink-0"
202 />
203 <Label htmlFor="internal" className="cursor-pointer">
204 On-chain balance
205 </Label>
206 </div>
207 <div className="flex items-start space-x-2">
208 <RadioGroupItem
209 value="external"
210 id="external"
211 className="shrink-0"
212 />
213 <Label htmlFor="external" className="cursor-pointer">
214 External on-chain wallet
215 </Label>
216 </div>
217 </RadioGroup>
218 </div>
219 {!isInternalSwap && (
220 <div className="grid gap-4">
221 <div className="flex flex-col gap-3">
222 <Label>Destination Type</Label>
223 <RadioGroup
224 value={externalType}
225 onValueChange={(value) => {
226 setExternalType(value as "address" | "xpub");
227 setDestination("");
228 }}
229 className="flex gap-4 flex-row"
230 >
231 <div className="flex items-start space-x-2">
232 <RadioGroupItem
233 value="address"
234 id="address"
235 className="shrink-0"
236 />
237 <div className="grid gap-1.5">
238 <Label htmlFor="address" className="cursor-pointer">
239 Single Address
240 </Label>
241 <p className="text-xs text-muted-foreground">
242 Send to the same address each time
243 </p>
244 </div>
245 </div>
246 <div className="flex items-start space-x-2">
247 <RadioGroupItem value="xpub" id="xpub" className="shrink-0" />
248 <div className="grid gap-1.5">
249 <Label htmlFor="xpub" className="cursor-pointer">
250 XPUB
251 </Label>
252 <p className="text-xs text-muted-foreground">
253 Generate new addresses from extended public key
254 </p>
255 </div>
256 </div>
257 </RadioGroup>
258 </div>
259 <div className="grid gap-1.5">
260 <Label>
261 {externalType === "address"
262 ? "Receiving on-chain address"
263 : "Extended Public Key (XPUB)"}
264 </Label>
265 <div className="flex gap-2">
266 <Input
267 placeholder={
268 externalType === "address" ? "bc1..." : "xpub..."
269 }
270 value={destination}
271 onChange={(e) => setDestination(e.target.value)}
272 required
273 />
274 <Button
275 type="button"
276 variant="outline"
277 className="px-2"
278 onClick={paste}
279 >
280 <ClipboardPasteIcon className="w-4 h-4" />
281 </Button>
282 </div>
283 <p className="text-xs text-muted-foreground">
284 {externalType === "address"
285 ? "Enter a Bitcoin address to receive swapped funds"
286 : "Enter an XPUB to automatically generate new addresses for each swap. You will be asked to enter your unlock password to encrypt it safely"}
287 </p>
288 </div>
289 </div>
290 )}
291
292 <div className="flex items-center justify-between border-t pt-4">
293 <Label>Fee</Label>
294 <p className="text-muted-foreground text-sm">
295 {swapInfo.albyServiceFee + swapInfo.boltzServiceFee}% + on-chain
296 fees
297 </p>
298 </div>
299 <div className="grid gap-1">
300 <LoadingButton className="w-full" loading={loading}>
301 Begin Auto Swap
302 </LoadingButton>
303 <p className="text-xs text-muted-foreground text-right">
304 powered by{" "}
305 <ExternalLink
306 to="https://boltz.exchange"
307 className="font-medium text-foreground"
308 >
309 boltz.exchange
310 </ExternalLink>
311 </p>
312 </div>
313 </form>
314 <AlertDialog
315 open={showUnlockPasswordDialog}
316 onOpenChange={(open) => {
317 setShowUnlockPasswordDialog(open);
318 if (!open) {
319 setUnlockPassword("");
320 }
321 }}
322 >
323 <AlertDialogContent>
324 <form onSubmit={onConfirmUnlockPassword}>
325 <AlertDialogHeader>
326 <AlertDialogTitle>Confirm Auto Swap Setup</AlertDialogTitle>
327 <AlertDialogDescription>
328 <div className="flex flex-col gap-4">
329 <p>
330 Please enter your unlock password to encrypt and securely
331 store the XPUB.
332 </p>
333 <div className="grid gap-1.5">
334 <Label htmlFor="unlockPassword">Unlock Password</Label>
335 <PasswordInput
336 id="unlockPassword"
337 onChange={setUnlockPassword}
338 autoFocus
339 value={unlockPassword}
340 />
341 </div>
342 </div>
343 </AlertDialogDescription>
344 </AlertDialogHeader>
345 <AlertDialogFooter className="mt-3">
346 <AlertDialogCancel>Cancel</AlertDialogCancel>
347 <Button type="submit" disabled={!unlockPassword || loading}>
348 Confirm
349 </Button>
350 </AlertDialogFooter>
351 </form>
352 </AlertDialogContent>
353 </AlertDialog>
354 </>
355 );
356 }
357
358 function ActiveSwapOutConfig({ swapConfig }: { swapConfig: AutoSwapConfig }) {
359 const { mutate } = useAutoSwapsConfig();
360 const { data: swapInfo } = useSwapInfo("out");
361
362 const [loading, setLoading] = useState(false);
363
364 const onDeactivate = async () => {
365 try {
366 setLoading(true);
367 await request(`/api/autoswap`, {
368 method: "DELETE",
369 headers: {
370 "Content-Type": "application/json",
371 },
372 });
373 toast("Deactivated auto swap successfully");
374 await mutate();
375 } catch (error) {
376 toast.error("Deactivating auto swaps failed", {
377 description: (error as Error).message,
378 });
379 } finally {
380 setLoading(false);
381 }
382 };
383
384 return (
385 <>
386 <h2 className="font-medium text-foreground flex items-center gap-1">
387 Active Lightning <MoveRightIcon /> On-chain Swap
388 </h2>
389 <p className="mt-1 text-muted-foreground">
390 Alby Hub will try to perform a swap every time the balance reaches the
391 threshold.
392 </p>
393 <p className="mt-2 text-muted-foreground flex gap-2 items-center text-sm">
394 <ClockIcon className="w-4 h-4" />
395 Swaps will be made once per hour
396 </p>
397
398 <div className="my-6 space-y-4 text-sm">
399 <div className="flex justify-between items-center gap-2">
400 <span className="font-medium">Type</span>
401 <span className="truncate text-muted-foreground text-right">
402 Lightning to On-chain
403 </span>
404 </div>
405 <div className="flex justify-between items-center gap-2">
406 <div className="font-medium">Destination</div>
407 <div className="flex min-w-0 items-center justify-end gap-2 text-muted-foreground">
408 <div className="truncate text-right">
409 {swapConfig.destination || "On-chain Balance"}
410 </div>
411 {swapConfig.destination && (
412 <CopyIcon
413 className="cursor-pointer size-4 shrink-0"
414 onClick={() => copyToClipboard(swapConfig.destination)}
415 />
416 )}
417 </div>
418 </div>
419 <div className="flex justify-between items-center gap-2">
420 <span className="font-medium truncate">
421 Lightning balance threshold
422 </span>
423 <span className="shrink-0 text-muted-foreground text-right">
424 <FormattedBitcoinAmount
425 amountMsat={swapConfig.balanceThresholdSat * 1000}
426 />
427 </span>
428 </div>
429 <div className="flex justify-between items-center gap-2">
430 <span className="font-medium truncate">Swap amount</span>
431 <span className="shrink-0 text-muted-foreground text-right">
432 <FormattedBitcoinAmount
433 amountMsat={swapConfig.swapAmountSat * 1000}
434 />
435 </span>
436 </div>
437 <div className="flex justify-between items-center gap-2">
438 <span className="font-medium">Fee</span>
439 {swapInfo ? (
440 <span className="truncate text-muted-foreground text-right">
441 {swapInfo.albyServiceFee + swapInfo.boltzServiceFee}% + on-chain
442 fees
443 </span>
444 ) : (
445 <Loading className="w-4 h-4" />
446 )}
447 </div>
448 </div>
449 <Button onClick={onDeactivate} disabled={loading} variant="outline">
450 <XCircleIcon />
451 Deactivate Auto Swap
452 </Button>
453 </>
454 );
455 }
456