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