IncreaseIncomingCapacity.tsx raw
1 import { ChevronDownIcon, 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 { Button } from "src/components/ui/button";
15 import { Checkbox } from "src/components/ui/checkbox";
16 import { ExternalLinkButton } from "src/components/ui/custom/external-link-button";
17 import { LinkButton } from "src/components/ui/custom/link-button";
18 import { Input } from "src/components/ui/input";
19 import { Label } from "src/components/ui/label";
20 import {
21 Select,
22 SelectContent,
23 SelectItem,
24 SelectTrigger,
25 SelectValue,
26 } from "src/components/ui/select";
27 import {
28 Tooltip,
29 TooltipContent,
30 TooltipProvider,
31 TooltipTrigger,
32 } from "src/components/ui/tooltip";
33 import { useChannelPeerSuggestions } from "src/hooks/useChannelPeerSuggestions";
34 import { useChannels } from "src/hooks/useChannels";
35 import { useInfo } from "src/hooks/useInfo";
36 import { cn } from "src/lib/utils";
37 import useChannelOrderStore from "src/state/ChannelOrderStore";
38 import {
39 Channel,
40 LightningOrder,
41 Network,
42 NewChannelOrder,
43 RecommendedChannelPeer,
44 } from "src/types";
45
46 import LightningNetworkDarkSVG from "public/images/illustrations/lightning-network-dark.svg";
47 import LightningNetworkLightSVG from "public/images/illustrations/lightning-network-light.svg";
48 import { LSPTermsDialog } from "src/components/channels/LSPTermsDialog";
49 import FormattedFiatAmount from "src/components/FormattedFiatAmount";
50
51 function getPeerKey(peer: RecommendedChannelPeer) {
52 return JSON.stringify(peer);
53 }
54
55 export default function IncreaseIncomingCapacity() {
56 const { data: info } = useInfo();
57 const { data: channels } = useChannels();
58
59 if (!info?.network || !channels) {
60 return <Loading />;
61 }
62
63 return <NewChannelInternal network={info.network} channels={channels} />;
64 }
65
66 function NewChannelInternal({
67 network,
68 channels,
69 }: {
70 network: Network;
71 channels: Channel[];
72 }) {
73 const { data: _channelPeerSuggestions, error: channelPeerSuggestionsError } =
74 useChannelPeerSuggestions();
75 const navigate = useNavigate();
76
77 const presetAmounts = [1_000_000, 2_000_000, 3_000_000];
78
79 const [order, setOrder] = React.useState<Partial<LightningOrder>>({
80 paymentMethod: "lightning",
81 status: "pay",
82 amountSat: presetAmounts[0].toString(),
83 prevChannelIds: channels.map((channel) => channel.id),
84 isPublic: !!channels.length && channels.every((channel) => channel.public),
85 });
86
87 const [showAdvanced, setShowAdvanced] = React.useState(false);
88
89 const [selectedPeer, setSelectedPeer] = React.useState<
90 RecommendedChannelPeer | undefined
91 >();
92
93 React.useEffect(() => {
94 if (channelPeerSuggestionsError) {
95 toast.error("Failed to load channel suggestions");
96 navigate("/channels/outgoing");
97 }
98 }, [channelPeerSuggestionsError, navigate]);
99
100 const channelPeerSuggestions = React.useMemo(() => {
101 return _channelPeerSuggestions
102 ? [
103 ..._channelPeerSuggestions.filter(
104 (peer) =>
105 peer.paymentMethod === "lightning" && peer.type === "LSPS1"
106 ),
107 ]
108 : undefined;
109 }, [_channelPeerSuggestions]);
110
111 function setPublic(isPublic: boolean) {
112 setOrder((current) => ({
113 ...current,
114 isPublic,
115 }));
116 }
117
118 const setAmountSat = React.useCallback((amountSat: string) => {
119 setOrder((current) => ({
120 ...current,
121 amountSat,
122 }));
123 }, []);
124
125 React.useEffect(() => {
126 if (!channelPeerSuggestions) {
127 return;
128 }
129 const recommendedPeer = channelPeerSuggestions.find(
130 (peer) =>
131 peer.network === network && peer.paymentMethod === order.paymentMethod
132 );
133
134 setSelectedPeer(recommendedPeer);
135 }, [network, order.paymentMethod, channelPeerSuggestions]);
136
137 React.useEffect(() => {
138 if (selectedPeer) {
139 if (
140 selectedPeer.paymentMethod === "lightning" &&
141 order.paymentMethod === "lightning"
142 ) {
143 setOrder((current) => ({
144 ...current,
145 lspType: selectedPeer.type,
146 lspIdentifier: selectedPeer.identifier,
147 ...(!selectedPeer.publicChannelsAllowed && { isPublic: false }),
148 }));
149 }
150 }
151 }, [order.paymentMethod, selectedPeer]);
152
153 // find the best channel partner
154 const okPartners = channelPeerSuggestions?.filter(
155 (partner) =>
156 parseInt(order.amountSat || "0") >= partner.minimumChannelSizeSat &&
157 parseInt(order.amountSat || "0") <= partner.maximumChannelSizeSat &&
158 partner.network === network &&
159 partner.paymentMethod === "lightning" &&
160 partner.type === "LSPS1" &&
161 partner.pubkey &&
162 (partner.publicChannelsAllowed || !order.isPublic) &&
163 !channels.some((channel) => channel.remotePubkey === partner.pubkey)
164 );
165
166 const bestPartner = okPartners?.[0];
167
168 function onSubmit(e: FormEvent) {
169 e.preventDefault();
170 try {
171 const nextOrder: Partial<LightningOrder> = { ...order };
172
173 if (!showAdvanced) {
174 if (!channelPeerSuggestions) {
175 throw new Error("Channel Peer suggestions not loaded");
176 }
177 if (!channels) {
178 throw new Error("Channels not loaded");
179 }
180 const amountSat = parseInt(order.amountSat || "0");
181 if (!amountSat) {
182 throw new Error("No amount set");
183 }
184
185 if (!bestPartner) {
186 toast.error("No channel partner found", {
187 description:
188 "No ideal channel partner found. Please choose from the advanced options to continue",
189 });
190 return;
191 }
192 nextOrder.paymentMethod = "lightning";
193 if (bestPartner.paymentMethod !== "lightning") {
194 throw new Error("Unexpected partner payment method");
195 }
196 nextOrder.lspType = bestPartner.type;
197 nextOrder.lspIdentifier = bestPartner.identifier;
198 }
199
200 useChannelOrderStore.getState().setOrder(nextOrder as NewChannelOrder);
201 navigate("/channels/order");
202 } catch (error) {
203 toast.error("Something went wrong", {
204 description: "" + error,
205 });
206 console.error(error);
207 }
208 }
209
210 if (!channelPeerSuggestions) {
211 return <Loading />;
212 }
213
214 const selectedPartner = showAdvanced ? selectedPeer : bestPartner;
215
216 const estimatedChannelPrice =
217 selectedPartner?.paymentMethod === "lightning"
218 ? order.amountSat === "1000000"
219 ? selectedPartner["feeTotalSat1m"]
220 : order.amountSat === "2000000"
221 ? selectedPartner["feeTotalSat2m"]
222 : order.amountSat === "3000000"
223 ? selectedPartner["feeTotalSat3m"]
224 : undefined
225 : undefined;
226
227 return (
228 <>
229 <AppHeader
230 pageTitle="Open Channel with Lightning"
231 title="Open Channel with Lightning"
232 description="Purchase a channel that allows you to receive payments"
233 contentRight={
234 <div className="flex items-end">
235 <Link
236 to="/channels/outgoing"
237 className="underline break-anywhere text-sm"
238 >
239 Open Channel with On-Chain
240 </Link>
241 </div>
242 }
243 />
244 <div className="md:max-w-md max-w-full flex flex-col gap-5 flex-1">
245 <img
246 src={LightningNetworkDarkSVG}
247 className="w-full hidden dark:block"
248 />
249 <img src={LightningNetworkLightSVG} className="w-full dark:hidden" />
250 <p className="text-muted-foreground">
251 Alby Hub works with selected service providers (LSPs) which provide
252 the best network connectivity and liquidity to receive payments.{" "}
253 <ExternalLink
254 className="underline"
255 to="https://guides.getalby.com/user-guide/alby-hub/faq/how-to-open-a-payment-channel"
256 >
257 Learn more
258 </ExternalLink>
259 </p>
260 <form onSubmit={onSubmit} className="flex flex-col gap-5 flex-1">
261 <div className="grid gap-1.5">
262 <TooltipProvider>
263 <Tooltip>
264 <TooltipTrigger type="button">
265 <div className="flex flex-row gap-2 items-center justify-start text-sm">
266 <Label htmlFor="amount">
267 Increase receiving capacity (sats)
268 </Label>
269 <InfoIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
270 </div>
271 </TooltipTrigger>
272 <TooltipContent>
273 Configure the amount of receiving capacity you need. You will
274 only pay for the liquidity fee which will be shown in the next
275 step.
276 </TooltipContent>
277 </Tooltip>
278 </TooltipProvider>
279
280 {order.amountSat && +order.amountSat < 200_000 && (
281 <p className="text-muted-foreground text-xs">
282 For a smooth experience consider a opening a channel of{" "}
283 <FormattedBitcoinAmount amountMsat={200_000 * 1000} /> in size
284 or more.{" "}
285 <ExternalLink
286 to="https://guides.getalby.com/user-guide/alby-hub/node"
287 className="underline"
288 >
289 Learn more
290 </ExternalLink>
291 </p>
292 )}
293 <Input
294 id="amount"
295 type="number"
296 required
297 min={
298 showAdvanced
299 ? selectedPeer?.minimumChannelSizeSat || 100000
300 : undefined
301 }
302 value={order.amountSat}
303 onChange={(e) => {
304 setAmountSat(e.target.value.trim());
305 }}
306 />
307 <div className="grid grid-cols-3 gap-1.5 text-xs">
308 {presetAmounts.map((presetAmountSat) => (
309 <div
310 key={presetAmountSat}
311 className={cn(
312 "text-center border rounded p-2 cursor-pointer hover:border-muted-foreground",
313 +(order.amountSat || "0") === presetAmountSat &&
314 "border-primary hover:border-primary"
315 )}
316 onClick={() => setAmountSat(presetAmountSat.toString())}
317 >
318 <FormattedBitcoinAmount amountMsat={presetAmountSat * 1000} />
319 <FormattedFiatAmount
320 amountSat={presetAmountSat}
321 showApprox
322 className="text-xs"
323 />
324 </div>
325 ))}
326 </div>
327 {estimatedChannelPrice && (
328 <span className="text-muted-foreground text-xs">
329 {" "}
330 Estimated channel price:{" "}
331 <span className="font-semibold">
332 <FormattedBitcoinAmount
333 amountMsat={estimatedChannelPrice * 1000}
334 />
335 </span>
336 </span>
337 )}
338 {selectedPartner?.paymentMethod === "lightning" && (
339 <div className="flex justify-between items-center">
340 <p className="text-sm">
341 You will receive a channel from{" "}
342 <span className="font-medium">{selectedPartner.name}</span>
343 .{" "}
344 </p>
345 <LSPTermsDialog
346 contactUrl={selectedPartner.contactUrl}
347 description={selectedPartner.description}
348 name={selectedPartner.name}
349 terms={selectedPartner.terms}
350 maximumChannelExpiryBlocks={
351 selectedPartner.maximumChannelExpiryBlocks
352 }
353 trigger={<p className="text-xs underline">View Terms</p>}
354 />
355 </div>
356 )}
357 </div>
358 {showAdvanced && (
359 <>
360 <div className="flex flex-col gap-3">
361 {selectedPeer && (
362 <div className="grid gap-1.5">
363 <Label>Choose your channel peer:</Label>
364 <Select
365 value={getPeerKey(selectedPeer)}
366 onValueChange={(value) =>
367 setSelectedPeer(
368 channelPeerSuggestions.find(
369 (x) => getPeerKey(x) === value
370 )
371 )
372 }
373 >
374 <SelectTrigger>
375 <SelectValue placeholder="Select channel peer" />
376 </SelectTrigger>
377 <SelectContent>
378 {channelPeerSuggestions
379 .filter(
380 (peer) =>
381 peer.network === network &&
382 peer.paymentMethod === order.paymentMethod
383 )
384 .map((peer) => (
385 <SelectItem
386 value={getPeerKey(peer)}
387 key={getPeerKey(peer)}
388 >
389 <div className="flex items-center gap-3">
390 <div className="flex items-center gap-3">
391 {peer.name !== "Custom" && (
392 <img
393 src={peer.image}
394 className="size-8 object-contain"
395 />
396 )}
397 <div>
398 {peer.name}
399 <span className="ml-4 text-xs text-muted-foreground slashed-zero">
400 Min.{" "}
401 <FormattedBitcoinAmount
402 amountMsat={
403 peer.minimumChannelSizeSat * 1000
404 }
405 />
406 <span className="mr-5" />
407 Max.{" "}
408 <FormattedBitcoinAmount
409 amountMsat={
410 peer.maximumChannelSizeSat * 1000
411 }
412 />
413 </span>
414 </div>
415 </div>
416 </div>
417 </SelectItem>
418 ))}
419 </SelectContent>
420 </Select>
421 {selectedPeer.name === "Custom" && (
422 <>
423 <div className="grid gap-1.5"></div>
424 </>
425 )}
426 </div>
427 )}
428 </div>
429 {order.paymentMethod === "lightning" && (
430 <NewChannelLightning order={order} setOrder={setOrder} />
431 )}
432
433 <div className="mt-2 flex items-top space-x-2">
434 <Checkbox
435 id="public-channel"
436 checked={order.isPublic}
437 onCheckedChange={() => setPublic(!order.isPublic)}
438 className="mr-2"
439 disabled={selectedPeer && !selectedPeer.publicChannelsAllowed}
440 title={
441 selectedPeer && !selectedPeer.publicChannelsAllowed
442 ? "This channel partner does not support public channels."
443 : undefined
444 }
445 />
446 <div className="grid gap-1.5 leading-none">
447 <Label htmlFor="public-channel" className="cursor-pointer">
448 Public Channel
449 </Label>
450 <p className="text-xs text-muted-foreground">
451 Not recommended for most users.{" "}
452 <ExternalLink
453 className="underline"
454 to="https://guides.getalby.com/user-guide/alby-hub/faq/should-i-open-a-private-or-public-channel"
455 >
456 Learn more
457 </ExternalLink>
458 </p>
459 </div>
460 </div>
461 </>
462 )}
463
464 {!showAdvanced && (
465 <Button
466 type="button"
467 variant="link"
468 className="text-muted-foreground text-xs"
469 onClick={() => setShowAdvanced((current) => !current)}
470 >
471 <ChevronDownIcon />
472 Advanced Options
473 </Button>
474 )}
475 <MempoolAlert />
476 <SwapAlert swapType="out" />
477 {channels?.some((channel) => channel.public !== !!order.isPublic) && (
478 <ChannelPublicPrivateAlert />
479 )}
480 {selectedPeer?.note && <ChannelPeerNote peer={selectedPeer} />}
481 {showAdvanced && (
482 <DuplicateChannelAlert
483 pubkey={selectedPeer?.pubkey}
484 name={selectedPeer?.name}
485 />
486 )}
487 <Button size="lg">Review Order</Button>
488 </form>
489
490 <div className="flex-1 flex flex-col justify-end gap-2">
491 <p className="text-sm text-muted-foreground text-center">
492 Other options
493 </p>
494 <LinkButton
495 to="/channels/outgoing"
496 className="w-full"
497 variant="secondary"
498 >
499 Increase Lightning Balance
500 </LinkButton>
501 <ExternalLinkButton
502 to="https://www.getalby.com/topup"
503 className="w-full"
504 variant="secondary"
505 >
506 Buy Bitcoin
507 </ExternalLinkButton>
508 </div>
509 </div>
510 </>
511 );
512 }
513
514 type NewChannelLightningProps = {
515 order: Partial<LightningOrder>;
516 setOrder(order: Partial<LightningOrder>): void;
517 };
518
519 function NewChannelLightning(props: NewChannelLightningProps) {
520 if (props.order.paymentMethod !== "lightning") {
521 throw new Error("unexpected payment method");
522 }
523 return null;
524 }
525