diff --git a/README.md b/README.md index 545f443f..c92fc752 100644 --- a/README.md +++ b/README.md @@ -246,6 +246,7 @@ _To configure via env, the following parameters must be provided:_ - `LDK_MAX_PATH_COUNT`: Maximum number of paths that may be used by MPP payments. - `LDK_LOG_LEVEL`: Log level for the LDK node. Higher is more verbose. Default: 3. This is separate from the main application log level, allowing you to enable more verbose LDK logging (e.g., level 4, 5 or 6) without enabling verbose logging for the entire application. - `LDK_CHANNEL_MONITOR_WARNING_SIZE_BYTES`: If a channel monitor is larger than this value, a performance warning will be shown on the node page. +- `LDK_LSPS2_ADDRESSES`: Override the LSPS2 just-in-time (JIT) LSP provider for receiving. When set, Alby Hub can receive payments even without inbound liquidity: the configured LSP opens a channel on the fly and the fee is deducted from the incoming payment. Expected format is a single `@:`. When set, the "Open Your First Channel" prompts are hidden since the first channel is created automatically on the first receive. #### LDK Network Configuration diff --git a/alby/models.go b/alby/models.go index 9c216846..f51a636f 100644 --- a/alby/models.go +++ b/alby/models.go @@ -119,6 +119,7 @@ type ChannelPeerSuggestion struct { Description string `json:"description"` Note string `json:"note"` PublicChannelsAllowed bool `json:"publicChannelsAllowed"` + NodeAddress string `json:"nodeAddress"` FeeTotalSat1m *uint32 `json:"feeTotalSat1m"` FeeTotalSat2m *uint32 `json:"feeTotalSat2m"` FeeTotalSat3m *uint32 `json:"feeTotalSat3m"` diff --git a/api/api.go b/api/api.go index 9b4005d4..da75b818 100644 --- a/api/api.go +++ b/api/api.go @@ -1493,6 +1493,7 @@ func (api *api) GetInfo(ctx context.Context) (*InfoResponse, error) { info := InfoResponse{} backendType, _ := api.cfg.Get("LNBackendType", "") ldkVssEnabled, _ := api.cfg.Get("LdkVssEnabled", "") + jitChannelsEnabled, _ := api.cfg.Get("JitChannelsEnabled", "") autoUnlockPassword, _ := api.cfg.Get("AutoUnlockPassword", "") setupCompleted, err := api.cfg.SetupCompleted() if err != nil { @@ -1516,6 +1517,7 @@ func (api *api) GetInfo(ctx context.Context) (*InfoResponse, error) { info.EnableAdvancedSetup = api.cfg.GetEnv().EnableAdvancedSetup info.HideUpdateBanner = api.cfg.GetEnv().HideUpdateBanner info.LdkVssEnabled = ldkVssEnabled == "true" + info.JitChannelsEnabled = jitChannelsEnabled != "false" info.VssSupported = backendType == config.LDKBackendType && api.cfg.GetEnv().LDKVssUrl != "" info.SupportsBolt12 = backendType == config.LDKBackendType || backendType == config.CLNBackendType info.AutoUnlockPasswordEnabled = autoUnlockPassword != "" @@ -1552,10 +1554,28 @@ func (api *api) GetInfo(ctx context.Context) (*InfoResponse, error) { type chainSourceProvider interface { GetChainDataSource() (string, string) } + type lsps2SourceProvider interface { + GetLiquiditySourceLsps2() string + } + type lsps2MinPaymentSizeProvider interface { + GetLiquiditySourceLsps2MinPaymentSizeMsat() *uint64 + } + type lsps2MaxPaymentSizeProvider interface { + GetLiquiditySourceLsps2MaxPaymentSizeMsat() *uint64 + } if ldkService, ok := api.svc.GetLNClient().(chainSourceProvider); ok { info.ChainDataSourceType, info.ChainDataSourceAddress = ldkService.GetChainDataSource() } + if ldkService, ok := api.svc.GetLNClient().(lsps2SourceProvider); ok { + info.JitChannelsLiquiditySource = ldkService.GetLiquiditySourceLsps2() + } + if ldkService, ok := api.svc.GetLNClient().(lsps2MinPaymentSizeProvider); ok { + info.JitChannelsMinPaymentSizeMsat = ldkService.GetLiquiditySourceLsps2MinPaymentSizeMsat() + } + if ldkService, ok := api.svc.GetLNClient().(lsps2MaxPaymentSizeProvider); ok { + info.JitChannelsMaxPaymentSizeMsat = ldkService.GetLiquiditySourceLsps2MaxPaymentSizeMsat() + } } } @@ -1566,7 +1586,7 @@ func (api *api) GetInfo(ctx context.Context) (*InfoResponse, error) { return &info, nil } -func (api *api) SetCurrency(currency string) error { +func (api *api) setCurrency(currency string) error { if currency == "" { return fmt.Errorf("currency value cannot be empty") } @@ -1580,7 +1600,7 @@ func (api *api) SetCurrency(currency string) error { return nil } -func (api *api) SetBitcoinDisplayFormat(format string) error { +func (api *api) setBitcoinDisplayFormat(format string) error { if format != constants.BITCOIN_DISPLAY_FORMAT_SATS && format != constants.BITCOIN_DISPLAY_FORMAT_BIP177 { return fmt.Errorf("bitcoin display format must be '%s' or '%s'", constants.BITCOIN_DISPLAY_FORMAT_SATS, constants.BITCOIN_DISPLAY_FORMAT_BIP177) } @@ -1594,21 +1614,43 @@ func (api *api) SetBitcoinDisplayFormat(format string) error { return nil } +func (api *api) setJitChannelsEnabled(enabled bool) error { + value := "true" + if !enabled { + value = "false" + } + + err := api.cfg.SetUpdate("JitChannelsEnabled", value, "") + if err != nil { + logger.Logger.WithError(err).Error("Failed to update JIT channels setting") + return err + } + + return nil +} + func (api *api) UpdateSettings(updateSettingsRequest *UpdateSettingsRequest) error { if updateSettingsRequest.Currency != "" { - err := api.SetCurrency(updateSettingsRequest.Currency) + err := api.setCurrency(updateSettingsRequest.Currency) if err != nil { return fmt.Errorf("failed to set currency: %w", err) } } if updateSettingsRequest.BitcoinDisplayFormat != "" { - err := api.SetBitcoinDisplayFormat(updateSettingsRequest.BitcoinDisplayFormat) + err := api.setBitcoinDisplayFormat(updateSettingsRequest.BitcoinDisplayFormat) if err != nil { return fmt.Errorf("failed to set bitcoin display format: %w", err) } } + if updateSettingsRequest.JitChannelsEnabled != nil { + err := api.setJitChannelsEnabled(*updateSettingsRequest.JitChannelsEnabled) + if err != nil { + return fmt.Errorf("failed to set JIT channels setting: %w", err) + } + } + return nil } diff --git a/api/models.go b/api/models.go index 0f0cf279..5448465d 100644 --- a/api/models.go +++ b/api/models.go @@ -64,8 +64,6 @@ type API interface { MigrateNodeStorage(ctx context.Context, to string) error GetWalletCapabilities(ctx context.Context) (*WalletCapabilitiesResponse, error) Health(ctx context.Context) (*HealthResponse, error) - SetCurrency(currency string) error - SetBitcoinDisplayFormat(format string) error UpdateSettings(updateSettingsRequest *UpdateSettingsRequest) error LookupSwap(swapId string) (*LookupSwapResponse, error) ListSwaps() (*ListSwapsResponse, error) @@ -332,6 +330,10 @@ type InfoResponse struct { MempoolUrl string `json:"mempoolUrl"` ChainDataSourceType string `json:"chainDataSourceType,omitempty"` ChainDataSourceAddress string `json:"chainDataSourceAddress,omitempty"` + JitChannelsLiquiditySource string `json:"jitChannelsLiquiditySource,omitempty"` + JitChannelsMinPaymentSizeMsat *uint64 `json:"jitChannelsMinPaymentSizeMsat,omitempty"` + JitChannelsMaxPaymentSizeMsat *uint64 `json:"jitChannelsMaxPaymentSizeMsat,omitempty"` + JitChannelsEnabled bool `json:"jitChannelsEnabled"` HideUpdateBanner bool `json:"hideUpdateBanner"` SupportsBolt12 bool `json:"supportsBolt12"` } @@ -339,6 +341,7 @@ type InfoResponse struct { type UpdateSettingsRequest struct { Currency string `json:"currency"` BitcoinDisplayFormat string `json:"bitcoinDisplayFormat"` + JitChannelsEnabled *bool `json:"jitChannelsEnabled"` } type SetNodeAliasRequest struct { diff --git a/config/models.go b/config/models.go index ca0be744..0fb88709 100644 --- a/config/models.go +++ b/config/models.go @@ -38,6 +38,7 @@ type AppConfig struct { LDKMaxPathCount uint8 `envconfig:"LDK_MAX_PATH_COUNT" default:"5"` LDKChannelMonitorWarningSizeBytes uint64 `envconfig:"LDK_CHANNEL_MONITOR_WARNING_SIZE_BYTES" default:"5000000"` LDKVssUrl string `envconfig:"LDK_VSS_URL" default:"https://vss.getalbypro.com/vss"` + LDKLiquiditySourceLsps2 string `envconfig:"LDK_LSPS2_ADDRESSES"` LDKListeningAddresses string `envconfig:"LDK_LISTENING_ADDRESSES" default:"[::]:9735"` LDKAnnouncementAddresses string `envconfig:"LDK_ANNOUNCEMENT_ADDRESSES"` LDKTransientNetworkGraph bool `envconfig:"LDK_TRANSIENT_NETWORK_GRAPH" default:"false"` diff --git a/frontend/src/components/CurrencyInputField.tsx b/frontend/src/components/CurrencyInputField.tsx index 56456195..33014d91 100644 --- a/frontend/src/components/CurrencyInputField.tsx +++ b/frontend/src/components/CurrencyInputField.tsx @@ -298,6 +298,10 @@ export function CurrencyInputField({ } function handleChangeMode(event: React.ChangeEvent) { + // clear any custom validity set via onInvalid so the field re-validates + // on the next submit + event.currentTarget.setCustomValidity(""); + const nextValue = event.target.value.trim(); if (mode === "bitcoin") { diff --git a/frontend/src/components/FirstChannelJitAlert.tsx b/frontend/src/components/FirstChannelJitAlert.tsx new file mode 100644 index 00000000..973b6af1 --- /dev/null +++ b/frontend/src/components/FirstChannelJitAlert.tsx @@ -0,0 +1,48 @@ +import { InfoIcon } from "lucide-react"; +import ExternalLink from "src/components/ExternalLink"; +import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount"; +import { Alert, AlertDescription, AlertTitle } from "src/components/ui/alert"; +import { useChannels } from "src/hooks/useChannels"; +import { useInfo } from "src/hooks/useInfo"; + +export default function FirstChannelJitAlert() { + const { data: info } = useInfo(); + const { data: channels } = useChannels(); + + // a JIT channel only opens when the feature is enabled AND an LSPS2 liquidity + // source is actually configured (jitChannelsEnabled alone is just a settings + // toggle and can be true on backends without an LSPS2 source). + const lsps2Source = info?.jitChannelsEnabled + ? info.jitChannelsLiquiditySource + : undefined; + + // only relevant when the user has no channels yet - their first received + // payment will open the channel. + if (!lsps2Source || !channels || channels.length > 0) { + return null; + } + + const minPaymentSizeMsat = info?.jitChannelsMinPaymentSizeMsat; + + return ( + + + First payment opens a channel + + A channel fee applies.{" "} + {!!minPaymentSizeMsat && ( + <> + Minimum payment{" "} + .{" "} + + )} + + Learn more + + + + ); +} diff --git a/frontend/src/components/ReceiveToLightning.tsx b/frontend/src/components/ReceiveToLightning.tsx index 444774eb..3e6e3f96 100644 --- a/frontend/src/components/ReceiveToLightning.tsx +++ b/frontend/src/components/ReceiveToLightning.tsx @@ -1,4 +1,5 @@ import { CopyIcon, LinkIcon, ReceiptTextIcon, ZapIcon } from "lucide-react"; +import FirstChannelJitAlert from "src/components/FirstChannelJitAlert"; import Loading from "src/components/Loading"; import QRCode from "src/components/QRCode"; import { Button } from "src/components/ui/button"; @@ -18,43 +19,46 @@ export function ReceiveToLightning() { } return ( - - - -

