CloseChannelDialogContent.tsx raw
1 import {
2 AlertCircleIcon,
3 AlertTriangleIcon,
4 CopyIcon,
5 ExternalLinkIcon,
6 } from "lucide-react";
7 import React from "react";
8 import { toast } from "sonner";
9 import { SwapAlert } from "src/components/channels/SwapAlert";
10 import ExternalLink from "src/components/ExternalLink";
11 import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount";
12 import { MempoolAlert } from "src/components/MempoolAlert";
13 import { Alert, AlertDescription, AlertTitle } from "src/components/ui/alert";
14 import { Button } from "src/components/ui/button";
15 import { Label } from "src/components/ui/label";
16 import { RadioGroup, RadioGroupItem } from "src/components/ui/radio-group";
17 import { useBalances } from "src/hooks/useBalances";
18 import { useChannels } from "src/hooks/useChannels";
19 import { useInfo } from "src/hooks/useInfo";
20 import { copyToClipboard } from "src/lib/clipboard";
21 import { Channel, CloseChannelResponse } from "src/types";
22 import { request } from "src/utils/request";
23 import {
24 AlertDialogCancel,
25 AlertDialogContent,
26 AlertDialogDescription,
27 AlertDialogFooter,
28 AlertDialogHeader,
29 AlertDialogTitle,
30 } from "./ui/alert-dialog";
31
32 type Props = {
33 alias: string;
34 channel: Channel;
35 };
36
37 export function CloseChannelDialogContent({ alias, channel }: Props) {
38 const [closeType, setCloseType] = React.useState("normal");
39 const [step, setStep] = React.useState(channel.active ? 2 : 1);
40 const [fundingTxId, setFundingTxId] = React.useState("");
41 const { data: info } = useInfo();
42 const { mutate: reloadBalances } = useBalances();
43 const { data: channels, mutate: reloadChannels } = useChannels();
44
45 const onContinue = () => {
46 setStep(step + 1);
47 };
48
49 const copy = (text: string) => {
50 copyToClipboard(text);
51 };
52
53 async function closeChannel() {
54 try {
55 console.info(`🎬 Closing channel with ${channel.remotePubkey}`);
56
57 const closeChannelResponse = await request<CloseChannelResponse>(
58 `/api/peers/${channel.remotePubkey}/channels/${channel.id}?force=${
59 closeType === "force"
60 }`,
61 {
62 method: "DELETE",
63 headers: {
64 "Content-Type": "application/json",
65 },
66 }
67 );
68
69 if (!closeChannelResponse) {
70 throw new Error("Error closing channel");
71 }
72
73 const closedChannel = channels?.find(
74 (c) => c.id === channel.id && c.remotePubkey === channel.remotePubkey
75 );
76 console.info("Closed channel", closedChannel);
77 if (closedChannel) {
78 setFundingTxId(closedChannel.fundingTxId);
79 setStep(step + 1);
80 }
81 toast("Successfully closed channel");
82 } catch (error) {
83 console.error(error);
84 toast.error("Something went wrong: " + error);
85 }
86 }
87
88 return (
89 <AlertDialogContent>
90 {step === 1 && (
91 <>
92 <AlertDialogHeader>
93 <AlertDialogTitle>
94 Are you sure you want to close the channel with {alias}?
95 </AlertDialogTitle>
96 <AlertDialogDescription>
97 This channel is inactive. Some channels require up to 6 onchain
98 confirmations before they are usable.
99 </AlertDialogDescription>
100 </AlertDialogHeader>
101 <AlertDialogFooter>
102 <AlertDialogCancel>Cancel</AlertDialogCancel>
103 <Button onClick={onContinue}>Confirm</Button>
104 </AlertDialogFooter>
105 </>
106 )}
107
108 {step === 2 && (
109 <>
110 <AlertDialogHeader>
111 <AlertDialogTitle>
112 Are you sure you want to close the channel with {alias}?
113 </AlertDialogTitle>
114 <AlertDialogDescription className="text-left">
115 <SwapAlert minChannels={0} className="mb-4" />
116 <Alert className="mb-4">
117 <AlertCircleIcon className="h-4 w-4" />
118 <AlertDescription>
119 <div>
120 Closing this channel will move{" "}
121 <FormattedBitcoinAmount
122 amountMsat={channel.localBalanceMsat}
123 />{" "}
124 in this channel to your on-chain balance and reduce your
125 receive limit by{" "}
126 <FormattedBitcoinAmount
127 amountMsat={channel.remoteBalanceMsat}
128 />
129 .
130 </div>
131 </AlertDescription>
132 </Alert>
133 <div>
134 <p className="font-medium text-foreground">Node ID</p>
135 <p className="break-all">{channel.remotePubkey}</p>
136 </div>
137 <div className="mt-4">
138 <p className="font-medium text-foreground">Channel ID</p>
139 <p className="break-all">{channel.id}</p>
140 </div>
141 </AlertDialogDescription>
142 </AlertDialogHeader>
143 <AlertDialogFooter>
144 <AlertDialogCancel>Cancel</AlertDialogCancel>
145 <Button onClick={onContinue}>Continue</Button>
146 </AlertDialogFooter>
147 </>
148 )}
149
150 {step === 3 && (
151 <>
152 <AlertDialogHeader>
153 <AlertDialogTitle>Select mode of channel closure</AlertDialogTitle>
154 <AlertDialogDescription className="text-left">
155 <div className="mb-4">
156 <MempoolAlert />
157 </div>
158 {closeType === "force" && (
159 <Alert className="mb-4">
160 <AlertTriangleIcon className="h-4 w-4" />
161 <AlertTitle>Heads up!</AlertTitle>
162 <AlertDescription>
163 Your channel balance will be locked for up to two weeks if
164 you force close
165 </AlertDescription>
166 </Alert>
167 )}
168 <RadioGroup
169 defaultValue="normal"
170 value={closeType}
171 onValueChange={() =>
172 setCloseType(closeType === "normal" ? "force" : "normal")
173 }
174 className="mt-2"
175 >
176 <div className="flex items-start space-x-2 mb-2">
177 <RadioGroupItem
178 value="normal"
179 id="normal"
180 className="shrink-0"
181 />
182 <div className="grid gap-1.5">
183 <Label
184 htmlFor="normal"
185 className="text-foreground cursor-pointer"
186 >
187 Normal Close (Recommended)
188 </Label>
189 <p className="text-sm text-muted-foreground">
190 Attempt to settle with your channel partner for a quick,
191 low-cost closure. Your funds should be available on-chain
192 within an hour. If your partner is offline or agreement
193 cannot be met, a force closure will be initiated.
194 </p>
195 </div>
196 </div>
197 <div className="flex items-start space-x-2">
198 <RadioGroupItem
199 value="force"
200 id="force"
201 className="shrink-0"
202 />
203 <div className="grid gap-1.5">
204 <Label
205 htmlFor="force"
206 className="text-foreground cursor-pointer"
207 >
208 Force Close
209 </Label>
210 <p className="text-sm text-muted-foreground">
211 You close the channel alone. Your funds may be locked for
212 up to two weeks and may incur higher fees. Only try this
213 if a normal closure does not work.
214 </p>
215 </div>
216 </div>
217 </RadioGroup>
218 <ExternalLink
219 to="https://guides.getalby.com/user-guide/alby-hub/faq/how-can-i-close-a-channel-what-happens-to-the-sats-in-this-channel"
220 className="underline flex items-center mt-4"
221 >
222 Learn more about closing channels
223 <ExternalLinkIcon className="size-4 ml-2" />
224 </ExternalLink>
225 </AlertDialogDescription>
226 </AlertDialogHeader>
227 <AlertDialogFooter>
228 <AlertDialogCancel>Cancel</AlertDialogCancel>
229 <Button onClick={closeChannel}>Close Channel</Button>
230 </AlertDialogFooter>
231 </>
232 )}
233
234 {step === 4 && (
235 <>
236 <AlertDialogHeader>
237 <AlertDialogTitle>Channel closed successfully</AlertDialogTitle>
238 <AlertDialogDescription className="text-left">
239 <p className="font-medium text-foreground">
240 Funding Transaction Id
241 </p>
242 <div className="flex items-center justify-between gap-4">
243 <p className="break-all">{fundingTxId}</p>
244 <CopyIcon
245 className="cursor-pointer text-muted-foreground size-4"
246 onClick={() => {
247 copy(fundingTxId);
248 }}
249 />
250 </div>
251 <ExternalLink
252 to={`${info?.mempoolUrl}/tx/${fundingTxId}`}
253 className="underline flex items-center mt-2"
254 >
255 View on Mempool
256 <ExternalLinkIcon className="size-4 ml-2" />
257 </ExternalLink>
258 </AlertDialogDescription>
259 </AlertDialogHeader>
260 <AlertDialogFooter>
261 <AlertDialogCancel
262 onClick={async () => {
263 await reloadChannels();
264 await reloadBalances();
265 }}
266 >
267 Done
268 </AlertDialogCancel>
269 </AlertDialogFooter>
270 </>
271 )}
272 </AlertDialogContent>
273 );
274 }
275