diff --git a/tapdb/assets_store_test.go b/tapdb/assets_store_test.go index f2fa05723..1746ceb11 100644 --- a/tapdb/assets_store_test.go +++ b/tapdb/assets_store_test.go @@ -36,6 +36,8 @@ type assetGenOptions struct { groupAnchorGen *asset.Genesis + groupAnchorGenPoint *wire.OutPoint + noGroupKey bool groupKeyPriv *btcec.PrivateKey @@ -87,6 +89,12 @@ func withGroupAnchorGen(g *asset.Genesis) assetGenOpt { } } +func withGroupAnchorGenPoint(op wire.OutPoint) assetGenOpt { + return func(opt *assetGenOptions) { + opt.groupAnchorGenPoint = &op + } +} + func withAssetGenPoint(op wire.OutPoint) assetGenOpt { return func(opt *assetGenOptions) { opt.genesisPoint = op @@ -151,6 +159,9 @@ func randAsset(t *testing.T, genOpts ...assetGenOpt) *asset.Asset { if opts.groupAnchorGen != nil { initialGen = *opts.groupAnchorGen } + if opts.groupAnchorGenPoint != nil { + initialGen.FirstPrevOut = *opts.groupAnchorGenPoint + } groupReq := asset.NewGroupKeyRequestNoErr( t, groupKeyDesc, initialGen, protoAsset, nil, @@ -433,6 +444,8 @@ type assetDesc struct { groupAnchorGen *asset.Genesis + groupAnchorGenPoint *wire.OutPoint + anchorPoint wire.OutPoint keyGroup *btcec.PrivateKey @@ -548,6 +561,11 @@ func (a *assetGenerator) genAssets(t *testing.T, assetStore *AssetStore, desc.groupAnchorGen, )) } + if desc.groupAnchorGenPoint != nil { + opts = append(opts, withGroupAnchorGenPoint( + *desc.groupAnchorGenPoint, + )) + } if desc.assetVersion != nil { opts = append(opts, withAssetVersionGen( desc.assetVersion, @@ -612,7 +630,7 @@ func (a *assetGenerator) bindAssetID(i int, op wire.OutPoint) *asset.ID { return &id } -func (a *assetGenerator) bindKeyGroup(i int, op wire.OutPoint) *btcec.PublicKey { +func (a *assetGenerator) bindGroupKey(i int, op wire.OutPoint) *btcec.PublicKey { gen := a.assetGens[i] gen.FirstPrevOut = op genTweak := gen.ID() @@ -901,6 +919,7 @@ func TestSelectCommitment(t *testing.T) { constraints tapfreighter.CommitmentConstraints numAssets int + sum int64 err error }{ @@ -911,7 +930,7 @@ func TestSelectCommitment(t *testing.T) { assets: []assetDesc{ { assetGen: assetGen.assetGens[0], - amt: 5, + amt: 6, anchorPoint: assetGen.anchorPoints[0], }, @@ -923,6 +942,7 @@ func TestSelectCommitment(t *testing.T) { MinAmt: 2, }, numAssets: 1, + sum: 6, }, // Asset matches all the params, but too small of a UTXO. only @@ -968,10 +988,10 @@ func TestSelectCommitment(t *testing.T) { err: tapfreighter.ErrMatchingAssetsNotFound, }, - // Create two assets, one has a key group the other doesn't. + // Create two assets, one has a group key the other doesn't. // We should only get one asset back. { - name: "asset with key group", + name: "asset with group key", assets: []assetDesc{ { assetGen: assetGen.assetGens[0], @@ -983,19 +1003,20 @@ func TestSelectCommitment(t *testing.T) { }, { assetGen: assetGen.assetGens[1], - amt: 10, + amt: 12, anchorPoint: assetGen.anchorPoints[1], noGroupKey: true, }, }, constraints: tapfreighter.CommitmentConstraints{ - GroupKey: assetGen.bindKeyGroup( + GroupKey: assetGen.bindGroupKey( 0, assetGen.anchorPoints[0], ), MinAmt: 1, }, numAssets: 1, + sum: 10, }, // Leased assets shouldn't be returned, and neither should other @@ -1027,6 +1048,48 @@ func TestSelectCommitment(t *testing.T) { numAssets: 0, err: tapfreighter.ErrMatchingAssetsNotFound, }, + + // Create three assets, the first two have a group key but + // different asset IDs, the other doesn't have a group key. + // We should only get the first two assets back. + { + name: "multiple different assets with same group key", + assets: []assetDesc{ + { + assetGen: assetGen.assetGens[0], + amt: 10, + + anchorPoint: assetGen.anchorPoints[0], + + keyGroup: assetGen.groupKeys[0], + }, + { + assetGen: assetGen.assetGens[1], + amt: 20, + + anchorPoint: assetGen.anchorPoints[0], + + keyGroup: assetGen.groupKeys[0], + groupAnchorGen: &assetGen.assetGens[0], + groupAnchorGenPoint: &assetGen.anchorPoints[0], + }, + { + assetGen: assetGen.assetGens[1], + amt: 15, + + anchorPoint: assetGen.anchorPoints[1], + noGroupKey: true, + }, + }, + constraints: tapfreighter.CommitmentConstraints{ + GroupKey: assetGen.bindGroupKey( + 0, assetGen.anchorPoints[0], + ), + MinAmt: 1, + }, + numAssets: 2, + sum: 30, + }, } ctx := context.Background() @@ -1053,6 +1116,14 @@ func TestSelectCommitment(t *testing.T) { // properly. require.Equal(t, tc.numAssets, len(selectedAssets)) + // Also verify the expected sum of asset amounts + // selected. + var sum int64 + for _, a := range selectedAssets { + sum += int64(a.Asset.Amount) + } + require.Equal(t, tc.sum, sum) + // If the expectation is to get a single asset, let's // make sure we can fetch the same asset commitment with // the FetchCommitment method. diff --git a/tapfreighter/coin_select.go b/tapfreighter/coin_select.go new file mode 100644 index 000000000..5c90c5021 --- /dev/null +++ b/tapfreighter/coin_select.go @@ -0,0 +1,174 @@ +package tapfreighter + +import ( + "context" + "fmt" + "sort" + "sync" + "time" + + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/taproot-assets/fn" +) + +// NewCoinSelect creates a new CoinSelect. +func NewCoinSelect(coinLister CoinLister) *CoinSelect { + return &CoinSelect{ + coinLister: coinLister, + } +} + +// CoinSelect selects asset coins to spend in order to fund a send +// transaction. +type CoinSelect struct { + coinLister CoinLister + + // coinLock is a read/write mutex that is used to ensure that only one + // goroutine is attempting to call any coin selection related methods at + // any time. This is necessary as some of the calls to the store (e.g. + // ListEligibleCoins -> LeaseCoin) are called after each other and + // cannot be placed within the same database transaction. So calls to + // those methods must hold this coin lock. + coinLock sync.Mutex +} + +// SelectCoins returns a set of not yet leased coins that satisfy the given +// constraints and strategy. The coins returned are leased for the default lease +// duration. +func (s *CoinSelect) SelectCoins(ctx context.Context, + constraints CommitmentConstraints, + strategy MultiCommitmentSelectStrategy) ([]*AnchoredCommitment, error) { + + s.coinLock.Lock() + defer s.coinLock.Unlock() + + // Before we select any coins, let's do some cleanup of expired leases. + if err := s.coinLister.DeleteExpiredLeases(ctx); err != nil { + return nil, fmt.Errorf("unable to delete expired leases: %w", + err) + } + + listConstraints := CommitmentConstraints{ + GroupKey: constraints.GroupKey, + AssetID: constraints.AssetID, + MinAmt: 1, + } + eligibleCommitments, err := s.coinLister.ListEligibleCoins( + ctx, listConstraints, + ) + if err != nil { + return nil, fmt.Errorf("unable to list eligible coins: %w", err) + } + + log.Infof("Identified %v eligible asset inputs for send of %d to %v", + len(eligibleCommitments), constraints) + + selectedCoins, err := s.selectForAmount( + constraints.MinAmt, eligibleCommitments, strategy, + ) + if err != nil { + return nil, fmt.Errorf("unable to select coins: %w", err) + } + + // We now need to lock/lease/reserve those selected coins so + // that they can't be used by other processes. + expiry := time.Now().Add(defaultCoinLeaseDuration) + coinOutPoints := fn.Map( + selectedCoins, func(c *AnchoredCommitment) wire.OutPoint { + return c.AnchorPoint + }, + ) + err = s.coinLister.LeaseCoins( + ctx, defaultWalletLeaseIdentifier, expiry, coinOutPoints..., + ) + if err != nil { + return nil, fmt.Errorf("unable to lease coin: %w", err) + } + + return selectedCoins, nil +} + +// LeaseCoins leases/locks/reserves coins for the given lease owner until the +// given expiry. This is used to prevent multiple concurrent coin selection +// attempts from selecting the same coin(s). +func (s *CoinSelect) LeaseCoins(ctx context.Context, leaseOwner [32]byte, + expiry time.Time, utxoOutpoints ...wire.OutPoint) error { + + s.coinLock.Lock() + defer s.coinLock.Unlock() + + return s.coinLister.LeaseCoins( + ctx, leaseOwner, expiry, utxoOutpoints..., + ) +} + +// ReleaseCoins releases/unlocks coins that were previously leased and makes +// them available for coin selection again. +func (s *CoinSelect) ReleaseCoins(ctx context.Context, + utxoOutpoints ...wire.OutPoint) error { + + s.coinLock.Lock() + defer s.coinLock.Unlock() + + return s.coinLister.ReleaseCoins(ctx, utxoOutpoints...) +} + +// selectForAmount selects a subset of the given eligible commitments which +// cumulatively sum to at least the minimum required amount. The selection +// strategy determines how the commitments are selected. +func (s *CoinSelect) selectForAmount(minTotalAmount uint64, + eligibleCommitments []*AnchoredCommitment, + strategy MultiCommitmentSelectStrategy) ([]*AnchoredCommitment, + error) { + + // Select the first subset of eligible commitments which cumulatively + // sum to at least the minimum required amount. + var selectedCommitments []*AnchoredCommitment + amountSum := uint64(0) + + switch strategy { + case PreferMaxAmount: + // Sort eligible commitments from the largest amount to + // smallest. + sort.Slice( + eligibleCommitments, func(i, j int) bool { + isLess := eligibleCommitments[i].Asset.Amount < + eligibleCommitments[j].Asset.Amount + + // Negate the result to sort in descending + // order. + return !isLess + }, + ) + + // Select the first subset of eligible commitments which + // cumulatively sum to at least the minimum required amount. + for _, anchoredCommitment := range eligibleCommitments { + selectedCommitments = append( + selectedCommitments, anchoredCommitment, + ) + + // Keep track of the total amount of assets we've seen + // so far. + amountSum += anchoredCommitment.Asset.Amount + if amountSum >= minTotalAmount { + // At this point a target min amount was + // specified and has been reached. + break + } + } + + default: + return nil, fmt.Errorf("unknown multi coin selection "+ + "strategy: %v", strategy) + } + + // Having examined all the eligible commitments, return an error if the + // minimal funding amount was not reached. + if amountSum < minTotalAmount { + return nil, ErrMatchingAssetsNotFound + } + return selectedCommitments, nil +} + +var _ CoinSelector = (*CoinSelect)(nil) diff --git a/tapfreighter/coin_select_test.go b/tapfreighter/coin_select_test.go new file mode 100644 index 000000000..64ce7bde8 --- /dev/null +++ b/tapfreighter/coin_select_test.go @@ -0,0 +1,248 @@ +package tapfreighter + +import ( + "context" + "testing" + "time" + + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/taproot-assets/asset" + "github.com/lightninglabs/taproot-assets/fn" + "github.com/stretchr/testify/require" +) + +// mockCoinLister is a mock implementation of the CoinLister interface. +type mockCoinLister struct { + eligibleCommitments []*AnchoredCommitment + + listSignals chan struct{} + leaseSignals chan struct{} + releaseSignals chan struct{} + deleteSignals chan struct{} +} + +func newMockCoinLister(c []*AnchoredCommitment) *mockCoinLister { + return &mockCoinLister{ + eligibleCommitments: c, + listSignals: make(chan struct{}, 1), + leaseSignals: make(chan struct{}, 1), + releaseSignals: make(chan struct{}, 1), + deleteSignals: make(chan struct{}, 1), + } +} + +func (m *mockCoinLister) ListEligibleCoins(context.Context, + CommitmentConstraints) ([]*AnchoredCommitment, error) { + + m.listSignals <- struct{}{} + + return m.eligibleCommitments, nil +} + +func (m *mockCoinLister) LeaseCoins(context.Context, [32]byte, time.Time, + ...wire.OutPoint) error { + + m.leaseSignals <- struct{}{} + + return nil +} + +func (m *mockCoinLister) ReleaseCoins(context.Context, ...wire.OutPoint) error { + m.releaseSignals <- struct{}{} + + return nil +} + +func (m *mockCoinLister) DeleteExpiredLeases(ctx context.Context) error { + m.deleteSignals <- struct{}{} + + return nil +} + +// TestCoinSelector tests that the coin selector behaves as expected. +func TestCoinSelector(t *testing.T) { + var ( + ctxb = context.Background() + timeout = 20 * time.Millisecond + coinLister = newMockCoinLister(nil) + coinSelect = NewCoinSelect(coinLister) + ) + + // Make sure the correct methods are called on the coin lister depending + // on the input. + _, err := coinSelect.SelectCoins( + ctxb, CommitmentConstraints{MinAmt: 1}, PreferMaxAmount, + ) + require.ErrorIs(t, err, ErrMatchingAssetsNotFound) + + // Both the list and delete signals should have been sent. + _, err = fn.RecvOrTimeout(coinLister.deleteSignals, timeout) + require.NoError(t, err) + _, err = fn.RecvOrTimeout(coinLister.listSignals, timeout) + require.NoError(t, err) + + // But because of the error we shouldn't have leased any coins. + _, err = fn.RecvOrTimeout(coinLister.listSignals, timeout) + require.Error(t, err) + + // Now let's add some UTXOs to the coin lister and actually select some. + coinLister.eligibleCommitments = []*AnchoredCommitment{ + { + Asset: &asset.Asset{ + Amount: 1000, + }, + }, + } + selected, err := coinSelect.SelectCoins( + ctxb, CommitmentConstraints{MinAmt: 1}, PreferMaxAmount, + ) + require.NoError(t, err) + require.Len(t, selected, 1) + + // In addition to old leases being deleted and coins listed, we now also + // should have leased the selected coins. + _, err = fn.RecvOrTimeout(coinLister.deleteSignals, timeout) + require.NoError(t, err) + _, err = fn.RecvOrTimeout(coinLister.listSignals, timeout) + require.NoError(t, err) + _, err = fn.RecvOrTimeout(coinLister.leaseSignals, timeout) + require.NoError(t, err) +} + +// TestCoinSelection tests that the coin selection logic behaves as expected. +func TestCoinSelection(t *testing.T) { + t.Parallel() + + type testCase struct { + name string + minTotalAmount uint64 + eligibleCommitments []*AnchoredCommitment + strategy MultiCommitmentSelectStrategy + + // Expected commitments (only set if no error is expected). + expectedCommitments []*AnchoredCommitment + + // Expected error status. + expectedErr string + } + + testCases := []testCase{ + // Test that an unknown strategy returns an error. + { + name: "unknown strategy", + minTotalAmount: 1000, + eligibleCommitments: []*AnchoredCommitment{{}}, + strategy: 100, + expectedErr: "unknown multi coin selection " + + "strategy", + }, + + // Test that when the PreferMaxAmount strategy is employed + // the selected commitment is the max amount commitment. + { + name: "prefer max amount", + minTotalAmount: 1000, + eligibleCommitments: []*AnchoredCommitment{ + { + Asset: &asset.Asset{ + Amount: 510, + }, + }, + { + Asset: &asset.Asset{ + Amount: 2000, + }, + }, + { + Asset: &asset.Asset{ + Amount: 490, + }, + }, + }, + strategy: PreferMaxAmount, + expectedCommitments: []*AnchoredCommitment{{ + Asset: &asset.Asset{ + Amount: 2000, + }, + }}, + }, + + // Test that when the PreferMaxAmount strategy is employed + // the selected commitments include the max amount commitment. + { + name: "prefer max amount with multiple " + + "commitments", + minTotalAmount: 1000, + eligibleCommitments: []*AnchoredCommitment{ + { + Asset: &asset.Asset{ + Amount: 980, + }, + }, + { + Asset: &asset.Asset{ + Amount: 999, + }, + }, + { + Asset: &asset.Asset{ + Amount: 10, + }, + }, + }, + strategy: PreferMaxAmount, + expectedCommitments: []*AnchoredCommitment{ + { + Asset: &asset.Asset{ + Amount: 999, + }, + }, + { + Asset: &asset.Asset{ + Amount: 980, + }, + }, + }, + }, + { + name: "not enough assets", + minTotalAmount: 1000, + eligibleCommitments: []*AnchoredCommitment{ + { + Asset: &asset.Asset{ + Amount: 980, + }, + }, + }, + strategy: PreferMaxAmount, + expectedErr: ErrMatchingAssetsNotFound.Error(), + }, + } + + for _, tc := range testCases { + tc := tc + + t.Run(tc.name, func(t *testing.T) { + coinLister := newMockCoinLister(tc.eligibleCommitments) + coinSelect := NewCoinSelect(coinLister) + + resultCommitments, err := coinSelect.selectForAmount( + tc.minTotalAmount, tc.eligibleCommitments, + tc.strategy, + ) + + if tc.expectedErr == "" { + require.NoError(t, err) + + require.EqualValues( + t, tc.expectedCommitments, + resultCommitments, + ) + + return + } + + require.ErrorContains(t, err, tc.expectedErr) + }) + } +} diff --git a/tapfreighter/interface.go b/tapfreighter/interface.go index 81c1df276..6238f4825 100644 --- a/tapfreighter/interface.go +++ b/tapfreighter/interface.go @@ -41,6 +41,19 @@ type CommitmentConstraints struct { MinAmt uint64 } +// String returns the string representation of the commitment constraints. +func (c *CommitmentConstraints) String() string { + var groupKeyBytes, assetIDBytes []byte + if c.GroupKey != nil { + groupKeyBytes = c.GroupKey.SerializeCompressed() + } + if c.AssetID != nil { + assetIDBytes = c.AssetID[:] + } + return fmt.Sprintf("group_key=%x, asset_id=%x, min_amt=%d", + groupKeyBytes, assetIDBytes, c.MinAmt) +} + // AnchoredCommitment is the response to satisfying the set of // CommitmentConstraints. This includes the asset itself, and also information // needed to locate the asset on-chain and also prove its existence. diff --git a/tapfreighter/wallet.go b/tapfreighter/wallet.go index 88533320e..ea08231cc 100644 --- a/tapfreighter/wallet.go +++ b/tapfreighter/wallet.go @@ -5,8 +5,6 @@ import ( "context" "errors" "fmt" - "sort" - "sync" "time" "github.com/btcsuite/btcd/blockchain" @@ -164,169 +162,6 @@ type AnchorVTxnsParams struct { PassiveAssetsVPkts []*tappsbt.VPacket } -// NewCoinSelect creates a new CoinSelect. -func NewCoinSelect(coinLister CoinLister) *CoinSelect { - return &CoinSelect{ - coinLister: coinLister, - } -} - -// CoinSelect selects asset coins to spend in order to fund a send -// transaction. -type CoinSelect struct { - coinLister CoinLister - - // coinLock is a read/write mutex that is used to ensure that only one - // goroutine is attempting to call any coin selection related methods at - // any time. This is necessary as some of the calls to the store (e.g. - // ListEligibleCoins -> LeaseCoin) are called after each other and - // cannot be placed within the same database transaction. So calls to - // those methods must hold this coin lock. - coinLock sync.Mutex -} - -// SelectCoins returns a set of not yet leased coins that satisfy the given -// constraints and strategy. The coins returned are leased for the default lease -// duration. -func (s *CoinSelect) SelectCoins(ctx context.Context, - constraints CommitmentConstraints, - strategy MultiCommitmentSelectStrategy) ([]*AnchoredCommitment, error) { - - s.coinLock.Lock() - defer s.coinLock.Unlock() - - // Before we select any coins, let's do some cleanup of expired leases. - if err := s.coinLister.DeleteExpiredLeases(ctx); err != nil { - return nil, fmt.Errorf("unable to delete expired leases: %w", - err) - } - - listConstraints := CommitmentConstraints{ - GroupKey: constraints.GroupKey, - AssetID: constraints.AssetID, - MinAmt: 1, - } - eligibleCommitments, err := s.coinLister.ListEligibleCoins( - ctx, listConstraints, - ) - if err != nil { - return nil, fmt.Errorf("unable to list eligible coins: %w", err) - } - - log.Infof("Identified %v eligible asset inputs for send of %d to %x", - len(eligibleCommitments), constraints.MinAmt, - constraints.AssetID[:]) - - selectedCoins, err := s.selectForAmount( - constraints.MinAmt, eligibleCommitments, strategy, - ) - if err != nil { - return nil, fmt.Errorf("unable to select coins: %w", err) - } - - // We now need to lock/lease/reserve those selected coins so - // that they can't be used by other processes. - expiry := time.Now().Add(defaultCoinLeaseDuration) - coinOutPoints := fn.Map( - selectedCoins, func(c *AnchoredCommitment) wire.OutPoint { - return c.AnchorPoint - }, - ) - err = s.coinLister.LeaseCoins( - ctx, defaultWalletLeaseIdentifier, expiry, coinOutPoints..., - ) - if err != nil { - return nil, fmt.Errorf("unable to lease coin: %w", err) - } - - return selectedCoins, nil -} - -// LeaseCoins leases/locks/reserves coins for the given lease owner until the -// given expiry. This is used to prevent multiple concurrent coin selection -// attempts from selecting the same coin(s). -func (s *CoinSelect) LeaseCoins(ctx context.Context, leaseOwner [32]byte, - expiry time.Time, utxoOutpoints ...wire.OutPoint) error { - - s.coinLock.Lock() - defer s.coinLock.Unlock() - - return s.coinLister.LeaseCoins( - ctx, leaseOwner, expiry, utxoOutpoints..., - ) -} - -// ReleaseCoins releases/unlocks coins that were previously leased and makes -// them available for coin selection again. -func (s *CoinSelect) ReleaseCoins(ctx context.Context, - utxoOutpoints ...wire.OutPoint) error { - - s.coinLock.Lock() - defer s.coinLock.Unlock() - - return s.coinLister.ReleaseCoins(ctx, utxoOutpoints...) -} - -// selectForAmount selects a subset of the given eligible commitments which -// cumulatively sum to at least the minimum required amount. The selection -// strategy determines how the commitments are selected. -func (s *CoinSelect) selectForAmount(minTotalAmount uint64, - eligibleCommitments []*AnchoredCommitment, - strategy MultiCommitmentSelectStrategy) ([]*AnchoredCommitment, - error) { - - // Select the first subset of eligible commitments which cumulatively - // sum to at least the minimum required amount. - var selectedCommitments []*AnchoredCommitment - amountSum := uint64(0) - - switch strategy { - case PreferMaxAmount: - // Sort eligible commitments from the largest amount to - // smallest. - sort.Slice( - eligibleCommitments, func(i, j int) bool { - isLess := eligibleCommitments[i].Asset.Amount < - eligibleCommitments[j].Asset.Amount - - // Negate the result to sort in descending - // order. - return !isLess - }, - ) - - // Select the first subset of eligible commitments which - // cumulatively sum to at least the minimum required amount. - for _, anchoredCommitment := range eligibleCommitments { - selectedCommitments = append( - selectedCommitments, anchoredCommitment, - ) - - // Keep track of the total amount of assets we've seen - // so far. - amountSum += anchoredCommitment.Asset.Amount - if amountSum >= minTotalAmount { - // At this point a target min amount was - // specified and has been reached. - break - } - } - - default: - return nil, fmt.Errorf("unknown multi coin selection "+ - "strategy: %v", strategy) - } - - // Having examined all the eligible commitments, return an error if the - // minimal funding amount was not reached. - if amountSum < minTotalAmount { - return nil, ErrMatchingAssetsNotFound - } - return selectedCommitments, nil -} - -var _ CoinSelector = (*CoinSelect)(nil) - // WalletConfig holds the configuration for a new Wallet. type WalletConfig struct { // CoinSelector is the interface used to select input coins (assets) diff --git a/tapfreighter/wallet_test.go b/tapfreighter/wallet_test.go deleted file mode 100644 index 0c661dfcc..000000000 --- a/tapfreighter/wallet_test.go +++ /dev/null @@ -1,165 +0,0 @@ -package tapfreighter - -import ( - "context" - "testing" - "time" - - "github.com/btcsuite/btcd/wire" - "github.com/lightninglabs/taproot-assets/asset" - "github.com/stretchr/testify/require" -) - -// mockCoinLister is a mock implementation of the CoinLister interface. -type mockCoinLister struct { - eligibleCommitments []*AnchoredCommitment -} - -func (m *mockCoinLister) ListEligibleCoins( - ctx context.Context, constraints CommitmentConstraints) ( - []*AnchoredCommitment, error) { - - return m.eligibleCommitments, nil -} - -func (m *mockCoinLister) LeaseCoins(context.Context, [32]byte, time.Time, - ...wire.OutPoint) error { - - return nil -} - -func (m *mockCoinLister) ReleaseCoins(context.Context, ...wire.OutPoint) error { - return nil -} - -func (m *mockCoinLister) DeleteExpiredLeases(ctx context.Context) error { - return nil -} - -// TestCoinSelection tests that the coin selection logic behaves as expected. -func TestCoinSelection(t *testing.T) { - t.Parallel() - - type testCase struct { - minTotalAmount uint64 - eligibleCommitments []*AnchoredCommitment - strategy MultiCommitmentSelectStrategy - - // Result analysis parameters. - // - // Expected commitments. - expectedCommitments []*AnchoredCommitment - checkSelectedCommitments bool - - // Expected error status. - expectedSomeErr bool - } - - testCases := []testCase{ - // Test that an unknown strategy returns an error. - { - minTotalAmount: 1000, - eligibleCommitments: []*AnchoredCommitment{{}}, - strategy: 100, // Set to unknown strategy. - expectedSomeErr: true, - }, - - // Test that when the PreferMaxAmount strategy is employed - // the selected commitment is the max amount commitment. - { - minTotalAmount: 1000, - eligibleCommitments: []*AnchoredCommitment{ - { - Asset: &asset.Asset{ - Amount: 510, - }, - }, - { - Asset: &asset.Asset{ - Amount: 2000, - }, - }, - { - Asset: &asset.Asset{ - Amount: 490, - }, - }, - }, - strategy: PreferMaxAmount, - checkSelectedCommitments: true, - expectedCommitments: []*AnchoredCommitment{{ - Asset: &asset.Asset{ - Amount: 2000, - }, - }}, - }, - - // Test that when the PreferMaxAmount strategy is employed - // the selected commitments include the max amount commitment. - { - minTotalAmount: 1000, - eligibleCommitments: []*AnchoredCommitment{ - { - Asset: &asset.Asset{ - Amount: 980, - }, - }, - { - Asset: &asset.Asset{ - Amount: 999, - }, - }, - { - Asset: &asset.Asset{ - Amount: 10, - }, - }, - }, - strategy: PreferMaxAmount, - checkSelectedCommitments: true, - expectedCommitments: []*AnchoredCommitment{ - { - Asset: &asset.Asset{ - Amount: 999, - }, - }, - { - Asset: &asset.Asset{ - Amount: 980, - }, - }, - }, - }, - } - - // Execute test cases. - for idx, testCase := range testCases { - coinLister := &mockCoinLister{ - eligibleCommitments: testCase.eligibleCommitments, - } - coinSelect := NewCoinSelect(coinLister) - - resultCommitments, err := coinSelect.selectForAmount( - testCase.minTotalAmount, testCase.eligibleCommitments, - testCase.strategy, - ) - - // Analyse results. - if testCase.checkSelectedCommitments { - require.EqualValues( - t, testCase.expectedCommitments, - resultCommitments, - ) - } - - if testCase.expectedSomeErr { - require.Error(t, err) - } else { - require.NoError(t, err) - } - - // Variable included for debugging (conditional breakpoints), - // may otherwise be unused. - _ = idx - } -}