import React from "react";
import {
ConnectPeerRequest,
MempoolUtxo,
NewChannelOrder,
OpenChannelRequest,
OpenChannelResponse,
PayInvoiceResponse,
} from "src/types";
import { CopyIcon, QrCodeIcon, RefreshCwIcon } from "lucide-react";
import { Link } from "react-router";
import { toast } from "sonner";
import AppHeader from "src/components/AppHeader";
import ExternalLink from "src/components/ExternalLink";
import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount";
import FormattedFiatAmount from "src/components/FormattedFiatAmount";
import Loading from "src/components/Loading";
import QRCode from "src/components/QRCode";
import { Button } from "src/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "src/components/ui/card";
import { LoadingButton } from "src/components/ui/custom/loading-button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "src/components/ui/dialog";
import { Input } from "src/components/ui/input";
import { Label } from "src/components/ui/label";
import { Separator } from "src/components/ui/separator";
import { Table, TableBody, TableCell, TableRow } from "src/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "src/components/ui/tooltip";
import { useBalances } from "src/hooks/useBalances";
import { ChannelWaitingForConfirmations } from "src/components/channels/ChannelWaitingForConfirmations";
import { PayLightningInvoice } from "src/components/PayLightningInvoice";
import TwoColumnLayoutHeader from "src/components/TwoColumnLayoutHeader";
import { ExternalLinkButton } from "src/components/ui/custom/external-link-button";
import { LinkButton } from "src/components/ui/custom/link-button";
import { useChannels } from "src/hooks/useChannels";
import { useMempoolApi } from "src/hooks/useMempoolApi";
import { useNodeDetails } from "src/hooks/useNodeDetails";
import { useOnchainAddress } from "src/hooks/useOnchainAddress";
import { usePeers } from "src/hooks/usePeers";
import { useSyncWallet } from "src/hooks/useSyncWallet";
import { copyToClipboard } from "src/lib/clipboard";
import { splitSocketAddress } from "src/lib/utils";
import useChannelOrderStore from "src/state/ChannelOrderStore";
import { LSPOrderRequest, LSPOrderResponse } from "src/types";
import { request } from "src/utils/request";
// ensures React does not open a duplicate channel
// this is a hack and will break if the user tries to open
// 2 outbound channels without refreshing the page (I think an edge case)
let hasStartedOpenedChannel = false;
export function CurrentChannelOrder() {
const order = useChannelOrderStore((store) => store.order);
if (!order) {
return (
No pending channel order.{" "}
Return to channels page
);
}
return ;
}
function ChannelOrderInternal({ order }: { order: NewChannelOrder }) {
useSyncWallet();
switch (order.status) {
case "pay":
switch (order.paymentMethod) {
case "onchain":
return ;
case "lightning":
return ;
default:
break;
}
break;
case "paid":
// LSPS1 only
return ;
case "opening":
return ;
case "success":
return ;
default:
break;
}
return (
TODO: {order.status} {order.paymentMethod}
);
}
function Success() {
return (
Congratulations! Your channel is active and can be used to send and
receive payments.
To ensure you can both send and receive, make sure to balance your{" "}
channel's liquidity
.
Go to your dashboard
);
}
function ChannelOpening({ fundingTxId }: { fundingTxId: string | undefined }) {
const { data: channels } = useChannels(true);
const channel = fundingTxId
? channels?.find((channel) => channel.fundingTxId === fundingTxId)
: undefined;
React.useEffect(() => {
if (channel?.active) {
useChannelOrderStore.getState().updateOrder({
status: "success",
});
}
}, [channel]);
if (!channel) {
return ;
}
return ;
}
function useEstimatedTransactionFeeSat() {
const { data: recommendedFees } = useMempoolApi<{ fastestFee: number }>(
"/v1/fees/recommended",
true
);
if (recommendedFees?.fastestFee) {
// estimated transaction size: 200 vbytes
return 200 * recommendedFees.fastestFee;
}
}
// TODO: move these to new files
function PayBitcoinChannelOrder({ order }: { order: NewChannelOrder }) {
if (order.paymentMethod !== "onchain") {
throw new Error("incorrect payment method");
}
const { data: balances } = useBalances(true);
if (!balances) {
return ;
}
// expect at least the user to have more funds than the channel size, hopefully enough to cover mempool fees.
if (balances.onchain.spendableSat > +order.amountSat) {
return ;
}
if (balances.onchain.totalSat > +order.amountSat) {
return ;
}
return ;
}
function PayBitcoinChannelOrderWaitingDepositConfirmation() {
return (
<>
Bitcoin deposited
Waiting for one block confirmation
estimated time: 10 minutes
>
);
}
function PayBitcoinChannelOrderTopup({ order }: { order: NewChannelOrder }) {
if (order.paymentMethod !== "onchain") {
throw new Error("incorrect payment method");
}
const { data: channels } = useChannels();
const { data: balances } = useBalances();
const {
data: onchainAddress,
getNewAddress,
loadingAddress,
} = useOnchainAddress();
const { data: mempoolAddressUtxos } = useMempoolApi(
onchainAddress ? `/address/${onchainAddress}/utxo` : undefined,
3000
);
const estimatedTransactionFeeSat = useEstimatedTransactionFeeSat();
if (!onchainAddress || !balances || !estimatedTransactionFeeSat) {
return (
);
}
// expect at least the user to have more funds than the channel size, hopefully enough to cover mempool fees.
// This only considers one UTXO and will not work well if the user generates a new address.
// However, this is just a fallback because LDK only updates onchain balances ~ once per minute.
const unspentAmountSat =
mempoolAddressUtxos?.map((utxo) => utxo.value).reduce((a, b) => a + b, 0) ||
0;
if (unspentAmountSat > +order.amountSat) {
return ;
}
const num0ConfChannels =
channels?.filter((c) => c.confirmationsRequired === 0).length || 0;
const estimatedAnchorReserveSat = Math.max(
num0ConfChannels * 25000 - balances.onchain.reservedSat,
0
);
const missingAmountSat =
+order.amountSat +
estimatedTransactionFeeSat +
estimatedAnchorReserveSat -
balances.onchain.totalSat;
const recommendedAmountSat = Math.ceil(missingAmountSat / 10000) * 10000;
const topupLink = `https://getalby.com/topup?address=${onchainAddress}&receive_amount=${recommendedAmountSat}`;
return (
You currently have{" "}
. We recommend depositing an additional amount of{" "}
{" "}
to open this channel.
This amount includes cost for the channel opening and potential
channel onchain reserves.
{!loadingAddress && }
Generate a new address
Waiting for your transaction
Send a bitcoin transaction to the address provided above. You'll
be redirected as soon as the transaction is seen in the mempool.
{unspentAmountSat > 0 && (
{" "}
deposited
)}
Top up with your credit card or bank account
Need receiving capacity?
);
}
function PayBitcoinChannelOrderWithSpendableFunds({
order,
}: {
order: NewChannelOrder;
}) {
if (order.paymentMethod !== "onchain") {
throw new Error("incorrect payment method");
}
const { data: peers } = usePeers();
const { pubkey, host } = order;
const { data: nodeDetails } = useNodeDetails(pubkey);
const connectPeer = React.useCallback(async () => {
if (!nodeDetails && !host) {
throw new Error("node details not found");
}
const socketAddress = nodeDetails?.sockets
? nodeDetails.sockets.split(",")[0]
: host;
const { address, port } = splitSocketAddress(socketAddress);
if (!address || !port) {
throw new Error("host not found");
}
console.info(`🔌 Peering with ${pubkey}`);
const connectPeerRequest: ConnectPeerRequest = {
pubkey,
address,
port: +port,
};
await request("/api/peers", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(connectPeerRequest),
});
}, [nodeDetails, pubkey, host]);
const openChannel = React.useCallback(async () => {
try {
if (order.paymentMethod !== "onchain") {
throw new Error("incorrect payment method");
}
if (!peers) {
throw new Error("peers not loaded");
}
// only pair if necessary
// also allows to open channel to existing peer without providing a socket address.
if (!peers.some((peer) => peer.nodeId === pubkey)) {
await connectPeer();
}
console.info(`🎬 Opening channel with ${pubkey}`);
const openChannelRequest: OpenChannelRequest = {
pubkey,
amountSats: +order.amountSat,
public: order.isPublic,
};
const openChannelResponse = await request(
"/api/channels",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(openChannelRequest),
}
);
if (!openChannelResponse?.fundingTxId) {
throw new Error("No funding txid in response");
}
console.info(
"Channel opening transaction published",
openChannelResponse.fundingTxId
);
toast("Successfully published channel opening transaction");
useChannelOrderStore.getState().updateOrder({
fundingTxId: openChannelResponse.fundingTxId,
status: "opening",
});
} catch (error) {
console.error(error);
toast.error("Something went wrong", {
description: "" + error,
});
}
}, [
connectPeer,
order.amountSat,
order.isPublic,
order.paymentMethod,
peers,
pubkey,
]);
React.useEffect(() => {
if (!peers || hasStartedOpenedChannel) {
return;
}
hasStartedOpenedChannel = true;
openChannel();
}, [openChannel, order.amountSat, peers, pubkey]);
return (
By proceeding, you consent the channel opens immediately and
that you lose the right to revoke once it is open.
<>
{canPayInternally && (
{
try {
setPaying(true);
// NOTE: for amboss this will not return until the HOLD invoice is settled
// which is after the channel has N block confirmations
await request(
`/api/payments/${lspOrderResponse.invoice}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
}
);
useChannelOrderStore.getState().updateOrder({
status: "paid",
});
toast("Channel successfully requested");
} catch (e) {
toast.error("Failed to send: ", {
description: "" + e,
});
console.error(e);
}
setPaying(false);
}}
>
Pay and open channel
{!payExternally && (
)}