mirror of
https://github.com/lightninglabs/loop.git
synced 2026-08-13 12:33:03 +02:00
staticaddr: arbitrary loop-in amount
In this commit we add a new function SelectDeposits to the loop-in manager. It coin-selects deposits that meet an arbitrary swap amount provided by the client. We have to ensure that the server creates the correct change outputs for the htlc- and sweepless sweep transactions.
This commit is contained in:
parent
4931fb9ccd
commit
581761a12f
7 changed files with 529 additions and 46 deletions
|
|
@ -338,6 +338,11 @@ type StaticAddressLoopInRequest struct {
|
|||
// swap payment. If the timeout is reached the swap will be aborted and
|
||||
// the client can retry the swap if desired with different parameters.
|
||||
PaymentTimeoutSeconds uint32
|
||||
|
||||
// SelectedAmount is the amount that the user selected for the swap. If
|
||||
// the user did not select an amount, the amount of the selected
|
||||
// deposits is used.
|
||||
SelectedAmount btcutil.Amount
|
||||
}
|
||||
|
||||
// LoopInTerms are the server terms on which it executes loop in swaps.
|
||||
|
|
|
|||
|
|
@ -68,9 +68,18 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
|
|||
}
|
||||
|
||||
// Calculate the swap invoice amount. The server needs to pay us the
|
||||
// sum of all deposits minus the fees that the server charges for the
|
||||
// swap.
|
||||
swapInvoiceAmt := f.loopIn.TotalDepositAmount() - f.loopIn.QuotedSwapFee
|
||||
// swap amount minus the fees that the server charges for the swap. The
|
||||
// swap amount is either the total value of the selected deposits, or
|
||||
// the selected amount if a specific amount was requested.
|
||||
swapAmount := f.loopIn.TotalDepositAmount()
|
||||
var hasChange bool
|
||||
if f.loopIn.SelectedAmount > 0 {
|
||||
swapAmount = f.loopIn.SelectedAmount
|
||||
remainingAmount := f.loopIn.TotalDepositAmount() - swapAmount
|
||||
hasChange = remainingAmount > 0 && remainingAmount <
|
||||
f.loopIn.TotalDepositAmount()
|
||||
}
|
||||
swapInvoiceAmt := swapAmount - f.loopIn.QuotedSwapFee
|
||||
|
||||
// Generate random preimage.
|
||||
var swapPreimage lntypes.Preimage
|
||||
|
|
@ -120,6 +129,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
|
|||
loopInReq := &swapserverrpc.ServerStaticAddressLoopInRequest{
|
||||
SwapHash: f.loopIn.SwapHash[:],
|
||||
DepositOutpoints: f.loopIn.DepositOutpoints,
|
||||
Amount: uint64(f.loopIn.SelectedAmount),
|
||||
HtlcClientPubKey: f.loopIn.ClientPubkey.SerializeCompressed(),
|
||||
SwapInvoice: f.loopIn.SwapInvoice,
|
||||
ProtocolVersion: version.CurrentRPCProtocolVersion(),
|
||||
|
|
@ -204,7 +214,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
|
|||
// We need to defend against the server setting high fees for the htlc
|
||||
// tx since we might have to sweep the timeout path. We maximally allow
|
||||
// a configured percentage of the swap value to be spent on fees.
|
||||
amt := float64(f.loopIn.TotalDepositAmount())
|
||||
amt := float64(swapAmount)
|
||||
maxHtlcTxFee := btcutil.Amount(amt *
|
||||
f.cfg.MaxStaticAddrHtlcFeePercentage)
|
||||
|
||||
|
|
@ -212,7 +222,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
|
|||
f.cfg.MaxStaticAddrHtlcBackupFeePercentage)
|
||||
|
||||
feeRate := chainfee.SatPerKWeight(loopInResp.StandardHtlcInfo.FeeRate)
|
||||
fee := feeRate.FeeForWeight(f.loopIn.htlcWeight())
|
||||
fee := feeRate.FeeForWeight(f.loopIn.htlcWeight(hasChange))
|
||||
if fee > maxHtlcTxFee {
|
||||
// Abort the swap by pushing empty sigs to the server.
|
||||
pushEmptySigs()
|
||||
|
|
@ -225,7 +235,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
|
|||
f.loopIn.HtlcTxFeeRate = feeRate
|
||||
|
||||
highFeeRate := chainfee.SatPerKWeight(loopInResp.HighFeeHtlcInfo.FeeRate)
|
||||
fee = highFeeRate.FeeForWeight(f.loopIn.htlcWeight())
|
||||
fee = highFeeRate.FeeForWeight(f.loopIn.htlcWeight(hasChange))
|
||||
if fee > maxHtlcTxBackupFee {
|
||||
// Abort the swap by pushing empty sigs to the server.
|
||||
pushEmptySigs()
|
||||
|
|
@ -241,7 +251,7 @@ func (f *FSM) InitHtlcAction(ctx context.Context,
|
|||
extremelyHighFeeRate := chainfee.SatPerKWeight(
|
||||
loopInResp.ExtremeFeeHtlcInfo.FeeRate,
|
||||
)
|
||||
fee = extremelyHighFeeRate.FeeForWeight(f.loopIn.htlcWeight())
|
||||
fee = extremelyHighFeeRate.FeeForWeight(f.loopIn.htlcWeight(hasChange))
|
||||
if fee > maxHtlcTxBackupFee {
|
||||
// Abort the swap by pushing empty sigs to the server.
|
||||
pushEmptySigs()
|
||||
|
|
|
|||
85
staticaddr/loopin/deduce_amount_test.go
Normal file
85
staticaddr/loopin/deduce_amount_test.go
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
package loopin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
"github.com/lightningnetwork/lnd/input"
|
||||
"github.com/lightningnetwork/lnd/lnwallet"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestDeduceSwapAmount covers all validation branches of DeduceSwapAmount.
|
||||
func TestDeduceSwapAmount(t *testing.T) {
|
||||
dl := lnwallet.DustLimitForSize(input.P2TRSize)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
total btcutil.Amount
|
||||
selectAmt btcutil.Amount
|
||||
wantAmt btcutil.Amount
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "negative selected amount",
|
||||
total: dl * 10,
|
||||
selectAmt: -1,
|
||||
wantErr: "negative",
|
||||
},
|
||||
{
|
||||
name: "selected is dust (>0 < dust)",
|
||||
total: dl * 10,
|
||||
selectAmt: dl - 1,
|
||||
wantErr: "is dust",
|
||||
},
|
||||
{
|
||||
name: "total deposit is dust",
|
||||
total: dl - 1,
|
||||
selectAmt: 0,
|
||||
wantErr: "total deposit value",
|
||||
},
|
||||
{
|
||||
name: "selected exceeds total",
|
||||
total: dl * 5,
|
||||
selectAmt: dl*5 + 1,
|
||||
wantErr: "exceeds total",
|
||||
},
|
||||
{
|
||||
name: "leaves dust change",
|
||||
total: dl*5 + (dl - 1),
|
||||
selectAmt: dl * 5,
|
||||
wantErr: "leaves dust change",
|
||||
},
|
||||
{
|
||||
name: "selected zero => swap total",
|
||||
total: dl * 7,
|
||||
selectAmt: 0,
|
||||
wantAmt: dl * 7,
|
||||
},
|
||||
{
|
||||
name: "selected equals total",
|
||||
total: dl * 9,
|
||||
selectAmt: dl * 9,
|
||||
wantAmt: dl * 9,
|
||||
},
|
||||
{
|
||||
name: "selected and remaining both >= dust",
|
||||
total: dl*10 + dl, // 11*dust
|
||||
selectAmt: dl * 10, // remaining = dust
|
||||
wantAmt: dl * 10,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
amt, err := DeduceSwapAmount(tc.total, tc.selectAmt)
|
||||
if tc.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, tc.wantErr)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.wantAmt, amt)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -53,6 +53,11 @@ type DepositManager interface {
|
|||
// outpoints.
|
||||
DepositsForOutpoints(ctx context.Context, outpoints []string) (
|
||||
[]*deposit.Deposit, error)
|
||||
|
||||
// GetActiveDepositsInState returns all active deposits in the given
|
||||
// state.
|
||||
GetActiveDepositsInState(stateFilter fsm.StateType) ([]*deposit.Deposit,
|
||||
error)
|
||||
}
|
||||
|
||||
// StaticAddressLoopInStore provides access to the static address loop-in DB.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package loopin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
|
@ -91,8 +92,15 @@ type StaticAddressLoopIn struct {
|
|||
|
||||
// The outpoints in the format txid:vout that are part of the loop-in
|
||||
// swap.
|
||||
// TODO(hieblmi): Replace this with a getter method that fetches the
|
||||
// outpoints from the deposits.
|
||||
DepositOutpoints []string
|
||||
|
||||
// SelectedAmount is the amount that the user selected for the swap. If
|
||||
// the user did not select an amount, the amount of all deposits is
|
||||
// used.
|
||||
SelectedAmount btcutil.Amount
|
||||
|
||||
// state is the current state of the swap.
|
||||
state fsm.StateType
|
||||
|
||||
|
|
@ -283,14 +291,25 @@ func (l *StaticAddressLoopIn) createHtlcTx(chainParams *chaincfg.Params,
|
|||
})
|
||||
}
|
||||
|
||||
// Determine the swap amount. If the user selected a specific amount, we
|
||||
// use that and use the difference to the total deposit amount as the
|
||||
// change.
|
||||
var (
|
||||
swapAmt = l.TotalDepositAmount()
|
||||
changeAmount btcutil.Amount
|
||||
)
|
||||
if l.SelectedAmount > 0 {
|
||||
swapAmt = l.SelectedAmount
|
||||
changeAmount = l.TotalDepositAmount() - l.SelectedAmount
|
||||
}
|
||||
|
||||
// Calculate htlc tx fee for server provided fee rate.
|
||||
weight := l.htlcWeight()
|
||||
hasChange := changeAmount > 0
|
||||
weight := l.htlcWeight(hasChange)
|
||||
fee := feeRate.FeeForWeight(weight)
|
||||
|
||||
// Check if the server breaches our fee limits.
|
||||
amt := float64(l.TotalDepositAmount())
|
||||
feeLimit := btcutil.Amount(amt * maxFeePercentage)
|
||||
|
||||
feeLimit := btcutil.Amount(float64(swapAmt) * maxFeePercentage)
|
||||
if fee > feeLimit {
|
||||
return nil, fmt.Errorf("htlc tx fee %v exceeds max fee %v",
|
||||
fee, feeLimit)
|
||||
|
|
@ -308,12 +327,20 @@ func (l *StaticAddressLoopIn) createHtlcTx(chainParams *chaincfg.Params,
|
|||
|
||||
// Create the sweep output
|
||||
sweepOutput := &wire.TxOut{
|
||||
Value: int64(l.TotalDepositAmount()) - int64(fee),
|
||||
Value: int64(swapAmt - fee),
|
||||
PkScript: pkscript,
|
||||
}
|
||||
|
||||
msgTx.AddTxOut(sweepOutput)
|
||||
|
||||
// We expect change to be sent back to our static address output script.
|
||||
if changeAmount > 0 {
|
||||
msgTx.AddTxOut(&wire.TxOut{
|
||||
Value: int64(changeAmount),
|
||||
PkScript: l.AddressParams.PkScript,
|
||||
})
|
||||
}
|
||||
|
||||
return msgTx, nil
|
||||
}
|
||||
|
||||
|
|
@ -325,7 +352,7 @@ func (l *StaticAddressLoopIn) isHtlcTimedOut(height int32) bool {
|
|||
}
|
||||
|
||||
// htlcWeight returns the weight for the htlc transaction.
|
||||
func (l *StaticAddressLoopIn) htlcWeight() lntypes.WeightUnit {
|
||||
func (l *StaticAddressLoopIn) htlcWeight(hasChange bool) lntypes.WeightUnit {
|
||||
var weightEstimator input.TxWeightEstimator
|
||||
for i := 0; i < len(l.Deposits); i++ {
|
||||
weightEstimator.AddTaprootKeySpendInput(
|
||||
|
|
@ -335,6 +362,10 @@ func (l *StaticAddressLoopIn) htlcWeight() lntypes.WeightUnit {
|
|||
|
||||
weightEstimator.AddP2WSHOutput()
|
||||
|
||||
if hasChange {
|
||||
weightEstimator.AddP2TROutput()
|
||||
}
|
||||
|
||||
return weightEstimator.Weight()
|
||||
}
|
||||
|
||||
|
|
@ -373,11 +404,25 @@ func (l *StaticAddressLoopIn) createHtlcSweepTx(ctx context.Context,
|
|||
return nil, err
|
||||
}
|
||||
|
||||
// Check if the htlc tx has a change output. If so we need to select the
|
||||
// non-change output index to construct the sweep with.
|
||||
htlcInputIndex := uint32(0)
|
||||
if len(htlcTx.TxOut) == 2 {
|
||||
// If the first htlc tx output matches our static address
|
||||
// script we need to select the second output to sweep from.
|
||||
if bytes.Equal(
|
||||
htlcTx.TxOut[0].PkScript, l.AddressParams.PkScript,
|
||||
) {
|
||||
|
||||
htlcInputIndex = 1
|
||||
}
|
||||
}
|
||||
|
||||
// Add the htlc input.
|
||||
sweepTx.AddTxIn(&wire.TxIn{
|
||||
PreviousOutPoint: wire.OutPoint{
|
||||
Hash: htlcTx.TxHash(),
|
||||
Index: 0,
|
||||
Index: htlcInputIndex,
|
||||
},
|
||||
SignatureScript: htlc.SigScript,
|
||||
Sequence: htlc.SuccessSequence(),
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@ import (
|
|||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/btcsuite/btcd/btcec/v2/schnorr/musig2"
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
"github.com/btcsuite/btcd/btcutil/psbt"
|
||||
"github.com/btcsuite/btcd/chaincfg"
|
||||
"github.com/btcsuite/btcd/chaincfg/chainhash"
|
||||
|
|
@ -19,7 +21,9 @@ import (
|
|||
"github.com/lightninglabs/loop/labels"
|
||||
"github.com/lightninglabs/loop/staticaddr/deposit"
|
||||
"github.com/lightninglabs/loop/swapserverrpc"
|
||||
"github.com/lightningnetwork/lnd/input"
|
||||
"github.com/lightningnetwork/lnd/lntypes"
|
||||
"github.com/lightningnetwork/lnd/lnwallet"
|
||||
"github.com/lightningnetwork/lnd/routing/route"
|
||||
)
|
||||
|
||||
|
|
@ -29,6 +33,10 @@ const (
|
|||
SwapNotFinishedMsg = "swap not finished yet"
|
||||
)
|
||||
|
||||
var (
|
||||
dustLimit = lnwallet.DustLimitForSize(input.P2TRSize)
|
||||
)
|
||||
|
||||
// Config contains the services required for the loop-in manager.
|
||||
type Config struct {
|
||||
// Server is the client that is used to communicate with the static
|
||||
|
|
@ -280,6 +288,14 @@ func (m *Manager) handleLoopInSweepReq(ctx context.Context,
|
|||
return err
|
||||
}
|
||||
|
||||
deposits, err := m.cfg.DepositManager.DepositsForOutpoints(
|
||||
ctx, loopIn.DepositOutpoints,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
loopIn.Deposits = deposits
|
||||
|
||||
reader := bytes.NewReader(req.SweepTxPsbt)
|
||||
sweepPacket, err := psbt.NewFromRawBytes(reader, false)
|
||||
if err != nil {
|
||||
|
|
@ -305,6 +321,34 @@ func (m *Manager) handleLoopInSweepReq(ctx context.Context,
|
|||
len(req.PrevoutInfo), len(sweepTx.TxIn))
|
||||
}
|
||||
|
||||
// If the user selected an amount that is less than the total deposit
|
||||
// amount we'll check that the server sends us the correct change amount
|
||||
// back to our static address.
|
||||
totalDepositAmount := loopIn.TotalDepositAmount()
|
||||
changeAmt := totalDepositAmount - loopIn.SelectedAmount
|
||||
if changeAmt > 0 && changeAmt < totalDepositAmount {
|
||||
var foundChange bool
|
||||
changePkScript := loopIn.AddressParams.PkScript
|
||||
|
||||
for _, out := range sweepTx.TxOut {
|
||||
if out.Value == int64(changeAmt) &&
|
||||
bytes.Equal(out.PkScript, changePkScript) {
|
||||
|
||||
foundChange = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !foundChange {
|
||||
return fmt.Errorf("expected change output to our "+
|
||||
"static address, total_deposit_amount=%v, "+
|
||||
"selected_amount=%v, "+
|
||||
"expected_change_amount=%v ",
|
||||
totalDepositAmount, loopIn.SelectedAmount,
|
||||
changeAmt)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if all the deposits requested are part of the loop-in and
|
||||
// find them in the requested sweep.
|
||||
depositToIdxMap, err := mapDepositsToIndices(req, loopIn, sweepTx)
|
||||
|
|
@ -531,29 +575,80 @@ func (m *Manager) DeliverLoopInRequest(ctx context.Context,
|
|||
func (m *Manager) initiateLoopIn(ctx context.Context,
|
||||
req *loop.StaticAddressLoopInRequest) (*StaticAddressLoopIn, error) {
|
||||
|
||||
// Validate the loop-in request.
|
||||
if len(req.DepositOutpoints) == 0 {
|
||||
return nil, fmt.Errorf("no deposit outpoints provided")
|
||||
}
|
||||
|
||||
// Retrieve all deposits referenced by the outpoints and ensure that
|
||||
// they are in state Deposited.
|
||||
deposits, active := m.cfg.DepositManager.AllStringOutpointsActiveDeposits( //nolint:lll
|
||||
req.DepositOutpoints, deposit.Deposited,
|
||||
var (
|
||||
err error
|
||||
selectedOutpoints = req.DepositOutpoints
|
||||
selectedDeposits []*deposit.Deposit
|
||||
)
|
||||
if !active {
|
||||
return nil, fmt.Errorf("one or more deposits are not in "+
|
||||
"state %s", deposit.Deposited)
|
||||
|
||||
// Determine which deposits to use for the loop-in swap. If none are
|
||||
// selected by the client, we will coin-select them based on the amount.
|
||||
switch {
|
||||
case len(selectedOutpoints) == 0 && req.SelectedAmount == 0:
|
||||
return nil, fmt.Errorf("neither deposit outpoints nor amount " +
|
||||
"provided")
|
||||
|
||||
case len(selectedOutpoints) > 0:
|
||||
// Retrieve all deposits referenced by the outpoints and ensure
|
||||
// that they are in state Deposited.
|
||||
var active bool
|
||||
selectedDeposits, active = m.cfg.DepositManager.
|
||||
AllStringOutpointsActiveDeposits(
|
||||
selectedOutpoints, deposit.Deposited,
|
||||
)
|
||||
if !active {
|
||||
return nil, fmt.Errorf("one or more deposits are not in "+
|
||||
"state %s", deposit.Deposited)
|
||||
}
|
||||
|
||||
case len(selectedOutpoints) == 0:
|
||||
// If an amount was provided, we'll coin-select deposits to
|
||||
// cover for the amount.
|
||||
allDeposits, err := m.cfg.DepositManager.
|
||||
GetActiveDepositsInState(deposit.Deposited)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to retrieve all "+
|
||||
"deposits: %w", err)
|
||||
}
|
||||
|
||||
// TODO(hieblmi): add params to deposit for multi-address
|
||||
// support.
|
||||
params, err := m.cfg.AddressManager.GetStaticAddressParameters(
|
||||
ctx,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to retrieve static "+
|
||||
"address parameters: %w", err)
|
||||
}
|
||||
|
||||
selectedDeposits, err = SelectDeposits(
|
||||
req.SelectedAmount, allDeposits, params.Expiry,
|
||||
m.currentHeight.Load(),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to select deposits: %w",
|
||||
err)
|
||||
}
|
||||
|
||||
selectedOutpoints = make([]string, 0, len(selectedDeposits))
|
||||
for _, deposit := range selectedDeposits {
|
||||
selectedOutpoints = append(selectedOutpoints,
|
||||
deposit.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate the total deposit amount.
|
||||
tmp := &StaticAddressLoopIn{
|
||||
Deposits: deposits,
|
||||
// Calculate the total deposit amount and check if the selected amount
|
||||
// would leave a dust output.
|
||||
swapAmount, err := DeduceSwapAmount(
|
||||
sumOfDeposits(selectedDeposits), req.SelectedAmount,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to determine swap amount: %w",
|
||||
err)
|
||||
}
|
||||
totalDepositAmount := tmp.TotalDepositAmount()
|
||||
|
||||
// Check that the label is valid.
|
||||
err := labels.Validate(req.Label)
|
||||
err = labels.Validate(req.Label)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid label: %w", err)
|
||||
}
|
||||
|
|
@ -577,7 +672,7 @@ func (m *Manager) initiateLoopIn(ctx context.Context,
|
|||
// Because the Private flag is set, we'll generate our own set
|
||||
// of hop hints.
|
||||
req.RouteHints, err = loop.SelectHopHints(
|
||||
ctx, m.cfg.LndClient, totalDepositAmount,
|
||||
ctx, m.cfg.LndClient, swapAmount,
|
||||
loop.DefaultMaxHopHints, includeNodes,
|
||||
)
|
||||
if err != nil {
|
||||
|
|
@ -586,19 +681,19 @@ func (m *Manager) initiateLoopIn(ctx context.Context,
|
|||
}
|
||||
}
|
||||
|
||||
// Request current server loop in terms and use these to calculate the
|
||||
// swap fee that we should subtract from the swap amount in the payment
|
||||
// request that we send to the server. We pass nil as optional route
|
||||
// hints as hop hint selection when generating invoices with private
|
||||
// channels is an LND side black box feature. Advanced users will quote
|
||||
// directly anyway and there they have the option to add specific route
|
||||
// hints.
|
||||
// Request the current server loop in terms and use these to calculate
|
||||
// the swap fee that we should subtract from the swap amount in the
|
||||
// payment request that we send to the server. We pass nil as optional
|
||||
// route hints as hop hint selection when generating invoices with
|
||||
// private channels is an LND side black box feature. Advanced users
|
||||
// will quote directly anyway, and there they are able to add specific
|
||||
// route hints.
|
||||
// The quote call will also request a probe from the server to ensure
|
||||
// feasibility of a loop-in for the totalDepositAmount.
|
||||
numDeposits := uint32(len(deposits))
|
||||
// feasibility of a loop-in for the selected.
|
||||
numDeposits := uint32(len(selectedDeposits))
|
||||
quote, err := m.cfg.QuoteGetter.GetLoopInQuote(
|
||||
ctx, totalDepositAmount, m.cfg.NodePubkey, req.LastHop,
|
||||
req.RouteHints, req.Initiator, numDeposits,
|
||||
ctx, swapAmount, m.cfg.NodePubkey, req.LastHop, req.RouteHints,
|
||||
req.Initiator, numDeposits,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to get loop in quote: %w", err)
|
||||
|
|
@ -619,8 +714,9 @@ func (m *Manager) initiateLoopIn(ctx context.Context,
|
|||
}
|
||||
|
||||
swap := &StaticAddressLoopIn{
|
||||
DepositOutpoints: req.DepositOutpoints,
|
||||
Deposits: deposits,
|
||||
SelectedAmount: req.SelectedAmount,
|
||||
DepositOutpoints: selectedOutpoints,
|
||||
Deposits: selectedDeposits,
|
||||
Label: req.Label,
|
||||
Initiator: req.Initiator,
|
||||
InitiationTime: time.Now(),
|
||||
|
|
@ -710,9 +806,102 @@ func (m *Manager) GetAllSwaps(ctx context.Context) ([]*StaticAddressLoopIn,
|
|||
return swaps, nil
|
||||
}
|
||||
|
||||
// SelectDeposits sorts the deposits by amount in descending order, then by
|
||||
// blocks-until-expiry in ascending 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(targetAmount btcutil.Amount, deposits []*deposit.Deposit,
|
||||
csvExpiry uint32, blockHeight uint32) ([]*deposit.Deposit, error) {
|
||||
|
||||
// Sort the deposits by amount in descending order, then by
|
||||
// blocks-until-expiry in ascending order.
|
||||
sort.Slice(deposits, func(i, j int) bool {
|
||||
if deposits[i].Value == deposits[j].Value {
|
||||
iExp := uint32(deposits[i].ConfirmationHeight) +
|
||||
csvExpiry - blockHeight
|
||||
jExp := uint32(deposits[j].ConfirmationHeight) +
|
||||
csvExpiry - blockHeight
|
||||
|
||||
return iExp < jExp
|
||||
}
|
||||
return deposits[i].Value > deposits[j].Value
|
||||
})
|
||||
|
||||
// Select the deposits that are needed to cover the swap amount without
|
||||
// leaving a dust change.
|
||||
var selectedDeposits []*deposit.Deposit
|
||||
var selectedAmount btcutil.Amount
|
||||
for _, deposit := range deposits {
|
||||
selectedDeposits = append(selectedDeposits, deposit)
|
||||
selectedAmount += deposit.Value
|
||||
if selectedAmount == targetAmount {
|
||||
return selectedDeposits, nil
|
||||
}
|
||||
if selectedAmount > targetAmount {
|
||||
if selectedAmount-targetAmount >= dustLimit {
|
||||
return selectedDeposits, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("not enough deposits to cover "+
|
||||
"requested amount or prevent dust change, have %d but need %d",
|
||||
selectedAmount, targetAmount)
|
||||
}
|
||||
|
||||
// DeduceSwapAmount calculates the swap amount based on the selected amount and
|
||||
// the total deposit amount. It checks if the selected amount leaves a dust
|
||||
// change output or exceeds the total deposits value. Note that if the selected
|
||||
// amount is 0, the swap amount is the total deposit value. If the selected
|
||||
// amount is equal to the total deposit value, the total deposit value will be
|
||||
// swapped.
|
||||
func DeduceSwapAmount(totalDepositAmount btcutil.Amount,
|
||||
selectedAmount btcutil.Amount) (btcutil.Amount, error) {
|
||||
|
||||
// If the selected amount leaves a dust change output or exceeds the
|
||||
// total deposits value, we return an error.
|
||||
swapAmount := selectedAmount
|
||||
remainingAmount := totalDepositAmount - selectedAmount
|
||||
switch {
|
||||
case selectedAmount < 0:
|
||||
return 0, fmt.Errorf("selected amount %v is negative",
|
||||
selectedAmount)
|
||||
|
||||
case selectedAmount > 0 && selectedAmount < dustLimit:
|
||||
return 0, fmt.Errorf("selected amount %v is dust, "+
|
||||
"need at least %v", selectedAmount, dustLimit)
|
||||
|
||||
case totalDepositAmount < dustLimit:
|
||||
return 0, fmt.Errorf("total deposit value %v is dust, "+
|
||||
"need at least %v", totalDepositAmount, dustLimit)
|
||||
|
||||
case remainingAmount < 0:
|
||||
return 0, fmt.Errorf("selected amount %v exceeds total "+
|
||||
"deposit value %v", selectedAmount, totalDepositAmount)
|
||||
|
||||
case remainingAmount > 0 && remainingAmount < dustLimit:
|
||||
return 0, fmt.Errorf("selected amount %v leaves dust change "+
|
||||
"%v", selectedAmount, remainingAmount)
|
||||
|
||||
default:
|
||||
// If the remaining amount is 0 or equal or greater than the
|
||||
// dust limit, we can proceed with the swap.
|
||||
}
|
||||
|
||||
// If the client didn't select an amount, we quote for the total
|
||||
// deposits value.
|
||||
if selectedAmount == 0 {
|
||||
swapAmount = totalDepositAmount
|
||||
}
|
||||
|
||||
return swapAmount, nil
|
||||
}
|
||||
|
||||
// mapDepositsToIndices maps the deposit outpoints to their respective indices
|
||||
// in the sweep transaction.
|
||||
func mapDepositsToIndices(req *swapserverrpc.ServerStaticLoopInSweepNotification, //nolint:lll
|
||||
func mapDepositsToIndices(
|
||||
req *swapserverrpc.ServerStaticLoopInSweepNotification,
|
||||
loopIn *StaticAddressLoopIn, sweepTx *wire.MsgTx) (map[string]int,
|
||||
error) {
|
||||
|
||||
|
|
@ -755,3 +944,12 @@ func mapDepositsToIndices(req *swapserverrpc.ServerStaticLoopInSweepNotification
|
|||
}
|
||||
return depositToIdxMap, nil
|
||||
}
|
||||
|
||||
func sumOfDeposits(deposits []*deposit.Deposit) btcutil.Amount {
|
||||
sum := btcutil.Amount(0)
|
||||
for _, d := range deposits {
|
||||
sum += d.Value
|
||||
}
|
||||
|
||||
return sum
|
||||
}
|
||||
|
|
|
|||
135
staticaddr/loopin/manager_test.go
Normal file
135
staticaddr/loopin/manager_test.go
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
package loopin
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/btcsuite/btcd/btcutil"
|
||||
"github.com/btcsuite/btcd/chaincfg/chainhash"
|
||||
"github.com/lightninglabs/loop/staticaddr/deposit"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type testCase struct {
|
||||
name string
|
||||
deposits []*deposit.Deposit
|
||||
targetValue btcutil.Amount
|
||||
csvExpiry uint32
|
||||
blockHeight uint32
|
||||
expected []*deposit.Deposit
|
||||
expectedErr string
|
||||
}
|
||||
|
||||
// TestSelectDeposits tests the selectDeposits function, which selects
|
||||
// deposits that can cover a target value while respecting the dust limit.
|
||||
func TestSelectDeposits(t *testing.T) {
|
||||
d1, d2, d3, d4 := &deposit.Deposit{
|
||||
Value: 1_000_000,
|
||||
ConfirmationHeight: 1000,
|
||||
}, &deposit.Deposit{
|
||||
Value: 2_000_000,
|
||||
ConfirmationHeight: 2000,
|
||||
}, &deposit.Deposit{
|
||||
Value: 3_000_000,
|
||||
ConfirmationHeight: 3000,
|
||||
}, &deposit.Deposit{
|
||||
Value: 3_000_000,
|
||||
ConfirmationHeight: 3001,
|
||||
}
|
||||
d1.Hash = chainhash.Hash{1}
|
||||
d1.Index = 0
|
||||
d2.Hash = chainhash.Hash{2}
|
||||
d2.Index = 0
|
||||
d3.Hash = chainhash.Hash{3}
|
||||
d3.Index = 0
|
||||
d4.Hash = chainhash.Hash{4}
|
||||
d4.Index = 0
|
||||
|
||||
testCases := []testCase{
|
||||
{
|
||||
name: "single deposit exact target",
|
||||
deposits: []*deposit.Deposit{d1},
|
||||
targetValue: 1_000_000,
|
||||
expected: []*deposit.Deposit{d1},
|
||||
expectedErr: "",
|
||||
},
|
||||
{
|
||||
name: "prefer larger deposit when both cover",
|
||||
deposits: []*deposit.Deposit{d1, d2},
|
||||
targetValue: 1_000_000,
|
||||
expected: []*deposit.Deposit{d2},
|
||||
expectedErr: "",
|
||||
},
|
||||
{
|
||||
name: "prefer largest among three when one is enough",
|
||||
deposits: []*deposit.Deposit{d1, d2, d3},
|
||||
targetValue: 1_000_000,
|
||||
expected: []*deposit.Deposit{d3},
|
||||
expectedErr: "",
|
||||
},
|
||||
{
|
||||
name: "single deposit insufficient by 1",
|
||||
deposits: []*deposit.Deposit{d1},
|
||||
targetValue: 1_000_001,
|
||||
expected: []*deposit.Deposit{},
|
||||
expectedErr: "not enough deposits to cover",
|
||||
},
|
||||
{
|
||||
name: "target leaves exact dust limit change",
|
||||
deposits: []*deposit.Deposit{d1},
|
||||
targetValue: 1_000_000 - dustLimit,
|
||||
expected: []*deposit.Deposit{d1},
|
||||
expectedErr: "",
|
||||
},
|
||||
{
|
||||
name: "target leaves dust change (just over)",
|
||||
deposits: []*deposit.Deposit{d1},
|
||||
targetValue: 1_000_000 - dustLimit + 1,
|
||||
expected: []*deposit.Deposit{},
|
||||
expectedErr: "not enough deposits to cover",
|
||||
},
|
||||
{
|
||||
name: "all deposits exactly match target",
|
||||
deposits: []*deposit.Deposit{d1, d2, d3},
|
||||
targetValue: d1.Value + d2.Value + d3.Value,
|
||||
expected: []*deposit.Deposit{d1, d2, d3},
|
||||
expectedErr: "",
|
||||
},
|
||||
{
|
||||
name: "sum minus dust limit is allowed (change == dust)",
|
||||
deposits: []*deposit.Deposit{d1, d2, d3},
|
||||
targetValue: d1.Value + d2.Value + d3.Value - dustLimit,
|
||||
expected: []*deposit.Deposit{d1, d2, d3},
|
||||
expectedErr: "",
|
||||
},
|
||||
{
|
||||
name: "sum minus dust limit plus 1 is not allowed (dust change)",
|
||||
deposits: []*deposit.Deposit{d1, d2, d3},
|
||||
targetValue: d1.Value + d2.Value + d3.Value - dustLimit + 1,
|
||||
expected: []*deposit.Deposit{},
|
||||
expectedErr: "not enough deposits to cover",
|
||||
},
|
||||
{
|
||||
name: "tie by value, prefer earlier expiry",
|
||||
deposits: []*deposit.Deposit{d3, d4},
|
||||
targetValue: d4.Value - dustLimit, // d3/d4 have the
|
||||
// same value but different expiration.
|
||||
expected: []*deposit.Deposit{d3},
|
||||
expectedErr: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
selectedDeposits, err := SelectDeposits(
|
||||
tc.targetValue, tc.deposits, tc.csvExpiry,
|
||||
tc.blockHeight,
|
||||
)
|
||||
if tc.expectedErr == "" {
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
require.ErrorContains(t, err, tc.expectedErr)
|
||||
}
|
||||
require.ElementsMatch(t, tc.expected, selectedDeposits)
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue