staticaddr: open channel manager

This commit is contained in:
Slyghtning 2025-05-21 14:17:14 +02:00
parent d12663ea7c
commit dfc75f2e1a
No known key found for this signature in database
GPG key ID: F82D456EA023C9BF
9 changed files with 1889 additions and 55 deletions

View file

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

View file

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

View file

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

View file

@ -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 <Ctrl+C> 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")
}
}

View file

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

View file

@ -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())
}

View file

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

View file

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

View file

@ -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",