diff --git a/CHANGELOG.md b/CHANGELOG.md index b52a157..8a74084 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) * [#65](https://github.com/babylonlabs-io/btc-staker/pull/65) Various fixes to pre-approval flow. Do not send signed staking transactions to Babylon. +* [#67](https://github.com/babylonlabs-io/btc-staker/pull/67) Enable concurrent +sending of multiple pre-approval staking transactions + ## v0.7.2 ### Bug fix diff --git a/itest/e2e_test.go b/itest/e2e_test.go index 3b9fc3e..f86fba2 100644 --- a/itest/e2e_test.go +++ b/itest/e2e_test.go @@ -544,6 +544,7 @@ func (tm *TestManager) FinalizeUntilEpoch(t *testing.T, epoch uint64) { }, 2000, tm.MinerAddr, + nil, ) require.NoError(t, err) _, err = tm.Sa.Wallet().SendRawTransaction(tx1, true) @@ -557,6 +558,7 @@ func (tm *TestManager) FinalizeUntilEpoch(t *testing.T, epoch uint64) { }, 2000, tm.MinerAddr, + nil, ) require.NoError(t, err) _, err = tm.Sa.Wallet().SendRawTransaction(tx2, true) @@ -740,7 +742,7 @@ func (tm *TestManager) sendStakingTxBTC( return hashFromString } -func (tm *TestManager) sendMultipleStakingTx(t *testing.T, testStakingData []*testStakingData) []*chainhash.Hash { +func (tm *TestManager) sendMultipleStakingTx(t *testing.T, testStakingData []*testStakingData, sendToBabylonFirst bool) []*chainhash.Hash { var hashes []*chainhash.Hash for _, data := range testStakingData { fpBTCPKs := []string{} @@ -754,7 +756,7 @@ func (tm *TestManager) sendMultipleStakingTx(t *testing.T, testStakingData []*te data.StakingAmount, fpBTCPKs, int64(data.StakingTime), - false, + sendToBabylonFirst, ) require.NoError(t, err) txHash, err := chainhash.NewHashFromStr(res.TxHash) @@ -768,14 +770,21 @@ func (tm *TestManager) sendMultipleStakingTx(t *testing.T, testStakingData []*te stakingDetails, err := tm.StakerClient.StakingDetails(context.Background(), hashStr) require.NoError(t, err) require.Equal(t, stakingDetails.StakingTxHash, hashStr) - require.Equal(t, stakingDetails.StakingState, proto.TransactionState_SENT_TO_BTC.String()) + + if sendToBabylonFirst { + require.Equal(t, stakingDetails.StakingState, proto.TransactionState_SENT_TO_BABYLON.String()) + } else { + require.Equal(t, stakingDetails.StakingState, proto.TransactionState_SENT_TO_BTC.String()) + } } - mBlock := tm.mineBlock(t) - require.Equal(t, len(hashes)+1, len(mBlock.Transactions)) + if !sendToBabylonFirst { + mBlock := tm.mineBlock(t) + require.Equal(t, len(hashes)+1, len(mBlock.Transactions)) - _, err := tm.BabylonClient.InsertBtcBlockHeaders([]*wire.BlockHeader{&mBlock.Header}) - require.NoError(t, err) + _, err := tm.BabylonClient.InsertBtcBlockHeaders([]*wire.BlockHeader{&mBlock.Header}) + require.NoError(t, err) + } return hashes } @@ -804,6 +813,7 @@ func (tm *TestManager) sendWatchedStakingTx( []*wire.TxOut{stakingInfo.StakingOutput}, 2000, tm.MinerAddr, + nil, ) require.NoError(t, err) txHash := tx.TxHash() @@ -1346,7 +1356,7 @@ func TestMultipleWithdrawableStakingTransactions(t *testing.T) { testStakingData3, testStakingData4, testStakingData5, - }) + }, false) go tm.mineNEmptyBlocks(t, params.ConfirmationTimeBlocks, true) @@ -1380,6 +1390,61 @@ func TestMultipleWithdrawableStakingTransactions(t *testing.T) { require.Equal(t, withdrawableTransactionsResp.Transactions[2].TransactionIdx, "4") } +func TestMultiplePreApprovalTransactions(t *testing.T) { + t.Parallel() + // need to have at least 300 block on testnet as only then segwit is activated. + // Mature output is out which has 100 confirmations, which means 200mature outputs + // will generate 300 blocks + numMatureOutputs := uint32(200) + ctx, cancel := context.WithCancel(context.Background()) + tm := StartManager(t, ctx, numMatureOutputs) + defer tm.Stop(t, cancel) + tm.insertAllMinedBlocksToBabylon(t) + + cl := tm.Sa.BabylonController() + params, err := cl.Params() + require.NoError(t, err) + minStakingTime := params.MinStakingTime + stakingTime1 := minStakingTime + stakingTime2 := minStakingTime + 4 + stakingTime3 := minStakingTime + 1 + + testStakingData1 := tm.getTestStakingData(t, tm.WalletPubKey, stakingTime1, 10000, 1) + testStakingData2 := testStakingData1.withStakingTime(stakingTime2) + testStakingData3 := testStakingData1.withStakingTime(stakingTime3) + + tm.createAndRegisterFinalityProviders(t, testStakingData1) + txHashes := tm.sendMultipleStakingTx(t, []*testStakingData{ + testStakingData1, + testStakingData2, + testStakingData3, + }, true) + + for _, txHash := range txHashes { + txHash := txHash + tm.waitForStakingTxState(t, txHash, proto.TransactionState_SENT_TO_BABYLON) + } + + pend, err := tm.BabylonClient.QueryPendingBTCDelegations() + require.NoError(t, err) + require.Len(t, pend, 3) + tm.insertCovenantSigForDelegation(t, pend[0]) + tm.insertCovenantSigForDelegation(t, pend[1]) + tm.insertCovenantSigForDelegation(t, pend[2]) + + for _, txHash := range txHashes { + txHash := txHash + tm.waitForStakingTxState(t, txHash, proto.TransactionState_VERIFIED) + } + + // Ultimately we will get 3 tx in the mempool meaning all staking transactions + // use valid inputs + require.Eventually(t, func() bool { + txFromMempool := retrieveTransactionFromMempool(t, tm.TestRpcClient, txHashes) + return len(txFromMempool) == 3 + }, eventuallyWaitTimeOut, eventuallyPollTime) +} + func TestSendingWatchedStakingTransaction(t *testing.T) { t.Parallel() // need to have at least 300 block on testnet as only then segwit is activated. @@ -1456,7 +1521,7 @@ func TestRestartingTxNotOnBabylon(t *testing.T) { txHashes := tm.sendMultipleStakingTx(t, []*testStakingData{ testStakingData1, testStakingData2, - }) + }, false) // Confirm tx on btc minedBlocks := tm.mineNEmptyBlocks(t, params.ConfirmationTimeBlocks, false) @@ -1677,6 +1742,7 @@ func TestBitcoindWalletRpcApi(t *testing.T) { []*wire.TxOut{newOutput}, btcutil.Amount(2000), walletAddress, + nil, ) require.NoError(t, err) diff --git a/staker/commands.go b/staker/commands.go index 2645a12..3f61ceb 100644 --- a/staker/commands.go +++ b/staker/commands.go @@ -8,6 +8,7 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/lightningnetwork/lnd/lnwallet/chainfee" ) // we can make command to implement StakingEvent interface @@ -15,10 +16,8 @@ var _ StakingEvent = (*stakingRequestCmd)(nil) type stakingRequestCmd struct { stakerAddress btcutil.Address - stakingTxHash chainhash.Hash - stakingTx *wire.MsgTx - stakingOutputIdx uint32 - stakingOutputPkScript []byte + stakingOutput *wire.TxOut + feeRate chainfee.SatPerKVByte stakingTime uint16 stakingValue btcutil.Amount fpBtcPks []*btcec.PublicKey @@ -36,9 +35,8 @@ func (req *stakingRequestCmd) isWatched() bool { func newOwnedStakingCommand( stakerAddress btcutil.Address, - stakingTx *wire.MsgTx, - stakingOutputIdx uint32, - stakingOutputPkScript []byte, + stakingOutput *wire.TxOut, + feeRate chainfee.SatPerKVByte, stakingTime uint16, stakingValue btcutil.Amount, fpBtcPks []*btcec.PublicKey, @@ -48,10 +46,8 @@ func newOwnedStakingCommand( ) *stakingRequestCmd { return &stakingRequestCmd{ stakerAddress: stakerAddress, - stakingTxHash: stakingTx.TxHash(), - stakingTx: stakingTx, - stakingOutputIdx: stakingOutputIdx, - stakingOutputPkScript: stakingOutputPkScript, + stakingOutput: stakingOutput, + feeRate: feeRate, stakingTime: stakingTime, stakingValue: stakingValue, fpBtcPks: fpBtcPks, @@ -65,6 +61,12 @@ func newOwnedStakingCommand( } type watchTxDataCmd struct { + // watched tx data + stakingTxHash chainhash.Hash + stakingTx *wire.MsgTx + stakingOutputIdx uint32 + stakingOutputPkScript []byte + slashingTx *wire.MsgTx slashingTxSig *schnorr.Signature stakerBabylonAddr sdk.AccAddress @@ -97,24 +99,24 @@ func newWatchedStakingCmd( ) *stakingRequestCmd { return &stakingRequestCmd{ stakerAddress: stakerAddress, - stakingTxHash: stakingTx.TxHash(), - stakingTx: stakingTx, - stakingOutputIdx: stakingOutputIdx, - stakingOutputPkScript: stakingOutputPkScript, stakingTime: stakingTime, stakingValue: stakingValue, fpBtcPks: fpBtcPks, requiredDepthOnBtcChain: confirmationTimeBlocks, pop: pop, watchTxData: &watchTxDataCmd{ - slashingTx: slashingTx, - slashingTxSig: slashingTxSignature, - stakerBabylonAddr: stakerBabylonAddr, - stakerBtcPk: stakerBtcPk, - unbondingTx: unbondingTx, - slashUnbondingTx: slashUnbondingTx, - slashUnbondingTxSig: slashUnbondingTxSig, - unbondingTime: unbondingTime, + stakingTxHash: stakingTx.TxHash(), + stakingTx: stakingTx, + stakingOutputIdx: stakingOutputIdx, + stakingOutputPkScript: stakingOutputPkScript, + slashingTx: slashingTx, + slashingTxSig: slashingTxSignature, + stakerBabylonAddr: stakerBabylonAddr, + stakerBtcPk: stakerBtcPk, + unbondingTx: unbondingTx, + slashUnbondingTx: slashUnbondingTx, + slashUnbondingTxSig: slashUnbondingTxSig, + unbondingTime: unbondingTime, }, errChan: make(chan error, 1), successChan: make(chan *chainhash.Hash, 1), @@ -122,7 +124,8 @@ func newWatchedStakingCmd( } func (event *stakingRequestCmd) EventId() chainhash.Hash { - return event.stakingTxHash + // we do not have has for this event + return chainhash.Hash{} } func (event *stakingRequestCmd) EventDesc() string { diff --git a/staker/stakerapp.go b/staker/stakerapp.go index d232079..d7813ec 100644 --- a/staker/stakerapp.go +++ b/staker/stakerapp.go @@ -1261,11 +1261,15 @@ func (app *StakerApp) sendDelegationToBabylonTask( } } -func (app *StakerApp) handlePreApprovalCmd(cmd *stakingRequestCmd) error { +func (app *StakerApp) handlePreApprovalCmd( + cmd *stakingRequestCmd, + stakingTx *wire.MsgTx, + stakingOutputIdx uint32, +) (*chainhash.Hash, error) { // just to pass to buildAndSendDelegation fakeStoredTx, err := stakerdb.CreateTrackedTransaction( - cmd.stakingTx, - cmd.stakingOutputIdx, + stakingTx, + stakingOutputIdx, cmd.stakingTime, cmd.fpBtcPks, babylonPopToDbPop(cmd.pop), @@ -1273,11 +1277,13 @@ func (app *StakerApp) handlePreApprovalCmd(cmd *stakingRequestCmd) error { ) if err != nil { - return err + return nil, err } + stakingTxHash := stakingTx.TxHash() + req := &sendDelegationRequest{ - txHash: cmd.stakingTxHash, + txHash: stakingTxHash, inclusionInfo: nil, requiredInclusionBlockDepth: cmd.requiredDepthOnBtcChain, } @@ -1289,12 +1295,12 @@ func (app *StakerApp) handlePreApprovalCmd(cmd *stakingRequestCmd) error { ) if err != nil { - return err + return nil, err } err = app.txTracker.AddTransactionSentToBabylon( - cmd.stakingTx, - cmd.stakingOutputIdx, + stakingTx, + stakingOutputIdx, cmd.stakingTime, cmd.fpBtcPks, babylonPopToDbPop(cmd.pop), @@ -1304,70 +1310,88 @@ func (app *StakerApp) handlePreApprovalCmd(cmd *stakingRequestCmd) error { ) if err != nil { - return err + return nil, err } app.wg.Add(1) - go app.checkForUnbondingTxSignaturesOnBabylon(&cmd.stakingTxHash) + go app.checkForUnbondingTxSignaturesOnBabylon(&stakingTxHash) - return nil + return &stakingTxHash, nil } -func (app *StakerApp) handlePostApprovalCmd(cmd *stakingRequestCmd) error { +func (app *StakerApp) handlePostApprovalCmd( + cmd *stakingRequestCmd, + stakingTx *wire.MsgTx, + stakingOutputIdx uint32, +) (*chainhash.Hash, error) { + stakingTxHash := stakingTx.TxHash() + bestBlockHeight := app.currentBestBlockHeight.Load() err := app.wc.UnlockWallet(defaultWalletUnlockTimeout) if err != nil { - return err + return nil, err } - tx, fullySignd, err := app.wc.SignRawTransaction(cmd.stakingTx) + tx, fullySignd, err := app.wc.SignRawTransaction(stakingTx) if err != nil { - return err + return nil, err } if !fullySignd { - return fmt.Errorf("failed to fully sign transaction with hash %s", cmd.stakingTxHash) + return nil, fmt.Errorf("failed to fully sign transaction with hash %s", stakingTxHash) } _, err = app.wc.SendRawTransaction(tx, true) if err != nil { - return err + return nil, err } - stakingOutputPkScript := cmd.stakingTx.TxOut[cmd.stakingOutputIdx].PkScript + stakingOutputPkScript := stakingTx.TxOut[stakingOutputIdx].PkScript if err := app.waitForStakingTransactionConfirmation( - &cmd.stakingTxHash, + &stakingTxHash, stakingOutputPkScript, cmd.requiredDepthOnBtcChain, uint32(bestBlockHeight), ); err != nil { - return err + return nil, err } - if err := app.txTracker.AddTransaction( - cmd.stakingTx, - cmd.stakingOutputIdx, + if err := app.txTracker.AddTransactionSentToBTC( + stakingTx, + stakingOutputIdx, cmd.stakingTime, cmd.fpBtcPks, babylonPopToDbPop(cmd.pop), cmd.stakerAddress, ); err != nil { - return err + return nil, err } - return nil + return &stakingTxHash, nil } -func (app *StakerApp) handleStakingCmd(cmd *stakingRequestCmd) error { +func (app *StakerApp) handleStakingCmd(cmd *stakingRequestCmd) (*chainhash.Hash, error) { + // Create unsigned transaction by wallet without signing. Signing will happen + // in next steps + stakingTx, err := app.wc.CreateTransaction( + []*wire.TxOut{cmd.stakingOutput}, + btcutil.Amount(cmd.feeRate), + cmd.stakerAddress, + app.filterUtxoFnGen(), + ) + if err != nil { + return nil, fmt.Errorf("failed to build staking transaction: %w", err) + } + if cmd.usePreApprovalFlow { - return app.handlePreApprovalCmd(cmd) + return app.handlePreApprovalCmd(cmd, stakingTx, 0) } else { - return app.handlePostApprovalCmd(cmd) + return app.handlePostApprovalCmd(cmd, stakingTx, 0) } } @@ -1383,8 +1407,8 @@ func (app *StakerApp) handleStakingCommands() { bestBlockHeight := app.currentBestBlockHeight.Load() err := app.txTracker.AddWatchedTransaction( - cmd.stakingTx, - cmd.stakingOutputIdx, + cmd.watchTxData.stakingTx, + cmd.watchTxData.stakingOutputIdx, cmd.stakingTime, cmd.fpBtcPks, babylonPopToDbPop(cmd.pop), @@ -1406,8 +1430,8 @@ func (app *StakerApp) handleStakingCommands() { // we assume tx is already on btc chain, so we need to wait for confirmation if err := app.waitForStakingTransactionConfirmation( - &cmd.stakingTxHash, - cmd.stakingTx.TxOut[cmd.stakingOutputIdx].PkScript, + &cmd.watchTxData.stakingTxHash, + cmd.watchTxData.stakingTx.TxOut[cmd.watchTxData.stakingOutputIdx].PkScript, cmd.requiredDepthOnBtcChain, uint32(bestBlockHeight), ); err != nil { @@ -1416,12 +1440,12 @@ func (app *StakerApp) handleStakingCommands() { } app.m.ValidReceivedDelegationRequests.Inc() - cmd.successChan <- &cmd.stakingTxHash + cmd.successChan <- &cmd.watchTxData.stakingTxHash app.logStakingEventProcessed(cmd) continue } - err := app.handleStakingCmd(cmd) + stakingTxHash, err := app.handleStakingCmd(cmd) if err != nil { utils.PushOrQuit( @@ -1432,7 +1456,7 @@ func (app *StakerApp) handleStakingCommands() { } else { utils.PushOrQuit( cmd.successChan, - &cmd.stakingTxHash, + stakingTxHash, app.quit, ) } @@ -1508,14 +1532,25 @@ func (app *StakerApp) handleStakingEvents() { if err := app.txTracker.SetTxUnbondingSignaturesReceived( &ev.stakingTxHash, - ev.delegationActive, babylonCovSigsToDbSigSigs(ev.covenantUnbondingSignatures), ); err != nil { // TODO: handle this error somehow, it means we possilbly make invalid state transition app.logger.Fatalf("Error setting state for tx %s: %s", &ev.stakingTxHash, err) } - if !ev.delegationActive { + if ev.delegationActive { + app.wg.Add(1) + go func(hash chainhash.Hash) { + defer app.wg.Done() + utils.PushOrQuit[*delegationActiveOnBabylonEvent]( + app.delegationActiveOnBabylonEvChan, + &delegationActiveOnBabylonEvent{ + stakingTxHash: hash, + }, + app.quit, + ) + }(ev.stakingTxHash) + } else { storedTx, _ := app.mustGetTransactionAndStakerAddress(&ev.stakingTxHash) // if the delegation is not active here, it can only mean that statking // is going through pre-approvel flow. Fire up task to send staking tx @@ -1528,7 +1563,6 @@ func (app *StakerApp) handleStakingEvents() { ) } - app.m.DelegationsActivatedOnBabylon.Inc() app.logStakingEventProcessed(ev) case ev := <-app.unbondingTxConfirmedOnBtcEvChan: @@ -1560,6 +1594,7 @@ func (app *StakerApp) handleStakingEvents() { // which is seems like programming error. Maybe panic? app.logger.Fatalf("Error setting state for tx %s: %s", ev.stakingTxHash, err) } + app.m.DelegationsActivatedOnBabylon.Inc() app.logStakingEventProcessed(ev) case ev := <-app.criticalErrorEvChan: @@ -1669,7 +1704,7 @@ func (app *StakerApp) WatchStaking( app.logger.WithFields(logrus.Fields{ "stakerAddress": stakerAddress, - "stakingAmount": watchedRequest.stakingTx.TxOut[watchedRequest.stakingOutputIdx].Value, + "stakingAmount": watchedRequest.watchTxData.stakingTx.TxOut[watchedRequest.watchTxData.stakingOutputIdx].Value, "btxTxHash": stakingTx.TxHash(), }).Info("Received valid staking tx to watch") @@ -1694,6 +1729,20 @@ func (app *StakerApp) WatchStaking( } } +func (app *StakerApp) filterUtxoFnGen() walletcontroller.UseUtxoFn { + return func(utxo walletcontroller.Utxo) bool { + outpoint := utxo.OutPoint + + used, err := app.txTracker.OutpointUsed(&outpoint) + + if err != nil { + return false + } + + return !used + } +} + func (app *StakerApp) StakeFunds( stakerAddress btcutil.Address, stakingAmount btcutil.Amount, @@ -1796,26 +1845,16 @@ func (app *StakerApp) StakeFunds( feeRate := app.feeEstimator.EstimateFeePerKb() - // Create unsigned transaction by wallet without signing. Signing will happen - // in next steps - tx, err := app.wc.CreateTransaction([]*wire.TxOut{stakingInfo.StakingOutput}, btcutil.Amount(feeRate), stakerAddress) - - if err != nil { - return nil, err - } - app.logger.WithFields(logrus.Fields{ "stakerAddress": stakerAddress, "stakingAmount": stakingInfo.StakingOutput, - "btxTxHash": tx.TxHash(), "fee": feeRate, }).Info("Created and signed staking transaction") req := newOwnedStakingCommand( stakerAddress, - tx, - 0, - stakingInfo.StakingOutput.PkScript, + stakingInfo.StakingOutput, + feeRate, stakingTimeBlocks, stakingAmount, fpPks, diff --git a/stakerdb/trackedtranactionstore.go b/stakerdb/trackedtranactionstore.go index 5393a05..26fb5bd 100644 --- a/stakerdb/trackedtranactionstore.go +++ b/stakerdb/trackedtranactionstore.go @@ -31,6 +31,11 @@ var ( // It holds additional data for staking transaction in watch only mode watchedTxDataBucketName = []byte("watched") + // mapping outpoint -> txHash + // It holds mapping from outpoint to transaction hash + // outpoint: outpoint.txHash || bigendian(outpoint.index) + inputsDataBucketName = []byte("inputs") + // key for next transaction numTxKey = []byte("ntk") ) @@ -245,6 +250,12 @@ func (c *TrackedTransactionStore) initBuckets() error { return err } + _, err = tx.CreateTopLevelBucket(inputsDataBucketName) + + if err != nil { + return err + } + return nil }) } @@ -474,6 +485,7 @@ func saveTrackedTransaction( txHashBytes []byte, tx *proto.TrackedTransaction, watchedTxData *proto.WatchedTxData, + id *inputData, ) error { if tx == nil { return fmt.Errorf("cannot save nil tracked transaciton") @@ -521,6 +533,22 @@ func saveTrackedTransaction( } } + if id != nil { + inputDataBucket := rwTx.ReadWriteBucket(inputsDataBucketName) + if inputDataBucket == nil { + return ErrCorruptedTransactionsDb + } + + for _, input := range id.inputs { + // save all the inputs to the transaction + err = inputDataBucket.Put(input, txHashBytes) + + if err != nil { + return err + } + } + } + // increment counter for the next transaction return txIdxBucket.Put(numTxKey, uint64KeyToBytes(nextTxKey+1)) } @@ -529,6 +557,7 @@ func (c *TrackedTransactionStore) addTransactionInternal( txHashBytes []byte, tt *proto.TrackedTransaction, wd *proto.WatchedTxData, + id *inputData, ) error { return kvdb.Batch(c.db, func(tx kvdb.RwTx) error { transactionsBucketIdxBucket := tx.ReadWriteBucket(transactionIndexName) @@ -548,7 +577,7 @@ func (c *TrackedTransactionStore) addTransactionInternal( return ErrCorruptedTransactionsDb } - return saveTrackedTransaction(tx, transactionsBucketIdxBucket, transactionsBucket, txHashBytes, tt, wd) + return saveTrackedTransaction(tx, transactionsBucketIdxBucket, transactionsBucket, txHashBytes, tt, wd, id) }) } @@ -599,6 +628,45 @@ func CreateTrackedTransaction( return protoTxToStoredTransaction(&msg) } +type inputData struct { + inputs [][]byte + txHash []byte +} + +func outpointBytes(op *wire.OutPoint) ([]byte, error) { + var buf bytes.Buffer + _, err := buf.Write(op.Hash.CloneBytes()) + if err != nil { + return nil, err + } + + err = binary.Write(&buf, binary.BigEndian, op.Index) + + if err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +func getInputData(tx *wire.MsgTx) (*inputData, error) { + var inputs [][]byte + + for _, in := range tx.TxIn { + opBytes, err := outpointBytes(&in.PreviousOutPoint) + if err != nil { + return nil, err + } + inputs = append(inputs, opBytes) + } + txHash := tx.TxHash() + + return &inputData{ + inputs: inputs, + txHash: txHash.CloneBytes(), + }, nil +} + func (c *TrackedTransactionStore) AddTransactionSentToBabylon( btcTx *wire.MsgTx, stakingOutputIndex uint32, @@ -653,12 +721,18 @@ func (c *TrackedTransactionStore) AddTransactionSentToBabylon( UnbondingTxData: update, } + inputData, err := getInputData(btcTx) + + if err != nil { + return fmt.Errorf("failed to get input data: %w", err) + } + return c.addTransactionInternal( - txHashBytes[:], &msg, nil, + txHashBytes[:], &msg, nil, inputData, ) } -func (c *TrackedTransactionStore) AddTransaction( +func (c *TrackedTransactionStore) AddTransactionSentToBTC( btcTx *wire.MsgTx, stakingOutputIndex uint32, stakingTime uint16, @@ -705,7 +779,7 @@ func (c *TrackedTransactionStore) AddTransaction( } return c.addTransactionInternal( - txHashBytes[:], &msg, nil, + txHashBytes[:], &msg, nil, nil, ) } @@ -791,7 +865,7 @@ func (c *TrackedTransactionStore) AddWatchedTransaction( } return c.addTransactionInternal( - txHashBytes, &msg, &watchedData, + txHashBytes, &msg, &watchedData, nil, ) } @@ -841,6 +915,39 @@ func (c *TrackedTransactionStore) setTxState( return err } + // delegation has been activaten remove used inputs if any exists + // TODO(konrad): This is not pretty architecture wise and a bit broken in scenario + // that delegation is never activated + if storedTx.State == proto.TransactionState_DELEGATION_ACTIVE { + inputDataBucket := tx.ReadWriteBucket(inputsDataBucketName) + if inputDataBucket == nil { + return ErrCorruptedTransactionsDb + } + + var stakingTx wire.MsgTx + err := stakingTx.Deserialize(bytes.NewReader(storedTx.StakingTransaction)) + + if err != nil { + return err + } + + for _, input := range stakingTx.TxIn { + input, err := outpointBytes(&input.PreviousOutPoint) + + if err != nil { + return err + } + + // if key does not exist, this operation is no-op + err = inputDataBucket.Delete(input) + + if err != nil { + return err + } + } + + } + return nil }) } @@ -915,7 +1022,6 @@ func (c *TrackedTransactionStore) SetDelegationActiveOnBabylon(txHash *chainhash func (c *TrackedTransactionStore) SetTxUnbondingSignaturesReceived( txHash *chainhash.Hash, - delegationActive bool, covenantSignatures []PubKeySigPair, ) error { setUnbondingSignaturesReceived := func(tx *proto.TrackedTransaction) error { @@ -926,12 +1032,7 @@ func (c *TrackedTransactionStore) SetTxUnbondingSignaturesReceived( if len(tx.UnbondingTxData.CovenantSignatures) > 0 { return fmt.Errorf("cannot set unbonding signatures received, because unbonding signatures already exist: %w", ErrInvalidUnbondingDataUpdate) } - - if delegationActive { - tx.State = proto.TransactionState_DELEGATION_ACTIVE - } else { - tx.State = proto.TransactionState_VERIFIED - } + tx.State = proto.TransactionState_VERIFIED tx.UnbondingTxData.CovenantSignatures = covenantSigsToProto(covenantSignatures) return nil } @@ -1200,3 +1301,31 @@ func (c *TrackedTransactionStore) ScanTrackedTransactions(scanFunc StoredTransac }) }, reset) } + +func (c *TrackedTransactionStore) OutpointUsed(op *wire.OutPoint) (bool, error) { + var used bool = false + + err := c.db.View(func(tx kvdb.RTx) error { + inputsBucket := tx.ReadBucket(inputsDataBucketName) + + if inputsBucket == nil { + return ErrCorruptedTransactionsDb + } + + opBytes, err := outpointBytes(op) + + if err != nil { + return fmt.Errorf("invalid outpoint provided: %w", err) + } + + res := inputsBucket.Get(opBytes) + + if res != nil { + used = true + } + + return nil + }, func() {}) + + return used, err +} diff --git a/stakerdb/trackedtransactionstore_test.go b/stakerdb/trackedtransactionstore_test.go index a22544d..2d2be75 100644 --- a/stakerdb/trackedtransactionstore_test.go +++ b/stakerdb/trackedtransactionstore_test.go @@ -123,7 +123,7 @@ func FuzzStoringTxs(f *testing.F) { stakerAddr, err := btcutil.DecodeAddress(storedTx.StakerAddress, &chaincfg.MainNetParams) require.NoError(t, err) require.NoError(t, err) - err = s.AddTransaction( + err = s.AddTransactionSentToBTC( storedTx.StakingTx, storedTx.StakingOutputIndex, storedTx.StakingTime, @@ -182,7 +182,7 @@ func TestStateTransitions(t *testing.T) { stakerAddr, err := btcutil.DecodeAddress(tx.StakerAddress, &chaincfg.MainNetParams) require.NoError(t, err) txHash := tx.StakingTx.TxHash() - err = s.AddTransaction( + err = s.AddTransactionSentToBTC( tx.StakingTx, tx.StakingOutputIndex, tx.StakingTime, @@ -238,8 +238,7 @@ func TestPaginator(t *testing.T) { for _, storedTx := range generatedStoredTxs { stakerAddr, err := btcutil.DecodeAddress(storedTx.StakerAddress, &chaincfg.MainNetParams) require.NoError(t, err) - require.NoError(t, err) - err = s.AddTransaction( + err = s.AddTransactionSentToBTC( storedTx.StakingTx, storedTx.StakingOutputIndex, storedTx.StakingTime, @@ -309,7 +308,7 @@ func FuzzQuerySpendableTx(f *testing.F) { stakerAddr, err := btcutil.DecodeAddress(storedTx.StakerAddress, &chaincfg.MainNetParams) require.NoError(t, err) require.NoError(t, err) - err = s.AddTransaction( + err = s.AddTransactionSentToBTC( storedTx.StakingTx, storedTx.StakingOutputIndex, storedTx.StakingTime, @@ -383,3 +382,71 @@ func FuzzQuerySpendableTx(f *testing.F) { require.Equal(t, storedResult.Total, uint64(maxCreatedTx)) }) } + +func FuzzTrackInputs(f *testing.F) { + // only 3 seeds as this is pretty slow test opening/closing db + datagen.AddRandomSeedsToFuzzer(f, 3) + + f.Fuzz(func(t *testing.T, seed int64) { + r := rand.New(rand.NewSource(seed)) + s := MakeTestStore(t) + numTx := 45 + // all gene + generatedStoredTxs := genNStoredTransactions(t, r, numTx, 200) + + randomUnbondingTx := datagen.GenRandomTx(r) + unbodningTime := uint16(r.Int31n(int32(200)) + 1) + + for _, storedTx := range generatedStoredTxs { + stakerAddr, err := btcutil.DecodeAddress(storedTx.StakerAddress, &chaincfg.MainNetParams) + require.NoError(t, err) + err = s.AddTransactionSentToBabylon( + storedTx.StakingTx, + storedTx.StakingOutputIndex, + storedTx.StakingTime, + storedTx.FinalityProvidersBtcPks, + storedTx.Pop, + stakerAddr, + randomUnbondingTx, + unbodningTime, + ) + require.NoError(t, err) + } + + for _, storedTx := range generatedStoredTxs { + // check all inputs are used + for _, inp := range storedTx.StakingTx.TxIn { + used, err := s.OutpointUsed(&inp.PreviousOutPoint) + require.NoError(t, err) + require.True(t, used) + } + } + + // generate few not saved transactions + notSaved := genNStoredTransactions(t, r, 20, 200) + + // check all input are not used + for _, storedTx := range notSaved { + for _, inp := range storedTx.StakingTx.TxIn { + used, err := s.OutpointUsed(&inp.PreviousOutPoint) + require.NoError(t, err) + require.False(t, used) + } + } + + for _, storedTx := range generatedStoredTxs { + stakingTxHash := storedTx.StakingTx.TxHash() + err := s.SetDelegationActiveOnBabylon(&stakingTxHash) + require.NoError(t, err) + } + + for _, storedTx := range generatedStoredTxs { + // check all inputs are deleted + for _, inp := range storedTx.StakingTx.TxIn { + used, err := s.OutpointUsed(&inp.PreviousOutPoint) + require.NoError(t, err) + require.False(t, used) + } + } + }) +} diff --git a/walletcontroller/client.go b/walletcontroller/client.go index 65770f0..ce48e1a 100644 --- a/walletcontroller/client.go +++ b/walletcontroller/client.go @@ -140,7 +140,9 @@ func (w *RpcWalletController) NetworkName() string { func (w *RpcWalletController) CreateTransaction( outputs []*wire.TxOut, feeRatePerKb btcutil.Amount, - changeAddres btcutil.Address) (*wire.MsgTx, error) { + changeAddres btcutil.Address, + useUtxoFn UseUtxoFn, +) (*wire.MsgTx, error) { utxoResults, err := w.ListUnspent() @@ -154,9 +156,20 @@ func (w *RpcWalletController) CreateTransaction( return nil, err } + var utxosToUse []Utxo + if useUtxoFn != nil { + for _, u := range utxos { + if useUtxoFn(u) { + utxosToUse = append(utxosToUse, u) + } + } + } else { + utxosToUse = utxos + } + // sort utxos by amount from highest to lowest, this is effectively strategy of using // largest inputs first - sort.Sort(sort.Reverse(byAmount(utxos))) + sort.Sort(sort.Reverse(byAmount(utxosToUse))) changeScript, err := txscript.PayToAddrScript(changeAddres) @@ -164,7 +177,7 @@ func (w *RpcWalletController) CreateTransaction( return nil, err } - tx, err := buildTxFromOutputs(utxos, outputs, feeRatePerKb, changeScript) + tx, err := buildTxFromOutputs(utxosToUse, outputs, feeRatePerKb, changeScript) if err != nil { return nil, err @@ -177,8 +190,9 @@ func (w *RpcWalletController) CreateAndSignTx( outputs []*wire.TxOut, feeRatePerKb btcutil.Amount, changeAddress btcutil.Address, + useUtxoFn UseUtxoFn, ) (*wire.MsgTx, error) { - tx, err := w.CreateTransaction(outputs, feeRatePerKb, changeAddress) + tx, err := w.CreateTransaction(outputs, feeRatePerKb, changeAddress, useUtxoFn) if err != nil { return nil, err diff --git a/walletcontroller/interface.go b/walletcontroller/interface.go index 2a776ba..d0e2b2d 100644 --- a/walletcontroller/interface.go +++ b/walletcontroller/interface.go @@ -37,21 +37,31 @@ type TaprootSigningResult struct { FullInputWitness wire.TxWitness } +// Function to filer utxos that should be used in transaction creation +type UseUtxoFn func(utxo Utxo) bool + type WalletController interface { UnlockWallet(timeoutSecs int64) error AddressPublicKey(address btcutil.Address) (*btcec.PublicKey, error) ImportPrivKey(privKeyWIF *btcutil.WIF) error NetworkName() string + // passning nil usedUtxoFilter will use all possible spendable utxos to choose + // inputs CreateTransaction( outputs []*wire.TxOut, feeRatePerKb btcutil.Amount, - changeScript btcutil.Address) (*wire.MsgTx, error) + changeScript btcutil.Address, + usedUtxoFilter UseUtxoFn, + ) (*wire.MsgTx, error) SignRawTransaction(tx *wire.MsgTx) (*wire.MsgTx, bool, error) // requires wallet to be unlocked + // passning nil usedUtxoFilter will use all possible spendable utxos to choose + // inputs CreateAndSignTx( outputs []*wire.TxOut, feeRatePerKb btcutil.Amount, changeAddress btcutil.Address, + usedUtxoFilter UseUtxoFn, ) (*wire.MsgTx, error) SendRawTransaction(tx *wire.MsgTx, allowHighFees bool) (*chainhash.Hash, error) ListOutputs(onlySpendable bool) ([]Utxo, error)