mirror of
https://github.com/getAlby/hub.git
synced 2026-08-13 12:33:39 +02:00
feat: just in time channels with lsps2 (#2275)
* feat: just in time channels with lsps2 * fix: clarify JIT receive channel fee * fix: fees * fix: fees 2 * fix: don't show low inbound when LSPS2 is active * fix: remove the receive limit below the input if LSPS2 is being used * fix: simplify * fix: bring back fee % for outgoing * fix: remove unneeded changes * fix: typo * fix: unneeded * fix: don't show open first channel is LSPS2 * feat: clearer JIT channel fee copy on receive screen * fix: add LSPS2 var info * fix: don't duplicate JIT fee hint on create invoice form * fix: make paymentDone a standard boolean * fix: update to golang:1.26 in Dockerfile * feat: read LSPS2 sources from channel suggestions, set minimum receive amount, update guide link * docs: update LDK_LSPS2_ADDRESSES to be used as an override * fix: only show minimum jit receive amount on validation error * fix: add more detail to receive error when receiving low amounts with jit * fix: do not use JIT when user has public channels * feat: add option to disable JIT * fix: isTrusted check, add jit property to event * fix: do not require node restart for toggling JIT * chore: simplify JIT alert * chore: add guide link on node settings JIT description * feat: fetch the lsp2info to have access to params like minimum/maximum payment size * refactor: share single learn-more link across JIT fee hint branches * fix: remove variable amount invoice support * fix: use lsps2info for min payment size and remove channelPeerSuggestion usage of minimumChannelSize * fix: only do amount validation according to lsps2Info values if jit is enabled in settings * feat: add jit first payment fee alert on receive via lightning address * fix: remove unnecessary conditional * fix: ensure at least one sat is left over when opening JIT channel * chore: remove hardcoded suggestions * chore: rename JIT enabled config variable * fix: ui checks when JIT is disabled * fix: amount input validation message * fix: formatting --------- Co-authored-by: anon <anon@anon.com> Co-authored-by: saunter <68239231+stackingsaunter@users.noreply.github.com> Co-authored-by: fmar <fmar@fmar> Co-authored-by: René Aaron <rene@twentyuno.net> Co-authored-by: Roland Bewick <roland.bewick@gmail.com>
This commit is contained in:
parent
e5dc19ae68
commit
b55978d7bc
25 changed files with 678 additions and 90 deletions
|
|
@ -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 `<pubkey>@<host>:<port>`. When set, the "Open Your First Channel" prompts are hidden since the first channel is created automatically on the first receive.
|
||||
|
||||
#### LDK Network Configuration
|
||||
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
50
api/api.go
50
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -298,6 +298,10 @@ export function CurrencyInputField({
|
|||
}
|
||||
|
||||
function handleChangeMode(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
// 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") {
|
||||
|
|
|
|||
48
frontend/src/components/FirstChannelJitAlert.tsx
Normal file
48
frontend/src/components/FirstChannelJitAlert.tsx
Normal file
|
|
@ -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 (
|
||||
<Alert>
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle>First payment opens a channel</AlertTitle>
|
||||
<AlertDescription className="inline">
|
||||
A channel fee applies.{" "}
|
||||
{!!minPaymentSizeMsat && (
|
||||
<>
|
||||
Minimum payment{" "}
|
||||
<FormattedBitcoinAmount amountMsat={minPaymentSizeMsat} />.{" "}
|
||||
</>
|
||||
)}
|
||||
<ExternalLink
|
||||
to="https://guides.getalby.com/user-guide/alby-hub/faq/what-are-just-in-time-channels"
|
||||
className="underline"
|
||||
>
|
||||
Learn more
|
||||
</ExternalLink>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,6 +19,8 @@ export function ReceiveToLightning() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<FirstChannelJitAlert />
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-6">
|
||||
<QRCode value={me.lightning_address} className="w-full h-auto" />
|
||||
|
|
@ -56,5 +59,6 @@ export function ReceiveToLightning() {
|
|||
</LinkButton>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -277,10 +277,10 @@ function TransactionItem({ tx, transactionListKey }: Props) {
|
|||
<TransactionDetailRow label="Date & Time">
|
||||
{updatedAt.format("D MMMM YYYY, HH:mm")}
|
||||
</TransactionDetailRow>
|
||||
{tx.state != "failed" && type == "outgoing" && (
|
||||
{tx.state != "failed" && tx.feesPaidMsat > 0 && (
|
||||
<TransactionDetailRow label="Fee">
|
||||
<FormattedBitcoinAmount amountMsat={tx.feesPaidMsat} />
|
||||
{tx.feesPaidMsat > 0 && (
|
||||
{type == "outgoing" && (
|
||||
<>
|
||||
(
|
||||
{((tx.feesPaidMsat / tx.amountMsat) * 100).toFixed(2)}%)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { useInfo } from "src/hooks/useInfo";
|
|||
|
||||
import {
|
||||
ArrowRightLeftIcon,
|
||||
BoxIcon,
|
||||
BugIcon,
|
||||
CloudBackupIcon,
|
||||
CodeIcon,
|
||||
|
|
@ -162,6 +163,11 @@ export default function SettingsLayout() {
|
|||
</NavGroup>
|
||||
|
||||
<NavGroup label="Advanced">
|
||||
{info?.backendType === "LDK" && (
|
||||
<MenuItem to="/settings/node" icon={BoxIcon}>
|
||||
Node
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem to="/settings/developer" icon={CodeIcon}>
|
||||
Developer
|
||||
</MenuItem>
|
||||
|
|
|
|||
|
|
@ -62,7 +62,8 @@ export const useOnboardingData = (): UseOnboardingDataResponse => {
|
|||
transactions.totalCount > 0 || balances.lightning.totalSpendableSat > 0;
|
||||
|
||||
const checklistItems: Omit<ChecklistItem, "disabled">[] = [
|
||||
...(hasChannelManagement
|
||||
...(hasChannelManagement &&
|
||||
!(info.jitChannelsEnabled && info.jitChannelsLiquiditySource)
|
||||
? [
|
||||
{
|
||||
title: "Open your first channel",
|
||||
|
|
|
|||
|
|
@ -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: <AutoUnlock />,
|
||||
handle: { crumb: () => "Auto Unlock" },
|
||||
},
|
||||
{
|
||||
path: "node",
|
||||
element: <NodeSettings />,
|
||||
handle: { crumb: () => "Node" },
|
||||
},
|
||||
{
|
||||
path: "change-unlock-password",
|
||||
element: <ChangeUnlockPassword />,
|
||||
|
|
|
|||
|
|
@ -326,7 +326,10 @@ export default function Channels() {
|
|||
{!!channels?.length && (
|
||||
<>
|
||||
{/* If all channels have less than 20% incoming capacity, show a warning */}
|
||||
{channels?.every(
|
||||
{!(
|
||||
info?.jitChannelsEnabled && info?.jitChannelsLiquiditySource
|
||||
) &&
|
||||
channels?.every(
|
||||
(channel) =>
|
||||
channel.remoteBalanceMsat <
|
||||
(channel.localBalanceMsat + channel.remoteBalanceMsat) * 0.2
|
||||
|
|
|
|||
|
|
@ -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 <Loading />;
|
||||
|
|
@ -56,6 +73,40 @@ export function About() {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
{info.jitChannelsLiquiditySource && (
|
||||
<div className="grid gap-2">
|
||||
<p className="font-medium text-sm">
|
||||
Just-in-Time channels Liquidity Source LSPS2
|
||||
</p>
|
||||
<div className="flex flex-col gap-1 text-sm text-muted-foreground">
|
||||
{lsps2Pubkey ? (
|
||||
<ExternalLink
|
||||
to={`${info.mempoolUrl}/lightning/node/${lsps2Pubkey}`}
|
||||
className="inline-flex items-center gap-1 underline w-fit"
|
||||
>
|
||||
{lsps2Label}
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</ExternalLink>
|
||||
) : (
|
||||
<p>{lsps2Label}</p>
|
||||
)}
|
||||
<p className="break-all">{info.jitChannelsLiquiditySource}</p>
|
||||
{(lsps2MinPaymentSizeSat || lsps2MaxPaymentSizeSat) && (
|
||||
<p>
|
||||
JIT payment size:{" "}
|
||||
{lsps2MinPaymentSizeSat
|
||||
? new Intl.NumberFormat().format(lsps2MinPaymentSizeSat)
|
||||
: "?"}
|
||||
{" - "}
|
||||
{lsps2MaxPaymentSizeSat
|
||||
? new Intl.NumberFormat().format(lsps2MaxPaymentSizeSat)
|
||||
: "?"}{" "}
|
||||
sats
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-2">
|
||||
<p className="font-medium text-sm">Nostr Relays</p>
|
||||
{info.relays.map((relay) => (
|
||||
|
|
|
|||
84
frontend/src/screens/settings/NodeSettings.tsx
Normal file
84
frontend/src/screens/settings/NodeSettings.tsx
Normal file
|
|
@ -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 <Loading />;
|
||||
}
|
||||
if (info.backendType !== "LDK") {
|
||||
return <p>Your Hub does not support this feature.</p>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<SettingsHeader
|
||||
pageTitle="Node"
|
||||
title="Node"
|
||||
description="Configure your node's behavior"
|
||||
/>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<p className="text-muted-foreground">
|
||||
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.{" "}
|
||||
<ExternalLink
|
||||
to="https://guides.getalby.com/user-guide/alby-hub/faq/what-are-just-in-time-channels"
|
||||
className="underline"
|
||||
>
|
||||
Learn more
|
||||
</ExternalLink>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
id="jit-channels"
|
||||
checked={info.jitChannelsEnabled}
|
||||
disabled={!hasJitSource}
|
||||
onCheckedChange={(checked) =>
|
||||
setJitChannelsEnabled(checked === true)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="jit-channels" className="ml-2 cursor-pointer">
|
||||
Enable JIT channels for receiving
|
||||
</Label>
|
||||
</div>
|
||||
{!hasJitSource && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No JIT liquidity source is available for your network, so JIT
|
||||
channels can't be used.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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<string>("");
|
||||
|
|
@ -49,17 +52,49 @@ export default function ReceiveInvoice() {
|
|||
const [transaction, setTransaction] = React.useState<Transaction | null>(
|
||||
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 <Loading />;
|
||||
|
|
@ -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 = (
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Includes a <FormattedBitcoinAmount amountMsat={displayedJitFeeMsat} />{" "}
|
||||
channel fee.{" "}
|
||||
<ExternalLink
|
||||
to="https://guides.getalby.com/user-guide/alby-hub/faq/what-are-just-in-time-channels"
|
||||
className="underline"
|
||||
>
|
||||
Learn more
|
||||
</ExternalLink>
|
||||
</p>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid gap-5">
|
||||
<AppHeader
|
||||
|
|
@ -109,9 +174,7 @@ export default function ReceiveInvoice() {
|
|||
/>
|
||||
<div className="flex flex-col md:flex-row gap-12">
|
||||
<div className="w-full md:max-w-lg grid gap-6">
|
||||
{hasChannelManagement &&
|
||||
(+amountSat * 1000 || transaction?.amountMsat || 0) >=
|
||||
0.8 * balances.lightning.totalReceivableMsat && (
|
||||
{!lsps2Source && !transaction && isNearReceivingCapacity && (
|
||||
<LowReceivingCapacityAlert />
|
||||
)}
|
||||
<div>
|
||||
|
|
@ -138,6 +201,9 @@ export default function ReceiveInvoice() {
|
|||
className="text-xl"
|
||||
/>
|
||||
</div>
|
||||
{isJitReceiveInvoice && displayedJitFeeMsat >= 1000 && (
|
||||
<div className="w-full">{newChannelFeeAlert}</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-2">
|
||||
<Button
|
||||
|
|
@ -174,7 +240,6 @@ export default function ReceiveInvoice() {
|
|||
<CardFooter className="flex flex-col gap-2 pt-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setPaymentDone(false);
|
||||
setTransaction(null);
|
||||
}}
|
||||
variant="outline"
|
||||
|
|
@ -201,19 +266,42 @@ export default function ReceiveInvoice() {
|
|||
id="amount"
|
||||
valueSat={amountSat}
|
||||
onValueSatChange={setAmountSat}
|
||||
minSat={1}
|
||||
maxSat={
|
||||
hasChannelManagement
|
||||
? balances.lightning.totalReceivableSat
|
||||
: undefined
|
||||
minSat={jitMinimumReceiveSat ?? 1}
|
||||
onInvalid={(e) => {
|
||||
if (
|
||||
jitMinimumReceiveSat &&
|
||||
e.currentTarget.validity.rangeUnderflow
|
||||
) {
|
||||
e.currentTarget.setCustomValidity(
|
||||
`You need to receive at least ${new Intl.NumberFormat().format(
|
||||
jitMinimumReceiveSat
|
||||
)} sats to open your first lightning channel`
|
||||
);
|
||||
} else if (
|
||||
jitMaximumReceiveSat &&
|
||||
e.currentTarget.validity.rangeOverflow
|
||||
) {
|
||||
e.currentTarget.setCustomValidity(
|
||||
lsps2Source
|
||||
? `This JIT channel setup supports receiving at most ${new Intl.NumberFormat().format(
|
||||
jitMaximumReceiveSat
|
||||
)} sats in a single payment`
|
||||
: `You can receive at most ${new Intl.NumberFormat().format(
|
||||
jitMaximumReceiveSat
|
||||
)} sats with your current capacity`
|
||||
);
|
||||
} else {
|
||||
e.currentTarget.setCustomValidity("");
|
||||
}
|
||||
}}
|
||||
maxSat={jitMaximumReceiveSat}
|
||||
autoFocus
|
||||
contextRows={
|
||||
hasChannelManagement
|
||||
hasChannelManagement && !lsps2Source && jitMaximumReceiveSat
|
||||
? [
|
||||
{
|
||||
label: "Receive limit",
|
||||
amountSat: balances.lightning.totalReceivableSat,
|
||||
amountSat: jitMaximumReceiveSat,
|
||||
},
|
||||
]
|
||||
: undefined
|
||||
|
|
|
|||
|
|
@ -179,6 +179,10 @@ export interface InfoResponse {
|
|||
bitcoinDisplayFormat: BitcoinDisplayFormat;
|
||||
chainDataSourceType?: string;
|
||||
chainDataSourceAddress?: string;
|
||||
jitChannelsLiquiditySource?: string;
|
||||
jitChannelsMinPaymentSizeMsat?: number;
|
||||
jitChannelsMaxPaymentSizeMsat?: number;
|
||||
jitChannelsEnabled: boolean;
|
||||
hideUpdateBanner: boolean;
|
||||
supportsBolt12: boolean;
|
||||
}
|
||||
|
|
@ -475,7 +479,7 @@ export type SetupNodeInfo = Partial<{
|
|||
clnAddressHold?: string;
|
||||
}>;
|
||||
|
||||
export type LSPType = "LSPS1";
|
||||
export type LSPType = "LSPS1" | "LSPS2";
|
||||
|
||||
export type LSPChannelOfferPaymentMethod =
|
||||
| "card"
|
||||
|
|
@ -509,9 +513,8 @@ export type RecommendedChannelPeer = {
|
|||
pubkey: string;
|
||||
host: string;
|
||||
}
|
||||
| {
|
||||
| ({
|
||||
paymentMethod: "lightning";
|
||||
type: LSPType;
|
||||
identifier: string;
|
||||
contactUrl: string;
|
||||
terms?: string;
|
||||
|
|
@ -520,7 +523,10 @@ export type RecommendedChannelPeer = {
|
|||
feeTotalSat1m?: number;
|
||||
feeTotalSat2m?: number;
|
||||
feeTotalSat3m?: number;
|
||||
}
|
||||
} & (
|
||||
| { type: "LSPS1" }
|
||||
| { type: "LSPS2"; nodeAddress: string } // nodeid@ip:port
|
||||
))
|
||||
);
|
||||
|
||||
export type AlbyInfo = {
|
||||
|
|
|
|||
2
go.mod
2
go.mod
|
|
@ -8,7 +8,7 @@ require (
|
|||
github.com/btcsuite/btcd/btcutil v1.2.0
|
||||
github.com/elnosh/gonuts v0.4.2
|
||||
github.com/getAlby/go-nostr v0.0.0-20260513161014-22fb7840c7a4
|
||||
github.com/getAlby/ldk-node-go v0.0.0-20260424111754-3690cdb3031c
|
||||
github.com/getAlby/ldk-node-go v0.0.0-20260608130949-5ba22268f000
|
||||
github.com/go-gormigrate/gormigrate/v2 v2.1.6
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/labstack/echo/v4 v4.15.2
|
||||
|
|
|
|||
4
go.sum
4
go.sum
|
|
@ -172,8 +172,8 @@ github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv
|
|||
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
|
||||
github.com/getAlby/go-nostr v0.0.0-20260513161014-22fb7840c7a4 h1:Z93wPXKIMY4Emr+zDz0R0NrSg1FEVGrzcqqomuSrmko=
|
||||
github.com/getAlby/go-nostr v0.0.0-20260513161014-22fb7840c7a4/go.mod h1:BtlkV9evCTjpY0YeFhoNgycp7XNFbnfVXJPoykp+NtM=
|
||||
github.com/getAlby/ldk-node-go v0.0.0-20260424111754-3690cdb3031c h1:ikai5+taiPSgbaocdVMdxPkmOhnXs5V/gCX+J/P8iRw=
|
||||
github.com/getAlby/ldk-node-go v0.0.0-20260424111754-3690cdb3031c/go.mod h1:8BRjtKcz8E0RyYTPEbMS8VIdgredcGSLne8vHDtcRLg=
|
||||
github.com/getAlby/ldk-node-go v0.0.0-20260608130949-5ba22268f000 h1:rlW59HX0myVvttj1VKiyNPNP564ecEDlUHRxC2dIDYs=
|
||||
github.com/getAlby/ldk-node-go v0.0.0-20260608130949-5ba22268f000/go.mod h1:8BRjtKcz8E0RyYTPEbMS8VIdgredcGSLne8vHDtcRLg=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/go-gormigrate/gormigrate/v2 v2.1.6 h1:VtX+l1Stj2v5RGubVQk0LS/8EPGXR+ldcOyCmlmKoyg=
|
||||
github.com/go-gormigrate/gormigrate/v2 v2.1.6/go.mod h1:PZpedQc4tWaxn6kvXicwhinh3L0seLpMc5ReKRX5id4=
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import (
|
|||
decodepay "github.com/nbd-wtf/ln-decodepay"
|
||||
"github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/getAlby/hub/alby"
|
||||
"github.com/getAlby/hub/config"
|
||||
"github.com/getAlby/hub/events"
|
||||
"github.com/getAlby/hub/lnclient"
|
||||
|
|
@ -54,13 +55,20 @@ type LDKService struct {
|
|||
lastWalletSyncRequest time.Time
|
||||
redeemedOnchainFundsWithinThisSync bool
|
||||
pubkey string
|
||||
lsps2Pubkey string
|
||||
lsps2Address string
|
||||
lsps2InfoMu sync.Mutex
|
||||
lsps2InfoFetchedAt time.Time
|
||||
lsps2MinPaymentSizeMsat *uint64
|
||||
lsps2MaxPaymentSizeMsat *uint64
|
||||
shuttingDown bool
|
||||
}
|
||||
|
||||
const resetRouterKey = "ResetRouter"
|
||||
const maxInvoiceExpiry = 24 * time.Hour
|
||||
const lsps2InfoCacheTTL = 60 * time.Minute
|
||||
|
||||
func NewLDKService(ctx context.Context, cfg config.Config, eventPublisher events.EventPublisher, mnemonic, workDir string, vssToken string, setStartupState func(startupState string)) (result lnclient.LNClient, err error) {
|
||||
func NewLDKService(ctx context.Context, cfg config.Config, eventPublisher events.EventPublisher, mnemonic, workDir string, vssToken string, setStartupState func(startupState string), channelPeerSuggestions []alby.ChannelPeerSuggestion) (result lnclient.LNClient, err error) {
|
||||
if mnemonic == "" || workDir == "" {
|
||||
return nil, errors.New("one or more required LDK configuration are missing")
|
||||
}
|
||||
|
|
@ -143,7 +151,40 @@ func NewLDKService(ctx context.Context, cfg config.Config, eventPublisher events
|
|||
builder.SetNodeAlias(alias)
|
||||
builder.SetEntropyBip39Mnemonic(mnemonic, nil)
|
||||
|
||||
liquiditySourceLsps2 := cfg.GetEnv().LDKLiquiditySourceLsps2
|
||||
network := cfg.GetNetwork()
|
||||
|
||||
// if no explicit override, try a matching LSPS2 suggestion for this network
|
||||
if liquiditySourceLsps2 == "" {
|
||||
for _, suggestion := range channelPeerSuggestions {
|
||||
if suggestion.PaymentMethod == "lightning" &&
|
||||
suggestion.Type == lsp.LSP_TYPE_LSPS2 &&
|
||||
suggestion.Network == network &&
|
||||
suggestion.NodeAddress != "" {
|
||||
liquiditySourceLsps2 = suggestion.NodeAddress
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fall back to a hardcoded per-network default
|
||||
if liquiditySourceLsps2 == "" {
|
||||
switch network {
|
||||
case "signet":
|
||||
// Alby LSP (Mutinynet)
|
||||
liquiditySourceLsps2 = "025010bd608771bc13f08f696e3dd226bf3a9ae6ea461e3922ed9bdca7bb0edfe5@141.95.84.44:9735"
|
||||
case "bitcoin":
|
||||
// Megalith LSP 2
|
||||
liquiditySourceLsps2 = "034066e29e402d9cf55af1ae1026cc5adf92eed1e0e421785442f53717ad1453b0@64.23.159.177:9735"
|
||||
}
|
||||
}
|
||||
|
||||
lsps2Pubkey, lsps2Address := parseLiquiditySourceLsps2(liquiditySourceLsps2)
|
||||
|
||||
if lsps2Pubkey != "" {
|
||||
builder.SetLiquiditySourceLsps2(lsps2Pubkey, lsps2Address, nil)
|
||||
}
|
||||
|
||||
switch network {
|
||||
case "signet":
|
||||
builder.SetNetwork(ldk_node.NetworkSignet)
|
||||
|
|
@ -251,6 +292,8 @@ func NewLDKService(ctx context.Context, cfg config.Config, eventPublisher events
|
|||
eventPublisher: eventPublisher,
|
||||
cfg: cfg,
|
||||
pubkey: nodeId,
|
||||
lsps2Pubkey: lsps2Pubkey,
|
||||
lsps2Address: lsps2Address,
|
||||
ctx: ldkCtx,
|
||||
}
|
||||
|
||||
|
|
@ -691,6 +734,16 @@ func (ls *LDKService) getMaxReceivable() int64 {
|
|||
return int64(receivable)
|
||||
}
|
||||
|
||||
func (ls *LDKService) hasPublicChannel() bool {
|
||||
channels := ls.node.ListChannels()
|
||||
for _, channel := range channels {
|
||||
if channel.IsAnnounced {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (ls *LDKService) getMaxSpendable() uint64 {
|
||||
var spendable uint64 = 0
|
||||
channels := ls.node.ListChannels()
|
||||
|
|
@ -710,7 +763,15 @@ func (ls *LDKService) MakeInvoice(ctx context.Context, amountMsat int64, descrip
|
|||
|
||||
maxReceivable := ls.getMaxReceivable()
|
||||
|
||||
if amountMsat > maxReceivable {
|
||||
jitChannelsEnabled, _ := ls.cfg.Get("JitChannelsEnabled", "")
|
||||
// JIT channels are only used for users without a public channel - users with
|
||||
// a public channel should increase inbound liquidity manually.
|
||||
isJitInvoice := ls.lsps2Pubkey != "" &&
|
||||
jitChannelsEnabled != "false" &&
|
||||
!ls.hasPublicChannel() &&
|
||||
amountMsat > maxReceivable
|
||||
|
||||
if amountMsat > maxReceivable && !isJitInvoice {
|
||||
ls.eventPublisher.Publish(&events.Event{
|
||||
Event: "nwc_incoming_liquidity_required",
|
||||
Properties: map[string]interface{}{
|
||||
|
|
@ -736,9 +797,21 @@ func (ls *LDKService) MakeInvoice(ctx context.Context, amountMsat int64, descrip
|
|||
}
|
||||
}
|
||||
|
||||
invoiceObj, err := ls.node.Bolt11Payment().Receive(uint64(amountMsat),
|
||||
var invoiceObj *ldk_node.Bolt11Invoice
|
||||
if isJitInvoice {
|
||||
invoiceObj, err = ls.node.Bolt11Payment().ReceiveViaJitChannel(
|
||||
uint64(amountMsat),
|
||||
descriptionType,
|
||||
uint32(expiry))
|
||||
uint32(expiry),
|
||||
nil,
|
||||
)
|
||||
} else {
|
||||
invoiceObj, err = ls.node.Bolt11Payment().Receive(
|
||||
uint64(amountMsat),
|
||||
descriptionType,
|
||||
uint32(expiry),
|
||||
)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
logger.Logger.WithError(err).Error("MakeInvoice failed")
|
||||
|
|
@ -746,7 +819,7 @@ func (ls *LDKService) MakeInvoice(ctx context.Context, amountMsat int64, descrip
|
|||
}
|
||||
|
||||
payment := ls.node.Payment(invoiceObj.PaymentHash())
|
||||
invoice := *payment.Kind.(ldk_node.PaymentKindBolt11).Bolt11Invoice
|
||||
invoice := invoiceObj.String()
|
||||
paymentRequest, err := decodepay.Decodepay(invoice)
|
||||
if err != nil {
|
||||
logger.Logger.WithFields(logrus.Fields{
|
||||
|
|
@ -757,12 +830,33 @@ func (ls *LDKService) MakeInvoice(ctx context.Context, amountMsat int64, descrip
|
|||
}
|
||||
expiresAtUnix := time.UnixMilli(int64(paymentRequest.CreatedAt) * 1000).Add(time.Duration(paymentRequest.Expiry) * time.Second).Unix()
|
||||
|
||||
preimage := ""
|
||||
estimatedLspFeeMsat := int64(0)
|
||||
if payment != nil {
|
||||
switch kind := payment.Kind.(type) {
|
||||
case ldk_node.PaymentKindBolt11:
|
||||
if kind.Preimage != nil {
|
||||
preimage = *kind.Preimage
|
||||
}
|
||||
case ldk_node.PaymentKindBolt11Jit:
|
||||
if kind.Preimage != nil {
|
||||
preimage = *kind.Preimage
|
||||
}
|
||||
if kind.LspFeeLimits.MaxTotalOpeningFeeMsat != nil {
|
||||
estimatedLspFeeMsat = int64(*kind.LspFeeLimits.MaxTotalOpeningFeeMsat)
|
||||
} else if kind.LspFeeLimits.MaxProportionalOpeningFeePpmMsat != nil && amountMsat > 0 {
|
||||
estimatedLspFeeMsat = int64((uint64(amountMsat) * *kind.LspFeeLimits.MaxProportionalOpeningFeePpmMsat) / 1_000_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
transaction = &lnclient.Transaction{
|
||||
Type: "incoming",
|
||||
Invoice: invoice,
|
||||
PaymentHash: paymentRequest.PaymentHash,
|
||||
Preimage: *payment.Kind.(ldk_node.PaymentKindBolt11).Preimage,
|
||||
Preimage: preimage,
|
||||
AmountMsat: amountMsat,
|
||||
FeesPaidMsat: estimatedLspFeeMsat,
|
||||
CreatedAt: int64(paymentRequest.CreatedAt),
|
||||
ExpiresAt: &expiresAtUnix,
|
||||
Description: paymentRequest.Description,
|
||||
|
|
@ -1377,6 +1471,20 @@ func (ls *LDKService) ldkPaymentToTransaction(payment *ldk_node.PaymentDetails)
|
|||
paymentHash = bolt11PaymentKind.Hash
|
||||
}
|
||||
|
||||
bolt11JitPaymentKind, isBolt11JitPaymentKind := payment.Kind.(ldk_node.PaymentKindBolt11Jit)
|
||||
if isBolt11JitPaymentKind {
|
||||
createdAt = int64(payment.CreatedAt)
|
||||
if payment.CreatedAt == 0 {
|
||||
createdAt = int64(payment.LatestUpdateTimestamp)
|
||||
}
|
||||
if payment.Status == ldk_node.PaymentStatusSucceeded && bolt11JitPaymentKind.Preimage != nil {
|
||||
preimage = *bolt11JitPaymentKind.Preimage
|
||||
lastUpdate := int64(payment.LatestUpdateTimestamp)
|
||||
settledAt = &lastUpdate
|
||||
}
|
||||
paymentHash = bolt11JitPaymentKind.Hash
|
||||
}
|
||||
|
||||
bolt12PaymentKind, isBolt12PaymentKind := payment.Kind.(ldk_node.PaymentKindBolt12Offer)
|
||||
|
||||
if isBolt12PaymentKind {
|
||||
|
|
@ -1442,6 +1550,9 @@ func (ls *LDKService) ldkPaymentToTransaction(payment *ldk_node.PaymentDetails)
|
|||
if payment.FeePaidMsat != nil {
|
||||
feeMsat = *payment.FeePaidMsat
|
||||
}
|
||||
if isBolt11JitPaymentKind && bolt11JitPaymentKind.CounterpartySkimmedFeeMsat != nil {
|
||||
feeMsat = *bolt11JitPaymentKind.CounterpartySkimmedFeeMsat
|
||||
}
|
||||
|
||||
return &lnclient.Transaction{
|
||||
Type: transactionType,
|
||||
|
|
@ -1535,15 +1646,22 @@ func (ls *LDKService) handleLdkEvent(event *ldk_node.Event) {
|
|||
return
|
||||
}
|
||||
|
||||
isTrusted := eventType.CounterpartyNodeId != nil && slices.Contains(ls.node.Config().AnchorChannelsConfig.TrustedPeersNoReserve, *eventType.CounterpartyNodeId)
|
||||
|
||||
channel := channels[channelIndex]
|
||||
|
||||
// assume it's a JIT channel if the channel peer matches
|
||||
// checking outbound capacity doesn't work (outbound capacity can be initially 0)
|
||||
isJit := !channel.IsOutbound && ls.lsps2Pubkey != "" && *eventType.CounterpartyNodeId == ls.lsps2Pubkey
|
||||
|
||||
isTrusted := eventType.CounterpartyNodeId != nil &&
|
||||
(slices.Contains(ls.node.Config().AnchorChannelsConfig.TrustedPeersNoReserve, *eventType.CounterpartyNodeId) || isJit)
|
||||
|
||||
ls.eventPublisher.Publish(&events.Event{
|
||||
Event: "nwc_channel_ready",
|
||||
Properties: map[string]interface{}{
|
||||
"counterparty_node_id": eventType.CounterpartyNodeId,
|
||||
"node_type": config.LDKBackendType,
|
||||
"public": channel.IsAnnounced,
|
||||
"jit": isJit,
|
||||
"capacity": channel.ChannelValueSats,
|
||||
"is_outbound": channel.IsOutbound,
|
||||
"trusted": isTrusted,
|
||||
|
|
@ -1904,6 +2022,8 @@ func (ls *LDKService) deleteOldLDKPayments() {
|
|||
switch (payment.Kind).(type) {
|
||||
case ldk_node.PaymentKindBolt11:
|
||||
deletablePaymentKind = true
|
||||
case ldk_node.PaymentKindBolt11Jit:
|
||||
deletablePaymentKind = true
|
||||
case ldk_node.PaymentKindSpontaneous:
|
||||
deletablePaymentKind = true
|
||||
}
|
||||
|
|
@ -2503,6 +2623,95 @@ func (ls *LDKService) GetChainDataSource() (string, string) {
|
|||
return "esplora", sanitizeChainEndpoint(endpoint, "")
|
||||
}
|
||||
|
||||
func (ls *LDKService) GetLiquiditySourceLsps2() string {
|
||||
if ls.lsps2Pubkey == "" || ls.lsps2Address == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s@%s", ls.lsps2Pubkey, ls.lsps2Address)
|
||||
}
|
||||
|
||||
func (ls *LDKService) GetLiquiditySourceLsps2MinPaymentSizeMsat() *uint64 {
|
||||
ls.fetchLsps2OpeningFeeParams()
|
||||
return ls.lsps2MinPaymentSizeMsat
|
||||
}
|
||||
|
||||
func (ls *LDKService) GetLiquiditySourceLsps2MaxPaymentSizeMsat() *uint64 {
|
||||
ls.fetchLsps2OpeningFeeParams()
|
||||
return ls.lsps2MaxPaymentSizeMsat
|
||||
}
|
||||
|
||||
func (ls *LDKService) fetchLsps2OpeningFeeParams() {
|
||||
if ls.lsps2Pubkey == "" || ls.lsps2Address == "" {
|
||||
return
|
||||
}
|
||||
|
||||
ls.lsps2InfoMu.Lock()
|
||||
defer ls.lsps2InfoMu.Unlock()
|
||||
|
||||
if !ls.lsps2InfoFetchedAt.IsZero() && time.Since(ls.lsps2InfoFetchedAt) < lsps2InfoCacheTTL {
|
||||
return
|
||||
}
|
||||
|
||||
response, err := ls.node.Lsps2Liquidity().RequestOpeningFeeParams()
|
||||
if err != nil {
|
||||
logger.Logger.WithError(err).Warn("Failed to fetch LSPS2 opening fee params")
|
||||
return
|
||||
}
|
||||
|
||||
var minPaymentSizeMsat *uint64
|
||||
var maxPaymentSizeMsat *uint64
|
||||
for _, params := range response.OpeningFeeParamsMenu {
|
||||
effectiveMinPaymentSizeMsat, ok := computeLsps2MinPaymentSizeMsat(params)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if minPaymentSizeMsat == nil || effectiveMinPaymentSizeMsat < *minPaymentSizeMsat {
|
||||
value := effectiveMinPaymentSizeMsat
|
||||
minPaymentSizeMsat = &value
|
||||
}
|
||||
if maxPaymentSizeMsat == nil || params.MaxPaymentSizeMsat > *maxPaymentSizeMsat {
|
||||
value := params.MaxPaymentSizeMsat
|
||||
maxPaymentSizeMsat = &value
|
||||
}
|
||||
}
|
||||
|
||||
ls.lsps2MinPaymentSizeMsat = minPaymentSizeMsat
|
||||
ls.lsps2MaxPaymentSizeMsat = maxPaymentSizeMsat
|
||||
ls.lsps2InfoFetchedAt = time.Now()
|
||||
}
|
||||
|
||||
// finds the smallest incoming payment for which the user is left
|
||||
// with a usable amount after the LSP skims its LSPS2 opening fee.
|
||||
func computeLsps2MinPaymentSizeMsat(params ldk_node.Lsps2OpeningFeeParams) (uint64, bool) {
|
||||
// The smallest amount the user must net after the opening fee. We require a
|
||||
// whole satoshi rather than a single millisat so the minimum payment size
|
||||
// represents a usable receive.
|
||||
const minNetReceiveMsat = 1000
|
||||
|
||||
paymentSizeMsat := params.MinPaymentSizeMsat
|
||||
|
||||
for range 8 {
|
||||
openingFeeMsat := ldk_node.Lsps2ComputeOpeningFeeMsat(paymentSizeMsat, params)
|
||||
if openingFeeMsat == nil {
|
||||
return 0, false
|
||||
}
|
||||
// The incoming amount must exceed the opening fee by at least 1 sat,
|
||||
// otherwise the user receives a sub-satoshi (effectively zero) amount
|
||||
// after the LSP skims its fee.
|
||||
if *openingFeeMsat+minNetReceiveMsat <= paymentSizeMsat {
|
||||
return paymentSizeMsat, paymentSizeMsat <= params.MaxPaymentSizeMsat
|
||||
}
|
||||
|
||||
nextPaymentSizeMsat := *openingFeeMsat + minNetReceiveMsat
|
||||
if nextPaymentSizeMsat <= paymentSizeMsat || nextPaymentSizeMsat > params.MaxPaymentSizeMsat {
|
||||
return 0, false
|
||||
}
|
||||
paymentSizeMsat = nextPaymentSizeMsat
|
||||
}
|
||||
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func sanitizeChainEndpoint(endpoint string, port string) string {
|
||||
u, err := url.Parse(endpoint)
|
||||
if err != nil || u.Host == "" {
|
||||
|
|
@ -2536,3 +2745,23 @@ func sanitizeChainEndpoint(endpoint string, port string) string {
|
|||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
func parseLiquiditySourceLsps2(lsps2Address string) (pubkey string, address string) {
|
||||
entry := strings.TrimSpace(lsps2Address)
|
||||
if entry == "" {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
pubkey, address, hasSeparator := strings.Cut(entry, "@")
|
||||
if !hasSeparator || pubkey == "" || address == "" {
|
||||
logger.Logger.WithField("entry", entry).Warn("Invalid LDK_LSPS2_ADDRESS, expected <pubkey>@<host>:<port>")
|
||||
return "", ""
|
||||
}
|
||||
|
||||
if _, _, err := net.SplitHostPort(address); err != nil {
|
||||
logger.Logger.WithField("entry", entry).WithError(err).Warn("Invalid LDK_LSPS2_ADDRESS host:port")
|
||||
return "", ""
|
||||
}
|
||||
|
||||
return pubkey, address
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ type LSP struct {
|
|||
|
||||
const (
|
||||
LSP_TYPE_LSPS1 = "LSPS1"
|
||||
LSP_TYPE_LSPS2 = "LSPS2"
|
||||
)
|
||||
|
||||
func OlympusMutinynetLSP() LSP {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import (
|
|||
)
|
||||
|
||||
type makeInvoiceParams struct {
|
||||
Amount uint64 `json:"amount"`
|
||||
Amount uint64 `json:"amount"` // msats (NIP-47)
|
||||
Description string `json:"description"`
|
||||
DescriptionHash string `json:"description_hash"`
|
||||
Expiry uint64 `json:"expiry"`
|
||||
|
|
|
|||
|
|
@ -362,7 +362,12 @@ func (svc *service) launchLNBackend(ctx context.Context, encryptionKey string) e
|
|||
setStartupState := func(startupState string) {
|
||||
svc.startupState = startupState
|
||||
}
|
||||
lnClient, err = ldk.NewLDKService(ctx, svc.cfg, svc.eventPublisher, mnemonic, ldkWorkdir, vssToken, setStartupState)
|
||||
|
||||
channelPeerSuggestions, suggestionsErr := svc.albySvc.GetChannelPeerSuggestions(ctx)
|
||||
if suggestionsErr != nil {
|
||||
logger.Logger.WithError(suggestionsErr).Warn("Failed to fetch channel peer suggestions for LSPS2 liquidity source")
|
||||
}
|
||||
lnClient, err = ldk.NewLDKService(ctx, svc.cfg, svc.eventPublisher, mnemonic, ldkWorkdir, vssToken, setStartupState, channelPeerSuggestions)
|
||||
case config.PhoenixBackendType:
|
||||
PhoenixdAddress, _ := svc.cfg.Get("PhoenixdAddress", encryptionKey)
|
||||
PhoenixdAuthorization, _ := svc.cfg.Get("PhoenixdAuthorization", encryptionKey)
|
||||
|
|
|
|||
|
|
@ -205,6 +205,7 @@ func (svc *transactionsService) MakeInvoice(ctx context.Context, amountMsat uint
|
|||
Type: lnClientTransaction.Type,
|
||||
State: constants.TRANSACTION_STATE_PENDING,
|
||||
AmountMsat: uint64(lnClientTransaction.AmountMsat),
|
||||
FeeMsat: uint64(max(lnClientTransaction.FeesPaidMsat, 0)),
|
||||
Description: description,
|
||||
DescriptionHash: descriptionHash,
|
||||
PaymentRequest: lnClientTransaction.Invoice,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue