diff --git a/internal/services/delegation.go b/internal/services/delegation.go index 51a050e..48b7fcb 100644 --- a/internal/services/delegation.go +++ b/internal/services/delegation.go @@ -122,11 +122,11 @@ func (s *Service) processCovenantQuorumReachedEvent( return err } - proceed, err := s.validateCovenantQuorumReachedEvent(ctx, covenantQuorumReachedEvent) + shouldProcess, err := s.validateCovenantQuorumReachedEvent(ctx, covenantQuorumReachedEvent) if err != nil { return err } - if !proceed { + if !shouldProcess { // Ignore the event silently return nil } @@ -168,11 +168,11 @@ func (s *Service) processBTCDelegationInclusionProofReceivedEvent( return err } - proceed, err := s.validateBTCDelegationInclusionProofReceivedEvent(ctx, inclusionProofEvent) + shouldProcess, err := s.validateBTCDelegationInclusionProofReceivedEvent(ctx, inclusionProofEvent) if err != nil { return err } - if !proceed { + if !shouldProcess { // Ignore the event silently return nil } @@ -225,10 +225,13 @@ func (s *Service) processBTCDelegationUnbondedEarlyEvent( return nil } - // Get delegation details - delegation, err := s.getDelegationDetails(ctx, unbondedEarlyEvent.StakingTxHash) - if err != nil { - return err + delegation, dbErr := s.db.GetBTCDelegationByStakingTxHash(ctx, unbondedEarlyEvent.StakingTxHash) + if dbErr != nil { + return types.NewError( + http.StatusInternalServerError, + types.InternalServiceError, + fmt.Errorf("failed to get BTC delegation by staking tx hash: %w", dbErr), + ) } // Emit consumer event @@ -241,8 +244,8 @@ func (s *Service) processBTCDelegationUnbondedEarlyEvent( return err } - // Start watching for spend - if err := s.startWatchingUnbondingSpend(ctx, delegation); err != nil { + // Register unbonding spend notification + if err := s.registerUnbondingSpendNotification(ctx, delegation); err != nil { return err } @@ -269,10 +272,13 @@ func (s *Service) processBTCDelegationExpiredEvent( return nil } - // Get delegation details - delegation, err := s.getDelegationDetails(ctx, expiredEvent.StakingTxHash) - if err != nil { - return err + delegation, dbErr := s.db.GetBTCDelegationByStakingTxHash(ctx, expiredEvent.StakingTxHash) + if dbErr != nil { + return types.NewError( + http.StatusInternalServerError, + types.InternalServiceError, + fmt.Errorf("failed to get BTC delegation by staking tx hash: %w", dbErr), + ) } // Emit consumer event @@ -280,13 +286,35 @@ func (s *Service) processBTCDelegationExpiredEvent( return err } - // Handle expiry process - if err := s.handleExpiryProcess(ctx, delegation); err != nil { - return err + // Save timelock expire + if err := s.db.SaveNewTimeLockExpire( + ctx, + delegation.StakingTxHashHex, + delegation.EndHeight, + types.ExpiredTxType.String(), + ); err != nil { + return types.NewError( + http.StatusInternalServerError, + types.InternalServiceError, + fmt.Errorf("failed to save timelock expire: %w", err), + ) } - // Start watching for spend - if err := s.startWatchingStakingSpend(ctx, delegation); err != nil { + // Update delegation state + if err := s.db.UpdateBTCDelegationState( + ctx, + delegation.StakingTxHashHex, + types.StateUnbonding, + ); err != nil { + return types.NewError( + http.StatusInternalServerError, + types.InternalServiceError, + fmt.Errorf("failed to update BTC delegation state: %w", err), + ) + } + + // Register spend notification + if err := s.registerStakingSpendNotification(ctx, delegation); err != nil { return err } @@ -326,9 +354,5 @@ func (s *Service) processSlashedFinalityProviderEvent( ) } - // TODO: babylon needs to emit slashing tx - // so indexer can start watching for slashing spend - // to identify if staker has withdrawn after slashing - return nil } diff --git a/internal/services/delegation_helpers.go b/internal/services/delegation_helpers.go index 258ea6c..f8fc9ca 100644 --- a/internal/services/delegation_helpers.go +++ b/internal/services/delegation_helpers.go @@ -16,22 +16,6 @@ import ( "github.com/btcsuite/btcd/wire" ) -// Delegation helper functions -func (s *Service) getDelegationDetails( - ctx context.Context, - stakingTxHash string, -) (*model.BTCDelegationDetails, *types.Error) { - delegation, dbErr := s.db.GetBTCDelegationByStakingTxHash(ctx, stakingTxHash) - if dbErr != nil { - return nil, types.NewError( - http.StatusInternalServerError, - types.InternalServiceError, - fmt.Errorf("failed to get BTC delegation by staking tx hash: %w", dbErr), - ) - } - return delegation, nil -} - func (s *Service) handleUnbondingProcess( ctx context.Context, event *bbntypes.EventBTCDelgationUnbondedEarly, @@ -77,7 +61,7 @@ func (s *Service) handleUnbondingProcess( return nil } -func (s *Service) startWatchingUnbondingSpend( +func (s *Service) registerUnbondingSpendNotification( ctx context.Context, delegation *model.BTCDelegationDetails, ) *types.Error { @@ -123,41 +107,7 @@ func (s *Service) startWatchingUnbondingSpend( return nil } -func (s *Service) handleExpiryProcess( - ctx context.Context, - delegation *model.BTCDelegationDetails, -) *types.Error { - // Save timelock expire - if err := s.db.SaveNewTimeLockExpire( - ctx, - delegation.StakingTxHashHex, - delegation.EndHeight, - types.ExpiredTxType.String(), - ); err != nil { - return types.NewError( - http.StatusInternalServerError, - types.InternalServiceError, - fmt.Errorf("failed to save timelock expire: %w", err), - ) - } - - // Update delegation state - if err := s.db.UpdateBTCDelegationState( - ctx, - delegation.StakingTxHashHex, - types.StateUnbonding, - ); err != nil { - return types.NewError( - http.StatusInternalServerError, - types.InternalServiceError, - fmt.Errorf("failed to update BTC delegation state: %w", err), - ) - } - - return nil -} - -func (s *Service) startWatchingStakingSpend( +func (s *Service) registerStakingSpendNotification( ctx context.Context, delegation *model.BTCDelegationDetails, ) *types.Error { diff --git a/internal/services/watch_btc_events.go b/internal/services/watch_btc_events.go index 19c65da..a360e1b 100644 --- a/internal/services/watch_btc_events.go +++ b/internal/services/watch_btc_events.go @@ -74,7 +74,49 @@ func (s *Service) watchForSpendUnbondingTx( case <-quitCtx.Done(): return } +} +func (s *Service) watchForSpendSlashingChange( + spendEvent *notifier.SpendEvent, + delegation *model.BTCDelegationDetails, +) { + defer s.wg.Done() + quitCtx, cancel := s.quitContext() + defer cancel() + + select { + case spendDetail := <-spendEvent.Spend: + log.Info(). + Str("delegation", delegation.StakingTxHashHex). + Str("spending_tx", spendDetail.SpendingTx.TxHash().String()). + Msg("slashing change output has been spent") + delegationState, err := s.db.GetBTCDelegationState(quitCtx, delegation.StakingTxHashHex) + if err != nil { + log.Error().Err(err).Msg("failed to get delegation state") + return + } + + qualifiedStates := types.QualifiedStatesForSlashedWithdrawn() + if qualifiedStates == nil || !utils.Contains(qualifiedStates, *delegationState) { + log.Error().Msgf("current state %s is not qualified for slashed withdrawn", *delegationState) + return + } + + // Update to withdrawn state + if err := s.db.UpdateBTCDelegationState( + quitCtx, + delegation.StakingTxHashHex, + types.StateSlashedWithdrawn, + ); err != nil { + log.Error().Err(err).Msg("failed to update delegation state") + return + } + + case <-s.quit: + return + case <-quitCtx.Done(): + return + } } func (s *Service) handleSpendingStakingTransaction( @@ -88,49 +130,39 @@ func (s *Service) handleSpendingStakingTransaction( return fmt.Errorf("failed to get staking params: %w", err) } - // check whether it is a valid unbonding tx + // First try to validate as unbonding tx isUnbonding, err := s.IsValidUnbondingTx(tx, delegation, params) if err != nil { - if errors.Is(err, types.ErrInvalidUnbondingTx) { - return nil - } return fmt.Errorf("failed to validate unbonding tx: %w", err) } + if isUnbonding { + // It's a valid unbonding tx, no further action needed at this stage + return nil + } - if !isUnbonding { - // not an unbonding tx, so this is a withdraw tx from the staking, - // validate it and process it - if err := s.validateWithdrawalTxFromStaking(tx, spendingInputIdx, delegation, params); err != nil { - if errors.Is(err, types.ErrInvalidWithdrawalTx) { - // TODO: consider slashing transaction for phase-2 - return nil - } - return fmt.Errorf("failed to validate withdrawal tx from staking: %w", err) - } - - delegationState, err := s.db.GetBTCDelegationState(ctx, delegation.StakingTxHashHex) - if err != nil { - return fmt.Errorf("failed to get delegation state: %w", err) - } - - qualifiedStates := types.QualifiedStatesForWithdrawn() - if qualifiedStates == nil { - return fmt.Errorf("invalid delegation state from Babylon: %s", delegationState) - } + // Try to validate as withdrawal transaction + withdrawalErr := s.validateWithdrawalTxFromStaking(tx, spendingInputIdx, delegation, params) + if withdrawalErr == nil { + // It's a valid withdrawal, process it + return s.handleWithdrawal(ctx, delegation) + } - if !utils.Contains(qualifiedStates, *delegationState) { - return fmt.Errorf("current state is not qualified for transition: %s", *delegationState) - } + // If it's not a valid withdrawal, check if it's a valid slashing + if !errors.Is(withdrawalErr, types.ErrInvalidWithdrawalTx) { + return fmt.Errorf("failed to validate withdrawal tx: %w", withdrawalErr) + } - // Update delegation status - if err := s.db.UpdateBTCDelegationState(ctx, delegation.StakingTxHashHex, types.StateWithdrawn); err != nil { - return fmt.Errorf("failed to update delegation status: %w", err) + // Try to validate as slashing transaction + if err := s.validateSlashingTxFromStaking(tx, spendingInputIdx, delegation, params); err != nil { + if errors.Is(err, types.ErrInvalidSlashingTx) { + // Neither withdrawal nor slashing - this is an invalid spend + return fmt.Errorf("transaction is neither valid unbonding, withdrawal, nor slashing: %w", err) } - - return nil + return fmt.Errorf("failed to validate slashing tx: %w", err) } - return nil + // It's a valid slashing tx, watch for spending change output + return s.startWatchingSlashingChange(ctx, tx, delegation) } func (s *Service) handleSpendingUnbondingTransaction( @@ -144,16 +176,33 @@ func (s *Service) handleSpendingUnbondingTransaction( return fmt.Errorf("failed to get staking params: %w", err) } - // Validate unbonding withdrawal transaction - if err := s.validateWithdrawalTxFromUnbonding(tx, delegation, spendingInputIdx, params); err != nil { - if errors.Is(err, types.ErrInvalidWithdrawalTx) { - // TODO: consider slashing transaction for phase-2 - return nil + // First try to validate as withdrawal transaction + withdrawalErr := s.validateWithdrawalTxFromUnbonding(tx, delegation, spendingInputIdx, params) + if withdrawalErr == nil { + // It's a valid withdrawal, process it + return s.handleWithdrawal(ctx, delegation) + } + + // If it's not a valid withdrawal, check if it's a valid slashing + if !errors.Is(withdrawalErr, types.ErrInvalidWithdrawalTx) { + return fmt.Errorf("failed to validate withdrawal tx: %w", withdrawalErr) + } + + // Try to validate as slashing transaction + if err := s.validateSlashingTxFromUnbonding(tx, delegation, spendingInputIdx, params); err != nil { + if errors.Is(err, types.ErrInvalidSlashingTx) { + // Neither withdrawal nor slashing - this is an invalid spend + return fmt.Errorf("transaction is neither valid withdrawal nor slashing: %w", err) } - return fmt.Errorf("failed to validate withdrawal tx from unbonding: %w", err) + return fmt.Errorf("failed to validate slashing tx: %w", err) } - delegationState, err := s.db.GetBTCDelegationState(context.Background(), delegation.StakingTxHashHex) + // It's a valid slashing tx, watch for spending change output + return s.startWatchingSlashingChange(ctx, tx, delegation) +} + +func (s *Service) handleWithdrawal(ctx context.Context, delegation *model.BTCDelegationDetails) error { + delegationState, err := s.db.GetBTCDelegationState(ctx, delegation.StakingTxHashHex) if err != nil { return fmt.Errorf("failed to get delegation state: %w", err) } @@ -165,12 +214,35 @@ func (s *Service) handleSpendingUnbondingTransaction( // Update to withdrawn state return s.db.UpdateBTCDelegationState( - context.Background(), + ctx, delegation.StakingTxHashHex, types.StateWithdrawn, ) } +func (s *Service) startWatchingSlashingChange(ctx context.Context, slashingTx *wire.MsgTx, delegation *model.BTCDelegationDetails) error { + // Create outpoint for the change output (index 1) + changeOutpoint := wire.OutPoint{ + Hash: slashingTx.TxHash(), + Index: 1, // Change output is always second + } + + // Register spend notification for the change output + spendEv, err := s.btcNotifier.RegisterSpendNtfn( + &changeOutpoint, + slashingTx.TxOut[1].PkScript, // Script of change output + delegation.StartHeight, + ) + if err != nil { + return fmt.Errorf("failed to register spend ntfn for slashing change output: %w", err) + } + + s.wg.Add(1) + go s.watchForSpendSlashingChange(spendEv, delegation) + + return nil +} + // IsValidUnbondingTx tries to identify a tx is a valid unbonding tx // It returns error when (1) it fails to verify the unbonding tx due // to invalid parameters, and (2) the tx spends the unbonding path @@ -448,3 +520,153 @@ func (s *Service) validateWithdrawalTxFromUnbonding( return nil } + +func (s *Service) validateSlashingTxFromStaking( + tx *wire.MsgTx, + spendingInputIdx uint32, + delegation *model.BTCDelegationDetails, + params *bbnclient.StakingParams, +) error { + stakerPk, err := bbn.NewBIP340PubKeyFromHex(delegation.StakerBtcPkHex) + if err != nil { + return fmt.Errorf("failed to convert staker btc pkh to a public key: %w", err) + } + + finalityProviderPks := make([]*btcec.PublicKey, len(delegation.FinalityProviderBtcPksHex)) + for i, hex := range delegation.FinalityProviderBtcPksHex { + fpPk, err := bbn.NewBIP340PubKeyFromHex(hex) + if err != nil { + return fmt.Errorf("failed to convert finality provider pk hex to a public key: %w", err) + } + finalityProviderPks[i] = fpPk.MustToBTCPK() + } + + covPks := make([]*btcec.PublicKey, len(params.CovenantPks)) + for i, hex := range params.CovenantPks { + covPk, err := bbn.NewBIP340PubKeyFromHex(hex) + if err != nil { + return fmt.Errorf("failed to convert finality provider pk hex to a public key: %w", err) + } + covPks[i] = covPk.MustToBTCPK() + } + + btcParams, err := utils.GetBTCParams(s.cfg.BTC.NetParams) + if err != nil { + return err + } + + stakingTx, err := utils.DeserializeBtcTransactionFromHex(delegation.StakingTxHex) + if err != nil { + return fmt.Errorf("failed to deserialize staking tx: %w", err) + } + + stakingValue := btcutil.Amount(stakingTx.TxOut[delegation.StakingOutputIdx].Value) + + // 3. re-build the unbonding path script and check whether the script from + // the witness matches + stakingInfo, err := btcstaking.BuildStakingInfo( + stakerPk.MustToBTCPK(), + finalityProviderPks, + covPks, + params.CovenantQuorum, + uint16(delegation.StakingTime), + stakingValue, + btcParams, + ) + if err != nil { + return fmt.Errorf("failed to rebuid the staking info: %w", err) + } + + slashingPathInfo, err := stakingInfo.SlashingPathSpendInfo() + if err != nil { + return fmt.Errorf("failed to get the slashing path spend info: %w", err) + } + + witness := tx.TxIn[spendingInputIdx].Witness + if len(witness) < 2 { + panic(fmt.Errorf("spending tx should have at least 2 elements in witness, got %d", len(witness))) + } + + scriptFromWitness := tx.TxIn[spendingInputIdx].Witness[len(tx.TxIn[spendingInputIdx].Witness)-2] + + if !bytes.Equal(slashingPathInfo.GetPkScriptPath(), scriptFromWitness) { + return fmt.Errorf("%w: the tx does not unlock the slashing path", types.ErrInvalidSlashingTx) + } + + return nil +} + +func (s *Service) validateSlashingTxFromUnbonding( + tx *wire.MsgTx, + delegation *model.BTCDelegationDetails, + spendingInputIdx uint32, + params *bbnclient.StakingParams, +) error { + stakerPk, err := bbn.NewBIP340PubKeyFromHex(delegation.StakerBtcPkHex) + if err != nil { + return fmt.Errorf("failed to convert staker btc pkh to a public key: %w", err) + } + + finalityProviderPks := make([]*btcec.PublicKey, len(delegation.FinalityProviderBtcPksHex)) + for i, hex := range delegation.FinalityProviderBtcPksHex { + fpPk, err := bbn.NewBIP340PubKeyFromHex(hex) + if err != nil { + return fmt.Errorf("failed to convert finality provider pk hex to a public key: %w", err) + } + finalityProviderPks[i] = fpPk.MustToBTCPK() + } + + covPks := make([]*btcec.PublicKey, len(params.CovenantPks)) + for i, hex := range params.CovenantPks { + covPk, err := bbn.NewBIP340PubKeyFromHex(hex) + if err != nil { + return fmt.Errorf("failed to convert finality provider pk hex to a public key: %w", err) + } + covPks[i] = covPk.MustToBTCPK() + } + + btcParams, err := utils.GetBTCParams(s.cfg.BTC.NetParams) + if err != nil { + return err + } + + stakingTx, err := utils.DeserializeBtcTransactionFromHex(delegation.StakingTxHex) + if err != nil { + return fmt.Errorf("failed to deserialize staking tx: %w", err) + } + + // re-build the time-lock path script and check whether the script from + // the witness matches + stakingValue := btcutil.Amount(stakingTx.TxOut[delegation.StakingOutputIdx].Value) + unbondingFee := btcutil.Amount(params.UnbondingFeeSat) + expectedUnbondingOutputValue := stakingValue - unbondingFee + unbondingInfo, err := btcstaking.BuildUnbondingInfo( + stakerPk.MustToBTCPK(), + finalityProviderPks, + covPks, + params.CovenantQuorum, + uint16(delegation.UnbondingTime), + expectedUnbondingOutputValue, + btcParams, + ) + if err != nil { + return fmt.Errorf("failed to rebuid the unbonding info: %w", err) + } + slashingPathInfo, err := unbondingInfo.SlashingPathSpendInfo() + if err != nil { + return fmt.Errorf("failed to get the slashing path spend info: %w", err) + } + + witness := tx.TxIn[spendingInputIdx].Witness + if len(witness) < 2 { + panic(fmt.Errorf("spending tx should have at least 2 elements in witness, got %d", len(witness))) + } + + scriptFromWitness := tx.TxIn[spendingInputIdx].Witness[len(tx.TxIn[spendingInputIdx].Witness)-2] + + if !bytes.Equal(slashingPathInfo.GetPkScriptPath(), scriptFromWitness) { + return fmt.Errorf("%w: the tx does not unlock the slashing path", types.ErrInvalidSlashingTx) + } + + return nil +} diff --git a/internal/types/error.go b/internal/types/error.go index c5ad6e4..bb0185c 100644 --- a/internal/types/error.go +++ b/internal/types/error.go @@ -82,4 +82,7 @@ var ( // ErrInvalidWithdrawalTx the withdrawal transaction is invalid as it does not unlock the expected time lock path ErrInvalidWithdrawalTx = errors.New("invalid withdrawal tx") + + // ErrInvalidSlashingTx the slashing transaction is invalid as it does not unlock the expected slashing path + ErrInvalidSlashingTx = errors.New("invalid slashing tx") ) diff --git a/internal/types/state.go b/internal/types/state.go index 80e89d5..71dc5fb 100644 --- a/internal/types/state.go +++ b/internal/types/state.go @@ -6,13 +6,14 @@ import bbntypes "github.com/babylonlabs-io/babylon/x/btcstaking/types" type DelegationState string const ( - StatePending DelegationState = "PENDING" - StateVerified DelegationState = "VERIFIED" - StateActive DelegationState = "ACTIVE" - StateUnbonding DelegationState = "UNBONDING" - StateWithdrawable DelegationState = "WITHDRAWABLE" - StateWithdrawn DelegationState = "WITHDRAWN" - StateSlashed DelegationState = "SLASHED" + StatePending DelegationState = "PENDING" + StateVerified DelegationState = "VERIFIED" + StateActive DelegationState = "ACTIVE" + StateUnbonding DelegationState = "UNBONDING" + StateWithdrawable DelegationState = "WITHDRAWABLE" + StateWithdrawn DelegationState = "WITHDRAWN" + StateSlashed DelegationState = "SLASHED" + StateSlashedWithdrawn DelegationState = "SLASHED_WITHDRAWN" ) func (s DelegationState) String() string { @@ -60,3 +61,8 @@ func QualifiedStatesForWithdrawn() []DelegationState { func QualifiedStatesForWithdrawable() []DelegationState { return []DelegationState{StateUnbonding} } + +// QualifiedStatesForSlashedWithdrawn returns the qualified current states for SlashedWithdrawn event +func QualifiedStatesForSlashedWithdrawn() []DelegationState { + return []DelegationState{StateSlashed} +}