diff --git a/loopout.go b/loopout.go index f2c1e4b22..fde6d2826 100644 --- a/loopout.go +++ b/loopout.go @@ -1269,7 +1269,7 @@ func (s *loopOutSwap) waitForHtlcSpendConfirmedV2(globalCtx context.Context, } // Send the sweep to the sweeper. - err := s.batcher.AddSweep(&sweepReq) + err := s.batcher.AddSweep(ctx, &sweepReq) if err != nil { return nil, err } diff --git a/sweepbatcher/log.go b/sweepbatcher/log.go index 502eef0f3..1149de0a6 100644 --- a/sweepbatcher/log.go +++ b/sweepbatcher/log.go @@ -50,8 +50,3 @@ func infof(format string, params ...interface{}) { func warnf(format string, params ...interface{}) { log().Warnf(format, params...) } - -// errorf logs a message with level ERROR. -func errorf(format string, params ...interface{}) { - log().Errorf(format, params...) -} diff --git a/sweepbatcher/presigned.go b/sweepbatcher/presigned.go new file mode 100644 index 000000000..0d52bcbfe --- /dev/null +++ b/sweepbatcher/presigned.go @@ -0,0 +1,630 @@ +package sweepbatcher + +import ( + "bytes" + "context" + "fmt" + + "github.com/btcsuite/btcd/blockchain" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" +) + +// ensurePresigned checks that there is a presigned transaction spending the +// inputs of this group only. If allowNonEmptyBatch is false, the batch must be +// empty. +func (b *batch) ensurePresigned(ctx context.Context, newSweeps []*sweep, + allowNonEmptyBatch bool) error { + + if b.cfg.presignedHelper == nil { + return fmt.Errorf("presignedHelper is not installed") + } + if len(b.sweeps) != 0 && !allowNonEmptyBatch { + return fmt.Errorf("ensurePresigned should be done when " + + "adding to an empty batch") + } + + return ensurePresigned( + ctx, newSweeps, b.cfg.presignedHelper, b.cfg.chainParams, + ) +} + +// presignedTxChecker has methods to check if the inputs are presigned. +type presignedTxChecker interface { + destPkScripter + + // SignTx signs an unsigned transaction or returns a pre-signed tx. + // It is only called with loadOnly=true by ensurePresigned. + SignTx(ctx context.Context, primarySweepID wire.OutPoint, + tx *wire.MsgTx, inputAmt btcutil.Amount, + minRelayFee, feeRate chainfee.SatPerKWeight, + loadOnly bool) (*wire.MsgTx, error) +} + +// ensurePresigned checks that there is a presigned transaction spending the +// inputs of this group only. +func ensurePresigned(ctx context.Context, newSweeps []*sweep, + presignedTxChecker presignedTxChecker, + chainParams *chaincfg.Params) error { + + sweeps := make([]sweep, len(newSweeps)) + for i, s := range newSweeps { + sweeps[i] = sweep{ + outpoint: s.outpoint, + value: s.value, + presigned: s.presigned, + } + } + + // The sweeps are ordered inside the group, the first one is the primary + // outpoint in the batch. + primarySweepID := sweeps[0].outpoint + + // Cache the destination address. + destAddr, err := getPresignedSweepsDestAddr( + ctx, presignedTxChecker, primarySweepID, chainParams, + ) + 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. + var batchAmt btcutil.Amount + for _, sweep := range newSweeps { + batchAmt += sweep.value + } + const loadOnly = true + signedTx, err := presignedTxChecker.SignTx( + ctx, primarySweepID, tx, batchAmt, feeRate, feeRate, loadOnly, + ) + if err != nil { + return fmt.Errorf("failed to find a presigned transaction "+ + "for feeRate %v, txid of the template is %v, inputs: %d, "+ + "outputs: %d: %w", feeRate, tx.TxHash(), + len(tx.TxIn), len(tx.TxOut), 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 +} + +// getOrderedSweeps returns the sweeps of the batch in the order they were +// added. The method must be called from the event loop of the batch. +func (b *batch) getOrderedSweeps(ctx context.Context) ([]sweep, error) { + // We use the DB just to know the order. Sweeps are copied from RAM. + utxo2sweep := make(map[wire.OutPoint]sweep, len(b.sweeps)) + for _, s := range b.sweeps { + utxo2sweep[s.outpoint] = s + } + + dbSweeps, err := b.store.FetchBatchSweeps(ctx, b.id) + if err != nil { + return nil, fmt.Errorf("FetchBatchSweeps(%d) failed: %w", b.id, + err) + } + if len(dbSweeps) != len(utxo2sweep) { + return nil, fmt.Errorf("FetchBatchSweeps(%d) returned %d "+ + "sweeps, len(b.sweeps) is %d", b.id, len(dbSweeps), + len(utxo2sweep)) + } + + orderedSweeps := make([]sweep, len(dbSweeps)) + for i, dbSweep := range dbSweeps { + // Sanity check: make sure dbSweep.ID grows. + if i > 0 && dbSweep.ID <= dbSweeps[i-1].ID { + return nil, fmt.Errorf("sweep ID does not grow: %d->%d", + dbSweeps[i-1].ID, dbSweep.ID) + } + + s, has := utxo2sweep[dbSweep.Outpoint] + if !has { + return nil, fmt.Errorf("FetchBatchSweeps(%d) returned "+ + "unknown sweep %v", b.id, dbSweep.Outpoint) + } + orderedSweeps[i] = s + } + + return orderedSweeps, nil +} + +// getSweepsGroups returns groups in which sweeps were added to the batch. +// All the sweeps are sorted by addition order and grouped by swap. +// The method must be called from the event loop of the batch. +func (b *batch) getSweepsGroups(ctx context.Context) ([][]sweep, error) { + orderedSweeps, err := b.getOrderedSweeps(ctx) + if err != nil { + return nil, fmt.Errorf("getOrderedSweeps(%d) failed: %w", b.id, + err) + } + + groups := [][]sweep{} + for _, s := range orderedSweeps { + index := len(groups) - 1 + + // Start new group if there are no groups or new swap starts. + if len(groups) == 0 || s.swapHash != groups[index][0].swapHash { + groups = append(groups, []sweep{}) + index++ + } + + groups[index] = append(groups[index], s) + } + + // Sanity check: make sure the number of groups is the same as the + // number of distinct swaps. + swapsSet := make(map[lntypes.Hash]struct{}, len(groups)) + for _, s := range orderedSweeps { + swapsSet[s.swapHash] = struct{}{} + } + if len(swapsSet) != len(groups) { + return nil, fmt.Errorf("batch %d: there are %d groups of "+ + "sweeps and %d distinct swaps", b.id, len(groups), + len(swapsSet)) + } + + return groups, nil +} + +// presign tries to presign batch sweep transactions composed of this batch and +// the sweep. In addition to that it presigns sweep transactions for any subset +// of sweeps that could remain if one of the sweep transactions gets confirmed. +// This can be done efficiently, since we keep track of the order in which +// sweeps are added and the associated swap hashes. So we presign transactions +// sweeping all the sweeps starting at some past sweeps group. For each inputs +// layout it presigns many transactions with different fee rates. +func (b *batch) presign(ctx context.Context, newSweeps []*sweep) error { + if b.cfg.presignedHelper == nil { + return fmt.Errorf("presignedHelper is not installed") + } + if len(b.sweeps) == 0 { + return fmt.Errorf("presigning should be 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 + // fee estimator from failing. + // See https://github.com/lightninglabs/loop/issues/898 + 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) + + // We need to restore previously added groups. We can do it by reading + // all the sweeps from DB (they must be ordered) and grouping by swap. + groups, err := b.getSweepsGroups(ctx) + if err != nil { + return fmt.Errorf("getSweepsGroups failed: %w", err) + } + if len(groups) == 0 { + return fmt.Errorf("getSweepsGroups returned no sweeps groups") + } + + // Now presign a transaction spending a suffix of groups as well as new + // sweeps. Any non-empty suffix of groups may remain non-swept after + // some past tx is confirmed. + for len(groups) != 0 { + // Create the list of sweeps from the remaining groups and new + // sweeps. + sweeps := make([]sweep, 0, len(b.sweeps)+len(newSweeps)) + for _, group := range groups { + sweeps = append(sweeps, group...) + } + for _, sweep := range newSweeps { + sweeps = append(sweeps, *sweep) + } + + // The primarySweepID is the first sweep from the list of + // remaining sweeps if previous groups are confirmed. + primarySweepID := sweeps[0].outpoint + + // Cache the destination address. + destAddr, err := getPresignedSweepsDestAddr( + ctx, b.cfg.presignedHelper, b.primarySweepID, + b.cfg.chainParams, + ) + if err != nil { + return fmt.Errorf("failed to find destination "+ + "address: %w", err) + } + + err = presign( + ctx, b.cfg.presignedHelper, destAddr, primarySweepID, + sweeps, nextBlockFeeRate, + ) + if err != nil { + return fmt.Errorf("failed to presign a transaction "+ + "of %d sweeps: %w", len(sweeps), err) + } + + // Cut a group to proceed to next suffix of original groups. + groups = groups[1:] + } + + // Ensure that a batch spending new sweeps only has been presigned by + // PresignSweepsGroup. + const allowNonEmptyBatch = true + err = b.ensurePresigned(ctx, newSweeps, allowNonEmptyBatch) + if err != nil { + return fmt.Errorf("new sweeps were not presigned; this means "+ + "that PresignSweepsGroup was not called prior to "+ + "AddSweep for the group: %w", err) + } + + return nil +} + +// 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, primarySweepID wire.OutPoint, + 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, + primarySweepID wire.OutPoint, 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. + // If timeout <= timeoutThreshold, don't set LockTime (keep value 0). + var highFeeRateLockTime uint32 + if timeout > timeoutThreshold { + 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, primarySweepID, 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 := getPresignedSweepsDestAddr( + ctx, b.cfg.presignedHelper, b.primarySweepID, + b.cfg.chainParams, + ) + 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 pre-signed transaction. + const loadOnly = false + signedTx, err := b.cfg.presignedHelper.SignTx( + ctx, b.primarySweepID, tx, batchAmt, minRelayFee, feeRate, + loadOnly, + ) + 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, realWeight, 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 +} + +// destPkScripter returns destination pkScript used by the sweep batch. +type destPkScripter interface { + // DestPkScript returns destination pkScript used by the sweep batch + // with the primary outpoint specified. Returns an error, if such tx + // doesn't exist. If there are many such transactions, returns any of + // pkScript's; all of them should have the same destination pkScript. + DestPkScript(ctx context.Context, + primarySweepID wire.OutPoint) ([]byte, error) +} + +// getPresignedSweepsDestAddr returns the destination address used by the +// primary outpoint. The function must be used in presigned mode only. +func getPresignedSweepsDestAddr(ctx context.Context, helper destPkScripter, + primarySweepID wire.OutPoint, + chainParams *chaincfg.Params) (btcutil.Address, error) { + + // Load pkScript from the presigned helper. + pkScriptBytes, err := helper.DestPkScript(ctx, primarySweepID) + if err != nil { + return nil, fmt.Errorf("presignedHelper.DestPkScript failed "+ + "for primarySweepID %v: %w", primarySweepID, 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 primarySweepID %v: %w", + pkScriptBytes, primarySweepID, err) + } + + address, err := pkScript.Address(chainParams) + if err != nil { + return nil, fmt.Errorf("pkScript.Address failed for "+ + "pkScript %x returned for primarySweepID %v: %w", + pkScriptBytes, primarySweepID, 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 all inputs of signedTx have a non-empty witness. + for _, txIn := range signedTx.TxIn { + if len(txIn.Witness) == 0 { + return fmt.Errorf("input %s of signed tx is not signed", + txIn.PreviousOutPoint) + } + } + + // 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..60f287764 --- /dev/null +++ b/sweepbatcher/presigned_test.go @@ -0,0 +1,1579 @@ +package sweepbatcher + +import ( + "context" + "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/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/stretchr/testify/require" +) + +// TestOrderedSweeps checks that methods batch.getOrderedSweeps and +// batch.getSweepsGroups works properly. +func TestOrderedSweeps(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} + op3 := wire.OutPoint{Hash: chainhash.Hash{3, 3, 3}, Index: 3} + op4 := wire.OutPoint{Hash: chainhash.Hash{4, 4, 4}, Index: 4} + op5 := wire.OutPoint{Hash: chainhash.Hash{5, 5, 5}, Index: 5} + op6 := wire.OutPoint{Hash: chainhash.Hash{6, 6, 6}, Index: 6} + + swapHash1 := lntypes.Hash{1, 1} + swapHash2 := lntypes.Hash{2, 2} + swapHash3 := lntypes.Hash{3, 3} + + ctx := context.Background() + + cases := []struct { + name string + sweeps []sweep + + // Testing errors. + skipStore bool + reverseStore bool + replaceSweeps map[wire.OutPoint]sweep + + wantGroups [][]sweep + wantErr1 string + wantErr2 string + }{ + { + name: "no sweeps", + sweeps: []sweep{}, + wantGroups: [][]sweep{}, + }, + + { + name: "one sweep", + sweeps: []sweep{ + { + outpoint: op1, + swapHash: swapHash1, + }, + }, + wantGroups: [][]sweep{ + { + { + outpoint: op1, + swapHash: swapHash1, + }, + }, + }, + }, + + { + name: "two sweeps, one swap", + sweeps: []sweep{ + { + outpoint: op2, + swapHash: swapHash1, + }, + { + outpoint: op1, + swapHash: swapHash1, + }, + }, + wantGroups: [][]sweep{ + { + { + outpoint: op2, + swapHash: swapHash1, + }, + { + outpoint: op1, + swapHash: swapHash1, + }, + }, + }, + }, + + { + name: "two sweeps, two swap", + sweeps: []sweep{ + { + outpoint: op2, + swapHash: swapHash1, + }, + { + outpoint: op1, + swapHash: swapHash2, + }, + }, + wantGroups: [][]sweep{ + { + { + outpoint: op2, + swapHash: swapHash1, + }, + }, + { + { + outpoint: op1, + swapHash: swapHash2, + }, + }, + }, + }, + + { + name: "many sweeps and swaps", + sweeps: []sweep{ + { + outpoint: op1, + swapHash: swapHash1, + }, + { + outpoint: op2, + swapHash: swapHash1, + }, + { + outpoint: op3, + swapHash: swapHash1, + }, + { + outpoint: op4, + swapHash: swapHash2, + }, + { + outpoint: op5, + swapHash: swapHash3, + }, + { + outpoint: op6, + swapHash: swapHash3, + }, + }, + wantGroups: [][]sweep{ + { + { + outpoint: op1, + swapHash: swapHash1, + }, + { + outpoint: op2, + swapHash: swapHash1, + }, + { + outpoint: op3, + swapHash: swapHash1, + }, + }, + { + { + outpoint: op4, + swapHash: swapHash2, + }, + }, + { + { + outpoint: op5, + swapHash: swapHash3, + }, + { + outpoint: op6, + swapHash: swapHash3, + }, + }, + }, + }, + + { + name: "error: sweeps not stored in DB", + sweeps: []sweep{ + { + outpoint: op1, + swapHash: swapHash1, + }, + }, + skipStore: true, + wantErr1: "returned 0 sweeps, len(b.sweeps) is 1", + }, + + { + name: "error: wrong order in DB", + sweeps: []sweep{ + { + outpoint: op1, + swapHash: swapHash1, + }, + { + outpoint: op2, + swapHash: swapHash2, + }, + }, + reverseStore: true, + }, + + { + name: "error: extra sweep in DB", + sweeps: []sweep{ + { + outpoint: op1, + swapHash: swapHash1, + }, + { + outpoint: op2, + swapHash: swapHash2, + }, + }, + replaceSweeps: map[wire.OutPoint]sweep{ + op2: { + outpoint: op3, + swapHash: swapHash3, + }, + }, + wantErr1: "returned unknown sweep", + }, + + { + name: "error: swaps interleaved", + sweeps: []sweep{ + { + outpoint: op1, + swapHash: swapHash1, + }, + { + outpoint: op2, + swapHash: swapHash2, + }, + { + outpoint: op3, + swapHash: swapHash1, + }, + }, + wantErr2: "3 groups of sweeps and 2 distinct swaps", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + // Create a map of sweeps. + m := make(map[wire.OutPoint]sweep, len(tc.sweeps)) + for _, s := range tc.sweeps { + require.NotContains(t, m, s.outpoint) + m[s.outpoint] = s + } + + // Create a batch. + b := &batch{ + sweeps: m, + store: NewStoreMock(), + } + + // Store the sweeps in mock store. + switch { + // Don't create DB sweeps at all. + case tc.skipStore: + + // Reverse the order of DB sweeps to ID incorrect IDs. + case tc.reverseStore: + for i := len(tc.sweeps) - 1; i >= 0; i-- { + s := tc.sweeps[i] + const completed = false + err := b.persistSweep(ctx, s, completed) + require.NoError(t, err) + } + + // Working DB sweeps. + default: + for _, s := range tc.sweeps { + const completed = false + err := b.persistSweep(ctx, s, completed) + require.NoError(t, err) + } + } + + // Replace some sweeps to test an error. + for removed, added := range tc.replaceSweeps { + require.Contains(t, m, removed) + delete(m, removed) + require.NotContains(t, m, added.outpoint) + m[added.outpoint] = added + } + + // Now run the tested functions. + orderedSweeps, err := b.getOrderedSweeps(ctx) + if tc.wantErr1 != "" { + require.ErrorContains(t, err, tc.wantErr1) + return + } + require.NoError(t, err) + + if tc.reverseStore { + require.NotEqual(t, tc.sweeps, orderedSweeps) + return + } + + // The wanted list of sweeps matches the input order. + require.Equal(t, tc.sweeps, orderedSweeps) + + groups, err := b.getSweepsGroups(ctx) + if tc.wantErr2 != "" { + require.ErrorContains(t, err, tc.wantErr2) + return + } + require.NoError(t, err) + require.Equal(t, tc.wantGroups, groups) + }) + } +} + +// mockPresignedTxChecker is an implementation of presignedTxChecker used in +// TestEnsurePresigned. +type mockPresignedTxChecker struct { + // primarySweepID is the value the mock matches this argument against. + primarySweepID wire.OutPoint + + // destPkScript is the value returned by mock.DestPkScript. + destPkScript []byte + + // signTxCalls is the number of SignTx calls. + signTxCalls int + + // recordedInputAmt is the saved value of inputAmt argument of SignTx. + recordedInputAmt btcutil.Amount + + // recordedMinRelayFee is the saved value of minRelayFee argument of + // SignTx. + recordedMinRelayFee chainfee.SatPerKWeight + + // recordedFeeRate is the saved value of feeRate argument of SignTx. + recordedFeeRate chainfee.SatPerKWeight + + // recordedLoadOnly is the saved value of loadOnly argument of SignTx. + recordedLoadOnly bool + + // destPkScriptErr is the error returned by DestPkScript (if any). + destPkScriptErr error + + // signedTxErr is the error returned by SignTx (if any). + signedTxErr error +} + +// DestPkScript returns destination pkScript used by the sweep batch. +func (m *mockPresignedTxChecker) DestPkScript(ctx context.Context, + primarySweepID wire.OutPoint) ([]byte, error) { + + if primarySweepID != m.primarySweepID { + return nil, fmt.Errorf("primarySweepID mismatch") + } + + if m.destPkScriptErr != nil { + return nil, m.destPkScriptErr + } + + return m.destPkScript, nil +} + +// SignTx records all its argumentd and returned a "presigned" tx. +func (m *mockPresignedTxChecker) SignTx(ctx context.Context, + primarySweepID wire.OutPoint, tx *wire.MsgTx, inputAmt btcutil.Amount, + minRelayFee, feeRate chainfee.SatPerKWeight, + loadOnly bool) (*wire.MsgTx, error) { + + m.signTxCalls++ + + if primarySweepID != m.primarySweepID { + return nil, fmt.Errorf("primarySweepID mismatch") + } + + m.recordedInputAmt = inputAmt + m.recordedMinRelayFee = minRelayFee + m.recordedFeeRate = feeRate + m.recordedLoadOnly = loadOnly + + if m.signedTxErr != nil { + return nil, m.signedTxErr + } + + // Pretend that we have a presigned transaction. + tx = tx.Copy() + for i := range tx.TxIn { + tx.TxIn[i].Witness = wire.TxWitness{ + make([]byte, 64), + } + } + + return tx, nil +} + +// TestEnsurePresigned checks that function ensurePresigned works correctly. +func TestEnsurePresigned(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) + + ctx := context.Background() + + cases := []struct { + name string + primarySweepID wire.OutPoint + sweeps []*sweep + destPkScript []byte + wantInputAmt btcutil.Amount + destPkScriptErr error + signedTxErr error + }{ + { + name: "one input", + primarySweepID: op1, + sweeps: []*sweep{ + { + outpoint: op1, + value: 1_000_000, + timeout: 1000, + }, + }, + destPkScript: batchPkScript, + wantInputAmt: 1_000_000, + }, + + { + name: "two inputs", + primarySweepID: op1, + sweeps: []*sweep{ + { + outpoint: op1, + value: 1_000_000, + timeout: 1000, + }, + { + outpoint: op2, + value: 2_000_000, + timeout: 1000, + }, + }, + destPkScript: batchPkScript, + wantInputAmt: 3_000_000, + }, + + { + name: "error: DestPkScript fails", + primarySweepID: op1, + sweeps: []*sweep{ + { + outpoint: op1, + value: 1_000_000, + timeout: 1000, + }, + }, + destPkScriptErr: fmt.Errorf("test DestPkScript error"), + }, + + { + name: "error: SignTx fails", + primarySweepID: op1, + sweeps: []*sweep{ + { + outpoint: op1, + value: 1_000_000, + timeout: 1000, + }, + }, + destPkScript: batchPkScript, + signedTxErr: fmt.Errorf("test SignTx error"), + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c := &mockPresignedTxChecker{ + primarySweepID: tc.primarySweepID, + destPkScript: tc.destPkScript, + destPkScriptErr: tc.destPkScriptErr, + signedTxErr: tc.signedTxErr, + } + + err := ensurePresigned( + ctx, tc.sweeps, c, + &chaincfg.RegressionNetParams, + ) + switch { + case tc.destPkScriptErr != nil: + require.ErrorIs(t, err, tc.destPkScriptErr) + case tc.signedTxErr != nil: + require.ErrorIs(t, err, tc.signedTxErr) + default: + require.NoError(t, err) + require.Equal(t, 1, c.signTxCalls) + require.Equal( + t, tc.wantInputAmt, c.recordedInputAmt, + ) + require.Equal( + t, chainfee.FeePerKwFloor, + c.recordedMinRelayFee, + ) + require.Equal( + t, chainfee.FeePerKwFloor, + c.recordedFeeRate, + ) + require.True(t, c.recordedLoadOnly) + } + }) + } +} + +// hasInput returns if the transaction spends the UTXO. +func hasInput(tx *wire.MsgTx, utxo wire.OutPoint) bool { + for _, txIn := range tx.TxIn { + if txIn.PreviousOutPoint == utxo { + return true + } + } + + return false +} + +// 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, + primarySweepID wire.OutPoint, tx *wire.MsgTx, + inputAmt btcutil.Amount) error { + + if !hasInput(tx, primarySweepID) { + return fmt.Errorf("primarySweepID %v not in tx", primarySweepID) + } + + 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 + primarySweepID wire.OutPoint + sweeps []sweep + destAddr btcutil.Address + nextBlockFeeRate chainfee.SatPerKWeight + wantErr string + wantOutputs []btcutil.Amount + wantLockTimes []uint32 + }{ + { + name: "error: no presigner", + primarySweepID: op1, + sweeps: []sweep{ + { + outpoint: op1, + value: 1_000_000, + timeout: 1000, + }, + }, + destAddr: destAddr, + nextBlockFeeRate: chainfee.FeePerKwFloor, + wantErr: "presigner is not installed", + }, + + { + name: "error: no sweeps", + primarySweepID: op1, + presigner: &mockPresigner{}, + destAddr: destAddr, + nextBlockFeeRate: chainfee.FeePerKwFloor, + wantErr: "there are no sweeps", + }, + + { + name: "error: no destAddr", + presigner: &mockPresigner{}, + primarySweepID: op1, + sweeps: []sweep{ + { + outpoint: op1, + value: 1_000_000, + timeout: 1000, + }, + }, + nextBlockFeeRate: chainfee.FeePerKwFloor, + wantErr: "unsupported address type ", + }, + + { + name: "error: zero nextBlockFeeRate", + presigner: &mockPresigner{}, + primarySweepID: op1, + 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{}, + primarySweepID: op1, + 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: "error: primary not set", + 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, + wantErr: "not in tx", + }, + + { + name: "error: primary not in tx", + presigner: &mockPresigner{}, + primarySweepID: op2, + sweeps: []sweep{ + { + outpoint: op1, + value: 1_000_000, + timeout: 1000, + }, + }, + destAddr: destAddr, + nextBlockFeeRate: chainfee.FeePerKwFloor, + wantErr: "not in tx", + }, + + { + name: "one sweep", + presigner: &mockPresigner{}, + primarySweepID: op1, + sweeps: []sweep{ + { + outpoint: op1, + value: 1_000_000, + timeout: 1000, + }, + }, + destAddr: destAddr, + nextBlockFeeRate: chainfee.FeePerKwFloor, + wantOutputs: []btcutil.Amount{ + 999900, 999880, 999856, 999827, 999793, 999752, + 999702, 999643, 999572, 999486, 999384, 999260, + 999113, 998935, 998723, 998467, 998161, 997793, + 997352, 996823, 996187, 995425, 994510, 993413, + 992096, 990515, 988618, 986342, 983610, 980332, + 976399, 971679, 966015, 959218, 951062, 941274, + 929530, 915435, 898523, 878227, 853873, 824648, + }, + 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: "two sweeps", + presigner: &mockPresigner{}, + primarySweepID: op1, + 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{ + 2999841, 2999810, 2999773, 2999728, 2999673, + 2999608, 2999530, 2999436, 2999323, 2999188, + 2999026, 2998831, 2998598, 2998317, 2997981, + 2997577, 2997093, 2996512, 2995814, 2994977, + 2993973, 2992768, 2991322, 2989587, 2987505, + 2985006, 2982007, 2978409, 2974091, 2968910, + 2962691, 2955230, 2946276, 2935532, 2922639, + 2907167, 2888600, 2866320, 2839584, 2807501, + 2769001, 2722802, + }, + 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: "two sweeps, another primary", + presigner: &mockPresigner{}, + primarySweepID: op2, + 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{ + 2999841, 2999810, 2999773, 2999728, 2999673, + 2999608, 2999530, 2999436, 2999323, 2999188, + 2999026, 2998831, 2998598, 2998317, 2997981, + 2997577, 2997093, 2996512, 2995814, 2994977, + 2993973, 2992768, 2991322, 2989587, 2987505, + 2985006, 2982007, 2978409, 2974091, 2968910, + 2962691, 2955230, 2946276, 2935532, 2922639, + 2907167, 2888600, 2866320, 2839584, 2807501, + 2769001, 2722802, + }, + 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: "timeout < 50", + presigner: &mockPresigner{}, + primarySweepID: op1, + sweeps: []sweep{ + { + outpoint: op1, + value: 1_000_000, + timeout: 40, + }, + { + outpoint: op2, + value: 2_000_000, + timeout: 40, + }, + }, + destAddr: destAddr, + nextBlockFeeRate: 50 * chainfee.FeePerKwFloor, + wantOutputs: []btcutil.Amount{ + 2999841, 2999810, 2999773, 2999728, 2999673, + 2999608, 2999530, 2999436, 2999323, 2999188, + 2999026, 2998831, 2998598, 2998317, 2997981, + 2997577, 2997093, 2996512, 2995814, 2994977, + 2993973, 2992768, 2991322, 2989587, 2987505, + 2985006, 2982007, 2978409, 2974091, 2968910, + 2962691, 2955230, 2946276, 2935532, 2922639, + 2907167, 2888600, 2866320, 2839584, 2807501, + 2769001, 2722802, + }, + 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, 0, 0, 0, 0, 0, 0, 0, + }, + }, + + { + name: "high current feerate => locktime later", + presigner: &mockPresigner{}, + primarySweepID: op1, + 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{ + 2999841, 2999810, 2999773, 2999728, 2999673, + 2999608, 2999530, 2999436, 2999323, 2999188, + 2999026, 2998831, 2998598, 2998317, 2997981, + 2997577, 2997093, 2996512, 2995814, 2994977, + 2993973, 2992768, 2991322, 2989587, 2987505, + 2985006, 2982007, 2978409, 2974091, 2968910, + 2962691, 2955230, 2946276, 2935532, 2922639, + 2907167, 2888600, 2866320, 2839584, 2807501, + 2769001, 2722802, + }, + 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{}, + primarySweepID: op1, + sweeps: []sweep{ + { + outpoint: op1, + value: 1_000, + timeout: 1000, + }, + { + outpoint: op2, + value: 2_000, + timeout: 1000, + }, + }, + destAddr: destAddr, + nextBlockFeeRate: chainfee.FeePerKwFloor, + wantOutputs: []btcutil.Amount{ + 2841, 2810, 2773, 2728, 2673, 2608, 2530, 2436, + 2400, + }, + wantLockTimes: []uint32{ + 0, 0, 0, 0, 0, 0, 0, 0, 0, + }, + }, + + { + name: "third signing fails", + presigner: &mockPresigner{ + failAt: 3, + }, + primarySweepID: op1, + 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 { + t.Run(tc.name, func(t *testing.T) { + err := presign( + ctx, tc.presigner, tc.destAddr, + tc.primarySweepID, 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: "unsigned input 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, + }, + }, + TxOut: []*wire.TxOut{ + { + Value: 2999374, + PkScript: batchPkScript, + }, + }, + LockTime: 799_999, + }, + inputAmt: 3_000_000, + minRelayFee: 253, + wantErr: "is not signed", + }, + + { + 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 { + 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/store_mock.go b/sweepbatcher/store_mock.go index 47b27ee86..90896aac1 100644 --- a/sweepbatcher/store_mock.go +++ b/sweepbatcher/store_mock.go @@ -15,6 +15,7 @@ type StoreMock struct { batches map[int32]dbBatch sweeps map[wire.OutPoint]dbSweep mu sync.Mutex + sweepID int32 } // NewStoreMock instantiates a new mock store. @@ -122,7 +123,18 @@ func (s *StoreMock) UpsertSweep(ctx context.Context, sweep *dbSweep) error { s.mu.Lock() defer s.mu.Unlock() - s.sweeps[sweep.Outpoint] = *sweep + sweepCopy := *sweep + + if old, exists := s.sweeps[sweep.Outpoint]; exists { + // Preserve existing sweep ID. + sweepCopy.ID = old.ID + } else { + // Assign fresh sweep ID. + sweepCopy.ID = s.sweepID + s.sweepID++ + } + + s.sweeps[sweep.Outpoint] = sweepCopy return nil } diff --git a/sweepbatcher/sweep_batch.go b/sweepbatcher/sweep_batch.go index e9f74add7..a437461ad 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" @@ -121,6 +122,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. @@ -176,6 +180,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. @@ -306,7 +318,7 @@ type batch struct { // Purger is a function that takes a sweep request and feeds it back to the // batcher main entry point. The name is inspired by its purpose, which is to // purge the batch from sweeps that didn't make it to the confirmed tx. -type Purger func(sweepReq *SweepRequest) error +type Purger func(ctx context.Context, sweepReq *SweepRequest) error // batchKit is a kit of dependencies that are used to initialize a batch. This // struct is only used as a wrapper for the arguments that are required to @@ -319,7 +331,6 @@ type batchKit struct { primaryID wire.OutPoint sweeps map[wire.OutPoint]sweep rbfCache rbfCache - returnChan chan SweepRequest wallet lndclient.WalletKitClient chainNotifier lndclient.ChainNotifierClient signerClient lndclient.SignerClient @@ -467,7 +478,10 @@ func (b *batch) Errorf(format string, params ...interface{}) { // checkSweepToAdd checks if a sweep can be added or updated in the batch. The // caller must lock the event loop using scheduleNextCall. The function returns -// if the sweep already exists in the batch. +// if the sweep already exists in the batch. If presigned mode is enabled, the +// result depends on the outcome of the method presignedHelper.Presign for a +// non-empty batch. For an empty batch, the input needs to pass +// PresignSweepsGroup. func (b *batch) checkSweepToAdd(_ context.Context, sweep *sweep) (bool, error) { // If the provided sweep is nil, we can't proceed with any checks, so // we just return early. @@ -587,6 +601,84 @@ func (b *batch) addSweeps(ctx context.Context, sweeps []*sweep) (bool, error) { outpointsSet[s.outpoint] = struct{}{} } + // Track if there is a presigned and a regular sweep. + var addingPresigned, addingRegular bool + for _, s := range sweeps { + if s.presigned { + addingPresigned = true + } else { + addingRegular = true + } + } + if addingPresigned && addingRegular { + b.Warnf("There are presigned and regular sweeps in the group") + + return false, nil + } + + // 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 addingPresigned { + // Ensure that all the sweeps in the batch use presigned mode. + for _, s := range b.sweeps { + if !s.presigned { + b.Warnf("Failed to add presigned sweep %x to "+ + "the batch, because the batch has "+ + "non-presigned sweep %x", + sweeps[0].swapHash[:6], s.swapHash[:6]) + + return false, nil + } + } + + switch { + // We don't need to run checks if existing sweeps are updated. + case numExisting == len(sweeps): + + // If new sweeps are added to the batch, we need to presign new + // version of batch transaction. + case len(b.sweeps) != 0: + if err := b.presign(ctx, sweeps); err != nil { + b.Warnf("Failed to add sweep %x to the batch, "+ + "because failed to presign new version"+ + " of batch tx: %v", + sweeps[0].swapHash[:6], err) + + return false, nil + } + + // If this is a new batch being formed, make sure we already + // have a presigned transaction. + default: + const allowNonEmptyBatch = false + err := b.ensurePresigned( + ctx, sweeps, allowNonEmptyBatch, + ) + if err != nil { + b.Warnf("Failed to check signing of input %x,"+ + " this means that PresignSweepsGroup "+ + "was not called prior to AddSweep for"+ + " this input: %v", + sweeps[0].swapHash[:6], err) + + return false, nil + } + } + } else { + // Ensure that all the sweeps in the batch don't use presigned. + for _, s := range b.sweeps { + if s.presigned { + b.Warnf("failed to add a non-presigned sweep "+ + "%x to the batch, because the batch "+ + "has presigned sweep %x", + sweeps[0].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. @@ -881,8 +973,8 @@ func (b *batch) Run(ctx context.Context) error { return fmt.Errorf("handleSpend error: %w", err) } - case <-b.confChan: - if err := b.handleConf(runCtx); err != nil { + case conf := <-b.confChan: + if err := b.handleConf(runCtx, conf); err != nil { return fmt.Errorf("handleConf error: %w", err) } @@ -998,6 +1090,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 ( @@ -1023,7 +1148,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) @@ -1094,9 +1231,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 { @@ -1106,7 +1243,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. @@ -1158,7 +1295,14 @@ func (b *batch) constructUnsignedTx(sweeps []sweep, // Find weight and fee. weight := weightEstimate.Weight() - feeForWeight := b.rbfCache.FeeRate.FeeForWeight(weight) + feeForWeight := feeRate.FeeForWeight(weight) + + // Fee can be rounded towards zero, leading to actual feeRate being + // slightly lower than the requested value. Increase the fee if this is + // the case. + if chainfee.NewSatPerKWeight(feeForWeight, weight) < feeRate { + feeForWeight++ + } // Clamp the calculated fee to the max allowed fee amount for the batch. fee := clampBatchFee(feeForWeight, batchAmt) @@ -1243,8 +1387,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), @@ -1814,13 +1958,36 @@ func (b *batch) handleSpend(ctx context.Context, spendTx *wire.MsgTx) error { b.Warnf("transaction %v has no outputs", txHash) } + // 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) + } + + // Sort sweeps by the addition order. This is important in presigned + // mode to pass them in correct order to purger (AddSweep) so the + // primary sweep is determined correctly and the presigned transaction + // is found. In regular mode the order doesn't matter, but we do it the + // same way for simplicity. + allSweeps, err := b.getOrderedSweeps(ctx) + if err != nil { + return fmt.Errorf("getOrderedSweeps(%d) failed: %w", + b.id, err) + } + // As a previous version of the batch transaction may get confirmed, // which does not contain the latest sweeps, we need to detect the // sweeps that did not make it to the confirmed transaction and feed // them back to the batcher. This will ensure that the sweeps will enter // a new batch instead of remaining dangling. - var totalSweptAmt btcutil.Amount - for _, sweep := range b.sweeps { + var ( + totalSweptAmt btcutil.Amount + confirmedSweeps = []wire.OutPoint{} + purgedSweeps = []wire.OutPoint{} + purgedSwaps = []lntypes.Hash{} + ) + for _, sweep := range allSweeps { found := false for _, txIn := range spendTx.TxIn { @@ -1828,27 +1995,58 @@ func (b *batch) handleSpend(ctx context.Context, spendTx *wire.MsgTx) error { found = true totalSweptAmt += sweep.value notifyList = append(notifyList, sweep) + confirmedSweeps = append( + confirmedSweeps, sweep.outpoint, + ) + + break } } // If the sweep's outpoint was not found in the transaction's // inputs this means it was left out. So we delete it from this // batch and feed it back to the batcher. - if !found { - newSweep := sweep - delete(b.sweeps, sweep.outpoint) + if found { + continue + } + + newSweep := sweep + delete(b.sweeps, sweep.outpoint) + + newInput := Input{ + Outpoint: newSweep.outpoint, + Value: newSweep.value, + } + + // In presigned mode we should form a SweepRequest per swap + // (i.e. per group) and keep them ordered. It should reproduce + // the arguments and the order of the original external AddSweep + // calls. + L := len(purgeList) + if presigned && L != 0 && + purgeList[L-1].SwapHash == newSweep.swapHash { + + // Add the input to existing SweepRequest for this swap. + purgeList[L-1].Inputs = append( + purgeList[L-1].Inputs, newInput, + ) + } else { + // Add the current sweep as a new element to purgeList. + // This is possible either in regular mode or in + // presigned mode in the beginning or on new swap. purgeList = append(purgeList, SweepRequest{ SwapHash: newSweep.swapHash, - Inputs: []Input{ - { - Outpoint: newSweep.outpoint, - Value: newSweep.value, - }, - }, + Inputs: []Input{newInput}, Notifier: newSweep.notifier, }) } } + for _, sweepReq := range purgeList { + purgedSwaps = append(purgedSwaps, sweepReq.SwapHash) + for _, input := range sweepReq.Inputs { + purgedSweeps = append(purgedSweeps, input.Outpoint) + } + } // Calculate the fee portion that each sweep should pay for the batch. feePortionPaidPerSweep, roundingDifference := getFeePortionForSweep( @@ -1881,7 +2079,13 @@ func (b *batch) handleSpend(ctx context.Context, spendTx *wire.MsgTx) error { // Dispatch the sweep notifier, we don't care about the outcome // of this action so we don't wait for it. - go sweep.notifySweepSpend(ctx, &spendDetail) + go func() { + // Make sure this context doesn't expire so we + // successfully notify the caller. + ctx := context.WithoutCancel(ctx) + + sweep.notifySweepSpend(ctx, &spendDetail) + }() } // Proceed with purging the sweeps. This will feed the sweeps that @@ -1889,21 +2093,26 @@ func (b *batch) handleSpend(ctx context.Context, spendTx *wire.MsgTx) error { // for re-entry. This batch doesn't care for the outcome of this // operation so we don't wait for it. go func() { + // Make sure this context doesn't expire so we successfully + // add the sweeps to the batcher. + ctx := context.WithoutCancel(ctx) + // Iterate over the purge list and feed the sweeps back to the // batcher. - for _, sweep := range purgeList { - err := b.purger(&sweep) + for _, sweepReq := range purgeList { + err := b.purger(ctx, &sweepReq) if err != nil { - b.Errorf("unable to purge sweep %x: %v", - sweep.SwapHash[:6], err) + b.Errorf("unable to purge sweep group %x: %v", + sweepReq.SwapHash[:6], err) } } }() - b.Infof("spent, total sweeps: %v, purged sweeps: %v", - len(notifyList), len(purgeList)) + b.Infof("spent, confirmed sweeps: %v, purged sweeps: %v, "+ + "purged swaps: %v, purged groups: %v", confirmedSweeps, + purgedSweeps, purgedSwaps, len(purgeList)) - err := b.monitorConfirmations(ctx) + err = b.monitorConfirmations(ctx) if err != nil { return err } @@ -1916,8 +2125,44 @@ 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 { +// batch. Here we signal to the batcher that this batch was completed. We also +// cleanup up presigned transactions whose primarySweepID is one of the sweeps +// that were spent and fully confirmed: such a transaction can't be broadcasted +// since it is either in a block or double-spends one of spent outputs. +func (b *batch) handleConf(ctx context.Context, + conf *chainntnfs.TxConfirmation) error { + + spendTx := conf.Tx + txHash := spendTx.TxHash() + if b.batchTxid == nil || *b.batchTxid != txHash { + b.Warnf("Mismatch of batch txid: tx in spend notification had "+ + "txid %v, but confirmation notification has txif %v. "+ + "Using the later.", b.batchTxid, txHash) + } + b.batchTxid = &txHash + + // 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(spendTx.TxIn)) + for _, txIn := range spendTx.TxIn { + inputs = append(inputs, txIn.PreviousOutPoint) + } + + 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 @@ -1960,7 +2205,22 @@ 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 getPresignedSweepsDestAddr +// 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_batch_test.go b/sweepbatcher/sweep_batch_test.go new file mode 100644 index 000000000..570565b0d --- /dev/null +++ b/sweepbatcher/sweep_batch_test.go @@ -0,0 +1,357 @@ +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", + }, + + { + name: "fix fee rounding", + sweeps: []sweep{ + { + outpoint: op1, + value: 1_000_000, + }, + { + outpoint: op2, + value: 2_000_000, + }, + }, + address: destAddr, + currentHeight: 800_000, + feeRate: 253, + wantTx: &wire.MsgTx{ + Version: 2, + LockTime: 800_000, + TxIn: []*wire.TxIn{ + { + PreviousOutPoint: op1, + }, + { + PreviousOutPoint: op2, + }, + }, + TxOut: []*wire.TxOut{ + { + Value: 2999841, + PkScript: batchPkScript, + }, + }, + }, + wantWeight: 626, + wantFeeForWeight: 159, + wantFee: 159, + }, + } + + for _, tc := range cases { + 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) + } + }) + } +} diff --git a/sweepbatcher/sweep_batcher.go b/sweepbatcher/sweep_batcher.go index 908e88896..03b697b19 100644 --- a/sweepbatcher/sweep_batcher.go +++ b/sweepbatcher/sweep_batcher.go @@ -132,6 +132,11 @@ type SweepInfo struct { // has to be spent using preimage. This is only used in fee estimations // when selecting a batch for the sweep to minimize fees. NonCoopHint bool + + // IsPresigned stores if presigned mode is enabled for the sweep. This + // value should be stable for a sweep. Currently presigned and + // non-presigned sweeps never appear in the same batch. + IsPresigned bool } // SweepFetcher is used to get details of a sweep. @@ -156,6 +161,51 @@ 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 { + // 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. The implementation should + // first check if this transaction already exists in the store to skip + // cosigning if possible. + Presign(ctx context.Context, primarySweepID wire.OutPoint, + tx *wire.MsgTx, inputAmt btcutil.Amount) error + + // DestPkScript returns destination pkScript used by the sweep batch + // with the primary outpoint specified. Returns an error, if such tx + // doesn't exist. If there are many such transactions, returns any of + // pkScript's; all of them should have the same destination pkScript. + DestPkScript(ctx context.Context, + primarySweepID 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; + // - witness must not be empty; + // - 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 loadOnly is set, it + // doesn't try to sign the transaction and only loads a presigned tx. + SignTx(ctx context.Context, primarySweepID wire.OutPoint, + tx *wire.MsgTx, inputAmt btcutil.Amount, + minRelayFee, feeRate chainfee.SatPerKWeight, + loadOnly 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 @@ -222,6 +272,26 @@ type SweepRequest struct { Notifier *SpendNotifier } +// addSweepsRequest is a request to sweep an outpoint or a group of outpoints +// that is used internally by the batcher (between AddSweep and handleSweeps). +type addSweepsRequest struct { + // sweeps is the list of sweeps already loaded from DB and fee rate + // source. + sweeps []*sweep + + // Notifier is a notifier that is used to notify the requester of this + // sweep that the sweep was successful. + notifier *SpendNotifier + + // completed is set if the sweep is spent and the spending transaction + // is confirmed. + completed bool + + // parentBatch is the parent batch of this sweep. It is loaded ony if + // completed is true. + parentBatch *dbBatch +} + type SpendDetail struct { // Tx is the transaction that spent the outpoint. Tx *wire.MsgTx @@ -266,8 +336,8 @@ type Batcher struct { // batches is a map of batch IDs to the currently active batches. batches map[int32]*batch - // sweepReqs is a channel where sweep requests are received. - sweepReqs chan SweepRequest + // addSweepsChan is a channel where sweep requests are received. + addSweepsChan chan *addSweepsRequest // testReqs is a channel where test requests are received. // This is used only in unit tests! The reason to have this is to @@ -354,6 +424,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. @@ -394,6 +468,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. @@ -467,6 +545,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 +// PresignSweepsGroup 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, @@ -501,7 +588,7 @@ func NewBatcher(wallet lndclient.WalletKitClient, return &Batcher{ batches: make(map[int32]*batch), - sweepReqs: make(chan SweepRequest), + addSweepsChan: make(chan *addSweepsRequest), testReqs: make(chan *testRequest), errChan: make(chan error, 1), quit: make(chan struct{}), @@ -521,6 +608,7 @@ func NewBatcher(wallet lndclient.WalletKitClient, txLabeler: cfg.txLabeler, customMuSig2Signer: cfg.customMuSig2Signer, publishErrorHandler: cfg.publishErrorHandler, + presignedHelper: cfg.presignedHelper, } } @@ -557,15 +645,11 @@ func (b *Batcher) Run(ctx context.Context) error { for { select { - case sweepReq := <-b.sweepReqs: - sweeps, err := b.fetchSweeps(runCtx, sweepReq) - if err != nil { - warnf("fetchSweeps failed: %v.", err) - - return err - } - - err = b.handleSweeps(runCtx, sweeps, sweepReq.Notifier) + case req := <-b.addSweepsChan: + err = b.handleSweeps( + runCtx, req.sweeps, req.notifier, req.completed, + req.parentBatch, + ) if err != nil { warnf("handleSweeps failed: %v.", err) @@ -589,11 +673,125 @@ func (b *Batcher) Run(ctx context.Context) error { } } -// 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. -func (b *Batcher) AddSweep(sweepReq *SweepRequest) error { +// PresignSweepsGroup creates and stores presigned transactions for the sweeps +// group. This method must be called prior to AddSweep if presigned mode is +// enabled, otherwise AddSweep will fail. All the sweeps must belong to the same +// swap. The order of sweeps is important. The first sweep serves as +// primarySweepID if the group starts a new batch. +func (b *Batcher) PresignSweepsGroup(ctx context.Context, inputs []Input, + sweepTimeout int32, destAddress btcutil.Address) error { + + if len(inputs) == 0 { + return fmt.Errorf("no inputs passed to PresignSweepsGroup") + } + 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("PresignSweepsGroup: nextBlockFeeRate is %v", nextBlockFeeRate) + + sweeps := make([]sweep, len(inputs)) + for i, input := range inputs { + sweeps[i] = sweep{ + outpoint: input.Outpoint, + value: input.Value, + timeout: sweepTimeout, + } + } + + // The sweeps are ordered inside the group, the first one is the primary + // outpoint in the batch. + primarySweepID := sweeps[0].outpoint + + return presign( + ctx, b.presignedHelper, destAddress, primarySweepID, sweeps, + nextBlockFeeRate, + ) +} + +// AddSweep loads information about sweeps from the store and fee rate source, +// and adds them to the batcher for handling. This will either place the sweep +// in an existing batch or create a new one. The method can be called multiple +// times, but the sweeps (including the order of them) must be the same. If +// notifier is provided, the batcher sends back sweeping results through it. +func (b *Batcher) AddSweep(ctx context.Context, sweepReq *SweepRequest) error { + // If the batcher is shutting down, quit now. select { - case b.sweepReqs <- *sweepReq: + case <-b.quit: + return ErrBatcherShuttingDown + + default: + } + + sweeps, err := b.fetchSweeps(ctx, *sweepReq) + if err != nil { + return fmt.Errorf("fetchSweeps failed: %w", err) + } + + if len(sweeps) == 0 { + return fmt.Errorf("trying to add an empty group of sweeps") + } + + // Since the whole group is added to the same batch and belongs to + // the same transaction, we use sweeps[0] below where we need any sweep. + sweep := sweeps[0] + + completed, err := b.store.GetSweepStatus(ctx, sweep.outpoint) + if err != nil { + return fmt.Errorf("failed to get the status of sweep %v: %w", + sweep.outpoint, err) + } + var ( + parentBatch *dbBatch + fullyConfirmed bool + ) + if completed { + // Verify that the parent batch is confirmed. Note that a batch + // is only considered confirmed after it has received three + // on-chain confirmations to prevent issues caused by reorgs. + parentBatch, err = b.store.GetParentBatch(ctx, sweep.outpoint) + if err != nil { + return fmt.Errorf("unable to get parent batch for "+ + "sweep %x: %w", sweep.swapHash[:6], err) + } + + if parentBatch.State == batchConfirmed { + fullyConfirmed = true + } + } + + // If this is a presigned mode, make sure PresignSweepsGroup was called. + // We skip the check for fully confirmed sweeps, because their presigned + // transactions were already cleaned up from the store. + if sweep.presigned && !fullyConfirmed { + err := ensurePresigned( + ctx, sweeps, b.presignedHelper, b.chainParams, + ) + if err != nil { + return fmt.Errorf("inputs with primarySweep %v were "+ + "not presigned (call PresignSweepsGroup "+ + "first): %w", sweep.outpoint, err) + } + } + + infof("Batcher adding sweep group of %d sweeps with primarySweep %x, "+ + "presigned=%v, completed=%v", len(sweeps), sweep.swapHash[:6], + sweep.presigned, completed) + + req := &addSweepsRequest{ + sweeps: sweeps, + notifier: sweepReq.Notifier, + completed: completed, + parentBatch: parentBatch, + } + + select { + case b.addSweepsChan <- req: return nil case <-b.quit: @@ -634,39 +832,16 @@ func (b *Batcher) testRunInEventLoop(ctx context.Context, handler func()) { // handleSweeps handles a sweep request by either placing the group of sweeps in // an existing batch, or by spinning up a new batch for it. func (b *Batcher) handleSweeps(ctx context.Context, sweeps []*sweep, - notifier *SpendNotifier) error { - - if len(sweeps) == 0 { - return fmt.Errorf("trying to add an empty group of sweeps") - } + notifier *SpendNotifier, completed bool, parentBatch *dbBatch) error { // Since the whole group is added to the same batch and belongs to // the same transaction, we use sweeps[0] below where we need any sweep. sweep := sweeps[0] - completed, err := b.store.GetSweepStatus(ctx, sweep.outpoint) - if err != nil { - return err - } - - infof("Batcher handling sweep %x, completed=%v", - sweep.swapHash[:6], 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. // Instead we directly detect and return the spend here. if completed && *notifier != (SpendNotifier{}) { - // Verify that the parent batch is confirmed. Note that a batch - // is only considered confirmed after it has received three - // on-chain confirmations to prevent issues caused by reorgs. - parentBatch, err := b.store.GetParentBatch(ctx, sweep.outpoint) - if err != nil { - errorf("unable to get parent batch for sweep %x:"+ - " %v", sweep.swapHash[:6], err) - - return err - } - // The parent batch is indeed confirmed, meaning it is complete // and we won't be able to attach this sweep to it. if parentBatch.State == batchConfirmed { @@ -707,7 +882,7 @@ func (b *Batcher) handleSweeps(ctx context.Context, sweeps []*sweep, } // Try to run the greedy algorithm of batch selection to minimize costs. - err = b.greedyAddSweeps(ctx, sweeps) + err := b.greedyAddSweeps(ctx, sweeps) if err == nil { // The greedy algorithm succeeded. return nil @@ -735,7 +910,9 @@ func (b *Batcher) handleSweeps(ctx context.Context, sweeps []*sweep, return b.spinUpNewBatch(ctx, sweeps) } -// spinUpNewBatch creates new batch, starts it and adds the sweeps to it. +// spinUpNewBatch creates new batch, starts it and adds the sweeps to it. If +// presigned mode is enabled, the result also depends on outcome of +// presignedHelper.Presign. func (b *Batcher) spinUpNewBatch(ctx context.Context, sweeps []*sweep) error { // Spin up a fresh batch. newBatch, err := b.spinUpBatch(ctx) @@ -1190,6 +1367,7 @@ func (b *Batcher) loadSweep(ctx context.Context, swapHash lntypes.Hash, destAddr: s.DestAddr, minFeeRate: minFeeRate, nonCoopHint: s.NonCoopHint, + presigned: s.IsPresigned, }, nil } @@ -1200,14 +1378,15 @@ 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, } } // newBatchKit creates new batch kit. func (b *Batcher) newBatchKit() batchKit { return batchKit{ - returnChan: b.sweepReqs, wallet: b.wallet, chainNotifier: b.chainNotifier, signerClient: b.signerClient, diff --git a/sweepbatcher/sweep_batcher_presigned_test.go b/sweepbatcher/sweep_batcher_presigned_test.go new file mode 100644 index 000000000..86f626cbc --- /dev/null +++ b/sweepbatcher/sweep_batcher_presigned_test.go @@ -0,0 +1,1685 @@ +package sweepbatcher + +import ( + "context" + "fmt" + "os" + "sync" + "testing" + + "github.com/btcsuite/btcd/blockchain" + "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/loop/loopdb" + "github.com/lightninglabs/loop/test" + "github.com/lightningnetwork/lnd/chainntnfs" + "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// 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 + + // presignedBatches is the collection of presigned batches. The key is + // primarySweepID. + presignedBatches map[wire.OutPoint][]*wire.MsgTx + + // mu should be hold by all the public methods of this type. + mu sync.Mutex + + // cleanupCalled is a channel where an element is sent every time + // CleanupTransactions is called. + cleanupCalled chan struct{} +} + +// newMockPresignedHelper returns new instance of mockPresignedHelper. +func newMockPresignedHelper() *mockPresignedHelper { + return &mockPresignedHelper{ + onlineOutpoints: make(map[wire.OutPoint]bool), + presignedBatches: make(map[wire.OutPoint][]*wire.MsgTx), + cleanupCalled: make(chan struct{}), + } +} + +// SetOutpointOnline changes the online status of an outpoint. +func (h *mockPresignedHelper) SetOutpointOnline(op wire.OutPoint, online bool) { + h.mu.Lock() + defer h.mu.Unlock() + + h.onlineOutpoints[op] = online +} + +// 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) + } + } + + return offline +} + +// 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), + } + } +} + +// getTxFeerate returns fee rate of a transaction. +func (h *mockPresignedHelper) getTxFeerate(tx *wire.MsgTx, + inputAmt btcutil.Amount) chainfee.SatPerKWeight { + + // "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) + + return chainfee.NewSatPerKWeight(fee, weight) +} + +// 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, + primarySweepID wire.OutPoint, tx *wire.MsgTx, + inputAmt btcutil.Amount) error { + + h.mu.Lock() + defer h.mu.Unlock() + + // Check if such a transaction already exists. This is not only an + // optimization, but also enables re-adding multiple groups if sweeps + // are offline. + wantTxHash := tx.TxHash() + for _, candidate := range h.presignedBatches[primarySweepID] { + if candidate.TxHash() == wantTxHash { + return nil + } + } + + if !hasInput(tx, primarySweepID) { + return fmt.Errorf("primarySweepID %v not in tx", primarySweepID) + } + + 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[primarySweepID] = append( + h.presignedBatches[primarySweepID], tx, + ) + + return nil +} + +// DestPkScript returns destination pkScript used in presigned tx sweeping +// these inputs. +func (h *mockPresignedHelper) DestPkScript(ctx context.Context, + primarySweepID wire.OutPoint) ([]byte, error) { + + h.mu.Lock() + defer h.mu.Unlock() + + for _, tx := range h.presignedBatches[primarySweepID] { + return tx.TxOut[0].PkScript, nil + } + + return nil, fmt.Errorf("tx with primarySweepID %v not found", + primarySweepID) +} + +// 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, + primarySweepID wire.OutPoint, tx *wire.MsgTx, inputAmt btcutil.Amount, + minRelayFee, feeRate chainfee.SatPerKWeight, + loadOnly bool) (*wire.MsgTx, error) { + + h.mu.Lock() + defer h.mu.Unlock() + + // If all the inputs are online and loadOnly is not set, sign this exact + // transaction. + if offline := h.offlineInputs(tx); len(offline) == 0 && !loadOnly { + tx = tx.Copy() + h.sign(tx) + + // Add to the collection. + h.presignedBatches[primarySweepID] = append( + h.presignedBatches[primarySweepID], 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[primarySweepID] { + 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 +} + +// 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() + + for _, primarySweepID := range inputs { + delete(h.presignedBatches, primarySweepID) + } + + h.cleanupCalled <- struct{}{} + + return nil +} + +// sweepTimeout is swap timeout block height used in tests of presigned mode. +const sweepTimeout = 1000 + +// FetchSweep returns blank SweepInfo. +// This method implements SweepFetcher interface. +func (h *mockPresignedHelper) FetchSweep(_ context.Context, + _ lntypes.Hash, utxo wire.OutPoint) (*SweepInfo, error) { + + h.mu.Lock() + defer h.mu.Unlock() + + _, has := h.onlineOutpoints[utxo] + + return &SweepInfo{ + // Set Timeout to prevent warning messages about timeout=0. + Timeout: sweepTimeout, + + IsPresigned: has, + }, nil +} + +// testPresigned_forgotten_presign checks that adding sweeps causes the batcher +// to fail if the sweeps were not presigned with PresignSweepsGroup. In addition +// to that it checks that PresignSweepsGroup fails if the outpoint is offline. +func testPresigned_forgotten_presign(t *testing.T, + batcherStore testBatcherStore) { + + defer test.Guard(t)() + + lnd := test.NewMockLnd() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + customFeeRate := func(_ context.Context, + _ lntypes.Hash) (chainfee.SatPerKWeight, error) { + + return chainfee.SatPerKWeight(10_000), nil + } + + presignedHelper := newMockPresignedHelper() + + batcher := NewBatcher(lnd.WalletKit, lnd.ChainNotifier, lnd.Signer, + testMuSig2SignSweep, testVerifySchnorrSig, lnd.ChainParams, + batcherStore, presignedHelper, + WithCustomFeeRate(customFeeRate), + WithPresignedHelper(presignedHelper)) + + go func() { + err := batcher.Run(ctx) + checkBatcherError(t, err) + }() + + // Create the first sweep. + swapHash1 := lntypes.Hash{1, 1, 1} + op1 := wire.OutPoint{ + Hash: chainhash.Hash{1, 1}, + Index: 1, + } + sweepReq1 := SweepRequest{ + SwapHash: swapHash1, + Inputs: []Input{{ + Value: 1_000_000, + Outpoint: op1, + }}, + Notifier: &dummyNotifier, + } + + // This should fail, because the input is offline. + presignedHelper.SetOutpointOnline(op1, false) + err := batcher.PresignSweepsGroup( + ctx, []Input{{Outpoint: op1, Value: 1_000_000}}, + sweepTimeout, destAddr, + ) + require.Error(t, err) + require.ErrorContains(t, err, "offline") + + // Make sure that the batcher crashes if AddSweep is called before + // PresignSweepsGroup even if the input is online. + presignedHelper.SetOutpointOnline(op1, true) + err = batcher.AddSweep(ctx, &sweepReq1) + require.ErrorContains(t, err, "were not presigned") +} + +// 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, presignedHelper, + 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, + Inputs: []Input{{ + Value: 1_000_000, + Outpoint: op1, + }}, + Notifier: &dummyNotifier, + } + + // Enable the input and presign. + presignedHelper.SetOutpointOnline(op1, true) + err = batcher.PresignSweepsGroup( + ctx, []Input{{Outpoint: op1, Value: 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(ctx, &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(988618), 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(ctx, &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, + Inputs: []Input{{ + Value: 2_000_000, + Outpoint: op2, + }}, + Notifier: &dummyNotifier, + } + presignedHelper.SetOutpointOnline(op2, true) + err = batcher.PresignSweepsGroup( + ctx, []Input{{Outpoint: op2, Value: 2_000_000}}, + sweepTimeout, destAddr, + ) + require.NoError(t, err) + + // Deliver sweep request to batcher. + require.NoError(t, batcher.AddSweep(ctx, &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: &op1, + 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 loadOnly = true + _, err = presignedHelper.SignTx( + ctx, op2, batch2, 2_000_000, chainfee.FeePerKwFloor, + chainfee.FeePerKwFloor, loadOnly, + ) + 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, presignedHelper, + 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, + Inputs: []Input{{ + Value: 1_000_000, + Outpoint: op1, + }}, + Notifier: &dummyNotifier, + } + presignedHelper.SetOutpointOnline(op1, true) + err = batcher.PresignSweepsGroup( + ctx, []Input{{Outpoint: op1, Value: 1_000_000}}, + sweepTimeout, destAddr, + ) + require.NoError(t, err) + require.NoError(t, batcher.AddSweep(ctx, &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, + Inputs: []Input{{ + Value: 2_000_000, + Outpoint: op2, + }}, + Notifier: &dummyNotifier, + } + presignedHelper.SetOutpointOnline(op2, true) + err = batcher.PresignSweepsGroup( + ctx, []Input{{Outpoint: op2, Value: 2_000_000}}, + sweepTimeout, destAddr, + ) + require.NoError(t, err) + require.NoError(t, batcher.AddSweep(ctx, &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(ctx, &sweepReq1)) + require.NoError(t, batcher.AddSweep(ctx, &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(2982007), 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, presignedHelper, + 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, + Inputs: []Input{{ + Value: 1_000_000, + Outpoint: op1, + }}, + Notifier: &dummyNotifier, + } + presignedHelper.SetOutpointOnline(op1, true) + err = batcher.PresignSweepsGroup( + ctx, []Input{{Outpoint: op1, Value: 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(ctx, &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(ctx, &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, presignedHelper, + 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, + Inputs: []Input{{ + Value: 1_000_000, + Outpoint: op1, + }}, + Notifier: &dummyNotifier, + } + presignedHelper.SetOutpointOnline(op1, true) + err = batcher.PresignSweepsGroup( + ctx, []Input{{Outpoint: op1, Value: 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(ctx, &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(966015), 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(824648), tx2.TxOut[0].Value) +} + +// testPresigned_presigned_group tests passing multiple sweeps to the method +// PresignSweepsGroup. +func testPresigned_presigned_group(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() + + customFeeRate := func(_ context.Context, + _ lntypes.Hash) (chainfee.SatPerKWeight, error) { + + return chainfee.SatPerKWeight(10_000), nil + } + + presignedHelper := newMockPresignedHelper() + + batcher := NewBatcher( + lnd.WalletKit, lnd.ChainNotifier, lnd.Signer, + testMuSig2SignSweep, testVerifySchnorrSig, lnd.ChainParams, + batcherStore, presignedHelper, + WithCustomFeeRate(customFeeRate), + WithPresignedHelper(presignedHelper), + ) + + go func() { + err := batcher.Run(ctx) + checkBatcherError(t, err) + }() + + // Create a swap of two sweeps. + swapHash1 := lntypes.Hash{1, 1, 1} + op1 := wire.OutPoint{ + Hash: chainhash.Hash{1, 1}, + Index: 1, + } + op2 := wire.OutPoint{ + Hash: chainhash.Hash{2, 2}, + Index: 2, + } + group1 := []Input{ + { + Outpoint: op1, + Value: 1_000_000, + }, + { + Outpoint: op2, + Value: 2_000_000, + }, + } + + // Enable only one of the sweeps. + presignedHelper.SetOutpointOnline(op1, true) + presignedHelper.SetOutpointOnline(op2, false) + + // An attempt to presign must fail. + err = batcher.PresignSweepsGroup(ctx, group1, sweepTimeout, destAddr) + require.ErrorContains(t, err, "some inputs of tx are offline") + + // Enable both outpoints. + presignedHelper.SetOutpointOnline(op2, true) + + // An attempt to presign must succeed. + err = batcher.PresignSweepsGroup(ctx, group1, sweepTimeout, destAddr) + require.NoError(t, err) + + // Add the sweep, triggering the publish attempt. + require.NoError(t, batcher.AddSweep(ctx, &SweepRequest{ + SwapHash: swapHash1, + Inputs: group1, + Notifier: &dummyNotifier, + })) + + // 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, 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) + + // Add another group of sweeps. + swapHash2 := lntypes.Hash{2, 2, 2} + op3 := wire.OutPoint{ + Hash: chainhash.Hash{3, 3}, + Index: 3, + } + op4 := wire.OutPoint{ + Hash: chainhash.Hash{4, 4}, + Index: 4, + } + group2 := []Input{ + { + Outpoint: op3, + Value: 3_000_000, + }, + { + Outpoint: op4, + Value: 4_000_000, + }, + } + presignedHelper.SetOutpointOnline(op3, true) + presignedHelper.SetOutpointOnline(op4, true) + + // An attempt to presign must succeed. + err = batcher.PresignSweepsGroup(ctx, group2, sweepTimeout, destAddr) + require.NoError(t, err) + + // Add the sweep. It should go to the same batch. + require.NoError(t, batcher.AddSweep(ctx, &SweepRequest{ + SwapHash: swapHash2, + Inputs: group2, + Notifier: &dummyNotifier, + })) + + // Mine a blocks to trigger republishing. + require.NoError(t, lnd.NotifyHeight(601)) + + tx = <-lnd.TxPublishChannel + require.Len(t, tx.TxIn, 4) + require.Len(t, tx.TxOut, 1) + require.ElementsMatch( + t, []wire.OutPoint{op1, op2, op3, op4}, + []wire.OutPoint{ + tx.TxIn[0].PreviousOutPoint, + tx.TxIn[1].PreviousOutPoint, + tx.TxIn[2].PreviousOutPoint, + tx.TxIn[3].PreviousOutPoint, + }, + ) + require.Equal(t, int64(9989140), tx.TxOut[0].Value) + require.Equal(t, batchPkScript, tx.TxOut[0].PkScript) + + // Turn off one of existing outpoints and add another group. + presignedHelper.SetOutpointOnline(op1, false) + + swapHash3 := lntypes.Hash{3, 3, 3} + op5 := wire.OutPoint{ + Hash: chainhash.Hash{5, 5}, + Index: 5, + } + op6 := wire.OutPoint{ + Hash: chainhash.Hash{6, 6}, + Index: 6, + } + group3 := []Input{ + { + Outpoint: op5, + Value: 5_000_000, + }, + { + Outpoint: op6, + Value: 6_000_000, + }, + } + presignedHelper.SetOutpointOnline(op5, true) + presignedHelper.SetOutpointOnline(op6, true) + + // An attempt to presign must succeed. + err = batcher.PresignSweepsGroup(ctx, group3, sweepTimeout, destAddr) + require.NoError(t, err) + + // Add the sweep. It should go to the same batch. + require.NoError(t, batcher.AddSweep(ctx, &SweepRequest{ + SwapHash: swapHash3, + Inputs: group3, + Notifier: &dummyNotifier, + })) + + // Since a batch was created we check that it registered for its primary + // sweep's spend. + <-lnd.RegisterSpendChannel + + tx = <-lnd.TxPublishChannel + require.Len(t, tx.TxIn, 2) + require.Len(t, tx.TxOut, 1) + require.ElementsMatch( + t, []wire.OutPoint{op5, op6}, + []wire.OutPoint{ + tx.TxIn[0].PreviousOutPoint, + tx.TxIn[1].PreviousOutPoint, + }, + ) + require.Equal(t, int64(10993740), tx.TxOut[0].Value) + require.Equal(t, batchPkScript, tx.TxOut[0].PkScript) +} + +// wrappedStoreWithPresignedFlag wraps a SweepFetcher store adding IsPresigned +// flag to the returned sweeps, taking it from mockPresignedHelper. +type wrappedStoreWithPresignedFlag struct { + backend SweepFetcher + helper *mockPresignedHelper +} + +// // FetchSweep returns details of the sweep. +func (s *wrappedStoreWithPresignedFlag) FetchSweep(ctx context.Context, + swap lntypes.Hash, utxo wire.OutPoint) (*SweepInfo, error) { + + sweepInfo, err := s.backend.FetchSweep(ctx, swap, utxo) + if err != nil { + return nil, err + } + + // Attach IsPresigned flag. + s.helper.mu.Lock() + defer s.helper.mu.Unlock() + _, sweepInfo.IsPresigned = s.helper.onlineOutpoints[utxo] + + return sweepInfo, nil +} + +// 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) + + sweepFetcher := &wrappedStoreWithPresignedFlag{ + backend: sweepStore, + helper: presignedHelper, + } + + batcher := NewBatcher( + lnd.WalletKit, lnd.ChainNotifier, lnd.Signer, + testMuSig2SignSweep, testVerifySchnorrSig, lnd.ChainParams, + batcherStore, sweepFetcher, + 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, + Inputs: []Input{{ + 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(ctx, &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, + Inputs: []Input{{ + Value: 2_000_000, + Outpoint: op2, + }}, + Notifier: &dummyNotifier, + } + presignedHelper.SetOutpointOnline(op2, true) + err = batcher.PresignSweepsGroup( + ctx, []Input{{Outpoint: op2, Value: 2_000_000}}, + sweepTimeout, destAddr, + ) + require.NoError(t, err) + require.NoError(t, batcher.AddSweep(ctx, &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, + Inputs: []Input{{ + 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(ctx, &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, + Inputs: []Input{{ + Value: 3_000_000, + Outpoint: op4, + }}, + Notifier: &dummyNotifier, + } + presignedHelper.SetOutpointOnline(op4, true) + err = batcher.PresignSweepsGroup( + ctx, []Input{{Outpoint: op4, Value: 3_000_000}}, + sweepTimeout, destAddr, + ) + require.NoError(t, err) + require.NoError(t, batcher.AddSweep(ctx, &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) +} + +// testPresigned_purging tests what happens if a non-final version of the batch +// is confirmed. Missing sweeps may be online or offline at that moment, which +// depends on the last argument of the function. In online case they are added +// to another online batch. In offline case they must are added to a new batch +// having valid presigned transactions. +func testPresigned_purging(t *testing.T, numSwaps, numConfirmedSwaps int, + store testStore, batcherStore testBatcherStore, online bool) { + + defer test.Guard(t)() + + require.LessOrEqual(t, numConfirmedSwaps, numSwaps) + + const sweepsPerSwap = 2 + + lnd := test.NewMockLnd() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + customFeeRate := func(_ context.Context, + _ lntypes.Hash) (chainfee.SatPerKWeight, error) { + + return chainfee.SatPerKWeight(10_000), nil + } + + presignedHelper := newMockPresignedHelper() + + batcher := NewBatcher( + lnd.WalletKit, lnd.ChainNotifier, lnd.Signer, + testMuSig2SignSweep, testVerifySchnorrSig, lnd.ChainParams, + batcherStore, presignedHelper, + WithCustomFeeRate(customFeeRate), + WithPresignedHelper(presignedHelper), + ) + + go func() { + err := batcher.Run(ctx) + checkBatcherError(t, err) + }() + + txs := make([]*wire.MsgTx, numSwaps) + allOps := make([]wire.OutPoint, 0, numSwaps*sweepsPerSwap) + + for i := range numSwaps { + // Create a swap of sweepsPerSwap sweeps. + swapHash := lntypes.Hash{byte(i + 1)} + ops := make([]wire.OutPoint, sweepsPerSwap) + group := make([]Input, sweepsPerSwap) + for j := range sweepsPerSwap { + ops[j] = wire.OutPoint{ + Hash: chainhash.Hash{byte(1 + i*2 + j)}, + Index: uint32(1 + i*2 + j), + } + allOps = append(allOps, ops[j]) + + group[j] = Input{ + Outpoint: ops[j], + Value: btcutil.Amount(1_000_000 * (j + 1)), + } + } + + // Create a swap in DB. + swap := &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{byte(i + 1)}, + }, + + DestAddr: destAddr, + SwapInvoice: swapInvoice, + SweepConfTarget: 111, + } + err := store.CreateLoopOut(ctx, swapHash, swap) + require.NoError(t, err) + store.AssertLoopOutStored() + + // Enable all the sweeps. + for _, op := range ops { + presignedHelper.SetOutpointOnline(op, true) + } + + // An attempt to presign must succeed. + err = batcher.PresignSweepsGroup( + ctx, group, sweepTimeout, destAddr, + ) + require.NoError(t, err) + + // Add the sweep, triggering the publish attempt. + require.NoError(t, batcher.AddSweep(ctx, &SweepRequest{ + SwapHash: swapHash, + Inputs: group, + Notifier: &dummyNotifier, + })) + + // For the first group it should register for the sweep's spend + // and publish a transaction. + if i == 0 { + <-lnd.RegisterSpendChannel + } else { + // Trigger transaction publishing after each group. + require.NoError(t, lnd.NotifyHeight(int32(601+i))) + } + + // Wait for a transactions to be published. + tx := <-lnd.TxPublishChannel + txs[i] = tx + } + + // Record batch ID of the first batch. + batch1id := getOnlyBatch(t, ctx, batcher).id + + // Turn off all the sweeps. + for _, op := range allOps { + presignedHelper.SetOutpointOnline(op, false) + } + + // In case we are testing the addition of the remaining sweeps to a + // batch in online state, we need to create that batch now. + opx := wire.OutPoint{Hash: chainhash.Hash{3, 2, 1}, Index: 1} + if online && numConfirmedSwaps < numSwaps { + swapHash := lntypes.Hash{1, 2, 3} + const amount = 1_234_567 + group := []Input{ + { + Outpoint: opx, + Value: amount, + }, + } + + // Create a swap in DB. + swap := &loopdb.LoopOutContract{ + SwapContract: loopdb.SwapContract{ + CltvExpiry: 111, + AmountRequested: amount, + ProtocolVersion: loopdb.ProtocolVersionMuSig2, + HtlcKeys: htlcKeys, + + // Make preimage unique to pass SQL constraints. + Preimage: lntypes.Preimage{1, 2, 3}, + }, + + DestAddr: destAddr, + SwapInvoice: swapInvoice, + SweepConfTarget: 111, + } + err := store.CreateLoopOut(ctx, swapHash, swap) + require.NoError(t, err) + store.AssertLoopOutStored() + + // Enable the sweep. + presignedHelper.SetOutpointOnline(opx, true) + + // An attempt to presign must succeed. + err = batcher.PresignSweepsGroup( + ctx, group, sweepTimeout, destAddr, + ) + require.NoError(t, err) + + // Add the sweep, triggering the publish attempt. + require.NoError(t, batcher.AddSweep(ctx, &SweepRequest{ + SwapHash: swapHash, + Inputs: group, + Notifier: &dummyNotifier, + })) + + <-lnd.RegisterSpendChannel + tx := <-lnd.TxPublishChannel + require.Len(t, tx.TxIn, 1) + require.Equal(t, opx, tx.TxIn[0].PreviousOutPoint) + + // Now enable our main sweeps again so the remaining ones are + // added to this new batch. + for _, op := range allOps { + presignedHelper.SetOutpointOnline(op, true) + } + } + + // Now mine the transaction which includes first numConfirmedSwaps. + tx := txs[numConfirmedSwaps-1] + + // Now confirm previously broadcasted transaction (op1 and op2). + txHash := tx.TxHash() + spendDetail := &chainntnfs.SpendDetail{ + SpentOutPoint: &allOps[0], + SpendingTx: tx, + SpenderTxHash: &txHash, + SpenderInputIndex: 0, + SpendingHeight: int32(601 + numSwaps + 1), + } + lnd.SpendChannel <- spendDetail + <-lnd.RegisterConfChannel + require.NoError(t, lnd.NotifyHeight( + int32(601+numSwaps+1+batchConfHeight), + )) + lnd.ConfChannel <- &chainntnfs.TxConfirmation{ + Tx: tx, + } + + // CleanupTransactions is called here. + <-presignedHelper.cleanupCalled + + // If all the swaps were confirmed, stop. + if numConfirmedSwaps == numSwaps { + return + } + + if !online { + // If the sweeps are offline, the missing sweeps in the + // confirmed transaction should be re-added to the batcher as + // new batch. The groups are added incrementally, so we need + // to wait until the batch reaches the expected size. + <-lnd.RegisterSpendChannel + <-lnd.TxPublishChannel + } + + // Wait to new batch to appear and to have the expected size. + wantSize := (numSwaps - numConfirmedSwaps) * sweepsPerSwap + if online { + // Add opx to the list of expected inputs. + wantSize++ + } + require.Eventually(t, func() bool { + // Wait for a batch with new ID to appear. + batches := getBatches(ctx, batcher) + var batch2 *batch + for _, b := range batches { + if b.id != batch1id { + batch2 = b + } + } + if batch2 == nil { + return false + } + + // Check the size of the second batch. + return batch2.numSweeps(ctx) == wantSize + }, test.Timeout, eventuallyCheckFrequency) + + // Now trigger batch publishing and inspect the published tx. + require.NoError(t, lnd.NotifyHeight(int32( + 601+numSwaps+1+batchConfHeight+1, + ))) + tx2 := <-lnd.TxPublishChannel + wantOps := allOps[numConfirmedSwaps*sweepsPerSwap:] + if online { + // Deep copy wantOps to unlink from allOps. + wantOps = append([]wire.OutPoint{}, wantOps...) + wantOps = append(wantOps, opx) + } + gotOps := make([]wire.OutPoint, 0, len(tx2.TxIn)) + for _, txIn := range tx2.TxIn { + gotOps = append(gotOps, txIn.PreviousOutPoint) + } + + require.ElementsMatch(t, wantOps, gotOps) +} + +// TestPresigned tests presigned mode. Most sub-tests doesn't use loopdb. +func TestPresigned(t *testing.T) { + logger := btclog.NewSLogger(btclog.NewDefaultHandler(os.Stdout)) + logger.SetLevel(btclog.LevelTrace) + UseLogger(logger.SubSystem("SWEEP")) + + t.Run("forgotten_presign", func(t *testing.T) { + testPresigned_forgotten_presign(t, NewStoreMock()) + }) + + 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_group", func(t *testing.T) { + testPresigned_presigned_group(t, NewStoreMock()) + }) + + t.Run("presigned_and_regular_sweeps", func(t *testing.T) { + runTests(t, testPresigned_presigned_and_regular_sweeps) + }) + + t.Run("purging", func(t *testing.T) { + testPurging := func(numSwaps, numConfirmedSwaps int, + online bool) { + + name := fmt.Sprintf("%d of %d swaps confirmed", + numConfirmedSwaps, numSwaps) + if online { + name += ", sweeps online" + } else { + name += ", sweeps offline" + } + + t.Run(name, func(t *testing.T) { + runTests(t, func(t *testing.T, store testStore, + batcherStore testBatcherStore) { + + testPresigned_purging( + t, numSwaps, numConfirmedSwaps, + store, batcherStore, online, + ) + }) + }) + } + + // Test cases in which the sweeps are offline. + testPurging(1, 1, false) + testPurging(2, 1, false) + testPurging(2, 2, false) + testPurging(3, 1, false) + testPurging(3, 2, false) + testPurging(5, 2, false) + + // Test cases in which the sweeps are online. + testPurging(2, 1, true) + testPurging(3, 1, true) + testPurging(3, 2, true) + testPurging(5, 2, true) + }) +} diff --git a/sweepbatcher/sweep_batcher_test.go b/sweepbatcher/sweep_batcher_test.go index 0fada5979..cd5bab01c 100644 --- a/sweepbatcher/sweep_batcher_test.go +++ b/sweepbatcher/sweep_batcher_test.go @@ -252,14 +252,14 @@ func testSweepBatcherBatchCreation(t *testing.T, store testStore, store.AssertLoopOutStored() // Deliver sweep request to batcher. - require.NoError(t, batcher.AddSweep(&sweepReq1)) + require.NoError(t, batcher.AddSweep(ctx, &sweepReq1)) // Since a batch was created we check that it registered for its primary // sweep's spend. <-lnd.RegisterSpendChannel // Insert the same swap twice, this should be a noop. - require.NoError(t, batcher.AddSweep(&sweepReq1)) + require.NoError(t, batcher.AddSweep(ctx, &sweepReq1)) // Once batcher receives sweep request it will eventually spin up a // batch. @@ -301,7 +301,7 @@ func testSweepBatcherBatchCreation(t *testing.T, store testStore, require.NoError(t, err) store.AssertLoopOutStored() - require.NoError(t, batcher.AddSweep(&sweepReq2)) + require.NoError(t, batcher.AddSweep(ctx, &sweepReq2)) // Tick tock next block. err = lnd.NotifyHeight(601) @@ -347,7 +347,7 @@ func testSweepBatcherBatchCreation(t *testing.T, store testStore, require.NoError(t, err) store.AssertLoopOutStored() - require.NoError(t, batcher.AddSweep(&sweepReq3)) + require.NoError(t, batcher.AddSweep(ctx, &sweepReq3)) // Since the second batch got created we check that it registered its // primary sweep's spend. @@ -457,7 +457,7 @@ func testFeeBumping(t *testing.T, store testStore, store.AssertLoopOutStored() // Deliver sweep request to batcher. - require.NoError(t, batcher.AddSweep(&sweepReq1)) + require.NoError(t, batcher.AddSweep(ctx, &sweepReq1)) // Since a batch was created we check that it registered for its primary // sweep's spend. @@ -562,7 +562,7 @@ func testTxLabeler(t *testing.T, store testStore, store.AssertLoopOutStored() // Deliver sweep request to batcher. - require.NoError(t, batcher.AddSweep(&sweepReq1)) + require.NoError(t, batcher.AddSweep(ctx, &sweepReq1)) // When batch is successfully created it will execute it's first step, // which leads to a spend monitor of the primary sweep. @@ -718,7 +718,7 @@ func testPublishErrorHandler(t *testing.T, store testStore, store.AssertLoopOutStored() // Deliver sweep request to batcher. - require.NoError(t, batcher.AddSweep(&sweepReq1)) + require.NoError(t, batcher.AddSweep(ctx, &sweepReq1)) // When batch is successfully created it will execute it's first step, // which leads to a spend monitor of the primary sweep. @@ -802,7 +802,7 @@ func testSweepBatcherSimpleLifecycle(t *testing.T, store testStore, store.AssertLoopOutStored() // Deliver sweep request to batcher. - require.NoError(t, batcher.AddSweep(&sweepReq1)) + require.NoError(t, batcher.AddSweep(ctx, &sweepReq1)) // When batch is successfully created it will execute it's first step, // which leads to a spend monitor of the primary sweep. @@ -918,6 +918,7 @@ type wrappedLogger struct { debugMessages []string infoMessages []string + warnMessages []string } // Debugf logs debug message. @@ -938,6 +939,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. @@ -1016,7 +1026,7 @@ func testDelays(t *testing.T, store testStore, batcherStore testBatcherStore) { store.AssertLoopOutStored() // Deliver sweep request to batcher. - require.NoError(t, batcher.AddSweep(&sweepReq)) + require.NoError(t, batcher.AddSweep(ctx, &sweepReq)) // Expect two timers to be set: initialDelay and publishDelay, // and RegisterSpend to be called. The order is not determined, @@ -1318,7 +1328,7 @@ func testDelays(t *testing.T, store testStore, batcherStore testBatcherStore) { store.AssertLoopOutStored() // Deliver sweep request to batcher. - require.NoError(t, batcher.AddSweep(&sweepReq2)) + require.NoError(t, batcher.AddSweep(ctx, &sweepReq2)) // Expect the sweep to be added to new batch. Expect two timers: // largeInitialDelay and publishDelay. RegisterSpend is called in @@ -1399,7 +1409,7 @@ func testDelays(t *testing.T, store testStore, batcherStore testBatcherStore) { store.AssertLoopOutStored() // Deliver sweep request to batcher. - require.NoError(t, batcher.AddSweep(&sweepReq3)) + require.NoError(t, batcher.AddSweep(ctx, &sweepReq3)) // Wait for sweep to be added to the batch. require.EventuallyWithT(t, func(c *assert.CollectT) { @@ -1532,7 +1542,7 @@ func testCustomDelays(t *testing.T, store testStore, store.AssertLoopOutStored() // Deliver sweep request to batcher. - require.NoError(t, batcher.AddSweep(&sweepReq1)) + require.NoError(t, batcher.AddSweep(ctx, &sweepReq1)) // Expect two timers to be set: initialDelay and publishDelay, // and RegisterSpend to be called. The order is not determined, @@ -1603,7 +1613,7 @@ func testCustomDelays(t *testing.T, store testStore, store.AssertLoopOutStored() // Deliver sweep request to batcher. - require.NoError(t, batcher.AddSweep(&sweepReq2)) + require.NoError(t, batcher.AddSweep(ctx, &sweepReq2)) // Expect timer for initialDelay2 to be registered, because // initialDelay2 is lower than initialDelay1, meaning that swap2 @@ -1752,7 +1762,7 @@ func testMaxSweepsPerBatch(t *testing.T, store testStore, store.AssertLoopOutStored() // Deliver sweep request to batcher. - require.NoError(t, batcher.AddSweep(&sweepReq)) + require.NoError(t, batcher.AddSweep(ctx, &sweepReq)) // If this is new batch, expect a spend registration. if i%MaxSweepsPerBatch == 0 { @@ -1931,7 +1941,7 @@ func testSweepBatcherSweepReentry(t *testing.T, store testStore, store.AssertLoopOutStored() // Feed the sweeps to the batcher. - require.NoError(t, batcher.AddSweep(&sweepReq1)) + require.NoError(t, batcher.AddSweep(ctx, &sweepReq1)) // After inserting the primary (first) sweep, a spend monitor should be // registered. @@ -1941,7 +1951,7 @@ func testSweepBatcherSweepReentry(t *testing.T, store testStore, <-lnd.TxPublishChannel // Add the second sweep. - require.NoError(t, batcher.AddSweep(&sweepReq2)) + require.NoError(t, batcher.AddSweep(ctx, &sweepReq2)) // Add next block to trigger batch publishing. err = lnd.NotifyHeight(601) @@ -1951,7 +1961,7 @@ func testSweepBatcherSweepReentry(t *testing.T, store testStore, <-lnd.TxPublishChannel // Add the third sweep. - require.NoError(t, batcher.AddSweep(&sweepReq3)) + require.NoError(t, batcher.AddSweep(ctx, &sweepReq3)) // Add next block to trigger batch publishing. err = lnd.NotifyHeight(602) @@ -2058,7 +2068,7 @@ func testSweepBatcherSweepReentry(t *testing.T, store testStore, // Re-add one of remaining sweeps to trigger removing the completed // batch from the batcher. - require.NoError(t, batcher.AddSweep(&sweepReq3)) + require.NoError(t, batcher.AddSweep(ctx, &sweepReq3)) // Eventually the batch receives the confirmation notification, // gracefully exits and the batcher deletes it. @@ -2144,7 +2154,7 @@ func testSweepBatcherGroup(t *testing.T, store testStore, }, Notifier: &dummyNotifier, } - require.NoError(t, batcher.AddSweep(&sweepReq)) + require.NoError(t, batcher.AddSweep(ctx, &sweepReq)) // After inserting the primary (first) sweep, a spend monitor should be // registered. @@ -2225,7 +2235,7 @@ func testSweepBatcherNonWalletAddr(t *testing.T, store testStore, store.AssertLoopOutStored() // Deliver sweep request to batcher. - require.NoError(t, batcher.AddSweep(&sweepReq1)) + require.NoError(t, batcher.AddSweep(ctx, &sweepReq1)) // Since a batch was created we check that it registered for its primary // sweep's spend. @@ -2241,7 +2251,7 @@ func testSweepBatcherNonWalletAddr(t *testing.T, store testStore, <-lnd.TxPublishChannel // Insert the same swap twice, this should be a noop. - require.NoError(t, batcher.AddSweep(&sweepReq1)) + require.NoError(t, batcher.AddSweep(ctx, &sweepReq1)) // Create a second sweep request that has a timeout distance less than // our configured threshold. @@ -2274,7 +2284,7 @@ func testSweepBatcherNonWalletAddr(t *testing.T, store testStore, require.NoError(t, err) store.AssertLoopOutStored() - require.NoError(t, batcher.AddSweep(&sweepReq2)) + require.NoError(t, batcher.AddSweep(ctx, &sweepReq2)) // Since a batch was created we check that it registered for its primary // sweep's spend. @@ -2320,7 +2330,7 @@ func testSweepBatcherNonWalletAddr(t *testing.T, store testStore, require.NoError(t, err) store.AssertLoopOutStored() - require.NoError(t, batcher.AddSweep(&sweepReq3)) + require.NoError(t, batcher.AddSweep(ctx, &sweepReq3)) // Since a batch was created we check that it registered for its primary // sweep's spend. @@ -2594,7 +2604,7 @@ func testSweepBatcherComposite(t *testing.T, store testStore, store.AssertLoopOutStored() // Deliver sweep request to batcher. - require.NoError(t, batcher.AddSweep(&sweepReq1)) + require.NoError(t, batcher.AddSweep(ctx, &sweepReq1)) // Since a batch was created we check that it registered for its primary // sweep's spend. @@ -2610,9 +2620,9 @@ func testSweepBatcherComposite(t *testing.T, store testStore, <-lnd.TxPublishChannel // Insert the same swap twice, this should be a noop. - require.NoError(t, batcher.AddSweep(&sweepReq1)) + require.NoError(t, batcher.AddSweep(ctx, &sweepReq1)) - require.NoError(t, batcher.AddSweep(&sweepReq2)) + require.NoError(t, batcher.AddSweep(ctx, &sweepReq2)) // Batcher should not create a second batch as timeout distance is small // enough. @@ -2628,7 +2638,7 @@ func testSweepBatcherComposite(t *testing.T, store testStore, tx := <-lnd.TxPublishChannel require.Len(t, tx.TxIn, 2) - require.NoError(t, batcher.AddSweep(&sweepReq3)) + require.NoError(t, batcher.AddSweep(ctx, &sweepReq3)) // Since a batch was created we check that it registered for its primary // sweep's spend. @@ -2644,7 +2654,7 @@ func testSweepBatcherComposite(t *testing.T, store testStore, tx = <-lnd.TxPublishChannel require.Len(t, tx.TxIn, 1) - require.NoError(t, batcher.AddSweep(&sweepReq4)) + require.NoError(t, batcher.AddSweep(ctx, &sweepReq4)) // Since a batch was created we check that it registered for its primary // sweep's spend. @@ -2660,7 +2670,7 @@ func testSweepBatcherComposite(t *testing.T, store testStore, tx = <-lnd.TxPublishChannel require.Len(t, tx.TxIn, 1) - require.NoError(t, batcher.AddSweep(&sweepReq5)) + require.NoError(t, batcher.AddSweep(ctx, &sweepReq5)) // Publish a block to trigger batch 3 republishing. err = lnd.NotifyHeight(601) @@ -2677,7 +2687,7 @@ func testSweepBatcherComposite(t *testing.T, store testStore, return batcher.numBatches(ctx) == 3 }, test.Timeout, eventuallyCheckFrequency) - require.NoError(t, batcher.AddSweep(&sweepReq6)) + require.NoError(t, batcher.AddSweep(ctx, &sweepReq6)) // Since a batch was created we check that it registered for its primary // sweep's spend. @@ -2845,7 +2855,7 @@ func testRestoringEmptyBatch(t *testing.T, store testStore, store.AssertLoopOutStored() // Deliver sweep request to batcher. - require.NoError(t, batcher.AddSweep(&sweepReq)) + require.NoError(t, batcher.AddSweep(ctx, &sweepReq)) // Since a batch was created we check that it registered for its primary // sweep's spend. @@ -3063,7 +3073,7 @@ func testHandleSweepTwice(t *testing.T, backend testStore, store.putLoopOutSwap(sweepReq2.SwapHash, loopOut2) // Deliver sweep request to batcher. - require.NoError(t, batcher.AddSweep(&sweepReq1)) + require.NoError(t, batcher.AddSweep(ctx, &sweepReq1)) // Since two batches were created we check that it registered for its // primary sweep's spend. @@ -3074,7 +3084,7 @@ func testHandleSweepTwice(t *testing.T, backend testStore, // Deliver the second sweep. It will go to a separate batch, // since CltvExpiry values are distant enough. - require.NoError(t, batcher.AddSweep(&sweepReq2)) + require.NoError(t, batcher.AddSweep(ctx, &sweepReq2)) <-lnd.RegisterSpendChannel // Wait for tx to be published. @@ -3117,7 +3127,7 @@ func testHandleSweepTwice(t *testing.T, backend testStore, // Re-add the second sweep. It is expected to stay in second batch, // not added to both batches. - require.NoError(t, batcher.AddSweep(&sweepReq2)) + require.NoError(t, batcher.AddSweep(ctx, &sweepReq2)) require.Eventually(t, func() bool { // Make sure there are two batches. @@ -3232,7 +3242,7 @@ func testRestoringPreservesConfTarget(t *testing.T, store testStore, store.AssertLoopOutStored() // Deliver sweep request to batcher. - require.NoError(t, batcher.AddSweep(&sweepReq)) + require.NoError(t, batcher.AddSweep(ctx, &sweepReq)) // Since a batch was created we check that it registered for its primary // sweep's spend. @@ -3450,7 +3460,7 @@ func testSweepFetcher(t *testing.T, store testStore, <-batcher.initDone // Deliver sweep request to batcher. - require.NoError(t, batcher.AddSweep(&sweepReq)) + require.NoError(t, batcher.AddSweep(ctx, &sweepReq)) // Since a batch was created we check that it registered for its primary // sweep's spend. @@ -3571,8 +3581,11 @@ func testSweepBatcherCloseDuringAdding(t *testing.T, store testStore, } // Deliver sweep request to batcher. - err := batcher.AddSweep(&sweepReq) - if err == ErrBatcherShuttingDown { + err := batcher.AddSweep(ctx, &sweepReq) + if errors.Is(err, ErrBatcherShuttingDown) { + break + } + if errors.Is(err, context.Canceled) { break } require.NoError(t, err) @@ -3667,7 +3680,7 @@ func testCustomSignMuSig2(t *testing.T, store testStore, store.AssertLoopOutStored() // Deliver sweep request to batcher. - require.NoError(t, batcher.AddSweep(&sweepReq)) + require.NoError(t, batcher.AddSweep(ctx, &sweepReq)) // Since a batch was created we check that it registered for its primary // sweep's spend. @@ -3830,7 +3843,7 @@ func testWithMixedBatch(t *testing.T, store testStore, }}, Notifier: &dummyNotifier, } - require.NoError(t, batcher.AddSweep(&sweepReq)) + require.NoError(t, batcher.AddSweep(ctx, &sweepReq)) if i == 0 { // Since a batch was created we check that it registered @@ -4000,7 +4013,7 @@ func testWithMixedBatchCustom(t *testing.T, store testStore, }}, Notifier: &dummyNotifier, } - require.NoError(t, batcher.AddSweep(&sweepReq)) + require.NoError(t, batcher.AddSweep(ctx, &sweepReq)) if i == 0 { // Since a batch was created we check that it registered @@ -4329,7 +4342,7 @@ func testFeeRateGrows(t *testing.T, store testStore, store.AssertLoopOutStored() // Deliver sweep request to batcher. - require.NoError(t, batcher.AddSweep(&sweepReq1)) + require.NoError(t, batcher.AddSweep(ctx, &sweepReq1)) // Since a batch was created we check that it registered for its primary // sweep's spend. @@ -4346,7 +4359,7 @@ func testFeeRateGrows(t *testing.T, store testStore, // Now decrease the fee of sweep1. setFeeRate(swapHash1, feeRateLow) - require.NoError(t, batcher.AddSweep(&sweepReq1)) + require.NoError(t, batcher.AddSweep(ctx, &sweepReq1)) // Tick tock next block. err = lnd.NotifyHeight(601) @@ -4395,7 +4408,7 @@ func testFeeRateGrows(t *testing.T, store testStore, store.AssertLoopOutStored() // Deliver sweep request to batcher. - require.NoError(t, batcher.AddSweep(&sweepReq2)) + require.NoError(t, batcher.AddSweep(ctx, &sweepReq2)) // Tick tock next block. err = lnd.NotifyHeight(602) @@ -4412,8 +4425,8 @@ func testFeeRateGrows(t *testing.T, store testStore, // Now update fee rate of second sweep (which is not primary) to // feeRateHigh. Fee rate of sweep 1 is still feeRateLow. setFeeRate(swapHash2, feeRateHigh) - require.NoError(t, batcher.AddSweep(&sweepReq1)) - require.NoError(t, batcher.AddSweep(&sweepReq2)) + require.NoError(t, batcher.AddSweep(ctx, &sweepReq1)) + require.NoError(t, batcher.AddSweep(ctx, &sweepReq2)) // Tick tock next block. err = lnd.NotifyHeight(603) diff --git a/test/lnd_services_mock.go b/test/lnd_services_mock.go index db4447448..aaf5c1106 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{} @@ -128,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 @@ -173,6 +179,8 @@ type LndMockServices struct { WaitForFinished func() + PublishHandler PublishHandler + lock sync.Mutex } @@ -278,3 +286,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..70a9f2f41 100644 --- a/test/walletkit_mock.go +++ b/test/walletkit_mock.go @@ -1,6 +1,7 @@ package test import ( + "bytes" "context" "errors" "fmt" @@ -34,6 +35,7 @@ type mockWalletKit struct { feeEstimateLock sync.Mutex feeEstimates map[int32]chainfee.SatPerKWeight + minRelayFee chainfee.SatPerKWeight } var _ lndclient.WalletKitClient = (*mockWalletKit)(nil) @@ -111,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 @@ -169,6 +177,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) { @@ -227,6 +253,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, @@ -239,9 +284,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