index.tsx raw
1 import {
2 ClipboardPasteIcon,
3 ExternalLinkIcon,
4 MoveRightIcon,
5 RefreshCwIcon,
6 } from "lucide-react";
7 import { useEffect, useState } from "react";
8 import { useNavigate, useSearchParams } from "react-router";
9 import { toast } from "sonner";
10 import { AnchorReserveAlert } from "src/components/AnchorReserveAlert";
11 import AppHeader from "src/components/AppHeader";
12 import { CurrencyInputField } from "src/components/CurrencyInputField";
13 import { FixedFloatButton } from "src/components/FixedFloatButton";
14 import { FixedFloatSwapInFlow } from "src/components/FixedFloatSwapInFlow";
15 import Loading from "src/components/Loading";
16 import LowReceivingCapacityAlert from "src/components/LowReceivingCapacityAlert";
17 import ResponsiveLinkButton from "src/components/ResponsiveLinkButton";
18 import { Button } from "src/components/ui/button";
19 import { LoadingButton } from "src/components/ui/custom/loading-button";
20 import { Input } from "src/components/ui/input";
21 import { Label } from "src/components/ui/label";
22 import { RadioGroup, RadioGroupItem } from "src/components/ui/radio-group";
23 import { Separator } from "src/components/ui/separator";
24 import {
25 Tabs,
26 TabsContent,
27 TabsList,
28 TabsTrigger,
29 } from "src/components/ui/tabs";
30 import { useBalances } from "src/hooks/useBalances";
31 import { useInfo } from "src/hooks/useInfo";
32 import { useSwapInfo } from "src/hooks/useSwaps";
33 import {
34 CreateInvoiceRequest,
35 InitiateSwapRequest,
36 SwapResponse,
37 Transaction,
38 } from "src/types";
39 import { openLink } from "src/utils/openLink";
40 import { request } from "src/utils/request";
41
42 export default function Swap() {
43 const [searchParams, setSearchParams] = useSearchParams();
44 const [tab, setTab] = useState(searchParams.get("type") || "in");
45
46 useEffect(() => {
47 const newTabValue = searchParams.get("type");
48 if (newTabValue) {
49 setTab(newTabValue);
50 setSearchParams({}, { replace: true });
51 }
52 }, [searchParams, setSearchParams]);
53
54 return (
55 <div className="grid gap-5">
56 <AppHeader
57 pageTitle="Swap"
58 title="Swap"
59 contentRight={
60 tab === "out" && (
61 <ResponsiveLinkButton
62 to="/wallet/swap/auto"
63 variant="outline"
64 icon={RefreshCwIcon}
65 text="Auto Swap"
66 />
67 )
68 }
69 />
70 <Tabs value={tab} onValueChange={setTab} className="w-full max-w-lg">
71 <TabsList className="w-full mb-4">
72 <TabsTrigger value="in" className="flex gap-2 items-center w-full">
73 Swap In
74 </TabsTrigger>
75 <TabsTrigger value="out" className="flex gap-2 items-center w-full">
76 Swap Out
77 </TabsTrigger>
78 </TabsList>
79 <TabsContent value="in">
80 <SwapInForm />
81 </TabsContent>
82 <TabsContent value="out">
83 <SwapOutForm />
84 </TabsContent>
85 </Tabs>
86 </div>
87 );
88 }
89
90 function SwapInForm() {
91 const [swapFrom, setSwapFrom] = useState<"internal" | "external" | "crypto">(
92 "external"
93 );
94 const { data: info, hasChannelManagement } = useInfo();
95 const { data: balances } = useBalances();
96 const { data: swapInfo } = useSwapInfo("in");
97 const navigate = useNavigate();
98
99 const [swapAmountSat, setSwapAmountSat] = useState("");
100 const [loading, setLoading] = useState(false);
101 const [cryptoTransaction, setCryptoTransaction] =
102 useState<Transaction | null>(null);
103
104 const onSubmit = async (e: React.FormEvent) => {
105 e.preventDefault();
106
107 try {
108 setLoading(true);
109 if (swapFrom === "crypto") {
110 const tx = await request<Transaction>("/api/invoices", {
111 method: "POST",
112 headers: {
113 "Content-Type": "application/json",
114 },
115 body: JSON.stringify({
116 amountMsat: (parseInt(swapAmountSat) || 0) * 1000,
117 description: "Fixed Float swap",
118 } as CreateInvoiceRequest),
119 });
120 if (!tx?.invoice) {
121 throw new Error("Failed to create invoice");
122 }
123 setCryptoTransaction(tx);
124 openLink(
125 `https://ff.io/?to=BTCLN&address=${encodeURIComponent(tx.invoice)}&ref=qnnjvywb`
126 );
127 toast("Initiated swap");
128 return;
129 }
130
131 const payload: InitiateSwapRequest = {
132 swapAmountSat: parseInt(swapAmountSat),
133 };
134 const swapInResponse = await request<SwapResponse>("/api/swaps/in", {
135 method: "POST",
136 headers: {
137 "Content-Type": "application/json",
138 },
139 body: JSON.stringify(payload),
140 });
141 if (!swapInResponse) {
142 throw new Error("Error swapping in");
143 }
144 navigate(
145 `/wallet/swap/in/status/${swapInResponse.swapId}${swapFrom === "internal" ? "?internal=true" : ""}`
146 );
147 toast("Initiated swap");
148 } catch (error) {
149 toast.error("Failed to initiate swap", {
150 description: (error as Error).message,
151 });
152 } finally {
153 setLoading(false);
154 }
155 };
156
157 if (!info || !balances || !swapInfo) {
158 return <Loading />;
159 }
160
161 const spendableOnchainBalance = balances.onchain.spendableSat;
162 const isInternalSwap = swapFrom === "internal";
163 const isCryptoSwappingState =
164 swapFrom === "crypto" && cryptoTransaction !== null;
165
166 return (
167 <form onSubmit={onSubmit} className="flex flex-col gap-6">
168 {!isCryptoSwappingState && (
169 <>
170 <div>
171 <h2 className="font-medium text-foreground flex items-center gap-1">
172 On-chain <MoveRightIcon /> Lightning
173 </h2>
174 <p className="mt-1 text-muted-foreground">
175 Swap on-chain funds into your lightning balance.
176 </p>
177 </div>
178 <div className="grid gap-1.5">
179 {hasChannelManagement &&
180 parseInt(swapAmountSat || "0") * 1000 >=
181 0.8 * balances.lightning.totalReceivableMsat && (
182 <div className="mb-4">
183 <LowReceivingCapacityAlert />
184 </div>
185 )}
186
187 {isInternalSwap && (
188 <AnchorReserveAlert amountSat={+swapAmountSat} />
189 )}
190 <CurrencyInputField
191 label="Swap amount"
192 autoFocus
193 valueSat={swapAmountSat}
194 onValueSatChange={setSwapAmountSat}
195 minSat={swapFrom !== "crypto" ? swapInfo.minAmountSat : undefined}
196 maxSat={
197 swapFrom === "crypto"
198 ? hasChannelManagement
199 ? balances.lightning.totalReceivableSat * 0.99
200 : undefined
201 : Math.min(
202 swapInfo.maxAmountSat,
203 ...(isInternalSwap ? [spendableOnchainBalance] : []),
204 ...(hasChannelManagement
205 ? [balances.lightning.totalReceivableSat * 0.99]
206 : [])
207 )
208 }
209 required
210 contextRows={[
211 ...(isInternalSwap
212 ? [
213 {
214 label: "Available on-chain",
215 amountSat: spendableOnchainBalance,
216 },
217 ]
218 : []),
219 ...(hasChannelManagement
220 ? [
221 {
222 label: "Receive limit",
223 amountSat: balances.lightning.totalReceivableSat,
224 },
225 ]
226 : []),
227 ]}
228 />
229 </div>
230 <div className="flex flex-col gap-4">
231 <Label>Swap from</Label>
232 <RadioGroup
233 defaultValue="external"
234 value={swapFrom}
235 onValueChange={(value: "internal" | "external" | "crypto") => {
236 setSwapFrom(value);
237 }}
238 className="flex gap-4 flex-wrap"
239 >
240 <div className="flex items-start space-x-2 mb-2">
241 <RadioGroupItem
242 value="internal"
243 id="internal"
244 className="shrink-0"
245 />
246 <Label htmlFor="internal" className="cursor-pointer">
247 On-chain balance
248 </Label>
249 </div>
250 <div className="flex items-start space-x-2">
251 <RadioGroupItem
252 value="external"
253 id="external"
254 className="shrink-0"
255 />
256 <Label htmlFor="external" className="cursor-pointer">
257 External on-chain wallet
258 </Label>
259 </div>
260 <div className="flex items-start space-x-2">
261 <RadioGroupItem
262 value="crypto"
263 id="crypto"
264 className="shrink-0"
265 />
266 <Label htmlFor="crypto" className="cursor-pointer">
267 Other Cryptocurrency
268 </Label>
269 </div>
270 </RadioGroup>
271 </div>
272 </>
273 )}
274
275 {swapFrom === "crypto" ? (
276 <FixedFloatSwapInFlow
277 loading={loading}
278 transaction={cryptoTransaction}
279 resetLabel="Swap Another Amount"
280 onReset={() => {
281 setCryptoTransaction(null);
282 setSwapAmountSat("");
283 }}
284 />
285 ) : (
286 <>
287 <div className="flex items-center justify-between border-t pt-4">
288 <Label>Fee</Label>
289 <p className="text-muted-foreground text-sm">
290 {swapInfo.albyServiceFee + swapInfo.boltzServiceFee}% + on-chain
291 fees
292 </p>
293 </div>
294 <div className="grid gap-2">
295 <LoadingButton className="w-full" loading={loading}>
296 Swap In
297 </LoadingButton>
298 <p className="text-xs text-muted-foreground text-center">
299 powered by{" "}
300 <span className="font-medium text-foreground">
301 boltz.exchange
302 </span>
303 </p>
304 </div>
305 </>
306 )}
307 </form>
308 );
309 }
310
311 function SwapOutForm() {
312 const { data: swapInfo } = useSwapInfo("out");
313 const navigate = useNavigate();
314 const { data: balances } = useBalances();
315
316 const [isInternalSwap, setInternalSwap] = useState(true);
317 const [swapAmountSat, setSwapAmountSat] = useState("");
318 const [destination, setDestination] = useState("");
319 const [loading, setLoading] = useState(false);
320
321 const onSubmit = async (e: React.FormEvent) => {
322 e.preventDefault();
323
324 try {
325 setLoading(true);
326 const payload: InitiateSwapRequest = {
327 swapAmountSat: parseInt(swapAmountSat),
328 destination,
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 (error) {
343 toast.error("Failed to initiate swap", {
344 description: (error as Error).message,
345 });
346 } finally {
347 setLoading(false);
348 }
349 };
350
351 const paste = async () => {
352 const text = await navigator.clipboard.readText();
353 setDestination(text.trim());
354 };
355
356 if (!balances || !swapInfo) {
357 return <Loading />;
358 }
359
360 return (
361 <form onSubmit={onSubmit} className="flex flex-col gap-6">
362 <div>
363 <h2 className="font-medium text-foreground flex items-center gap-1">
364 Lightning <MoveRightIcon /> On-chain
365 </h2>
366 <p className="mt-1 text-muted-foreground">
367 Swap bitcoin lightning into your on-chain balance.
368 </p>
369 </div>
370 <div className="grid gap-1.5">
371 <CurrencyInputField
372 label="Swap amount"
373 autoFocus
374 valueSat={swapAmountSat}
375 onValueSatChange={setSwapAmountSat}
376 minSat={swapInfo.minAmountSat}
377 maxSat={Math.min(
378 swapInfo.maxAmountSat,
379 balances.lightning.totalSpendableSat
380 )}
381 required
382 contextRows={[
383 {
384 label: "Lightning balance",
385 amountSat: balances.lightning.totalSpendableSat,
386 },
387 {
388 label: "Minimum",
389 amountSat: swapInfo.minAmountSat,
390 },
391 ]}
392 />
393 </div>
394 <div className="flex flex-col gap-4">
395 <Label>Swap to</Label>
396 <RadioGroup
397 defaultValue="normal"
398 value={isInternalSwap ? "internal" : "external"}
399 onValueChange={() => {
400 setDestination("");
401 setInternalSwap(!isInternalSwap);
402 }}
403 className="flex gap-4 flex-row"
404 >
405 <div className="flex items-start space-x-2 mb-2">
406 <RadioGroupItem
407 value="internal"
408 id="internal"
409 className="shrink-0"
410 />
411 <Label htmlFor="internal" className="cursor-pointer">
412 On-chain balance
413 </Label>
414 </div>
415 <div className="flex items-start space-x-2">
416 <RadioGroupItem
417 value="external"
418 id="external"
419 className="shrink-0"
420 />
421 <Label htmlFor="external" className="cursor-pointer">
422 External on-chain wallet
423 </Label>
424 </div>
425 </RadioGroup>
426 </div>
427 {!isInternalSwap && (
428 <div className="grid gap-1.5">
429 <Label>Receiving on-chain address</Label>
430 <div className="flex gap-2">
431 <Input
432 placeholder="bc1..."
433 value={destination}
434 onChange={(e) => setDestination(e.target.value)}
435 required
436 />
437 <Button
438 type="button"
439 variant="outline"
440 className="px-2"
441 onClick={paste}
442 >
443 <ClipboardPasteIcon className="w-4 h-4" />
444 </Button>
445 </div>
446 </div>
447 )}
448
449 <div className="flex items-center justify-between border-t pt-4">
450 <Label>Fee</Label>
451 <p className="text-muted-foreground text-sm">
452 {swapInfo.albyServiceFee + swapInfo.boltzServiceFee}% + on-chain fees
453 </p>
454 </div>
455 <div className="grid gap-2">
456 <LoadingButton className="w-full" loading={loading}>
457 Swap Out
458 </LoadingButton>
459 <p className="text-xs text-muted-foreground text-center">
460 powered by{" "}
461 <span className="font-medium text-foreground">boltz.exchange</span>
462 </p>
463 </div>
464 <Separator className="my-2" />
465 <FixedFloatButton from="BTCLN" className="w-full" variant="secondary">
466 <ExternalLinkIcon className="size-4" />
467 Swap out to other Cryptocurrency
468 </FixedFloatButton>
469 </form>
470 );
471 }
472