package ldk import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "math" "net" "net/http" "net/url" "os" "path/filepath" "slices" "sort" "strconv" "strings" "sync" "time" "github.com/getAlby/ldk-node-go/ldk_node" "github.com/tyler-smith/go-bip32" // "github.com/getAlby/hub/ldk_node" 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" "github.com/getAlby/hub/logger" "github.com/getAlby/hub/lsp" "github.com/getAlby/hub/nip47/models" "github.com/getAlby/hub/nip47/notifications" "github.com/getAlby/hub/service/keys" "github.com/getAlby/hub/transactions" ) type LDKService struct { workdir string node *ldk_node.Node ldkEventBroadcaster LDKEventBroadcaster cancel context.CancelFunc ctx context.Context network string eventPublisher events.EventPublisher syncing bool lastFullSync time.Time lastFeeEstimatesSync time.Time cfg config.Config lastWalletSyncRequest time.Time redeemedOnchainFundsWithinThisSync bool pubkey string lsps2Pubkey string lsps2Address string lsps2InfoMu sync.Mutex lsps2InfoFetchedAt time.Time lsps2MinPaymentSizeMsat *uint64 lsps2MaxPaymentSizeMsat *uint64 lsps2OpeningFeeParamsMenu []ldk_node.Lsps2OpeningFeeParams shuttingDown bool eventHandlingMutex sync.Mutex } const resetRouterKey = "ResetRouter" const maxInvoiceExpiry = 24 * time.Hour const lsps2InfoCacheTTL = 60 * time.Minute // cached opening fee params must be at most this old when used to derive the // maximum LSP fee for a new JIT channel invoice const lsps2FeeCapCacheTTL = 1 * time.Minute // absolute ceiling on the LSPS2 opening fee accepted for a JIT channel, // regardless of the fee menu the LSP advertises: the greater of a base amount // and a percentage of the payment, so small payments can absorb the fixed // cost of a channel open while larger payments cannot be overcharged. const lsps2MaxOpeningFeeBaseMsat = 5_000_000 const lsps2MaxOpeningFeePercent = 10 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") } setStartupState("Configuring node") // create dir if not exists newpath := filepath.Join(workDir) err = os.MkdirAll(newpath, os.ModePerm) if err != nil { logger.Logger.WithError(err).Error("Failed to create LDK working dir") return nil, err } ldkConfig := ldk_node.DefaultConfig() ldkConfig.TrustedPeers0conf = []string{ lsp.OlympusLSP().Pubkey, lsp.MegalithLSP().Pubkey, "02b4552a7a85274e4da01a7c71ca57407181752e8568b31d51f13c111a2941dce3", // LNServer_Wave "038ba8f67ba8ff5c48764cdd3251c33598d55b203546d08a8f0ec9dcd9f27e3637", // Flashsats "03a5c38d0dfd2dd1ebe679c308788c468a0186720bb33973b1c56710ffe6696c08", // BHODL // Mutinynet lsp.OlympusMutinynetLSP().Pubkey, lsp.MegalithMutinynetLSP().Pubkey, "03f726f240f0391448fb31c33e130ecc9708c9137e1f4e77b5d17d5dec74b0dd1e", // flashsats } // rather than fully trusting our LSPs, we set the channel reserve to 0. // this allows us to receive incoming channels without any on-chain balance // but if the user has 0 on-chain balance when the channel is closed, // we rely on the counterparty to bump the transaction. // It's also possible in rare situations the counterparty can take // funds if the channel was closed due to a stuck HTLC. // Therefore, the user SHOULD add some on-chain funds to prevent this. ldkConfig.AnchorChannelsConfig.PerChannelReserveSats = 0 ldkConfig.AnchorChannelsConfig.TrustedPeersNoReserve = []string{ /*lsp.OlympusLSP().Pubkey, lsp.AlbyPlebsLSP().Pubkey, lsp.MegalithLSP().Pubkey, "02b4552a7a85274e4da01a7c71ca57407181752e8568b31d51f13c111a2941dce3", // LNServer_Wave "0296b2db342fcf87ea94d981757fdf4d3e545bd5cef4919f58b5d38dfdd73bf5c9", // blocktank "038ba8f67ba8ff5c48764cdd3251c33598d55b203546d08a8f0ec9dcd9f27e3637", // flashsats "0370a5392cd7c81ff5128fa656ee6db0c4d11c778fcd6cb98cb6ba3b48394f5705", // lqwd // Mutinynet lsp.AlbyMutinynetPlebsLSP().Pubkey, lsp.OlympusMutinynetLSP().Pubkey, lsp.MegalithMutinynetLSP().Pubkey, "0296820bbba5bd33719962bafd69996ee89e03ce7164d8f368cbb85463f5f47876", // flashsats "035e8a9034a8c68f219aacadae748c7a3cd719109309db39b09886e5ff17696b1b", // lqwd*/ } listeningAddresses := strings.Split(cfg.GetEnv().LDKListeningAddresses, ",") ldkConfig.ListeningAddresses = &listeningAddresses if cfg.GetEnv().LDKAnnouncementAddresses != "" { announcementAddresses := strings.Split(cfg.GetEnv().LDKAnnouncementAddresses, ",") ldkConfig.AnnouncementAddresses = &announcementAddresses } logLevel, err := strconv.Atoi(cfg.GetEnv().LDKLogLevel) if err != nil { // If parsing log level fails we default to info log level logLevel = int(logrus.InfoLevel) } ldkLogger, err := NewLDKLogger(logrus.Level(logLevel), cfg.GetEnv().LogToFile, workDir) if err != nil { return nil, err } ldkConfig.TransientNetworkGraph = cfg.GetEnv().LDKTransientNetworkGraph alias, _ := cfg.Get("NodeAlias", "") if alias == "" { alias = "Alby Hub" } builder := ldk_node.BuilderFromConfig(ldkConfig) builder.SetCustomLogger(ldkLogger) 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": // Megalith LSP 2 (Mutinynet) liquiditySourceLsps2 = "03e30fda71887a916ef5548a4d02b06fe04aaa1a8de9e24134ce7f139cf79d7579@64.23.192.68:9736" 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) case "regtest": builder.SetNetwork(ldk_node.NetworkRegtest) case "testnet": builder.SetNetwork(ldk_node.NetworkSignet) default: builder.SetNetwork(ldk_node.NetworkBitcoin) } var chainSource string if cfg.GetEnv().LDKBitcoindRpcHost != "" { logger.Logger.WithFields(logrus.Fields{ "rpc_host": cfg.GetEnv().LDKBitcoindRpcHost, "rpc_port": cfg.GetEnv().LDKBitcoindRpcPort, }).Info("Using LDK node bitcoin RPC chain source") port, err := strconv.ParseUint(cfg.GetEnv().LDKBitcoindRpcPort, 10, 16) if err != nil { return nil, err } builder.SetChainSourceBitcoindRpc(cfg.GetEnv().LDKBitcoindRpcHost, uint16(port), cfg.GetEnv().LDKBitcoindRpcUser, cfg.GetEnv().LDKBitcoindRpcPassword) chainSource = "bitcoind" } else if cfg.GetEnv().LDKElectrumServer != "" { builder.SetChainSourceElectrum(cfg.GetEnv().LDKElectrumServer, &ldk_node.ElectrumSyncConfig{ // turn off background sync - we manage syncs ourselves BackgroundSyncConfig: nil, }) chainSource = "electrum" } else { logger.Logger.WithFields(logrus.Fields{ "esplora_url": cfg.GetEnv().LDKEsploraServer, }).Info("Using LDK node esplora chain source") builder.SetChainSourceEsplora(cfg.GetEnv().LDKEsploraServer, &ldk_node.EsploraSyncConfig{ // turn off background sync - we manage syncs ourselves BackgroundSyncConfig: nil, }) chainSource = "esplora" } if cfg.GetEnv().LDKGossipSource != "" { logger.Logger.WithField("gossipSource", cfg.GetEnv().LDKGossipSource).Warn("LDK RGS instance set") builder.SetGossipSourceRgs(cfg.GetEnv().LDKGossipSource) } builder.SetStorageDirPath(filepath.Join(newpath, "./storage")) migrateStorage, _ := cfg.Get("LdkMigrateStorage", "") clearMigrateStorageConfigValue := false if migrateStorage == "VSS" { clearMigrateStorageConfigValue = true if vssToken == "" { return nil, errors.New("migration enabled but no vss token found") } builder.MigrateStorage(ldk_node.MigrateStorageVss) } resetStateRequest := getResetStateRequest(cfg) if resetStateRequest != nil { builder.ResetState(*resetStateRequest) } logger.Logger.WithFields(logrus.Fields{ "migrate_storage": migrateStorage, "vss_enabled": vssToken != "", "node_alias": alias, "listening_addresses": listeningAddresses, "chain_source": chainSource, }).Info("Creating LDK node") setStartupState("Loading node data...") var node *ldk_node.Node if vssToken != "" { node, err = builder.BuildWithVssStoreAndFixedHeaders(cfg.GetEnv().LDKVssUrl, "albyhub", map[string]string{ "Authorization": fmt.Sprintf("Bearer %s", vssToken), }) } else { node, err = builder.Build() } if err != nil { logger.Logger.WithError(err).Error("Failed to create LDK node") return nil, err } logger.Logger.WithFields(logrus.Fields{}).Info("LDK node created") if clearMigrateStorageConfigValue { err = cfg.SetUpdate("LdkMigrateStorage", "", "") if err != nil { logger.Logger.WithError(err).Error("Failed to clear LDK migrate storage config value") return nil, err } } ldkEventConsumer := make(chan *ldk_node.Event) ldkCtx, cancel := context.WithCancel(ctx) ldkEventBroadcaster := NewLDKEventBroadcaster(ldkCtx, ldkEventConsumer) nodeId := node.NodeId() ls := LDKService{ workdir: newpath, node: node, cancel: cancel, ldkEventBroadcaster: ldkEventBroadcaster, network: network, eventPublisher: eventPublisher, cfg: cfg, pubkey: nodeId, lsps2Pubkey: lsps2Pubkey, lsps2Address: lsps2Address, ctx: ldkCtx, } eventPublisher.RegisterSubscriber(&ls) // TODO: remove after 2026-01-01 - we now log to app logs rather than ldk log files // this line is just left to cleanup old logs after the update deleteOldLDKLogs(filepath.Join(newpath, "./logs")) // check for and forward new LDK events to LDKEventBroadcaster (through ldkEventConsumer) go func() { for { select { case <-ldkCtx.Done(): return default: } // NextEventAsync parks this goroutine on a Go channel until the next event // arrives - unlike WaitNextEvent it does not block an OS thread in FFI. // NOTE: the call cannot be cancelled; after shutdown it stays parked until // the node emits a final event or the process exits. event := node.NextEventAsync() // eventHandlingMutex is held while handling so Shutdown() can wait // for in-flight event handling to finish before stopping the node. // Events dropped without EventHandled() are redelivered by LDK on // the next startup. ok := func() bool { ls.eventHandlingMutex.Lock() defer ls.eventHandlingMutex.Unlock() if ldkCtx.Err() != nil { return false } ls.handleLdkEvent(&event) select { case ldkEventConsumer <- &event: case <-ldkCtx.Done(): return false } if err := node.EventHandled(); err != nil { logger.Logger.WithError(err).Error("Failed to mark LDK event as handled") } return true }() if !ok { return } } }() logger.Logger.WithFields(logrus.Fields{ "nodeId": nodeId, }).Info("Starting LDK node...") setStartupState("Starting node...") err = node.Start() if err != nil { logger.Logger.WithError(err).Error("Failed to start LDK node") return nil, err } logger.Logger.WithFields(logrus.Fields{ "nodeId": nodeId, "status": node.Status(), }).Info("Started LDK node. Syncing wallet...") setStartupState("Syncing node...") syncStartTime := time.Now() err = node.SyncWallets() if err != nil { logger.Logger.WithError(err).Error("Failed to sync LDK wallets") ls.eventPublisher.Publish(&events.Event{ Event: "nwc_node_sync_failed", Properties: map[string]interface{}{ "error": err.Error(), "sync_type": "full", "initial_sync": true, "node_type": config.LDKBackendType, }, }) shutdownErr := ls.Shutdown() if shutdownErr != nil { logger.Logger.WithError(shutdownErr).Error("Failed to shutdown LDK node") } return nil, err } ls.lastFullSync = time.Now() ls.lastFeeEstimatesSync = time.Now() logger.Logger.WithFields(logrus.Fields{ "nodeId": nodeId, "status": node.Status(), "duration": math.Ceil(time.Since(syncStartTime).Seconds()), }).Info("LDK node synced successfully") // setup background sync go func() { MIN_SYNC_INTERVAL := 1 * time.Minute MIN_FEE_ESTIMATES_SYNC_INTERVAL := 5 * time.Minute MAX_SYNC_INTERVAL := 1 * time.Hour // NOTE: this could be increased further (possibly to 6 hours) for { ls.syncing = false select { case <-ldkCtx.Done(): return case <-time.After(MIN_SYNC_INTERVAL): ls.syncing = true channels := ls.node.ListChannels() for _, channel := range channels { if channel.Confirmations != nil && channel.ConfirmationsRequired != nil && *channel.Confirmations < *channel.ConfirmationsRequired { logger.Logger.WithField("channel_id", channel.UserChannelId).Debug("Using short sync time while opening channel") ls.lastWalletSyncRequest = time.Now() break } } balances := ls.node.ListBalances() for _, balance := range balances.LightningBalances { switch balanceType := (balance).(type) { case ldk_node.LightningBalanceContentiousClaimable: logger.Logger.WithField("channel_id", balanceType.ChannelId).Debug("Using short sync time while balances are contentious claimable after channel closure") ls.lastWalletSyncRequest = time.Now() } } if time.Since(ls.lastWalletSyncRequest) > MIN_SYNC_INTERVAL && time.Since(ls.lastFullSync) < MAX_SYNC_INTERVAL { if time.Since(ls.lastFeeEstimatesSync) < MIN_FEE_ESTIMATES_SYNC_INTERVAL { logger.Logger.Debug("Skipping updating fee estimates") continue } // only update fee estimates logger.Logger.Debug("Updating fee estimates") err = node.UpdateFeeEstimates() if err != nil { logger.Logger.WithError(err).Error("Failed to update fee estimates") ls.eventPublisher.Publish(&events.Event{ Event: "nwc_node_sync_failed", Properties: map[string]interface{}{ "error": err.Error(), "sync_type": "fee_estimates", "node_type": config.LDKBackendType, }, }) continue } ls.lastFeeEstimatesSync = time.Now() continue } logger.Logger.Debug("Starting full background wallet sync") syncStartTime := time.Now() err = node.SyncWallets() if err != nil { logger.Logger.WithError(err).Error("Failed to sync LDK wallets") ls.eventPublisher.Publish(&events.Event{ Event: "nwc_node_sync_failed", Properties: map[string]interface{}{ "error": err.Error(), "sync_type": "full", "node_type": config.LDKBackendType, }, }) // try again at next MIN_SYNC_INTERVAL continue } ls.redeemedOnchainFundsWithinThisSync = false ls.lastFullSync = time.Now() // fee estimates happens as part of full sync ls.lastFeeEstimatesSync = time.Now() logger.Logger.WithFields(logrus.Fields{ "nodeId": nodeId, "status": node.Status(), "duration": math.Ceil(time.Since(syncStartTime).Seconds()), }).Info("LDK node synced successfully") // delete old payments while node is not syncing ls.deleteOldLDKPayments() } } }() return &ls, nil } var shutdownMutex sync.Mutex func (ls *LDKService) Shutdown() error { shutdownMutex.Lock() defer shutdownMutex.Unlock() if ls.shuttingDown { logger.Logger.Debug("LDK client is already shutting down") return nil } ls.shuttingDown = true ls.eventPublisher.RemoveSubscriber(ls) logger.Logger.Info("shutting down LDK client") logger.Logger.Info("cancelling LDK context") ls.cancel() // wait for in-flight LDK event handling to finish - handleLdkEvent makes // node calls which must not run once the node is stopped and destroyed. // Held until the end of Shutdown; the event loop checks the cancelled // context under this mutex before touching the node. ls.eventHandlingMutex.Lock() defer ls.eventHandlingMutex.Unlock() maxAttempts := 40 for i := 0; ls.syncing; i++ { logger.Logger.WithField("attempt", i).Warn("Waiting for background sync to finish before stopping LDK node...") time.Sleep(1 * time.Second) if i > maxAttempts { logger.Logger.Error("Timed out waiting for background sync to finish before stopping LDK node") break } } logger.Logger.Info("stopping LDK node") shutdownChannel := make(chan error) go func() { shutdownChannel <- ls.node.Stop() }() select { case err := <-shutdownChannel: if err != nil { logger.Logger.WithError(err).Error("Failed to stop LDK node") // do not return error - we still need to destroy the node } else { logger.Logger.Info("LDK stop node succeeded") } case <-time.After(5 * time.Minute): logger.Logger.Error("Timeout shutting down LDK node after 5 minutes") } logger.Logger.Debug("Destroying LDK node object") ls.node.Destroy() logger.Logger.Info("LDK shutdown complete") return nil } func getMaxTotalRoutingFeeLimit(amountMsat uint64) uint64 { return transactions.CalculateFeeReserveMsat(amountMsat) } func (ls *LDKService) MakeOffer(ctx context.Context, description string) (string, error) { offer, err := ls.node.Bolt12Payment().ReceiveVariableAmount(description, nil) if err != nil { logger.Logger.WithError(err).Error("Failed to generate BOLT12 offer") return "", err } logger.Logger.WithField("offer", offer).Info("Generated BOLT12 offer") return offer.String(), nil } func (ls *LDKService) SendPaymentSync(invoice string, amountMsat *uint64) (*lnclient.PayInvoiceResponse, error) { paymentRequest, err := decodepay.Decodepay(invoice) if err != nil { logger.Logger.WithFields(logrus.Fields{ "bolt11": invoice, }).WithError(err).Error("Failed to decode bolt11 invoice") return nil, err } paymentAmountMsat := uint64(paymentRequest.MSatoshi) if amountMsat != nil { paymentAmountMsat = *amountMsat } maxSpendable := ls.getMaxSpendable() if paymentAmountMsat > maxSpendable { ls.eventPublisher.Publish(&events.Event{ Event: "nwc_outgoing_liquidity_required", Properties: map[string]interface{}{ // "amount": amount / 1000, // "max_receivable": maxReceivable, // "num_channels": len(gs.node.ListChannels()), "node_type": config.LDKBackendType, }, }) } paymentStart := time.Now() ldkEventSubscription := ls.ldkEventBroadcaster.Subscribe() defer ls.ldkEventBroadcaster.CancelSubscription(ldkEventSubscription) saturationPower := ls.cfg.GetEnv().LDKMaxChannelSaturationPowerOfHalf maxPathCount := ls.cfg.GetEnv().LDKMaxPathCount maxTotalRoutingFeeMsat := getMaxTotalRoutingFeeLimit(paymentAmountMsat) routeParameters := &ldk_node.RouteParametersConfig{ MaxTotalRoutingFeeMsat: &maxTotalRoutingFeeMsat, MaxChannelSaturationPowerOfHalf: saturationPower, MaxPathCount: maxPathCount, MaxTotalCltvExpiryDelta: 1008, // TODO: remove and use default } invoiceObj, err := ldk_node.Bolt11InvoiceFromStr(invoice) if err != nil { logger.Logger.WithError(err).Error("ldk failed to parse bolt 11 invoice from string") return nil, err } var paymentHash string if amountMsat == nil { paymentHash, err = ls.node.Bolt11Payment().Send(invoiceObj, routeParameters) } else { paymentHash, err = ls.node.Bolt11Payment().SendUsingAmount(invoiceObj, *amountMsat, routeParameters) } if err != nil { logger.Logger.WithError(err).Error("SendPayment failed") return nil, err } feeMsat := uint64(0) preimage := "" for { select { case <-ls.ctx.Done(): return nil, ls.ctx.Err() case ev := <-ldkEventSubscription: switch event := (*ev).(type) { case ldk_node.EventPaymentSuccessful: if event.PaymentHash != paymentHash { continue } logger.Logger.WithFields(logrus.Fields{ "event": event, }).Info("Got payment success event") if event.PaymentPreimage == nil { logger.Logger.WithField("payment_hash", paymentHash).Error("No payment preimage in payment success event") return nil, errors.New("payment preimage not found") } preimage = *event.PaymentPreimage if event.FeePaidMsat != nil { feeMsat = *event.FeePaidMsat } logger.Logger.WithFields(logrus.Fields{ "duration": time.Since(paymentStart).Milliseconds(), "fee": feeMsat, "payment_hash": event.PaymentHash, }).Info("Successful payment") return &lnclient.PayInvoiceResponse{ Preimage: preimage, FeeMsat: feeMsat, }, nil case ldk_node.EventPaymentFailed: if event.PaymentHash != nil && *event.PaymentHash == paymentHash { failureReasonMessage := ls.getPaymentFailReason(&event) logger.Logger.WithFields(logrus.Fields{ "payment_hash": paymentHash, "reason": failureReasonMessage, }).Error("Received payment failed event") return nil, fmt.Errorf("received payment failed event: %s", failureReasonMessage) } } } } } func (ls *LDKService) SendKeysend(amountMsat uint64, destination string, custom_records []lnclient.TLVRecord, preimage string) (*lnclient.PayKeysendResponse, error) { paymentStart := time.Now() customTlvs := []ldk_node.CustomTlvRecord{} for _, customRecord := range custom_records { decodedValue, err := hex.DecodeString(customRecord.Value) if err != nil { return nil, err } customTlvs = append(customTlvs, ldk_node.CustomTlvRecord{ TypeNum: customRecord.Type, Value: decodedValue, }) } ldkEventSubscription := ls.ldkEventBroadcaster.Subscribe() defer ls.ldkEventBroadcaster.CancelSubscription(ldkEventSubscription) saturationPower := ls.cfg.GetEnv().LDKMaxChannelSaturationPowerOfHalf maxPathCount := ls.cfg.GetEnv().LDKMaxPathCount maxTotalRoutingFeeMsat := getMaxTotalRoutingFeeLimit(amountMsat) routeParameters := &ldk_node.RouteParametersConfig{ MaxTotalRoutingFeeMsat: &maxTotalRoutingFeeMsat, MaxChannelSaturationPowerOfHalf: saturationPower, MaxPathCount: maxPathCount, MaxTotalCltvExpiryDelta: 1008, // TODO: remove and use default } paymentHash, err := ls.node.SpontaneousPayment().SendWithPreimageAndCustomTlvs(amountMsat, destination, customTlvs, preimage, routeParameters) if err != nil { logger.Logger.WithError(err).Error("Keysend failed") return nil, err } feeMsat := uint64(0) for { select { case <-ls.ctx.Done(): return nil, ls.ctx.Err() case event := <-ldkEventSubscription: eventPaymentSuccessful, isEventPaymentSuccessfulEvent := (*event).(ldk_node.EventPaymentSuccessful) eventPaymentFailed, isEventPaymentFailedEvent := (*event).(ldk_node.EventPaymentFailed) if isEventPaymentSuccessfulEvent && eventPaymentSuccessful.PaymentHash == paymentHash { logger.Logger.Info("Got payment success event") if eventPaymentSuccessful.FeePaidMsat != nil { feeMsat = *eventPaymentSuccessful.FeePaidMsat } logger.Logger.WithFields(logrus.Fields{ "duration": time.Since(paymentStart).Milliseconds(), "fee": feeMsat, }).Info("Successful keysend payment") return &lnclient.PayKeysendResponse{ FeeMsat: feeMsat, }, nil } if isEventPaymentFailedEvent && eventPaymentFailed.PaymentHash != nil && *eventPaymentFailed.PaymentHash == paymentHash { failureReasonMessage := ls.getPaymentFailReason(&eventPaymentFailed) logger.Logger.WithFields(logrus.Fields{ "payment_hash": paymentHash, "reason": failureReasonMessage, }).Error("Received payment failed event") return nil, fmt.Errorf("payment failed event: %s", failureReasonMessage) } } } } func (ls *LDKService) getMaxReceivable() int64 { var receivable int64 = 0 channels := ls.node.ListChannels() for _, channel := range channels { if channel.IsUsable { receivable += min(int64(channel.InboundCapacityMsat), int64(*channel.InboundHtlcMaximumMsat)) } } 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() for _, channel := range channels { if channel.IsUsable { spendable += min(channel.OutboundCapacityMsat, *channel.CounterpartyOutboundHtlcMaximumMsat) } } return spendable } func (ls *LDKService) MakeInvoice(ctx context.Context, amountMsat int64, description string, descriptionHash string, expirySeconds int64, throughNodePubkey *string) (transaction *lnclient.Transaction, err error) { if expirySeconds < 0 || expirySeconds > int64(maxInvoiceExpiry/time.Second) { return nil, errors.New("invalid invoice expiry") } maxReceivable := ls.getMaxReceivable() 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{}{ // "amount": amount / 1000, // "max_receivable": maxReceivable, // "num_channels": len(gs.node.ListChannels()), "node_type": config.LDKBackendType, }, }) } if expirySeconds == 0 { expirySeconds = lnclient.DEFAULT_INVOICE_EXPIRY } var descriptionType ldk_node.Bolt11InvoiceDescription descriptionType = ldk_node.Bolt11InvoiceDescriptionDirect{ Description: description, } if description == "" && descriptionHash != "" { descriptionType = ldk_node.Bolt11InvoiceDescriptionHash{ Hash: descriptionHash, } } var invoiceObj *ldk_node.Bolt11Invoice if isJitInvoice { // cap the opening fee the LSP may deduct from the incoming payment maxLspFeeLimitMsat := ls.getLsps2MaxTotalOpeningFeeMsat(uint64(amountMsat)) invoiceObj, err = ls.node.Bolt11Payment().ReceiveViaJitChannel( uint64(amountMsat), descriptionType, uint32(expirySeconds), &maxLspFeeLimitMsat, ) } else { invoiceObj, err = ls.node.Bolt11Payment().Receive( uint64(amountMsat), descriptionType, uint32(expirySeconds), ) } if err != nil { logger.Logger.WithError(err).Error("MakeInvoice failed") return nil, err } payment := ls.node.Payment(invoiceObj.PaymentHash()) invoice := invoiceObj.String() paymentRequest, err := decodepay.Decodepay(invoice) if err != nil { logger.Logger.WithFields(logrus.Fields{ "bolt11": invoice, }).WithError(err).Error("Failed to decode bolt11 invoice") return nil, err } 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: preimage, AmountMsat: amountMsat, FeesPaidMsat: estimatedLspFeeMsat, CreatedAt: int64(paymentRequest.CreatedAt), ExpiresAt: &expiresAtUnix, Description: paymentRequest.Description, DescriptionHash: paymentRequest.DescriptionHash, } return transaction, nil } func (ls *LDKService) LookupInvoice(ctx context.Context, paymentHash string) (transaction *lnclient.Transaction, err error) { // this method shouldn't be any more because this LNClient supports notifications return nil, errors.New("this method should not be called") } func (ls *LDKService) ListOnchainTransactions(ctx context.Context) ([]lnclient.OnchainTransaction, error) { transactions := []lnclient.OnchainTransaction{} for _, payment := range ls.node.ListPayments() { onchainPaymentKind, isOnchainPaymentKind := payment.Kind.(ldk_node.PaymentKindOnchain) if !isOnchainPaymentKind { continue } transactionType := "incoming" if payment.Direction == ldk_node.PaymentDirectionOutbound { transactionType = "outgoing" } var amountMsat uint64 if payment.AmountMsat != nil { amountMsat = *payment.AmountMsat } var status string var height uint32 var numConfirmations uint32 switch onchainPaymentStatus := onchainPaymentKind.Status.(type) { case ldk_node.ConfirmationStatusConfirmed: status = "confirmed" height = onchainPaymentStatus.Height nodeStatus := ls.node.Status() numConfirmations = nodeStatus.CurrentBestBlock.Height - height case ldk_node.ConfirmationStatusUnconfirmed: status = "unconfirmed" } createdAt := payment.CreatedAt if createdAt == 0 { createdAt = payment.LatestUpdateTimestamp } transactions = append(transactions, lnclient.OnchainTransaction{ AmountSat: amountMsat / 1000, CreatedAt: createdAt, State: status, Type: transactionType, NumConfirmations: numConfirmations, TxId: onchainPaymentKind.Txid, }) } sort.SliceStable(transactions, func(i, j int) bool { return transactions[i].CreatedAt > transactions[j].CreatedAt }) return transactions, nil } func (ls *LDKService) GetInfo(ctx context.Context) (info *lnclient.NodeInfo, err error) { // TODO: should alias, color be configured in LDK-node? or can we manage them in NWC? // an alias is only needed if the user has public channels and wants their node to be publicly visible? status := ls.node.Status() return &lnclient.NodeInfo{ Alias: "NWC", Color: "#897FFF", Pubkey: ls.node.NodeId(), Network: ls.network, BlockHeight: status.CurrentBestBlock.Height, BlockHash: status.CurrentBestBlock.BlockHash, }, nil } func (ls *LDKService) ListChannels(ctx context.Context) ([]lnclient.Channel, error) { ldkChannels := ls.node.ListChannels() channels := []lnclient.Channel{} // logger.Logger.WithFields(logrus.Fields{ // "channels": ldkChannels, // }).Debug("Listed Channels") for _, ldkChannel := range ldkChannels { fundingTxId := "" fundingTxVout := uint32(0) if ldkChannel.FundingTxo != nil { fundingTxId = ldkChannel.FundingTxo.Txid fundingTxVout = ldkChannel.FundingTxo.Vout } internalChannel := map[string]interface{}{} internalChannel["channel"] = ldkChannel internalChannel["config"] = map[string]interface{}{ "AcceptUnderpayingHtlcs": ldkChannel.Config.AcceptUnderpayingHtlcs, "CltvExpiryDelta": ldkChannel.Config.CltvExpiryDelta, "ForceCloseAvoidanceMaxFeeSatoshis": ldkChannel.Config.ForceCloseAvoidanceMaxFeeSatoshis, "ForwardingFeeBaseMsat": ldkChannel.Config.ForwardingFeeBaseMsat, "ForwardingFeeProportionalMillionths": ldkChannel.Config.ForwardingFeeProportionalMillionths, "MaxDustHtlcExposure": ldkChannel.Config.MaxDustHtlcExposure, } unspendablePunishmentReserveSat := uint64(0) if ldkChannel.UnspendablePunishmentReserve != nil { unspendablePunishmentReserveSat = *ldkChannel.UnspendablePunishmentReserve } var channelError *string if fundingTxId == "" { channelErrorValue := "This channel has no funding transaction. Please contact support@getalby.com" channelError = &channelErrorValue } else if ldkChannel.IsUsable && ldkChannel.CounterpartyForwardingInfoFeeBaseMsat == nil { // if we don't have this, routing will not work (LND <-> LDK interoperability bug - https://github.com/lightningnetwork/lnd/issues/6870 ) channelErrorValue := "Counterparty forwarding info is not yet available, but normally resolves automatically. Try restarting Alby Hub if this warning does not resolve within a few hours." channelError = &channelErrorValue } isActive := ldkChannel.IsUsable /* superset of ldkChannel.IsReady */ && channelError == nil // Public channels require 6 confirmations before they can be gossiped/announced // (BOLT-7), and they only become usable once announced. However, LDK accepts // channels from trusted LSP peers as 0-conf, so it reports ConfirmationsRequired // as nil/0. Override to 6 for public channels so the UI shows confirmation // progress while opening instead of an indefinite blank loading spinner. confirmationsRequired := ldkChannel.ConfirmationsRequired if ldkChannel.IsAnnounced { publicChannelConfirmationsRequired := uint32(6) if confirmationsRequired == nil || *confirmationsRequired < publicChannelConfirmationsRequired { confirmationsRequired = &publicChannelConfirmationsRequired } } channels = append(channels, lnclient.Channel{ InternalChannel: internalChannel, LocalBalanceMsat: int64(ldkChannel.ChannelValueSats*1000 - ldkChannel.InboundCapacityMsat - ldkChannel.CounterpartyUnspendablePunishmentReserve*1000), LocalSpendableBalanceMsat: int64(ldkChannel.OutboundCapacityMsat), RemoteBalanceMsat: int64(ldkChannel.InboundCapacityMsat), RemotePubkey: ldkChannel.CounterpartyNodeId, Id: ldkChannel.UserChannelId, // CloseChannel takes the UserChannelId Active: isActive, Public: ldkChannel.IsAnnounced, FundingTxId: fundingTxId, FundingTxVout: fundingTxVout, Confirmations: ldkChannel.Confirmations, ConfirmationsRequired: confirmationsRequired, ForwardingFeeBaseMsat: ldkChannel.Config.ForwardingFeeBaseMsat, ForwardingFeeProportionalMillionths: ldkChannel.Config.ForwardingFeeProportionalMillionths, UnspendablePunishmentReserveSat: unspendablePunishmentReserveSat, CounterpartyUnspendablePunishmentReserveSat: ldkChannel.CounterpartyUnspendablePunishmentReserve, Error: channelError, IsOutbound: ldkChannel.IsOutbound, }) } return channels, nil } func (ls *LDKService) GetNodeConnectionInfo(ctx context.Context) (nodeConnectionInfo *lnclient.NodeConnectionInfo, err error) { nodeConnectionInfo = &lnclient.NodeConnectionInfo{ Pubkey: ls.node.NodeId(), } if ls.cfg.GetEnv().LDKAnnouncementAddresses != "" { addresses := strings.Split(ls.cfg.GetEnv().LDKAnnouncementAddresses, ",") for _, address := range addresses { address = strings.TrimSpace(address) if address == "" { continue } var ip string var portStr string if strings.HasPrefix(address, "[") { // IPv6 format: [ipv6]:port closeBracket := strings.Index(address, "]") if closeBracket > 0 { ip = address[0 : closeBracket+1] if closeBracket+2 < len(address) && address[closeBracket+1] == ':' { portStr = address[closeBracket+2:] } } } else { // IPv4 or hostname format: ip:port parts := strings.Split(address, ":") if len(parts) >= 2 { portStr = parts[len(parts)-1] ip = strings.Join(parts[:len(parts)-1], ":") } } if portStr != "" { if port, parseErr := strconv.Atoi(portStr); parseErr == nil && ip != "" { nodeConnectionInfo.Address = ip nodeConnectionInfo.Port = port break } } } } return nodeConnectionInfo, nil } func (ls *LDKService) ConnectPeer(ctx context.Context, connectPeerRequest *lnclient.ConnectPeerRequest) error { peers := ls.node.ListPeers() var foundPeer *ldk_node.PeerDetails for _, peer := range peers { if peer.NodeId == connectPeerRequest.Pubkey { foundPeer = &peer break } } if foundPeer != nil && !strings.Contains(foundPeer.Address, connectPeerRequest.Address) { logger.Logger.WithFields(logrus.Fields{ "existing_address": foundPeer.Address, "new_address": connectPeerRequest.Address, }).Warn("peer address changed, disconnecting first") // disconnect first to ensure new IP address is saved in case of re-connecting err := ls.node.Disconnect(connectPeerRequest.Pubkey) if err != nil { // non-critical: only log an error logger.Logger.WithField("request", connectPeerRequest).WithError(err).Error("Disconnect failed while connecting peer") } } err := ls.node.Connect(connectPeerRequest.Pubkey, connectPeerRequest.Address+":"+strconv.Itoa(int(connectPeerRequest.Port)), true) if err != nil { logger.Logger.WithField("request", connectPeerRequest).WithError(err).Error("ConnectPeer failed") return err } return nil } func (ls *LDKService) OpenChannel(ctx context.Context, openChannelRequest *lnclient.OpenChannelRequest) (*lnclient.OpenChannelResponse, error) { peers := ls.node.ListPeers() var foundPeer *ldk_node.PeerDetails for _, peer := range peers { if peer.NodeId == openChannelRequest.Pubkey { foundPeer = &peer break } } if foundPeer == nil { return nil, errors.New("node is not peered yet") } ldkEventSubscription := ls.ldkEventBroadcaster.Subscribe() defer ls.ldkEventBroadcaster.CancelSubscription(ldkEventSubscription) logger.Logger.WithField("peer_id", foundPeer.NodeId).Info("Opening channel") var userChannelId string var err error if openChannelRequest.Public { userChannelId, err = ls.node.OpenAnnouncedChannel(foundPeer.NodeId, foundPeer.Address, uint64(openChannelRequest.AmountSats), nil, nil) } else { userChannelId, err = ls.node.OpenChannel(foundPeer.NodeId, foundPeer.Address, uint64(openChannelRequest.AmountSats), nil, nil) } if err != nil { logger.Logger.WithError(err).Error("OpenChannel failed") return nil, err } // userChannelId allows to locally keep track of the channel (and is also used to close the channel) logger.Logger.WithFields(logrus.Fields{ "peer_id": foundPeer.NodeId, "channel_id": userChannelId, }).Info("Funded channel") for start := time.Now(); time.Since(start) < time.Second*60; { event := <-ldkEventSubscription channelPendingEvent, isChannelPendingEvent := (*event).(ldk_node.EventChannelPending) channelClosedEvent, isChannelClosedEvent := (*event).(ldk_node.EventChannelClosed) if isChannelClosedEvent { closureReason := ls.getChannelCloseReason(&channelClosedEvent) logger.Logger.WithFields(logrus.Fields{ "event": channelClosedEvent, "reason": closureReason, }).Info("Failed to open channel") return nil, fmt.Errorf("failed to open channel with %s: %s", foundPeer.NodeId, closureReason) } if !isChannelPendingEvent { continue } return &lnclient.OpenChannelResponse{ FundingTxId: channelPendingEvent.FundingTxo.Txid, }, nil } return nil, errors.New("open channel timeout") } func (ls *LDKService) UpdateChannel(ctx context.Context, updateChannelRequest *lnclient.UpdateChannelRequest) error { channels := ls.node.ListChannels() var foundChannel *ldk_node.ChannelDetails for _, channel := range channels { if channel.UserChannelId == updateChannelRequest.ChannelId && channel.CounterpartyNodeId == updateChannelRequest.NodeId { foundChannel = &channel break } } if foundChannel == nil { logger.Logger.WithField("request", updateChannelRequest).Error("failed to find channel to update") return errors.New("channel not found") } existingConfig := foundChannel.Config existingConfig.ForwardingFeeBaseMsat = updateChannelRequest.ForwardingFeeBaseMsat existingConfig.ForwardingFeeProportionalMillionths = updateChannelRequest.ForwardingFeeProportionalMillionths if updateChannelRequest.MaxDustHtlcExposureFromFeeRateMultiplier > 0 { existingConfig.MaxDustHtlcExposure = ldk_node.MaxDustHtlcExposureFeeRateMultiplier{ Multiplier: updateChannelRequest.MaxDustHtlcExposureFromFeeRateMultiplier, } } err := ls.node.UpdateChannelConfig(updateChannelRequest.ChannelId, updateChannelRequest.NodeId, existingConfig) if err != nil { logger.Logger.WithError(err).Error("UpdateChannelConfig failed") return err } return nil } func (ls *LDKService) CloseChannel(ctx context.Context, closeChannelRequest *lnclient.CloseChannelRequest) error { logger.Logger.WithFields(logrus.Fields{ "request": closeChannelRequest, }).Info("Closing Channel") var err error if closeChannelRequest.Force { err = ls.node.ForceCloseChannel(closeChannelRequest.ChannelId, closeChannelRequest.NodeId, nil) } else { err = ls.node.CloseChannel(closeChannelRequest.ChannelId, closeChannelRequest.NodeId) } if err != nil { logger.Logger.WithError(err).Error("CloseChannel failed") return err } return nil } func (ls *LDKService) GetNewOnchainAddress(ctx context.Context) (string, error) { address, err := ls.node.OnchainPayment().NewAddress() if err != nil { logger.Logger.WithError(err).Error("NewOnchainAddress failed") return "", err } return address, nil } func (ls *LDKService) GetOnchainBalance(ctx context.Context) (*lnclient.OnchainBalanceResponse, error) { nodeStatus := ls.node.Status() channels := ls.node.ListChannels() balances := ls.node.ListBalances() logger.Logger.WithFields(logrus.Fields{ "balances": balances, }).Debug("Listed Balances") type internalLightningBalance struct { BalanceType string Balance ldk_node.LightningBalance } internalLightningBalances := []internalLightningBalance{} pendingBalancesDetails := make([]lnclient.PendingBalanceDetails, 0) pendingBalancesFromChannelClosuresSat := uint64(0) // increase pending balance from any lightning balances for channels that are pending closure // (they do not exist in our list of open channels) for _, balance := range balances.LightningBalances { increasePendingBalance := func(nodeId, channelId string, amountSat uint64, fundingTxId ldk_node.Txid, fundingTxIndex uint16) { if !slices.ContainsFunc(channels, func(channel ldk_node.ChannelDetails) bool { return channel.ChannelId == channelId }) { pendingBalancesFromChannelClosuresSat += amountSat pendingBalancesDetails = append(pendingBalancesDetails, lnclient.PendingBalanceDetails{ NodeId: nodeId, ChannelId: channelId, AmountSat: amountSat, FundingTxId: fundingTxId, FundingTxVout: uint32(fundingTxIndex), }) } } // include the balance type as it's useful to know the state of the channel internalLightningBalances = append(internalLightningBalances, internalLightningBalance{ BalanceType: fmt.Sprintf("%T", balance), Balance: balance, }) switch balanceType := (balance).(type) { case ldk_node.LightningBalanceClaimableOnChannelClose: increasePendingBalance(balanceType.CounterpartyNodeId, balanceType.ChannelId, balanceType.AmountSatoshis, balanceType.FundingTxId, balanceType.FundingTxIndex) case ldk_node.LightningBalanceClaimableAwaitingConfirmations: increasePendingBalance(balanceType.CounterpartyNodeId, balanceType.ChannelId, balanceType.AmountSatoshis, balanceType.FundingTxId, balanceType.FundingTxIndex) case ldk_node.LightningBalanceContentiousClaimable: increasePendingBalance(balanceType.CounterpartyNodeId, balanceType.ChannelId, balanceType.AmountSatoshis, balanceType.FundingTxId, balanceType.FundingTxIndex) case ldk_node.LightningBalanceMaybeTimeoutClaimableHtlc: increasePendingBalance(balanceType.CounterpartyNodeId, balanceType.ChannelId, balanceType.AmountSatoshis, balanceType.FundingTxId, balanceType.FundingTxIndex) case ldk_node.LightningBalanceMaybePreimageClaimableHtlc: increasePendingBalance(balanceType.CounterpartyNodeId, balanceType.ChannelId, balanceType.AmountSatoshis, balanceType.FundingTxId, balanceType.FundingTxIndex) case ldk_node.LightningBalanceCounterpartyRevokedOutputClaimable: increasePendingBalance(balanceType.CounterpartyNodeId, balanceType.ChannelId, balanceType.AmountSatoshis, balanceType.FundingTxId, balanceType.FundingTxIndex) } } pendingSweepBalanceDetails := make([]lnclient.PendingBalanceDetails, 0) increasePendingBalanceFromClosure := func(nodeId, channelId *string, amountSat uint64, fundingTxId *ldk_node.Txid, fundingTxIndex *uint16) { pendingBalancesFromChannelClosuresSat += amountSat if nodeId != nil && channelId != nil && fundingTxId != nil && fundingTxIndex != nil { pendingSweepBalanceDetails = append(pendingSweepBalanceDetails, lnclient.PendingBalanceDetails{ NodeId: *nodeId, ChannelId: *channelId, AmountSat: amountSat, FundingTxId: *fundingTxId, FundingTxVout: uint32(*fundingTxIndex), }) } } // increase pending balance from any lightning balances for channels that were closed for _, balance := range balances.PendingBalancesFromChannelClosures { switch pendingType := (balance).(type) { case ldk_node.PendingSweepBalancePendingBroadcast: increasePendingBalanceFromClosure(pendingType.CounterpartyNodeId, pendingType.ChannelId, pendingType.AmountSatoshis, pendingType.FundingTxId, pendingType.FundingTxIndex) case ldk_node.PendingSweepBalanceBroadcastAwaitingConfirmation: increasePendingBalanceFromClosure(pendingType.CounterpartyNodeId, pendingType.ChannelId, pendingType.AmountSatoshis, pendingType.FundingTxId, pendingType.FundingTxIndex) case ldk_node.PendingSweepBalanceAwaitingThresholdConfirmations: if nodeStatus.CurrentBestBlock.Height < pendingType.ConfirmationHeight+6 { // LDK now keeps the balance in this state for four weeks even after the funds are confirmed to be swept // to confirm the channel monitors are archived before the sweeper entries are dropped // so now we just check for 6 confirmations increasePendingBalanceFromClosure(pendingType.CounterpartyNodeId, pendingType.ChannelId, pendingType.AmountSatoshis, pendingType.FundingTxId, pendingType.FundingTxIndex) } } } return &lnclient.OnchainBalanceResponse{ SpendableSat: int64(balances.SpendableOnchainBalanceSats), TotalSat: int64(balances.TotalOnchainBalanceSats - balances.TotalAnchorChannelsReserveSats), ReservedSat: int64(balances.TotalAnchorChannelsReserveSats), PendingBalancesFromChannelClosuresSat: pendingBalancesFromChannelClosuresSat, PendingBalancesDetails: pendingBalancesDetails, PendingSweepBalancesDetails: pendingSweepBalanceDetails, InternalBalances: map[string]interface{}{ "internal_lightning_balances": internalLightningBalances, "all_balances": balances, }, }, nil } func (ls *LDKService) RedeemOnchainFunds(ctx context.Context, toAddress string, amountSat uint64, feeRate *uint64, sendAll bool) (string, error) { if ls.redeemedOnchainFundsWithinThisSync { return "", errors.New("please wait a minute for the wallet to sync before doing another on-chain payment") } var feePtr **ldk_node.FeeRate if feeRate != nil { fee := ldk_node.FeeRateFromSatPerVbUnchecked(*feeRate) feePtr = &fee } var txId string var err error if !sendAll { // NOTE: this may fail if user does not reserve enough for the onchain transaction // and can also drain the anchor reserves if the user provides a too high amount. txId, err = ls.node.OnchainPayment().SendToAddress(toAddress, amountSat, feePtr) } else { txId, err = ls.node.OnchainPayment().SendAllToAddress(toAddress, false, feePtr) } if err != nil { logger.Logger.WithField("send_all", sendAll).WithError(err).Error("LDK onchain payment to redeem funds failed") return "", err } // make sure we do a sync after sending on-chain funds ls.redeemedOnchainFundsWithinThisSync = true ls.lastWalletSyncRequest = time.Now() // FIXME: remove once LDK-node returns an error if it can't broadcast the transaction tryCheckTransactionWasBroadcasted := func() error { url := ls.cfg.GetEnv().MempoolApi + "/tx/" + txId client := http.Client{ Timeout: time.Second * 10, } req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { logger.Logger.WithError(err).WithFields(logrus.Fields{ "url": url, }).Error("Failed to create http request") return err } res, err := client.Do(req) if err != nil { logger.Logger.WithError(err).WithFields(logrus.Fields{ "url": url, }).Error("Failed to send request") return err } if res.StatusCode >= 300 { // transaction not found return errors.New("unexpected status code") } return nil } for attempt := 1; attempt < 30; attempt++ { err := tryCheckTransactionWasBroadcasted() if err != nil { logger.Logger.WithError(err).WithField("attempt", attempt).Error("Failed to fetch broadcasted transaction") time.Sleep(1 * time.Second) continue } return txId, nil } return "", errors.New("ran out of attempts to fetch broadcasted transaction") } func (ls *LDKService) ResetRouter(key string) error { err := ls.cfg.SetUpdate(resetRouterKey, key, "") if err != nil { logger.Logger.WithError(err).Error("Failed to set reset router key") return err } return nil } func (ls *LDKService) SignMessage(ctx context.Context, message string) (string, error) { signedMessage := ls.node.SignMessage([]byte(message)) return signedMessage, nil } func (ls *LDKService) ldkPaymentToTransaction(payment *ldk_node.PaymentDetails) (*lnclient.Transaction, error) { // logger.Logger.WithField("payment", payment).Debug("Mapping LDK payment to transaction") transactionType := "incoming" if payment.Direction == ldk_node.PaymentDirectionOutbound { transactionType = "outgoing" } var expiresAt *int64 var createdAt int64 var description string var descriptionHash string var bolt11Invoice string var settledAt *int64 preimage := "" paymentHash := "" metadata := map[string]interface{}{} bolt11PaymentKind, isBolt11PaymentKind := payment.Kind.(ldk_node.PaymentKindBolt11) if isBolt11PaymentKind && bolt11PaymentKind.Bolt11Invoice != nil { bolt11Invoice = *bolt11PaymentKind.Bolt11Invoice paymentRequest, err := decodepay.Decodepay(strings.ToLower(bolt11Invoice)) if err != nil { logger.Logger.WithFields(logrus.Fields{ "bolt11": bolt11Invoice, }).WithError(err).Error("Failed to decode bolt11 invoice") return nil, err } createdAt = int64(paymentRequest.CreatedAt) expiresAtUnix := time.UnixMilli(int64(paymentRequest.CreatedAt) * 1000).Add(time.Duration(paymentRequest.Expiry) * time.Second).Unix() expiresAt = &expiresAtUnix description = paymentRequest.Description descriptionHash = paymentRequest.DescriptionHash if payment.Status == ldk_node.PaymentStatusSucceeded { if bolt11PaymentKind.Preimage != nil { preimage = *bolt11PaymentKind.Preimage } settledAt = &createdAt // fallback settledAt to created at time if payment.LatestUpdateTimestamp > 0 { lastUpdate := int64(payment.LatestUpdateTimestamp) settledAt = &lastUpdate } } 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 { createdAt = int64(payment.CreatedAt) if bolt12PaymentKind.Hash == nil { return nil, errors.New("BOLT-12 payment has no payment hash") } paymentHash = *bolt12PaymentKind.Hash offer := map[string]interface{}{} offer["id"] = bolt12PaymentKind.OfferId if bolt12PaymentKind.PayerNote != nil { offer["payer_note"] = *bolt12PaymentKind.PayerNote } metadata["offer"] = offer if payment.Status == ldk_node.PaymentStatusSucceeded { if bolt12PaymentKind.Preimage != nil { preimage = *bolt12PaymentKind.Preimage } lastUpdate := int64(payment.LatestUpdateTimestamp) settledAt = &lastUpdate } } spontaneousPaymentKind, isSpontaneousPaymentKind := payment.Kind.(ldk_node.PaymentKindSpontaneous) if isSpontaneousPaymentKind { // keysend payment lastUpdate := int64(payment.LatestUpdateTimestamp) createdAt = int64(payment.CreatedAt) // TODO: remove this check some point in the future // all payments after v0.6.2 will have createdAt set if createdAt == 0 { createdAt = lastUpdate } if payment.Status == ldk_node.PaymentStatusSucceeded { settledAt = &lastUpdate } paymentHash = spontaneousPaymentKind.Hash if spontaneousPaymentKind.Preimage != nil { preimage = *spontaneousPaymentKind.Preimage } tlvRecords := []lnclient.TLVRecord{} for _, tlv := range spontaneousPaymentKind.CustomTlvs { tlvRecords = append(tlvRecords, lnclient.TLVRecord{ Type: tlv.Type, Value: hex.EncodeToString(tlv.Value), }) } metadata["tlv_records"] = tlvRecords } var amountMsat uint64 = 0 if payment.AmountMsat != nil { amountMsat = *payment.AmountMsat } var feeMsat uint64 = 0 if payment.FeePaidMsat != nil { feeMsat = *payment.FeePaidMsat } if isBolt11JitPaymentKind && bolt11JitPaymentKind.CounterpartySkimmedFeeMsat != nil { feeMsat = *bolt11JitPaymentKind.CounterpartySkimmedFeeMsat } return &lnclient.Transaction{ Type: transactionType, Preimage: preimage, PaymentHash: paymentHash, SettledAt: settledAt, AmountMsat: int64(amountMsat), Invoice: bolt11Invoice, FeesPaidMsat: int64(feeMsat), CreatedAt: createdAt, Description: description, DescriptionHash: descriptionHash, ExpiresAt: expiresAt, Metadata: metadata, }, nil } func (ls *LDKService) ListPeers(ctx context.Context) ([]lnclient.PeerDetails, error) { peers := ls.node.ListPeers() ret := make([]lnclient.PeerDetails, 0, len(peers)) for _, peer := range peers { ret = append(ret, lnclient.PeerDetails{ NodeId: peer.NodeId, Address: peer.Address, IsPersisted: peer.IsPersisted, IsConnected: peer.IsConnected, }) } return ret, nil } func (ls *LDKService) GetNetworkGraph(ctx context.Context, nodeIds []string) (lnclient.NetworkGraphResponse, error) { graph := ls.node.NetworkGraph() type NodeInfoWithId struct { Node *ldk_node.NodeInfo `json:"node"` NodeId string `json:"nodeId"` } nodes := []NodeInfoWithId{} channels := []*ldk_node.ChannelInfo{} for _, nodeId := range nodeIds { _, err := hex.DecodeString(nodeId) if err != nil { return nil, err } if len(nodeId) != 66 { return nil, errors.New("unexpected node ID length") } graphNode := graph.Node(nodeId) if graphNode != nil { nodes = append(nodes, NodeInfoWithId{ Node: graphNode, NodeId: nodeId, }) if graphNode.Channels != nil { for _, channelId := range graphNode.Channels { graphChannel := graph.Channel(channelId) if graphChannel != nil { channels = append(channels, graphChannel) } } } } } networkGraph := map[string]interface{}{ "nodes": nodes, "channels": channels, } return networkGraph, nil } func (ls *LDKService) GetLogOutput(ctx context.Context, maxLen int) ([]byte, error) { return []byte("Node logs are now included in application logs"), nil } func (ls *LDKService) handleLdkEvent(event *ldk_node.Event) { logger.Logger.WithFields(logrus.Fields{ "event": event, }).Info("Received LDK event") switch eventType := (*event).(type) { case ldk_node.EventChannelReady: channels := ls.node.ListChannels() channelIndex := slices.IndexFunc(channels, func(c ldk_node.ChannelDetails) bool { return c.ChannelId == eventType.ChannelId }) if channelIndex == -1 { logger.Logger.WithField("event", eventType).Error("Failed to find channel by ID") return } 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, }, }) ls.backupChannels() if eventType.CounterpartyNodeId == nil { logger.Logger.WithField("event", eventType).Error("channel ready event has no counterparty node ID") return } maxDustHtlcExposureFromFeeRateMultiplier := uint64(0) if isTrusted { // avoid closures like "ProcessingError: Peer sent update_fee with a feerate (62500) // which may over-expose us to dust-in-flight on our counterparty's transactions (totaling 69348000 msat)" maxDustHtlcExposureFromFeeRateMultiplier = 100_000 // default * 10 } // set a super-high forwarding fee of 100K sats by default to disable unwanted routing by default forwardingFeeBaseMsat := uint32(100_000_000) err := ls.UpdateChannel(context.Background(), &lnclient.UpdateChannelRequest{ ChannelId: eventType.UserChannelId, NodeId: *eventType.CounterpartyNodeId, MaxDustHtlcExposureFromFeeRateMultiplier: maxDustHtlcExposureFromFeeRateMultiplier, ForwardingFeeBaseMsat: forwardingFeeBaseMsat, }) if err != nil { logger.Logger.WithField("event", eventType).Error("channel ready event has no counterparty node ID") return } case ldk_node.EventChannelClosed: // make sure we do a sync after receiving a channel closed event ls.lastWalletSyncRequest = time.Now() closureReason := ls.getChannelCloseReason(&eventType) logger.Logger.WithFields(logrus.Fields{ "event": event, "reason": closureReason, }).Info("Channel closed") onchainBalance, err := ls.GetOnchainBalance(context.Background()) if err != nil { logger.Logger.WithError(err).Error("failed to retrieve on-chain balance when closing channel") } var pendingBalance uint64 var fundingTxId string var fundingTxVout uint32 var fundingTxUrl string if onchainBalance != nil { logger.Logger.WithField("onchain_balance", onchainBalance).Info("got on-chain balance when closing channel") for _, details := range onchainBalance.PendingBalancesDetails { if details.ChannelId == eventType.ChannelId { fundingTxId = details.FundingTxId fundingTxVout = details.FundingTxVout fundingTxUrl = fmt.Sprintf("https://mempool.space/tx/%s#flow=&vout=%d", fundingTxId, fundingTxVout) pendingBalance += details.AmountSat } } for _, details := range onchainBalance.PendingSweepBalancesDetails { if details.ChannelId == eventType.ChannelId { fundingTxId = details.FundingTxId fundingTxVout = details.FundingTxVout fundingTxUrl = fmt.Sprintf("https://mempool.space/tx/%s#flow=&vout=%d", fundingTxId, fundingTxVout) pendingBalance += details.AmountSat } } } var counterpartyNodeId string var counterpartyNodeUrl string if eventType.CounterpartyNodeId != nil { counterpartyNodeId = *eventType.CounterpartyNodeId counterpartyNodeUrl = "https://amboss.space/node/" + counterpartyNodeId } ls.eventPublisher.Publish(&events.Event{ Event: "nwc_channel_closed", Properties: map[string]interface{}{ "counterparty_node_id": counterpartyNodeId, "counterparty_node_url": counterpartyNodeUrl, "reason": closureReason, "node_type": config.LDKBackendType, "pending_balance": pendingBalance, "funding_tx_id": fundingTxId, "funding_tx_vout": fundingTxVout, "funding_tx_url": fundingTxUrl, }, }) case ldk_node.EventPaymentReceived: if eventType.PaymentId == nil { logger.Logger.WithField("payment_hash", eventType.PaymentHash).Error("payment received event has no payment ID") return } payment := ls.node.Payment(*eventType.PaymentId) if payment == nil { logger.Logger.WithField("payment_id", *eventType.PaymentId).Error("could not find LDK payment") return } transaction, err := ls.ldkPaymentToTransaction(payment) if err != nil { logger.Logger.WithField("payment_id", *eventType.PaymentId).Error("failed to convert LDK payment to transaction") return } ls.eventPublisher.Publish(&events.Event{ Event: "nwc_lnclient_payment_received", Properties: transaction, }) case ldk_node.EventPaymentSuccessful: if eventType.PaymentId == nil { logger.Logger.WithField("payment_hash", eventType.PaymentHash).Error("payment received event has no payment ID") return } payment := ls.node.Payment(*eventType.PaymentId) if payment == nil { logger.Logger.WithField("payment_id", *eventType.PaymentId).Error("could not find LDK payment") return } transaction, err := ls.ldkPaymentToTransaction(payment) if err != nil { logger.Logger.WithField("payment_id", *eventType.PaymentId).Error("failed to convert LDK payment to transaction") return } ls.eventPublisher.Publish(&events.Event{ Event: "nwc_lnclient_payment_sent", Properties: transaction, }) case ldk_node.EventPaymentFailed: if eventType.PaymentId == nil { logger.Logger.WithField("payment_hash", eventType.PaymentHash).Error("payment failed event has no payment ID") return } payment := ls.node.Payment(*eventType.PaymentId) if payment == nil { logger.Logger.WithField("payment_id", *eventType.PaymentId).Error("could not find LDK payment") return } transaction, err := ls.ldkPaymentToTransaction(payment) if err != nil { logger.Logger.WithField("payment_id", *eventType.PaymentId).Error("failed to convert LDK payment to transaction") return } reason := ls.getPaymentFailReason(&eventType) ls.eventPublisher.Publish(&events.Event{ Event: "nwc_lnclient_payment_failed", Properties: &lnclient.PaymentFailedEventProperties{ Transaction: transaction, Reason: reason, }, }) case ldk_node.EventPaymentForwarded: logger.Logger.WithFields(logrus.Fields{ "total_fee_earned_msat": eventType.TotalFeeEarnedMsat, "outbound_amount_forwarded_msat": eventType.OutboundAmountForwardedMsat, }).Info("LDK Payment forwarded") if eventType.TotalFeeEarnedMsat == nil || eventType.OutboundAmountForwardedMsat == nil { logger.Logger.WithFields(logrus.Fields{ "earned_msat": eventType.TotalFeeEarnedMsat, "outbound_amount_forwarded_msat": eventType.OutboundAmountForwardedMsat, }).Error("forwarded payment has missing required fields") return } ls.eventPublisher.Publish(&events.Event{ Event: "nwc_payment_forwarded", Properties: &lnclient.PaymentForwardedEventProperties{ TotalFeeEarnedMsat: *eventType.TotalFeeEarnedMsat, OutboundAmountForwardedMsat: *eventType.OutboundAmountForwardedMsat, }, }) case ldk_node.EventPaymentClaimable: if eventType.ClaimDeadline == nil { logger.Logger.WithField("payment_id", eventType.PaymentId).Error("claimable payment has no claim deadline") return } logger.Logger.WithFields(logrus.Fields{ "claimable_amount_msats": eventType.ClaimableAmountMsat, "payment_hash": eventType.PaymentHash, "claim_deadline": *eventType.ClaimDeadline, }).Info("LDK Payment Claimable") payment := ls.node.Payment(eventType.PaymentId) if payment == nil { logger.Logger.WithField("payment_id", eventType.PaymentId).Error("could not find LDK payment") return } transaction, err := ls.ldkPaymentToTransaction(payment) if err != nil { logger.Logger.WithField("payment_id", eventType.PaymentId).Error("failed to convert LDK payment to transaction") return } transaction.SettleDeadline = eventType.ClaimDeadline ls.eventPublisher.Publish(&events.Event{ Event: "nwc_lnclient_hold_invoice_accepted", Properties: transaction, }) } } func (ls *LDKService) backupChannels() { ldkChannels := ls.node.ListChannels() ldkPeers := ls.node.ListPeers() channels := make([]events.ChannelBackup, 0, len(ldkChannels)) for _, ldkChannel := range ldkChannels { var fundingTxId string var fundingTxVout uint32 if ldkChannel.FundingTxo != nil { fundingTxId = ldkChannel.FundingTxo.Txid fundingTxVout = ldkChannel.FundingTxo.Vout } var peer *ldk_node.PeerDetails for _, matchingPeer := range ldkPeers { if matchingPeer.NodeId == ldkChannel.CounterpartyNodeId { peer = &matchingPeer } } if peer == nil { logger.Logger.WithField("peer_id", ldkChannel.CounterpartyNodeId).Error("failed to find peer for channel") continue } channels = append(channels, events.ChannelBackup{ ChannelID: ldkChannel.ChannelId, PeerID: ldkChannel.CounterpartyNodeId, PeerSocketAddress: peer.Address, ChannelSize: ldkChannel.ChannelValueSats, FundingTxID: fundingTxId, FundingTxVout: fundingTxVout, }) } monitors, err := ls.node.GetEncodedChannelMonitors() if err != nil { logger.Logger.WithError(err).Error("Failed to list channel monitors") return } encodedMonitors := []events.EncodedChannelMonitorBackup{} for _, monitor := range monitors { encodedMonitors = append(encodedMonitors, events.EncodedChannelMonitorBackup{ Key: monitor.Key, Value: hex.EncodeToString(monitor.Value), }) } event := &events.StaticChannelsBackupEvent{ Channels: channels, Monitors: encodedMonitors, NodeID: ls.node.NodeId(), } ls.saveStaticChannelBackupToDisk(event) ls.eventPublisher.Publish(&events.Event{ Event: "nwc_backup_channels", Properties: event, }) } func (ls *LDKService) saveStaticChannelBackupToDisk(event *events.StaticChannelsBackupEvent) { backupDirectory := filepath.Join(ls.workdir, "static_channel_backups") err := os.MkdirAll(backupDirectory, os.ModePerm) if err != nil { logger.Logger.WithError(err).Error("Failed to make static channel backup directory") return } backupFilePath := filepath.Join(backupDirectory, time.Now().Format("2006-01-02T15-04-05")+".json") eventBytes, err := json.Marshal(event) if err != nil { logger.Logger.WithError(err).Error("Failed to serialize static channel backup to json") return } err = os.WriteFile(backupFilePath, eventBytes, 0644) if err != nil { logger.Logger.WithError(err).Error("Failed to write static channel backup to disk") return } logger.Logger.WithField("backupPath", backupFilePath).Debug("Saved static channel backup to disk") } func (ls *LDKService) GetBalances(ctx context.Context, includeInactiveChannels bool) (*lnclient.BalancesResponse, error) { onchainBalance, err := ls.GetOnchainBalance(ctx) if err != nil { logger.Logger.WithError(err).Error("Failed to retrieve onchain balance") return nil, err } var totalReceivable int64 = 0 var totalSpendable int64 = 0 var nextMaxReceivable int64 = 0 var nextMaxSpendable int64 = 0 var nextMaxReceivableMPP int64 = 0 var nextMaxSpendableMPP int64 = 0 channels := ls.node.ListChannels() for _, channel := range channels { if channel.IsUsable || includeInactiveChannels { // spending or receiving amount may be constrained by channel configuration (e.g. ACINQ does this) channelConstrainedSpendable := min(int64(channel.OutboundCapacityMsat), int64(*channel.CounterpartyOutboundHtlcMaximumMsat)) channelConstrainedReceivable := min(int64(channel.InboundCapacityMsat), int64(*channel.InboundHtlcMaximumMsat)) nextMaxSpendable = max(nextMaxSpendable, channelConstrainedSpendable) nextMaxReceivable = max(nextMaxReceivable, channelConstrainedReceivable) nextMaxSpendableMPP += channelConstrainedSpendable nextMaxReceivableMPP += channelConstrainedReceivable // these are what the wallet can send and receive, but not necessarily in one go totalSpendable += int64(channel.OutboundCapacityMsat) totalReceivable += int64(channel.InboundCapacityMsat) } } return &lnclient.BalancesResponse{ Onchain: *onchainBalance, Lightning: lnclient.LightningBalanceResponse{ TotalSpendableMsat: totalSpendable, TotalReceivableMsat: totalReceivable, NextMaxSpendableMsat: nextMaxSpendable, NextMaxReceivableMsat: nextMaxReceivable, NextMaxSpendableMPPMsat: nextMaxSpendableMPP, NextMaxReceivableMPPMsat: nextMaxReceivableMPP, }, }, nil } func (ls *LDKService) GetStorageDir() (string, error) { // Note: the below will return the path including the WORK_DIR which is harder to use, // so for now we just return a hardcoded value. // cfg := ls.node.Config() // return cfg.StorageDirPath, nil return "ldk/storage", nil } func (ls *LDKService) deleteOldLDKPayments() { payments := ls.node.ListPayments() now := time.Now() for _, payment := range payments { paymentCreatedAt := time.Unix(int64(payment.CreatedAt), 0) deletablePaymentKind := false switch (payment.Kind).(type) { case ldk_node.PaymentKindBolt11: deletablePaymentKind = true case ldk_node.PaymentKindBolt11Jit: deletablePaymentKind = true case ldk_node.PaymentKindSpontaneous: deletablePaymentKind = true } if !deletablePaymentKind { logger.Logger.WithFields(logrus.Fields{ "created_at": paymentCreatedAt, "payment_id": payment.Id, }).Debug("Skipping undeletable payment kind") continue } if paymentCreatedAt.Add(maxInvoiceExpiry).Before(now) { logger.Logger.WithFields(logrus.Fields{ "created_at": paymentCreatedAt, "payment_id": payment.Id, }).Debug("Deleting old payment") err := ls.node.RemovePayment(payment.Id) if err != nil { logger.Logger.WithError(err).WithField("id", payment.Id).Error("failed to delete old payment") } } } } func deleteOldLDKLogs(ldkLogDir string) { logger.Logger.WithField("ldkLogDir", ldkLogDir).Debug("Deleting old LDK logs") files, err := os.ReadDir(ldkLogDir) if err != nil { if errors.Is(err, os.ErrNotExist) { // no log file directory - expected when VSS is enabled return } logger.Logger.WithField("path", ldkLogDir).WithError(err).Error("Failed to list ldk log directory") return } for _, file := range files { // get files with a date (e.g. ldk_node_2024_03_29.log) if strings.HasPrefix(file.Name(), "ldk_node_2") && strings.HasSuffix(file.Name(), ".log") { filePath := filepath.Join(ldkLogDir, file.Name()) fileInfo, err := file.Info() if err != nil { logger.Logger.WithField("filePath", filePath).WithError(err).Error("Failed to get file info") continue } // delete files last modified over 3 days ago if fileInfo.ModTime().Before(time.Now().AddDate(0, 0, -3)) { err := os.Remove(filePath) if err != nil { logger.Logger.WithField("filePath", filePath).WithError(err).Error("Failed to get file info") continue } logger.Logger.WithField("filePath", filePath).Info("Deleted old LDK log file") } } } } func (ls *LDKService) GetNodeStatus(ctx context.Context) (nodeStatus *lnclient.NodeStatus, err error) { status := ls.node.Status() return &lnclient.NodeStatus{ IsReady: status.IsRunning, InternalNodeStatus: status, }, nil } func (ls *LDKService) DisconnectPeer(ctx context.Context, peerId string) error { return ls.node.Disconnect(peerId) } func (ls *LDKService) UpdateLastWalletSyncRequest() { ls.lastWalletSyncRequest = time.Now() } func (ls *LDKService) GetSupportedNIP47Methods() []string { return []string{ models.PAY_INVOICE_METHOD, models.PAY_KEYSEND_METHOD, models.GET_BALANCE_METHOD, models.GET_BUDGET_METHOD, models.GET_INFO_METHOD, models.MAKE_INVOICE_METHOD, models.LOOKUP_INVOICE_METHOD, models.LIST_TRANSACTIONS_METHOD, models.MULTI_PAY_INVOICE_METHOD, models.MULTI_PAY_KEYSEND_METHOD, models.SIGN_MESSAGE_METHOD, models.MAKE_HOLD_INVOICE_METHOD, models.SETTLE_HOLD_INVOICE_METHOD, models.CANCEL_HOLD_INVOICE_METHOD, } } func (ls *LDKService) GetSupportedNIP47NotificationTypes() []string { return []string{ notifications.PAYMENT_RECEIVED_NOTIFICATION, notifications.PAYMENT_SENT_NOTIFICATION, notifications.HOLD_INVOICE_ACCEPTED_NOTIFICATION, } } func (ls *LDKService) getPaymentFailReason(eventPaymentFailed *ldk_node.EventPaymentFailed) string { var failureReason ldk_node.PaymentFailureReason var failureReasonMessage string if eventPaymentFailed.Reason != nil { failureReason = *eventPaymentFailed.Reason } switch failureReason { case ldk_node.PaymentFailureReasonRecipientRejected: failureReasonMessage = "RecipientRejected" case ldk_node.PaymentFailureReasonUserAbandoned: failureReasonMessage = "UserAbandoned" case ldk_node.PaymentFailureReasonRetriesExhausted: failureReasonMessage = "RetriesExhausted" case ldk_node.PaymentFailureReasonPaymentExpired: failureReasonMessage = "PaymentExpired" case ldk_node.PaymentFailureReasonRouteNotFound: failureReasonMessage = "RouteNotFound" case ldk_node.PaymentFailureReasonUnexpectedError: failureReasonMessage = "UnexpectedError" case ldk_node.PaymentFailureReasonUnknownRequiredFeatures: failureReasonMessage = "UnknownRequiredFeatures" case ldk_node.PaymentFailureReasonInvoiceRequestExpired: failureReasonMessage = "InvoiceRequestExpired" case ldk_node.PaymentFailureReasonInvoiceRequestRejected: failureReasonMessage = "InvoiceRequestRejected" case ldk_node.PaymentFailureReasonBlindedPathCreationFailed: failureReasonMessage = "BlindedPathCreationFailed" default: failureReasonMessage = "UnknownError" } return failureReasonMessage } func (ls *LDKService) getChannelCloseReason(event *ldk_node.EventChannelClosed) string { var reason string switch reasonType := (*event.Reason).(type) { case ldk_node.ClosureReasonCounterpartyForceClosed: reason = fmt.Sprintf("CounterpartyForceClosed (Peer message: %s)", reasonType.PeerMsg) case ldk_node.ClosureReasonHolderForceClosed: reason = "HolderForceClosed" case ldk_node.ClosureReasonLegacyCooperativeClosure: reason = "LegacyCooperativeClosure" case ldk_node.ClosureReasonCounterpartyInitiatedCooperativeClosure: reason = "CounterpartyInitiatedCooperativeClosure" case ldk_node.ClosureReasonLocallyInitiatedCooperativeClosure: reason = "LocallyInitiatedCooperativeClosure" case ldk_node.ClosureReasonCommitmentTxConfirmed: reason = "CommitmentTxConfirmed" case ldk_node.ClosureReasonFundingTimedOut: reason = "FundingTimedOut" case ldk_node.ClosureReasonProcessingError: reason = fmt.Sprintf("ProcessingError: %s", reasonType.Err) case ldk_node.ClosureReasonDisconnectedPeer: reason = "DisconnectedPeer" case ldk_node.ClosureReasonOutdatedChannelManager: reason = "OutdatedChannelManager" case ldk_node.ClosureReasonCounterpartyCoopClosedUnfundedChannel: reason = "CounterpartyCoopClosedUnfundedChannel" case ldk_node.ClosureReasonFundingBatchClosure: reason = "FundingBatchClosure" case ldk_node.ClosureReasonHtlCsTimedOut: reason = "HTLCsTimedOut" default: reason = fmt.Sprintf("Unknown: %s", *event.Reason) } return reason } func (ls *LDKService) GetPubkey() string { return ls.pubkey } func (ls *LDKService) PayOfferSync(ctx context.Context, offer string, amount uint64, payerNote string) (*lnclient.PayOfferResponse, error) { // TODO: this is only for testing MakeOffer and needs improvements // (+ BOLT-12 payments need to go through transactions service) // TODO: send liquidity event if amount too large offerObj, err := ldk_node.OfferFromStr(offer) if err != nil { return nil, err } paymentStart := time.Now() ldkEventSubscription := ls.ldkEventBroadcaster.Subscribe() defer ls.ldkEventBroadcaster.CancelSubscription(ldkEventSubscription) // TODO: use normal send if no amount is provided // TODO: configure sending params to ensure fee reserve is used, etc. paymentId, err := ls.node.Bolt12Payment().SendUsingAmount(offerObj, amount, nil, &payerNote, nil) if err != nil { logger.Logger.WithError(err).Error("Failed to initiate BOLT-12 variable amount payment") return nil, errors.New("failed to initiate BOLT-12 variable amount payment") } logger.Logger.WithFields(logrus.Fields{ "payment_id": paymentId, }).Info("Initiated BOLT-12 variable amount payment") feeMsat := uint64(0) preimage := "" payment := ls.node.Payment(paymentId) if payment == nil { return nil, errors.New("payment not found by payment ID") } paymentHash := "" for start := time.Now(); time.Since(start) < time.Second*60; { event := <-ldkEventSubscription eventPaymentSuccessful, isEventPaymentSuccessfulEvent := (*event).(ldk_node.EventPaymentSuccessful) eventPaymentFailed, isEventPaymentFailedEvent := (*event).(ldk_node.EventPaymentFailed) if isEventPaymentSuccessfulEvent && eventPaymentSuccessful.PaymentId != nil && *eventPaymentSuccessful.PaymentId == paymentId { logger.Logger.Info("Got payment success event") payment := ls.node.Payment(paymentId) if payment == nil { logger.Logger.Errorf("Couldn't find payment by payment ID: %v", paymentId) return nil, errors.New("payment not found") } bolt12PaymentKind, ok := payment.Kind.(ldk_node.PaymentKindBolt12Offer) if !ok { logger.Logger.WithFields(logrus.Fields{ "payment": payment, }).Error("Payment is not a BOLT-12 offer kind") return nil, errors.New("payment is not a BOLT-12 offer") } if bolt12PaymentKind.Preimage == nil { logger.Logger.Errorf("No payment preimage for payment ID: %v", paymentId) return nil, errors.New("payment preimage not found") } preimage = *bolt12PaymentKind.Preimage if bolt12PaymentKind.Hash == nil { logger.Logger.Errorf("No payment hash for payment ID: %v", paymentId) return nil, errors.New("payment hash not found") } paymentHash = *bolt12PaymentKind.Hash if eventPaymentSuccessful.FeePaidMsat != nil { feeMsat = *eventPaymentSuccessful.FeePaidMsat } break } if isEventPaymentFailedEvent && eventPaymentFailed.PaymentId != nil && *eventPaymentFailed.PaymentId == paymentId { reason := ls.getPaymentFailReason(&eventPaymentFailed) logger.Logger.WithFields(logrus.Fields{ "payment_id": paymentId, "reason": reason, }).Error("Received payment failed event") return nil, fmt.Errorf("received payment failed event: %s", reason) } } logger.Logger.WithFields(logrus.Fields{ "duration": time.Since(paymentStart).Milliseconds(), "feeMsat": feeMsat, }).Info("Successful BOLT-12 payment") return &lnclient.PayOfferResponse{ PaymentHash: paymentHash, Preimage: preimage, FeeMsat: feeMsat, }, nil } const nodeCommandPayBOLT12Offer = "pay_bolt12_offer" const nodeCommandExportPathfindingScores = "export_pathfinding_scores" const nodeCommandListChannelMonitorSizes = "list_channel_monitor_sizes" func (ls *LDKService) GetCustomNodeCommandDefinitions() []lnclient.CustomNodeCommandDef { return []lnclient.CustomNodeCommandDef{ { Name: nodeCommandPayBOLT12Offer, Description: "Send payments to a BOLT-12 offer. NOTE: this is for testing only. Payment will not show in transaction list.", Args: []lnclient.CustomNodeCommandArgDef{ { Name: "offer", Description: "BOLT-12 offer of receiver", }, { Name: "amount", Description: "amount to send in millisats", }, { Name: "payer_note", Description: "note to the recepient", }, }, }, { Name: nodeCommandExportPathfindingScores, Description: "Exports pathfinding scores from the LDK node.", Args: []lnclient.CustomNodeCommandArgDef{}, // Assuming no arguments for now }, { Name: nodeCommandListChannelMonitorSizes, Description: "List Channel Monitor sizes from the LDK node.", Args: []lnclient.CustomNodeCommandArgDef{}, }, } } func (ls *LDKService) ExecuteCustomNodeCommand(ctx context.Context, command *lnclient.CustomNodeCommandRequest) (*lnclient.CustomNodeCommandResponse, error) { switch command.Name { case nodeCommandPayBOLT12Offer: var offer string var amount uint64 var payerNote string var err error for i := range command.Args { switch command.Args[i].Name { case "offer": offer = command.Args[i].Value case "amount": amount, err = strconv.ParseUint(string(command.Args[i].Value), 10, 64) case "payer_note": payerNote = command.Args[i].Value } } if err != nil { return nil, err } payOfferResponse, err := ls.PayOfferSync(ctx, offer, amount, payerNote) if err != nil { return nil, err } return &lnclient.CustomNodeCommandResponse{ Response: map[string]interface{}{ "paymentHash": payOfferResponse.PaymentHash, "preimage": payOfferResponse.Preimage, "feeMsat": payOfferResponse.FeeMsat, }, }, nil case nodeCommandExportPathfindingScores: scores, err := ls.node.ExportPathfindingScores() if err != nil { logger.Logger.WithError(err).Error("ExportPathfindingScores command failed") return nil, fmt.Errorf("failed to export pathfinding scores: %w", err) } return &lnclient.CustomNodeCommandResponse{ Response: map[string]interface{}{ "scores": hex.EncodeToString(scores), }, }, nil case nodeCommandListChannelMonitorSizes: channelMonitorSizes := ls.node.ListChannelMonitorSizes() channels := ls.node.ListChannels() type channelMonitorSizeResponse struct { SizeBytes uint64 `json:"sizeBytes"` RemotePubkey string `json:"remotePubkey"` HasWarning bool `json:"hasWarning"` } channelMonitorSizesResponse := []channelMonitorSizeResponse{} for _, channelMonitorSizeInfo := range channelMonitorSizes { for _, channel := range channels { if channel.ChannelId == channelMonitorSizeInfo.ChannelId { channelMonitorSizesResponse = append(channelMonitorSizesResponse, channelMonitorSizeResponse{ SizeBytes: channelMonitorSizeInfo.SizeBytes, RemotePubkey: channel.CounterpartyNodeId, HasWarning: channelMonitorSizeInfo.SizeBytes >= ls.cfg.GetEnv().LDKChannelMonitorWarningSizeBytes, }) } } } return &lnclient.CustomNodeCommandResponse{ Response: channelMonitorSizesResponse, }, nil } return nil, lnclient.ErrUnknownCustomNodeCommand } func (ls *LDKService) MakeHoldInvoice(ctx context.Context, amountMsat int64, description string, descriptionHash string, expirySeconds int64, paymentHash string, minCltvExpiryDelta *uint64) (*lnclient.Transaction, error) { if expirySeconds < 0 || expirySeconds > int64(maxInvoiceExpiry/time.Second) { return nil, errors.New("invalid invoice expiry") } maxReceivable := ls.getMaxReceivable() if amountMsat > maxReceivable { ls.eventPublisher.Publish(&events.Event{ Event: "nwc_incoming_liquidity_required", Properties: map[string]interface{}{ "node_type": config.LDKBackendType, }, }) } if expirySeconds == 0 { expirySeconds = lnclient.DEFAULT_INVOICE_EXPIRY } var descriptionType ldk_node.Bolt11InvoiceDescription descriptionType = ldk_node.Bolt11InvoiceDescriptionDirect{ Description: description, } if description == "" && descriptionHash != "" { descriptionType = ldk_node.Bolt11InvoiceDescriptionHash{ Hash: descriptionHash, } } decodedPaymentHash, err := hex.DecodeString(paymentHash) if err != nil { logger.Logger.WithError(err).WithField("paymentHash", paymentHash).Error("Failed to decode payment hash for MakeHoldInvoice") return nil, fmt.Errorf("failed to decode payment hash: %w", err) } if len(decodedPaymentHash) != 32 { return nil, errors.New("payment hash must be 32 bytes") } var paymentHash32 [32]byte copy(paymentHash32[:], decodedPaymentHash) ldkPaymentHash := ldk_node.PaymentHash(hex.EncodeToString(paymentHash32[:])) var invoiceObj *ldk_node.Bolt11Invoice if minCltvExpiryDelta != nil { if *minCltvExpiryDelta > uint64(65535) { return nil, errors.New("min_cltv_expiry_delta must be <= 65535") } invoiceObj, err = ls.node.Bolt11Payment().ReceiveForHashWithMinCltvExpiryDelta( uint64(amountMsat), descriptionType, uint32(expirySeconds), ldkPaymentHash, uint16(*minCltvExpiryDelta), ) } else { invoiceObj, err = ls.node.Bolt11Payment().ReceiveForHash( uint64(amountMsat), descriptionType, uint32(expirySeconds), ldkPaymentHash, ) } if err != nil { logger.Logger.WithError(err).Error("MakeHoldInvoice failed") return nil, err } payment := ls.node.Payment(invoiceObj.PaymentHash()) invoice := *payment.Kind.(ldk_node.PaymentKindBolt11).Bolt11Invoice paymentRequest, err := decodepay.Decodepay(invoice) if err != nil { logger.Logger.WithFields(logrus.Fields{ "bolt11": invoice, }).WithError(err).Error("Failed to decode bolt11 invoice") return nil, err } expiresAtUnix := time.UnixMilli(int64(paymentRequest.CreatedAt) * 1000).Add(time.Duration(paymentRequest.Expiry) * time.Second).Unix() transaction := &lnclient.Transaction{ Type: "incoming", Invoice: *payment.Kind.(ldk_node.PaymentKindBolt11).Bolt11Invoice, PaymentHash: paymentRequest.PaymentHash, AmountMsat: amountMsat, CreatedAt: int64(payment.CreatedAt), ExpiresAt: &expiresAtUnix, Description: paymentRequest.Description, DescriptionHash: paymentRequest.DescriptionHash, } return transaction, nil } func (ls *LDKService) CancelHoldInvoice(ctx context.Context, paymentHash string) error { _, err := hex.DecodeString(paymentHash) if err != nil { logger.Logger.WithError(err).WithField("paymentHash", paymentHash).Error("Failed to decode payment hash for CancelHoldInvoice") return err } err = ls.node.Bolt11Payment().FailForHash(paymentHash) if err != nil { logger.Logger.WithError(err).WithField("paymentHash", paymentHash).Error("CancelHoldInvoice failed") } return err } func (ls *LDKService) SettleHoldInvoice(ctx context.Context, preimage string) error { decodedPreimage, err := hex.DecodeString(preimage) if err != nil { logger.Logger.WithError(err).WithField("preimage", preimage).Error("Failed to decode preimage for SettleHoldInvoice") return err } if len(decodedPreimage) != 32 { return errors.New("preimage must be 32 bytes") } paymentHash256 := sha256.New() paymentHash256.Write(decodedPreimage) paymentHashBytes := paymentHash256.Sum(nil) paymentHash := hex.EncodeToString(paymentHashBytes) paymentDetails := ls.node.Payment(paymentHash) if paymentDetails == nil { logger.Logger.WithField("payment_hash", paymentHash).Error("SettleHoldInvoice: Could not find payment by derived hash") return errors.New("payment not found for derived hash") } if paymentDetails.AmountMsat == nil { logger.Logger.WithField("payment_hash", paymentHash).Error("SettleHoldInvoice: Payment has no amount_msat") return errors.New("payment has no amount_msat") } err = ls.node.Bolt11Payment().ClaimForHash(paymentHash, *paymentDetails.AmountMsat, preimage) if err != nil { logger.Logger.WithError(err).WithField("preimage", preimage).WithField("derived_payment_hash", paymentHash).Error("SettleHoldInvoice failed") } return err } func GetVssNodeIdentifier(keys keys.Keys) (string, error) { key, err := keys.DeriveKey([]uint32{bip32.FirstHardenedChild + 2}) if err != nil { return "", err } // return a 6-character hex string of the hash of a derived key to ensure if same user // runs multiple hubs with different mnemonics, they are all // saved in the VSS under different user_tokens. pubkeyHash256 := sha256.New() pubkeyHash256.Write(key.Key) pubkeyHashBytes := pubkeyHash256.Sum(nil) return hex.EncodeToString(pubkeyHashBytes[0:3]), nil } func getResetStateRequest(cfg config.Config) *ldk_node.ResetState { resetKey, err := cfg.Get(resetRouterKey, "") if err != nil { logger.Logger.Error("Failed to retrieve ResetRouter key") return nil } if resetKey == "" { return nil } err = cfg.SetUpdate(resetRouterKey, "", "") if err != nil { logger.Logger.WithError(err).Error("Failed to remove reset router key") return nil } var ret ldk_node.ResetState switch resetKey { case "ALL": ret = ldk_node.ResetStateAll case "Scorer": ret = ldk_node.ResetStateScorer case "NetworkGraph": ret = ldk_node.ResetStateNetworkGraph case "NodeMetrics": ret = ldk_node.ResetStateNodeMetrics default: logger.Logger.WithField("key", resetKey).Error("Unknown reset router key") return nil } return &ret } func (ls *LDKService) ConsumeEvent(ctx context.Context, event *events.Event, globalProperties map[string]interface{}) { if event.Event == "nwc_alby_account_connected" { // backup existing channels to the user's Alby Account on first connect ls.backupChannels() } } func (ls *LDKService) GetChainDataSource() (string, string) { if endpoint := ls.cfg.GetEnv().LDKBitcoindRpcHost; endpoint != "" { rpcPort := ls.cfg.GetEnv().LDKBitcoindRpcPort return "bitcoind", sanitizeChainEndpoint(endpoint, rpcPort) } if endpoint := ls.cfg.GetEnv().LDKElectrumServer; endpoint != "" { return "electrum", sanitizeChainEndpoint(endpoint, "") } // Fallback to Esplora endpoint := ls.cfg.GetEnv().LDKEsploraServer 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(lsps2InfoCacheTTL) ls.lsps2InfoMu.Lock() defer ls.lsps2InfoMu.Unlock() return ls.lsps2MinPaymentSizeMsat } func (ls *LDKService) GetLiquiditySourceLsps2MaxPaymentSizeMsat() *uint64 { ls.fetchLsps2OpeningFeeParams(lsps2InfoCacheTTL) ls.lsps2InfoMu.Lock() defer ls.lsps2InfoMu.Unlock() return ls.lsps2MaxPaymentSizeMsat } // getLsps2MaxTotalOpeningFeeMsat returns the maximum opening fee to accept // for a JIT channel invoice of the given payment size, derived from the // LSP's advertised opening fee menu and an absolute ceiling. func (ls *LDKService) getLsps2MaxTotalOpeningFeeMsat(paymentSizeMsat uint64) uint64 { ls.fetchLsps2OpeningFeeParams(lsps2FeeCapCacheTTL) ls.lsps2InfoMu.Lock() defer ls.lsps2InfoMu.Unlock() return computeLsps2MaxTotalOpeningFeeMsat(paymentSizeMsat, ls.lsps2OpeningFeeParamsMenu) } func (ls *LDKService) fetchLsps2OpeningFeeParams(maxCacheAge time.Duration) { if ls.lsps2Pubkey == "" || ls.lsps2Address == "" { return } ls.lsps2InfoMu.Lock() defer ls.lsps2InfoMu.Unlock() if !ls.lsps2InfoFetchedAt.IsZero() && time.Since(ls.lsps2InfoFetchedAt) < maxCacheAge { 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.lsps2OpeningFeeParamsMenu = response.OpeningFeeParamsMenu ls.lsps2InfoFetchedAt = time.Now() } // computeLsps2MaxTotalOpeningFeeMsat returns the maximum LSPS2 opening fee to // accept for a payment of the given size: the highest fee the advertised fee // menu allows for that size, further limited by the absolute fee ceiling. The // ceiling alone is used when no menu entry covers the payment size. func computeLsps2MaxTotalOpeningFeeMsat(paymentSizeMsat uint64, menu []ldk_node.Lsps2OpeningFeeParams) uint64 { maxAcceptableFeeMsat := lsps2MaxAcceptableOpeningFeeMsat(paymentSizeMsat) var menuMaxFeeMsat *uint64 for _, params := range menu { if paymentSizeMsat < params.MinPaymentSizeMsat || paymentSizeMsat > params.MaxPaymentSizeMsat { continue } feeMsat := ldk_node.Lsps2ComputeOpeningFeeMsat(paymentSizeMsat, params) if feeMsat == nil { continue } if menuMaxFeeMsat == nil || *feeMsat > *menuMaxFeeMsat { menuMaxFeeMsat = feeMsat } } if menuMaxFeeMsat != nil && *menuMaxFeeMsat < maxAcceptableFeeMsat { return *menuMaxFeeMsat } return maxAcceptableFeeMsat } // the absolute ceiling on the LSPS2 opening fee for a payment of the given // size, independent of the fees the LSP advertises func lsps2MaxAcceptableOpeningFeeMsat(paymentSizeMsat uint64) uint64 { return max(lsps2MaxOpeningFeeBaseMsat, paymentSizeMsat/100*lsps2MaxOpeningFeePercent) } // finds the smallest incoming payment for which the user is left // with a usable amount after the LSP skims its LSPS2 opening fee and the fee // stays within the absolute fee ceiling applied when creating JIT invoices. 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. The fee must also stay within the // absolute fee ceiling, otherwise invoices of this size are rejected. if *openingFeeMsat+minNetReceiveMsat <= paymentSizeMsat && *openingFeeMsat <= lsps2MaxAcceptableOpeningFeeMsat(paymentSizeMsat) { return paymentSizeMsat, paymentSizeMsat <= params.MaxPaymentSizeMsat } nextPaymentSizeMsat := *openingFeeMsat + minNetReceiveMsat if *openingFeeMsat > lsps2MaxOpeningFeeBaseMsat { // the smallest payment size at which a fee this large stays within // the percentage part of the ceiling minSizeForFeeMsat := (*openingFeeMsat + lsps2MaxOpeningFeePercent - 1) / lsps2MaxOpeningFeePercent * 100 nextPaymentSizeMsat = max(nextPaymentSizeMsat, minSizeForFeeMsat) } 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 == "" { u, err = url.Parse("//" + endpoint) } if err != nil { return endpoint } u.User = nil host := u.Hostname() if host == "" { return endpoint } existingPort := u.Port() if existingPort == "" { existingPort = port } if existingPort != "" { u.Host = net.JoinHostPort(host, existingPort) } else { u.Host = host } sanitized := u.String() if u.Scheme == "" { return strings.TrimPrefix(sanitized, "//") } 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 @:") 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 }