CurrentChannelOrder.tsx raw
1 import React from "react";
2 import {
3 ConnectPeerRequest,
4 MempoolUtxo,
5 NewChannelOrder,
6 OpenChannelRequest,
7 OpenChannelResponse,
8 PayInvoiceResponse,
9 } from "src/types";
10
11 import { CopyIcon, QrCodeIcon, RefreshCwIcon } from "lucide-react";
12 import { Link } from "react-router";
13 import { toast } from "sonner";
14 import AppHeader from "src/components/AppHeader";
15 import ExternalLink from "src/components/ExternalLink";
16 import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount";
17 import FormattedFiatAmount from "src/components/FormattedFiatAmount";
18 import Loading from "src/components/Loading";
19 import QRCode from "src/components/QRCode";
20 import { Button } from "src/components/ui/button";
21 import {
22 Card,
23 CardContent,
24 CardDescription,
25 CardFooter,
26 CardHeader,
27 CardTitle,
28 } from "src/components/ui/card";
29 import { LoadingButton } from "src/components/ui/custom/loading-button";
30 import {
31 Dialog,
32 DialogContent,
33 DialogDescription,
34 DialogHeader,
35 DialogTitle,
36 DialogTrigger,
37 } from "src/components/ui/dialog";
38 import { Input } from "src/components/ui/input";
39 import { Label } from "src/components/ui/label";
40 import { Separator } from "src/components/ui/separator";
41 import { Table, TableBody, TableCell, TableRow } from "src/components/ui/table";
42 import {
43 Tooltip,
44 TooltipContent,
45 TooltipProvider,
46 TooltipTrigger,
47 } from "src/components/ui/tooltip";
48 import { useBalances } from "src/hooks/useBalances";
49
50 import { ChannelWaitingForConfirmations } from "src/components/channels/ChannelWaitingForConfirmations";
51 import { PayLightningInvoice } from "src/components/PayLightningInvoice";
52 import TwoColumnLayoutHeader from "src/components/TwoColumnLayoutHeader";
53 import { ExternalLinkButton } from "src/components/ui/custom/external-link-button";
54 import { LinkButton } from "src/components/ui/custom/link-button";
55 import { useChannels } from "src/hooks/useChannels";
56 import { useMempoolApi } from "src/hooks/useMempoolApi";
57 import { useNodeDetails } from "src/hooks/useNodeDetails";
58 import { useOnchainAddress } from "src/hooks/useOnchainAddress";
59 import { usePeers } from "src/hooks/usePeers";
60 import { useSyncWallet } from "src/hooks/useSyncWallet";
61 import { copyToClipboard } from "src/lib/clipboard";
62 import { splitSocketAddress } from "src/lib/utils";
63 import useChannelOrderStore from "src/state/ChannelOrderStore";
64 import { LSPOrderRequest, LSPOrderResponse } from "src/types";
65 import { request } from "src/utils/request";
66
67 // ensures React does not open a duplicate channel
68 // this is a hack and will break if the user tries to open
69 // 2 outbound channels without refreshing the page (I think an edge case)
70 let hasStartedOpenedChannel = false;
71
72 export function CurrentChannelOrder() {
73 const order = useChannelOrderStore((store) => store.order);
74 if (!order) {
75 return (
76 <p>
77 No pending channel order.{" "}
78 <Link to="/channels" className="underline">
79 Return to channels page
80 </Link>
81 </p>
82 );
83 }
84 return <ChannelOrderInternal order={order} />;
85 }
86
87 function ChannelOrderInternal({ order }: { order: NewChannelOrder }) {
88 useSyncWallet();
89 switch (order.status) {
90 case "pay":
91 switch (order.paymentMethod) {
92 case "onchain":
93 return <PayBitcoinChannelOrder order={order} />;
94 case "lightning":
95 return <PayLightningChannelOrder order={order} />;
96 default:
97 break;
98 }
99 break;
100 case "paid":
101 // LSPS1 only
102 return <PaidLightningChannelOrder />;
103 case "opening":
104 return <ChannelOpening fundingTxId={order.fundingTxId} />;
105 case "success":
106 return <Success />;
107 default:
108 break;
109 }
110
111 return (
112 <p>
113 TODO: {order.status} {order.paymentMethod}
114 </p>
115 );
116 }
117
118 function Success() {
119 return (
120 <div className="flex flex-col justify-center gap-5 p-5 max-w-md items-stretch">
121 <TwoColumnLayoutHeader
122 title="Channel Opened"
123 pageTitle="Channel Opened"
124 description="Your new lightning channel is ready to use"
125 />
126
127 <p>
128 Congratulations! Your channel is active and can be used to send and
129 receive payments.
130 </p>
131 <p>
132 To ensure you can both send and receive, make sure to balance your{" "}
133 <ExternalLink
134 to="https://guides.getalby.com/user-guide/alby-hub/node"
135 className="underline"
136 >
137 channel's liquidity
138 </ExternalLink>
139 .
140 </p>
141
142 <LinkButton to="/home" className="flex justify-center mt-8">
143 Go to your dashboard
144 </LinkButton>
145 </div>
146 );
147 }
148
149 function ChannelOpening({ fundingTxId }: { fundingTxId: string | undefined }) {
150 const { data: channels } = useChannels(true);
151 const channel = fundingTxId
152 ? channels?.find((channel) => channel.fundingTxId === fundingTxId)
153 : undefined;
154
155 React.useEffect(() => {
156 if (channel?.active) {
157 useChannelOrderStore.getState().updateOrder({
158 status: "success",
159 });
160 }
161 }, [channel]);
162
163 if (!channel) {
164 return <Loading />;
165 }
166
167 return <ChannelWaitingForConfirmations channel={channel} />;
168 }
169
170 function useEstimatedTransactionFeeSat() {
171 const { data: recommendedFees } = useMempoolApi<{ fastestFee: number }>(
172 "/v1/fees/recommended",
173 true
174 );
175 if (recommendedFees?.fastestFee) {
176 // estimated transaction size: 200 vbytes
177 return 200 * recommendedFees.fastestFee;
178 }
179 }
180
181 // TODO: move these to new files
182 function PayBitcoinChannelOrder({ order }: { order: NewChannelOrder }) {
183 if (order.paymentMethod !== "onchain") {
184 throw new Error("incorrect payment method");
185 }
186 const { data: balances } = useBalances(true);
187
188 if (!balances) {
189 return <Loading />;
190 }
191
192 // expect at least the user to have more funds than the channel size, hopefully enough to cover mempool fees.
193 if (balances.onchain.spendableSat > +order.amountSat) {
194 return <PayBitcoinChannelOrderWithSpendableFunds order={order} />;
195 }
196 if (balances.onchain.totalSat > +order.amountSat) {
197 return <PayBitcoinChannelOrderWaitingDepositConfirmation />;
198 }
199 return <PayBitcoinChannelOrderTopup order={order} />;
200 }
201
202 function PayBitcoinChannelOrderWaitingDepositConfirmation() {
203 return (
204 <>
205 <Card>
206 <CardHeader>
207 <CardTitle className="flex flex-row items-center gap-2">
208 Bitcoin deposited
209 </CardTitle>
210 </CardHeader>
211 <CardContent className="flex items-center gap-2">
212 <Loading /> Waiting for one block confirmation
213 </CardContent>
214 <CardFooter className="text-muted-foreground">
215 estimated time: 10 minutes
216 </CardFooter>
217 </Card>
218 </>
219 );
220 }
221
222 function PayBitcoinChannelOrderTopup({ order }: { order: NewChannelOrder }) {
223 if (order.paymentMethod !== "onchain") {
224 throw new Error("incorrect payment method");
225 }
226
227 const { data: channels } = useChannels();
228
229 const { data: balances } = useBalances();
230 const {
231 data: onchainAddress,
232 getNewAddress,
233 loadingAddress,
234 } = useOnchainAddress();
235
236 const { data: mempoolAddressUtxos } = useMempoolApi<MempoolUtxo[]>(
237 onchainAddress ? `/address/${onchainAddress}/utxo` : undefined,
238 3000
239 );
240 const estimatedTransactionFeeSat = useEstimatedTransactionFeeSat();
241
242 if (!onchainAddress || !balances || !estimatedTransactionFeeSat) {
243 return (
244 <div className="flex justify-center">
245 <Loading />
246 </div>
247 );
248 }
249
250 // expect at least the user to have more funds than the channel size, hopefully enough to cover mempool fees.
251 // This only considers one UTXO and will not work well if the user generates a new address.
252 // However, this is just a fallback because LDK only updates onchain balances ~ once per minute.
253 const unspentAmountSat =
254 mempoolAddressUtxos?.map((utxo) => utxo.value).reduce((a, b) => a + b, 0) ||
255 0;
256
257 if (unspentAmountSat > +order.amountSat) {
258 return <PayBitcoinChannelOrderWaitingDepositConfirmation />;
259 }
260
261 const num0ConfChannels =
262 channels?.filter((c) => c.confirmationsRequired === 0).length || 0;
263
264 const estimatedAnchorReserveSat = Math.max(
265 num0ConfChannels * 25000 - balances.onchain.reservedSat,
266 0
267 );
268
269 const missingAmountSat =
270 +order.amountSat +
271 estimatedTransactionFeeSat +
272 estimatedAnchorReserveSat -
273 balances.onchain.totalSat;
274
275 const recommendedAmountSat = Math.ceil(missingAmountSat / 10000) * 10000;
276 const topupLink = `https://getalby.com/topup?address=${onchainAddress}&receive_amount=${recommendedAmountSat}`;
277
278 return (
279 <div className="grid gap-5">
280 <AppHeader
281 pageTitle="Deposit bitcoin"
282 title="Deposit bitcoin"
283 description="You don't have enough Bitcoin to open your intended channel"
284 />
285 <div className="grid gap-5 max-w-lg">
286 <div className="grid gap-1.5">
287 <Label htmlFor="text">On-Chain Address</Label>
288 <p className="text-xs slashed-zero">
289 You currently have{" "}
290 <span className="font-semibold sensitive">
291 <FormattedBitcoinAmount
292 amountMsat={balances.onchain.totalSat * 1000}
293 />
294 </span>
295 . We recommend depositing an additional amount of{" "}
296 <span className="font-semibold">
297 <FormattedBitcoinAmount
298 amountMsat={recommendedAmountSat * 1000}
299 />
300 </span>{" "}
301 to open this channel.
302 </p>
303 <p className="text-xs text-muted-foreground">
304 This amount includes cost for the channel opening and potential
305 channel onchain reserves.
306 </p>
307 <div className="flex flex-row gap-2 items-center">
308 <Input
309 type="text"
310 value={onchainAddress}
311 readOnly
312 className="flex-1"
313 />
314 <Button
315 variant="secondary"
316 size="icon"
317 onClick={() => {
318 copyToClipboard(onchainAddress);
319 }}
320 >
321 <CopyIcon className="size-4" />
322 </Button>
323 <Dialog>
324 <DialogTrigger asChild>
325 <Button variant="secondary" size="icon">
326 <QrCodeIcon className="size-4" />
327 </Button>
328 </DialogTrigger>
329 <DialogContent>
330 <DialogHeader>
331 <DialogTitle>Deposit bitcoin</DialogTitle>
332 <DialogDescription>
333 Scan this QR code with your wallet to send funds.
334 </DialogDescription>
335 </DialogHeader>
336 <div className="flex flex-row justify-center p-3">
337 <a href={`bitcoin:${onchainAddress}`} target="_blank">
338 <QRCode value={onchainAddress} paymentType="onchain" />
339 </a>
340 </div>
341 </DialogContent>
342 </Dialog>
343 <TooltipProvider>
344 <Tooltip>
345 <TooltipTrigger asChild>
346 <LoadingButton
347 variant="secondary"
348 size="icon"
349 onClick={getNewAddress}
350 loading={loadingAddress}
351 className="w-9 h-9"
352 >
353 {!loadingAddress && <RefreshCwIcon className="size-4" />}
354 </LoadingButton>
355 </TooltipTrigger>
356 <TooltipContent>Generate a new address</TooltipContent>
357 </Tooltip>
358 </TooltipProvider>
359 </div>
360 </div>
361
362 <Card>
363 <CardHeader>
364 <CardTitle className="flex flex-row items-center gap-2">
365 <Loading /> Waiting for your transaction
366 </CardTitle>
367 <CardDescription>
368 Send a bitcoin transaction to the address provided above. You'll
369 be redirected as soon as the transaction is seen in the mempool.
370 </CardDescription>
371 </CardHeader>
372 {unspentAmountSat > 0 && (
373 <CardContent className="slashed-zero">
374 <FormattedBitcoinAmount amountMsat={unspentAmountSat * 1000} />{" "}
375 deposited
376 </CardContent>
377 )}
378 </Card>
379
380 <ExternalLinkButton to={topupLink} className="w-full">
381 Top up with your credit card or bank account
382 </ExternalLinkButton>
383 <LinkButton
384 to="/channels/incoming"
385 variant="secondary"
386 className="w-full"
387 >
388 Need receiving capacity?
389 </LinkButton>
390 </div>
391 </div>
392 );
393 }
394
395 function PayBitcoinChannelOrderWithSpendableFunds({
396 order,
397 }: {
398 order: NewChannelOrder;
399 }) {
400 if (order.paymentMethod !== "onchain") {
401 throw new Error("incorrect payment method");
402 }
403 const { data: peers } = usePeers();
404
405 const { pubkey, host } = order;
406
407 const { data: nodeDetails } = useNodeDetails(pubkey);
408
409 const connectPeer = React.useCallback(async () => {
410 if (!nodeDetails && !host) {
411 throw new Error("node details not found");
412 }
413 const socketAddress = nodeDetails?.sockets
414 ? nodeDetails.sockets.split(",")[0]
415 : host;
416
417 const { address, port } = splitSocketAddress(socketAddress);
418
419 if (!address || !port) {
420 throw new Error("host not found");
421 }
422 console.info(`🔌 Peering with ${pubkey}`);
423 const connectPeerRequest: ConnectPeerRequest = {
424 pubkey,
425 address,
426 port: +port,
427 };
428 await request("/api/peers", {
429 method: "POST",
430 headers: {
431 "Content-Type": "application/json",
432 },
433 body: JSON.stringify(connectPeerRequest),
434 });
435 }, [nodeDetails, pubkey, host]);
436
437 const openChannel = React.useCallback(async () => {
438 try {
439 if (order.paymentMethod !== "onchain") {
440 throw new Error("incorrect payment method");
441 }
442
443 if (!peers) {
444 throw new Error("peers not loaded");
445 }
446
447 // only pair if necessary
448 // also allows to open channel to existing peer without providing a socket address.
449 if (!peers.some((peer) => peer.nodeId === pubkey)) {
450 await connectPeer();
451 }
452
453 console.info(`🎬 Opening channel with ${pubkey}`);
454
455 const openChannelRequest: OpenChannelRequest = {
456 pubkey,
457 amountSats: +order.amountSat,
458 public: order.isPublic,
459 };
460 const openChannelResponse = await request<OpenChannelResponse>(
461 "/api/channels",
462 {
463 method: "POST",
464 headers: {
465 "Content-Type": "application/json",
466 },
467 body: JSON.stringify(openChannelRequest),
468 }
469 );
470
471 if (!openChannelResponse?.fundingTxId) {
472 throw new Error("No funding txid in response");
473 }
474 console.info(
475 "Channel opening transaction published",
476 openChannelResponse.fundingTxId
477 );
478 toast("Successfully published channel opening transaction");
479 useChannelOrderStore.getState().updateOrder({
480 fundingTxId: openChannelResponse.fundingTxId,
481 status: "opening",
482 });
483 } catch (error) {
484 console.error(error);
485 toast.error("Something went wrong", {
486 description: "" + error,
487 });
488 }
489 }, [
490 connectPeer,
491 order.amountSat,
492 order.isPublic,
493 order.paymentMethod,
494 peers,
495 pubkey,
496 ]);
497
498 React.useEffect(() => {
499 if (!peers || hasStartedOpenedChannel) {
500 return;
501 }
502
503 hasStartedOpenedChannel = true;
504 openChannel();
505 }, [openChannel, order.amountSat, peers, pubkey]);
506
507 return (
508 <div className="flex flex-col gap-5">
509 <AppHeader
510 pageTitle="Opening channel"
511 title="Opening channel"
512 description="Your funds have been successfully deposited"
513 />
514
515 <div className="flex flex-col gap-5">
516 <Loading />
517 <p>Please wait...</p>
518 </div>
519 </div>
520 );
521 }
522
523 function useWaitForNewChannel() {
524 const order = useChannelOrderStore((store) => store.order);
525 const { data: channels } = useChannels(true);
526
527 const newChannel =
528 channels && order?.prevChannelIds
529 ? channels.find(
530 (newChannel) =>
531 !order.prevChannelIds.some(
532 (current) => newChannel.id === current
533 ) && newChannel.fundingTxId
534 )
535 : undefined;
536
537 React.useEffect(() => {
538 if (newChannel) {
539 useChannelOrderStore.getState().updateOrder({
540 status: "opening",
541 fundingTxId: newChannel.fundingTxId,
542 });
543 }
544 }, [newChannel]);
545 }
546
547 function PaidLightningChannelOrder() {
548 useWaitForNewChannel();
549
550 return (
551 <div className="flex w-full h-full gap-2 items-center justify-center">
552 <Loading /> <p>Waiting for channel to be opened...</p>
553 </div>
554 );
555 }
556
557 function PayLightningChannelOrder({ order }: { order: NewChannelOrder }) {
558 if (order.paymentMethod !== "lightning") {
559 throw new Error("incorrect payment method");
560 }
561 const { data: balances } = useBalances();
562 const { data: channels } = useChannels(true);
563 const [, setRequestedInvoice] = React.useState(false);
564
565 const [lspOrderResponse, setLspOrderResponse] = React.useState<
566 LSPOrderResponse | undefined
567 >();
568
569 useWaitForNewChannel();
570
571 React.useEffect(() => {
572 if (!channels) {
573 return;
574 }
575 setRequestedInvoice((current) => {
576 if (!current) {
577 (async () => {
578 try {
579 if (!order.lspType || !order.lspIdentifier) {
580 throw new Error("missing lsp info in order");
581 }
582 const newLSPOrderRequest: LSPOrderRequest = {
583 lspType: order.lspType,
584 lspIdentifier: order.lspIdentifier,
585 amountSat: parseInt(order.amountSat),
586 public: order.isPublic,
587 };
588 const response = await request<LSPOrderResponse>(
589 "/api/lsp-orders",
590 {
591 method: "POST",
592 headers: {
593 "Content-Type": "application/json",
594 },
595 body: JSON.stringify(newLSPOrderRequest),
596 }
597 );
598 if (!response) {
599 throw new Error("no LSP order response");
600 }
601
602 if (!response.invoice) {
603 // assume payment is handled by Alby Account
604 // we will wait for a channel to be opened to us
605 useChannelOrderStore.getState().updateOrder({
606 status: "paid",
607 });
608 }
609 setLspOrderResponse(response);
610 } catch (error) {
611 toast.error("Something went wrong", {
612 description: "" + error,
613 });
614 }
615 })();
616 }
617 return true;
618 });
619 }, [
620 channels,
621 order.amountSat,
622 order.isPublic,
623 order.lspType,
624 order.lspIdentifier,
625 ]);
626
627 const canPayInternally =
628 balances &&
629 lspOrderResponse &&
630 balances.lightning.nextMaxSpendableMPPMsat / 1000 >
631 lspOrderResponse.invoiceAmountSat;
632 const [isPaying, setPaying] = React.useState(false);
633 const [payExternally, setPayExternally] = React.useState(false);
634
635 return (
636 <div className="flex flex-col gap-5">
637 <AppHeader
638 pageTitle="Review Channel Purchase"
639 title="Review Channel Purchase"
640 description={
641 lspOrderResponse
642 ? "Complete Payment to open a channel to your node"
643 : "Please wait, loading..."
644 }
645 />
646 {!lspOrderResponse?.invoice && <Loading />}
647
648 {lspOrderResponse?.invoice && (
649 <>
650 <div className="max-w-md flex flex-col gap-5">
651 <div className="border rounded-lg slashed-zero">
652 <Table>
653 <TableBody>
654 {lspOrderResponse.outgoingLiquiditySat > 0 && (
655 <TableRow>
656 <TableCell className="font-medium p-3">
657 Lightning Balance
658 </TableCell>
659 <TableCell className="text-right p-3">
660 <FormattedBitcoinAmount
661 amountMsat={
662 lspOrderResponse.outgoingLiquiditySat * 1000
663 }
664 />
665 </TableCell>
666 </TableRow>
667 )}
668 {lspOrderResponse.incomingLiquiditySat > 0 && (
669 <TableRow>
670 <TableCell className="font-medium p-3">
671 Incoming Liquidity
672 </TableCell>
673 <TableCell className="text-right p-3">
674 <div className="flex flex-col items-end">
675 <FormattedBitcoinAmount
676 amountMsat={
677 lspOrderResponse.incomingLiquiditySat * 1000
678 }
679 />
680 <FormattedFiatAmount
681 amountSat={lspOrderResponse.incomingLiquiditySat}
682 showApprox
683 />
684 </div>
685 </TableCell>
686 </TableRow>
687 )}
688 <TableRow>
689 <TableCell className="font-medium p-3">
690 Amount to pay
691 </TableCell>
692 <TableCell className="font-semibold text-right p-3">
693 <div className="flex flex-col items-end">
694 <FormattedBitcoinAmount
695 amountMsat={lspOrderResponse.invoiceAmountSat * 1000}
696 />
697 <FormattedFiatAmount
698 amountSat={lspOrderResponse.invoiceAmountSat}
699 showApprox
700 />
701 </div>
702 </TableCell>
703 </TableRow>
704 </TableBody>
705 </Table>
706 </div>
707 <div className="flex justify-center w-full -mb-5">
708 <p className="text-center text-xs text-muted-foreground max-w-sm">
709 By proceeding, you consent the channel opens immediately and
710 that you lose the right to revoke once it is open.
711 </p>
712 </div>
713 <>
714 {canPayInternally && (
715 <div className="flex flex-col gap-2">
716 <LoadingButton
717 loading={isPaying}
718 className="mt-4"
719 onClick={async () => {
720 try {
721 setPaying(true);
722
723 // NOTE: for amboss this will not return until the HOLD invoice is settled
724 // which is after the channel has N block confirmations
725 await request<PayInvoiceResponse>(
726 `/api/payments/${lspOrderResponse.invoice}`,
727 {
728 method: "POST",
729 headers: {
730 "Content-Type": "application/json",
731 },
732 }
733 );
734
735 useChannelOrderStore.getState().updateOrder({
736 status: "paid",
737 });
738 toast("Channel successfully requested");
739 } catch (e) {
740 toast.error("Failed to send: ", {
741 description: "" + e,
742 });
743 console.error(e);
744 }
745 setPaying(false);
746 }}
747 >
748 Pay and open channel
749 </LoadingButton>
750 {!payExternally && (
751 <Button
752 type="button"
753 variant="link"
754 className="text-muted-foreground"
755 onClick={() => setPayExternally(true)}
756 >
757 Pay with another wallet
758 </Button>
759 )}
760 </div>
761 )}
762
763 {(payExternally || !canPayInternally) && (
764 <div className="flex flex-row justify-center">
765 <PayLightningInvoice invoice={lspOrderResponse.invoice} />
766 </div>
767 )}
768
769 <div className="flex-1 flex flex-col justify-end items-center gap-4">
770 <Separator className="mt-4 mb-6" />
771 <p className="text-sm text-muted-foreground text-center">
772 Other options
773 </p>
774 <LinkButton
775 to="/channels/outgoing"
776 variant="secondary"
777 className="w-full"
778 >
779 Increase Lightning Balance
780 </LinkButton>
781 <ExternalLinkButton
782 to="https://www.getalby.com/topup"
783 variant="secondary"
784 className="w-full"
785 >
786 Buy Bitcoin
787 </ExternalLinkButton>
788 </div>
789 </>
790 </div>
791 </>
792 )}
793 </div>
794 );
795 }
796