From 1097511289e5cd536b97e9147e7ffbfb7c0129a5 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Mon, 10 Feb 2025 15:53:16 -0300 Subject: [PATCH 1/5] sweepbatcher: make func constructUnsignedTx pure Also added a unit test for it. --- sweepbatcher/sweep_batch.go | 14 +- sweepbatcher/sweep_batch_test.go | 319 +++++++++++++++++++++++++++++++ 2 files changed, 326 insertions(+), 7 deletions(-) create mode 100644 sweepbatcher/sweep_batch_test.go diff --git a/sweepbatcher/sweep_batch.go b/sweepbatcher/sweep_batch.go index fa2fca3ae..83668c30f 100644 --- a/sweepbatcher/sweep_batch.go +++ b/sweepbatcher/sweep_batch.go @@ -959,9 +959,9 @@ func (b *batch) createPsbt(unsignedTx *wire.MsgTx, sweeps []sweep) ([]byte, // constructUnsignedTx creates unsigned tx from the sweeps, paying to the addr. // It also returns absolute fee (from weight and clamped). -func (b *batch) constructUnsignedTx(sweeps []sweep, - address btcutil.Address) (*wire.MsgTx, lntypes.WeightUnit, - btcutil.Amount, btcutil.Amount, error) { +func constructUnsignedTx(sweeps []sweep, address btcutil.Address, + currentHeight int32, feeRate chainfee.SatPerKWeight) (*wire.MsgTx, + lntypes.WeightUnit, btcutil.Amount, btcutil.Amount, error) { // Sanity check, there should be at least 1 sweep in this batch. if len(sweeps) == 0 { @@ -971,7 +971,7 @@ func (b *batch) constructUnsignedTx(sweeps []sweep, // Create the batch transaction. batchTx := &wire.MsgTx{ Version: 2, - LockTime: uint32(b.currentHeight), + LockTime: uint32(currentHeight), } // Add transaction inputs and estimate its weight. @@ -1023,7 +1023,7 @@ func (b *batch) constructUnsignedTx(sweeps []sweep, // Find weight and fee. weight := weightEstimate.Weight() - feeForWeight := b.rbfCache.FeeRate.FeeForWeight(weight) + feeForWeight := feeRate.FeeForWeight(weight) // Clamp the calculated fee to the max allowed fee amount for the batch. fee := clampBatchFee(feeForWeight, batchAmt) @@ -1108,8 +1108,8 @@ func (b *batch) publishMixedBatch(ctx context.Context) (btcutil.Amount, error, // Construct unsigned batch transaction. var err error - tx, weight, feeForWeight, fee, err = b.constructUnsignedTx( - sweeps, address, + tx, weight, feeForWeight, fee, err = constructUnsignedTx( + sweeps, address, b.currentHeight, b.rbfCache.FeeRate, ) if err != nil { return 0, fmt.Errorf("failed to construct tx: %w", err), diff --git a/sweepbatcher/sweep_batch_test.go b/sweepbatcher/sweep_batch_test.go new file mode 100644 index 000000000..dd873b300 --- /dev/null +++ b/sweepbatcher/sweep_batch_test.go @@ -0,0 +1,319 @@ +package sweepbatcher + +import ( + "fmt" + "testing" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/loop/loopdb" + "github.com/lightninglabs/loop/utils" + "github.com/lightningnetwork/lnd/input" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/stretchr/testify/require" +) + +// TestConstructUnsignedTx verifies that the function constructUnsignedTx +// correctly creates unsigned transactions. +func TestConstructUnsignedTx(t *testing.T) { + // Prepare the necessary data for test cases. + op1 := wire.OutPoint{ + Hash: chainhash.Hash{1, 1, 1}, + Index: 1, + } + op2 := wire.OutPoint{ + Hash: chainhash.Hash{2, 2, 2}, + Index: 2, + } + + batchPkScript, err := txscript.PayToAddrScript(destAddr) + require.NoError(t, err) + + p2trAddr := "bcrt1pa38tp2hgjevqv3jcsxeu7v72n0s5a3ck8q2u8r" + + "k6mm67dv7uk26qq8je7e" + p2trAddress, err := btcutil.DecodeAddress(p2trAddr, nil) + require.NoError(t, err) + p2trPkScript, err := txscript.PayToAddrScript(p2trAddress) + require.NoError(t, err) + + serializedPubKey := []byte{ + 0x02, 0x19, 0x2d, 0x74, 0xd0, 0xcb, 0x94, 0x34, 0x4c, 0x95, + 0x69, 0xc2, 0xe7, 0x79, 0x01, 0x57, 0x3d, 0x8d, 0x79, 0x03, + 0xc3, 0xeb, 0xec, 0x3a, 0x95, 0x77, 0x24, 0x89, 0x5d, 0xca, + 0x52, 0xc6, 0xb4} + p2pkAddress, err := btcutil.NewAddressPubKey( + serializedPubKey, &chaincfg.RegressionNetParams, + ) + require.NoError(t, err) + + swapHash := lntypes.Hash{1, 1, 1} + + swapContract := &loopdb.SwapContract{ + CltvExpiry: 222, + AmountRequested: 2_000_000, + ProtocolVersion: loopdb.ProtocolVersionMuSig2, + HtlcKeys: htlcKeys, + } + + htlc, err := utils.GetHtlc( + swapHash, swapContract, &chaincfg.RegressionNetParams, + ) + require.NoError(t, err) + estimator := htlc.AddSuccessToEstimator + + brokenEstimator := func(*input.TxWeightEstimator) error { + return fmt.Errorf("weight estimator test failure") + } + + cases := []struct { + name string + sweeps []sweep + address btcutil.Address + currentHeight int32 + feeRate chainfee.SatPerKWeight + wantErr string + wantTx *wire.MsgTx + wantWeight lntypes.WeightUnit + wantFeeForWeight btcutil.Amount + wantFee btcutil.Amount + }{ + { + name: "no sweeps error", + wantErr: "no sweeps in batch", + }, + + { + name: "two coop sweeps", + sweeps: []sweep{ + { + outpoint: op1, + value: 1_000_000, + }, + { + outpoint: op2, + value: 2_000_000, + }, + }, + address: destAddr, + currentHeight: 800_000, + feeRate: 1000, + wantTx: &wire.MsgTx{ + Version: 2, + LockTime: 800_000, + TxIn: []*wire.TxIn{ + { + PreviousOutPoint: op1, + }, + { + PreviousOutPoint: op2, + }, + }, + TxOut: []*wire.TxOut{ + { + Value: 2999374, + PkScript: batchPkScript, + }, + }, + }, + wantWeight: 626, + wantFeeForWeight: 626, + wantFee: 626, + }, + + { + name: "p2tr destination address", + sweeps: []sweep{ + { + outpoint: op1, + value: 1_000_000, + }, + { + outpoint: op2, + value: 2_000_000, + }, + }, + address: p2trAddress, + currentHeight: 800_000, + feeRate: 1000, + wantTx: &wire.MsgTx{ + Version: 2, + LockTime: 800_000, + TxIn: []*wire.TxIn{ + { + PreviousOutPoint: op1, + }, + { + PreviousOutPoint: op2, + }, + }, + TxOut: []*wire.TxOut{ + { + Value: 2999326, + PkScript: p2trPkScript, + }, + }, + }, + wantWeight: 674, + wantFeeForWeight: 674, + wantFee: 674, + }, + + { + name: "unknown kind of address", + sweeps: []sweep{ + { + outpoint: op1, + value: 1_000_000, + }, + { + outpoint: op2, + value: 2_000_000, + }, + }, + address: nil, + wantErr: "unsupported address type", + }, + + { + name: "pay-to-pubkey address", + sweeps: []sweep{ + { + outpoint: op1, + value: 1_000_000, + }, + { + outpoint: op2, + value: 2_000_000, + }, + }, + address: p2pkAddress, + wantErr: "unknown address type", + }, + + { + name: "fee more than 20% clamped", + sweeps: []sweep{ + { + outpoint: op1, + value: 1_000_000, + }, + { + outpoint: op2, + value: 2_000_000, + }, + }, + address: destAddr, + currentHeight: 800_000, + feeRate: 1_000_000, + wantTx: &wire.MsgTx{ + Version: 2, + LockTime: 800_000, + TxIn: []*wire.TxIn{ + { + PreviousOutPoint: op1, + }, + { + PreviousOutPoint: op2, + }, + }, + TxOut: []*wire.TxOut{ + { + Value: 2400000, + PkScript: batchPkScript, + }, + }, + }, + wantWeight: 626, + wantFeeForWeight: 626_000, + wantFee: 600_000, + }, + + { + name: "coop and noncoop", + sweeps: []sweep{ + { + outpoint: op1, + value: 1_000_000, + }, + { + outpoint: op2, + value: 2_000_000, + nonCoopHint: true, + htlc: *htlc, + htlcSuccessEstimator: estimator, + }, + }, + address: destAddr, + currentHeight: 800_000, + feeRate: 1000, + wantTx: &wire.MsgTx{ + Version: 2, + LockTime: 800_000, + TxIn: []*wire.TxIn{ + { + PreviousOutPoint: op1, + }, + { + PreviousOutPoint: op2, + Sequence: 1, + }, + }, + TxOut: []*wire.TxOut{ + { + Value: 2999211, + PkScript: batchPkScript, + }, + }, + }, + wantWeight: 789, + wantFeeForWeight: 789, + wantFee: 789, + }, + + { + name: "weight estimator fails", + sweeps: []sweep{ + { + outpoint: op1, + value: 1_000_000, + }, + { + outpoint: op2, + value: 2_000_000, + nonCoopHint: true, + htlc: *htlc, + htlcSuccessEstimator: brokenEstimator, + }, + }, + address: destAddr, + currentHeight: 800_000, + feeRate: 1000, + wantErr: "sweep.htlcSuccessEstimator failed: " + + "weight estimator test failure", + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + tx, weight, feeForW, fee, err := constructUnsignedTx( + tc.sweeps, tc.address, tc.currentHeight, + tc.feeRate, + ) + if tc.wantErr != "" { + require.Error(t, err) + require.ErrorContains(t, err, tc.wantErr) + } else { + require.NoError(t, err) + require.Equal(t, tc.wantTx, tx) + require.Equal(t, tc.wantWeight, weight) + require.Equal(t, tc.wantFeeForWeight, feeForW) + require.Equal(t, tc.wantFee, fee) + } + }) + } +} From 6839feda31a29871c977373c5d4caa119b0aaf4f Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Thu, 20 Feb 2025 01:51:07 -0300 Subject: [PATCH 2/5] test: implement MinRelayFee RPC --- test/lnd_services_mock.go | 5 +++++ test/walletkit_mock.go | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/test/lnd_services_mock.go b/test/lnd_services_mock.go index db4447448..07e25170b 100644 --- a/test/lnd_services_mock.go +++ b/test/lnd_services_mock.go @@ -29,6 +29,7 @@ func NewMockLnd() *LndMockServices { lightningClient := &mockLightningClient{} walletKit := &mockWalletKit{ feeEstimates: make(map[int32]chainfee.SatPerKWeight), + minRelayFee: chainfee.FeePerKwFloor, } chainNotifier := &mockChainNotifier{} signer := &mockSigner{} @@ -278,3 +279,7 @@ func (s *LndMockServices) SetFeeEstimate(confTarget int32, confTarget, feeEstimate, ) } + +func (s *LndMockServices) SetMinRelayFee(feeEstimate chainfee.SatPerKWeight) { + s.LndServices.WalletKit.(*mockWalletKit).setMinRelayFee(feeEstimate) +} diff --git a/test/walletkit_mock.go b/test/walletkit_mock.go index 0f7ccee9a..e4d4e38b1 100644 --- a/test/walletkit_mock.go +++ b/test/walletkit_mock.go @@ -34,6 +34,7 @@ type mockWalletKit struct { feeEstimateLock sync.Mutex feeEstimates map[int32]chainfee.SatPerKWeight + minRelayFee chainfee.SatPerKWeight } var _ lndclient.WalletKitClient = (*mockWalletKit)(nil) @@ -169,6 +170,24 @@ func (m *mockWalletKit) EstimateFeeRate(ctx context.Context, return feeEstimate, nil } +func (m *mockWalletKit) setMinRelayFee(fee chainfee.SatPerKWeight) { + m.feeEstimateLock.Lock() + defer m.feeEstimateLock.Unlock() + + m.minRelayFee = fee +} + +// MinRelayFee returns the current minimum relay fee based on our chain backend +// in sat/kw. It can be set with setMinRelayFee. +func (m *mockWalletKit) MinRelayFee( + ctx context.Context) (chainfee.SatPerKWeight, error) { + + m.feeEstimateLock.Lock() + defer m.feeEstimateLock.Unlock() + + return m.minRelayFee, nil +} + // ListSweeps returns a list of the sweep transaction ids known to our node. func (m *mockWalletKit) ListSweeps(_ context.Context, _ int32) ( []string, error) { From c6b6d8c1cbabbedc1cba7bb54bbca012b4477703 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Fri, 21 Feb 2025 14:44:08 -0300 Subject: [PATCH 3/5] test: implement SignPsbt RPC --- test/walletkit_mock.go | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/test/walletkit_mock.go b/test/walletkit_mock.go index e4d4e38b1..4229e9642 100644 --- a/test/walletkit_mock.go +++ b/test/walletkit_mock.go @@ -1,6 +1,7 @@ package test import ( + "bytes" "context" "errors" "fmt" @@ -246,6 +247,25 @@ func (m *mockWalletKit) FundPsbt(_ context.Context, return nil, 0, nil, nil } +// finalScriptWitness is a sample signature suitable to put into PSBT. +var finalScriptWitness = func() []byte { + const pver = 0 + var buf bytes.Buffer + + // Write the number of witness elements. + if err := wire.WriteVarInt(&buf, pver, 1); err != nil { + panic(err) + } + + // Write a single witness element with a signature. + signature := make([]byte, 64) + if err := wire.WriteVarBytes(&buf, pver, signature); err != nil { + panic(err) + } + + return buf.Bytes() +}() + // SignPsbt expects a partial transaction with all inputs and outputs // fully declared and tries to sign all unsigned inputs that have all // required fields (UTXO information, BIP32 derivation information, @@ -258,9 +278,19 @@ func (m *mockWalletKit) FundPsbt(_ context.Context, // locking or input/output/fee value validation, PSBT finalization). Any // input that is incomplete will be skipped. func (m *mockWalletKit) SignPsbt(_ context.Context, - _ *psbt.Packet) (*psbt.Packet, error) { + packet *psbt.Packet) (*psbt.Packet, error) { - return nil, nil + inputs := make([]psbt.PInput, len(packet.Inputs)) + copy(inputs, packet.Inputs) + + for i := range inputs { + inputs[i].FinalScriptWitness = finalScriptWitness + } + + signedPacket := *packet + signedPacket.Inputs = inputs + + return &signedPacket, nil } // FinalizePsbt expects a partial transaction with all inputs and From 84926b434f6da985b05c309fc00109f908c9ddf8 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Fri, 21 Feb 2025 20:47:18 -0300 Subject: [PATCH 4/5] test: allow intercepting PublishTransaction --- test/lnd_services_mock.go | 7 +++++++ test/walletkit_mock.go | 8 +++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/test/lnd_services_mock.go b/test/lnd_services_mock.go index 07e25170b..aaf5c1106 100644 --- a/test/lnd_services_mock.go +++ b/test/lnd_services_mock.go @@ -129,6 +129,11 @@ type SignOutputRawRequest struct { SignDescriptors []*lndclient.SignDescriptor } +// PublishHandler is optional transaction handler function called upon calling +// the method PublishTransaction. +type PublishHandler func(ctx context.Context, tx *wire.MsgTx, + label string) error + // LndMockServices provides a full set of mocked lnd services. type LndMockServices struct { lndclient.LndServices @@ -174,6 +179,8 @@ type LndMockServices struct { WaitForFinished func() + PublishHandler PublishHandler + lock sync.Mutex } diff --git a/test/walletkit_mock.go b/test/walletkit_mock.go index 4229e9642..70a9f2f41 100644 --- a/test/walletkit_mock.go +++ b/test/walletkit_mock.go @@ -113,7 +113,13 @@ func (m *mockWalletKit) NextAddr(context.Context, string, walletrpc.AddressType, } func (m *mockWalletKit) PublishTransaction(ctx context.Context, tx *wire.MsgTx, - _ string) error { + label string) error { + + if m.lnd.PublishHandler != nil { + if err := m.lnd.PublishHandler(ctx, tx, label); err != nil { + return err + } + } m.lnd.AddTx(tx) m.lnd.TxPublishChannel <- tx From 37ca7b75ceb6d6b961cba277821f059d4157acf9 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Mon, 10 Feb 2025 18:39:17 -0300 Subject: [PATCH 5/5] sweepbatcher: add mode with presigned transactions In this mode sweepbatcher uses transactions provided by the Presigned helper. Transactions are signed upon adding an input to a batch. A single Batcher instance can handle both presigned and regular batches. Currently presigned and non-presigned sweeps never appear in the same batch. --- sweepbatcher/presigned.go | 451 ++++++++++ sweepbatcher/presigned_test.go | 848 ++++++++++++++++++ sweepbatcher/sweep_batch.go | 147 +++- sweepbatcher/sweep_batcher.go | 120 ++- sweepbatcher/sweep_batcher_test.go | 1300 +++++++++++++++++++++++++--- 5 files changed, 2752 insertions(+), 114 deletions(-) create mode 100644 sweepbatcher/presigned.go create mode 100644 sweepbatcher/presigned_test.go diff --git a/sweepbatcher/presigned.go b/sweepbatcher/presigned.go new file mode 100644 index 000000000..ecb39bd67 --- /dev/null +++ b/sweepbatcher/presigned.go @@ -0,0 +1,451 @@ +package sweepbatcher + +import ( + "bytes" + "context" + "fmt" + + "github.com/btcsuite/btcd/blockchain" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" +) + +// ensurePresigned checks that we can sign a 1:1 transaction sweeping the input. +func (b *batch) ensurePresigned(ctx context.Context, newSweep *sweep) error { + if b.cfg.presignedHelper == nil { + return fmt.Errorf("presignedHelper is not installed") + } + if len(b.sweeps) != 0 { + return fmt.Errorf("ensurePresigned is done when adding to an " + + "empty batch") + } + + sweeps := []sweep{ + { + outpoint: newSweep.outpoint, + value: newSweep.value, + presigned: newSweep.presigned, + }, + } + + // Cache the destination address. + destAddr, err := b.getSweepsDestAddr(ctx, sweeps) + if err != nil { + return fmt.Errorf("failed to find destination address: %w", err) + } + + // Set LockTime to 0. It is not critical. + const currentHeight = 0 + + // Check if we can sign with minimum fee rate. + const feeRate = chainfee.FeePerKwFloor + + tx, _, _, _, err := constructUnsignedTx( + sweeps, destAddr, currentHeight, feeRate, + ) + if err != nil { + return fmt.Errorf("failed to construct unsigned tx "+ + "for feeRate %v: %w", feeRate, err) + } + + // Check of a presigned transaction exists. + batchAmt := newSweep.value + const presignedOnly = true + signedTx, err := b.cfg.presignedHelper.SignTx( + ctx, tx, batchAmt, feeRate, feeRate, presignedOnly, + ) + if err != nil { + return fmt.Errorf("failed to sign unsigned tx %v "+ + "for feeRate %v: %w", tx.TxHash(), feeRate, err) + } + + // Check the SignTx worked correctly. + err = CheckSignedTx(tx, signedTx, batchAmt, feeRate) + if err != nil { + return fmt.Errorf("signed tx doesn't correspond the "+ + "unsigned tx: %w", err) + } + + return nil +} + +// presign tries to presign batch sweep transactions composed of this batch and +// the sweep. It signs multiple versions of the transaction to make sure there +// is a transaction to be published if minRelayFee grows. +func (b *batch) presign(ctx context.Context, newSweep *sweep) error { + if b.cfg.presignedHelper == nil { + return fmt.Errorf("presignedHelper is not installed") + } + if len(b.sweeps) == 0 { + return fmt.Errorf("presigning is done when adding to a " + + "non-empty batch") + } + + // priorityConfTarget defines the confirmation target for quick + // inclusion in a block. A value of 2, rather than 1, is used to prevent + // overestimation caused by temporary anomalies, such as periods without + // blocks. + const priorityConfTarget = 2 + + // Find the feerate needed to get into next block. + nextBlockFeeRate, err := b.wallet.EstimateFeeRate( + ctx, priorityConfTarget, + ) + if err != nil { + return fmt.Errorf("failed to get nextBlockFeeRate: %w", err) + } + + b.Infof("nextBlockFeeRate is %v", nextBlockFeeRate) + + // Create the list of sweeps of the future batch. + sweeps := make([]sweep, 0, len(b.sweeps)+1) + for _, sweep := range b.sweeps { + sweeps = append(sweeps, sweep) + } + existingSweeps := sweeps + sweeps = append(sweeps, *newSweep) + + // Cache the destination address. + destAddr, err := b.getSweepsDestAddr(ctx, existingSweeps) + if err != nil { + return fmt.Errorf("failed to find destination address: %w", err) + } + + return presign( + ctx, b.cfg.presignedHelper, destAddr, sweeps, nextBlockFeeRate, + ) +} + +// presigner tries to presign a batch transaction. +type presigner interface { + // Presign tries to presign a batch transaction. If the method returns + // nil, it is guaranteed that future calls to SignTx on this set of + // sweeps return valid signed transactions. + Presign(ctx context.Context, tx *wire.MsgTx, + inputAmt btcutil.Amount) error +} + +// presign tries to presign batch sweep transactions of the sweeps. It signs +// multiple versions of the transaction to make sure there is a transaction to +// be published if minRelayFee grows. If feerate is high, then a presigned tx +// gets LockTime equal to timeout minus 50 blocks, as a precautionary measure. +// A feerate is considered high if it is at least 100 sat/vbyte AND is at least +// 10x of the current next block feerate. +func presign(ctx context.Context, presigner presigner, destAddr btcutil.Address, + sweeps []sweep, nextBlockFeeRate chainfee.SatPerKWeight) error { + + if presigner == nil { + return fmt.Errorf("presigner is not installed") + } + + if len(sweeps) == 0 { + return fmt.Errorf("there are no sweeps") + } + + if nextBlockFeeRate == 0 { + return fmt.Errorf("nextBlockFeeRate is not set") + } + + // Keep track of the total amount this batch is sweeping back. + batchAmt := btcutil.Amount(0) + for _, sweep := range sweeps { + batchAmt += sweep.value + } + + // Find the sweep with the earliest expiry. + timeout := sweeps[0].timeout + for _, sweep := range sweeps[1:] { + timeout = min(timeout, sweep.timeout) + } + if timeout <= 0 { + return fmt.Errorf("timeout is invalid: %d", timeout) + } + + // Go from the floor (1.01 sat/vbyte) to 2k sat/vbyte with step of 1.2x. + const ( + start = chainfee.FeePerKwFloor + stop = chainfee.AbsoluteFeePerKwFloor * 2_000 + factorPPM = 1_200_000 + timeoutThreshold = 50 + ) + + // Calculate the locktime value to use for high feerate transactions. + highFeeRateLockTime := uint32(timeout - timeoutThreshold) + + // Calculate which feerate to consider high. At least 100 sat/vbyte and + // at least 10x of current nextBlockFeeRate. + highFeeRate := max(100*chainfee.FeePerKwFloor, 10*nextBlockFeeRate) + + // Set LockTime to 0. It is not critical. + const currentHeight = 0 + + for fr := start; fr <= stop; fr = (fr * factorPPM) / 1_000_000 { + // Construct an unsigned transaction for this fee rate. + tx, _, feeForWeight, fee, err := constructUnsignedTx( + sweeps, destAddr, currentHeight, fr, + ) + if err != nil { + return fmt.Errorf("failed to construct unsigned tx "+ + "for feeRate %v: %w", fr, err) + } + + // If the feerate is high enough, set locktime to prevent + // broadcasting such a transaction too early by mistake. + if fr >= highFeeRate { + tx.LockTime = highFeeRateLockTime + } + + // Try to presign this transaction. + err = presigner.Presign(ctx, tx, batchAmt) + if err != nil { + return fmt.Errorf("failed to presign unsigned tx %v "+ + "for feeRate %v: %w", tx.TxHash(), fr, err) + } + + // If fee was clamped, stop here, because fee rate won't grow. + if fee < feeForWeight { + break + } + } + + return nil +} + +// publishPresigned creates sweep transaction using a custom transaction signer +// and publishes it. It returns the fee of the transaction, and an error (if +// signing and/or publishing failed) and a boolean flag indicating signing +// success. This mode is incompatible with an external address. +func (b *batch) publishPresigned(ctx context.Context) (btcutil.Amount, error, + bool) { + + // Sanity check, there should be at least 1 sweep in this batch. + if len(b.sweeps) == 0 { + return 0, fmt.Errorf("no sweeps in batch"), false + } + + // Make sure that no external address is used. + for _, sweep := range b.sweeps { + if sweep.isExternalAddr { + return 0, fmt.Errorf("external address was used with " + + "a custom transaction signer"), false + } + } + + // Cache current height and desired feerate of the batch. + currentHeight := b.currentHeight + feeRate := b.rbfCache.FeeRate + + // Append this sweep to an array of sweeps. This is needed to keep the + // order of sweeps stored, as iterating the sweeps map does not + // guarantee same order. + sweeps := make([]sweep, 0, len(b.sweeps)) + for _, sweep := range b.sweeps { + sweeps = append(sweeps, sweep) + } + + // Cache the destination address. + address, err := b.getSweepsDestAddr(ctx, sweeps) + if err != nil { + return 0, fmt.Errorf("failed to find destination address: %w", + err), false + } + + // Construct unsigned batch transaction. + tx, weight, _, fee, err := constructUnsignedTx( + sweeps, address, currentHeight, feeRate, + ) + if err != nil { + return 0, fmt.Errorf("failed to construct tx: %w", err), + false + } + + // Adjust feeRate, because it may have been clamped. + feeRate = chainfee.NewSatPerKWeight(fee, weight) + + // Calculate total input amount. + batchAmt := btcutil.Amount(0) + for _, sweep := range sweeps { + batchAmt += sweep.value + } + + // Determine the current minimum relay fee based on our chain backend. + minRelayFee, err := b.wallet.MinRelayFee(ctx) + if err != nil { + return 0, fmt.Errorf("failed to get minRelayFee: %w", err), + false + } + + // Get a signed transaction. It may be either new transaction or a + // pre-signed one. + const presignedOnly = false + signedTx, err := b.cfg.presignedHelper.SignTx( + ctx, tx, batchAmt, minRelayFee, feeRate, presignedOnly, + ) + if err != nil { + return 0, fmt.Errorf("failed to sign tx: %w", err), + false + } + + // Run sanity checks to make sure presignedHelper.SignTx complied with + // all the invariants. + err = CheckSignedTx(tx, signedTx, batchAmt, minRelayFee) + if err != nil { + return 0, fmt.Errorf("signed tx doesn't correspond the "+ + "unsigned tx: %w", err), false + } + tx = signedTx + txHash := tx.TxHash() + + // Make sure tx weight matches the expected value. + realWeight := lntypes.WeightUnit( + blockchain.GetTransactionWeight(btcutil.NewTx(tx)), + ) + if realWeight != weight { + b.Warnf("actual weight of tx %v is %v, estimated as %d", + txHash, realWeight, weight) + } + + // Find actual fee rate of the signed transaction. It may differ from + // the desired fee rate, because SignTx may return a presigned tx. + output := btcutil.Amount(tx.TxOut[0].Value) + fee = batchAmt - output + signedFeeRate := chainfee.NewSatPerKWeight(fee, realWeight) + + numSweeps := len(tx.TxIn) + b.Infof("attempting to publish custom signed tx=%v, desiredFeerate=%v,"+ + " signedFeeRate=%v, weight=%v, fee=%v, sweeps=%d, destAddr=%s", + txHash, feeRate, signedFeeRate, weight, fee, numSweeps, address) + b.debugLogTx("serialized batch", tx) + + // Publish the transaction. + err = b.wallet.PublishTransaction(ctx, tx, b.cfg.txLabeler(b.id)) + if err != nil { + return 0, fmt.Errorf("publishing tx failed: %w", err), true + } + + // Store the batch transaction's txid and pkScript, for monitoring + // purposes. + b.batchTxid = &txHash + b.batchPkScript = tx.TxOut[0].PkScript + + return fee, nil, true +} + +// getSweepsDestAddr returns the destination address used by a group of sweeps. +// The method must be used in presigned mode only. +func (b *batch) getSweepsDestAddr(ctx context.Context, + sweeps []sweep) (btcutil.Address, error) { + + if b.cfg.presignedHelper == nil { + return nil, fmt.Errorf("getSweepsDestAddr used without " + + "presigned mode") + } + + inputs := make([]wire.OutPoint, len(sweeps)) + for i, s := range sweeps { + if !s.presigned { + return nil, fmt.Errorf("getSweepsDestAddr used on a " + + "non-presigned input") + } + + inputs[i] = s.outpoint + } + + // Load pkScript from the presigned helper. + pkScriptBytes, err := b.cfg.presignedHelper.DestPkScript(ctx, inputs) + if err != nil { + return nil, fmt.Errorf("presignedHelper.DestPkScript failed "+ + "for inputs %v: %w", inputs, err) + } + + // Convert pkScript to btcutil.Address. + pkScript, err := txscript.ParsePkScript(pkScriptBytes) + if err != nil { + return nil, fmt.Errorf("txscript.ParsePkScript failed for "+ + "pkScript %x returned for inputs %v: %w", pkScriptBytes, + inputs, err) + } + + address, err := pkScript.Address(b.cfg.chainParams) + if err != nil { + return nil, fmt.Errorf("pkScript.Address failed for "+ + "pkScript %x returned for inputs %v: %w", pkScriptBytes, + inputs, err) + } + + return address, nil +} + +// CheckSignedTx makes sure that signedTx matches the unsignedTx. It checks +// according to criteria specified in the description of PresignedHelper.SignTx. +func CheckSignedTx(unsignedTx, signedTx *wire.MsgTx, inputAmt btcutil.Amount, + minRelayFee chainfee.SatPerKWeight) error { + + // Make sure the set of inputs is the same. + unsignedMap := make(map[wire.OutPoint]uint32, len(unsignedTx.TxIn)) + for _, txIn := range unsignedTx.TxIn { + unsignedMap[txIn.PreviousOutPoint] = txIn.Sequence + } + for _, txIn := range signedTx.TxIn { + seq, has := unsignedMap[txIn.PreviousOutPoint] + if !has { + return fmt.Errorf("input %s is new in signed tx", + txIn.PreviousOutPoint) + } + if seq != txIn.Sequence { + return fmt.Errorf("sequence mismatch in input %s: "+ + "%d in unsigned, %d in signed", + txIn.PreviousOutPoint, seq, txIn.Sequence) + } + delete(unsignedMap, txIn.PreviousOutPoint) + } + for outpoint := range unsignedMap { + return fmt.Errorf("input %s is missing in signed tx", outpoint) + } + + // Compare outputs. + if len(unsignedTx.TxOut) != 1 { + return fmt.Errorf("unsigned tx has %d outputs, want 1", + len(unsignedTx.TxOut)) + } + if len(signedTx.TxOut) != 1 { + return fmt.Errorf("the signed tx has %d outputs, want 1", + len(signedTx.TxOut)) + } + unsignedOut := unsignedTx.TxOut[0] + signedOut := signedTx.TxOut[0] + if !bytes.Equal(unsignedOut.PkScript, signedOut.PkScript) { + return fmt.Errorf("mismatch of output pkScript: %v, %v", + unsignedOut.PkScript, signedOut.PkScript) + } + + // Find the feerate of signedTx. + fee := inputAmt - btcutil.Amount(signedOut.Value) + weight := lntypes.WeightUnit( + blockchain.GetTransactionWeight(btcutil.NewTx(signedTx)), + ) + feeRate := chainfee.NewSatPerKWeight(fee, weight) + if feeRate < minRelayFee { + return fmt.Errorf("feerate (%v) of signed tx is lower than "+ + "minRelayFee (%v)", feeRate, minRelayFee) + } + + // Check LockTime. + if signedTx.LockTime > unsignedTx.LockTime { + return fmt.Errorf("locktime (%d) of signed tx is higher than "+ + "locktime of unsigned tx (%d)", signedTx.LockTime, + unsignedTx.LockTime) + } + + // Check Version. + if signedTx.Version != unsignedTx.Version { + return fmt.Errorf("version (%d) of signed tx is not equal to "+ + "version of unsigned tx (%d)", signedTx.Version, + unsignedTx.Version) + } + + return nil +} diff --git a/sweepbatcher/presigned_test.go b/sweepbatcher/presigned_test.go new file mode 100644 index 000000000..7a3a8a95a --- /dev/null +++ b/sweepbatcher/presigned_test.go @@ -0,0 +1,848 @@ +package sweepbatcher + +import ( + "context" + "fmt" + "testing" + + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/stretchr/testify/require" +) + +// mockPresigner is an implementation of Presigner used in TestPresign. +type mockPresigner struct { + // outputs collects outputs of presigned transactions. + outputs []btcutil.Amount + + // lockTimes collects LockTime's of presigned transactions. + lockTimes []uint32 + + // failAt is optional index of a call at which it fails, 1 based. + failAt int +} + +// Presign memorizes the value of the output and fails if the number of +// calls previously made is failAt. +func (p *mockPresigner) Presign(ctx context.Context, tx *wire.MsgTx, + inputAmt btcutil.Amount) error { + + if len(p.outputs)+1 == p.failAt { + return fmt.Errorf("test error in Presign") + } + + p.outputs = append(p.outputs, btcutil.Amount(tx.TxOut[0].Value)) + p.lockTimes = append(p.lockTimes, tx.LockTime) + + return nil +} + +// TestPresign checks that function presign presigns correct set of transactions +// and handles edge cases properly. +func TestPresign(t *testing.T) { + // Prepare the necessary data for test cases. + op1 := wire.OutPoint{ + Hash: chainhash.Hash{1, 1, 1}, + Index: 1, + } + op2 := wire.OutPoint{ + Hash: chainhash.Hash{2, 2, 2}, + Index: 2, + } + + ctx := context.Background() + + cases := []struct { + name string + presigner presigner + sweeps []sweep + destAddr btcutil.Address + nextBlockFeeRate chainfee.SatPerKWeight + wantErr string + wantOutputs []btcutil.Amount + wantLockTimes []uint32 + }{ + { + name: "error: no presigner", + sweeps: []sweep{ + { + outpoint: op1, + value: 1_000_000, + timeout: 1000, + }, + }, + destAddr: destAddr, + nextBlockFeeRate: chainfee.FeePerKwFloor, + wantErr: "presigner is not installed", + }, + + { + name: "error: no sweeps", + presigner: &mockPresigner{}, + destAddr: destAddr, + nextBlockFeeRate: chainfee.FeePerKwFloor, + wantErr: "there are no sweeps", + }, + + { + name: "error: no destAddr", + presigner: &mockPresigner{}, + sweeps: []sweep{ + { + outpoint: op1, + value: 1_000_000, + timeout: 1000, + }, + }, + nextBlockFeeRate: chainfee.FeePerKwFloor, + wantErr: "unsupported address type ", + }, + + { + name: "error: zero nextBlockFeeRate", + presigner: &mockPresigner{}, + sweeps: []sweep{ + { + outpoint: op1, + value: 1_000_000, + timeout: 1000, + }, + { + outpoint: op2, + value: 2_000_000, + timeout: 1000, + }, + }, + destAddr: destAddr, + wantErr: "nextBlockFeeRate is not set", + }, + + { + name: "error: timeout is not set", + presigner: &mockPresigner{}, + sweeps: []sweep{ + { + outpoint: op1, + value: 1_000_000, + }, + { + outpoint: op2, + value: 2_000_000, + }, + }, + destAddr: destAddr, + nextBlockFeeRate: chainfee.FeePerKwFloor, + wantErr: "timeout is invalid: 0", + }, + + { + name: "two coop sweeps", + presigner: &mockPresigner{}, + sweeps: []sweep{ + { + outpoint: op1, + value: 1_000_000, + timeout: 1000, + }, + { + outpoint: op2, + value: 2_000_000, + timeout: 1100, + }, + }, + destAddr: destAddr, + nextBlockFeeRate: chainfee.FeePerKwFloor, + wantOutputs: []btcutil.Amount{ + 2999842, 2999811, 2999773, 2999728, 2999674, + 2999609, 2999530, 2999436, 2999324, 2999189, + 2999026, 2998832, 2998598, 2998318, 2997982, + 2997578, 2997093, 2996512, 2995815, 2994978, + 2993974, 2992769, 2991323, 2989588, 2987506, + 2985007, 2982008, 2978410, 2974092, 2968910, + 2962692, 2955231, 2946277, 2935533, 2922639, + 2907167, 2888601, 2866321, 2839585, 2807502, + 2769002, 2722803, + }, + wantLockTimes: []uint32{ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 950, 950, 950, + 950, 950, 950, 950, 950, 950, 950, 950, 950, + 950, 950, 950, 950, + }, + }, + + { + name: "high current feerate => locktime later", + presigner: &mockPresigner{}, + sweeps: []sweep{ + { + outpoint: op1, + value: 1_000_000, + timeout: 1000, + }, + { + outpoint: op2, + value: 2_000_000, + timeout: 1000, + }, + }, + destAddr: destAddr, + nextBlockFeeRate: 50 * chainfee.FeePerKwFloor, + wantOutputs: []btcutil.Amount{ + 2999842, 2999811, 2999773, 2999728, 2999674, + 2999609, 2999530, 2999436, 2999324, 2999189, + 2999026, 2998832, 2998598, 2998318, 2997982, + 2997578, 2997093, 2996512, 2995815, 2994978, + 2993974, 2992769, 2991323, 2989588, 2987506, + 2985007, 2982008, 2978410, 2974092, 2968910, + 2962692, 2955231, 2946277, 2935533, 2922639, + 2907167, 2888601, 2866321, 2839585, 2807502, + 2769002, 2722803, + }, + wantLockTimes: []uint32{ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 950, 950, 950, 950, 950, 950, 950, + }, + }, + + { + name: "small amount => fewer steps until clamped", + presigner: &mockPresigner{}, + sweeps: []sweep{ + { + outpoint: op1, + value: 1_000, + timeout: 1000, + }, + { + outpoint: op2, + value: 2_000, + timeout: 1000, + }, + }, + destAddr: destAddr, + nextBlockFeeRate: chainfee.FeePerKwFloor, + wantOutputs: []btcutil.Amount{ + 2842, 2811, 2773, 2728, 2674, 2609, 2530, 2436, + 2400, + }, + wantLockTimes: []uint32{ + 0, 0, 0, 0, 0, 0, 0, 0, 0, + }, + }, + + { + name: "third signing fails", + presigner: &mockPresigner{ + failAt: 3, + }, + sweeps: []sweep{ + { + outpoint: op1, + value: 1_000, + timeout: 1000, + }, + { + outpoint: op2, + value: 2_000, + timeout: 1000, + }, + }, + destAddr: destAddr, + nextBlockFeeRate: chainfee.FeePerKwFloor, + wantErr: "for feeRate 363 sat/kw", + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + err := presign( + ctx, tc.presigner, tc.destAddr, tc.sweeps, + tc.nextBlockFeeRate, + ) + if tc.wantErr != "" { + require.Error(t, err) + require.ErrorContains(t, err, tc.wantErr) + } else { + require.NoError(t, err) + p := tc.presigner.(*mockPresigner) + require.Equal(t, tc.wantOutputs, p.outputs) + require.Equal(t, tc.wantLockTimes, p.lockTimes) + } + }) + } +} + +// TestCheckSignedTx tests that function CheckSignedTx checks all the criteria +// of PresignedHelper.SignTx correctly. +func TestCheckSignedTx(t *testing.T) { + // Prepare the necessary data for test cases. + op1 := wire.OutPoint{ + Hash: chainhash.Hash{1, 1, 1}, + Index: 1, + } + op2 := wire.OutPoint{ + Hash: chainhash.Hash{2, 2, 2}, + Index: 2, + } + + batchPkScript, err := txscript.PayToAddrScript(destAddr) + require.NoError(t, err) + + cases := []struct { + name string + unsignedTx *wire.MsgTx + signedTx *wire.MsgTx + inputAmt btcutil.Amount + minRelayFee chainfee.SatPerKWeight + wantErr string + }{ + { + name: "success", + unsignedTx: &wire.MsgTx{ + Version: 2, + TxIn: []*wire.TxIn{ + { + PreviousOutPoint: op1, + Sequence: 1, + }, + { + PreviousOutPoint: op2, + Sequence: 2, + }, + }, + TxOut: []*wire.TxOut{ + { + Value: 2999374, + PkScript: batchPkScript, + }, + }, + LockTime: 800_000, + }, + signedTx: &wire.MsgTx{ + Version: 2, + TxIn: []*wire.TxIn{ + { + PreviousOutPoint: op2, + Sequence: 2, + Witness: wire.TxWitness{ + []byte("test"), + }, + }, + { + PreviousOutPoint: op1, + Sequence: 1, + Witness: wire.TxWitness{ + []byte("test"), + }, + }, + }, + TxOut: []*wire.TxOut{ + { + Value: 2999374, + PkScript: batchPkScript, + }, + }, + LockTime: 799_999, + }, + inputAmt: 3_000_000, + minRelayFee: 253, + wantErr: "", + }, + + { + name: "bad locktime", + unsignedTx: &wire.MsgTx{ + Version: 2, + TxIn: []*wire.TxIn{ + { + PreviousOutPoint: op1, + Sequence: 1, + }, + { + PreviousOutPoint: op2, + Sequence: 2, + }, + }, + TxOut: []*wire.TxOut{ + { + Value: 2999374, + PkScript: batchPkScript, + }, + }, + LockTime: 800_000, + }, + signedTx: &wire.MsgTx{ + Version: 2, + TxIn: []*wire.TxIn{ + { + PreviousOutPoint: op2, + Sequence: 2, + Witness: wire.TxWitness{ + []byte("test"), + }, + }, + { + PreviousOutPoint: op1, + Sequence: 1, + Witness: wire.TxWitness{ + []byte("test"), + }, + }, + }, + TxOut: []*wire.TxOut{ + { + Value: 2999374, + PkScript: batchPkScript, + }, + }, + LockTime: 800_001, + }, + inputAmt: 3_000_000, + minRelayFee: 253, + wantErr: "locktime", + }, + + { + name: "bad version", + unsignedTx: &wire.MsgTx{ + Version: 2, + TxIn: []*wire.TxIn{ + { + PreviousOutPoint: op1, + Sequence: 1, + }, + { + PreviousOutPoint: op2, + Sequence: 2, + }, + }, + TxOut: []*wire.TxOut{ + { + Value: 2999374, + PkScript: batchPkScript, + }, + }, + LockTime: 800_000, + }, + signedTx: &wire.MsgTx{ + Version: 3, + TxIn: []*wire.TxIn{ + { + PreviousOutPoint: op2, + Sequence: 2, + Witness: wire.TxWitness{ + []byte("test"), + }, + }, + { + PreviousOutPoint: op1, + Sequence: 1, + Witness: wire.TxWitness{ + []byte("test"), + }, + }, + }, + TxOut: []*wire.TxOut{ + { + Value: 2999374, + PkScript: batchPkScript, + }, + }, + LockTime: 799_999, + }, + inputAmt: 3_000_000, + minRelayFee: 253, + wantErr: "version", + }, + + { + name: "missing input", + unsignedTx: &wire.MsgTx{ + Version: 2, + TxIn: []*wire.TxIn{ + { + PreviousOutPoint: op1, + Sequence: 1, + }, + { + PreviousOutPoint: op2, + Sequence: 2, + }, + }, + TxOut: []*wire.TxOut{ + { + Value: 2999374, + PkScript: batchPkScript, + }, + }, + LockTime: 800_000, + }, + signedTx: &wire.MsgTx{ + Version: 2, + TxIn: []*wire.TxIn{ + { + PreviousOutPoint: op2, + Sequence: 2, + Witness: wire.TxWitness{ + []byte("test"), + }, + }, + }, + TxOut: []*wire.TxOut{ + { + Value: 2999374, + PkScript: batchPkScript, + }, + }, + LockTime: 799_999, + }, + inputAmt: 3_000_000, + minRelayFee: 253, + wantErr: "is missing in signed tx", + }, + + { + name: "extra input", + unsignedTx: &wire.MsgTx{ + Version: 2, + TxIn: []*wire.TxIn{ + { + PreviousOutPoint: op1, + Sequence: 1, + }, + }, + TxOut: []*wire.TxOut{ + { + Value: 2999374, + PkScript: batchPkScript, + }, + }, + LockTime: 800_000, + }, + signedTx: &wire.MsgTx{ + Version: 2, + TxIn: []*wire.TxIn{ + { + PreviousOutPoint: op2, + Sequence: 2, + Witness: wire.TxWitness{ + []byte("test"), + }, + }, + { + PreviousOutPoint: op1, + Sequence: 1, + Witness: wire.TxWitness{ + []byte("test"), + }, + }, + }, + TxOut: []*wire.TxOut{ + { + Value: 2999374, + PkScript: batchPkScript, + }, + }, + LockTime: 799_999, + }, + inputAmt: 3_000_000, + minRelayFee: 253, + wantErr: "is new in signed tx", + }, + + { + name: "mismatch of sequence numbers", + unsignedTx: &wire.MsgTx{ + Version: 2, + TxIn: []*wire.TxIn{ + { + PreviousOutPoint: op1, + Sequence: 1, + }, + { + PreviousOutPoint: op2, + Sequence: 2, + }, + }, + TxOut: []*wire.TxOut{ + { + Value: 2999374, + PkScript: batchPkScript, + }, + }, + LockTime: 800_000, + }, + signedTx: &wire.MsgTx{ + Version: 2, + TxIn: []*wire.TxIn{ + { + PreviousOutPoint: op2, + Sequence: 2, + Witness: wire.TxWitness{ + []byte("test"), + }, + }, + { + PreviousOutPoint: op1, + Sequence: 3, + Witness: wire.TxWitness{ + []byte("test"), + }, + }, + }, + TxOut: []*wire.TxOut{ + { + Value: 2999374, + PkScript: batchPkScript, + }, + }, + LockTime: 799_999, + }, + inputAmt: 3_000_000, + minRelayFee: 253, + wantErr: "sequence mismatch", + }, + + { + name: "extra output in unsignedTx", + unsignedTx: &wire.MsgTx{ + Version: 2, + TxIn: []*wire.TxIn{ + { + PreviousOutPoint: op1, + Sequence: 1, + }, + { + PreviousOutPoint: op2, + Sequence: 2, + }, + }, + TxOut: []*wire.TxOut{ + { + Value: 2999374, + PkScript: batchPkScript, + }, + { + Value: 2999374, + PkScript: batchPkScript, + }, + }, + LockTime: 800_000, + }, + signedTx: &wire.MsgTx{ + Version: 2, + TxIn: []*wire.TxIn{ + { + PreviousOutPoint: op2, + Sequence: 2, + Witness: wire.TxWitness{ + []byte("test"), + }, + }, + { + PreviousOutPoint: op1, + Sequence: 1, + Witness: wire.TxWitness{ + []byte("test"), + }, + }, + }, + TxOut: []*wire.TxOut{ + { + Value: 2999374, + PkScript: batchPkScript, + }, + }, + LockTime: 799_999, + }, + inputAmt: 3_000_000, + minRelayFee: 253, + wantErr: "unsigned tx has 2 outputs, want 1", + }, + + { + name: "extra output in signedTx", + unsignedTx: &wire.MsgTx{ + Version: 2, + TxIn: []*wire.TxIn{ + { + PreviousOutPoint: op1, + Sequence: 1, + }, + { + PreviousOutPoint: op2, + Sequence: 2, + }, + }, + TxOut: []*wire.TxOut{ + { + Value: 2999374, + PkScript: batchPkScript, + }, + }, + LockTime: 800_000, + }, + signedTx: &wire.MsgTx{ + Version: 2, + TxIn: []*wire.TxIn{ + { + PreviousOutPoint: op2, + Sequence: 2, + Witness: wire.TxWitness{ + []byte("test"), + }, + }, + { + PreviousOutPoint: op1, + Sequence: 1, + Witness: wire.TxWitness{ + []byte("test"), + }, + }, + }, + TxOut: []*wire.TxOut{ + { + Value: 2999374, + PkScript: batchPkScript, + }, + { + Value: 2999374, + PkScript: batchPkScript, + }, + }, + LockTime: 799_999, + }, + inputAmt: 3_000_000, + minRelayFee: 253, + wantErr: "the signed tx has 2 outputs, want 1", + }, + + { + name: "mismatch of output pk_script", + unsignedTx: &wire.MsgTx{ + Version: 2, + TxIn: []*wire.TxIn{ + { + PreviousOutPoint: op1, + Sequence: 1, + }, + { + PreviousOutPoint: op2, + Sequence: 2, + }, + }, + TxOut: []*wire.TxOut{ + { + Value: 2999374, + PkScript: batchPkScript, + }, + }, + LockTime: 800_000, + }, + signedTx: &wire.MsgTx{ + Version: 2, + TxIn: []*wire.TxIn{ + { + PreviousOutPoint: op2, + Sequence: 2, + Witness: wire.TxWitness{ + []byte("test"), + }, + }, + { + PreviousOutPoint: op1, + Sequence: 1, + Witness: wire.TxWitness{ + []byte("test"), + }, + }, + }, + TxOut: []*wire.TxOut{ + { + Value: 2999374, + PkScript: batchPkScript[1:], + }, + }, + LockTime: 799_999, + }, + inputAmt: 3_000_000, + minRelayFee: 253, + wantErr: "mismatch of output pkScript", + }, + + { + name: "too low feerate in signedTx", + unsignedTx: &wire.MsgTx{ + Version: 2, + TxIn: []*wire.TxIn{ + { + PreviousOutPoint: op1, + Sequence: 1, + }, + { + PreviousOutPoint: op2, + Sequence: 2, + }, + }, + TxOut: []*wire.TxOut{ + { + Value: 2999374, + PkScript: batchPkScript, + }, + }, + LockTime: 800_000, + }, + signedTx: &wire.MsgTx{ + Version: 2, + TxIn: []*wire.TxIn{ + { + PreviousOutPoint: op2, + Sequence: 2, + Witness: wire.TxWitness{ + []byte("test"), + }, + }, + { + PreviousOutPoint: op1, + Sequence: 1, + Witness: wire.TxWitness{ + []byte("test"), + }, + }, + }, + TxOut: []*wire.TxOut{ + { + Value: 2999374, + PkScript: batchPkScript, + }, + }, + LockTime: 799_999, + }, + inputAmt: 3_000_000, + minRelayFee: 250_000, + wantErr: "is lower than minRelayFee", + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + err := CheckSignedTx( + tc.unsignedTx, tc.signedTx, tc.inputAmt, + tc.minRelayFee, + ) + if tc.wantErr != "" { + require.Error(t, err) + require.ErrorContains(t, err, tc.wantErr) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/sweepbatcher/sweep_batch.go b/sweepbatcher/sweep_batch.go index 83668c30f..af1149f90 100644 --- a/sweepbatcher/sweep_batch.go +++ b/sweepbatcher/sweep_batch.go @@ -17,6 +17,7 @@ import ( "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" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" @@ -120,6 +121,9 @@ type sweep struct { // but it failed. We try to spend a sweep cooperatively only once. This // status is not persisted in the DB. coopFailed bool + + // presigned is set, if the sweep should be handled in presigned mode. + presigned bool } // batchState is the state of the batch. @@ -173,6 +177,14 @@ type batchConfig struct { // Note that musig2SignSweep must be nil in this case, however signer // client must still be provided, as it is used for non-coop spendings. customMuSig2Signer SignMuSig2 + + // presignedHelper provides methods used when presigned batches are + // enabled. + presignedHelper PresignedHelper + + // chainParams are the chain parameters of the chain that is used by + // batches. + chainParams *chaincfg.Params } // rbfCache stores data related to our last fee bump. @@ -460,7 +472,9 @@ func (b *batch) Errorf(format string, params ...interface{}) { } // addSweep tries to add a sweep to the batch. If this is the first sweep being -// added to the batch then it also sets the primary sweep ID. +// added to the batch then it also sets the primary sweep ID. If presigned mode +// is enabled, the result depends on the outcome of presignedHelper.Presign for +// a non-empty batch. For an empty batch, the input needs to pass PresignSweep. func (b *batch) addSweep(ctx context.Context, sweep *sweep) (bool, error) { done, err := b.scheduleNextCall() defer done() @@ -566,6 +580,54 @@ func (b *batch) addSweep(ctx context.Context, sweep *sweep) (bool, error) { } } + // If presigned mode is enabled, we should first presign the new version + // of batch transaction. Also ensure that all the sweeps in the batch + // use the same mode (presigned or regular). + if sweep.presigned { + // Ensure that all the sweeps in the batch use presigned mode. + for _, s := range b.sweeps { + if !s.presigned { + b.Infof("failed to add presigned sweep %x to "+ + "the batch, because the batch has "+ + "non-presigned sweep %x", + sweep.swapHash[:6], s.swapHash[:6]) + + return false, nil + } + } + + if len(b.sweeps) != 0 { + if err := b.presign(ctx, sweep); err != nil { + b.Infof("failed to add sweep %x to the "+ + "batch, because failed to presign new "+ + "version of batch tx: %v", + sweep.swapHash[:6], err) + + return false, nil + } + } else { + if err := b.ensurePresigned(ctx, sweep); err != nil { + return false, fmt.Errorf("failed to check "+ + "signing of input %x, this means that "+ + "batcher.PresignSweep was not called "+ + "prior to AddSweep for this input: %w", + sweep.swapHash[:6], err) + } + } + } else { + // Ensure that all the sweeps in the batch don't use presigned. + for _, s := range b.sweeps { + if s.presigned { + b.Infof("failed to add a non-presigned sweep "+ + "%x to the batch, because the batch "+ + "has presigned sweep %x", + sweep.swapHash[:6], s.swapHash[:6]) + + return false, nil + } + } + } + // Past this point we know that a new incoming sweep passes the // acceptance criteria and is now ready to be added to this batch. @@ -863,6 +925,39 @@ func (b *batch) isUrgent(skipBefore time.Time) bool { return true } +// isPresigned returns if the batch uses presigned mode. Currently presigned and +// non-presigned sweeps never appear in the same batch. Fails if the batch is +// empty or contains both presigned and regular sweeps. +func (b *batch) isPresigned() (bool, error) { + var ( + hasPresigned bool + hasRegular bool + ) + + for _, sweep := range b.sweeps { + if sweep.presigned { + hasPresigned = true + } else { + hasRegular = true + } + } + + switch { + case hasPresigned && !hasRegular: + return true, nil + + case !hasPresigned && hasRegular: + return false, nil + + case hasPresigned && hasRegular: + return false, fmt.Errorf("the batch has both presigned and " + + "non-presigned sweeps") + + default: + return false, fmt.Errorf("the batch is empty") + } +} + // publish creates and publishes the latest batch transaction to the network. func (b *batch) publish(ctx context.Context) error { var ( @@ -888,7 +983,19 @@ func (b *batch) publish(ctx context.Context) error { b.publishErrorHandler(err, errMsg, b.log()) } - fee, err, signSuccess = b.publishMixedBatch(ctx) + // Determine if we should use presigned mode for the batch. + presigned, err := b.isPresigned() + if err != nil { + return fmt.Errorf("failed to determine if the batch %d uses "+ + "presigned mode: %w", b.id, err) + } + + if presigned { + fee, err, signSuccess = b.publishPresigned(ctx) + } else { + fee, err, signSuccess = b.publishMixedBatch(ctx) + } + if err != nil { if signSuccess { logPublishError("publish error", err) @@ -1766,6 +1873,28 @@ func (b *batch) handleSpend(ctx context.Context, spendTx *wire.MsgTx) error { // handleConf handles a confirmation notification. This is the final step of the // batch. Here we signal to the batcher that this batch was completed. func (b *batch) handleConf(ctx context.Context) error { + // If the batch is in presigned mode, cleanup presignedHelper. + presigned, err := b.isPresigned() + if err != nil { + return fmt.Errorf("failed to determine if the batch %d uses "+ + "presigned mode: %w", b.id, err) + } + + if presigned { + b.Infof("Cleaning up presigned store") + + inputs := make([]wire.OutPoint, 0, len(b.sweeps)) + for _, sweep := range b.sweeps { + inputs = append(inputs, sweep.outpoint) + } + + err := b.cfg.presignedHelper.CleanupTransactions(ctx, inputs) + if err != nil { + return fmt.Errorf("failed to clean up store for "+ + "batch %d, inputs %v: %w", b.id, inputs, err) + } + } + b.Infof("confirmed in txid %s", b.batchTxid) b.state = Confirmed @@ -1808,7 +1937,21 @@ func (b *batch) persist(ctx context.Context) error { // getBatchDestAddr returns the batch's destination address. If the batch // has already generated an address then the same one will be returned. +// The method must not be used in presigned mode. Use getSweepsDestAddr instead. func (b *batch) getBatchDestAddr(ctx context.Context) (btcutil.Address, error) { + // Determine if we should use presigned mode for the batch. + presigned, err := b.isPresigned() + if err != nil { + return nil, fmt.Errorf("failed to determine if the batch %d "+ + "uses presigned mode: %w", b.id, err) + } + + // Make sure that the method is not used for presigned batches. + if presigned { + return nil, fmt.Errorf("getBatchDestAddr used in presigned " + + "mode") + } + var address btcutil.Address // If a batch address is set, use that. Otherwise, generate a diff --git a/sweepbatcher/sweep_batcher.go b/sweepbatcher/sweep_batcher.go index a09ab4d30..0319a22f4 100644 --- a/sweepbatcher/sweep_batcher.go +++ b/sweepbatcher/sweep_batcher.go @@ -153,6 +153,53 @@ type SignMuSig2 func(ctx context.Context, muSig2Version input.MuSig2Version, swapHash lntypes.Hash, rootHash chainhash.Hash, sigHash [32]byte, ) ([]byte, error) +// PresignedHelper provides methods used when batches are presigned in advance. +// In this mode sweepbatcher uses transactions provided by PresignedHelper, +// which are pre-signed. The helper also memorizes transactions it previously +// produced. It also affects batch selection: presigned inputs and regular +// (non-presigned) inputs never appear in the same batch. Also if presigning +// fails (e.g. because one of the inputs is offline), an input can't be added to +// a batch. +type PresignedHelper interface { + // IsPresigned returns if presigned mode is enabled for a particular + // sweep. This method should always return the same value for the same + // sweep. Currently presigned and non-presigned sweeps never appear in + // the same batch. + IsPresigned(ctx context.Context, input wire.OutPoint) (bool, error) + + // Presign tries to presign a batch transaction. If the method returns + // nil, it is guaranteed that future calls to SignTx on this set of + // sweeps return valid signed transactions. + Presign(ctx context.Context, tx *wire.MsgTx, + inputAmt btcutil.Amount) error + + // DestPkScript returns destination pkScript used in a presigned + // transaction sweeping the inputs. Returns an error, if such tx + // doesn't exist. If there are many such transactions, returns any + // of pkScript's. + DestPkScript(ctx context.Context, + inputs []wire.OutPoint) ([]byte, error) + + // SignTx signs an unsigned transaction or returns a pre-signed tx. + // It must satisfy the following invariants: + // - the set of inputs is the same, though the order may change; + // - the output is the same, but its amount may be different; + // - feerate is higher or equal to minRelayFee; + // - LockTime may be decreased; + // - transaction version must be the same; + // - Sequence numbers in the inputs must be preserved. + // When choosing a presigned transaction, a transaction with fee rate + // closer to the fee rate passed is selected. If presignedOnly is set, + // it doesn't try to sign the transaction and only loads a presigned tx. + SignTx(ctx context.Context, tx *wire.MsgTx, inputAmt btcutil.Amount, + minRelayFee, feeRate chainfee.SatPerKWeight, + presignedOnly bool) (*wire.MsgTx, error) + + // CleanupTransactions removes all transactions related to any of the + // outpoints. Should be called after sweep batch tx is fully confirmed. + CleanupTransactions(ctx context.Context, inputs []wire.OutPoint) error +} + // VerifySchnorrSig is a function that can be used to verify a schnorr // signature. type VerifySchnorrSig func(pubKey *btcec.PublicKey, hash, sig []byte) error @@ -329,6 +376,10 @@ type Batcher struct { // error. By default, it logs all errors as warnings, but "insufficient // fee" as Info. publishErrorHandler PublishErrorHandler + + // presignedHelper provides methods used when presigned batches are + // enabled. + presignedHelper PresignedHelper } // BatcherConfig holds batcher configuration. @@ -369,6 +420,10 @@ type BatcherConfig struct { // error. By default, it logs all errors as warnings, but "insufficient // fee" as Info. publishErrorHandler PublishErrorHandler + + // presignedHelper provides methods used when presigned batches are + // enabled. + presignedHelper PresignedHelper } // BatcherOption configures batcher behaviour. @@ -442,6 +497,15 @@ func WithPublishErrorHandler(handler PublishErrorHandler) BatcherOption { } } +// WithPresignedHelper enables presigned batches in the batcher. When a sweep +// intended for presigning is added, it must be first passed to the PresignSweep +// method, before first call of the AddSweep method. +func WithPresignedHelper(presignedHelper PresignedHelper) BatcherOption { + return func(cfg *BatcherConfig) { + cfg.presignedHelper = presignedHelper + } +} + // NewBatcher creates a new Batcher instance. func NewBatcher(wallet lndclient.WalletKitClient, chainNotifier lndclient.ChainNotifierClient, @@ -496,6 +560,7 @@ func NewBatcher(wallet lndclient.WalletKitClient, txLabeler: cfg.txLabeler, customMuSig2Signer: cfg.customMuSig2Signer, publishErrorHandler: cfg.publishErrorHandler, + presignedHelper: cfg.presignedHelper, } } @@ -564,8 +629,40 @@ func (b *Batcher) Run(ctx context.Context) error { } } +// PresignSweep creates and stores presigned 1:1 transactions for the sweep. +// This method must be called prior to AddSweep if presigned mode is enabled. +func (b *Batcher) PresignSweep(ctx context.Context, sweepOutpoint wire.OutPoint, + sweepValue btcutil.Amount, sweepTimeout int32, + destAddress btcutil.Address) error { + + if b.presignedHelper == nil { + return fmt.Errorf("presignedHelper is not installed") + } + + // Find the feerate needed to get into next block. Use conf_target=2, + nextBlockFeeRate, err := b.wallet.EstimateFeeRate(ctx, 2) + if err != nil { + return fmt.Errorf("failed to get nextBlockFeeRate: %w", err) + } + infof("nextBlockFeeRate is %v", nextBlockFeeRate) + + sweeps := []sweep{ + { + outpoint: sweepOutpoint, + value: sweepValue, + timeout: sweepTimeout, + }, + } + + return presign( + ctx, b.presignedHelper, destAddress, sweeps, nextBlockFeeRate, + ) +} + // AddSweep adds a sweep request to the batcher for handling. This will either -// place the sweep in an existing batch or create a new one. +// place the sweep in an existing batch or create a new one. In presigned mode +// call PresignSweep prior to AddSweep. If PresignSweep fails, AddSweep must not +// be called. func (b *Batcher) AddSweep(sweepReq *SweepRequest) error { select { case b.sweepReqs <- *sweepReq: @@ -616,8 +713,8 @@ func (b *Batcher) handleSweep(ctx context.Context, sweep *sweep, return err } - infof("Batcher handling sweep %x, completed=%v", - sweep.swapHash[:6], completed) + infof("Batcher handling sweep %x, presigned=%v, completed=%v", + sweep.swapHash[:6], sweep.presigned, completed) // If the sweep has already been completed in a confirmed batch then we // can't attach its notifier to the batch as that is no longer running. @@ -702,7 +799,9 @@ func (b *Batcher) handleSweep(ctx context.Context, sweep *sweep, return b.spinUpNewBatch(ctx, sweep) } -// spinUpNewBatch creates new batch, starts it and adds the sweep to it. +// spinUpNewBatch creates new batch, starts it and adds the sweep to it. If +// presigned mode is enabled, the result also depends on outcome of +// presignedHelper.Presign. func (b *Batcher) spinUpNewBatch(ctx context.Context, sweep *sweep) error { // Spin up a fresh batch. newBatch, err := b.spinUpBatch(ctx) @@ -1096,6 +1195,16 @@ func (b *Batcher) loadSweep(ctx context.Context, swapHash lntypes.Hash, swapHash[:6], err) } + // Determine if presigned mode is used for this sweep. + var presigned bool + if b.presignedHelper != nil { + presigned, err = b.presignedHelper.IsPresigned(ctx, outpoint) + if err != nil { + return nil, fmt.Errorf("failed to determine presigned "+ + "status for sweep %x: %w", swapHash[:6], err) + } + } + // Find minimum fee rate for the sweep. Use customFeeRate if it is // provided, otherwise use wallet's EstimateFeeRate. var minFeeRate chainfee.SatPerKWeight @@ -1139,6 +1248,7 @@ func (b *Batcher) loadSweep(ctx context.Context, swapHash lntypes.Hash, destAddr: s.DestAddr, minFeeRate: minFeeRate, nonCoopHint: s.NonCoopHint, + presigned: presigned, }, nil } @@ -1149,7 +1259,9 @@ func (b *Batcher) newBatchConfig(maxTimeoutDistance int32) batchConfig { noBumping: b.customFeeRate != nil, txLabeler: b.txLabeler, customMuSig2Signer: b.customMuSig2Signer, + presignedHelper: b.presignedHelper, clock: b.clock, + chainParams: b.chainParams, } } diff --git a/sweepbatcher/sweep_batcher_test.go b/sweepbatcher/sweep_batcher_test.go index f4b297647..ef55187c2 100644 --- a/sweepbatcher/sweep_batcher_test.go +++ b/sweepbatcher/sweep_batcher_test.go @@ -13,6 +13,7 @@ import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btclog/v2" "github.com/lightninglabs/lndclient" @@ -892,6 +893,7 @@ type wrappedLogger struct { debugMessages []string infoMessages []string + warnMessages []string } // Debugf logs debug message. @@ -912,6 +914,15 @@ func (l *wrappedLogger) Infof(format string, params ...interface{}) { l.Logger.Infof(format, params...) } +// Warnf logs a warning message. +func (l *wrappedLogger) Warnf(format string, params ...interface{}) { + l.mu.Lock() + defer l.mu.Unlock() + + l.warnMessages = append(l.warnMessages, format) + l.Logger.Warnf(format, params...) +} + // testDelays tests that WithInitialDelay and WithPublishDelay work. func testDelays(t *testing.T, store testStore, batcherStore testBatcherStore) { // Set initial delay and publish delay. @@ -3996,151 +4007,1224 @@ func testFeeRateGrows(t *testing.T, store testStore, require.Equal(t, feeRateHigh, snapshot.rbfCache.FeeRate) } -// TestSweepBatcherBatchCreation tests that sweep requests enter the expected -// batch based on their timeout distance. -func TestSweepBatcherBatchCreation(t *testing.T) { - runTests(t, testSweepBatcherBatchCreation) -} - -// TestFeeBumping tests that sweep is RBFed with slightly higher fee rate after -// each block unless WithCustomFeeRate is passed. -func TestFeeBumping(t *testing.T) { - t.Run("regular", func(t *testing.T) { - runTests(t, func(t *testing.T, store testStore, - batcherStore testBatcherStore) { +// mockPresignedHelper implements PresignedHelper interface and stores arguments +// passed in its methods to validate correctness of function publishPresigned. +type mockPresignedHelper struct { + // onlineOutpoints specifies which outpoints are capable of + // participating in presigning. + onlineOutpoints map[wire.OutPoint]bool - testFeeBumping(t, store, batcherStore, false) - }) - }) + // presignedBatches is the collection of presigned batches. + presignedBatches []*wire.MsgTx - t.Run("fixed fee rate", func(t *testing.T) { - runTests(t, func(t *testing.T, store testStore, - batcherStore testBatcherStore) { + // mu should be hold by all the public methods of this type. + mu sync.Mutex - testFeeBumping(t, store, batcherStore, true) - }) - }) + // cleanupCalled is a channel where an element is sent every time + // CleanupTransactions is called. + cleanupCalled chan struct{} } -// TestTxLabeler tests transaction labels. -func TestTxLabeler(t *testing.T) { - runTests(t, testTxLabeler) +// newMockPresignedHelper returns new instance of mockPresignedHelper. +func newMockPresignedHelper() *mockPresignedHelper { + return &mockPresignedHelper{ + onlineOutpoints: make(map[wire.OutPoint]bool), + cleanupCalled: make(chan struct{}), + } } -// TestPublishErrorHandler tests transaction labels. -func TestPublishErrorHandler(t *testing.T) { - runTests(t, testPublishErrorHandler) -} +// SetOutpointOnline changes the online status of an outpoint. +func (h *mockPresignedHelper) SetOutpointOnline(op wire.OutPoint, online bool) { + h.mu.Lock() + defer h.mu.Unlock() -// TestSweepBatcherSimpleLifecycle tests the simple lifecycle of the batches -// that are created and run by the batcher. -func TestSweepBatcherSimpleLifecycle(t *testing.T) { - runTests(t, testSweepBatcherSimpleLifecycle) + h.onlineOutpoints[op] = online } -// TestDelays tests that WithInitialDelay and WithPublishDelay work. -func TestDelays(t *testing.T) { - runTests(t, testDelays) -} +// offlineInputs returns inputs of a tx which are offline. +func (h *mockPresignedHelper) offlineInputs(tx *wire.MsgTx) []wire.OutPoint { + offline := make([]wire.OutPoint, 0, len(tx.TxIn)) + for _, txIn := range tx.TxIn { + if !h.onlineOutpoints[txIn.PreviousOutPoint] { + offline = append(offline, txIn.PreviousOutPoint) + } + } -// TestMaxSweepsPerBatch tests the limit on max number of sweeps per batch. -func TestMaxSweepsPerBatch(t *testing.T) { - runTests(t, testMaxSweepsPerBatch) + return offline } -// TestSweepBatcherSweepReentry tests that when an old version of the batch tx -// gets confirmed the sweep leftovers are sent back to the batcher. -func TestSweepBatcherSweepReentry(t *testing.T) { - runTests(t, testSweepBatcherSweepReentry) +// sign signs the transaction. +func (h *mockPresignedHelper) sign(tx *wire.MsgTx) { + // Sign all the inputs. + for i := range tx.TxIn { + tx.TxIn[i].Witness = wire.TxWitness{ + make([]byte, 64), + } + } } -// TestSweepBatcherNonWalletAddr tests that sweep requests that sweep to a non -// wallet address enter individual batches. -func TestSweepBatcherNonWalletAddr(t *testing.T) { - runTests(t, testSweepBatcherNonWalletAddr) -} +// getTxFeerate returns fee rate of a transaction. +func (h *mockPresignedHelper) getTxFeerate(tx *wire.MsgTx, + inputAmt btcutil.Amount) chainfee.SatPerKWeight { -// TestSweepBatcherComposite tests that sweep requests that sweep to both wallet -// addresses and non-wallet addresses enter the correct batches. -func TestSweepBatcherComposite(t *testing.T) { - runTests(t, testSweepBatcherComposite) -} + // "Sign" tx's copy to assess the weight. + tx2 := tx.Copy() + h.sign(tx2) + weight := lntypes.WeightUnit( + blockchain.GetTransactionWeight(btcutil.NewTx(tx2)), + ) + fee := inputAmt - btcutil.Amount(tx.TxOut[0].Value) -// TestGetFeePortionForSweep tests that the fee portion for a sweep is correctly -// calculated. -func TestGetFeePortionForSweep(t *testing.T) { - runTests(t, testGetFeePortionForSweep) + return chainfee.NewSatPerKWeight(fee, weight) } -// TestRestoringEmptyBatch tests that the batcher can be restored with an empty -// batch. -func TestRestoringEmptyBatch(t *testing.T) { - runTests(t, testRestoringEmptyBatch) -} +// IsPresigned returns if the input was previously used in any call to the +// SetOutpointOnline method. +func (h *mockPresignedHelper) IsPresigned(ctx context.Context, + input wire.OutPoint) (bool, error) { -// TestHandleSweepTwice tests that handing the same sweep twice must not -// add it to different batches. -func TestHandleSweepTwice(t *testing.T) { - runTests(t, testHandleSweepTwice) -} + h.mu.Lock() + defer h.mu.Unlock() -// TestRestoringPreservesConfTarget tests that after the batch is written to DB -// and loaded back, its batchConfTarget value is preserved. -func TestRestoringPreservesConfTarget(t *testing.T) { - runTests(t, testRestoringPreservesConfTarget) -} + _, has := h.onlineOutpoints[input] -// TestSweepFetcher tests providing custom sweep fetcher to Batcher. -func TestSweepFetcher(t *testing.T) { - runTests(t, testSweepFetcher) + return has, nil } -// TestSweepBatcherCloseDuringAdding tests that sweep batcher works correctly -// if it is closed (stops running) during AddSweep call. -func TestSweepBatcherCloseDuringAdding(t *testing.T) { - runTests(t, testSweepBatcherCloseDuringAdding) -} +// Presign tries to presign the transaction. It succeeds if all the inputs +// are online. In case of success it adds the transaction to presignedBatches. +func (h *mockPresignedHelper) Presign(ctx context.Context, tx *wire.MsgTx, + inputAmt btcutil.Amount) error { -// TestCustomSignMuSig2 tests the operation with custom musig2 signer. -func TestCustomSignMuSig2(t *testing.T) { - runTests(t, testCustomSignMuSig2) + h.mu.Lock() + defer h.mu.Unlock() + + if offline := h.offlineInputs(tx); len(offline) != 0 { + return fmt.Errorf("some inputs of tx are offline: %v", offline) + } + + tx = tx.Copy() + h.sign(tx) + h.presignedBatches = append(h.presignedBatches, tx) + + return nil } -// TestWithMixedBatch tests mixed batches construction. It also tests -// non-cooperative sweeping (using a preimage). Sweeps are added one by one. -func TestWithMixedBatch(t *testing.T) { - runTests(t, testWithMixedBatch) +// DestPkScript returns destination pkScript used in 1:1 presigned tx. +func (h *mockPresignedHelper) DestPkScript(ctx context.Context, + inputs []wire.OutPoint) ([]byte, error) { + + h.mu.Lock() + defer h.mu.Unlock() + + inputsSet := make(map[wire.OutPoint]struct{}, len(inputs)) + for _, input := range inputs { + inputsSet[input] = struct{}{} + } + if len(inputsSet) != len(inputs) { + return nil, fmt.Errorf("duplicate inputs") + } + + inputsMatch := func(tx *wire.MsgTx) bool { + if len(tx.TxIn) != len(inputsSet) { + return false + } + + for _, txIn := range tx.TxIn { + if _, has := inputsSet[txIn.PreviousOutPoint]; !has { + return false + } + } + + return true + } + + for _, tx := range h.presignedBatches { + if inputsMatch(tx) { + return tx.TxOut[0].PkScript, nil + } + } + + return nil, fmt.Errorf("tx sweeping inputs %v not found", inputs) } -// TestWithMixedBatchLarge tests mixed batches construction, many sweeps. -// All sweeps are added at once. -func TestWithMixedBatchLarge(t *testing.T) { - runTests(t, testWithMixedBatchLarge) +// SignTx tries to sign the transaction. If all the inputs are online, it signs +// the exact transaction passed and adds it to presignedBatches. Otherwise it +// looks for a transaction in presignedBatches satisfying the criteria. +func (h *mockPresignedHelper) SignTx(ctx context.Context, tx *wire.MsgTx, + inputAmt btcutil.Amount, minRelayFee, feeRate chainfee.SatPerKWeight, + presignedOnly bool) (*wire.MsgTx, error) { + + h.mu.Lock() + defer h.mu.Unlock() + + // If all the inputs are online and presignedOnly is not set, sign + // this exact transaction. + if offline := h.offlineInputs(tx); len(offline) == 0 && !presignedOnly { + tx = tx.Copy() + h.sign(tx) + + // Add to the collection. + h.presignedBatches = append(h.presignedBatches, tx) + + return tx, nil + } + + // Try to find a transaction in the collection satisfying all the + // criteria of PresignedHelper.SignTx. If there are many such + // transactions, select a transaction with feerate which is the closest + // to the feerate of the input tx. + var ( + bestTx *wire.MsgTx + bestFeerateDistance chainfee.SatPerKWeight + ) + for _, candidate := range h.presignedBatches { + err := CheckSignedTx(tx, candidate, inputAmt, minRelayFee) + if err != nil { + continue + } + + feeRateDistance := h.getTxFeerate(candidate, inputAmt) - feeRate + if feeRateDistance < 0 { + feeRateDistance = -feeRateDistance + } + + if bestTx == nil || feeRateDistance < bestFeerateDistance { + bestTx = candidate + bestFeerateDistance = feeRateDistance + } + } + + if bestTx == nil { + return nil, fmt.Errorf("no such presigned tx found") + } + + return bestTx.Copy(), nil } -// TestWithMixedBatchCoopOnly tests mixed batches construction, -// All sweeps are added at once. All the sweeps are cooperative. -func TestWithMixedBatchCoopOnly(t *testing.T) { - runTests(t, testWithMixedBatchCoopOnly) +// CleanupTransactions removes all transactions related to any of the outpoints. +func (h *mockPresignedHelper) CleanupTransactions(ctx context.Context, + inputs []wire.OutPoint) error { + + h.mu.Lock() + defer h.mu.Unlock() + + inputsSet := make(map[wire.OutPoint]struct{}, len(inputs)) + for _, input := range inputs { + inputsSet[input] = struct{}{} + } + if len(inputsSet) != len(inputs) { + return fmt.Errorf("duplicate inputs") + } + + var presignedBatches []*wire.MsgTx + + // Filter out transactions spending any of the inputs passed. + for _, tx := range h.presignedBatches { + var match bool + for _, txIn := range tx.TxIn { + if _, has := inputsSet[txIn.PreviousOutPoint]; has { + match = true + break + } + } + + if !match { + presignedBatches = append(presignedBatches, tx) + } + } + + h.presignedBatches = presignedBatches + + h.cleanupCalled <- struct{}{} + + return nil } -// TestWithMixedBatchNonCoopHintOnly tests mixed batches construction, -// All sweeps are added at once. All the sweeps are known to be non-cooperative -// in advance. -func TestWithMixedBatchNonCoopHintOnly(t *testing.T) { - runTests(t, testWithMixedBatchNonCoopHintOnly) +// sweepTimeout is swap timeout block height used in tests of presigned mode. +const sweepTimeout = 1000 + +// dummySweepFetcherMock implements SweepFetcher by returning blank SweepInfo. +// It is used in TestPresigned, because it doesn't use any fields of SweepInfo. +type dummySweepFetcherMock struct { } -// TestWithMixedBatchCoopFailedOnly tests mixed batches construction, -// All sweeps are added at once. All the sweeps fail co-signing. -func TestWithMixedBatchCoopFailedOnly(t *testing.T) { - runTests(t, testWithMixedBatchCoopFailedOnly) +// FetchSweep returns blank SweepInfo. +func (f *dummySweepFetcherMock) FetchSweep(ctx context.Context, + hash lntypes.Hash) (*SweepInfo, error) { + + return &SweepInfo{ + // Set Timeout to prevent warning messages about timeout=0. + Timeout: sweepTimeout, + }, nil } -// TestFeeRateGrows tests that fee rate of a batch does not decrease and is at -// least as high as the highest fee rate of sweeps. -func TestFeeRateGrows(t *testing.T) { - runTests(t, testFeeRateGrows) +// testPresigned_input1_offline_then_input2 tests presigned mode for the +// following scenario: first input is added, then goes offline, then feerate +// grows, one of presigned transactions is published, and then another online +// input is added and is assigned to another batch. +func testPresigned_input1_offline_then_input2(t *testing.T, + batcherStore testBatcherStore) { + + defer test.Guard(t)() + + batchPkScript, err := txscript.PayToAddrScript(destAddr) + require.NoError(t, err) + + lnd := test.NewMockLnd() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + const ( + feeRateLow = chainfee.SatPerKWeight(10_000) + feeRateMedium = chainfee.SatPerKWeight(30_000) + feeRateHigh = chainfee.SatPerKWeight(31_000) + ) + + currentFeeRate := feeRateLow + setFeeRate := func(feeRate chainfee.SatPerKWeight) { + currentFeeRate = feeRate + } + customFeeRate := func(_ context.Context, + _ lntypes.Hash) (chainfee.SatPerKWeight, error) { + + return currentFeeRate, nil + } + + presignedHelper := newMockPresignedHelper() + + batcher := NewBatcher(lnd.WalletKit, lnd.ChainNotifier, lnd.Signer, + testMuSig2SignSweep, testVerifySchnorrSig, lnd.ChainParams, + batcherStore, &dummySweepFetcherMock{}, + WithCustomFeeRate(customFeeRate), + WithPresignedHelper(presignedHelper)) + + batcherErrChan := make(chan error) + go func() { + batcherErrChan <- batcher.Run(ctx) + }() + + setFeeRate(feeRateLow) + + // Create the first sweep. + swapHash1 := lntypes.Hash{1, 1, 1} + op1 := wire.OutPoint{ + Hash: chainhash.Hash{1, 1}, + Index: 1, + } + sweepReq1 := SweepRequest{ + SwapHash: swapHash1, + Value: 1_000_000, + Outpoint: op1, + Notifier: &dummyNotifier, + } + + // Make sure that the batcher crashes if AddSweep is called before + // PresignSweep even if the input is online. + presignedHelper.SetOutpointOnline(op1, true) + require.NoError(t, batcher.AddSweep(&sweepReq1)) + err = <-batcherErrChan + require.Error(t, err) + require.ErrorContains(t, err, "PresignSweep was not called") + + // Start the batcher again. + batcher = NewBatcher(lnd.WalletKit, lnd.ChainNotifier, lnd.Signer, + testMuSig2SignSweep, testVerifySchnorrSig, lnd.ChainParams, + batcherStore, &dummySweepFetcherMock{}, + WithCustomFeeRate(customFeeRate), + WithPresignedHelper(presignedHelper)) + go func() { + err := batcher.Run(ctx) + checkBatcherError(t, err) + }() + + // This should fail, because the input is offline. + presignedHelper.SetOutpointOnline(op1, false) + err = batcher.PresignSweep(ctx, op1, 1_000_000, sweepTimeout, destAddr) + require.Error(t, err) + require.ErrorContains(t, err, "offline") + + // Enable the input and try again. + presignedHelper.SetOutpointOnline(op1, true) + err = batcher.PresignSweep(ctx, op1, 1_000_000, sweepTimeout, destAddr) + require.NoError(t, err) + + // Increase fee rate and turn off the input, so it can't sign updated + // tx. The feerate is close to the feerate of one of presigned txs. + setFeeRate(feeRateMedium) + presignedHelper.SetOutpointOnline(op1, false) + + // Deliver sweep request to batcher. + require.NoError(t, batcher.AddSweep(&sweepReq1)) + + // Since a batch was created we check that it registered for its primary + // sweep's spend. + <-lnd.RegisterSpendChannel + + // Wait for a transactions to be published. + tx := <-lnd.TxPublishChannel + require.Len(t, tx.TxIn, 1) + require.Len(t, tx.TxOut, 1) + require.Equal(t, op1, tx.TxIn[0].PreviousOutPoint) + require.Equal(t, int64(988619), tx.TxOut[0].Value) + require.Equal(t, batchPkScript, tx.TxOut[0].PkScript) + + // Make sure the fee rate is feeRateMedium. + batch := getOnlyBatch(t, ctx, batcher) + var ( + numSweeps int + cachedFeeRate chainfee.SatPerKWeight + ) + batch.testRunInEventLoop(ctx, func() { + numSweeps = len(batch.sweeps) + cachedFeeRate = batch.rbfCache.FeeRate + }) + require.Equal(t, 1, numSweeps) + require.Equal(t, feeRateMedium, cachedFeeRate) + + // Raise feerate and trigger new publishing. The tx should be the same. + setFeeRate(feeRateHigh) + require.NoError(t, batcher.AddSweep(&sweepReq1)) + require.NoError(t, lnd.NotifyHeight(601)) + + tx2 := <-lnd.TxPublishChannel + require.Equal(t, tx.TxHash(), tx2.TxHash()) + + // Now add another input. It is online, but the first input is still + // offline, so another input should go to another batch. + swapHash2 := lntypes.Hash{2, 2, 2} + op2 := wire.OutPoint{ + Hash: chainhash.Hash{2, 2}, + Index: 2, + } + sweepReq2 := SweepRequest{ + SwapHash: swapHash2, + Value: 2_000_000, + Outpoint: op2, + Notifier: &dummyNotifier, + } + presignedHelper.SetOutpointOnline(op2, true) + err = batcher.PresignSweep(ctx, op2, 2_000_000, sweepTimeout, destAddr) + require.NoError(t, err) + + // Deliver sweep request to batcher. + require.NoError(t, batcher.AddSweep(&sweepReq2)) + + // Since a batch was created we check that it registered for its primary + // sweep's spend. + <-lnd.RegisterSpendChannel + + // Wait for a transactions to be published. + batch2 := <-lnd.TxPublishChannel + require.Len(t, batch2.TxIn, 1) + require.Len(t, batch2.TxOut, 1) + require.Equal(t, op2, batch2.TxIn[0].PreviousOutPoint) + require.Equal(t, int64(1987724), batch2.TxOut[0].Value) + require.Equal(t, batchPkScript, batch2.TxOut[0].PkScript) + + // Now confirm the first batch. Make sure its presigned transactions + // were removed, but not the transactions of the second batch. + presignedSize1 := len(presignedHelper.presignedBatches) + + tx2hash := tx2.TxHash() + spendDetail := &chainntnfs.SpendDetail{ + SpentOutPoint: &sweepReq1.Outpoint, + SpendingTx: tx2, + SpenderTxHash: &tx2hash, + SpenderInputIndex: 0, + SpendingHeight: 601, + } + lnd.SpendChannel <- spendDetail + <-lnd.RegisterConfChannel + require.NoError(t, lnd.NotifyHeight(604)) + lnd.ConfChannel <- &chainntnfs.TxConfirmation{ + Tx: tx2, + } + + <-presignedHelper.cleanupCalled + + presignedSize2 := len(presignedHelper.presignedBatches) + require.Greater(t, presignedSize2, 0) + require.Greater(t, presignedSize1, presignedSize2) + + // Make sure we still have presigned transactions for the second batch. + presignedHelper.SetOutpointOnline(op2, false) + const presignedOnly = true + _, err = presignedHelper.SignTx( + ctx, batch2, 2_000_000, chainfee.FeePerKwFloor, + chainfee.FeePerKwFloor, presignedOnly, + ) + require.NoError(t, err) +} + +// testPresigned_two_inputs_one_goes_offline tests presigned mode for the +// following scenario: two online inputs are added, then one of them goes +// offline, then feerate grows and a presigned transaction is used. +func testPresigned_two_inputs_one_goes_offline(t *testing.T, + batcherStore testBatcherStore) { + + defer test.Guard(t)() + + batchPkScript, err := txscript.PayToAddrScript(destAddr) + require.NoError(t, err) + + lnd := test.NewMockLnd() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + const ( + feeRateLow = chainfee.SatPerKWeight(10_000) + feeRateMedium = chainfee.SatPerKWeight(30_000) + feeRateHigh = chainfee.SatPerKWeight(40_000) + ) + + currentFeeRate := feeRateLow + setFeeRate := func(feeRate chainfee.SatPerKWeight) { + currentFeeRate = feeRate + } + customFeeRate := func(_ context.Context, + _ lntypes.Hash) (chainfee.SatPerKWeight, error) { + + return currentFeeRate, nil + } + + presignedHelper := newMockPresignedHelper() + + batcher := NewBatcher( + lnd.WalletKit, lnd.ChainNotifier, lnd.Signer, + testMuSig2SignSweep, testVerifySchnorrSig, lnd.ChainParams, + batcherStore, &dummySweepFetcherMock{}, + WithCustomFeeRate(customFeeRate), + WithPresignedHelper(presignedHelper), + ) + + go func() { + err := batcher.Run(ctx) + checkBatcherError(t, err) + }() + + setFeeRate(feeRateLow) + + // Create the first sweep. + swapHash1 := lntypes.Hash{1, 1, 1} + op1 := wire.OutPoint{ + Hash: chainhash.Hash{1, 1}, + Index: 1, + } + sweepReq1 := SweepRequest{ + SwapHash: swapHash1, + Value: 1_000_000, + Outpoint: op1, + Notifier: &dummyNotifier, + } + presignedHelper.SetOutpointOnline(op1, true) + err = batcher.PresignSweep(ctx, op1, 1_000_000, sweepTimeout, destAddr) + require.NoError(t, err) + require.NoError(t, batcher.AddSweep(&sweepReq1)) + + // Since a batch was created we check that it registered for its primary + // sweep's spend. + <-lnd.RegisterSpendChannel + + // Add second sweep. + swapHash2 := lntypes.Hash{2, 2, 2} + op2 := wire.OutPoint{ + Hash: chainhash.Hash{2, 2}, + Index: 2, + } + sweepReq2 := SweepRequest{ + SwapHash: swapHash2, + Value: 2_000_000, + Outpoint: op2, + Notifier: &dummyNotifier, + } + presignedHelper.SetOutpointOnline(op2, true) + err = batcher.PresignSweep(ctx, op2, 2_000_000, sweepTimeout, destAddr) + require.NoError(t, err) + require.NoError(t, batcher.AddSweep(&sweepReq2)) + + // Wait for a transactions to be published. + tx := <-lnd.TxPublishChannel + require.Len(t, tx.TxIn, 2) + require.Len(t, tx.TxOut, 1) + require.ElementsMatch( + t, []wire.OutPoint{op1, op2}, + []wire.OutPoint{ + tx.TxIn[0].PreviousOutPoint, + tx.TxIn[1].PreviousOutPoint, + }, + ) + require.Equal(t, int64(2993740), tx.TxOut[0].Value) + require.Equal(t, batchPkScript, tx.TxOut[0].PkScript) + + // Now turn off the second input, raise feerate and trigger new + // publishing. The feerate is close to one of the presigned feerates, + // so this should result in RBF. + presignedHelper.SetOutpointOnline(op2, false) + setFeeRate(feeRateMedium) + require.NoError(t, batcher.AddSweep(&sweepReq1)) + require.NoError(t, batcher.AddSweep(&sweepReq2)) + require.NoError(t, lnd.NotifyHeight(601)) + + tx2 := <-lnd.TxPublishChannel + require.NotEqual(t, tx.TxHash(), tx2.TxHash()) + require.Len(t, tx2.TxIn, 2) + require.Len(t, tx2.TxOut, 1) + require.ElementsMatch( + t, []wire.OutPoint{op1, op2}, + []wire.OutPoint{ + tx.TxIn[0].PreviousOutPoint, + tx.TxIn[1].PreviousOutPoint, + }, + ) + require.Equal(t, int64(2982008), tx2.TxOut[0].Value) + require.Equal(t, batchPkScript, tx2.TxOut[0].PkScript) +} + +// testPresigned_first_publish_fails tests presigned mode for the following +// scenario: one input is added and goes offline, feerate grows a transaction is +// attempted to be published, but fails. Then the input goes online and is +// published being signed online. +func testPresigned_first_publish_fails(t *testing.T, + batcherStore testBatcherStore) { + + defer test.Guard(t)() + + batchPkScript, err := txscript.PayToAddrScript(destAddr) + require.NoError(t, err) + + lnd := test.NewMockLnd() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + const ( + feeRateLow = chainfee.SatPerKWeight(10_000) + feeRateMedium = chainfee.SatPerKWeight(30_000) + feeRateHigh = chainfee.SatPerKWeight(40_000) + ) + + currentFeeRate := feeRateLow + setFeeRate := func(feeRate chainfee.SatPerKWeight) { + currentFeeRate = feeRate + } + customFeeRate := func(_ context.Context, + _ lntypes.Hash) (chainfee.SatPerKWeight, error) { + + return currentFeeRate, nil + } + + presignedHelper := newMockPresignedHelper() + + batcher := NewBatcher( + lnd.WalletKit, lnd.ChainNotifier, lnd.Signer, + testMuSig2SignSweep, testVerifySchnorrSig, lnd.ChainParams, + batcherStore, &dummySweepFetcherMock{}, + WithCustomFeeRate(customFeeRate), + WithPresignedHelper(presignedHelper), + ) + + go func() { + err := batcher.Run(ctx) + checkBatcherError(t, err) + }() + + setFeeRate(feeRateLow) + + // Create the first sweep. + swapHash1 := lntypes.Hash{1, 1, 1} + op1 := wire.OutPoint{ + Hash: chainhash.Hash{1, 1}, + Index: 1, + } + sweepReq1 := SweepRequest{ + SwapHash: swapHash1, + Value: 1_000_000, + Outpoint: op1, + Notifier: &dummyNotifier, + } + presignedHelper.SetOutpointOnline(op1, true) + err = batcher.PresignSweep(ctx, op1, 1_000_000, sweepTimeout, destAddr) + require.NoError(t, err) + presignedHelper.SetOutpointOnline(op1, false) + + // Make sure that publish attempt fails. + lnd.PublishHandler = func(ctx context.Context, tx *wire.MsgTx, + label string) error { + + return fmt.Errorf("test error") + } + + // Add the sweep, triggering the publish attempt. + require.NoError(t, batcher.AddSweep(&sweepReq1)) + + // Since a batch was created we check that it registered for its primary + // sweep's spend. + <-lnd.RegisterSpendChannel + + // Replace the logger in the batch with wrappedLogger to watch messages. + batch := getOnlyBatch(t, ctx, batcher) + testLogger := &wrappedLogger{ + Logger: batch.log(), + } + batch.setLog(testLogger) + + // Trigger another publish attempt in case the publish error was logged + // before we installed the logger watcher. + require.NoError(t, lnd.NotifyHeight(601)) + + // Wait for batcher to log the publish error. It is logged with + // publishErrorHandler, so the format is "%s: %v". + require.EventuallyWithT(t, func(c *assert.CollectT) { + testLogger.mu.Lock() + defer testLogger.mu.Unlock() + + assert.Contains(c, testLogger.warnMessages, "%s: %v") + }, test.Timeout, eventuallyCheckFrequency) + + // Now turn on the first input, raise feerate and trigger new + // publishing, which should succeed. + lnd.PublishHandler = nil + setFeeRate(feeRateMedium) + presignedHelper.SetOutpointOnline(op1, true) + require.NoError(t, batcher.AddSweep(&sweepReq1)) + require.NoError(t, lnd.NotifyHeight(602)) + + // Wait for a transactions to be published. + tx := <-lnd.TxPublishChannel + require.Len(t, tx.TxIn, 1) + require.Len(t, tx.TxOut, 1) + require.Equal(t, op1, tx.TxIn[0].PreviousOutPoint) + require.Equal(t, int64(988120), tx.TxOut[0].Value) + require.Equal(t, batchPkScript, tx.TxOut[0].PkScript) +} + +// testPresigned_locktime tests presigned mode for the following scenario: one +// input is added and goes offline, feerate grows, but this is constrainted by +// locktime logic, so the published transaction has medium feerate (maximum +// feerate among transactions without locktime protection). Then blocks are +// mined and a transaction with a higher feerate is published. +func testPresigned_locktime(t *testing.T, + batcherStore testBatcherStore) { + + defer test.Guard(t)() + + batchPkScript, err := txscript.PayToAddrScript(destAddr) + require.NoError(t, err) + + lnd := test.NewMockLnd() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + const ( + feeRateLow = chainfee.SatPerKWeight(10_000) + feeRateHigh = chainfee.SatPerKWeight(10_000_000) + ) + + currentFeeRate := feeRateLow + setFeeRate := func(feeRate chainfee.SatPerKWeight) { + currentFeeRate = feeRate + } + customFeeRate := func(_ context.Context, + _ lntypes.Hash) (chainfee.SatPerKWeight, error) { + + return currentFeeRate, nil + } + + presignedHelper := newMockPresignedHelper() + + batcher := NewBatcher( + lnd.WalletKit, lnd.ChainNotifier, lnd.Signer, + testMuSig2SignSweep, testVerifySchnorrSig, lnd.ChainParams, + batcherStore, &dummySweepFetcherMock{}, + WithCustomFeeRate(customFeeRate), + WithPresignedHelper(presignedHelper), + ) + + go func() { + err := batcher.Run(ctx) + checkBatcherError(t, err) + }() + + setFeeRate(feeRateLow) + + // Create the first sweep. + swapHash1 := lntypes.Hash{1, 1, 1} + op1 := wire.OutPoint{ + Hash: chainhash.Hash{1, 1}, + Index: 1, + } + sweepReq1 := SweepRequest{ + SwapHash: swapHash1, + Value: 1_000_000, + Outpoint: op1, + Notifier: &dummyNotifier, + } + presignedHelper.SetOutpointOnline(op1, true) + err = batcher.PresignSweep(ctx, op1, 1_000_000, sweepTimeout, destAddr) + require.NoError(t, err) + presignedHelper.SetOutpointOnline(op1, false) + + setFeeRate(feeRateHigh) + + // Add the sweep, triggering the publish attempt. + require.NoError(t, batcher.AddSweep(&sweepReq1)) + + // Since a batch was created we check that it registered for its primary + // sweep's spend. + <-lnd.RegisterSpendChannel + + // Wait for a transactions to be published. + tx := <-lnd.TxPublishChannel + require.Len(t, tx.TxIn, 1) + require.Len(t, tx.TxOut, 1) + require.Equal(t, op1, tx.TxIn[0].PreviousOutPoint) + require.Equal(t, int64(966016), tx.TxOut[0].Value) + require.Equal(t, batchPkScript, tx.TxOut[0].PkScript) + + // Mine blocks to overcome the locktime constraint. + require.NoError(t, lnd.NotifyHeight(950)) + + tx2 := <-lnd.TxPublishChannel + require.Equal(t, int64(824649), tx2.TxOut[0].Value) +} + +// testPresigned_presigned_and_regular_sweeps tests a combination of presigned +// mode and regular mode for the following scenario: one regular input is added, +// then a presigned input is added and it goes to another batch, because they +// should not appear in the same batch. Then another regular and another +// presigned inputs are added and go to the existing batches of their types. +func testPresigned_presigned_and_regular_sweeps(t *testing.T, store testStore, + batcherStore testBatcherStore) { + + defer test.Guard(t)() + + lnd := test.NewMockLnd() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + const ( + feeRateLow = chainfee.SatPerKWeight(10_000) + feeRateMedium = chainfee.SatPerKWeight(30_000) + feeRateHigh = chainfee.SatPerKWeight(40_000) + ) + + currentFeeRate := feeRateLow + setFeeRate := func(feeRate chainfee.SatPerKWeight) { + currentFeeRate = feeRate + } + customFeeRate := func(_ context.Context, + _ lntypes.Hash) (chainfee.SatPerKWeight, error) { + + return currentFeeRate, nil + } + + presignedHelper := newMockPresignedHelper() + + sweepStore, err := NewSweepFetcherFromSwapStore(store, lnd.ChainParams) + require.NoError(t, err) + + batcher := NewBatcher( + lnd.WalletKit, lnd.ChainNotifier, lnd.Signer, + testMuSig2SignSweep, testVerifySchnorrSig, lnd.ChainParams, + batcherStore, sweepStore, + WithCustomFeeRate(customFeeRate), + WithPresignedHelper(presignedHelper), + ) + + go func() { + err := batcher.Run(ctx) + checkBatcherError(t, err) + }() + + setFeeRate(feeRateLow) + + ///////////////////////////////////// + // Create the first regular sweep. // + ///////////////////////////////////// + swapHash1 := lntypes.Hash{1, 1, 1} + op1 := wire.OutPoint{ + Hash: chainhash.Hash{1, 1}, + Index: 1, + } + sweepReq1 := SweepRequest{ + SwapHash: swapHash1, + Value: 1_000_000, + Outpoint: op1, + Notifier: &dummyNotifier, + } + + swap1 := &loopdb.LoopOutContract{ + SwapContract: loopdb.SwapContract{ + CltvExpiry: 111, + AmountRequested: 1_000_000, + ProtocolVersion: loopdb.ProtocolVersionMuSig2, + HtlcKeys: htlcKeys, + + // Make preimage unique to pass SQL constraints. + Preimage: lntypes.Preimage{1}, + }, + + DestAddr: destAddr, + SwapInvoice: swapInvoice, + SweepConfTarget: 111, + } + + err = store.CreateLoopOut(ctx, swapHash1, swap1) + require.NoError(t, err) + store.AssertLoopOutStored() + + // Deliver sweep request to batcher. + require.NoError(t, batcher.AddSweep(&sweepReq1)) + + // Since a batch was created we check that it registered for its primary + // sweep's spend. + <-lnd.RegisterSpendChannel + + // Wait for a transactions to be published. + tx1 := <-lnd.TxPublishChannel + require.Len(t, tx1.TxIn, 1) + require.Len(t, tx1.TxOut, 1) + + /////////////////////////////////////// + // Create the first presigned sweep. // + /////////////////////////////////////// + swapHash2 := lntypes.Hash{2, 2, 2} + op2 := wire.OutPoint{ + Hash: chainhash.Hash{2, 2}, + Index: 2, + } + + swap2 := &loopdb.LoopOutContract{ + SwapContract: loopdb.SwapContract{ + CltvExpiry: 111, + AmountRequested: 2_000_000, + ProtocolVersion: loopdb.ProtocolVersionMuSig2, + HtlcKeys: htlcKeys, + + // Make preimage unique to pass SQL constraints. + Preimage: lntypes.Preimage{2}, + }, + + DestAddr: destAddr, + SwapInvoice: swapInvoice, + SweepConfTarget: 111, + } + + err = store.CreateLoopOut(ctx, swapHash2, swap2) + require.NoError(t, err) + store.AssertLoopOutStored() + + sweepReq2 := SweepRequest{ + SwapHash: swapHash2, + Value: 2_000_000, + Outpoint: op2, + Notifier: &dummyNotifier, + } + presignedHelper.SetOutpointOnline(op2, true) + err = batcher.PresignSweep(ctx, op2, 2_000_000, sweepTimeout, destAddr) + require.NoError(t, err) + require.NoError(t, batcher.AddSweep(&sweepReq2)) + + // Since a batch was created we check that it registered for its primary + // sweep's spend. + <-lnd.RegisterSpendChannel + + // Wait for a transactions to be published. + tx2 := <-lnd.TxPublishChannel + require.Len(t, tx2.TxIn, 1) + require.Len(t, tx2.TxOut, 1) + require.Equal(t, op2, tx2.TxIn[0].PreviousOutPoint) + + ////////////////////////////////////// + // Create the second regular sweep. // + ////////////////////////////////////// + swapHash3 := lntypes.Hash{3, 3, 3} + op3 := wire.OutPoint{ + Hash: chainhash.Hash{3, 3}, + Index: 3, + } + sweepReq3 := SweepRequest{ + SwapHash: swapHash3, + Value: 4_000_000, + Outpoint: op3, + Notifier: &dummyNotifier, + } + + swap3 := &loopdb.LoopOutContract{ + SwapContract: loopdb.SwapContract{ + CltvExpiry: 111, + AmountRequested: 4_000_000, + ProtocolVersion: loopdb.ProtocolVersionMuSig2, + HtlcKeys: htlcKeys, + + // Make preimage unique to pass SQL constraints. + Preimage: lntypes.Preimage{3}, + }, + + DestAddr: destAddr, + SwapInvoice: swapInvoice, + SweepConfTarget: 111, + } + + err = store.CreateLoopOut(ctx, swapHash3, swap3) + require.NoError(t, err) + store.AssertLoopOutStored() + + // Deliver sweep request to batcher. + require.NoError(t, batcher.AddSweep(&sweepReq3)) + + //////////////////////////////////////// + // Create the second presigned sweep. // + //////////////////////////////////////// + swapHash4 := lntypes.Hash{4, 4, 4} + op4 := wire.OutPoint{ + Hash: chainhash.Hash{4, 4}, + Index: 4, + } + + swap4 := &loopdb.LoopOutContract{ + SwapContract: loopdb.SwapContract{ + CltvExpiry: 111, + AmountRequested: 3_000_000, + ProtocolVersion: loopdb.ProtocolVersionMuSig2, + HtlcKeys: htlcKeys, + + // Make preimage unique to pass SQL constraints. + Preimage: lntypes.Preimage{4}, + }, + + DestAddr: destAddr, + SwapInvoice: swapInvoice, + SweepConfTarget: 111, + } + + err = store.CreateLoopOut(ctx, swapHash4, swap4) + require.NoError(t, err) + store.AssertLoopOutStored() + + sweepReq4 := SweepRequest{ + SwapHash: swapHash4, + Value: 3_000_000, + Outpoint: op4, + Notifier: &dummyNotifier, + } + presignedHelper.SetOutpointOnline(op4, true) + err = batcher.PresignSweep(ctx, op4, 4_000_000, sweepTimeout, destAddr) + require.NoError(t, err) + require.NoError(t, batcher.AddSweep(&sweepReq4)) + + // Wait for the both batches to have two sweeps. + require.Eventually(t, func() bool { + // Make sure there are two batches. + batches := getBatches(ctx, batcher) + if len(batches) != 2 { + return false + } + + // Make sure each batch has two sweeps. + for _, batch := range batches { + var numSweeps int + batch.testRunInEventLoop(ctx, func() { + numSweeps = len(batch.sweeps) + }) + if numSweeps != 2 { + return false + } + } + + return true + }, test.Timeout, eventuallyCheckFrequency) + + // Mine a block to trigger both batches publishing. + require.NoError(t, lnd.NotifyHeight(601)) + + // Wait for a transactions to be published. + tx3 := <-lnd.TxPublishChannel + require.Len(t, tx3.TxIn, 2) + require.Len(t, tx3.TxOut, 1) + require.Equal(t, int64(4993740), tx3.TxOut[0].Value) + + tx4 := <-lnd.TxPublishChannel + require.Len(t, tx4.TxIn, 2) + require.Len(t, tx4.TxOut, 1) + require.Equal(t, int64(4993740), tx4.TxOut[0].Value) +} + +// TestSweepBatcherBatchCreation tests that sweep requests enter the expected +// batch based on their timeout distance. +func TestSweepBatcherBatchCreation(t *testing.T) { + runTests(t, testSweepBatcherBatchCreation) +} + +// TestFeeBumping tests that sweep is RBFed with slightly higher fee rate after +// each block unless WithCustomFeeRate is passed. +func TestFeeBumping(t *testing.T) { + t.Run("regular", func(t *testing.T) { + runTests(t, func(t *testing.T, store testStore, + batcherStore testBatcherStore) { + + testFeeBumping(t, store, batcherStore, false) + }) + }) + + t.Run("fixed fee rate", func(t *testing.T) { + runTests(t, func(t *testing.T, store testStore, + batcherStore testBatcherStore) { + + testFeeBumping(t, store, batcherStore, true) + }) + }) +} + +// TestTxLabeler tests transaction labels. +func TestTxLabeler(t *testing.T) { + runTests(t, testTxLabeler) +} + +// TestPublishErrorHandler tests transaction labels. +func TestPublishErrorHandler(t *testing.T) { + runTests(t, testPublishErrorHandler) +} + +// TestSweepBatcherSimpleLifecycle tests the simple lifecycle of the batches +// that are created and run by the batcher. +func TestSweepBatcherSimpleLifecycle(t *testing.T) { + runTests(t, testSweepBatcherSimpleLifecycle) +} + +// TestDelays tests that WithInitialDelay and WithPublishDelay work. +func TestDelays(t *testing.T) { + runTests(t, testDelays) +} + +// TestMaxSweepsPerBatch tests the limit on max number of sweeps per batch. +func TestMaxSweepsPerBatch(t *testing.T) { + runTests(t, testMaxSweepsPerBatch) +} + +// TestSweepBatcherSweepReentry tests that when an old version of the batch tx +// gets confirmed the sweep leftovers are sent back to the batcher. +func TestSweepBatcherSweepReentry(t *testing.T) { + runTests(t, testSweepBatcherSweepReentry) +} + +// TestSweepBatcherNonWalletAddr tests that sweep requests that sweep to a non +// wallet address enter individual batches. +func TestSweepBatcherNonWalletAddr(t *testing.T) { + runTests(t, testSweepBatcherNonWalletAddr) +} + +// TestSweepBatcherComposite tests that sweep requests that sweep to both wallet +// addresses and non-wallet addresses enter the correct batches. +func TestSweepBatcherComposite(t *testing.T) { + runTests(t, testSweepBatcherComposite) +} + +// TestGetFeePortionForSweep tests that the fee portion for a sweep is correctly +// calculated. +func TestGetFeePortionForSweep(t *testing.T) { + runTests(t, testGetFeePortionForSweep) +} + +// TestRestoringEmptyBatch tests that the batcher can be restored with an empty +// batch. +func TestRestoringEmptyBatch(t *testing.T) { + runTests(t, testRestoringEmptyBatch) +} + +// TestHandleSweepTwice tests that handing the same sweep twice must not +// add it to different batches. +func TestHandleSweepTwice(t *testing.T) { + runTests(t, testHandleSweepTwice) +} + +// TestRestoringPreservesConfTarget tests that after the batch is written to DB +// and loaded back, its batchConfTarget value is preserved. +func TestRestoringPreservesConfTarget(t *testing.T) { + runTests(t, testRestoringPreservesConfTarget) +} + +// TestSweepFetcher tests providing custom sweep fetcher to Batcher. +func TestSweepFetcher(t *testing.T) { + runTests(t, testSweepFetcher) +} + +// TestSweepBatcherCloseDuringAdding tests that sweep batcher works correctly +// if it is closed (stops running) during AddSweep call. +func TestSweepBatcherCloseDuringAdding(t *testing.T) { + runTests(t, testSweepBatcherCloseDuringAdding) +} + +// TestCustomSignMuSig2 tests the operation with custom musig2 signer. +func TestCustomSignMuSig2(t *testing.T) { + runTests(t, testCustomSignMuSig2) +} + +// TestWithMixedBatch tests mixed batches construction. It also tests +// non-cooperative sweeping (using a preimage). Sweeps are added one by one. +func TestWithMixedBatch(t *testing.T) { + runTests(t, testWithMixedBatch) +} + +// TestWithMixedBatchLarge tests mixed batches construction, many sweeps. +// All sweeps are added at once. +func TestWithMixedBatchLarge(t *testing.T) { + runTests(t, testWithMixedBatchLarge) +} + +// TestWithMixedBatchCoopOnly tests mixed batches construction, +// All sweeps are added at once. All the sweeps are cooperative. +func TestWithMixedBatchCoopOnly(t *testing.T) { + runTests(t, testWithMixedBatchCoopOnly) +} + +// TestWithMixedBatchNonCoopHintOnly tests mixed batches construction, +// All sweeps are added at once. All the sweeps are known to be non-cooperative +// in advance. +func TestWithMixedBatchNonCoopHintOnly(t *testing.T) { + runTests(t, testWithMixedBatchNonCoopHintOnly) +} + +// TestWithMixedBatchCoopFailedOnly tests mixed batches construction, +// All sweeps are added at once. All the sweeps fail co-signing. +func TestWithMixedBatchCoopFailedOnly(t *testing.T) { + runTests(t, testWithMixedBatchCoopFailedOnly) +} + +// TestFeeRateGrows tests that fee rate of a batch does not decrease and is at +// least as high as the highest fee rate of sweeps. +func TestFeeRateGrows(t *testing.T) { + runTests(t, testFeeRateGrows) +} + +// TestPresigned tests presigned mode. Most sub-tests doesn't use loopdb. +func TestPresigned(t *testing.T) { + logger := btclog.NewBackend(os.Stdout).Logger("SWEEP") + logger.SetLevel(btclog.LevelTrace) + UseLogger(logger) + + t.Run("input1_offline_then_input2", func(t *testing.T) { + testPresigned_input1_offline_then_input2(t, NewStoreMock()) + }) + + t.Run("two_inputs_one_goes_offline", func(t *testing.T) { + testPresigned_two_inputs_one_goes_offline(t, NewStoreMock()) + }) + + t.Run("first_publish_fails", func(t *testing.T) { + testPresigned_first_publish_fails(t, NewStoreMock()) + }) + + t.Run("locktime", func(t *testing.T) { + testPresigned_locktime(t, NewStoreMock()) + }) + + t.Run("presigned_and_regular_sweeps", func(t *testing.T) { + runTests(t, testPresigned_presigned_and_regular_sweeps) + }) } // testBatcherStore is BatcherStore used in tests.