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/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)
|
||||
}
|
||||
|
|
|
|||
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/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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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(
|
||||
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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue