mirror of
https://github.com/lightninglabs/loop.git
synced 2026-08-13 12:33:03 +02:00
staticaddr: open channel manager
This commit is contained in:
parent
d12663ea7c
commit
dfc75f2e1a
9 changed files with 1889 additions and 55 deletions
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"github.com/lightninglabs/loop/staticaddr/address"
|
"github.com/lightninglabs/loop/staticaddr/address"
|
||||||
"github.com/lightninglabs/loop/staticaddr/deposit"
|
"github.com/lightninglabs/loop/staticaddr/deposit"
|
||||||
"github.com/lightninglabs/loop/staticaddr/loopin"
|
"github.com/lightninglabs/loop/staticaddr/loopin"
|
||||||
|
"github.com/lightninglabs/loop/staticaddr/openchannel"
|
||||||
"github.com/lightninglabs/loop/staticaddr/withdraw"
|
"github.com/lightninglabs/loop/staticaddr/withdraw"
|
||||||
"github.com/lightningnetwork/lnd/build"
|
"github.com/lightningnetwork/lnd/build"
|
||||||
)
|
)
|
||||||
|
|
@ -29,4 +30,5 @@ func UseLogger(logger btclog.Logger) {
|
||||||
deposit.UseLogger(log)
|
deposit.UseLogger(log)
|
||||||
withdraw.UseLogger(log)
|
withdraw.UseLogger(log)
|
||||||
loopin.UseLogger(log)
|
loopin.UseLogger(log)
|
||||||
|
openchannel.UseLogger(log)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
50
staticaddr/openchannel/interface.go
Normal file
50
staticaddr/openchannel/interface.go
Normal 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)
|
||||||
|
}
|
||||||
24
staticaddr/openchannel/log.go
Normal file
24
staticaddr/openchannel/log.go
Normal 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
|
||||||
|
}
|
||||||
796
staticaddr/openchannel/manager.go
Normal file
796
staticaddr/openchannel/manager.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
314
staticaddr/openchannel/manager_test.go
Normal file
314
staticaddr/openchannel/manager_test.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
|
|
||||||
"github.com/btcsuite/btcd/btcutil"
|
"github.com/btcsuite/btcd/btcutil"
|
||||||
"github.com/btcsuite/btcd/chaincfg/chainhash"
|
"github.com/btcsuite/btcd/chaincfg/chainhash"
|
||||||
|
"github.com/btcsuite/btcd/txscript"
|
||||||
"github.com/btcsuite/btcd/wire"
|
"github.com/btcsuite/btcd/wire"
|
||||||
"github.com/lightninglabs/lndclient"
|
"github.com/lightninglabs/lndclient"
|
||||||
"github.com/lightninglabs/loop/staticaddr/address"
|
"github.com/lightninglabs/loop/staticaddr/address"
|
||||||
|
|
@ -15,7 +16,9 @@ import (
|
||||||
"github.com/lightninglabs/loop/staticaddr/script"
|
"github.com/lightninglabs/loop/staticaddr/script"
|
||||||
"github.com/lightninglabs/loop/swapserverrpc"
|
"github.com/lightninglabs/loop/swapserverrpc"
|
||||||
"github.com/lightningnetwork/lnd/input"
|
"github.com/lightningnetwork/lnd/input"
|
||||||
|
"github.com/lightningnetwork/lnd/lnrpc"
|
||||||
"github.com/lightningnetwork/lnd/lnwallet"
|
"github.com/lightningnetwork/lnd/lnwallet"
|
||||||
|
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ToPrevOuts converts a slice of deposits to a map of outpoints to TxOuts.
|
// 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
|
// SelectDeposits sorts the deposits by amount in descending order. It then
|
||||||
// selects the deposits that are needed to cover the amount requested without
|
// selects the deposits that are needed to cover the requested amount plus
|
||||||
// leaving a dust change. It returns an error if the sum of deposits minus dust
|
// transaction fees and dust. The fee rate and commitment type are used to
|
||||||
// is less than the requested amount.
|
// estimate the transaction fee for the current selection, since each
|
||||||
func SelectDeposits(deposits []*deposit.Deposit, amount int64) (
|
// additional input increases the fee.
|
||||||
[]*deposit.Deposit, error) {
|
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)
|
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
|
var depositSum btcutil.Amount
|
||||||
for _, deposit := range deposits {
|
for _, d := range deposits {
|
||||||
depositSum += deposit.Value
|
depositSum += d.Value
|
||||||
}
|
}
|
||||||
if depositSum-dustLimit < btcutil.Amount(amount) {
|
if depositSum < btcutil.Amount(amount)+dustLimit {
|
||||||
return nil, fmt.Errorf("insufficient funds to cover swap " +
|
return nil, fmt.Errorf("insufficient funds to cover swap " +
|
||||||
"amount, try manually selecting deposits")
|
"amount, try manually selecting deposits")
|
||||||
}
|
}
|
||||||
|
|
@ -189,17 +195,52 @@ func SelectDeposits(deposits []*deposit.Deposit, amount int64) (
|
||||||
return deposits[i].Value > deposits[j].Value
|
return deposits[i].Value > deposits[j].Value
|
||||||
})
|
})
|
||||||
|
|
||||||
// Select the deposits that are needed to cover the swap amount without
|
// Select deposits until the total covers the requested amount plus
|
||||||
// leaving a dust change.
|
// 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 selectedDeposits []*deposit.Deposit
|
||||||
var selectedAmount btcutil.Amount
|
var selectedAmount btcutil.Amount
|
||||||
for _, deposit := range deposits {
|
for _, d := range deposits {
|
||||||
if selectedAmount >= btcutil.Amount(amount)+dustLimit {
|
selectedDeposits = append(selectedDeposits, d)
|
||||||
break
|
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())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@ import (
|
||||||
looptest "github.com/lightninglabs/loop/test"
|
looptest "github.com/lightninglabs/loop/test"
|
||||||
"github.com/lightningnetwork/lnd/input"
|
"github.com/lightningnetwork/lnd/input"
|
||||||
"github.com/lightningnetwork/lnd/keychain"
|
"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"
|
"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[:]))
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
265
staticaddr/withdraw/funding_values_test.go
Normal file
265
staticaddr/withdraw/funding_values_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(
|
finalizedTx, _, err := m.CreateFinalizedWithdrawalTx(
|
||||||
ctx, deposits, withdrawalAddress, satPerVbyte, amount,
|
ctx, deposits, withdrawalAddress, withdrawFeeRate, amount,
|
||||||
|
lnrpc.CommitmentType_UNKNOWN_COMMITMENT_TYPE,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
|
|
@ -510,8 +526,9 @@ func (m *Manager) WithdrawDeposits(ctx context.Context,
|
||||||
// signed *wire.MsgTx representation and the unsigned psbt.
|
// signed *wire.MsgTx representation and the unsigned psbt.
|
||||||
func (m *Manager) CreateFinalizedWithdrawalTx(ctx context.Context,
|
func (m *Manager) CreateFinalizedWithdrawalTx(ctx context.Context,
|
||||||
deposits []*deposit.Deposit, withdrawalAddress btcutil.Address,
|
deposits []*deposit.Deposit, withdrawalAddress btcutil.Address,
|
||||||
satPerVbyte int64, selectedWithdrawalAmount int64) (*wire.MsgTx, []byte,
|
feeRate chainfee.SatPerKWeight,
|
||||||
error) {
|
selectedWithdrawalAmount int64,
|
||||||
|
commitmentType lnrpc.CommitmentType) (*wire.MsgTx, []byte, error) {
|
||||||
|
|
||||||
// Create a musig2 session for each deposit.
|
// Create a musig2 session for each deposit.
|
||||||
addrParams, err := m.cfg.AddressManager.GetStaticAddressParameters(ctx)
|
addrParams, err := m.cfg.AddressManager.GetStaticAddressParameters(ctx)
|
||||||
|
|
@ -531,21 +548,6 @@ func (m *Manager) CreateFinalizedWithdrawalTx(ctx context.Context,
|
||||||
return nil, nil, err
|
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)
|
params, err := m.cfg.AddressManager.GetStaticAddressParameters(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("couldn't get confirmation "+
|
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(
|
withdrawalTx, unsignedPsbt, err := m.createWithdrawalTx(
|
||||||
ctx, outpoints, deposits, prevOuts,
|
ctx, outpoints, deposits, prevOuts,
|
||||||
btcutil.Amount(selectedWithdrawalAmount), withdrawalAddress,
|
btcutil.Amount(selectedWithdrawalAmount), withdrawalAddress,
|
||||||
withdrawalSweepFeeRate,
|
feeRate, commitmentType,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
|
|
@ -850,7 +852,8 @@ func (m *Manager) createWithdrawalTx(ctx context.Context,
|
||||||
outpoints []wire.OutPoint, deposits []*deposit.Deposit,
|
outpoints []wire.OutPoint, deposits []*deposit.Deposit,
|
||||||
prevOuts map[wire.OutPoint]*wire.TxOut,
|
prevOuts map[wire.OutPoint]*wire.TxOut,
|
||||||
selectedWithdrawalAmount btcutil.Amount, withdrawAddr btcutil.Address,
|
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.
|
// First Create the tx.
|
||||||
msgTx := wire.NewMsgTx(2)
|
msgTx := wire.NewMsgTx(2)
|
||||||
|
|
@ -865,7 +868,7 @@ func (m *Manager) createWithdrawalTx(ctx context.Context,
|
||||||
|
|
||||||
withdrawalAmount, changeAmount, err := CalculateWithdrawalTxValues(
|
withdrawalAmount, changeAmount, err := CalculateWithdrawalTxValues(
|
||||||
deposits, selectedWithdrawalAmount, feeRate,
|
deposits, selectedWithdrawalAmount, feeRate,
|
||||||
withdrawAddr, lnrpc.CommitmentType_UNKNOWN_COMMITMENT_TYPE,
|
withdrawAddr, commitmentType,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("error calculating funding tx "+
|
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
|
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,
|
func CalculateWithdrawalTxValues(deposits []*deposit.Deposit,
|
||||||
localAmount btcutil.Amount, feeRate chainfee.SatPerKWeight,
|
selectedAmount btcutil.Amount, feeRate chainfee.SatPerKWeight,
|
||||||
withdrawalAddress btcutil.Address,
|
withdrawalAddress btcutil.Address,
|
||||||
commitmentType lnrpc.CommitmentType) (btcutil.Amount, btcutil.Amount,
|
commitmentType lnrpc.CommitmentType) (btcutil.Amount, btcutil.Amount,
|
||||||
error) {
|
error) {
|
||||||
|
|
@ -978,7 +984,7 @@ func CalculateWithdrawalTxValues(deposits []*deposit.Deposit,
|
||||||
totalDepositAmount += d.Value
|
totalDepositAmount += d.Value
|
||||||
}
|
}
|
||||||
|
|
||||||
// Estimate the open channel transaction fee without change.
|
// Estimate the withdrawal transaction fee without change.
|
||||||
hasChange := false
|
hasChange := false
|
||||||
weight, err := WithdrawalTxWeight(
|
weight, err := WithdrawalTxWeight(
|
||||||
len(deposits), withdrawalAddress, commitmentType, hasChange,
|
len(deposits), withdrawalAddress, commitmentType, hasChange,
|
||||||
|
|
@ -988,9 +994,9 @@ func CalculateWithdrawalTxValues(deposits []*deposit.Deposit,
|
||||||
}
|
}
|
||||||
feeWithoutChange := feeRate.FeeForWeight(weight)
|
feeWithoutChange := feeRate.FeeForWeight(weight)
|
||||||
|
|
||||||
// If the user selected a local amount for the channel, check if a
|
// If the user selected an amount to withdraw, check if a change output
|
||||||
// change output is needed.
|
// is needed.
|
||||||
if localAmount > 0 {
|
if selectedAmount > 0 {
|
||||||
// Estimate the transaction weight with change.
|
// Estimate the transaction weight with change.
|
||||||
hasChange = true
|
hasChange = true
|
||||||
weightWithChange, err := WithdrawalTxWeight(
|
weightWithChange, err := WithdrawalTxWeight(
|
||||||
|
|
@ -1003,30 +1009,30 @@ func CalculateWithdrawalTxValues(deposits []*deposit.Deposit,
|
||||||
feeWithChange := feeRate.FeeForWeight(weightWithChange)
|
feeWithChange := feeRate.FeeForWeight(weightWithChange)
|
||||||
|
|
||||||
// The available change that can cover fees is the total
|
// The available change that can cover fees is the total
|
||||||
// selected deposit amount minus the local channel amount.
|
// selected deposit amount minus the selected amount.
|
||||||
change := totalDepositAmount - localAmount
|
change := totalDepositAmount - selectedAmount
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case change-feeWithChange >= dustLimit:
|
case change-feeWithChange >= dustLimit:
|
||||||
// If the change can cover the fees without turning into
|
// If the change can cover the fees without turning into
|
||||||
// dust, add a non-dust change output.
|
// dust, add a non-dust change output.
|
||||||
changeAmount = change - feeWithChange
|
changeAmount = change - feeWithChange
|
||||||
withdrawalFundingAmt = localAmount
|
withdrawalFundingAmt = selectedAmount
|
||||||
|
|
||||||
case change-feeWithoutChange >= 0:
|
case change-feeWithoutChange >= 0:
|
||||||
// If the change is dust, we give it to the miners.
|
// If the change is dust, we give it to the miners.
|
||||||
withdrawalFundingAmt = localAmount
|
withdrawalFundingAmt = selectedAmount
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// If the fees eat into our local channel amount, we
|
// If the fees eat into our selected amount, we fail the
|
||||||
// fail to open the channel.
|
// withdrawal.
|
||||||
return 0, 0, fmt.Errorf("the change doesn't " +
|
return 0, 0, fmt.Errorf("the change doesn't " +
|
||||||
"cover for fees. Consider lowering the fee " +
|
"cover for fees. Consider lowering the fee " +
|
||||||
"rate or decrease the local amount")
|
"rate or decrease the selected amount")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// If the user wants to open the channel with the total value of
|
// If the user wants to withdraw the total value of deposits, we
|
||||||
// deposits, we don't need a change output.
|
// don't need a change output.
|
||||||
withdrawalFundingAmt = totalDepositAmount - feeWithoutChange
|
withdrawalFundingAmt = totalDepositAmount - feeWithoutChange
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1038,8 +1044,8 @@ func CalculateWithdrawalTxValues(deposits []*deposit.Deposit,
|
||||||
return 0, 0, fmt.Errorf("change amount is negative")
|
return 0, 0, fmt.Errorf("change amount is negative")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure that the channel funding amount is at least in the amount of
|
// In case of a channel open, ensure that the channel funding amount is
|
||||||
// lnd's minimum channel size.
|
// at least in the amount of lnd's minimum channel size.
|
||||||
if isChannelOpen && withdrawalFundingAmt < funding.MinChanFundingSize {
|
if isChannelOpen && withdrawalFundingAmt < funding.MinChanFundingSize {
|
||||||
return 0, 0, fmt.Errorf("channel funding amount %v is lower "+
|
return 0, 0, fmt.Errorf("channel funding amount %v is lower "+
|
||||||
"than the minimum channel funding size %v",
|
"than the minimum channel funding size %v",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue