FirstChannelJitAlert.tsx raw
1 import { AlertTriangleIcon, InfoIcon } from "lucide-react";
2 import React from "react";
3 import { Link } from "react-router";
4 import ExternalLink from "src/components/ExternalLink";
5 import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount";
6 import { Alert, AlertDescription, AlertTitle } from "src/components/ui/alert";
7 import { useBalances } from "src/hooks/useBalances";
8 import { useChannels } from "src/hooks/useChannels";
9 import { useInfo } from "src/hooks/useInfo";
10 import { CreateInvoiceRequest, Transaction } from "src/types";
11 import { request } from "src/utils/request";
12
13 const PROBE_TIMEOUT_MS = 5000;
14
15 export default function FirstChannelJitAlert() {
16 const { data: info } = useInfo();
17 const { data: channels } = useChannels();
18 const { data: balances } = useBalances();
19
20 // a JIT channel only opens when the feature is enabled AND an LSPS2 liquidity
21 // source is actually configured (jitChannelsEnabled alone is just a settings
22 // toggle and can be true on backends without an LSPS2 source).
23 const lsps2Source = info?.jitChannelsEnabled
24 ? info.jitChannelsLiquiditySource
25 : undefined;
26
27 const minPaymentSizeMsat = info?.jitChannelsMinPaymentSizeMsat;
28
29 const isJitEnabled = !!lsps2Source && !!channels;
30 // the user's first received payment opens the channel when they have none yet.
31 const isFirstChannel = isJitEnabled && channels.length === 0;
32
33 // probe whether a JIT channel can actually be obtained by requesting an
34 // invoice for the minimum payment size. If it (or waiting for the minimum
35 // payment size) doesn't succeed within the timeout, we surface a fallback
36 // alert depending on whether the user already has channels.
37 const [probeState, setProbeState] = React.useState<
38 "loading" | "ok" | "failed"
39 >("loading");
40 const deadlineRef = React.useRef<number | null>(null);
41 // single-flight the non-idempotent probe invoice: hold the in-flight request
42 // so effect re-entry (e.g. StrictMode remount) reuses the same POST instead
43 // of creating a duplicate invoice. Reset when the probe window ends.
44 const probeRequestRef = React.useRef<Promise<Transaction | undefined> | null>(
45 null
46 );
47
48 React.useEffect(() => {
49 if (!isJitEnabled) {
50 deadlineRef.current = null;
51 probeRequestRef.current = null;
52 return;
53 }
54
55 // start the 5s clock once when we enter JIT mode - it keeps ticking while
56 // we wait for the minimum payment size to become available.
57 if (deadlineRef.current === null) {
58 deadlineRef.current = Date.now() + PROBE_TIMEOUT_MS;
59 }
60
61 let cancelled = false;
62 const remainingMs = deadlineRef.current - Date.now();
63 if (remainingMs <= 0) {
64 setProbeState("failed");
65 return;
66 }
67
68 const timer = setTimeout(() => {
69 if (!cancelled) {
70 setProbeState("failed");
71 }
72 }, remainingMs);
73
74 // wait for the minimum payment size before requesting the probe invoice.
75 if (minPaymentSizeMsat) {
76 // reuse an already in-flight probe so a re-run doesn't issue a second POST.
77 const probeRequest =
78 probeRequestRef.current ??
79 (probeRequestRef.current = request<Transaction>("/api/invoices", {
80 method: "POST",
81 headers: {
82 "Content-Type": "application/json",
83 },
84 body: JSON.stringify({
85 amountMsat: minPaymentSizeMsat,
86 description: "",
87 } as CreateInvoiceRequest),
88 }));
89 probeRequest
90 .then(() => {
91 if (!cancelled) {
92 clearTimeout(timer);
93 setProbeState("ok");
94 }
95 })
96 .catch(() => {
97 if (!cancelled) {
98 clearTimeout(timer);
99 setProbeState("failed");
100 }
101 });
102 }
103
104 return () => {
105 cancelled = true;
106 clearTimeout(timer);
107 };
108 }, [isJitEnabled, minPaymentSizeMsat]);
109
110 if (!isJitEnabled || probeState === "loading") {
111 return null;
112 }
113
114 if (probeState === "failed") {
115 // no channels yet and a JIT channel couldn't be obtained - the user has no
116 // receiving capacity at all and will fail to receive until a channel opens.
117 if (isFirstChannel) {
118 return (
119 <Alert variant="warning">
120 <AlertTriangleIcon className="h-4 w-4" />
121 <AlertTitle>Can't receive payments yet</AlertTitle>
122 <AlertDescription className="inline">
123 You won't be able to receive payments until you{" "}
124 <Link className="underline" to="/channels/incoming">
125 open a channel
126 </Link>
127 .
128 </AlertDescription>
129 </Alert>
130 );
131 }
132
133 // they already have channels but a JIT channel couldn't be obtained, so they
134 // can only receive up to their current capacity without opening one. Wait for
135 // balances so we don't claim a misleading "0" receivable amount.
136 if (!balances) {
137 return null;
138 }
139 return (
140 <Alert>
141 <InfoIcon className="h-4 w-4" />
142 <AlertDescription className="inline">
143 You can currently receive up to{" "}
144 <FormattedBitcoinAmount
145 amountMsat={balances.lightning.totalReceivableMsat}
146 />
147 . If you want to receive a larger payment,{" "}
148 <Link className="underline" to="/channels/incoming">
149 open a channel
150 </Link>
151 .
152 </AlertDescription>
153 </Alert>
154 );
155 }
156
157 // probe succeeded - only the first-channel case needs an informational alert.
158 if (!isFirstChannel) {
159 return null;
160 }
161
162 return (
163 <Alert>
164 <InfoIcon className="h-4 w-4" />
165 <AlertTitle>First payment opens a channel</AlertTitle>
166 <AlertDescription className="inline">
167 A channel fee applies.{" "}
168 {!!minPaymentSizeMsat && (
169 <>
170 Minimum payment{" "}
171 <FormattedBitcoinAmount amountMsat={minPaymentSizeMsat} />.{" "}
172 </>
173 )}
174 <ExternalLink
175 to="https://guides.getalby.com/user-guide/alby-hub/faq/what-are-just-in-time-channels"
176 className="underline"
177 >
178 Learn more
179 </ExternalLink>
180 </AlertDescription>
181 </Alert>
182 );
183 }
184