alby-hub/lnclient/ldk/ldk.go
Roland 979644cf68
fix: limit LSP opening fees for JIT channel invoices (#2535)
* fix: limit LSP opening fees for JIT channel invoices

JIT channel invoices are now created with a maximum LSP opening fee
instead of no limit: the fee the LSP advertises in its LSPS2 opening fee
menu for the payment size, bounded by an absolute ceiling of 5000 sats
or 10% of the payment, whichever is greater. Invoice creation fails if
the LSP quotes a fee above this limit.

The minimum JIT payment size calculation now uses the same ceiling so
the advertised receivable range matches what invoice creation accepts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: validate invoice expiry range and guard LSPS2 cache reads

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 13:38:13 +07:00

2875 lines
96 KiB
Go

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 <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
}