- {me.lightning_address} -

-
- - - - - - Create Invoice - - {info.supportsBolt12 && ( - + + + + +

+ {me.lightning_address} +

+
+ + + + + + Create Invoice - )} - - - Receive from On-chain / Other Cryptocurrency - - -
+ {info.supportsBolt12 && ( + + + Lightning Offer + + )} + + + Receive from On-chain / Other Cryptocurrency + +
+
+ ); } diff --git a/frontend/src/components/TransactionItem.tsx b/frontend/src/components/TransactionItem.tsx index dd60caa0..8ad3d5fa 100644 --- a/frontend/src/components/TransactionItem.tsx +++ b/frontend/src/components/TransactionItem.tsx @@ -277,10 +277,10 @@ function TransactionItem({ tx, transactionListKey }: Props) { {updatedAt.format("D MMMM YYYY, HH:mm")} - {tx.state != "failed" && type == "outgoing" && ( + {tx.state != "failed" && tx.feesPaidMsat > 0 && ( - {tx.feesPaidMsat > 0 && ( + {type == "outgoing" && ( <>  ( {((tx.feesPaidMsat / tx.amountMsat) * 100).toFixed(2)}%) diff --git a/frontend/src/components/layouts/SettingsLayout.tsx b/frontend/src/components/layouts/SettingsLayout.tsx index d403af4e..6129402c 100644 --- a/frontend/src/components/layouts/SettingsLayout.tsx +++ b/frontend/src/components/layouts/SettingsLayout.tsx @@ -6,6 +6,7 @@ import { useInfo } from "src/hooks/useInfo"; import { ArrowRightLeftIcon, + BoxIcon, BugIcon, CloudBackupIcon, CodeIcon, @@ -162,6 +163,11 @@ export default function SettingsLayout() { + {info?.backendType === "LDK" && ( + + Node + + )} Developer diff --git a/frontend/src/hooks/useOnboardingData.ts b/frontend/src/hooks/useOnboardingData.ts index bef166bf..10810a95 100644 --- a/frontend/src/hooks/useOnboardingData.ts +++ b/frontend/src/hooks/useOnboardingData.ts @@ -62,7 +62,8 @@ export const useOnboardingData = (): UseOnboardingDataResponse => { transactions.totalCount > 0 || balances.lightning.totalSpendableSat > 0; const checklistItems: Omit[] = [ - ...(hasChannelManagement + ...(hasChannelManagement && + !(info.jitChannelsEnabled && info.jitChannelsLiquiditySource) ? [ { title: "Open your first channel", diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx index 73636512..9cffc14f 100644 --- a/frontend/src/routes.tsx +++ b/frontend/src/routes.tsx @@ -49,6 +49,7 @@ import Peers from "src/screens/peers/Peers"; import { About } from "src/screens/settings/About"; import { AlbyAccount } from "src/screens/settings/AlbyAccount"; import { AutoUnlock } from "src/screens/settings/AutoUnlock"; +import { NodeSettings } from "src/screens/settings/NodeSettings"; import Backup from "src/screens/settings/Backup"; import { ChangeUnlockPassword } from "src/screens/settings/ChangeUnlockPassword"; import DebugTools from "src/screens/settings/DebugTools"; @@ -253,6 +254,11 @@ const routes: RouteObject[] = [ element: , handle: { crumb: () => "Auto Unlock" }, }, + { + path: "node", + element: , + handle: { crumb: () => "Node" }, + }, { path: "change-unlock-password", element: , diff --git a/frontend/src/screens/channels/Channels.tsx b/frontend/src/screens/channels/Channels.tsx index e25d654b..7c21150f 100644 --- a/frontend/src/screens/channels/Channels.tsx +++ b/frontend/src/screens/channels/Channels.tsx @@ -326,11 +326,14 @@ export default function Channels() { {!!channels?.length && ( <> {/* If all channels have less than 20% incoming capacity, show a warning */} - {channels?.every( - (channel) => - channel.remoteBalanceMsat < - (channel.localBalanceMsat + channel.remoteBalanceMsat) * 0.2 - ) && } + {!( + info?.jitChannelsEnabled && info?.jitChannelsLiquiditySource + ) && + channels?.every( + (channel) => + channel.remoteBalanceMsat < + (channel.localBalanceMsat + channel.remoteBalanceMsat) * 0.2 + ) && } )} diff --git a/frontend/src/screens/settings/About.tsx b/frontend/src/screens/settings/About.tsx index 161958c4..f503265f 100644 --- a/frontend/src/screens/settings/About.tsx +++ b/frontend/src/screens/settings/About.tsx @@ -1,13 +1,30 @@ +import { ExternalLinkIcon } from "lucide-react"; +import ExternalLink from "src/components/ExternalLink"; import Loading from "src/components/Loading"; import SettingsHeader from "src/components/SettingsHeader"; import { Badge } from "src/components/ui/badge"; import { useAlbyMe } from "src/hooks/useAlbyMe"; +import { useNodeDetails } from "src/hooks/useNodeDetails"; import { useInfo } from "src/hooks/useInfo"; export function About() { const { data: info } = useInfo(); const { data: albyMe, error: albyMeError } = useAlbyMe(); + const lsps2Source = info?.jitChannelsLiquiditySource; + const lsps2Pubkey = lsps2Source?.includes("@") + ? lsps2Source.split("@")[0] + : undefined; + const { data: lsps2NodeDetails } = useNodeDetails(lsps2Pubkey); + const lsps2Label = + lsps2NodeDetails?.alias || + (lsps2Pubkey ? lsps2Pubkey.slice(0, 8) + "..." : lsps2Source); + const lsps2MinPaymentSizeSat = info?.jitChannelsMinPaymentSizeMsat + ? Math.ceil(info.jitChannelsMinPaymentSizeMsat / 1000) + : undefined; + const lsps2MaxPaymentSizeSat = info?.jitChannelsMaxPaymentSizeMsat + ? Math.floor(info.jitChannelsMaxPaymentSizeMsat / 1000) + : undefined; if (!info || (info.albyAccountConnected && !albyMe && !albyMeError)) { return ; @@ -56,6 +73,40 @@ export function About() { )} + {info.jitChannelsLiquiditySource && ( +
+

+ Just-in-Time channels Liquidity Source LSPS2 +

+
+ {lsps2Pubkey ? ( + + {lsps2Label} + + + ) : ( +

{lsps2Label}

+ )} +

{info.jitChannelsLiquiditySource}

+ {(lsps2MinPaymentSizeSat || lsps2MaxPaymentSizeSat) && ( +

+ JIT payment size:{" "} + {lsps2MinPaymentSizeSat + ? new Intl.NumberFormat().format(lsps2MinPaymentSizeSat) + : "?"} + {" - "} + {lsps2MaxPaymentSizeSat + ? new Intl.NumberFormat().format(lsps2MaxPaymentSizeSat) + : "?"}{" "} + sats +

+ )} +
+
+ )}

Nostr Relays

{info.relays.map((relay) => ( diff --git a/frontend/src/screens/settings/NodeSettings.tsx b/frontend/src/screens/settings/NodeSettings.tsx new file mode 100644 index 00000000..f432bc7d --- /dev/null +++ b/frontend/src/screens/settings/NodeSettings.tsx @@ -0,0 +1,84 @@ +import { toast } from "sonner"; +import ExternalLink from "src/components/ExternalLink"; +import Loading from "src/components/Loading"; +import SettingsHeader from "src/components/SettingsHeader"; +import { Checkbox } from "src/components/ui/checkbox"; +import { Label } from "src/components/ui/label"; + +import { useInfo } from "src/hooks/useInfo"; +import { handleRequestError } from "src/utils/handleRequestError"; +import { request } from "src/utils/request"; + +export function NodeSettings() { + const { data: info, mutate: refetchInfo } = useInfo(); + + if (!info) { + return ; + } + if (info.backendType !== "LDK") { + return

Your Hub does not support this feature.

; + } + + const hasJitSource = !!info.jitChannelsLiquiditySource; + + async function setJitChannelsEnabled(enabled: boolean) { + try { + await request("/api/settings", { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ jitChannelsEnabled: enabled }), + }); + await refetchInfo(); + toast(enabled ? "JIT channels enabled" : "JIT channels disabled"); + } catch (error) { + handleRequestError("Failed to update JIT channels setting", error); + } + } + + return ( + <> + +
+
+

+ JIT (just-in-time) channels let you receive payments larger than + your current inbound capacity by automatically opening a new channel + through a liquidity provider. The provider's fee is deducted from + the incoming payment.{" "} + + Learn more + +

+
+
+ + setJitChannelsEnabled(checked === true) + } + /> + +
+ {!hasJitSource && ( +

+ No JIT liquidity source is available for your network, so JIT + channels can't be used. +

+ )} +
+ + ); +} diff --git a/frontend/src/screens/wallet/Lightning.tsx b/frontend/src/screens/wallet/Lightning.tsx index cc299fd8..259a5e93 100644 --- a/frontend/src/screens/wallet/Lightning.tsx +++ b/frontend/src/screens/wallet/Lightning.tsx @@ -16,7 +16,7 @@ import { useChannels } from "src/hooks/useChannels"; import { useInfo } from "src/hooks/useInfo"; export default function Lightning() { - const { hasChannelManagement } = useInfo(); + const { data: info, hasChannelManagement } = useInfo(); const { data: balances } = useBalances(true); const { data: channels } = useChannels(); @@ -37,7 +37,10 @@ export default function Lightning() { balances.lightning.totalReceivableMsat < balances.lightning.totalSpendableMsat * 0.1; const showOpenFirstChannel = - hasChannelManagement && channels && !hasChannelsOpen; + hasChannelManagement && + channels && + !hasChannelsOpen && + !(info?.jitChannelsEnabled && info?.jitChannelsLiquiditySource); return ( <> diff --git a/frontend/src/screens/wallet/receive/ReceiveInvoice.tsx b/frontend/src/screens/wallet/receive/ReceiveInvoice.tsx index ad593eca..3c394adc 100644 --- a/frontend/src/screens/wallet/receive/ReceiveInvoice.tsx +++ b/frontend/src/screens/wallet/receive/ReceiveInvoice.tsx @@ -10,6 +10,7 @@ import React from "react"; import { toast } from "sonner"; import AppHeader from "src/components/AppHeader"; import { CurrencyInputField } from "src/components/CurrencyInputField"; +import ExternalLink from "src/components/ExternalLink"; import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount"; import FormattedFiatAmount from "src/components/FormattedFiatAmount"; import Loading from "src/components/Loading"; @@ -30,6 +31,7 @@ import { Input } from "src/components/ui/input"; import { Label } from "src/components/ui/label"; import { useAlbyMe } from "src/hooks/useAlbyMe"; import { useBalances } from "src/hooks/useBalances"; +import { useChannels } from "src/hooks/useChannels"; import { useInfo } from "src/hooks/useInfo"; import { useTransaction } from "src/hooks/useTransaction"; @@ -42,6 +44,7 @@ export default function ReceiveInvoice() { const { data: info, hasChannelManagement } = useInfo(); const { data: me } = useAlbyMe(); const { data: balances } = useBalances(); + const { data: channels } = useChannels(); const [isLoading, setLoading] = React.useState(false); const [amountSat, setAmountSat] = React.useState(""); @@ -49,17 +52,49 @@ export default function ReceiveInvoice() { const [transaction, setTransaction] = React.useState( null ); - const [paymentDone, setPaymentDone] = React.useState(false); const { data: invoiceData } = useTransaction( transaction ? transaction.paymentHash : "", true ); - - React.useEffect(() => { - if (invoiceData?.settledAt) { - setPaymentDone(true); + const paymentDone = !!invoiceData?.settledAt; + const jitChannelsEnabled = !!info?.jitChannelsEnabled; + const configuredLsps2Source = info?.jitChannelsLiquiditySource; + const lsps2Source = jitChannelsEnabled ? configuredLsps2Source : undefined; + const lsps2MinimumPaymentSizeSat = React.useMemo(() => { + if (jitChannelsEnabled && info?.jitChannelsMinPaymentSizeMsat) { + return Math.ceil(info.jitChannelsMinPaymentSizeMsat / 1000); } - }, [invoiceData]); + return undefined; + }, [info?.jitChannelsMinPaymentSizeMsat, jitChannelsEnabled]); + // only enforce the minimum on the input when the user has no channels yet - + // their first channel must meet the minimum size. + const jitMinimumReceiveSat = channels?.length + ? undefined + : lsps2MinimumPaymentSizeSat; + const lsps2MaximumPaymentSizeSat = React.useMemo(() => { + if (jitChannelsEnabled && info?.jitChannelsMaxPaymentSizeMsat) { + return Math.floor(info.jitChannelsMaxPaymentSizeMsat / 1000); + } + return undefined; + }, [info?.jitChannelsMaxPaymentSizeMsat, jitChannelsEnabled]); + const jitMaximumReceiveSat = + hasChannelManagement && lsps2Source + ? lsps2MaximumPaymentSizeSat + : !lsps2Source && hasChannelManagement + ? balances?.lightning.totalReceivableSat + : undefined; + const totalReceivableMsat = balances?.lightning.totalReceivableMsat ?? 0; + const requestedAmountMsat = +amountSat * 1000 || transaction?.amountMsat || 0; + const isNearReceivingCapacity = + !!hasChannelManagement && requestedAmountMsat >= 0.8 * totalReceivableMsat; + const isJitReceiveInvoice = + !!hasChannelManagement && + !!lsps2Source && + !!transaction && + transaction.amountMsat > totalReceivableMsat; + const displayedJitFeeMsat = paymentDone + ? (invoiceData?.feesPaidMsat ?? 0) + : (transaction?.feesPaidMsat ?? 0); if (!balances || !info || (info.albyAccountConnected && !me)) { return ; @@ -88,8 +123,25 @@ export default function ReceiveInvoice() { toast("Successfully created invoice"); } } catch (e) { + const requestedAmountSat = parseInt(amountSat) || 0; + // the user already has channels but this amount exceeds their receiving + // capacity (so a new channel is needed) and is below the LSP's minimum + // channel size - the receive may have failed because the amount was too + // small to open a second channel, so add a hint alongside the error. + const likelyTooSmallForNewChannel = + jitChannelsEnabled && + !!channels?.length && + !!lsps2MinimumPaymentSizeSat && + requestedAmountSat < lsps2MinimumPaymentSizeSat && + requestedAmountSat * 1000 > totalReceivableMsat; + let description = "" + e; + if (likelyTooSmallForNewChannel) { + description += `\n\nThis amount is over your receiving capacity and may be too small to open a new Lightning channel. Try receiving at least ${new Intl.NumberFormat().format( + lsps2MinimumPaymentSizeSat as number + )} sats, or lower the amount to fit your current capacity.`; + } toast.error("Failed to create invoice", { - description: "" + e, + description, }); console.error(e); } finally { @@ -101,6 +153,19 @@ export default function ReceiveInvoice() { copyToClipboard(transaction?.invoice as string); }; + const newChannelFeeAlert = ( +

+ Includes a {" "} + channel fee.{" "} + + Learn more + +

+ ); + return (
- {hasChannelManagement && - (+amountSat * 1000 || transaction?.amountMsat || 0) >= - 0.8 * balances.lightning.totalReceivableMsat && ( - - )} + {!lsps2Source && !transaction && isNearReceivingCapacity && ( + + )}
{transaction ? ( @@ -138,6 +201,9 @@ export default function ReceiveInvoice() { className="text-xl" />
+ {isJitReceiveInvoice && displayedJitFeeMsat >= 1000 && ( +
{newChannelFeeAlert}
+ )}