From dfc75f2e1ade87d8bfc82551071d8b4b4c00bc74 Mon Sep 17 00:00:00 2001 From: Slyghtning Date: Wed, 21 May 2025 14:17:14 +0200 Subject: [PATCH] staticaddr: open channel manager --- staticaddr/log.go | 2 + staticaddr/openchannel/interface.go | 50 ++ staticaddr/openchannel/log.go | 24 + staticaddr/openchannel/manager.go | 796 +++++++++++++++++++++ staticaddr/openchannel/manager_test.go | 314 ++++++++ staticaddr/staticutil/utils.go | 77 +- staticaddr/staticutil/utils_test.go | 336 +++++++++ staticaddr/withdraw/funding_values_test.go | 265 +++++++ staticaddr/withdraw/manager.go | 80 ++- 9 files changed, 1889 insertions(+), 55 deletions(-) create mode 100644 staticaddr/openchannel/interface.go create mode 100644 staticaddr/openchannel/log.go create mode 100644 staticaddr/openchannel/manager.go create mode 100644 staticaddr/openchannel/manager_test.go create mode 100644 staticaddr/withdraw/funding_values_test.go diff --git a/staticaddr/log.go b/staticaddr/log.go index 70dccc87..26b43bde 100644 --- a/staticaddr/log.go +++ b/staticaddr/log.go @@ -5,6 +5,7 @@ import ( "github.com/lightninglabs/loop/staticaddr/address" "github.com/lightninglabs/loop/staticaddr/deposit" "github.com/lightninglabs/loop/staticaddr/loopin" + "github.com/lightninglabs/loop/staticaddr/openchannel" "github.com/lightninglabs/loop/staticaddr/withdraw" "github.com/lightningnetwork/lnd/build" ) @@ -29,4 +30,5 @@ func UseLogger(logger btclog.Logger) { deposit.UseLogger(log) withdraw.UseLogger(log) loopin.UseLogger(log) + openchannel.UseLogger(log) } diff --git a/staticaddr/openchannel/interface.go b/staticaddr/openchannel/interface.go new file mode 100644 index 00000000..e8e20691 --- /dev/null +++ b/staticaddr/openchannel/interface.go @@ -0,0 +1,50 @@ +package openchannel + +import ( + "context" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop/fsm" + "github.com/lightninglabs/loop/staticaddr/address" + "github.com/lightninglabs/loop/staticaddr/deposit" + "github.com/lightninglabs/loop/staticaddr/script" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" +) + +// AddressManager handles fetching of address parameters. +type AddressManager interface { + // GetStaticAddressParameters returns the static address parameters. + GetStaticAddressParameters(ctx context.Context) (*address.Parameters, + error) + + // GetStaticAddress returns the deposit address for the given + // client and server public keys. + GetStaticAddress(ctx context.Context) (*script.StaticAddress, error) +} + +type DepositManager interface { + // AllOutpointsActiveDeposits returns all deposits that are in the + // given state. If the state filter is fsm.StateTypeNone, all deposits + // are returned. + AllOutpointsActiveDeposits(outpoints []wire.OutPoint, + stateFilter fsm.StateType) ([]*deposit.Deposit, bool) + + // GetActiveDepositsInState returns all deposits that are in the + // given state. + GetActiveDepositsInState(stateFilter fsm.StateType) ([]*deposit.Deposit, + error) + + // TransitionDeposits transitions the deposits to the given state. + TransitionDeposits(ctx context.Context, deposits []*deposit.Deposit, + event fsm.EventType, expectedFinalState fsm.StateType) error +} + +type WithdrawalManager interface { + CreateFinalizedWithdrawalTx(ctx context.Context, + deposits []*deposit.Deposit, withdrawalAddress btcutil.Address, + feeRate chainfee.SatPerKWeight, + selectedWithdrawalAmount int64, + commitmentType lnrpc.CommitmentType) (*wire.MsgTx, []byte, error) +} diff --git a/staticaddr/openchannel/log.go b/staticaddr/openchannel/log.go new file mode 100644 index 00000000..855bafba --- /dev/null +++ b/staticaddr/openchannel/log.go @@ -0,0 +1,24 @@ +package openchannel + +import ( + "github.com/btcsuite/btclog/v2" + "github.com/lightningnetwork/lnd/build" +) + +// Subsystem defines the sub system name of this package. +const Subsystem = "SCHOPEN" + +// log is a logger that is initialized with no output filters. This means the +// package will not perform any logging by default until the caller requests it. +var log btclog.Logger + +// The default amount of logging is none. +func init() { + UseLogger(build.NewSubLogger(Subsystem, nil)) +} + +// UseLogger uses a specified Logger to output package logging info. This should +// be used in preference to SetLogWriter if the caller is also using btclog. +func UseLogger(logger btclog.Logger) { + log = logger +} diff --git a/staticaddr/openchannel/manager.go b/staticaddr/openchannel/manager.go new file mode 100644 index 00000000..e4d306be --- /dev/null +++ b/staticaddr/openchannel/manager.go @@ -0,0 +1,796 @@ +package openchannel + +import ( + "bytes" + "context" + "crypto/rand" + "errors" + "fmt" + "io" + "strings" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/lndclient" + "github.com/lightninglabs/loop/fsm" + "github.com/lightninglabs/loop/staticaddr/deposit" + "github.com/lightninglabs/loop/staticaddr/staticutil" + "github.com/lightninglabs/loop/staticaddr/withdraw" + serverrpc "github.com/lightninglabs/loop/swapserverrpc" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/lightningnetwork/lnd/lnwallet/chanfunding" +) + +const ( + // The minimum number of confirmations lnd requires inputs to have for a + // channel opening. + defaultUtxoMinConf = 1 + + // Is the default confirmation target for a channel open transaction. + defaultConfTarget int32 = 3 +) + +var ( + ErrOpeningChannelUnavailableDeposits = errors.New("some deposits are " + + "not usable to open a channel with") + + // errPsbtFinalized is returned when the PSBT finalize step was already + // sent to lnd. After this point the funding transaction may have been + // broadcast, so deposits must not be rolled back to Deposited. + errPsbtFinalized = errors.New("PSBT finalize already sent") +) + +// Config is the configuration struct for the open channel manager. +type Config struct { + // StaticAddressServerClient is the client that calls the swap server + // rpcs to negotiate static address withdrawals. + Server serverrpc.StaticAddressServerClient + + // AddressManager gives the withdrawal manager access to static address + // parameters. + AddressManager AddressManager + + // DepositManager gives the withdrawal manager access to the deposits + // enabling it to create and manage withdrawals. + DepositManager DepositManager + + // WithdrawalManager is used to create the withdrawal transaction into + // the channel funding address. + WithdrawalManager WithdrawalManager + + // WalletKit is the wallet client that is used to derive new keys from + // lnd's wallet. + WalletKit lndclient.WalletKitClient + + // ChainParams is the chain configuration(mainnet, testnet...) this + // manager uses. + ChainParams *chaincfg.Params + + // ChainNotifier is the chain notifier that is used to listen for new + // blocks. + ChainNotifier lndclient.ChainNotifierClient + + // Signer is the signer client that is used to sign transactions. + Signer lndclient.SignerClient + + // LightningClient is the lnd client that is used to open channels. + LightningClient lndclient.LightningClient +} + +type newOpenChannelRequest struct { + request *lnrpc.OpenChannelRequest + respChan chan *newOpenChannelResponse +} + +type newOpenChannelResponse struct { + // ChanTxHash is the transaction hash of the channel open transaction. + ChanTxHash *chainhash.Hash + + // Err is the error that occurred during the channel open process. + err error +} + +// Manager is the main struct that handles the open channel manager. +type Manager struct { + cfg *Config + + newOpenChannelRequestChan chan newOpenChannelRequest + + // exitChan signals subroutines that the open channel is exiting. + exitChan chan struct{} + + // errChan forwards errors from the open channel to the server. + errChan chan error +} + +// NewManager creates a new manager instance. +func NewManager(cfg *Config) *Manager { + m := &Manager{ + cfg: cfg, + exitChan: make(chan struct{}), + newOpenChannelRequestChan: make(chan newOpenChannelRequest), + errChan: make(chan error), + } + + return m +} + +// Run runs the open channel manager. +func (m *Manager) Run(ctx context.Context) error { + err := m.recoverOpeningChannelDeposits(ctx) + if err != nil { + return err + } + + for { + select { + case req := <-m.newOpenChannelRequestChan: + chanTxHash, err := m.OpenChannel(ctx, req.request) + resp := &newOpenChannelResponse{ + ChanTxHash: chanTxHash, + err: err, + } + + select { + case req.respChan <- resp: + + case <-ctx.Done(): + // Notify subroutines that the main loop has + // been canceled. + close(m.exitChan) + + return ctx.Err() + } + + case <-ctx.Done(): + // Signal subroutines that the manager is exiting. + close(m.exitChan) + + return ctx.Err() + } + } +} + +// recoverOpeningChannelDeposits resolves deposits that were left in +// OpeningChannel after a client restart. If a deposit input is still unspent, +// the channel open did not publish and we move back to Deposited. If the input +// is no longer unspent, it was spent on-chain and we finalize it as +// ChannelPublished. +func (m *Manager) recoverOpeningChannelDeposits(ctx context.Context) error { + openingDeposits, err := m.cfg.DepositManager.GetActiveDepositsInState( + deposit.OpeningChannel, + ) + if err != nil { + return fmt.Errorf("unable to fetch opening channel deposits: %w", + err) + } + + if len(openingDeposits) == 0 { + return nil + } + + log.Infof("Recovering %d deposits in OpeningChannel state", + len(openingDeposits)) + + utxos, err := m.cfg.WalletKit.ListUnspent( + ctx, 0, 0, + ) + if err != nil { + return fmt.Errorf("unable to list unspent outputs for recovery: %w", + err) + } + + unspentOutpoints := make(map[wire.OutPoint]struct{}, len(utxos)) + for _, utxo := range utxos { + unspentOutpoints[utxo.OutPoint] = struct{}{} + } + + var ( + deposited []*deposit.Deposit + channelPublished []*deposit.Deposit + ) + + for _, d := range openingDeposits { + _, stillUnspent := unspentOutpoints[d.OutPoint] + if stillUnspent { + deposited = append(deposited, d) + continue + } + + channelPublished = append(channelPublished, d) + } + + if len(deposited) > 0 { + err = m.cfg.DepositManager.TransitionDeposits( + ctx, deposited, fsm.OnError, deposit.Deposited, + ) + if err != nil { + return fmt.Errorf("unable to recover unspent opening "+ + "deposits: %w", err) + } + } + + if len(channelPublished) > 0 { + err = m.cfg.DepositManager.TransitionDeposits( + ctx, channelPublished, deposit.OnChannelPublished, + deposit.ChannelPublished, + ) + if err != nil { + return fmt.Errorf("unable to recover spent opening "+ + "deposits: %w", err) + } + } + + log.Infof("Recovered opening channel deposits: %d returned to Deposited, "+ + "%d marked ChannelPublished", len(deposited), + len(channelPublished)) + + return nil +} + +// OpenChannel transitions the requested deposits into the OpeningChannel state +// and then starts the open channel psbt flow between the client's lnd instance +// and the server. +func (m *Manager) OpenChannel(ctx context.Context, + req *lnrpc.OpenChannelRequest) (*chainhash.Hash, error) { + + var ( + outpoints []wire.OutPoint + deposits []*deposit.Deposit + allActive bool + feeRate chainfee.SatPerKWeight + err error + ) + + if req.LocalFundingAmount == 0 && !req.FundMax { + return nil, fmt.Errorf("either local funding amount or " + + "fundmax must be set") + } + + if req.LocalFundingAmount != 0 && req.FundMax { + return nil, fmt.Errorf("local funding amount and fundmax " + + "cannot be set at the same time") + } + + // Validate PSBT-incompatible flags early, before locking deposits. + // We accept MinConfs=0 here because that's the proto default for unset + // values and normalize to defaultUtxoMinConf later. + if err := validateInitialPsbtFlags(req); err != nil { + return nil, err + } + + // Determine the commitment type for the channel. + chanCommitmentType, err := resolveCommitmentType(req.CommitmentType) + if err != nil { + return nil, err + } + + // Estimate the fee rate before deposit selection so that we can verify + // the selected deposits cover the funding amount plus fees. + if req.SatPerVbyte == 0 { + feeRate, err = m.cfg.WalletKit.EstimateFeeRate( + ctx, defaultConfTarget, + ) + if err != nil { + return nil, fmt.Errorf("error estimating fee rate: %w", + err) + } + } else { + feeRate = chainfee.SatPerKVByte( + req.SatPerVbyte * 1000, + ).FeePerKWeight() + } + + // There are three ways in which we select deposits to open a channel + // with. 1.) The user manually selects the deposits. 2.) The user only + // selects a local channel amount in which case we coin-select deposits + // to cover for it. 3.) The user selects the fundmax flag, in which case + // we select all deposits to fund the channel. + if len(req.Outpoints) > 0 { + // Ensure that the deposits are in a state in which they are + // available for a channel open. + outpoints, err = staticutil.ToWireOutpoints(req.Outpoints) + if err != nil { + return nil, fmt.Errorf("error parsing outpoints: %w", + err) + } + + deposits, allActive = + m.cfg.DepositManager.AllOutpointsActiveDeposits( + outpoints, deposit.Deposited, + ) + if !allActive { + return nil, ErrOpeningChannelUnavailableDeposits + } + } else { + // We have to select the deposits that are used to fund the + // channel. + deposits, err = m.cfg.DepositManager.GetActiveDepositsInState( + deposit.Deposited, + ) + if err != nil { + return nil, err + } + + if req.LocalFundingAmount != 0 { + deposits, err = staticutil.SelectDeposits( + deposits, req.LocalFundingAmount, + feeRate, chanCommitmentType, + ) + if err != nil { + return nil, fmt.Errorf("error selecting "+ + "deposits: %w", err) + } + } else { + // The fundmax flag is set, hence we select all deposits + // for funding the channel. + } + } + + // Pre-check: calculate the channel funding amount and the optional + // change before locking deposits. This ensures the selected deposits + // can cover the funding amount plus fees. + chanFundingAmt, _, calcErr := withdraw.CalculateWithdrawalTxValues( + deposits, btcutil.Amount(req.LocalFundingAmount), feeRate, nil, + chanCommitmentType, + ) + if calcErr != nil { + return nil, fmt.Errorf("error calculating funding tx "+ + "values: %w", calcErr) + } + + // We need to transition the deposits to the opening channel state + // before we start the channel open process. This is important to + // ensure that the deposits are not used for other purposes while we + // are opening the channel. + err = m.cfg.DepositManager.TransitionDeposits( + ctx, deposits, deposit.OnOpeningChannel, deposit.OpeningChannel, + ) + if err != nil { + return nil, err + } + + openChanRequest := &lnrpc.OpenChannelRequest{ + NodePubkey: req.NodePubkey, + LocalFundingAmount: int64(chanFundingAmt), + PushSat: req.PushSat, + Private: req.Private, + MinHtlcMsat: req.MinHtlcMsat, + RemoteCsvDelay: req.RemoteCsvDelay, + MinConfs: defaultUtxoMinConf, + SpendUnconfirmed: false, + CloseAddress: req.CloseAddress, + RemoteMaxValueInFlightMsat: req.RemoteMaxValueInFlightMsat, + RemoteMaxHtlcs: req.RemoteMaxHtlcs, + MaxLocalCsv: req.MaxLocalCsv, + CommitmentType: chanCommitmentType, + ZeroConf: req.ZeroConf, + ScidAlias: req.ScidAlias, + BaseFee: req.BaseFee, + FeeRate: req.FeeRate, + UseBaseFee: req.UseBaseFee, + UseFeeRate: req.UseFeeRate, + RemoteChanReserveSat: req.RemoteChanReserveSat, + Memo: req.Memo, + } + + chanTxHash, err := m.openChannelPsbt( + ctx, openChanRequest, deposits, feeRate, + ) + if err != nil { + log.Infof("error opening channel: %v", err) + + // If the PSBT was already finalized and sent to lnd, the + // funding transaction may have been broadcast. In that case + // we must not roll back the deposits to Deposited as they + // may already be spent on-chain. + if !errors.Is(err, errPsbtFinalized) { + err2 := m.cfg.DepositManager.TransitionDeposits( + ctx, deposits, fsm.OnError, + deposit.Deposited, + ) + if err2 != nil { + log.Errorf("failed transitioning deposits "+ + "after failed channel open: %v", + err2) + } + } + + return nil, err + } + + return chanTxHash, nil +} + +// openChannelPsbt starts an interactive channel open protocol that uses a +// partially signed bitcoin transaction (PSBT) to fund the channel output. The +// protocol involves several steps between the loop client and the server: +// +// RPC server CLI client +// +// | | +// | |<------open channel (stream)-----| +// | |-------ready for funding----->| | +// | |------------------------------| | create psbt from deposits +// | |<------PSBT verify------------| | +// | |-------ready for signing----->| | +// | |------------------------------| | request server co-sig +// | |------------------------------| | sign psbt with combined sig +// | |<------PSBT finalize----------| | +// | |-------channel pending------->| | +// | |-------channel open------------->| +// | | +func (m *Manager) openChannelPsbt(ctx context.Context, + req *lnrpc.OpenChannelRequest, deposits []*deposit.Deposit, + feeRate chainfee.SatPerKWeight) (*chainhash.Hash, error) { + + var ( + pendingChanID [32]byte + shimPending = true + psbtFinalized bool + basePsbtBytes []byte + quit = make(chan struct{}) + srvMsg = make(chan *lnrpc.OpenStatusUpdate, 1) + srvErr = make(chan error, 1) + ) + + // Make sure the user didn't supply any command line flags that are + // incompatible with PSBT funding. + err := checkPsbtFlags(req) + if err != nil { + return nil, err + } + + // Generate a new, random pending channel ID that we'll use as the main + // identifier when sending update messages to the RPC server. + if _, err := rand.Read(pendingChanID[:]); err != nil { + return nil, fmt.Errorf("unable to generate random chan ID: "+ + "%w", err) + } + log.Infof("Starting PSBT funding flow with pending channel ID %x.\n", + pendingChanID) + + // maybeCancelShim is a helper function that cancels the funding shim + // with the RPC server in case we end up aborting early. + maybeCancelShim := func() { + // If the user canceled while there was still a shim registered + // with the wallet, release the resources now. + if shimPending { + log.Infof("Canceling PSBT funding flow for pending "+ + "channel ID %x.\n", pendingChanID) + + cancelMsg := &lnrpc.FundingTransitionMsg{ + Trigger: &lnrpc.FundingTransitionMsg_ShimCancel{ + ShimCancel: &lnrpc.FundingShimCancel{ + PendingChanId: pendingChanID[:], + }, + }, + } + _, err := m.cfg.LightningClient.FundingStateStep( + ctx, cancelMsg, + ) + if err != nil { + log.Errorf("Error canceling shim: %v\n", err) + } + shimPending = false + } + } + defer maybeCancelShim() + + // Create the PSBT funding shim that will tell the funding manager we + // want to use a PSBT. + req.FundingShim = &lnrpc.FundingShim{ + Shim: &lnrpc.FundingShim_PsbtShim{ + PsbtShim: &lnrpc.PsbtShim{ + PendingChanId: pendingChanID[:], + BasePsbt: basePsbtBytes, + // Setting this to false since we don't batch + // open channels. + NoPublish: false, + }, + }, + } + + // Start the interactive process by opening the stream connection to the + // daemon. If the user cancels by pressing we need to cancel + // the shim. To not just kill the process on interrupt, we need to + // explicitly capture the signal. + rawCtx, _, rawClient := m.cfg.LightningClient.RawClientWithMacAuth(ctx) + stream, err := rawClient.OpenChannel(rawCtx, req) + if err != nil { + return nil, fmt.Errorf("opening stream to server "+ + "failed: %w", err) + } + + // We also need to spawn a goroutine that reads from the server. This + // will copy the messages to the channel as long as they come in or add + // exactly one error to the error stream and then bail out. + go func() { + for { + // Recv blocks until a message or error arrives. + resp, err := stream.Recv() + if err == io.EOF { + srvErr <- fmt.Errorf("loop shutting down: %w", + err) + + return + } else if err != nil { + srvErr <- fmt.Errorf("got error from server: "+ + "%v", err) + + return + } + + // Don't block on sending in case of shutting down. + select { + case srvMsg <- resp: + case <-quit: + + return + } + } + }() + + // Spawn another goroutine that only handles loop server shutdown or + // errors from the lnd server. Both will trigger an attempt to cancel + // the shim with the server. + go func() { + select { + case <-ctx.Done(): + log.Infof("OpenChannel context cancel.") + close(quit) + + case err := <-srvErr: + log.Errorf("OpenChannel lnd server error received: "+ + "%v\n", err) + + // If the remote peer canceled on us, the reservation + // has already been deleted. We don't need to try to + // remove it again, this would just produce another + // error. + cancelErr := chanfunding.ErrRemoteCanceled.Error() + if err != nil && strings.Contains( + err.Error(), cancelErr, + ) { + + shimPending = false + } + close(quit) + + case <-quit: + } + }() + + for { + var srvResponse *lnrpc.OpenStatusUpdate + select { + case srvResponse = <-srvMsg: + case <-quit: + cancelErr := fmt.Errorf("open channel flow canceled") + if psbtFinalized { + return nil, fmt.Errorf("%w: %v", + errPsbtFinalized, cancelErr) + } + return nil, cancelErr + } + + switch update := srvResponse.Update.(type) { + case *lnrpc.OpenStatusUpdate_PsbtFund: + fundingAmount := update.PsbtFund.FundingAmount + if req.LocalFundingAmount != fundingAmount { + err := fmt.Errorf("funding amount "+ + "%v doesn't match local "+ + "funding amount %v", + fundingAmount, + req.LocalFundingAmount) + + return nil, err + } + + addr := update.PsbtFund.FundingAddress + + log.Infof("PSBT funding initiated with peer "+ + "%x, funding amount %v, funding "+ + "address %v", req.NodePubkey, + fundingAmount, addr) + + // Create the psbt funding transaction for the + // channel. Ensure the selected deposits amount + // to the psbt funding amount. + channelFundingAddress, err := btcutil.DecodeAddress( + addr, m.cfg.ChainParams, + ) + if err != nil { + return nil, fmt.Errorf("decoding funding "+ + "address: %w", err) + } + + //nolint:ll + signedTx, unsignedPsbt, err := m.cfg.WithdrawalManager.CreateFinalizedWithdrawalTx( + ctx, deposits, channelFundingAddress, feeRate, + fundingAmount, req.CommitmentType, + ) + if err != nil { + return nil, fmt.Errorf("creating PSBT "+ + "failed: %w", err) + } + + // Verify that the psbt contains the correct outputs. + verifyMsg := &lnrpc.FundingTransitionMsg{ + Trigger: &lnrpc.FundingTransitionMsg_PsbtVerify{ + PsbtVerify: &lnrpc.FundingPsbtVerify{ + FundedPsbt: unsignedPsbt, + PendingChanId: pendingChanID[:], + }, + }, + } + _, err = m.cfg.LightningClient.FundingStateStep( + ctx, verifyMsg, + ) + if err != nil { + return nil, fmt.Errorf("verifying PSBT by lnd "+ + "failed: %v", err) + } + + // Now that we have the final transaction, we can + // finalize the PSBT and publish the channel open + // transaction. + var buffer bytes.Buffer + err = signedTx.Serialize(&buffer) + if err != nil { + return nil, fmt.Errorf("error serializing "+ + "tx: %w", err) + } + transitionMsg := &lnrpc.FundingTransitionMsg{ + Trigger: &lnrpc.FundingTransitionMsg_PsbtFinalize{ + PsbtFinalize: &lnrpc.FundingPsbtFinalize{ + FinalRawTx: buffer.Bytes(), + PendingChanId: pendingChanID[:], + }, + }, + } + _, err = m.cfg.LightningClient.FundingStateStep( + ctx, transitionMsg, + ) + if err != nil { + return nil, fmt.Errorf("finalizing PSBT "+ + "funding flow failed: %v", err) + } + + // The finalize step succeeded. From this point + // on the funding tx may have been broadcast, so + // deposits must not be rolled back. + psbtFinalized = true + + case *lnrpc.OpenStatusUpdate_ChanPending: + // As soon as the channel is pending, there is no more + // shim that needs to be canceled. If the user + // interrupts now, we don't need to clean up anything. + shimPending = false + + hash, err := chainhash.NewHash( + update.ChanPending.Txid, + ) + if err != nil { + log.Infof("Error creating hash for channel "+ + "open tx: %v", err) + } + + log.Infof("Channel transaction pending: %v", + hash.String()) + log.Infof("Please monitor the channel from lnd") + + err = m.cfg.DepositManager.TransitionDeposits( + ctx, deposits, deposit.OnChannelPublished, + deposit.ChannelPublished, + ) + if err != nil { + log.Errorf("error transitioning deposits to "+ + "ChannelPublished: %v", err) + } + + // We can now close the quit channel to stop the + // goroutine that reads from the server. + close(quit) + + // Nil indicates that the channel was successfully + // published. + return hash, nil + } + } +} + +// validateInitialPsbtFlags validates request fields that are incompatible with +// the interactive PSBT channel funding flow. +func validateInitialPsbtFlags(req *lnrpc.OpenChannelRequest) error { + if req.MinConfs != 0 && req.MinConfs != defaultUtxoMinConf { + return fmt.Errorf("custom MinConfs not supported for PSBT " + + "funding, only the default is allowed") + } + + if req.SpendUnconfirmed { + return fmt.Errorf("SpendUnconfirmed is not supported " + + "for PSBT funding") + } + + return nil +} + +// resolveCommitmentType validates supported channel commitment types and +// normalizes unknown/default to STATIC_REMOTE_KEY. +func resolveCommitmentType(commitmentType lnrpc.CommitmentType) ( + lnrpc.CommitmentType, error) { + + switch commitmentType { + case lnrpc.CommitmentType_UNKNOWN_COMMITMENT_TYPE, + lnrpc.CommitmentType_STATIC_REMOTE_KEY: + + return lnrpc.CommitmentType_STATIC_REMOTE_KEY, nil + + case lnrpc.CommitmentType_ANCHORS: + return lnrpc.CommitmentType_ANCHORS, nil + + case lnrpc.CommitmentType_SIMPLE_TAPROOT: + return lnrpc.CommitmentType_SIMPLE_TAPROOT, nil + + default: + return lnrpc.CommitmentType_UNKNOWN_COMMITMENT_TYPE, fmt.Errorf( + "unsupported commitment type %v", commitmentType, + ) + } +} + +// checkPsbtFlags make sure a request to open a channel doesn't set any +// parameters that are incompatible with the PSBT funding flow. +func checkPsbtFlags(req *lnrpc.OpenChannelRequest) error { + if req.MinConfs != defaultUtxoMinConf || req.SpendUnconfirmed { + return fmt.Errorf("specifying minimum confirmations for PSBT " + + "funding is not supported") + } + if req.TargetConf != 0 || req.SatPerByte != 0 || req.SatPerVbyte != 0 { // nolint:staticcheck + return fmt.Errorf("setting fee estimation parameters not " + + "supported for PSBT funding") + } + return nil +} + +// DeliverOpenChannelRequest forwards a open channel request to the manager main +// loop. +func (m *Manager) DeliverOpenChannelRequest(ctx context.Context, + req *lnrpc.OpenChannelRequest) (*chainhash.Hash, error) { + + request := newOpenChannelRequest{ + request: req, + respChan: make(chan *newOpenChannelResponse), + } + + // Send the open channel request to the manager run loop. + select { + case m.newOpenChannelRequestChan <- request: + + case <-m.exitChan: + return nil, fmt.Errorf("open channel manager has been " + + "canceled") + + case <-ctx.Done(): + return nil, fmt.Errorf("context canceled while opening " + + "channel") + } + + // Wait for the response from the manager run loop. + select { + case resp := <-request.respChan: + return resp.ChanTxHash, resp.err + + case <-m.exitChan: + return nil, fmt.Errorf("open channel manager has been " + + "canceled") + + case <-ctx.Done(): + return nil, fmt.Errorf("context canceled while waiting " + + "for open channel response") + } +} diff --git a/staticaddr/openchannel/manager_test.go b/staticaddr/openchannel/manager_test.go new file mode 100644 index 00000000..807d275f --- /dev/null +++ b/staticaddr/openchannel/manager_test.go @@ -0,0 +1,314 @@ +package openchannel + +import ( + "context" + "errors" + "testing" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/lndclient" + "github.com/lightninglabs/loop/fsm" + "github.com/lightninglabs/loop/staticaddr/deposit" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/stretchr/testify/require" +) + +type transitionCall struct { + event fsm.EventType + expectedState fsm.StateType + outpoints []wire.OutPoint +} + +type mockDepositManager struct { + openingDeposits []*deposit.Deposit + getErr error + transitionErrs map[fsm.EventType]error + calls []transitionCall +} + +func (m *mockDepositManager) AllOutpointsActiveDeposits([]wire.OutPoint, + fsm.StateType) ([]*deposit.Deposit, bool) { + + return nil, false +} + +func (m *mockDepositManager) GetActiveDepositsInState(stateFilter fsm.StateType) ( + []*deposit.Deposit, error) { + + if stateFilter != deposit.OpeningChannel { + return nil, nil + } + + if m.getErr != nil { + return nil, m.getErr + } + + return m.openingDeposits, nil +} + +func (m *mockDepositManager) TransitionDeposits(_ context.Context, + deposits []*deposit.Deposit, event fsm.EventType, + expectedFinalState fsm.StateType) error { + + call := transitionCall{ + event: event, + expectedState: expectedFinalState, + outpoints: make([]wire.OutPoint, len(deposits)), + } + for i, d := range deposits { + call.outpoints[i] = d.OutPoint + } + m.calls = append(m.calls, call) + + if err, ok := m.transitionErrs[event]; ok { + return err + } + + return nil +} + +type mockWalletKit struct { + lndclient.WalletKitClient + + utxos []*lnwallet.Utxo + err error + calls int +} + +func (m *mockWalletKit) ListUnspent(_ context.Context, _, _ int32, + _ ...lndclient.ListUnspentOption) ([]*lnwallet.Utxo, error) { + + m.calls++ + + if m.err != nil { + return nil, m.err + } + + return m.utxos, nil +} + +func TestRecoverOpeningChannelDepositsMixed(t *testing.T) { + t.Parallel() + + unspentDeposit := &deposit.Deposit{OutPoint: testOutPoint(1)} + spentDeposit := &deposit.Deposit{OutPoint: testOutPoint(2)} + + depositManager := &mockDepositManager{ + openingDeposits: []*deposit.Deposit{unspentDeposit, spentDeposit}, + } + walletKit := &mockWalletKit{ + utxos: []*lnwallet.Utxo{ + {OutPoint: unspentDeposit.OutPoint}, + {OutPoint: testOutPoint(99)}, + }, + } + + manager := &Manager{ + cfg: &Config{ + DepositManager: depositManager, + WalletKit: walletKit, + }, + } + + err := manager.recoverOpeningChannelDeposits(context.Background()) + require.NoError(t, err) + require.Equal(t, 1, walletKit.calls) + require.Len(t, depositManager.calls, 2) + + require.Equal(t, fsm.OnError, depositManager.calls[0].event) + require.Equal(t, deposit.Deposited, depositManager.calls[0].expectedState) + require.Equal( + t, []wire.OutPoint{unspentDeposit.OutPoint}, + depositManager.calls[0].outpoints, + ) + + require.Equal(t, deposit.OnChannelPublished, depositManager.calls[1].event) + require.Equal( + t, deposit.ChannelPublished, + depositManager.calls[1].expectedState, + ) + require.Equal( + t, []wire.OutPoint{spentDeposit.OutPoint}, + depositManager.calls[1].outpoints, + ) +} + +func TestRecoverOpeningChannelDepositsNoDeposits(t *testing.T) { + t.Parallel() + + depositManager := &mockDepositManager{} + walletKit := &mockWalletKit{} + manager := &Manager{ + cfg: &Config{ + DepositManager: depositManager, + WalletKit: walletKit, + }, + } + + err := manager.recoverOpeningChannelDeposits(context.Background()) + require.NoError(t, err) + require.Zero(t, walletKit.calls) + require.Empty(t, depositManager.calls) +} + +func TestRecoverOpeningChannelDepositsListUnspentError(t *testing.T) { + t.Parallel() + + depositManager := &mockDepositManager{ + openingDeposits: []*deposit.Deposit{ + {OutPoint: testOutPoint(1)}, + }, + } + walletKit := &mockWalletKit{ + err: errors.New("list unspent failed"), + } + manager := &Manager{ + cfg: &Config{ + DepositManager: depositManager, + WalletKit: walletKit, + }, + } + + err := manager.recoverOpeningChannelDeposits(context.Background()) + require.ErrorContains(t, err, "unable to list unspent outputs") + require.Empty(t, depositManager.calls) +} + +func TestRecoverOpeningChannelDepositsTransitionError(t *testing.T) { + t.Parallel() + + unspentDeposit := &deposit.Deposit{OutPoint: testOutPoint(1)} + depositManager := &mockDepositManager{ + openingDeposits: []*deposit.Deposit{unspentDeposit}, + transitionErrs: map[fsm.EventType]error{ + fsm.OnError: errors.New("transition failed"), + }, + } + walletKit := &mockWalletKit{ + utxos: []*lnwallet.Utxo{ + {OutPoint: unspentDeposit.OutPoint}, + }, + } + manager := &Manager{ + cfg: &Config{ + DepositManager: depositManager, + WalletKit: walletKit, + }, + } + + err := manager.recoverOpeningChannelDeposits(context.Background()) + require.ErrorContains(t, err, "unable to recover unspent opening deposits") + require.Len(t, depositManager.calls, 1) +} + +func testOutPoint(b byte) wire.OutPoint { + return wire.OutPoint{ + Hash: chainhash.Hash{b}, + Index: uint32(b), + } +} + +func TestValidateInitialPsbtFlags(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + minConfs int32 + spendUnconfirmed bool + expectedErrSubstr string + }{ + { + name: "default min confs accepted", + minConfs: 0, + spendUnconfirmed: false, + }, + { + name: "explicit default min confs accepted", + minConfs: defaultUtxoMinConf, + spendUnconfirmed: false, + }, + { + name: "custom min confs rejected", + minConfs: defaultUtxoMinConf + 1, + spendUnconfirmed: false, + expectedErrSubstr: "custom MinConfs not supported", + }, + { + name: "spend unconfirmed rejected", + minConfs: defaultUtxoMinConf, + spendUnconfirmed: true, + expectedErrSubstr: "SpendUnconfirmed is not supported", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req := &lnrpc.OpenChannelRequest{ + MinConfs: tc.minConfs, + SpendUnconfirmed: tc.spendUnconfirmed, + } + + err := validateInitialPsbtFlags(req) + if tc.expectedErrSubstr == "" { + require.NoError(t, err) + return + } + + require.ErrorContains(t, err, tc.expectedErrSubstr) + }) + } +} + +func TestResolveCommitmentType(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + commitmentType lnrpc.CommitmentType + expectedType lnrpc.CommitmentType + expectedErrSubstr string + }{ + { + name: "unknown defaults to static remote key", + commitmentType: lnrpc.CommitmentType_UNKNOWN_COMMITMENT_TYPE, + expectedType: lnrpc.CommitmentType_STATIC_REMOTE_KEY, + }, + { + name: "static remote key supported", + commitmentType: lnrpc.CommitmentType_STATIC_REMOTE_KEY, + expectedType: lnrpc.CommitmentType_STATIC_REMOTE_KEY, + }, + { + name: "anchors supported", + commitmentType: lnrpc.CommitmentType_ANCHORS, + expectedType: lnrpc.CommitmentType_ANCHORS, + }, + { + name: "simple taproot supported", + commitmentType: lnrpc.CommitmentType_SIMPLE_TAPROOT, + expectedType: lnrpc.CommitmentType_SIMPLE_TAPROOT, + }, + { + name: "legacy rejected", + commitmentType: lnrpc.CommitmentType_LEGACY, + expectedErrSubstr: "unsupported commitment type", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + commitmentType, err := resolveCommitmentType( + tc.commitmentType, + ) + if tc.expectedErrSubstr == "" { + require.NoError(t, err) + require.Equal(t, tc.expectedType, commitmentType) + return + } + + require.ErrorContains(t, err, tc.expectedErrSubstr) + }) + } +} diff --git a/staticaddr/staticutil/utils.go b/staticaddr/staticutil/utils.go index 0d4b8c5c..3c663735 100644 --- a/staticaddr/staticutil/utils.go +++ b/staticaddr/staticutil/utils.go @@ -8,6 +8,7 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop/staticaddr/address" @@ -15,7 +16,9 @@ import ( "github.com/lightninglabs/loop/staticaddr/script" "github.com/lightninglabs/loop/swapserverrpc" "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lnwallet" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" ) // ToPrevOuts converts a slice of deposits to a map of outpoints to TxOuts. @@ -166,20 +169,23 @@ func bip69inputLess(input1, input2 *swapserverrpc.PrevoutInfo) bool { } // SelectDeposits sorts the deposits by amount in descending order. It then -// selects the deposits that are needed to cover the amount requested without -// leaving a dust change. It returns an error if the sum of deposits minus dust -// is less than the requested amount. -func SelectDeposits(deposits []*deposit.Deposit, amount int64) ( - []*deposit.Deposit, error) { +// selects the deposits that are needed to cover the requested amount plus +// transaction fees and dust. The fee rate and commitment type are used to +// estimate the transaction fee for the current selection, since each +// additional input increases the fee. +func SelectDeposits(deposits []*deposit.Deposit, amount int64, + feeRate chainfee.SatPerKWeight, + commitmentType lnrpc.CommitmentType) ([]*deposit.Deposit, error) { - // Check that sum of deposits covers the swap amount while leaving no - // dust change. dustLimit := lnwallet.DustLimitForSize(input.P2TRSize) + + // Quick check: if total deposits can't even cover amount + dust + // (ignoring fees), there's no way to succeed. var depositSum btcutil.Amount - for _, deposit := range deposits { - depositSum += deposit.Value + for _, d := range deposits { + depositSum += d.Value } - if depositSum-dustLimit < btcutil.Amount(amount) { + if depositSum < btcutil.Amount(amount)+dustLimit { return nil, fmt.Errorf("insufficient funds to cover swap " + "amount, try manually selecting deposits") } @@ -189,17 +195,52 @@ func SelectDeposits(deposits []*deposit.Deposit, amount int64) ( return deposits[i].Value > deposits[j].Value }) - // Select the deposits that are needed to cover the swap amount without - // leaving a dust change. + // Select deposits until the total covers the requested amount plus + // the estimated fee and dust reserve. We estimate the fee + // pessimistically with a change output to ensure we always select + // enough. var selectedDeposits []*deposit.Deposit var selectedAmount btcutil.Amount - for _, deposit := range deposits { - if selectedAmount >= btcutil.Amount(amount)+dustLimit { - break + for _, d := range deposits { + selectedDeposits = append(selectedDeposits, d) + selectedAmount += d.Value + + fee := estimateFee( + len(selectedDeposits), feeRate, commitmentType, + ) + + if selectedAmount >= btcutil.Amount(amount)+fee+dustLimit { + return selectedDeposits, nil } - selectedDeposits = append(selectedDeposits, deposit) - selectedAmount += deposit.Value } - return selectedDeposits, nil + // We exhausted all deposits without meeting the threshold. + return nil, fmt.Errorf("insufficient funds to cover swap " + + "amount plus fees, try manually selecting deposits") +} + +// estimateFee returns the estimated fee for a transaction with the given +// number of taproot keyspend inputs and a single output determined by +// the commitment type. It includes a change output in the estimate to +// be conservative. +func estimateFee(numInputs int, feeRate chainfee.SatPerKWeight, + commitmentType lnrpc.CommitmentType) btcutil.Amount { + + var we input.TxWeightEstimator + for i := 0; i < numInputs; i++ { + we.AddTaprootKeySpendInput(txscript.SigHashDefault) + } + + // Add the funding output based on commitment type. + switch commitmentType { + case lnrpc.CommitmentType_SIMPLE_TAPROOT: + we.AddP2TROutput() + default: + we.AddP2WSHOutput() + } + + // Add a change output (P2TR) to be conservative. + we.AddP2TROutput() + + return feeRate.FeeForWeight(we.Weight()) } diff --git a/staticaddr/staticutil/utils_test.go b/staticaddr/staticutil/utils_test.go index 43da817f..0694f8c8 100644 --- a/staticaddr/staticutil/utils_test.go +++ b/staticaddr/staticutil/utils_test.go @@ -16,6 +16,9 @@ import ( looptest "github.com/lightninglabs/loop/test" "github.com/lightningnetwork/lnd/input" "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" "github.com/stretchr/testify/require" ) @@ -234,3 +237,336 @@ func TestCreateMusig2Sessions_Multiple(t *testing.T) { require.True(t, bytes.Equal(nonces[i], sessions[i].PublicNonce[:])) } } + +// makeDeposit creates a deposit with the given value for testing. +func makeDeposit(value btcutil.Amount) *deposit.Deposit { + return &deposit.Deposit{Value: value} +} + +// makeDeposits creates a slice of deposits with the given values. +func makeDeposits(values ...btcutil.Amount) []*deposit.Deposit { + deps := make([]*deposit.Deposit, len(values)) + for i, v := range values { + deps[i] = makeDeposit(v) + } + return deps +} + +// depositSum returns the total value of the given deposits. +func depositSum(deps []*deposit.Deposit) btcutil.Amount { + var total btcutil.Amount + for _, d := range deps { + total += d.Value + } + return total +} + +func TestSelectDeposits(t *testing.T) { + t.Parallel() + + dustLimit := lnwallet.DustLimitForSize(input.P2TRSize) + + // Standard fee rate: 1 sat/vbyte = 250 sat/kw. + lowFeeRate := chainfee.SatPerKVByte(1000).FeePerKWeight() + + // High fee rate: 100 sat/vbyte = 25000 sat/kw. + highFeeRate := chainfee.SatPerKVByte(100_000).FeePerKWeight() + + anchors := lnrpc.CommitmentType_ANCHORS + taproot := lnrpc.CommitmentType_SIMPLE_TAPROOT + + tests := []struct { + name string + deposits []*deposit.Deposit + amount int64 + feeRate chainfee.SatPerKWeight + commitmentType lnrpc.CommitmentType + wantErr string + wantCount int + // validate runs extra assertions on the result. + validate func(t *testing.T, selected []*deposit.Deposit) + }{ + { + name: "insufficient total funds", + deposits: makeDeposits(1_000, 2_000), + amount: 1_000_000, + feeRate: lowFeeRate, + commitmentType: anchors, + wantErr: "insufficient funds", + }, + { + name: "total equals amount but no room for dust", + deposits: makeDeposits(100_000), + amount: 100_000, + feeRate: lowFeeRate, + commitmentType: anchors, + wantErr: "insufficient funds", + }, + { + name: "total covers amount and dust but not fees", + // 1 input high fee = 15400. Need 50k + 15400 + + // 330 = 65730. Deposit = 51k passes the early + // check (51k >= 50k + 330) but the loop finds + // 51k < 65730 and returns an error. + deposits: makeDeposits(51_000), + amount: 50_000, + feeRate: highFeeRate, + commitmentType: anchors, + wantErr: "insufficient funds", + }, + { + name: "many tiny deposits don't block large selection", + // Two large deposits easily cover 400k + fees. + // Many tiny deposits should not cause a false + // rejection in the early check. + deposits: append( + makeDeposits(300_000, 200_000), + makeDeposits( + 100, 100, 100, 100, 100, + 100, 100, 100, 100, 100, + )..., + ), + amount: 400_000, + feeRate: highFeeRate, + commitmentType: anchors, + wantCount: 2, + validate: func(t *testing.T, selected []*deposit.Deposit) { + require.Equal( + t, btcutil.Amount(300_000), + selected[0].Value, + ) + require.Equal( + t, btcutil.Amount(200_000), + selected[1].Value, + ) + }, + }, + { + name: "single deposit covers amount plus fee and dust", + deposits: makeDeposits(500_000), + amount: 100_000, + feeRate: lowFeeRate, + commitmentType: anchors, + wantCount: 1, + }, + { + name: "two deposits needed when first is insufficient", + deposits: makeDeposits(60_000, 60_000), + amount: 100_000, + feeRate: lowFeeRate, + commitmentType: anchors, + wantCount: 2, + }, + { + name: "selects largest deposits first", + deposits: makeDeposits(10_000, 200_000, 50_000), + amount: 100_000, + feeRate: lowFeeRate, + commitmentType: anchors, + wantCount: 1, + validate: func(t *testing.T, selected []*deposit.Deposit) { + // Should pick the 200k deposit. + require.Equal( + t, btcutil.Amount(200_000), + selected[0].Value, + ) + }, + }, + { + name: "fee-awareness selects extra deposit", + // With 2 inputs at high fee: need 50k + 21150 + + // 330 = 71480. Two deposits of 36k = 72k which + // is just above. But a single 36k deposit = 36k + // < 50k + 15400 + 330 = 65730, so 1 is not + // enough. Now make it tighter: amount = 50k, + // deposits = [35_500, 35_500, 10_000]. + // 1 input: need 50k + 15400 + 330 = 65730. 35.5k + // < 65730 -> not enough. + // 2 inputs: need 50k + 21150 + 330 = 71480. + // 35.5k + 35.5k = 71k < 71480 -> not enough! + // 3 inputs: need 50k + 26900 + 330 = 77230. + // 35.5k + 35.5k + 10k = 81k >= 77230 -> enough. + deposits: makeDeposits(35_500, 35_500, 10_000), + amount: 50_000, + feeRate: highFeeRate, + commitmentType: anchors, + wantCount: 3, + validate: func(t *testing.T, selected []*deposit.Deposit) { + total := depositSum(selected) + fee := estimateFee( + len(selected), highFeeRate, + anchors, + ) + require.GreaterOrEqual( + t, total, + btcutil.Amount(50_000)+fee+dustLimit, + ) + }, + }, + { + name: "all deposits selected when all are needed", + deposits: makeDeposits(40_000, 40_000, 40_000), + amount: 100_000, + feeRate: lowFeeRate, + commitmentType: anchors, + wantCount: 3, + }, + { + name: "zero fee rate means only dust matters", + deposits: makeDeposits(100_000, 50_000), + amount: 99_000, + feeRate: 0, + commitmentType: anchors, + wantCount: 1, + validate: func(t *testing.T, selected []*deposit.Deposit) { + // With zero fee, 100k covers 99k + 0 + dust. + require.Equal( + t, btcutil.Amount(100_000), + selected[0].Value, + ) + }, + }, + { + name: "high fee rate forces more deposits", + deposits: makeDeposits(200_000, 100_000, 50_000), + amount: 100_000, + feeRate: highFeeRate, + commitmentType: anchors, + validate: func(t *testing.T, selected []*deposit.Deposit) { + total := depositSum(selected) + fee := estimateFee( + len(selected), highFeeRate, + anchors, + ) + require.GreaterOrEqual( + t, total, + btcutil.Amount(100_000)+fee+dustLimit, + ) + }, + }, + { + name: "taproot commitment type", + deposits: makeDeposits(500_000), + amount: 100_000, + feeRate: lowFeeRate, + commitmentType: taproot, + wantCount: 1, + }, + { + name: "many small deposits accumulate", + deposits: makeDeposits( + 10_000, 10_000, 10_000, 10_000, 10_000, + 10_000, 10_000, 10_000, 10_000, 10_000, + ), + amount: 50_000, + feeRate: lowFeeRate, + commitmentType: anchors, + validate: func(t *testing.T, selected []*deposit.Deposit) { + total := depositSum(selected) + fee := estimateFee( + len(selected), lowFeeRate, + anchors, + ) + require.GreaterOrEqual( + t, total, + btcutil.Amount(50_000)+fee+dustLimit, + ) + // With 10k each and low fees, we need at + // least 6 (50k + dust + fee). + require.GreaterOrEqual( + t, len(selected), 6, + ) + }, + }, + { + name: "selection result satisfies fee invariant", + deposits: makeDeposits( + 80_000, 70_000, 60_000, 50_000, + ), + amount: 150_000, + feeRate: lowFeeRate, + commitmentType: anchors, + validate: func(t *testing.T, selected []*deposit.Deposit) { + total := depositSum(selected) + fee := estimateFee( + len(selected), lowFeeRate, + anchors, + ) + // Core invariant: selected amount covers + // requested amount + fee + dust. + require.GreaterOrEqual( + t, total, + btcutil.Amount(150_000)+fee+dustLimit, + ) + }, + }, + { + name: "deposits sorted descending before selection", + // Give deposits in ascending order; verify largest + // are picked first. + deposits: makeDeposits(10_000, 20_000, 300_000), + amount: 100_000, + feeRate: lowFeeRate, + commitmentType: anchors, + wantCount: 1, + validate: func(t *testing.T, selected []*deposit.Deposit) { + require.Equal( + t, btcutil.Amount(300_000), + selected[0].Value, + ) + }, + }, + { + name: "high fee eats into margin requiring extra deposit", + // Two deposits of 60k each = 120k total. + // Amount = 50k. With low fee: 60k > 50k + fee + + // dust, so 1 deposit suffices. + // With high fee: 60k < 50k + ~10k fee + dust, + // so 2 deposits needed. + deposits: makeDeposits(60_000, 60_000), + amount: 50_000, + feeRate: highFeeRate, + commitmentType: anchors, + wantCount: 2, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + selected, err := SelectDeposits( + tc.deposits, tc.amount, tc.feeRate, + tc.commitmentType, + ) + + if tc.wantErr != "" { + require.Error(t, err) + require.ErrorContains(t, err, tc.wantErr) + return + } + + require.NoError(t, err) + require.NotEmpty(t, selected) + + if tc.wantCount > 0 { + require.Len(t, selected, tc.wantCount) + } + + // Universal invariant: selected deposits must + // cover amount + fee + dust. + total := depositSum(selected) + fee := estimateFee( + len(selected), tc.feeRate, + tc.commitmentType, + ) + require.GreaterOrEqual( + t, total, + btcutil.Amount(tc.amount)+fee+dustLimit, + "selection must cover amount + fee + dust", + ) + + if tc.validate != nil { + tc.validate(t, selected) + } + }) + } +} diff --git a/staticaddr/withdraw/funding_values_test.go b/staticaddr/withdraw/funding_values_test.go new file mode 100644 index 00000000..97e5fd14 --- /dev/null +++ b/staticaddr/withdraw/funding_values_test.go @@ -0,0 +1,265 @@ +package withdraw + +import ( + "testing" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/lightninglabs/loop/staticaddr/deposit" + "github.com/lightningnetwork/lnd/funding" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lnwallet" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/stretchr/testify/require" +) + +// TestCalculateFundingTxValues tests the CalculateWithdrawalTxValues function +// with various channel funding scenarios. +func TestCalculateFundingTxValues(t *testing.T) { + var ( + dustLimit = lnwallet.DustLimitForSize(input.P2TRSize) + satPerVbyte = 1 + allDeposits = []*deposit.Deposit{ + { + Value: 100_000, + }, + { + Value: 200_000, + }, + { + Value: funding.MinChanFundingSize - 1, + }, + } + + chanOpenFeeRate = chainfee.SatPerKVByte( + satPerVbyte * 1000, + ).FeePerKWeight() + + deposits = func(idxs ...int) []*deposit.Deposit { + var selectedDeposits []*deposit.Deposit + for _, i := range idxs { + selectedDeposits = append( + selectedDeposits, allDeposits[i-1], + ) + } + + return selectedDeposits + } + + sum = func(idxs ...int) btcutil.Amount { + var total btcutil.Amount + for _, i := range idxs { + total += allDeposits[i-1].Value + } + + return total + } + ) + + weightWithoutChange, err := WithdrawalTxWeight( + len(allDeposits), nil, lnrpc.CommitmentType_ANCHORS, false, + ) + require.NoError(t, err) + + feeWithoutChange := chanOpenFeeRate.FeeForWeight( + weightWithoutChange, + ) + + weightWithChange, err := WithdrawalTxWeight( + len(allDeposits), nil, lnrpc.CommitmentType_ANCHORS, true, + ) + require.NoError(t, err) + + feeWithChange := chanOpenFeeRate.FeeForWeight( + weightWithChange, + ) + + cases := []struct { + name string + deposits []*deposit.Deposit + localAmount btcutil.Amount + fundMax bool + satPerVbyte uint64 + commitmentType lnrpc.CommitmentType + wantFundingAmt btcutil.Amount + wantChangeAmt btcutil.Amount + wantErr string + }{ + { + name: "fundmax", + deposits: deposits(1, 2, 3), + fundMax: true, + satPerVbyte: 1, + commitmentType: lnrpc.CommitmentType_ANCHORS, + wantFundingAmt: sum(1, 2, 3) - feeWithoutChange, + wantChangeAmt: 0, + wantErr: "", + }, + { + name: "local_amt", + deposits: deposits(1, 2, 3), + localAmount: sum(1, 2, 3) - 10_000, + satPerVbyte: 1, + commitmentType: lnrpc.CommitmentType_ANCHORS, + wantFundingAmt: sum(1, 2, 3) - 10_000, + wantChangeAmt: 10_000 - feeWithChange, + wantErr: "", + }, + { + name: "change to miners", + deposits: deposits(1, 2), + localAmount: sum(1, 2) - dustLimit + 1, + fundMax: false, + satPerVbyte: 1, + commitmentType: lnrpc.CommitmentType_ANCHORS, + wantFundingAmt: sum(1, 2) - dustLimit + 1, + wantChangeAmt: 0, + wantErr: "", + }, + { + name: "change doesn't cover for fees", + deposits: deposits(1, 2), + localAmount: sum(1, 2), + fundMax: false, + satPerVbyte: 1, + commitmentType: lnrpc.CommitmentType_ANCHORS, + wantFundingAmt: 0, + wantChangeAmt: 0, + wantErr: "the change doesn't cover for fees", + }, + { + name: "minimum channel funding size", + deposits: deposits(3), + fundMax: true, + satPerVbyte: 1, + commitmentType: lnrpc.CommitmentType_ANCHORS, + wantFundingAmt: 0, + wantChangeAmt: 0, + wantErr: "minimum channel funding size", + }, + { + name: "satPerVbyte = 0 means no fee", + deposits: deposits(1, 2), + localAmount: sum(1, 2) - 50_000, + satPerVbyte: 0, + commitmentType: lnrpc.CommitmentType_ANCHORS, + wantFundingAmt: sum(1, 2) - 50_000, + wantChangeAmt: 50_000, + wantErr: "", + }, + { + name: "change >= input triggers efficiency error", + deposits: []*deposit.Deposit{ + {Value: 40_000}, + {Value: 60_000}, + }, + localAmount: 40_000, + satPerVbyte: 1, + commitmentType: lnrpc.CommitmentType_ANCHORS, + wantFundingAmt: 40_000, + wantChangeAmt: 60_000 - feeWithChange, + wantErr: "is higher than an input value", + }, + { + name: "channel funding below minimum", + deposits: []*deposit.Deposit{ + {Value: 30_000}, + }, + localAmount: 20_000 - 1, + satPerVbyte: 1, + commitmentType: lnrpc.CommitmentType_ANCHORS, + wantFundingAmt: 0, + wantChangeAmt: 0, + wantErr: "is lower than the minimum", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + feeRate := chainfee.SatPerKVByte( + tc.satPerVbyte * 1000, + ).FeePerKWeight() + fundingAmt, changeAmt, err := CalculateWithdrawalTxValues( + tc.deposits, tc.localAmount, feeRate, nil, + tc.commitmentType, + ) + if tc.wantErr != "" { + require.Error(t, err) + require.ErrorContains(t, err, tc.wantErr) + } else { + require.NoError(t, err) + require.Equal(t, tc.wantFundingAmt, fundingAmt) + require.Equal(t, tc.wantChangeAmt, changeAmt) + } + }) + } +} + +// TestCalculateWithdrawalTxValuesCommitmentTypeParity ensures channel funding +// value calculations are identical whether we derive output type from +// commitment type or from an equivalent funding address type. +func TestCalculateWithdrawalTxValuesCommitmentTypeParity(t *testing.T) { + t.Parallel() + + feeRate := chainfee.SatPerKVByte(1000).FeePerKWeight() + deposits := []*deposit.Deposit{ + {Value: 500_000}, + {Value: 300_000}, + } + + p2wshAddr, err := btcutil.NewAddressWitnessScriptHash( + make([]byte, 32), &chaincfg.RegressionNetParams, + ) + require.NoError(t, err) + + taprootAddr, err := btcutil.NewAddressTaproot( + make([]byte, 32), &chaincfg.RegressionNetParams, + ) + require.NoError(t, err) + + type testCase struct { + name string + commitmentType lnrpc.CommitmentType + addr btcutil.Address + } + + cases := []testCase{ + { + name: "anchors and p2wsh", + commitmentType: lnrpc.CommitmentType_ANCHORS, + addr: p2wshAddr, + }, + { + name: "simple taproot and p2tr", + commitmentType: lnrpc.CommitmentType_SIMPLE_TAPROOT, + addr: taprootAddr, + }, + } + + selectedAmounts := []btcutil.Amount{ + 0, // fundmax/no change path + 600_000, // selected amount with potential change path + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + for _, selected := range selectedAmounts { + fundingByType, changeByType, err := CalculateWithdrawalTxValues( + deposits, selected, feeRate, nil, + tc.commitmentType, + ) + require.NoError(t, err) + + fundingByAddr, changeByAddr, err := CalculateWithdrawalTxValues( + deposits, selected, feeRate, tc.addr, + lnrpc.CommitmentType_UNKNOWN_COMMITMENT_TYPE, + ) + require.NoError(t, err) + + require.Equal(t, fundingByType, fundingByAddr) + require.Equal(t, changeByType, changeByAddr) + } + }) + } +} diff --git a/staticaddr/withdraw/manager.go b/staticaddr/withdraw/manager.go index e71f492d..00de15a0 100644 --- a/staticaddr/withdraw/manager.go +++ b/staticaddr/withdraw/manager.go @@ -410,8 +410,24 @@ func (m *Manager) WithdrawDeposits(ctx context.Context, } } + var withdrawFeeRate chainfee.SatPerKWeight + if satPerVbyte == 0 { + withdrawFeeRate, err = m.cfg.WalletKit.EstimateFeeRate( + ctx, defaultConfTarget, + ) + if err != nil { + return "", "", fmt.Errorf("error estimating fee "+ + "rate: %w", err) + } + } else { + withdrawFeeRate = chainfee.SatPerKVByte( + satPerVbyte * 1000, + ).FeePerKWeight() + } + finalizedTx, _, err := m.CreateFinalizedWithdrawalTx( - ctx, deposits, withdrawalAddress, satPerVbyte, amount, + ctx, deposits, withdrawalAddress, withdrawFeeRate, amount, + lnrpc.CommitmentType_UNKNOWN_COMMITMENT_TYPE, ) if err != nil { return "", "", err @@ -510,8 +526,9 @@ func (m *Manager) WithdrawDeposits(ctx context.Context, // signed *wire.MsgTx representation and the unsigned psbt. func (m *Manager) CreateFinalizedWithdrawalTx(ctx context.Context, deposits []*deposit.Deposit, withdrawalAddress btcutil.Address, - satPerVbyte int64, selectedWithdrawalAmount int64) (*wire.MsgTx, []byte, - error) { + feeRate chainfee.SatPerKWeight, + selectedWithdrawalAmount int64, + commitmentType lnrpc.CommitmentType) (*wire.MsgTx, []byte, error) { // Create a musig2 session for each deposit. addrParams, err := m.cfg.AddressManager.GetStaticAddressParameters(ctx) @@ -531,21 +548,6 @@ func (m *Manager) CreateFinalizedWithdrawalTx(ctx context.Context, return nil, nil, err } - var withdrawalSweepFeeRate chainfee.SatPerKWeight - if satPerVbyte == 0 { - // Get the fee rate for the withdrawal sweep. - withdrawalSweepFeeRate, err = m.cfg.WalletKit.EstimateFeeRate( - ctx, defaultConfTarget, - ) - if err != nil { - return nil, nil, err - } - } else { - withdrawalSweepFeeRate = chainfee.SatPerKVByte( - satPerVbyte * 1000, - ).FeePerKWeight() - } - params, err := m.cfg.AddressManager.GetStaticAddressParameters(ctx) if err != nil { return nil, nil, fmt.Errorf("couldn't get confirmation "+ @@ -561,7 +563,7 @@ func (m *Manager) CreateFinalizedWithdrawalTx(ctx context.Context, withdrawalTx, unsignedPsbt, err := m.createWithdrawalTx( ctx, outpoints, deposits, prevOuts, btcutil.Amount(selectedWithdrawalAmount), withdrawalAddress, - withdrawalSweepFeeRate, + feeRate, commitmentType, ) if err != nil { return nil, nil, err @@ -850,7 +852,8 @@ func (m *Manager) createWithdrawalTx(ctx context.Context, outpoints []wire.OutPoint, deposits []*deposit.Deposit, prevOuts map[wire.OutPoint]*wire.TxOut, selectedWithdrawalAmount btcutil.Amount, withdrawAddr btcutil.Address, - feeRate chainfee.SatPerKWeight) (*wire.MsgTx, []byte, error) { + feeRate chainfee.SatPerKWeight, + commitmentType lnrpc.CommitmentType) (*wire.MsgTx, []byte, error) { // First Create the tx. msgTx := wire.NewMsgTx(2) @@ -865,7 +868,7 @@ func (m *Manager) createWithdrawalTx(ctx context.Context, withdrawalAmount, changeAmount, err := CalculateWithdrawalTxValues( deposits, selectedWithdrawalAmount, feeRate, - withdrawAddr, lnrpc.CommitmentType_UNKNOWN_COMMITMENT_TYPE, + withdrawAddr, commitmentType, ) if err != nil { return nil, nil, fmt.Errorf("error calculating funding tx "+ @@ -952,8 +955,11 @@ func (m *Manager) createWithdrawalTx(ctx context.Context, return msgTx, psbtBuf.Bytes(), nil } +// CalculateWithdrawalTxValues calculates the values of the withdrawal +// transaction. It returns the withdrawal amount, the change amount, and an +// error if any. func CalculateWithdrawalTxValues(deposits []*deposit.Deposit, - localAmount btcutil.Amount, feeRate chainfee.SatPerKWeight, + selectedAmount btcutil.Amount, feeRate chainfee.SatPerKWeight, withdrawalAddress btcutil.Address, commitmentType lnrpc.CommitmentType) (btcutil.Amount, btcutil.Amount, error) { @@ -978,7 +984,7 @@ func CalculateWithdrawalTxValues(deposits []*deposit.Deposit, totalDepositAmount += d.Value } - // Estimate the open channel transaction fee without change. + // Estimate the withdrawal transaction fee without change. hasChange := false weight, err := WithdrawalTxWeight( len(deposits), withdrawalAddress, commitmentType, hasChange, @@ -988,9 +994,9 @@ func CalculateWithdrawalTxValues(deposits []*deposit.Deposit, } feeWithoutChange := feeRate.FeeForWeight(weight) - // If the user selected a local amount for the channel, check if a - // change output is needed. - if localAmount > 0 { + // If the user selected an amount to withdraw, check if a change output + // is needed. + if selectedAmount > 0 { // Estimate the transaction weight with change. hasChange = true weightWithChange, err := WithdrawalTxWeight( @@ -1003,30 +1009,30 @@ func CalculateWithdrawalTxValues(deposits []*deposit.Deposit, feeWithChange := feeRate.FeeForWeight(weightWithChange) // The available change that can cover fees is the total - // selected deposit amount minus the local channel amount. - change := totalDepositAmount - localAmount + // selected deposit amount minus the selected amount. + change := totalDepositAmount - selectedAmount switch { case change-feeWithChange >= dustLimit: // If the change can cover the fees without turning into // dust, add a non-dust change output. changeAmount = change - feeWithChange - withdrawalFundingAmt = localAmount + withdrawalFundingAmt = selectedAmount case change-feeWithoutChange >= 0: // If the change is dust, we give it to the miners. - withdrawalFundingAmt = localAmount + withdrawalFundingAmt = selectedAmount default: - // If the fees eat into our local channel amount, we - // fail to open the channel. + // If the fees eat into our selected amount, we fail the + // withdrawal. return 0, 0, fmt.Errorf("the change doesn't " + "cover for fees. Consider lowering the fee " + - "rate or decrease the local amount") + "rate or decrease the selected amount") } } else { - // If the user wants to open the channel with the total value of - // deposits, we don't need a change output. + // If the user wants to withdraw the total value of deposits, we + // don't need a change output. withdrawalFundingAmt = totalDepositAmount - feeWithoutChange } @@ -1038,8 +1044,8 @@ func CalculateWithdrawalTxValues(deposits []*deposit.Deposit, return 0, 0, fmt.Errorf("change amount is negative") } - // Ensure that the channel funding amount is at least in the amount of - // lnd's minimum channel size. + // In case of a channel open, ensure that the channel funding amount is + // at least in the amount of lnd's minimum channel size. if isChannelOpen && withdrawalFundingAmt < funding.MinChanFundingSize { return 0, 0, fmt.Errorf("channel funding amount %v is lower "+ "than the minimum channel funding size %v",