diff --git a/Makefile b/Makefile index f93c41df..fea3c4fd 100644 --- a/Makefile +++ b/Makefile @@ -66,7 +66,7 @@ mocks: $(MOCKGEN_CMD) -source=monitor/expected_babylon_client.go -package monitor -destination monitor/mock_babylon_client.go $(MOCKGEN_CMD) -source=btcstaking-tracker/btcslasher/expected_babylon_client.go -package btcslasher -destination btcstaking-tracker/btcslasher/mock_babylon_client.go $(MOCKGEN_CMD) -source=btcstaking-tracker/atomicslasher/expected_babylon_client.go -package atomicslasher -destination btcstaking-tracker/atomicslasher/mock_babylon_client.go - $(MOCKGEN_CMD) -source=btcstaking-tracker/unbondingwatcher/expected_babylon_client.go -package unbondingwatcher -destination btcstaking-tracker/unbondingwatcher/mock_babylon_client.go + $(MOCKGEN_CMD) -source=btcstaking-tracker/stakingeventwatcher/expected_babylon_client.go -package stakingeventwatcher -destination btcstaking-tracker/stakingeventwatcher/mock_babylon_client.go update-changelog: @echo ./scripts/update_changelog.sh $(sinceTag) $(upcomingTag) diff --git a/btcclient/interface.go b/btcclient/interface.go index 58e42ff8..72fe738f 100644 --- a/btcclient/interface.go +++ b/btcclient/interface.go @@ -23,6 +23,7 @@ type BTCClient interface { SendRawTransaction(tx *wire.MsgTx, allowHighFees bool) (*chainhash.Hash, error) GetTransaction(txHash *chainhash.Hash) (*btcjson.GetTransactionResult, error) GetRawTransaction(txHash *chainhash.Hash) (*btcutil.Tx, error) + TxDetails(txHash *chainhash.Hash, pkScript []byte) (*notifier.TxConfirmation, TxStatus, error) } type BTCWallet interface { diff --git a/btcstaking-tracker/unbondingwatcher/expected_babylon_client.go b/btcstaking-tracker/stakingeventwatcher/expected_babylon_client.go similarity index 62% rename from btcstaking-tracker/unbondingwatcher/expected_babylon_client.go rename to btcstaking-tracker/stakingeventwatcher/expected_babylon_client.go index 11ea2792..dfdfaa5b 100644 --- a/btcstaking-tracker/unbondingwatcher/expected_babylon_client.go +++ b/btcstaking-tracker/stakingeventwatcher/expected_babylon_client.go @@ -1,8 +1,9 @@ -package unbondingwatcher +package stakingeventwatcher import ( "context" "fmt" + btcctypes "github.com/babylonlabs-io/babylon/x/btccheckpoint/types" "cosmossdk.io/errors" bbnclient "github.com/babylonlabs-io/babylon/client/client" @@ -14,22 +15,21 @@ import ( "github.com/cosmos/cosmos-sdk/types/query" ) -var ( - ErrInvalidBabylonMsgExecution = fmt.Errorf("invalid babylon msg execution") -) - type Delegation struct { StakingTx *wire.MsgTx StakingOutputIdx uint32 DelegationStartHeight uint64 UnbondingOutput *wire.TxOut + HasProof bool } type BabylonNodeAdapter interface { - ActiveBtcDelegations(offset uint64, limit uint64) ([]Delegation, error) + DelegationsByStatus(status btcstakingtypes.BTCDelegationStatus, offset uint64, limit uint64) ([]Delegation, error) IsDelegationActive(stakingTxHash chainhash.Hash) (bool, error) + IsDelegationVerified(stakingTxHash chainhash.Hash) (bool, error) ReportUnbonding(ctx context.Context, stakingTxHash chainhash.Hash, stakerUnbondingSig *schnorr.Signature) error BtcClientTipHeight() (uint32, error) + ActivateDelegation(ctx context.Context, stakingTxHash chainhash.Hash, proof *btcctypes.BTCSpvProof) error } type BabylonClientAdapter struct { @@ -44,10 +44,11 @@ func NewBabylonClientAdapter(babylonClient *bbnclient.Client) *BabylonClientAdap } } -// TODO: Consider doing quick retries for failed queries. -func (bca *BabylonClientAdapter) ActiveBtcDelegations(offset uint64, limit uint64) ([]Delegation, error) { +// DelegationsByStatus - returns btc delegations by status +func (bca *BabylonClientAdapter) DelegationsByStatus( + status btcstakingtypes.BTCDelegationStatus, offset uint64, limit uint64) ([]Delegation, error) { resp, err := bca.babylonClient.BTCDelegations( - btcstakingtypes.BTCDelegationStatus_ACTIVE, + status, &query.PageRequest{ Key: nil, Offset: offset, @@ -75,8 +76,8 @@ func (bca *BabylonClientAdapter) ActiveBtcDelegations(offset uint64, limit uint6 StakingTx: stakingTx, StakingOutputIdx: delegation.StakingOutputIdx, DelegationStartHeight: delegation.StartHeight, - // unbonding transaction always has only one output - UnbondingOutput: unbondingTx.TxOut[0], + UnbondingOutput: unbondingTx.TxOut[0], + HasProof: delegation.StartHeight > 0, } } @@ -94,6 +95,17 @@ func (bca *BabylonClientAdapter) IsDelegationActive(stakingTxHash chainhash.Hash return resp.BtcDelegation.Active, nil } +// IsDelegationVerified method for BabylonClientAdapter checks if delegation is in status verified +func (bca *BabylonClientAdapter) IsDelegationVerified(stakingTxHash chainhash.Hash) (bool, error) { + resp, err := bca.babylonClient.BTCDelegation(stakingTxHash.String()) + + if err != nil { + return false, fmt.Errorf("failed to retrieve delegation from babyln: %w", err) + } + + return resp.BtcDelegation.StatusDesc == btcstakingtypes.BTCDelegationStatus_VERIFIED.String(), nil +} + // ReportUnbonding method for BabylonClientAdapter func (bca *BabylonClientAdapter) ReportUnbonding( ctx context.Context, @@ -130,3 +142,27 @@ func (bca *BabylonClientAdapter) BtcClientTipHeight() (uint32, error) { return uint32(resp.Header.Height), nil } + +// ActivateDelegation provides inclusion proof to activate delegation +func (bca *BabylonClientAdapter) ActivateDelegation( + ctx context.Context, stakingTxHash chainhash.Hash, proof *btcctypes.BTCSpvProof) error { + signer := bca.babylonClient.MustGetAddr() + + msg := btcstakingtypes.MsgAddBTCDelegationInclusionProof{ + Signer: signer, + StakingTxHash: stakingTxHash.String(), + StakingTxInclusionProof: btcstakingtypes.NewInclusionProofFromSpvProof(proof), + } + + resp, err := bca.babylonClient.ReliablySendMsg(ctx, &msg, []*errors.Error{}, []*errors.Error{}) + + if err != nil && resp != nil { + return fmt.Errorf("msg MsgAddBTCDelegationInclusionProof failed exeuction with code %d and error %w", resp.Code, err) + } + + if err != nil { + return fmt.Errorf("failed to report unbonding: %w", err) + } + + return nil +} diff --git a/btcstaking-tracker/unbondingwatcher/mock_babylon_client.go b/btcstaking-tracker/stakingeventwatcher/mock_babylon_client.go similarity index 57% rename from btcstaking-tracker/unbondingwatcher/mock_babylon_client.go rename to btcstaking-tracker/stakingeventwatcher/mock_babylon_client.go index e309842a..d38ea704 100644 --- a/btcstaking-tracker/unbondingwatcher/mock_babylon_client.go +++ b/btcstaking-tracker/stakingeventwatcher/mock_babylon_client.go @@ -1,13 +1,15 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: btcstaking-tracker/unbondingwatcher/expected_babylon_client.go +// Source: btcstaking-tracker/stakingeventwatcher/expected_babylon_client.go -// Package unbondingwatcher is a generated GoMock package. -package unbondingwatcher +// Package stakingeventwatcher is a generated GoMock package. +package stakingeventwatcher import ( context "context" reflect "reflect" + types "github.com/babylonlabs-io/babylon/x/btccheckpoint/types" + types0 "github.com/babylonlabs-io/babylon/x/btcstaking/types" schnorr "github.com/btcsuite/btcd/btcec/v2/schnorr" chainhash "github.com/btcsuite/btcd/chaincfg/chainhash" gomock "github.com/golang/mock/gomock" @@ -36,19 +38,18 @@ func (m *MockBabylonNodeAdapter) EXPECT() *MockBabylonNodeAdapterMockRecorder { return m.recorder } -// ActiveBtcDelegations mocks base method. -func (m *MockBabylonNodeAdapter) ActiveBtcDelegations(offset, limit uint64) ([]Delegation, error) { +// ActivateDelegation mocks base method. +func (m *MockBabylonNodeAdapter) ActivateDelegation(ctx context.Context, stakingTxHash chainhash.Hash, proof *types.BTCSpvProof) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ActiveBtcDelegations", offset, limit) - ret0, _ := ret[0].([]Delegation) - ret1, _ := ret[1].(error) - return ret0, ret1 + ret := m.ctrl.Call(m, "ActivateDelegation", ctx, stakingTxHash, proof) + ret0, _ := ret[0].(error) + return ret0 } -// ActiveBtcDelegations indicates an expected call of ActiveBtcDelegations. -func (mr *MockBabylonNodeAdapterMockRecorder) ActiveBtcDelegations(offset, limit interface{}) *gomock.Call { +// ActivateDelegation indicates an expected call of ActivateDelegation. +func (mr *MockBabylonNodeAdapterMockRecorder) ActivateDelegation(ctx, stakingTxHash, proof interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ActiveBtcDelegations", reflect.TypeOf((*MockBabylonNodeAdapter)(nil).ActiveBtcDelegations), offset, limit) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ActivateDelegation", reflect.TypeOf((*MockBabylonNodeAdapter)(nil).ActivateDelegation), ctx, stakingTxHash, proof) } // BtcClientTipHeight mocks base method. @@ -66,6 +67,21 @@ func (mr *MockBabylonNodeAdapterMockRecorder) BtcClientTipHeight() *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BtcClientTipHeight", reflect.TypeOf((*MockBabylonNodeAdapter)(nil).BtcClientTipHeight)) } +// DelegationsByStatus mocks base method. +func (m *MockBabylonNodeAdapter) DelegationsByStatus(status types0.BTCDelegationStatus, offset, limit uint64) ([]Delegation, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DelegationsByStatus", status, offset, limit) + ret0, _ := ret[0].([]Delegation) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DelegationsByStatus indicates an expected call of DelegationsByStatus. +func (mr *MockBabylonNodeAdapterMockRecorder) DelegationsByStatus(status, offset, limit interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DelegationsByStatus", reflect.TypeOf((*MockBabylonNodeAdapter)(nil).DelegationsByStatus), status, offset, limit) +} + // IsDelegationActive mocks base method. func (m *MockBabylonNodeAdapter) IsDelegationActive(stakingTxHash chainhash.Hash) (bool, error) { m.ctrl.T.Helper() @@ -81,6 +97,21 @@ func (mr *MockBabylonNodeAdapterMockRecorder) IsDelegationActive(stakingTxHash i return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsDelegationActive", reflect.TypeOf((*MockBabylonNodeAdapter)(nil).IsDelegationActive), stakingTxHash) } +// IsDelegationVerified mocks base method. +func (m *MockBabylonNodeAdapter) IsDelegationVerified(stakingTxHash chainhash.Hash) (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsDelegationVerified", stakingTxHash) + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// IsDelegationVerified indicates an expected call of IsDelegationVerified. +func (mr *MockBabylonNodeAdapterMockRecorder) IsDelegationVerified(stakingTxHash interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsDelegationVerified", reflect.TypeOf((*MockBabylonNodeAdapter)(nil).IsDelegationVerified), stakingTxHash) +} + // ReportUnbonding mocks base method. func (m *MockBabylonNodeAdapter) ReportUnbonding(ctx context.Context, stakingTxHash chainhash.Hash, stakerUnbondingSig *schnorr.Signature) error { m.ctrl.T.Helper() diff --git a/btcstaking-tracker/stakingeventwatcher/stakingeventwatcher.go b/btcstaking-tracker/stakingeventwatcher/stakingeventwatcher.go new file mode 100644 index 00000000..62a9a36c --- /dev/null +++ b/btcstaking-tracker/stakingeventwatcher/stakingeventwatcher.go @@ -0,0 +1,595 @@ +package stakingeventwatcher + +import ( + "bytes" + "context" + "errors" + "fmt" + btcctypes "github.com/babylonlabs-io/babylon/x/btccheckpoint/types" + btcstakingtypes "github.com/babylonlabs-io/babylon/x/btcstaking/types" + "github.com/babylonlabs-io/vigilante/btcclient" + "github.com/babylonlabs-io/vigilante/types" + "sync" + "sync/atomic" + "time" + + "github.com/avast/retry-go/v4" + "github.com/babylonlabs-io/vigilante/config" + "github.com/babylonlabs-io/vigilante/metrics" + "github.com/babylonlabs-io/vigilante/utils" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + notifier "github.com/lightningnetwork/lnd/chainntnfs" + "go.uber.org/zap" +) + +var ( + fixedDelyTypeWithJitter = retry.DelayType(retry.CombineDelay(retry.FixedDelay, retry.RandomDelay)) + retryForever = retry.Attempts(0) +) + +func (sew *StakingEventWatcher) quitContext() (context.Context, func()) { + ctx, cancel := context.WithCancel(context.Background()) + sew.wg.Add(1) + go func() { + defer cancel() + defer sew.wg.Done() + + select { + case <-sew.quit: + + case <-ctx.Done(): + } + }() + + return ctx, cancel +} + +type newDelegation struct { + stakingTxHash chainhash.Hash + stakingTx *wire.MsgTx + stakingOutputIdx uint32 + delegationStartHeight uint64 + unbondingOutput *wire.TxOut +} + +type delegationInactive struct { + stakingTxHash chainhash.Hash +} + +type StakingEventWatcher struct { + startOnce sync.Once + stopOnce sync.Once + wg sync.WaitGroup + quit chan struct{} + cfg *config.BTCStakingTrackerConfig + logger *zap.SugaredLogger + + btcClient btcclient.BTCClient + btcNotifier notifier.ChainNotifier + metrics *metrics.UnbondingWatcherMetrics + // TODO: Ultimately all requests to babylon should go through some kind of semaphore + // to avoid spamming babylon with requests + babylonNodeAdapter BabylonNodeAdapter + unbondingTracker *TrackedDelegations + pendingTracker *TrackedDelegations + + unbondingDelegationChan chan *newDelegation + + unbondingRemovalChan chan *delegationInactive + currentBestBlockHeight atomic.Uint32 +} + +func NewStakingEventWatcher( + btcNotifier notifier.ChainNotifier, + btcClient btcclient.BTCClient, + babylonNodeAdapter BabylonNodeAdapter, + cfg *config.BTCStakingTrackerConfig, + parentLogger *zap.Logger, + metrics *metrics.UnbondingWatcherMetrics, +) *StakingEventWatcher { + return &StakingEventWatcher{ + quit: make(chan struct{}), + cfg: cfg, + logger: parentLogger.With(zap.String("module", "staking_event_watcher")).Sugar(), + btcNotifier: btcNotifier, + btcClient: btcClient, + babylonNodeAdapter: babylonNodeAdapter, + metrics: metrics, + unbondingTracker: NewTrackedDelegations(), + pendingTracker: NewTrackedDelegations(), + unbondingDelegationChan: make(chan *newDelegation), + unbondingRemovalChan: make(chan *delegationInactive), + } +} + +func (sew *StakingEventWatcher) Start() error { + var startErr error + sew.startOnce.Do(func() { + sew.logger.Info("starting staking event watcher") + + blockEventNotifier, err := sew.btcNotifier.RegisterBlockEpochNtfn(nil) + if err != nil { + startErr = err + return + } + + // we registered for notifications with `nil` so we should receive best block immediately + select { + case block := <-blockEventNotifier.Epochs: + sew.currentBestBlockHeight.Store(uint32(block.Height)) + case <-sew.quit: + startErr = errors.New("watcher quit before finishing start") + return + } + + sew.logger.Infof("Initial btc best block height is: %d", sew.currentBestBlockHeight.Load()) + + sew.wg.Add(4) + go sew.handleNewBlocks(blockEventNotifier) + go sew.handleUnbondedDelegations() + go sew.fetchDelegations() + go sew.handlerVerifiedDelegations() + + sew.logger.Info("staking event watcher started") + }) + return startErr +} + +func (sew *StakingEventWatcher) Stop() error { + var stopErr error + sew.stopOnce.Do(func() { + sew.logger.Info("stopping staking event watcher") + close(sew.quit) + sew.wg.Wait() + sew.logger.Info("stopped staking event watcher") + }) + return stopErr +} + +func (sew *StakingEventWatcher) handleNewBlocks(blockNotifier *notifier.BlockEpochEvent) { + defer sew.wg.Done() + defer blockNotifier.Cancel() + for { + select { + case block, ok := <-blockNotifier.Epochs: + if !ok { + return + } + sew.currentBestBlockHeight.Store(uint32(block.Height)) + sew.logger.Debugf("Received new best btc block: %d", block.Height) + case <-sew.quit: + return + } + } +} + +// checkBabylonDelegations iterates over all active babylon delegations, and reports not already +// tracked delegations to the unbondingDelegationChan +func (sew *StakingEventWatcher) checkBabylonDelegations(status btcstakingtypes.BTCDelegationStatus, addDel func(del Delegation)) error { + var i = uint64(0) + for { + delegations, err := sew.babylonNodeAdapter.DelegationsByStatus(status, i, sew.cfg.NewDelegationsBatchSize) + + if err != nil { + return fmt.Errorf("error fetching active delegations from babylon: %v", err) + } + + sew.logger.Debugf("fetched %d delegations from babylon", len(delegations)) + + for _, delegation := range delegations { + addDel(delegation) + } + + if len(delegations) < int(sew.cfg.NewDelegationsBatchSize) { + // we received fewer delegations than we asked for; it means went through all of them + return nil + } + + i += sew.cfg.NewDelegationsBatchSize + } +} + +func (sew *StakingEventWatcher) fetchDelegations() { + defer sew.wg.Done() + ticker := time.NewTicker(sew.cfg.CheckDelegationsInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + sew.logger.Debug("Querying babylon for new delegations") + + nodeSynced, err := sew.syncedWithBabylon() + if err != nil || !nodeSynced { + // Log message and continue if there's an error or node isn't synced + continue + } + + addToUnbonding := func(delegation Delegation) { + del := &newDelegation{ + stakingTxHash: delegation.StakingTx.TxHash(), + stakingTx: delegation.StakingTx, + stakingOutputIdx: delegation.StakingOutputIdx, + delegationStartHeight: delegation.DelegationStartHeight, + unbondingOutput: delegation.UnbondingOutput, + } + + // if we already have this delegation, skip it + // we should track both verified and active status for unbonding + if sew.unbondingTracker.GetDelegation(delegation.StakingTx.TxHash()) == nil { + utils.PushOrQuit(sew.unbondingDelegationChan, del, sew.quit) + } + } + + addToPending := func(delegation Delegation) { + del := &newDelegation{ + stakingTxHash: delegation.StakingTx.TxHash(), + stakingTx: delegation.StakingTx, + stakingOutputIdx: delegation.StakingOutputIdx, + delegationStartHeight: delegation.DelegationStartHeight, + unbondingOutput: delegation.UnbondingOutput, + } + + if sew.pendingTracker.GetDelegation(delegation.StakingTx.TxHash()) == nil && !delegation.HasProof { + _, _ = sew.pendingTracker.AddDelegation( + del.stakingTx, + del.stakingOutputIdx, + del.unbondingOutput, + ) + } + } + + var wg sync.WaitGroup + wg.Add(3) + + go func() { + defer wg.Done() + if err = sew.checkBabylonDelegations(btcstakingtypes.BTCDelegationStatus_ACTIVE, addToUnbonding); err != nil { + sew.logger.Errorf("error checking babylon delegations: %v", err) + } + }() + + go func() { + defer wg.Done() + if err = sew.checkBabylonDelegations(btcstakingtypes.BTCDelegationStatus_VERIFIED, addToUnbonding); err != nil { + sew.logger.Errorf("error checking babylon delegations: %v", err) + } + }() + + go func() { + defer wg.Done() + if err = sew.checkBabylonDelegations(btcstakingtypes.BTCDelegationStatus_VERIFIED, addToPending); err != nil { + sew.logger.Errorf("error checking babylon delegations: %v", err) + } + }() + + wg.Wait() + case <-sew.quit: + sew.logger.Debug("fetch delegations loop quit") + return + } + } +} + +func (sew *StakingEventWatcher) syncedWithBabylon() (bool, error) { + btcLightClientTipHeight, err := sew.babylonNodeAdapter.BtcClientTipHeight() + if err != nil { + sew.logger.Errorf("error fetching babylon tip height: %v", err) + return false, err + } + + currentBtcNodeHeight := sew.currentBestBlockHeight.Load() + + if currentBtcNodeHeight < btcLightClientTipHeight { + sew.logger.Debugf("btc light client tip height is %d, connected node best block height is %d. Waiting for node to catch up", btcLightClientTipHeight, currentBtcNodeHeight) + return false, nil + } + + return true, nil +} + +func getStakingTxInputIdx(tx *wire.MsgTx, td *TrackedDelegation) (int, error) { + stakingTxHash := td.StakingTx.TxHash() + + for i, txIn := range tx.TxIn { + if txIn.PreviousOutPoint.Hash == stakingTxHash && txIn.PreviousOutPoint.Index == td.StakingOutputIdx { + return i, nil + } + } + + return -1, fmt.Errorf("transaction does not point to staking output. expected hash:%s, expected outoputidx: %d", stakingTxHash, td.StakingOutputIdx) +} + +// tryParseStakerSignatureFromSpentTx tries to parse staker signature from unbonding tx. +// If provided tx is not unbonding tx it returns error. +func tryParseStakerSignatureFromSpentTx(tx *wire.MsgTx, td *TrackedDelegation) (*schnorr.Signature, error) { + if len(tx.TxOut) != 1 { + return nil, fmt.Errorf("unbonding tx must have exactly one output. Priovided tx has %d outputs", len(tx.TxOut)) + } + + if tx.TxOut[0].Value != td.UnbondingOutput.Value || !bytes.Equal(tx.TxOut[0].PkScript, td.UnbondingOutput.PkScript) { + return nil, fmt.Errorf("unbonding tx must have ouput which matches unbonding output of retrieved from Babylon") + } + + stakingTxInputIdx, err := getStakingTxInputIdx(tx, td) + + if err != nil { + return nil, fmt.Errorf("unbonding tx does not spend staking output: %v", err) + } + + stakingTxInput := tx.TxIn[stakingTxInputIdx] + witnessLen := len(stakingTxInput.Witness) + // minimal witness size for staking tx input is 4: + // covenant_signature, staker_signature, script, control_block + // If that is not the case, something weird is going on and we should investigate. + if witnessLen < 4 { + panic(fmt.Errorf("staking tx input witness has less than 4 elements for unbonding tx %s", tx.TxHash())) + } + + // staker signature is 3rd element from the end + stakerSignature := stakingTxInput.Witness[witnessLen-3] + + return schnorr.ParseSignature(stakerSignature) +} + +// waitForDelegationToStopBeingActive polls babylon until delegation is no longer active. +func (sew *StakingEventWatcher) waitForDelegationToStopBeingActive( + ctx context.Context, + stakingTxHash chainhash.Hash, +) { + _ = retry.Do(func() error { + active, err := sew.babylonNodeAdapter.IsDelegationActive(stakingTxHash) + + if err != nil { + return fmt.Errorf("error checking if delegation is active: %v", err) + } + + if !active { + return nil + } + + return fmt.Errorf("delegation for staking tx %s is still active", stakingTxHash) + }, + retry.Context(ctx), + retryForever, + fixedDelyTypeWithJitter, + retry.MaxDelay(sew.cfg.CheckDelegationActiveInterval), + retry.MaxJitter(sew.cfg.RetryJitter), + retry.OnRetry(func(n uint, err error) { + sew.logger.Debugf("retrying checking if delegation is active for staking tx %s. Attempt: %d. Err: %v", stakingTxHash, n, err) + }), + ) +} + +func (sew *StakingEventWatcher) reportUnbondingToBabylon( + ctx context.Context, + stakingTxHash chainhash.Hash, + unbondingSignature *schnorr.Signature, +) { + _ = retry.Do(func() error { + active, err := sew.babylonNodeAdapter.IsDelegationActive(stakingTxHash) + + if err != nil { + return fmt.Errorf("error checking if delegation is active: %v", err) + } + + verified, err := sew.babylonNodeAdapter.IsDelegationVerified(stakingTxHash) + + if err != nil { + return fmt.Errorf("error checking if delegation is verified: %v", err) + } + + if !active && !verified { + sew.logger.Debugf("cannot report unbonding. delegation for staking tx %s is no longer active", stakingTxHash) + return nil + } + + if err = sew.babylonNodeAdapter.ReportUnbonding(ctx, stakingTxHash, unbondingSignature); err != nil { + sew.metrics.FailedReportedUnbondingTransactions.Inc() + return fmt.Errorf("error reporting unbonding tx %s to babylon: %v", stakingTxHash, err) + } + + sew.metrics.ReportedUnbondingTransactionsCounter.Inc() + return nil + }, + retry.Context(ctx), + retryForever, + fixedDelyTypeWithJitter, + retry.MaxDelay(sew.cfg.RetrySubmitUnbondingTxInterval), + retry.MaxJitter(sew.cfg.RetryJitter), + retry.OnRetry(func(n uint, err error) { + sew.logger.Debugf("retrying submitting unbodning tx, for staking tx: %s. Attempt: %d. Err: %v", stakingTxHash, n, err) + }), + ) +} + +func (sew *StakingEventWatcher) watchForSpend(spendEvent *notifier.SpendEvent, td *TrackedDelegation) { + defer sew.wg.Done() + quitCtx, cancel := sew.quitContext() + defer cancel() + + var spendingTx *wire.MsgTx = nil + select { + case spendDetail := <-spendEvent.Spend: + spendingTx = spendDetail.SpendingTx + case <-sew.quit: + return + } + + schnorrSignature, err := tryParseStakerSignatureFromSpentTx(spendingTx, td) + delegationId := td.StakingTx.TxHash() + spendingTxHash := spendingTx.TxHash() + + if err != nil { + sew.metrics.DetectedNonUnbondingTransactionsCounter.Inc() + // Error means that this is not unbonding tx. At this point, it means that it is + // either withdrawal transaction or slashing transaction spending staking staking output. + // As we only care about unbonding transactions, we do not need to take additional actions. + // We start polling babylon for delegation to stop being active, and then delete it from unbondingTracker. + sew.logger.Debugf("Spending tx %s for staking tx %s is not unbonding tx. Info: %v", spendingTxHash, delegationId, err) + sew.waitForDelegationToStopBeingActive(quitCtx, delegationId) + } else { + sew.metrics.DetectedUnbondingTransactionsCounter.Inc() + // We found valid unbonding tx. We need to try to report it to babylon. + // We stop reporting if delegation is no longer active or we succeed. + sew.logger.Debugf("found unbonding tx %s for staking tx %s", spendingTxHash, delegationId) + sew.reportUnbondingToBabylon(quitCtx, delegationId, schnorrSignature) + sew.logger.Debugf("unbonding tx %s for staking tx %s reported to babylon", spendingTxHash, delegationId) + } + + utils.PushOrQuit[*delegationInactive]( + sew.unbondingRemovalChan, + &delegationInactive{stakingTxHash: delegationId}, + sew.quit, + ) +} + +func (sew *StakingEventWatcher) handleUnbondedDelegations() { + defer sew.wg.Done() + for { + select { + case activeDel := <-sew.unbondingDelegationChan: + sew.logger.Debugf("Received new delegation to watch for staking transaction with hash %s", activeDel.stakingTxHash) + + del, err := sew.unbondingTracker.AddDelegation( + activeDel.stakingTx, + activeDel.stakingOutputIdx, + activeDel.unbondingOutput, + ) + + if err != nil { + sew.logger.Errorf("error adding delegation to unbondingTracker: %v", err) + continue + } + + sew.metrics.NumberOfTrackedActiveDelegations.Inc() + + stakingOutpoint := wire.OutPoint{ + Hash: activeDel.stakingTxHash, + Index: activeDel.stakingOutputIdx, + } + + spendEv, err := sew.btcNotifier.RegisterSpendNtfn( + &stakingOutpoint, + activeDel.stakingTx.TxOut[activeDel.stakingOutputIdx].PkScript, + uint32(activeDel.delegationStartHeight), + ) + + if err != nil { + sew.logger.Errorf("error registering spend ntfn for staking tx %s: %v", activeDel.stakingTxHash, err) + continue + } + + sew.wg.Add(1) + go sew.watchForSpend(spendEv, del) + case in := <-sew.unbondingRemovalChan: + sew.logger.Debugf("Delegation for staking transaction with hash %s stopped being active", in.stakingTxHash) + // remove delegation from unbondingTracker + sew.unbondingTracker.RemoveDelegation(in.stakingTxHash) + + sew.metrics.NumberOfTrackedActiveDelegations.Dec() + + case <-sew.quit: + sew.logger.Debug("handle delegations loop quit") + return + } + } +} + +func (sew *StakingEventWatcher) handlerVerifiedDelegations() { + defer sew.wg.Done() + ticker := time.NewTicker(sew.cfg.CheckDelegationsInterval) // todo(lazar): use different interval in config + defer ticker.Stop() + + for { + select { + case <-ticker.C: + sew.logger.Debug("Checking for new staking txs") + + if err := sew.checkBtcForStakingTx(); err != nil { + sew.logger.Errorf("error checking verified delegations: %v", err) + continue + } + + case <-sew.quit: + sew.logger.Debug("verified delegations loop quit") + return + } + } +} + +// checkBtcForStakingTx gets a snapshot of current Delegations in cache +// checks if staking tx is in BTC, generates a proof and invokes sending of MsgAddBTCDelegationInclusionProof +func (sew *StakingEventWatcher) checkBtcForStakingTx() error { + delegations := sew.pendingTracker.GetDelegations() + if delegations == nil { + return nil + } + + for _, del := range delegations { + txHash := del.StakingTx.TxHash() + details, status, err := sew.btcClient.TxDetails(&txHash, del.StakingTx.TxOut[del.StakingOutputIdx].PkScript) + if err != nil { + sew.logger.Debugf("error getting tx %v", txHash) + continue + } + + if status != btcclient.TxInChain { + continue + } + + btcTxs := types.GetWrappedTxs(details.Block) + ib := types.NewIndexedBlock(int32(details.BlockHeight), &details.Block.Header, btcTxs) + + proof, err := ib.GenSPVProof(int(details.TxIndex)) + if err != nil { + sew.logger.Debugf("error making spv proof %s", err) + continue + } + + go sew.activateBtcDelegation(txHash, proof) + } + + return nil +} + +// activateBtcDelegation invokes bbn client and send MsgAddBTCDelegationInclusionProof +func (sew *StakingEventWatcher) activateBtcDelegation( + stakingTxHash chainhash.Hash, proof *btcctypes.BTCSpvProof) { + ctx, cancel := sew.quitContext() + defer cancel() + + _ = retry.Do(func() error { + verified, err := sew.babylonNodeAdapter.IsDelegationVerified(stakingTxHash) + if err != nil { + return fmt.Errorf("error checking if delegation is active: %v", err) + } + + if !verified { + sew.logger.Debugf("skipping tx %s is not in verified status", stakingTxHash) + return nil + } + + if err := sew.babylonNodeAdapter.ActivateDelegation(ctx, stakingTxHash, proof); err != nil { + sew.metrics.FailedReportedActivateDelegations.Inc() + return fmt.Errorf("error reporting activate delegation tx %s to babylon: %v", stakingTxHash, err) + } + + sew.metrics.ReportedActivateDelegationsCounter.Inc() + + sew.pendingTracker.RemoveDelegation(stakingTxHash) + + return nil + }, + retry.Context(ctx), + retry.Attempts(5), // todo(Lazar): add this to config + fixedDelyTypeWithJitter, + retry.MaxDelay(sew.cfg.RetrySubmitUnbondingTxInterval), + retry.MaxJitter(sew.cfg.RetryJitter), + retry.OnRetry(func(n uint, err error) { + sew.logger.Debugf("retrying to submit activation tx, for staking tx: %s. Attempt: %d. Err: %v", stakingTxHash, n, err) + }), + ) +} diff --git a/btcstaking-tracker/unbondingwatcher/tracked_delegations.go b/btcstaking-tracker/stakingeventwatcher/tracked_delegations.go similarity index 53% rename from btcstaking-tracker/unbondingwatcher/tracked_delegations.go rename to btcstaking-tracker/stakingeventwatcher/tracked_delegations.go index 2b20ed0f..293db8b9 100644 --- a/btcstaking-tracker/unbondingwatcher/tracked_delegations.go +++ b/btcstaking-tracker/stakingeventwatcher/tracked_delegations.go @@ -1,4 +1,4 @@ -package unbondingwatcher +package stakingeventwatcher import ( "fmt" @@ -15,7 +15,7 @@ type TrackedDelegation struct { } type TrackedDelegations struct { - mu sync.Mutex + mu sync.RWMutex // key: staking tx hash mapping map[chainhash.Hash]*TrackedDelegation } @@ -27,11 +27,11 @@ func NewTrackedDelegations() *TrackedDelegations { } // GetDelegation returns the tracked delegation for the given staking tx hash or nil if not found. -func (dt *TrackedDelegations) GetDelegation(stakingTxHash chainhash.Hash) *TrackedDelegation { - dt.mu.Lock() - defer dt.mu.Unlock() +func (td *TrackedDelegations) GetDelegation(stakingTxHash chainhash.Hash) *TrackedDelegation { + td.mu.RLock() + defer td.mu.RUnlock() - del, ok := dt.mapping[stakingTxHash] + del, ok := td.mapping[stakingTxHash] if !ok { return nil @@ -40,7 +40,23 @@ func (dt *TrackedDelegations) GetDelegation(stakingTxHash chainhash.Hash) *Track return del } -func (dt *TrackedDelegations) AddDelegation( +// GetDelegations returns all tracked delegations as a slice. +func (td *TrackedDelegations) GetDelegations() []*TrackedDelegation { + td.mu.RLock() + defer td.mu.RUnlock() + + // Create a slice to hold all delegations + delegations := make([]*TrackedDelegation, 0, len(td.mapping)) + + // Iterate over the map and collect all values (TrackedDelegation) + for _, delegation := range td.mapping { + delegations = append(delegations, delegation) + } + + return delegations +} + +func (td *TrackedDelegations) AddDelegation( StakingTx *wire.MsgTx, StakingOutputIdx uint32, UnbondingOutput *wire.TxOut, @@ -53,20 +69,20 @@ func (dt *TrackedDelegations) AddDelegation( stakingTxHash := StakingTx.TxHash() - dt.mu.Lock() - defer dt.mu.Unlock() + td.mu.Lock() + defer td.mu.Unlock() - if _, ok := dt.mapping[stakingTxHash]; ok { + if _, ok := td.mapping[stakingTxHash]; ok { return nil, fmt.Errorf("delegation already tracked for staking tx hash %s", stakingTxHash) } - dt.mapping[stakingTxHash] = delegation + td.mapping[stakingTxHash] = delegation return delegation, nil } -func (dt *TrackedDelegations) RemoveDelegation(stakingTxHash chainhash.Hash) { - dt.mu.Lock() - defer dt.mu.Unlock() +func (td *TrackedDelegations) RemoveDelegation(stakingTxHash chainhash.Hash) { + td.mu.Lock() + defer td.mu.Unlock() - delete(dt.mapping, stakingTxHash) + delete(td.mapping, stakingTxHash) } diff --git a/btcstaking-tracker/tracker.go b/btcstaking-tracker/tracker.go index e8a1fb4e..3137f80a 100644 --- a/btcstaking-tracker/tracker.go +++ b/btcstaking-tracker/tracker.go @@ -8,7 +8,7 @@ import ( "github.com/babylonlabs-io/vigilante/btcclient" "github.com/babylonlabs-io/vigilante/btcstaking-tracker/atomicslasher" "github.com/babylonlabs-io/vigilante/btcstaking-tracker/btcslasher" - uw "github.com/babylonlabs-io/vigilante/btcstaking-tracker/unbondingwatcher" + uw "github.com/babylonlabs-io/vigilante/btcstaking-tracker/stakingeventwatcher" "github.com/babylonlabs-io/vigilante/config" "github.com/babylonlabs-io/vigilante/metrics" "github.com/babylonlabs-io/vigilante/netparams" @@ -27,9 +27,10 @@ type BTCStakingTracker struct { // to avoid spamming babylon with requests bbnClient *bbnclient.Client - // unbondingWatcher monitors early unbonding transactions on Bitcoin - // and reports unbonding BTC delegations back to Babylon - unbondingWatcher *uw.UnbondingWatcher + // stakingEventWatcher monitors early all staking transactions on Bitcoin + // and reports unbonding BTC delegations back to Babylon. As well as staking transactions + // that lack inclusion proof, wait for them on BTC and submits MsgActivateBTCDelegation + stakingEventWatcher *uw.StakingEventWatcher // btcSlasher monitors slashing events in BTC staking protocol, // and slashes BTC delegations under each equivocating finality provider // by signing and submitting their slashing txs @@ -55,7 +56,7 @@ type BTCStakingTracker struct { quit chan struct{} } -func NewBTCSTakingTracker( +func NewBTCStakingTracker( btcClient btcclient.BTCClient, btcNotifier notifier.ChainNotifier, bbnClient *bbnclient.Client, @@ -68,7 +69,14 @@ func NewBTCSTakingTracker( // watcher routine babylonAdapter := uw.NewBabylonClientAdapter(bbnClient) - watcher := uw.NewUnbondingWatcher(btcNotifier, babylonAdapter, cfg, logger, metrics.UnbondingWatcherMetrics) + watcher := uw.NewStakingEventWatcher( + btcNotifier, + btcClient, + babylonAdapter, + cfg, + logger, + metrics.UnbondingWatcherMetrics, + ) slashedFPSKChan := make(chan *btcec.PrivateKey, 100) // TODO: parameterise buffer size @@ -111,17 +119,17 @@ func NewBTCSTakingTracker( ) return &BTCStakingTracker{ - cfg: cfg, - logger: logger.Sugar(), - btcClient: btcClient, - btcNotifier: btcNotifier, - bbnClient: bbnClient, - btcSlasher: btcSlasher, - atomicSlasher: atomicSlasher, - unbondingWatcher: watcher, - slashedFPSKChan: slashedFPSKChan, - metrics: metrics, - quit: make(chan struct{}), + cfg: cfg, + logger: logger.Sugar(), + btcClient: btcClient, + btcNotifier: btcNotifier, + bbnClient: bbnClient, + btcSlasher: btcSlasher, + atomicSlasher: atomicSlasher, + stakingEventWatcher: watcher, + slashedFPSKChan: slashedFPSKChan, + metrics: metrics, + quit: make(chan struct{}), } } @@ -141,7 +149,7 @@ func (tracker *BTCStakingTracker) Start() error { tracker.startOnce.Do(func() { tracker.logger.Info("starting BTC staking tracker") - if err := tracker.unbondingWatcher.Start(); err != nil { + if err := tracker.stakingEventWatcher.Start(); err != nil { startErr = err return } @@ -165,7 +173,7 @@ func (tracker *BTCStakingTracker) Stop() error { tracker.stopOnce.Do(func() { tracker.logger.Info("stopping BTC staking tracker") - if err := tracker.unbondingWatcher.Stop(); err != nil { + if err := tracker.stakingEventWatcher.Stop(); err != nil { stopErr = err return } diff --git a/btcstaking-tracker/unbondingwatcher/unbondingwatcher.go b/btcstaking-tracker/unbondingwatcher/unbondingwatcher.go deleted file mode 100644 index fc7f7482..00000000 --- a/btcstaking-tracker/unbondingwatcher/unbondingwatcher.go +++ /dev/null @@ -1,436 +0,0 @@ -package unbondingwatcher - -import ( - "bytes" - "context" - "errors" - "fmt" - "sync" - "sync/atomic" - "time" - - "github.com/avast/retry-go/v4" - "github.com/babylonlabs-io/vigilante/config" - "github.com/babylonlabs-io/vigilante/metrics" - "github.com/babylonlabs-io/vigilante/utils" - "github.com/btcsuite/btcd/btcec/v2/schnorr" - "github.com/btcsuite/btcd/chaincfg/chainhash" - "github.com/btcsuite/btcd/wire" - notifier "github.com/lightningnetwork/lnd/chainntnfs" - "go.uber.org/zap" -) - -var ( - fixedDelyTypeWithJitter = retry.DelayType(retry.CombineDelay(retry.FixedDelay, retry.RandomDelay)) - retryForever = retry.Attempts(0) -) - -func (uw *UnbondingWatcher) quitContext() (context.Context, func()) { - ctx, cancel := context.WithCancel(context.Background()) - uw.wg.Add(1) - go func() { - defer cancel() - defer uw.wg.Done() - - select { - case <-uw.quit: - - case <-ctx.Done(): - } - }() - - return ctx, cancel -} - -type newDelegation struct { - stakingTxHash chainhash.Hash - stakingTx *wire.MsgTx - stakingOutputIdx uint32 - delegationStartHeight uint64 - unbondingOutput *wire.TxOut -} - -type delegationInactive struct { - stakingTxHash chainhash.Hash -} - -type UnbondingWatcher struct { - startOnce sync.Once - stopOnce sync.Once - wg sync.WaitGroup - quit chan struct{} - cfg *config.BTCStakingTrackerConfig - logger *zap.SugaredLogger - btcNotifier notifier.ChainNotifier - metrics *metrics.UnbondingWatcherMetrics - // TODO: Ultimately all requests to babylon should go through some kind of semaphore - // to avoid spamming babylon with requests - babylonNodeAdapter BabylonNodeAdapter - tracker *TrackedDelegations - newDelegationChan chan *newDelegation - delegetionInactiveChan chan *delegationInactive - currentBestBlockHeight atomic.Uint32 -} - -func NewUnbondingWatcher( - btcNotifier notifier.ChainNotifier, - babylonNodeAdapter BabylonNodeAdapter, - cfg *config.BTCStakingTrackerConfig, - parentLogger *zap.Logger, - metrics *metrics.UnbondingWatcherMetrics, -) *UnbondingWatcher { - return &UnbondingWatcher{ - quit: make(chan struct{}), - cfg: cfg, - logger: parentLogger.With(zap.String("module", "unbonding_watcher")).Sugar(), - btcNotifier: btcNotifier, - babylonNodeAdapter: babylonNodeAdapter, - metrics: metrics, - tracker: NewTrackedDelegations(), - newDelegationChan: make(chan *newDelegation), - delegetionInactiveChan: make(chan *delegationInactive), - } -} - -func (uw *UnbondingWatcher) Start() error { - var startErr error - uw.startOnce.Do(func() { - uw.logger.Info("starting unbonding watcher") - - blockEventNotifier, err := uw.btcNotifier.RegisterBlockEpochNtfn(nil) - - if err != nil { - startErr = err - return - } - - // we registered for notifications with `nil` so we should receive best block - // immeadiatly - select { - case block := <-blockEventNotifier.Epochs: - uw.currentBestBlockHeight.Store(uint32(block.Height)) - case <-uw.quit: - startErr = errors.New("watcher quit before finishing start") - return - } - - uw.logger.Infof("Initial btc best block height is: %d", uw.currentBestBlockHeight.Load()) - - uw.wg.Add(3) - go uw.handleNewBlocks(blockEventNotifier) - go uw.handleDelegations() - go uw.fetchDelegations() - uw.logger.Info("unbonding watcher started") - }) - return startErr -} - -func (uw *UnbondingWatcher) Stop() error { - var stopErr error - uw.stopOnce.Do(func() { - uw.logger.Info("stopping unbonding watcher") - close(uw.quit) - uw.wg.Wait() - uw.logger.Info("stopped unbonding watcher") - }) - return stopErr -} - -func (uw *UnbondingWatcher) handleNewBlocks(blockNotifier *notifier.BlockEpochEvent) { - defer uw.wg.Done() - defer blockNotifier.Cancel() - for { - select { - case block, ok := <-blockNotifier.Epochs: - if !ok { - return - } - uw.currentBestBlockHeight.Store(uint32(block.Height)) - uw.logger.Debugf("Received new best btc block: %d", block.Height) - case <-uw.quit: - return - } - } -} - -// checkBabylonDelegations iterates over all active babylon delegations, and reports not already -// tracked delegations to the newDelegationChan -func (uw *UnbondingWatcher) checkBabylonDelegations() error { - var i = uint64(0) - for { - delegations, err := uw.babylonNodeAdapter.ActiveBtcDelegations(i, uw.cfg.NewDelegationsBatchSize) - - if err != nil { - return fmt.Errorf("error fetching active delegations from babylon: %v", err) - } - - uw.logger.Debugf("fetched %d delegations from babylon", len(delegations)) - - for _, delegation := range delegations { - stakingTxHash := delegation.StakingTx.TxHash() - - // if we already have this delegation, skip it - if uw.tracker.GetDelegation(stakingTxHash) == nil { - utils.PushOrQuit(uw.newDelegationChan, &newDelegation{ - stakingTxHash: stakingTxHash, - stakingTx: delegation.StakingTx, - stakingOutputIdx: delegation.StakingOutputIdx, - delegationStartHeight: delegation.DelegationStartHeight, - unbondingOutput: delegation.UnbondingOutput, - }, uw.quit) - } - } - - if len(delegations) < int(uw.cfg.NewDelegationsBatchSize) { - // we received less delegations than we asked for, it means went through all of them - return nil - } - - i += uw.cfg.NewDelegationsBatchSize - } -} - -func (uw *UnbondingWatcher) fetchDelegations() { - defer uw.wg.Done() - ticker := time.NewTicker(uw.cfg.CheckDelegationsInterval) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - uw.logger.Debug("Quering babylon for new delegations") - btcLightClientTipHeight, err := uw.babylonNodeAdapter.BtcClientTipHeight() - - if err != nil { - uw.logger.Errorf("error fetching babylon tip height: %v", err) - continue - } - - currentBtcNodeHeight := uw.currentBestBlockHeight.Load() - - // Our local node is out of sync with the babylon btc light client. If we would - // query for delegation we might receive delegations from blocks that we cannot check. - // Log this and give node chance to catch up i.e do not check current delegations - if currentBtcNodeHeight < btcLightClientTipHeight { - uw.logger.Debugf("btc light client tip height is %d, connected node best block height is %d. Waiting for node to catch up", btcLightClientTipHeight, uw.currentBestBlockHeight.Load()) - continue - } - - err = uw.checkBabylonDelegations() - - if err != nil { - uw.logger.Errorf("error checking babylon delegations: %v", err) - continue - } - - case <-uw.quit: - uw.logger.Debug("fetch delegations loop quit") - return - } - } -} - -func getStakingTxInputIdx(tx *wire.MsgTx, td *TrackedDelegation) (int, error) { - stakingTxHash := td.StakingTx.TxHash() - - for i, txIn := range tx.TxIn { - if txIn.PreviousOutPoint.Hash == stakingTxHash && txIn.PreviousOutPoint.Index == td.StakingOutputIdx { - return i, nil - } - } - - return -1, fmt.Errorf("transaction does not point to staking output. expected hash:%s, expected outoputidx: %d", stakingTxHash, td.StakingOutputIdx) -} - -// tryParseStakerSignatureFromSpentTx tries to parse staker signature from unbonding tx. -// If provided tx is not unbonding tx it returns error. -func tryParseStakerSignatureFromSpentTx(tx *wire.MsgTx, td *TrackedDelegation) (*schnorr.Signature, error) { - if len(tx.TxOut) != 1 { - return nil, fmt.Errorf("unbonding tx must have exactly one output. Priovided tx has %d outputs", len(tx.TxOut)) - } - - if tx.TxOut[0].Value != td.UnbondingOutput.Value || !bytes.Equal(tx.TxOut[0].PkScript, td.UnbondingOutput.PkScript) { - return nil, fmt.Errorf("unbonding tx must have ouput which matches unbonding output of retrieved from Babylon") - } - - stakingTxInputIdx, err := getStakingTxInputIdx(tx, td) - - if err != nil { - return nil, fmt.Errorf("unbonding tx does not spend staking output: %v", err) - } - - stakingTxInput := tx.TxIn[stakingTxInputIdx] - witnessLen := len(stakingTxInput.Witness) - // minimal witness size for staking tx input is 4: - // covenant_signature, staker_signature, script, control_block - // If that is not the case, something weird is going on and we should investigate. - if witnessLen < 4 { - panic(fmt.Errorf("staking tx input witness has less than 4 elements for unbonding tx %s", tx.TxHash())) - } - - // staker signature is 3rd element from the end - stakerSignature := stakingTxInput.Witness[witnessLen-3] - - return schnorr.ParseSignature(stakerSignature) -} - -// waitForDelegationToStopBeingActive polls babylon until delegation is no longer active. -func (uw *UnbondingWatcher) waitForDelegationToStopBeingActive( - ctx context.Context, - stakingTxHash chainhash.Hash, -) { - _ = retry.Do(func() error { - active, err := uw.babylonNodeAdapter.IsDelegationActive(stakingTxHash) - - if err != nil { - return fmt.Errorf("error checking if delegation is active: %v", err) - } - - if !active { - return nil - } - - return fmt.Errorf("delegation for staking tx %s is still active", stakingTxHash) - }, - retry.Context(ctx), - retryForever, - fixedDelyTypeWithJitter, - retry.MaxDelay(uw.cfg.CheckDelegationActiveInterval), - retry.MaxJitter(uw.cfg.RetryJitter), - retry.OnRetry(func(n uint, err error) { - uw.logger.Debugf("retrying checking if delegation is active for staking tx %s. Attempt: %d. Err: %v", stakingTxHash, n, err) - }), - ) -} - -func (uw *UnbondingWatcher) reportUnbondingToBabylon( - ctx context.Context, - stakingTxHash chainhash.Hash, - unbondingSignature *schnorr.Signature, -) { - _ = retry.Do(func() error { - active, err := uw.babylonNodeAdapter.IsDelegationActive(stakingTxHash) - - if err != nil { - return fmt.Errorf("error checking if delegation is active: %v", err) - } - - if !active { - // - uw.logger.Debugf("cannot report unbonding. delegation for staking tx %s is no longer active", stakingTxHash) - return nil - } - - err = uw.babylonNodeAdapter.ReportUnbonding(ctx, stakingTxHash, unbondingSignature) - - if err != nil { - uw.metrics.FailedReportedUnbondingTransactions.Inc() - return fmt.Errorf("error reporting unbonding tx %s to babylon: %v", stakingTxHash, err) - } - - uw.metrics.ReportedUnbondingTransactionsCounter.Inc() - return nil - }, - retry.Context(ctx), - retryForever, - fixedDelyTypeWithJitter, - retry.MaxDelay(uw.cfg.RetrySubmitUnbondingTxInterval), - retry.MaxJitter(uw.cfg.RetryJitter), - retry.OnRetry(func(n uint, err error) { - uw.logger.Debugf("retrying submitting unbodning tx, for staking tx: %s. Attempt: %d. Err: %v", stakingTxHash, n, err) - }), - ) -} - -func (uw *UnbondingWatcher) watchForSpend(spendEvent *notifier.SpendEvent, td *TrackedDelegation) { - defer uw.wg.Done() - quitCtx, cancel := uw.quitContext() - defer cancel() - - var spendingTx *wire.MsgTx = nil - select { - case spendDetail := <-spendEvent.Spend: - spendingTx = spendDetail.SpendingTx - case <-uw.quit: - return - } - - schnorrSignature, err := tryParseStakerSignatureFromSpentTx(spendingTx, td) - delegationId := td.StakingTx.TxHash() - spendingTxHash := spendingTx.TxHash() - - if err != nil { - uw.metrics.DetectedNonUnbondingTransactionsCounter.Inc() - // Error means that this is not unbonding tx. At this point, it means that it is - // either withdrawal transaction or slashing transaction spending staking staking output. - // As we only care about unbonding transactions, we do not need to take additional actions. - // We start polling babylon for delegation to stop being active, and then delete it from tracker. - uw.logger.Debugf("Spending tx %s for staking tx %s is not unbonding tx. Info: %v", spendingTxHash, delegationId, err) - uw.waitForDelegationToStopBeingActive(quitCtx, delegationId) - } else { - uw.metrics.DetectedUnbondingTransactionsCounter.Inc() - // We found valid unbonding tx. We need to try to report it to babylon. - // We stop reporting if delegation is no longer active or we succeed. - uw.logger.Debugf("found unbonding tx %s for staking tx %s", spendingTxHash, delegationId) - uw.reportUnbondingToBabylon(quitCtx, delegationId, schnorrSignature) - uw.logger.Debugf("unbonding tx %s for staking tx %s reported to babylon", spendingTxHash, delegationId) - } - - utils.PushOrQuit[*delegationInactive]( - uw.delegetionInactiveChan, - &delegationInactive{stakingTxHash: delegationId}, - uw.quit, - ) -} - -func (uw *UnbondingWatcher) handleDelegations() { - defer uw.wg.Done() - for { - select { - case newDelegation := <-uw.newDelegationChan: - uw.logger.Debugf("Received new delegation to watch for staking transaction with hash %s", newDelegation.stakingTxHash) - - del, err := uw.tracker.AddDelegation( - newDelegation.stakingTx, - newDelegation.stakingOutputIdx, - newDelegation.unbondingOutput, - ) - - if err != nil { - uw.logger.Errorf("error adding delegation to tracker: %v", err) - continue - } - - uw.metrics.NumberOfTrackedActiveDelegations.Inc() - - stakingOutpoint := wire.OutPoint{ - Hash: newDelegation.stakingTxHash, - Index: newDelegation.stakingOutputIdx, - } - - spendEv, err := uw.btcNotifier.RegisterSpendNtfn( - &stakingOutpoint, - newDelegation.stakingTx.TxOut[newDelegation.stakingOutputIdx].PkScript, - uint32(newDelegation.delegationStartHeight), - ) - - if err != nil { - uw.logger.Errorf("error registering spend ntfn for staking tx %s: %v", newDelegation.stakingTxHash, err) - continue - } - - uw.wg.Add(1) - go uw.watchForSpend(spendEv, del) - case in := <-uw.delegetionInactiveChan: - uw.logger.Debugf("Delegation for staking transaction with hash %s stopped being active", in.stakingTxHash) - // remove delegation from tracker - uw.tracker.RemoveDelegation(in.stakingTxHash) - - uw.metrics.NumberOfTrackedActiveDelegations.Dec() - - case <-uw.quit: - uw.logger.Debug("handle delegations loop quit") - return - } - } -} diff --git a/cmd/vigilante/cmd/btcstaking_tracker.go b/cmd/vigilante/cmd/btcstaking_tracker.go index 3f650e44..c1d78b7d 100644 --- a/cmd/vigilante/cmd/btcstaking_tracker.go +++ b/cmd/vigilante/cmd/btcstaking_tracker.go @@ -75,7 +75,7 @@ func GetBTCStakingTracker() *cobra.Command { bsMetrics := metrics.NewBTCStakingTrackerMetrics() - bstracker := bst.NewBTCSTakingTracker( + bstracker := bst.NewBTCStakingTracker( btcClient, btcNotifier, bbnClient, diff --git a/e2etest/atomicslasher_e2e_test.go b/e2etest/atomicslasher_e2e_test.go index dc02e0de..aa85d872 100644 --- a/e2etest/atomicslasher_e2e_test.go +++ b/e2etest/atomicslasher_e2e_test.go @@ -49,7 +49,7 @@ func TestAtomicSlasher(t *testing.T) { bstCfg := config.DefaultBTCStakingTrackerConfig() bstCfg.CheckDelegationsInterval = 1 * time.Second - bsTracker := bst.NewBTCSTakingTracker( + bsTracker := bst.NewBTCStakingTracker( tm.BTCClient, backend, tm.BabylonClient, @@ -171,7 +171,7 @@ func TestAtomicSlasher_Unbonding(t *testing.T) { stakingTrackerMetrics := metrics.NewBTCStakingTrackerMetrics() - bsTracker := bst.NewBTCSTakingTracker( + bsTracker := bst.NewBTCStakingTracker( tm.BTCClient, backend, tm.BabylonClient, diff --git a/e2etest/slasher_e2e_test.go b/e2etest/slasher_e2e_test.go index fbc23a7a..4f717706 100644 --- a/e2etest/slasher_e2e_test.go +++ b/e2etest/slasher_e2e_test.go @@ -45,7 +45,7 @@ func TestSlasher_GracefulShutdown(t *testing.T) { stakingTrackerMetrics := metrics.NewBTCStakingTrackerMetrics() - bsTracker := bst.NewBTCSTakingTracker( + bsTracker := bst.NewBTCStakingTracker( tm.BTCClient, backend, tm.BabylonClient, @@ -95,7 +95,7 @@ func TestSlasher_Slasher(t *testing.T) { bstCfg.CheckDelegationsInterval = 1 * time.Second stakingTrackerMetrics := metrics.NewBTCStakingTrackerMetrics() - bsTracker := bst.NewBTCSTakingTracker( + bsTracker := bst.NewBTCStakingTracker( tm.BTCClient, backend, tm.BabylonClient, @@ -164,7 +164,7 @@ func TestSlasher_SlashingUnbonding(t *testing.T) { bstCfg.CheckDelegationsInterval = 1 * time.Second stakingTrackerMetrics := metrics.NewBTCStakingTrackerMetrics() - bsTracker := bst.NewBTCSTakingTracker( + bsTracker := bst.NewBTCStakingTracker( tm.BTCClient, backend, tm.BabylonClient, @@ -259,7 +259,7 @@ func TestSlasher_Bootstrapping(t *testing.T) { bstCfg.CheckDelegationsInterval = 1 * time.Second stakingTrackerMetrics := metrics.NewBTCStakingTrackerMetrics() - bsTracker := bst.NewBTCSTakingTracker( + bsTracker := bst.NewBTCStakingTracker( tm.BTCClient, backend, tm.BabylonClient, diff --git a/e2etest/test_manager_btcstaking.go b/e2etest/test_manager_btcstaking.go index d938c052..cc0319fa 100644 --- a/e2etest/test_manager_btcstaking.go +++ b/e2etest/test_manager_btcstaking.go @@ -106,40 +106,7 @@ func (tm *TestManager) CreateBTCDelegation( stakingValue := int64(topUTXO.Amount) / 3 // generate legitimate BTC del - stakingSlashingInfo := datagen.GenBTCStakingSlashingInfoWithOutPoint( - r, - t, - regtestParams, - topUTXO.GetOutPoint(), - tm.WalletPrivKey, - []*btcec.PublicKey{fpPK}, - covenantBtcPks, - bsParams.Params.CovenantQuorum, - uint16(stakingTimeBlocks), - stakingValue, - bsParams.Params.SlashingPkScript, - bsParams.Params.SlashingRate, - uint16(tm.getBTCUnbondingTime(t)), - ) - // sign staking tx and overwrite the staking tx to the signed version - // NOTE: the tx hash has changed here since stakingMsgTx is pre-segwit - stakingMsgTx, signed, err := tm.BTCClient.SignRawTransactionWithWallet(stakingSlashingInfo.StakingTx) - require.NoError(t, err) - require.True(t, signed) - // overwrite staking tx - stakingSlashingInfo.StakingTx = stakingMsgTx - // get signed staking tx hash - stakingMsgTxHash1 := stakingSlashingInfo.StakingTx.TxHash() - stakingMsgTxHash := &stakingMsgTxHash1 - t.Logf("signed staking tx hash: %s", stakingMsgTxHash.String()) - - // change outpoint tx hash of slashing tx to the txhash of the signed staking tx - slashingMsgTx, err := stakingSlashingInfo.SlashingTx.ToMsgTx() - require.NoError(t, err) - slashingMsgTx.TxIn[0].PreviousOutPoint.Hash = stakingSlashingInfo.StakingTx.TxHash() - // update slashing tx - stakingSlashingInfo.SlashingTx, err = bstypes.NewBTCSlashingTxFromMsgTx(slashingMsgTx) - require.NoError(t, err) + stakingMsgTx, stakingSlashingInfo, stakingMsgTxHash := tm.createStakingAndSlashingTx(t, fpSK, bsParams, covenantBtcPks, topUTXO, stakingValue, stakingTimeBlocks) // send staking tx to Bitcoin node's mempool _, err = tm.BTCClient.SendRawTransaction(stakingMsgTx, true) @@ -178,6 +145,7 @@ func (tm *TestManager) CreateBTCDelegation( require.NoError(t, err) // generate proper delegator sig require.NoError(t, err) + delegatorSig, err := stakingSlashingInfo.SlashingTx.Sign( stakingMsgTx, stakingOutIdx, @@ -186,41 +154,21 @@ func (tm *TestManager) CreateBTCDelegation( ) require.NoError(t, err) - // Genearate all data necessary for unbonding - fee := int64(1000) - unbondingValue := stakingSlashingInfo.StakingInfo.StakingOutput.Value - fee - unbondingSlashingInfo := datagen.GenBTCUnbondingSlashingInfo( - r, + // Generate all data necessary for unbonding + unbondingSlashingInfo, unbondingSlashingPathSpendInfo, unbondingTxBytes, slashingTxSig := tm.createUnbondingData( t, - regtestParams, - tm.WalletPrivKey, - []*btcec.PublicKey{fpPK}, + fpPK, + bsParams, covenantBtcPks, - bsParams.Params.CovenantQuorum, - wire.NewOutPoint(stakingMsgTxHash, stakingOutIdx), - uint16(stakingTimeBlocks), - unbondingValue, - bsParams.Params.SlashingPkScript, - bsParams.Params.SlashingRate, - uint16(tm.getBTCUnbondingTime(t)), - ) - require.NoError(t, err) - unbondingTxBytes, err := bbn.SerializeBTCTx(unbondingSlashingInfo.UnbondingTx) - require.NoError(t, err) - - unbondingSlashingPathSpendInfo, err := unbondingSlashingInfo.UnbondingInfo.SlashingPathSpendInfo() - require.NoError(t, err) - slashingTxSig, err := unbondingSlashingInfo.SlashingTx.Sign( - unbondingSlashingInfo.UnbondingTx, - 0, // Only one output in the unbonding tx - unbondingSlashingPathSpendInfo.GetPkScriptPath(), - tm.WalletPrivKey, + stakingSlashingInfo, + stakingMsgTxHash, + stakingOutIdx, + stakingTimeBlocks, ) - require.NoError(t, err) - // Build message to send tm.CatchUpBTCLightClient(t) + // Build a message to send // submit BTC delegation to Babylon msgBTCDel := &bstypes.MsgCreateBTCDelegation{ StakerAddr: signerAddr, @@ -236,7 +184,7 @@ func (tm *TestManager) CreateBTCDelegation( }, SlashingTx: stakingSlashingInfo.SlashingTx, DelegatorSlashingSig: delegatorSig, - // Ubonding related data + // Unbonding related data UnbondingTime: uint32(tm.getBTCUnbondingTime(t)), UnbondingTx: unbondingTxBytes, UnbondingValue: unbondingSlashingInfo.UnbondingInfo.UnbondingOutput.Value, @@ -247,9 +195,257 @@ func (tm *TestManager) CreateBTCDelegation( require.NoError(t, err) t.Logf("submitted MsgCreateBTCDelegation") + // generate and insert new covenant signature, to activate the BTC delegation + tm.addCovenantSig( + t, + signerAddr, + stakingMsgTx, + stakingMsgTxHash, + fpSK, slashingSpendPath, + stakingSlashingInfo, + unbondingSlashingInfo, + unbondingSlashingPathSpendInfo, + stakingOutIdx, + ) + + return stakingSlashingInfo, unbondingSlashingInfo, tm.WalletPrivKey +} + +func (tm *TestManager) CreateBTCDelegationWithoutIncl( + t *testing.T, + fpSK *btcec.PrivateKey, +) (*datagen.TestStakingSlashingInfo, *datagen.TestUnbondingSlashingInfo, *btcec.PrivateKey) { + signerAddr := tm.BabylonClient.MustGetAddr() + addr := sdk.MustAccAddressFromBech32(signerAddr) + + fpPK := fpSK.PubKey() + /* - generate and insert new covenant signature, in order to activate the BTC delegation + create BTC delegation */ + // generate staking tx and slashing tx + bsParams, err := tm.BabylonClient.BTCStakingParams() + require.NoError(t, err) + covenantBtcPks, err := bbnPksToBtcPks(bsParams.Params.CovenantPks) + require.NoError(t, err) + stakingTimeBlocks := bsParams.Params.MaxStakingTimeBlocks + // get top UTXO + topUnspentResult, _, err := tm.BTCClient.GetHighUTXOAndSum() + require.NoError(t, err) + topUTXO, err := types.NewUTXO(topUnspentResult, regtestParams) + require.NoError(t, err) + // staking value + stakingValue := int64(topUTXO.Amount) / 3 + + // generate legitimate BTC del + stakingMsgTx, stakingSlashingInfo, stakingMsgTxHash := tm.createStakingAndSlashingTx(t, fpSK, bsParams, covenantBtcPks, topUTXO, stakingValue, stakingTimeBlocks) + + stakingOutIdx, err := outIdx(stakingSlashingInfo.StakingTx, stakingSlashingInfo.StakingInfo.StakingOutput) + require.NoError(t, err) + + // create PoP + pop, err := bstypes.NewPoPBTC(addr, tm.WalletPrivKey) + require.NoError(t, err) + slashingSpendPath, err := stakingSlashingInfo.StakingInfo.SlashingPathSpendInfo() + require.NoError(t, err) + // generate proper delegator sig + require.NoError(t, err) + + delegatorSig, err := stakingSlashingInfo.SlashingTx.Sign( + stakingMsgTx, + stakingOutIdx, + slashingSpendPath.GetPkScriptPath(), + tm.WalletPrivKey, + ) + require.NoError(t, err) + + // Generate all data necessary for unbonding + unbondingSlashingInfo, unbondingSlashingPathSpendInfo, unbondingTxBytes, slashingTxSig := tm.createUnbondingData( + t, + fpPK, + bsParams, + covenantBtcPks, + stakingSlashingInfo, + stakingMsgTxHash, + stakingOutIdx, + stakingTimeBlocks, + ) + + var stakingTxBuf bytes.Buffer + err = stakingMsgTx.Serialize(&stakingTxBuf) + require.NoError(t, err) + + // submit BTC delegation to Babylon + msgBTCDel := &bstypes.MsgCreateBTCDelegation{ + StakerAddr: signerAddr, + Pop: pop, + BtcPk: bbn.NewBIP340PubKeyFromBTCPK(tm.WalletPrivKey.PubKey()), + FpBtcPkList: []bbn.BIP340PubKey{*bbn.NewBIP340PubKeyFromBTCPK(fpPK)}, + StakingTime: stakingTimeBlocks, + StakingValue: stakingValue, + StakingTx: stakingTxBuf.Bytes(), + StakingTxInclusionProof: nil, + SlashingTx: stakingSlashingInfo.SlashingTx, + DelegatorSlashingSig: delegatorSig, + // Unbonding related data + UnbondingTime: uint32(tm.getBTCUnbondingTime(t)), + UnbondingTx: unbondingTxBytes, + UnbondingValue: unbondingSlashingInfo.UnbondingInfo.UnbondingOutput.Value, + UnbondingSlashingTx: unbondingSlashingInfo.SlashingTx, + DelegatorUnbondingSlashingSig: slashingTxSig, + } + _, err = tm.BabylonClient.ReliablySendMsg(context.Background(), msgBTCDel, nil, nil) + require.NoError(t, err) + t.Logf("submitted MsgCreateBTCDelegation") + + // generate and insert new covenant signature, to activate the BTC delegation + tm.addCovenantSig( + t, + signerAddr, + stakingMsgTx, + stakingMsgTxHash, + fpSK, slashingSpendPath, + stakingSlashingInfo, + unbondingSlashingInfo, + unbondingSlashingPathSpendInfo, + stakingOutIdx, + ) + + // send staking tx to Bitcoin node's mempool + _, err = tm.BTCClient.SendRawTransaction(stakingMsgTx, true) + require.NoError(t, err) + + require.Eventually(t, func() bool { + return len(tm.RetrieveTransactionFromMempool(t, []*chainhash.Hash{stakingMsgTxHash})) == 1 + }, eventuallyWaitTimeOut, eventuallyPollTime) + + mBlock := tm.mineBlock(t) + require.Equal(t, 2, len(mBlock.Transactions)) + + // wait until staking tx is on Bitcoin + require.Eventually(t, func() bool { + _, err := tm.BTCClient.GetRawTransaction(stakingMsgTxHash) + return err == nil + }, eventuallyWaitTimeOut, eventuallyPollTime) + + // insert k empty blocks to Bitcoin + btccParamsResp, err := tm.BabylonClient.BTCCheckpointParams() + require.NoError(t, err) + btccParams := btccParamsResp.Params + for i := 0; i < int(btccParams.BtcConfirmationDepth); i++ { + tm.mineBlock(t) + } + + tm.CatchUpBTCLightClient(t) + + return stakingSlashingInfo, unbondingSlashingInfo, tm.WalletPrivKey +} + +func (tm *TestManager) createStakingAndSlashingTx( + t *testing.T, fpSK *btcec.PrivateKey, + bsParams *bstypes.QueryParamsResponse, + covenantBtcPks []*btcec.PublicKey, + topUTXO *types.UTXO, + stakingValue int64, + stakingTimeBlocks uint32, +) (*wire.MsgTx, *datagen.TestStakingSlashingInfo, *chainhash.Hash) { + // generate staking tx and slashing tx + fpPK := fpSK.PubKey() + + // generate legitimate BTC del + stakingSlashingInfo := datagen.GenBTCStakingSlashingInfoWithOutPoint( + r, + t, + regtestParams, + topUTXO.GetOutPoint(), + tm.WalletPrivKey, + []*btcec.PublicKey{fpPK}, + covenantBtcPks, + bsParams.Params.CovenantQuorum, + uint16(stakingTimeBlocks), + stakingValue, + bsParams.Params.SlashingPkScript, + bsParams.Params.SlashingRate, + uint16(tm.getBTCUnbondingTime(t)), + ) + // sign staking tx and overwrite the staking tx to the signed version + // NOTE: the tx hash has changed here since stakingMsgTx is pre-segwit + stakingMsgTx, signed, err := tm.BTCClient.SignRawTransactionWithWallet(stakingSlashingInfo.StakingTx) + require.NoError(t, err) + require.True(t, signed) + // overwrite staking tx + stakingSlashingInfo.StakingTx = stakingMsgTx + // get signed staking tx hash + stakingMsgTxHash1 := stakingSlashingInfo.StakingTx.TxHash() + stakingMsgTxHash := &stakingMsgTxHash1 + t.Logf("signed staking tx hash: %s", stakingMsgTxHash.String()) + + // change outpoint tx hash of slashing tx to the txhash of the signed staking tx + slashingMsgTx, err := stakingSlashingInfo.SlashingTx.ToMsgTx() + require.NoError(t, err) + slashingMsgTx.TxIn[0].PreviousOutPoint.Hash = stakingSlashingInfo.StakingTx.TxHash() + // update slashing tx + stakingSlashingInfo.SlashingTx, err = bstypes.NewBTCSlashingTxFromMsgTx(slashingMsgTx) + require.NoError(t, err) + + return stakingMsgTx, stakingSlashingInfo, stakingMsgTxHash +} + +func (tm *TestManager) createUnbondingData( + t *testing.T, + fpPK *btcec.PublicKey, + bsParams *bstypes.QueryParamsResponse, + covenantBtcPks []*btcec.PublicKey, + stakingSlashingInfo *datagen.TestStakingSlashingInfo, + stakingMsgTxHash *chainhash.Hash, + stakingOutIdx uint32, + stakingTimeBlocks uint32, +) (*datagen.TestUnbondingSlashingInfo, *btcstaking.SpendInfo, []byte, *bbn.BIP340Signature) { + fee := int64(1000) + unbondingValue := stakingSlashingInfo.StakingInfo.StakingOutput.Value - fee + unbondingSlashingInfo := datagen.GenBTCUnbondingSlashingInfo( + r, + t, + regtestParams, + tm.WalletPrivKey, + []*btcec.PublicKey{fpPK}, + covenantBtcPks, + bsParams.Params.CovenantQuorum, + wire.NewOutPoint(stakingMsgTxHash, stakingOutIdx), + uint16(stakingTimeBlocks), + unbondingValue, + bsParams.Params.SlashingPkScript, + bsParams.Params.SlashingRate, + uint16(tm.getBTCUnbondingTime(t)), + ) + unbondingTxBytes, err := bbn.SerializeBTCTx(unbondingSlashingInfo.UnbondingTx) + require.NoError(t, err) + + unbondingSlashingPathSpendInfo, err := unbondingSlashingInfo.UnbondingInfo.SlashingPathSpendInfo() + require.NoError(t, err) + slashingTxSig, err := unbondingSlashingInfo.SlashingTx.Sign( + unbondingSlashingInfo.UnbondingTx, + 0, // Only one output in the unbonding tx + unbondingSlashingPathSpendInfo.GetPkScriptPath(), + tm.WalletPrivKey, + ) + require.NoError(t, err) + + return unbondingSlashingInfo, unbondingSlashingPathSpendInfo, unbondingTxBytes, slashingTxSig +} + +func (tm *TestManager) addCovenantSig( + t *testing.T, + signerAddr string, + stakingMsgTx *wire.MsgTx, + stakingMsgTxHash *chainhash.Hash, + fpSK *btcec.PrivateKey, + slashingSpendPath *btcstaking.SpendInfo, + stakingSlashingInfo *datagen.TestStakingSlashingInfo, + unbondingSlashingInfo *datagen.TestUnbondingSlashingInfo, + unbondingSlashingPathSpendInfo *btcstaking.SpendInfo, + stakingOutIdx uint32, +) { // TODO: Make this handle multiple covenant signatures fpEncKey, err := asig.NewEncryptionKeyFromBTCPK(fpSK.PubKey()) require.NoError(t, err) @@ -261,7 +457,6 @@ func (tm *TestManager) CreateBTCDelegation( fpEncKey, ) require.NoError(t, err) - // TODO: Add covenant sigs for all covenants // add covenant sigs // covenant Schnorr sig on unbonding tx @@ -297,8 +492,6 @@ func (tm *TestManager) CreateBTCDelegation( _, err = tm.BabylonClient.ReliablySendMsg(context.Background(), msgAddCovenantSig, nil, nil) require.NoError(t, err) t.Logf("submitted covenant signature") - - return stakingSlashingInfo, unbondingSlashingInfo, tm.WalletPrivKey } func (tm *TestManager) Undelegate( diff --git a/e2etest/unbondingwatcher_e2e_test.go b/e2etest/unbondingwatcher_e2e_test.go index d03ee666..4d50811e 100644 --- a/e2etest/unbondingwatcher_e2e_test.go +++ b/e2etest/unbondingwatcher_e2e_test.go @@ -46,7 +46,7 @@ func TestUnbondingWatcher(t *testing.T) { bstCfg.CheckDelegationsInterval = 1 * time.Second stakingTrackerMetrics := metrics.NewBTCStakingTrackerMetrics() - bsTracker := bst.NewBTCSTakingTracker( + bsTracker := bst.NewBTCStakingTracker( tm.BTCClient, backend, tm.BabylonClient, @@ -113,3 +113,60 @@ func TestUnbondingWatcher(t *testing.T) { }, eventuallyWaitTimeOut, eventuallyPollTime) } + +// TestActivatingDelegation verifies that a delegation created without an inclusion proof will +// eventually become "active". +// Specifically, that stakingEventWatcher will send a MsgAddBTCDelegationInclusionProof to do so. +func TestActivatingDelegation(t *testing.T) { + t.Parallel() + // segwit is activated at height 300. It's necessary for staking/slashing tx + numMatureOutputs := uint32(300) + + tm := StartManager(t, numMatureOutputs, defaultEpochInterval) + defer tm.Stop(t) + // Insert all existing BTC headers to babylon node + tm.CatchUpBTCLightClient(t) + + btcNotifier, err := btcclient.NewNodeBackend( + btcclient.ToBitcoindConfig(tm.Config.BTC), + &chaincfg.RegressionNetParams, + &btcclient.EmptyHintCache{}, + ) + require.NoError(t, err) + + err = btcNotifier.Start() + require.NoError(t, err) + + commonCfg := config.DefaultCommonConfig() + bstCfg := config.DefaultBTCStakingTrackerConfig() + bstCfg.CheckDelegationsInterval = 1 * time.Second + stakingTrackerMetrics := metrics.NewBTCStakingTrackerMetrics() + + bsTracker := bst.NewBTCStakingTracker( + tm.BTCClient, + btcNotifier, + tm.BabylonClient, + &bstCfg, + &commonCfg, + zap.NewNop(), + stakingTrackerMetrics, + ) + bsTracker.Start() + defer bsTracker.Stop() + + // set up a finality provider + _, fpSK := tm.CreateFinalityProvider(t) + // set up a BTC delegation + stakingSlashingInfo, _, _ := tm.CreateBTCDelegationWithoutIncl(t, fpSK) + + // created delegation lacks inclusion proof, once created it will be in + // pending status, once convenant signatures are added it will be in verified status, + // and once the stakingEventWatcher submits MsgAddBTCDelegationInclusionProof it will + // be in active status + require.Eventually(t, func() bool { + resp, err := tm.BabylonClient.BTCDelegation(stakingSlashingInfo.StakingTx.TxHash().String()) + require.NoError(t, err) + + return resp.BtcDelegation.Active + }, eventuallyWaitTimeOut, eventuallyPollTime) +} diff --git a/metrics/btcstaking_tracker.go b/metrics/btcstaking_tracker.go index 989c707c..17165478 100644 --- a/metrics/btcstaking_tracker.go +++ b/metrics/btcstaking_tracker.go @@ -29,6 +29,8 @@ type UnbondingWatcherMetrics struct { NumberOfTrackedActiveDelegations prometheus.Gauge DetectedUnbondingTransactionsCounter prometheus.Counter DetectedNonUnbondingTransactionsCounter prometheus.Counter + FailedReportedActivateDelegations prometheus.Counter + ReportedActivateDelegationsCounter prometheus.Counter } func newUnbondingWatcherMetrics(registry *prometheus.Registry) *UnbondingWatcherMetrics { @@ -56,6 +58,14 @@ func newUnbondingWatcherMetrics(registry *prometheus.Registry) *UnbondingWatcher Name: "unbonding_watcher_detected_non_unbonding_transactions", Help: "The total number of non unbonding (slashing or withdrawal) transactions detected by unbonding watcher", }), + FailedReportedActivateDelegations: registerer.NewCounter(prometheus.CounterOpts{ + Name: "unbonding_watcher_failed_reported_activate_delegation", + Help: "The total number times reporting activation delegation failed on Babylon node", + }), + ReportedActivateDelegationsCounter: registerer.NewCounter(prometheus.CounterOpts{ + Name: "unbonding_watcher_reported_activate_delegations", + Help: "The total number of unbonding transactions successfuly reported to Babylon node", + }), } return uwMetrics diff --git a/testutil/mocks/btcclient.go b/testutil/mocks/btcclient.go index b8a6a075..1cb5c03b 100644 --- a/testutil/mocks/btcclient.go +++ b/testutil/mocks/btcclient.go @@ -176,6 +176,22 @@ func (mr *MockBTCClientMockRecorder) Stop() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Stop", reflect.TypeOf((*MockBTCClient)(nil).Stop)) } +// TxDetails mocks base method. +func (m *MockBTCClient) TxDetails(txHash *chainhash.Hash, pkScript []byte) (*chainntnfs.TxConfirmation, btcclient.TxStatus, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "TxDetails", txHash, pkScript) + ret0, _ := ret[0].(*chainntnfs.TxConfirmation) + ret1, _ := ret[1].(btcclient.TxStatus) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// TxDetails indicates an expected call of TxDetails. +func (mr *MockBTCClientMockRecorder) TxDetails(txHash, pkScript interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TxDetails", reflect.TypeOf((*MockBTCClient)(nil).TxDetails), txHash, pkScript) +} + // WaitForShutdown mocks base method. func (m *MockBTCClient) WaitForShutdown() { m.ctrl.T.Helper()