mirror of
https://github.com/lightninglabs/loop.git
synced 2026-08-13 12:33:03 +02:00
Create a fresh static change address for fractional loop-ins and persist its key locator with the selected HTLC outpoint. Recovery reconstructs the same change output instead of returning funds to the legacy root address.
792 lines
23 KiB
Go
792 lines
23 KiB
Go
package staticutil
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
|
|
"github.com/btcsuite/btcd/btcec/v2"
|
|
"github.com/btcsuite/btcd/btcutil"
|
|
"github.com/btcsuite/btcd/chaincfg/chainhash"
|
|
"github.com/btcsuite/btcd/wire"
|
|
"github.com/lightninglabs/lndclient"
|
|
"github.com/lightninglabs/loop/staticaddr/address"
|
|
"github.com/lightninglabs/loop/staticaddr/deposit"
|
|
"github.com/lightninglabs/loop/swapserverrpc"
|
|
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"
|
|
)
|
|
|
|
type sessionCleanupSigner struct {
|
|
lndclient.SignerClient
|
|
|
|
createCalls int
|
|
failCreateAt int
|
|
cleaned [][32]byte
|
|
cleanupCtxErr []error
|
|
}
|
|
|
|
func (s *sessionCleanupSigner) MuSig2CreateSession(context.Context,
|
|
input.MuSig2Version, *keychain.KeyLocator, [][]byte,
|
|
...lndclient.MuSig2SessionOpts) (*input.MuSig2SessionInfo, error) {
|
|
|
|
s.createCalls++
|
|
if s.createCalls == s.failCreateAt {
|
|
return nil, errors.New("session creation failed")
|
|
}
|
|
|
|
sessionID := [32]byte{byte(s.createCalls)}
|
|
return &input.MuSig2SessionInfo{SessionID: sessionID}, nil
|
|
}
|
|
|
|
func (s *sessionCleanupSigner) MuSig2Cleanup(ctx context.Context,
|
|
sessionID [32]byte) error {
|
|
|
|
s.cleaned = append(s.cleaned, sessionID)
|
|
s.cleanupCtxErr = append(s.cleanupCtxErr, ctx.Err())
|
|
|
|
return nil
|
|
}
|
|
|
|
// mustHash converts a hex string to a chainhash.Hash and panics on error.
|
|
func mustHash(t *testing.T, s string) chainhash.Hash {
|
|
t.Helper()
|
|
h, err := chainhash.NewHashFromStr(s)
|
|
require.NoError(t, err)
|
|
return *h
|
|
}
|
|
|
|
func TestToPrevOuts_Success(t *testing.T) {
|
|
// Prepare two distinct deposits with different outpoints and values.
|
|
d1 := &deposit.Deposit{
|
|
OutPoint: wire.OutPoint{
|
|
Hash: mustHash(t, "0000000000000000000000000000000000000000000000000000000000000001"),
|
|
Index: 0,
|
|
},
|
|
Value: btcutil.Amount(12345),
|
|
AddressParams: &address.Parameters{PkScript: []byte{0x51}},
|
|
}
|
|
|
|
d2 := &deposit.Deposit{
|
|
OutPoint: wire.OutPoint{
|
|
Hash: mustHash(t, "1111111111111111111111111111111111111111111111111111111111111111"),
|
|
Index: 7,
|
|
},
|
|
Value: btcutil.Amount(987654321),
|
|
AddressParams: &address.Parameters{PkScript: []byte{0x52}},
|
|
}
|
|
|
|
prevOuts, err := ToPrevOuts([]*deposit.Deposit{d1, d2})
|
|
require.NoError(t, err)
|
|
|
|
// We expect two entries.
|
|
require.Len(t, prevOuts, 2)
|
|
|
|
// Check the first outpoint mapping.
|
|
txOut1, ok := prevOuts[d1.OutPoint]
|
|
require.True(t, ok, "expected outpoint d1 to be present")
|
|
require.EqualValues(t, int64(d1.Value), txOut1.Value)
|
|
require.Equal(t, d1.AddressParams.PkScript, txOut1.PkScript)
|
|
|
|
// Check the second outpoint mapping.
|
|
txOut2, ok := prevOuts[d2.OutPoint]
|
|
require.True(t, ok, "expected outpoint d2 to be present")
|
|
require.EqualValues(t, int64(d2.Value), txOut2.Value)
|
|
require.Equal(t, d2.AddressParams.PkScript, txOut2.PkScript)
|
|
|
|
// Ensure the keys in the map are exactly the outpoints we provided.
|
|
for op := range prevOuts {
|
|
require.True(t, op == d1.OutPoint || op == d2.OutPoint)
|
|
}
|
|
}
|
|
|
|
func TestToPrevOuts_DuplicateOutpoint(t *testing.T) {
|
|
// Two deposits that share the exact same outpoint should cause an error.
|
|
shared := wire.OutPoint{
|
|
Hash: mustHash(t, "2222222222222222222222222222222222222222222222222222222222222222"),
|
|
Index: 2,
|
|
}
|
|
|
|
d1 := &deposit.Deposit{
|
|
OutPoint: shared,
|
|
Value: btcutil.Amount(100),
|
|
AddressParams: &address.Parameters{PkScript: []byte{0x00}},
|
|
}
|
|
d2 := &deposit.Deposit{
|
|
OutPoint: shared,
|
|
Value: btcutil.Amount(200),
|
|
AddressParams: &address.Parameters{PkScript: []byte{0x01}},
|
|
}
|
|
|
|
_, err := ToPrevOuts([]*deposit.Deposit{d1, d2})
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestToPrevOutsMissingAddressParams(t *testing.T) {
|
|
d := &deposit.Deposit{
|
|
OutPoint: wire.OutPoint{
|
|
Hash: mustHash(t, "3333333333333333333333333333333333333333333333333333333333333333"),
|
|
Index: 3,
|
|
},
|
|
Value: btcutil.Amount(100),
|
|
}
|
|
|
|
_, err := ToPrevOuts([]*deposit.Deposit{d})
|
|
require.ErrorContains(t, err, "missing static address parameters")
|
|
}
|
|
|
|
func TestDepositClientPubkeys(t *testing.T) {
|
|
clientKey1, err := btcec.NewPrivateKey()
|
|
require.NoError(t, err)
|
|
clientKey2, err := btcec.NewPrivateKey()
|
|
require.NoError(t, err)
|
|
|
|
d1 := &deposit.Deposit{
|
|
OutPoint: wire.OutPoint{
|
|
Hash: mustHash(t, "4444444444444444444444444444444444444444444444444444444444444444"),
|
|
Index: 0,
|
|
},
|
|
AddressParams: &address.Parameters{
|
|
ClientPubkey: clientKey1.PubKey(),
|
|
PkScript: []byte{0x51, 0x20, 0x01},
|
|
},
|
|
}
|
|
d2 := &deposit.Deposit{
|
|
OutPoint: wire.OutPoint{
|
|
Hash: mustHash(t, "5555555555555555555555555555555555555555555555555555555555555555"),
|
|
Index: 1,
|
|
},
|
|
AddressParams: &address.Parameters{
|
|
ClientPubkey: clientKey2.PubKey(),
|
|
PkScript: []byte{0x51, 0x20, 0x02},
|
|
},
|
|
}
|
|
|
|
proofs, err := DepositClientPubkeys([]*deposit.Deposit{d1, d2})
|
|
require.NoError(t, err)
|
|
require.Equal(
|
|
t, clientKey1.PubKey().SerializeCompressed(),
|
|
proofs[d1.String()].GetPubkey(),
|
|
)
|
|
require.Equal(
|
|
t, d1.AddressParams.PkScript,
|
|
proofs[d1.String()].GetPkScript(),
|
|
)
|
|
require.Equal(
|
|
t, clientKey2.PubKey().SerializeCompressed(),
|
|
proofs[d2.String()].GetPubkey(),
|
|
)
|
|
require.Equal(
|
|
t, d2.AddressParams.PkScript,
|
|
proofs[d2.String()].GetPkScript(),
|
|
)
|
|
}
|
|
|
|
func TestDepositClientPubkeysRejectsInvalidDeposits(t *testing.T) {
|
|
t.Run("missing params", func(t *testing.T) {
|
|
d := &deposit.Deposit{OutPoint: wire.OutPoint{Index: 1}}
|
|
_, err := DepositClientPubkeys([]*deposit.Deposit{d})
|
|
require.ErrorContains(t, err, "missing static address parameters")
|
|
})
|
|
|
|
t.Run("missing client key", func(t *testing.T) {
|
|
d := &deposit.Deposit{
|
|
OutPoint: wire.OutPoint{Index: 1},
|
|
AddressParams: &address.Parameters{},
|
|
}
|
|
_, err := DepositClientPubkeys([]*deposit.Deposit{d})
|
|
require.ErrorContains(t, err, "missing static address client pubkey")
|
|
})
|
|
|
|
t.Run("duplicate outpoint", func(t *testing.T) {
|
|
clientKey, err := btcec.NewPrivateKey()
|
|
require.NoError(t, err)
|
|
|
|
d := &deposit.Deposit{
|
|
OutPoint: wire.OutPoint{
|
|
Hash: mustHash(t, "6666666666666666666666666666666666666666666666666666666666666666"),
|
|
Index: 1,
|
|
},
|
|
AddressParams: &address.Parameters{
|
|
ClientPubkey: clientKey.PubKey(),
|
|
PkScript: []byte{0x51, 0x20, 0x03},
|
|
},
|
|
}
|
|
_, err = DepositClientPubkeys([]*deposit.Deposit{d, d})
|
|
require.ErrorContains(t, err, "duplicate outpoint")
|
|
})
|
|
|
|
t.Run("missing pkscript", func(t *testing.T) {
|
|
clientKey, err := btcec.NewPrivateKey()
|
|
require.NoError(t, err)
|
|
|
|
d := &deposit.Deposit{
|
|
OutPoint: wire.OutPoint{Index: 1},
|
|
AddressParams: &address.Parameters{
|
|
ClientPubkey: clientKey.PubKey(),
|
|
},
|
|
}
|
|
_, err = DepositClientPubkeys([]*deposit.Deposit{d})
|
|
require.ErrorContains(t, err, "missing static address pkscript")
|
|
})
|
|
}
|
|
|
|
func TestChangeOutput(t *testing.T) {
|
|
clientKey, err := btcec.NewPrivateKey()
|
|
require.NoError(t, err)
|
|
|
|
params := &address.Parameters{
|
|
ClientPubkey: clientKey.PubKey(),
|
|
PkScript: []byte{0x51, 0x20, 0x01},
|
|
}
|
|
amount := btcutil.Amount(12345)
|
|
|
|
changeOutput, err := ChangeOutput(params, amount)
|
|
require.NoError(t, err)
|
|
require.Equal(
|
|
t, clientKey.PubKey().SerializeCompressed(),
|
|
changeOutput.StaticAddress.GetPubkey(),
|
|
)
|
|
require.Equal(t, params.PkScript, changeOutput.StaticAddress.GetPkScript())
|
|
require.EqualValues(t, amount, changeOutput.Amount)
|
|
|
|
changeOutput, err = ChangeOutput(params, 0)
|
|
require.NoError(t, err)
|
|
require.Nil(t, changeOutput)
|
|
}
|
|
|
|
func TestChangeOutputRejectsInvalidParams(t *testing.T) {
|
|
_, err := ChangeOutput(nil, 100)
|
|
require.ErrorContains(t, err, "missing static address change parameters")
|
|
|
|
_, err = ChangeOutput(&address.Parameters{}, 100)
|
|
require.ErrorContains(t, err, "missing static address change client pubkey")
|
|
|
|
clientKey, err := btcec.NewPrivateKey()
|
|
require.NoError(t, err)
|
|
_, err = ChangeOutput(&address.Parameters{
|
|
ClientPubkey: clientKey.PubKey(),
|
|
}, 100)
|
|
require.ErrorContains(t, err, "missing static address change pkscript")
|
|
}
|
|
|
|
func TestGetPrevoutInfo_ConversionAndSorting(t *testing.T) {
|
|
// Helper to create a hash from string.
|
|
must := func(s string) chainhash.Hash {
|
|
h, err := chainhash.NewHashFromStr(s)
|
|
require.NoError(t, err)
|
|
return *h
|
|
}
|
|
|
|
// Choose txids such that after reversal, ordering is determined by the
|
|
// last byte of the original hex string.
|
|
txidA := must("0000000000000000000000000000000000000000000000000000000000000001")
|
|
txidB := must("0000000000000000000000000000000000000000000000000000000000000002")
|
|
|
|
pkScript := []byte{0xaa, 0xbb}
|
|
|
|
prevOuts := map[wire.OutPoint]*wire.TxOut{
|
|
{Hash: txidA, Index: 5}: {Value: 11, PkScript: pkScript},
|
|
{Hash: txidA, Index: 2}: {Value: 22, PkScript: pkScript},
|
|
{Hash: txidB, Index: 0}: {Value: 33, PkScript: pkScript},
|
|
}
|
|
|
|
infos := GetPrevoutInfo(prevOuts)
|
|
|
|
// Expect deterministic ordering:
|
|
// 1) All entries with txidA (..01) before txidB (..02) due to BIP-69
|
|
// compare on reversed hashes.
|
|
// 2) Within txidA, index 2 before index 5.
|
|
require.Len(t, infos, 3)
|
|
|
|
require.Equal(t, &swapserverrpc.PrevoutInfo{
|
|
TxidBytes: txidA[:],
|
|
OutputIndex: 2,
|
|
Value: 22,
|
|
PkScript: pkScript,
|
|
}, infos[0])
|
|
|
|
require.Equal(t, &swapserverrpc.PrevoutInfo{
|
|
TxidBytes: txidA[:],
|
|
OutputIndex: 5,
|
|
Value: 11,
|
|
PkScript: pkScript,
|
|
}, infos[1])
|
|
|
|
require.Equal(t, &swapserverrpc.PrevoutInfo{
|
|
TxidBytes: txidB[:],
|
|
OutputIndex: 0,
|
|
Value: 33,
|
|
PkScript: pkScript,
|
|
}, infos[2])
|
|
}
|
|
|
|
func TestBip69InputLess_SameHashIndexOrder(t *testing.T) {
|
|
txid := make([]byte, 32)
|
|
txid[31] = 0x7f // Arbitrary value.
|
|
|
|
a := &swapserverrpc.PrevoutInfo{TxidBytes: txid, OutputIndex: 1}
|
|
b := &swapserverrpc.PrevoutInfo{TxidBytes: txid, OutputIndex: 3}
|
|
|
|
require.True(t, bip69inputLess(a, b))
|
|
require.False(t, bip69inputLess(b, a))
|
|
}
|
|
|
|
func TestBip69InputLess_DifferentHashes(t *testing.T) {
|
|
// txid1 ends with 0x01, txid2 ends with 0x02. After reversing for
|
|
// comparison, txid1 should still come before txid2 in lexicographic
|
|
// order.
|
|
h1, _ := chainhash.NewHashFromStr("0000000000000000000000000000000000000000000000000000000000000001")
|
|
h2, _ := chainhash.NewHashFromStr("0000000000000000000000000000000000000000000000000000000000000002")
|
|
|
|
a := &swapserverrpc.PrevoutInfo{TxidBytes: h1[:], OutputIndex: 9}
|
|
b := &swapserverrpc.PrevoutInfo{TxidBytes: h2[:], OutputIndex: 0}
|
|
|
|
require.True(t, bip69inputLess(a, b))
|
|
require.False(t, bip69inputLess(b, a))
|
|
}
|
|
|
|
func TestCreateMusig2Session_Success(t *testing.T) {
|
|
// Set up mock signer from loop/test package.
|
|
lnd := looptest.NewMockLnd()
|
|
signer := lnd.Signer
|
|
|
|
// Create dummy key material for address parameters.
|
|
clientKey, err := btcec.NewPrivateKey()
|
|
require.NoError(t, err)
|
|
serverKey, err := btcec.NewPrivateKey()
|
|
require.NoError(t, err)
|
|
|
|
params := &address.Parameters{
|
|
ClientPubkey: clientKey.PubKey(),
|
|
ServerPubkey: serverKey.PubKey(),
|
|
Expiry: 10,
|
|
PkScript: []byte{0x51},
|
|
KeyLocator: keychain.KeyLocator{Family: 1, Index: 2},
|
|
}
|
|
|
|
d := &deposit.Deposit{AddressParams: params}
|
|
sess, err := CreateMusig2Session(context.Background(), signer, d)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, sess)
|
|
}
|
|
|
|
func TestCreateMusig2Sessions_Multiple(t *testing.T) {
|
|
lnd := looptest.NewMockLnd()
|
|
signer := lnd.Signer
|
|
|
|
// Keys/params/static address.
|
|
clientKey, err := btcec.NewPrivateKey()
|
|
require.NoError(t, err)
|
|
serverKey, err := btcec.NewPrivateKey()
|
|
require.NoError(t, err)
|
|
|
|
params := &address.Parameters{
|
|
ClientPubkey: clientKey.PubKey(),
|
|
ServerPubkey: serverKey.PubKey(),
|
|
Expiry: 12,
|
|
PkScript: []byte{0xaa},
|
|
KeyLocator: keychain.KeyLocator{Family: 9, Index: 8},
|
|
}
|
|
|
|
// Prepare N deposits; only the length matters for session count.
|
|
deposits := []*deposit.Deposit{
|
|
{OutPoint: wire.OutPoint{Index: 0}, AddressParams: params},
|
|
{OutPoint: wire.OutPoint{Index: 1}, AddressParams: params},
|
|
{OutPoint: wire.OutPoint{Index: 2}, AddressParams: params},
|
|
}
|
|
|
|
sessions, nonces, err := CreateMusig2Sessions(
|
|
context.Background(), signer, deposits,
|
|
)
|
|
require.NoError(t, err)
|
|
require.Len(t, sessions, len(deposits))
|
|
require.Len(t, nonces, len(deposits))
|
|
|
|
// The mock signer returns a zero-value PublicNonce; assert consistency.
|
|
for i := range sessions {
|
|
require.NotNil(t, sessions[i])
|
|
require.True(t, bytes.Equal(nonces[i], sessions[i].PublicNonce[:]))
|
|
}
|
|
}
|
|
|
|
func TestCreateMusig2SessionsPerDepositCleansUpPartialFailure(
|
|
t *testing.T) {
|
|
|
|
clientKey, err := btcec.NewPrivateKey()
|
|
require.NoError(t, err)
|
|
serverKey, err := btcec.NewPrivateKey()
|
|
require.NoError(t, err)
|
|
|
|
params := &address.Parameters{
|
|
ClientPubkey: clientKey.PubKey(),
|
|
ServerPubkey: serverKey.PubKey(),
|
|
Expiry: 12,
|
|
KeyLocator: keychain.KeyLocator{Family: 9, Index: 8},
|
|
}
|
|
deposits := []*deposit.Deposit{
|
|
{
|
|
OutPoint: wire.OutPoint{Index: 1},
|
|
AddressParams: params,
|
|
},
|
|
{
|
|
OutPoint: wire.OutPoint{Index: 2},
|
|
AddressParams: params,
|
|
},
|
|
}
|
|
|
|
signer := &sessionCleanupSigner{failCreateAt: 2}
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
cancel()
|
|
|
|
_, _, _, err = CreateMusig2SessionsPerDeposit(
|
|
ctx, signer, deposits,
|
|
)
|
|
require.ErrorContains(t, err, "session creation failed")
|
|
require.Equal(t, [][32]byte{{1}}, signer.cleaned)
|
|
require.Equal(t, []error{nil}, signer.cleanupCtxErr)
|
|
}
|
|
|
|
// 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()
|
|
|
|
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: lnrpc.CommitmentType_ANCHORS,
|
|
wantErr: "insufficient funds",
|
|
},
|
|
{
|
|
name: "total equals amount but no room for dust",
|
|
deposits: makeDeposits(100_000),
|
|
amount: 100_000,
|
|
feeRate: lowFeeRate,
|
|
commitmentType: lnrpc.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: lnrpc.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: lnrpc.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: lnrpc.CommitmentType_ANCHORS,
|
|
wantCount: 1,
|
|
},
|
|
{
|
|
name: "two deposits needed when first is insufficient",
|
|
deposits: makeDeposits(60_000, 60_000),
|
|
amount: 100_000,
|
|
feeRate: lowFeeRate,
|
|
commitmentType: lnrpc.CommitmentType_ANCHORS,
|
|
wantCount: 2,
|
|
},
|
|
{
|
|
name: "selects largest deposits first",
|
|
deposits: makeDeposits(10_000, 200_000, 50_000),
|
|
amount: 100_000,
|
|
feeRate: lowFeeRate,
|
|
commitmentType: lnrpc.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: lnrpc.CommitmentType_ANCHORS,
|
|
wantCount: 3,
|
|
validate: func(t *testing.T, selected []*deposit.Deposit) {
|
|
total := depositSum(selected)
|
|
fee := estimateFee(
|
|
len(selected), highFeeRate,
|
|
lnrpc.CommitmentType_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: lnrpc.CommitmentType_ANCHORS,
|
|
wantCount: 3,
|
|
},
|
|
{
|
|
name: "zero fee rate means only dust matters",
|
|
deposits: makeDeposits(100_000, 50_000),
|
|
amount: 99_000,
|
|
feeRate: 0,
|
|
commitmentType: lnrpc.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: lnrpc.CommitmentType_ANCHORS,
|
|
validate: func(t *testing.T, selected []*deposit.Deposit) {
|
|
total := depositSum(selected)
|
|
fee := estimateFee(
|
|
len(selected), highFeeRate,
|
|
lnrpc.CommitmentType_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: lnrpc.CommitmentType_SIMPLE_TAPROOT,
|
|
wantCount: 1,
|
|
},
|
|
{
|
|
name: "production taproot commitment type",
|
|
deposits: makeDeposits(500_000),
|
|
amount: 100_000,
|
|
feeRate: lowFeeRate,
|
|
commitmentType: lnrpc.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: lnrpc.CommitmentType_ANCHORS,
|
|
validate: func(t *testing.T, selected []*deposit.Deposit) {
|
|
total := depositSum(selected)
|
|
fee := estimateFee(
|
|
len(selected), lowFeeRate,
|
|
lnrpc.CommitmentType_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: lnrpc.CommitmentType_ANCHORS,
|
|
validate: func(t *testing.T, selected []*deposit.Deposit) {
|
|
total := depositSum(selected)
|
|
fee := estimateFee(
|
|
len(selected), lowFeeRate,
|
|
lnrpc.CommitmentType_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: lnrpc.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: lnrpc.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)
|
|
}
|
|
})
|
|
}
|
|
}
|