RebalanceChannelDialogContent.tsx raw
1 import { AlertTriangleIcon, ExternalLinkIcon } from "lucide-react";
2 import React from "react";
3 import { toast } from "sonner";
4 import ExternalLink from "src/components/ExternalLink";
5 import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount";
6 import Loading from "src/components/Loading";
7 import { Alert, AlertDescription, AlertTitle } from "src/components/ui/alert";
8 import { LoadingButton } from "src/components/ui/custom/loading-button";
9 import { Input } from "src/components/ui/input";
10 import { Label } from "src/components/ui/label";
11 import { useBalances } from "src/hooks/useBalances";
12 import { useChannels } from "src/hooks/useChannels";
13 import { request } from "src/utils/request";
14 import {
15 AlertDialogCancel,
16 AlertDialogContent,
17 AlertDialogDescription,
18 AlertDialogFooter,
19 AlertDialogHeader,
20 AlertDialogTitle,
21 } from "./ui/alert-dialog";
22
23 type Props = {
24 receiveThroughNodePubkey: string;
25 closeDialog(): void;
26 };
27
28 export function RebalanceChannelDialogContent({
29 receiveThroughNodePubkey,
30 closeDialog,
31 }: Props) {
32 const [amountSat, setAmountSat] = React.useState("");
33 const { data: channels, mutate: reloadChannels } = useChannels();
34 const { mutate: reloadBalances } = useBalances();
35 const [isRebalancing, setRebalancing] = React.useState(false);
36
37 const inputRef = React.useRef<HTMLInputElement>(null);
38
39 React.useEffect(() => {
40 setTimeout(() => {
41 // for some reason `autoFocus` is not working on this input
42 inputRef.current?.focus();
43 }, 100);
44 }, [inputRef]);
45
46 if (!channels) {
47 return <Loading />;
48 }
49
50 async function handleSubmit(e: React.FormEvent) {
51 e.preventDefault();
52 setRebalancing(true);
53 try {
54 if (!channels) {
55 throw new Error("channels not loaded");
56 }
57
58 const response = await request<{ totalFeeSat: number }>(
59 `/api/channels/rebalance`,
60 {
61 method: "POST",
62 headers: {
63 "Content-Type": "application/json",
64 },
65 body: JSON.stringify({
66 receiveThroughNodePubkey,
67 amountSat: parseInt(amountSat),
68 }),
69 }
70 );
71 if (!response) {
72 throw new Error("No rebalance response received");
73 }
74
75 await Promise.all([reloadChannels(), reloadBalances()]);
76 toast(
77 "Successfully rebalanced channels. Total fee: " +
78 response.totalFeeSat +
79 " sats"
80 );
81 closeDialog();
82 } catch (error) {
83 console.error(error);
84 toast.error("" + error);
85 }
86 setRebalancing(false);
87 }
88
89 return (
90 <AlertDialogContent>
91 <form onSubmit={handleSubmit}>
92 <AlertDialogHeader>
93 <AlertDialogTitle>Rebalance In</AlertDialogTitle>
94 <AlertDialogDescription>
95 <p className="mb-4">
96 Rebalance funds from other channels into this channel.
97 </p>
98 <Label htmlFor="fee" className="block mb-2">
99 Rebalance amount (sats)
100 </Label>
101 <Input
102 ref={inputRef}
103 id="amount"
104 name="amount"
105 type="number"
106 required
107 autoFocus
108 min={Math.max(
109 10000,
110 Math.floor(
111 Math.min(
112 ...channels
113 .filter(
114 (channel) =>
115 channel.remotePubkey === receiveThroughNodePubkey
116 )
117 .map((channel) => channel.localSpendableBalanceSat)
118 ) + 1
119 )
120 )}
121 max={Math.floor(
122 Math.max(
123 ...channels
124 .filter(
125 (channel) =>
126 channel.remotePubkey === receiveThroughNodePubkey
127 )
128 .map((channel) => channel.remoteBalanceSat)
129 )
130 )}
131 value={amountSat}
132 onChange={(e) => {
133 setAmountSat(e.target.value.trim());
134 }}
135 />
136 <p className="mt-2 text-xs text-muted-foreground">
137 Fee: 0.5%
138 {!!amountSat && (
139 <>
140 (
141 <FormattedBitcoinAmount
142 amountMsat={Math.floor(
143 parseInt(amountSat || "0") * 0.003 * 1000
144 )}
145 />
146 )
147 </>
148 )}{" "}
149 + routing fees
150 </p>
151 <ExternalLink
152 to="https://guides.getalby.com/user-guide/alby-hub/faq/can-i-rebalance-funds-from-one-of-my-channels-to-another"
153 className="underline flex items-center mt-4"
154 >
155 Learn more about rebalancing between channels
156 <ExternalLinkIcon className="size-4 ml-2" />
157 </ExternalLink>
158 <Alert className="mt-2">
159 <AlertTriangleIcon className="h-4 w-4" />
160 <AlertTitle>Rebalancing is in beta</AlertTitle>
161 <AlertDescription>
162 Funds may be rebalanced out of unexpected channels.
163 </AlertDescription>
164 </Alert>
165 {channels.filter(
166 (channel) => channel.remotePubkey === receiveThroughNodePubkey
167 ).length > 1 && (
168 <Alert className="mt-2">
169 <AlertTriangleIcon className="h-4 w-4" />
170 <AlertTitle>
171 Multiple channels with same counterparty
172 </AlertTitle>
173 <AlertDescription>
174 Funds may be rebalanced to an unexpected channel.
175 </AlertDescription>
176 </Alert>
177 )}
178 {channels.some(
179 (channel) =>
180 channel.remotePubkey !== receiveThroughNodePubkey &&
181 channel.localSpendableBalanceMsat <
182 (channels.find(
183 (other) => other.remotePubkey === receiveThroughNodePubkey
184 )?.localSpendableBalanceMsat || 0)
185 ) && (
186 <Alert className="mt-2">
187 <AlertTriangleIcon className="h-4 w-4" />
188 <AlertTitle>
189 You have another channel with less funds
190 </AlertTitle>
191 <AlertDescription>
192 Consider choosing a channel with less local balance to
193 rebalance into.
194 </AlertDescription>
195 </Alert>
196 )}
197 </AlertDialogDescription>
198 </AlertDialogHeader>
199 <AlertDialogFooter className="mt-4">
200 <AlertDialogCancel>Cancel</AlertDialogCancel>
201 <LoadingButton loading={isRebalancing}>Confirm</LoadingButton>
202 </AlertDialogFooter>
203 </form>
204 </AlertDialogContent>
205 );
206 }
207