IncreaseOutgoingCapacity.tsx raw
1 import { InfoIcon } from "lucide-react";
2 import React, { FormEvent } from "react";
3 import { Link, useNavigate } from "react-router";
4 import { toast } from "sonner";
5 import AppHeader from "src/components/AppHeader";
6 import { ChannelPeerNote } from "src/components/channels/ChannelPeerNote";
7 import { ChannelPublicPrivateAlert } from "src/components/channels/ChannelPublicPrivateAlert";
8 import { DuplicateChannelAlert } from "src/components/channels/DuplicateChannelAlert";
9 import { SwapAlert } from "src/components/channels/SwapAlert";
10 import ExternalLink from "src/components/ExternalLink";
11 import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount";
12 import Loading from "src/components/Loading";
13 import { MempoolAlert } from "src/components/MempoolAlert";
14 import { Alert, AlertDescription } from "src/components/ui/alert";
15 import { Button } from "src/components/ui/button";
16 import { Checkbox } from "src/components/ui/checkbox";
17 import { ExternalLinkButton } from "src/components/ui/custom/external-link-button";
18 import { LinkButton } from "src/components/ui/custom/link-button";
19 import {
20 Dialog,
21 DialogContent,
22 DialogDescription,
23 DialogFooter,
24 DialogHeader,
25 DialogTitle,
26 } from "src/components/ui/dialog";
27 import { Input } from "src/components/ui/input";
28 import { Label } from "src/components/ui/label";
29 import {
30 Select,
31 SelectContent,
32 SelectItem,
33 SelectTrigger,
34 SelectValue,
35 } from "src/components/ui/select";
36 import {
37 Tooltip,
38 TooltipContent,
39 TooltipProvider,
40 TooltipTrigger,
41 } from "src/components/ui/tooltip";
42 import { useBalances } from "src/hooks/useBalances";
43 import { useChannelPeerSuggestions } from "src/hooks/useChannelPeerSuggestions";
44 import { useChannels } from "src/hooks/useChannels";
45 import { useInfo } from "src/hooks/useInfo";
46 import { usePeers } from "src/hooks/usePeers";
47 import { cn, formatAmount } from "src/lib/utils";
48 import useChannelOrderStore from "src/state/ChannelOrderStore";
49 import {
50 Channel,
51 Network,
52 NewChannelOrder,
53 OnchainOrder,
54 RecommendedChannelPeer,
55 } from "src/types";
56
57 import LightningNetworkDarkSVG from "public/images/illustrations/lightning-network-dark.svg";
58 import LightningNetworkLightSVG from "public/images/illustrations/lightning-network-light.svg";
59 import { useNodeDetails } from "src/hooks/useNodeDetails";
60
61 function getPeerKey(peer: RecommendedChannelPeer) {
62 return JSON.stringify(peer);
63 }
64
65 export default function IncreaseOutgoingCapacity() {
66 const { data: info } = useInfo();
67 const { data: channels } = useChannels();
68
69 if (!info?.network || !channels) {
70 return <Loading />;
71 }
72
73 return <NewChannelInternal network={info.network} channels={channels} />;
74 }
75
76 function NewChannelInternal({
77 network,
78 channels,
79 }: {
80 network: Network;
81 channels: Channel[];
82 }) {
83 const { data: _channelPeerSuggestions } = useChannelPeerSuggestions();
84 const { data: balances } = useBalances();
85
86 const navigate = useNavigate();
87
88 const presetAmounts = [250_000, 500_000, 1_000_000];
89
90 const [order, setOrder] = React.useState<Partial<OnchainOrder>>({
91 paymentMethod: "onchain",
92 status: "pay",
93 amountSat: presetAmounts[0].toString(),
94 isPublic: !!channels.length && channels.every((channel) => channel.public),
95 });
96
97 const [selectedPeer, setSelectedPeer] = React.useState<
98 RecommendedChannelPeer | undefined
99 >();
100
101 const [showConfirmModal, setShowConfirmModal] = React.useState(false);
102
103 const channelPeerSuggestions = React.useMemo(() => {
104 const customOption: RecommendedChannelPeer = {
105 name: "Custom",
106 network,
107 paymentMethod: "onchain",
108 minimumChannelSizeSat: 0,
109 maximumChannelSizeSat: 0,
110 description: "",
111 pubkey: "",
112 host: "",
113 image: "",
114 note: "",
115 publicChannelsAllowed: true,
116 };
117 return _channelPeerSuggestions
118 ? [
119 ..._channelPeerSuggestions.filter(
120 (peer) => peer.paymentMethod !== "lightning"
121 ),
122 customOption,
123 ]
124 : [customOption];
125 }, [_channelPeerSuggestions, network]);
126
127 function setPublic(isPublic: boolean) {
128 setOrder((current) => ({
129 ...current,
130 isPublic,
131 }));
132 }
133
134 const setAmountSat = React.useCallback((amountSat: string) => {
135 setOrder((current) => ({
136 ...current,
137 amountSat,
138 }));
139 }, []);
140
141 React.useEffect(() => {
142 if (!channelPeerSuggestions) {
143 return;
144 }
145 const recommendedPeer = channelPeerSuggestions.find(
146 (peer) =>
147 peer.network === network && peer.paymentMethod === order.paymentMethod
148 );
149
150 setSelectedPeer(recommendedPeer);
151 }, [network, order.paymentMethod, channelPeerSuggestions]);
152
153 React.useEffect(() => {
154 if (selectedPeer) {
155 if (
156 selectedPeer.paymentMethod === "onchain" &&
157 order.paymentMethod === "onchain"
158 ) {
159 setOrder((current) => ({
160 ...current,
161 pubkey: selectedPeer.pubkey,
162 host: selectedPeer.host,
163 ...(!selectedPeer.publicChannelsAllowed && { isPublic: false }),
164 }));
165 }
166 }
167 }, [order.paymentMethod, selectedPeer]);
168
169 function onSubmit(e: FormEvent) {
170 e.preventDefault();
171 setShowConfirmModal(true);
172 }
173
174 function handleConfirmSubmit() {
175 try {
176 if (!channels) {
177 throw new Error("Channels not loaded");
178 }
179 if (
180 channels.some(
181 (channel) =>
182 channel.status === "opening" &&
183 channel.isOutbound &&
184 !channel.confirmations
185 )
186 ) {
187 throw new Error(
188 "You already are opening a channel which has not been confirmed yet. Please wait for one block confirmation."
189 );
190 }
191
192 useChannelOrderStore.getState().setOrder(order as NewChannelOrder);
193 setShowConfirmModal(false);
194 navigate("/channels/order");
195 } catch (error) {
196 toast.error("Something went wrong", {
197 description: `${error}`,
198 });
199 setShowConfirmModal(false);
200 }
201 }
202
203 if (!channelPeerSuggestions || !balances) {
204 return <Loading />;
205 }
206
207 const openImmediately =
208 order.amountSat &&
209 order.paymentMethod === "onchain" &&
210 +order.amountSat < balances.onchain.spendableSat;
211
212 return (
213 <>
214 <AppHeader
215 pageTitle="Open Channel with On-Chain"
216 title="Open Channel with On-Chain"
217 description="Funds used to open a channel minus fees will be added to your lightning balance"
218 contentRight={
219 <div className="flex items-end">
220 <Link to="/channels/incoming" className="underline text-sm">
221 Open Channel with Lightning
222 </Link>
223 </div>
224 }
225 />
226 <div className="md:max-w-md max-w-full flex flex-col gap-5 flex-1">
227 <img
228 src={LightningNetworkDarkSVG}
229 className="w-full hidden dark:block"
230 />
231 <img src={LightningNetworkLightSVG} className="w-full dark:hidden" />
232 <p className="text-muted-foreground">
233 Open a channel with on-chain funds. Both parties are free to close the
234 channel at any time. However, by keeping more funds on your side of
235 the channel and using it regularly, there is more chance the channel
236 will stay open.{" "}
237 <ExternalLink
238 className="underline"
239 to="https://guides.getalby.com/user-guide/alby-hub/node/advanced-increase-spending-balance-with-on-chain-bitcoin"
240 >
241 Learn more
242 </ExternalLink>
243 .
244 </p>
245 <form
246 onSubmit={onSubmit}
247 className="md:max-w-md max-w-full flex flex-col gap-5 flex-1"
248 >
249 <div className="grid gap-1.5">
250 <TooltipProvider>
251 <Tooltip>
252 <TooltipTrigger type="button">
253 <div className="flex flex-row gap-2 items-center justify-start text-sm">
254 <Label htmlFor="amount">
255 Increase lightning balance (sats)
256 </Label>
257 <InfoIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
258 </div>
259 </TooltipTrigger>
260 <TooltipContent>
261 Configure the amount of spending balance you need in your new
262 lightning channel. You will need to deposit on-chain bitcoin
263 to cover the entire channel size, plus on-chain fees.
264 </TooltipContent>
265 </Tooltip>
266 </TooltipProvider>
267
268 {order.amountSat && +order.amountSat < 200_000 && (
269 <p className="text-muted-foreground text-xs">
270 For a smooth experience consider a opening a channel of{" "}
271 <FormattedBitcoinAmount amountMsat={200_000 * 1000} /> in size
272 or more.{" "}
273 <ExternalLink
274 to="https://guides.getalby.com/user-guide/alby-hub/node"
275 className="underline"
276 >
277 Learn more
278 </ExternalLink>
279 </p>
280 )}
281 <Input
282 id="amount"
283 type="number"
284 required
285 min={selectedPeer?.minimumChannelSizeSat || 100000}
286 value={order.amountSat}
287 onChange={(e) => {
288 setAmountSat(e.target.value.trim());
289 }}
290 />
291 <div className="text-muted-foreground text-sm sensitive slashed-zero">
292 Current on-chain balance:{" "}
293 <FormattedBitcoinAmount
294 amountMsat={balances.onchain.spendableSat * 1000}
295 />
296 </div>
297 <div className="grid grid-cols-3 gap-1.5 text-muted-foreground text-xs">
298 {presetAmounts.map((presetAmountSat) => (
299 <div
300 key={presetAmountSat}
301 className={cn(
302 "text-center border rounded p-2 cursor-pointer hover:border-muted-foreground",
303 +(order.amountSat || "0") === presetAmountSat &&
304 "border-primary hover:border-primary"
305 )}
306 onClick={() => setAmountSat(presetAmountSat.toString())}
307 >
308 {formatAmount(presetAmountSat * 1000, 0)}
309 </div>
310 ))}
311 </div>
312 </div>
313 <>
314 <div className="flex flex-col gap-3">
315 {selectedPeer &&
316 order.paymentMethod === "onchain" &&
317 selectedPeer.pubkey === order.pubkey && (
318 <div className="grid gap-1.5">
319 <Label>Choose your channel peer:</Label>
320 <Select
321 value={getPeerKey(selectedPeer)}
322 onValueChange={(value) =>
323 setSelectedPeer(
324 channelPeerSuggestions.find(
325 (x) => getPeerKey(x) === value
326 )
327 )
328 }
329 >
330 <SelectTrigger>
331 <SelectValue placeholder="Select channel peer" />
332 </SelectTrigger>
333 <SelectContent>
334 {channelPeerSuggestions
335 .filter(
336 (peer) =>
337 peer.network === network &&
338 peer.paymentMethod === order.paymentMethod
339 )
340 .map((peer) => (
341 <SelectItem
342 value={getPeerKey(peer)}
343 key={getPeerKey(peer)}
344 >
345 <div className="flex items-center gap-3">
346 <div className="flex items-center gap-3">
347 {peer.name !== "Custom" && (
348 <img
349 src={peer.image}
350 className="size-8 object-contain"
351 />
352 )}
353 <div>
354 {peer.name}
355 {peer.minimumChannelSizeSat > 0 && (
356 <span className="ml-4 text-xs text-muted-foreground slashed-zero">
357 Min.{" "}
358 <FormattedBitcoinAmount
359 amountMsat={
360 peer.minimumChannelSizeSat * 1000
361 }
362 />
363 </span>
364 )}
365 </div>
366 </div>
367 </div>
368 </SelectItem>
369 ))}
370 </SelectContent>
371 </Select>
372 {selectedPeer.name === "Custom" && (
373 <>
374 <div className="grid gap-1.5"></div>
375 </>
376 )}
377 </div>
378 )}
379 </div>
380 {order.paymentMethod === "onchain" && (
381 <NewChannelOnchain
382 order={order}
383 setOrder={setOrder}
384 showCustomOptions={selectedPeer?.name === "Custom"}
385 />
386 )}
387
388 <div className="mt-2 flex items-top space-x-2">
389 <Checkbox
390 id="public-channel"
391 checked={order.isPublic}
392 onCheckedChange={() => setPublic(!order.isPublic)}
393 className="mr-2"
394 disabled={selectedPeer && !selectedPeer.publicChannelsAllowed}
395 title={
396 selectedPeer && !selectedPeer.publicChannelsAllowed
397 ? "This channel partner does not support public channels."
398 : undefined
399 }
400 />
401 <div className="grid gap-1.5 leading-none">
402 <Label htmlFor="public-channel" className="cursor-pointer">
403 Public Channel
404 </Label>
405 <p className="text-xs text-muted-foreground">
406 Not recommended for most users.{" "}
407 <ExternalLink
408 className="underline"
409 to="https://guides.getalby.com/user-guide/alby-hub/faq/should-i-open-a-private-or-public-channel"
410 >
411 Learn more
412 </ExternalLink>
413 </p>
414 </div>
415 </div>
416 </>
417 <MempoolAlert />
418 <SwapAlert swapType="in" />
419 {channels?.some((channel) => channel.public !== !!order.isPublic) && (
420 <ChannelPublicPrivateAlert />
421 )}
422 {selectedPeer?.note && <ChannelPeerNote peer={selectedPeer} />}
423 <DuplicateChannelAlert
424 pubkey={order?.pubkey}
425 name={selectedPeer?.name}
426 />
427 <Button size="lg">{openImmediately ? "Open Channel" : "Next"}</Button>
428 </form>
429
430 <div className="flex-1 flex flex-col justify-end items-center gap-4">
431 <p className="mt-32 text-sm text-muted-foreground text-center">
432 Other options
433 </p>
434 <LinkButton
435 to="/channels/incoming"
436 className="w-full"
437 variant="secondary"
438 >
439 Increase Receiving Capacity
440 </LinkButton>
441 <ExternalLinkButton
442 to="https://www.getalby.com/topup"
443 className="w-full"
444 variant="secondary"
445 >
446 Buy Bitcoin
447 </ExternalLinkButton>
448 </div>
449 </div>
450
451 {/* Confirmation Modal */}
452 <Dialog open={showConfirmModal} onOpenChange={setShowConfirmModal}>
453 <DialogContent className="sm:max-w-md">
454 <DialogHeader>
455 <DialogTitle>Confirm Channel Opening</DialogTitle>
456 <DialogDescription>
457 Are you sure you want to open a Lightning channel with the
458 following details?
459 </DialogDescription>
460 </DialogHeader>
461
462 <div className="space-y-4">
463 <div className="grid grid-cols-2 gap-4 text-sm">
464 <div>
465 <div className="font-medium text-muted-foreground">Peer</div>
466 <div>{selectedPeer?.name || "Custom"}</div>
467 </div>
468 <div>
469 <div className="font-medium text-muted-foreground">Amount</div>
470 <div>
471 <FormattedBitcoinAmount
472 amountMsat={parseInt(order.amountSat || "0") * 1000}
473 />
474 </div>
475 </div>
476 <div>
477 <div className="font-medium text-muted-foreground">
478 Channel Type
479 </div>
480 <div>{order.isPublic ? "Public" : "Private"}</div>
481 </div>
482 <div>
483 <div className="font-medium text-muted-foreground">
484 Payment Method
485 </div>
486 <div>On-chain</div>
487 </div>
488 </div>
489
490 {selectedPeer?.name === "Custom" && order.pubkey && (
491 <div className="text-sm">
492 <div className="font-medium text-muted-foreground">
493 Node Public Key
494 </div>
495 <div className="font-mono text-xs break-all bg-muted p-2 rounded">
496 {order.pubkey}
497 </div>
498 </div>
499 )}
500
501 <Alert variant="warning">
502 <InfoIcon />
503 <AlertDescription>
504 <strong>Important:</strong> Opening a channel requires an
505 on-chain transaction and network fees. This action cannot be
506 undone. Please verify all details before proceeding.
507 </AlertDescription>
508 </Alert>
509 </div>
510
511 <DialogFooter className="gap-2">
512 <Button
513 variant="outline"
514 onClick={() => setShowConfirmModal(false)}
515 >
516 Cancel
517 </Button>
518 <Button onClick={handleConfirmSubmit}>
519 Confirm & Open Channel
520 </Button>
521 </DialogFooter>
522 </DialogContent>
523 </Dialog>
524 </>
525 );
526 }
527
528 type NewChannelOnchainProps = {
529 order: Partial<OnchainOrder>;
530 setOrder: React.Dispatch<React.SetStateAction<Partial<OnchainOrder>>>;
531 showCustomOptions: boolean;
532 };
533
534 function NewChannelOnchain(props: NewChannelOnchainProps) {
535 const { data: peers } = usePeers();
536
537 if (props.order.paymentMethod !== "onchain") {
538 throw new Error("unexpected payment method");
539 }
540 const { pubkey, host } = props.order;
541 const { setOrder } = props;
542 const isAlreadyPeered =
543 pubkey && peers?.some((peer) => peer.nodeId === pubkey);
544
545 function setPubkey(pubkey: string) {
546 props.setOrder((current) => ({
547 ...current,
548 paymentMethod: "onchain",
549 pubkey,
550 }));
551 }
552 const setHost = React.useCallback(
553 (host: string) => {
554 setOrder((current) => ({
555 ...current,
556 paymentMethod: "onchain",
557 host,
558 }));
559 },
560 [setOrder]
561 );
562
563 const { data: nodeDetails } = useNodeDetails(pubkey);
564
565 React.useEffect(() => {
566 const socketAddress = nodeDetails?.sockets?.split(",")?.[0];
567 if (socketAddress) {
568 setHost(socketAddress);
569 }
570 }, [nodeDetails, setHost]);
571
572 return (
573 <>
574 <div className="flex flex-col gap-5">
575 {props.showCustomOptions && (
576 <>
577 <div className="grid gap-1.5">
578 <Label htmlFor="pubkey">Peer</Label>
579 <Input
580 id="pubkey"
581 type="text"
582 value={pubkey}
583 required
584 placeholder="Pubkey of the peer"
585 onChange={(e) => {
586 const parts = e.target.value.trim().split("@");
587 setPubkey(parts[0]);
588 if (parts.length > 1) {
589 setHost(parts[1]);
590 }
591 }}
592 />
593 {nodeDetails && (
594 <div className="ml-2 text-muted-foreground text-sm">
595 <span
596 className="mr-2"
597 style={{ color: `${nodeDetails.color}` }}
598 >
599 ⬤
600 </span>
601 {nodeDetails.alias && (
602 <>
603 {nodeDetails.alias} ({nodeDetails.active_channel_count}{" "}
604 channels)
605 </>
606 )}
607 </div>
608 )}
609 </div>
610
611 {!isAlreadyPeered && /*!nodeDetails && */ pubkey && (
612 <div className="grid gap-1.5">
613 <Label htmlFor="host">Host:Port</Label>
614 <Input
615 id="host"
616 type="text"
617 value={host}
618 required
619 placeholder="0.0.0.0:9735 or [2600::]:9735"
620 onChange={(e) => {
621 setHost(e.target.value.trim());
622 }}
623 />
624 </div>
625 )}
626 </>
627 )}
628 </div>
629 </>
630 );
631 }
632