Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: unbonding state transitions #27

Merged
merged 15 commits into from
Oct 23, 2024
1 change: 1 addition & 0 deletions config/config-docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ bbn:
timeout: 30s
poller:
param-polling-interval: 60s
expiry-checker-polling-interval: 10s
queue:
queue_user: user # can be replaced by values in .env file
queue_password: password
Expand Down
1 change: 1 addition & 0 deletions config/config-local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ bbn:
timeout: 30s
poller:
param-polling-interval: 10s
expiry-checker-polling-interval: 10s
queue:
queue_user: user # can be replaced by values in .env file
queue_password: password
Expand Down
7 changes: 6 additions & 1 deletion internal/config/poller.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,18 @@ import (
)

type PollerConfig struct {
ParamPollingInterval time.Duration `mapstructure:"param-polling-interval"`
ParamPollingInterval time.Duration `mapstructure:"param-polling-interval"`
ExpiryCheckerPollingInterval time.Duration `mapstructure:"expiry-checker-polling-interval"`
}

func (cfg *PollerConfig) Validate() error {
if cfg.ParamPollingInterval < 0 {
gusin13 marked this conversation as resolved.
Show resolved Hide resolved
return errors.New("param-polling-interval must be positive")
}

if cfg.ExpiryCheckerPollingInterval < 0 {
gusin13 marked this conversation as resolved.
Show resolved Hide resolved
return errors.New("expiry-checker-polling-interval must be positive")
}

return nil
}
26 changes: 26 additions & 0 deletions internal/db/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,4 +113,30 @@ type DbInterface interface {
GetBTCDelegationByStakingTxHash(
ctx context.Context, stakingTxHash string,
) (*model.BTCDelegationDetails, error)
/**
* SaveNewTimeLockExpire saves a new timelock expire to the database.
* If the timelock expire already exists, DuplicateKeyError will be returned.
* @param ctx The context
* @param stakingTxHashHex The staking tx hash hex
* @param expireHeight The expire height
* @param txType The transaction type
* @return An error if the operation failed
*/
SaveNewTimeLockExpire(
ctx context.Context, stakingTxHashHex string, expireHeight uint32, txType string,
) error
/**
* FindExpiredDelegations finds the expired delegations.
* @param ctx The context
* @param btcTipHeight The BTC tip height
* @return The expired delegations or an error
*/
FindExpiredDelegations(ctx context.Context, btcTipHeight uint64) ([]model.TimeLockDocument, error)
/**
* DeleteExpiredDelegation deletes an expired delegation.
* @param ctx The context
* @param id The ID of the expired delegation
* @return An error if the operation failed
*/
DeleteExpiredDelegation(ctx context.Context, stakingTxHashHex string) error
}
1 change: 1 addition & 0 deletions internal/db/model/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
const (
FinalityProviderDetailsCollection = "finality_provider_details"
BTCDelegationDetailsCollection = "btc_delegation_details"
TimeLockCollection = "timelock_queue"
gusin13 marked this conversation as resolved.
Show resolved Hide resolved
GlobalParamsCollection = "global_params"
)

Expand Down
15 changes: 15 additions & 0 deletions internal/db/model/timelock.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package model

type TimeLockDocument struct {
StakingTxHashHex string `bson:"_id"` // Primary key
ExpireHeight uint32 `bson:"expire_height"`
TxType string `bson:"tx_type"`
}

func NewTimeLockDocument(stakingTxHashHex string, expireHeight uint32, txType string) *TimeLockDocument {
return &TimeLockDocument{
StakingTxHashHex: stakingTxHashHex,
ExpireHeight: expireHeight,
TxType: txType,
}
}
73 changes: 73 additions & 0 deletions internal/db/timelock.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package db

import (
"context"
"errors"
"fmt"

"github.com/babylonlabs-io/babylon-staking-indexer/internal/db/model"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)

func (db *Database) SaveNewTimeLockExpire(
ctx context.Context, stakingTxHashHex string,
expireHeight uint32, txType string,
) error {
tlDoc := model.NewTimeLockDocument(stakingTxHashHex, expireHeight, txType)
_, err := db.client.Database(db.dbName).
Collection(model.TimeLockCollection).
InsertOne(ctx, tlDoc)
if err != nil {
var writeErr mongo.WriteException
if errors.As(err, &writeErr) {
for _, e := range writeErr.WriteErrors {
if mongo.IsDuplicateKeyError(e) {
return &DuplicateKeyError{
Key: tlDoc.StakingTxHashHex,
Message: "timelock already exists",
}
}
}
}
return err
}
return nil
}

func (db *Database) FindExpiredDelegations(ctx context.Context, btcTipHeight uint64) ([]model.TimeLockDocument, error) {
client := db.client.Database(db.dbName).Collection(model.TimeLockCollection)
filter := bson.M{"expire_height": bson.M{"$lte": btcTipHeight}}

opts := options.Find().SetLimit(100)
gusin13 marked this conversation as resolved.
Show resolved Hide resolved
cursor, err := client.Find(ctx, filter, opts)
if err != nil {
return nil, err
}
defer cursor.Close(ctx)

var delegations []model.TimeLockDocument
if err = cursor.All(ctx, &delegations); err != nil {
return nil, err
}

return delegations, nil
}

func (db *Database) DeleteExpiredDelegation(ctx context.Context, stakingTxHashHex string) error {
client := db.client.Database(db.dbName).Collection(model.TimeLockCollection)
filter := bson.M{"staking_tx_hash_hex": stakingTxHashHex}

result, err := client.DeleteOne(ctx, filter)
if err != nil {
return fmt.Errorf("failed to delete expired delegation with stakingTxHashHex %v: %w", stakingTxHashHex, err)
}

// Check if any document was deleted
if result.DeletedCount == 0 {
return fmt.Errorf("no expired delegation found with stakingTxHashHex %v", stakingTxHashHex)
}

return nil
}
25 changes: 23 additions & 2 deletions internal/services/delegation.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,11 @@ func (s *Service) processBTCDelegationUnbondedEarlyEvent(
return err
}

// TODO: save timelock expire, need to figure out what will be the expire height in this case.
// https://github.com/babylonlabs-io/babylon-staking-indexer/issues/28
gusin13 marked this conversation as resolved.
Show resolved Hide resolved

if err := s.db.UpdateBTCDelegationState(
ctx, unbondedEarlyEvent.StakingTxHash, types.DelegationState(unbondedEarlyEvent.NewState),
ctx, unbondedEarlyEvent.StakingTxHash, types.StateUnbonding,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could we have a method in the utils to map the BBN state into web state? i.e types.DelegationState(unbondedEarlyEvent.NewState) seems ok to me, but not sure why it's removed?

Copy link
Collaborator Author

@gusin13 gusin13 Oct 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the state sent in Babylon event is Unbonded, types.DelegationState(unbondedEarlyEvent.NewState) would give us Unbonded

but the indexer needs to treat this as Unbonding

); err != nil {
return types.NewError(
http.StatusInternalServerError,
Expand All @@ -146,8 +149,26 @@ func (s *Service) processBTCDelegationExpiredEvent(
return err
}

delegation, err2 := s.db.GetBTCDelegationByStakingTxHash(ctx, expiredEvent.StakingTxHash)
gusin13 marked this conversation as resolved.
Show resolved Hide resolved
if err2 != nil {
return types.NewError(
http.StatusInternalServerError,
types.InternalServiceError,
fmt.Errorf("failed to get BTC delegation by staking tx hash: %w", err2),
)
}
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),
)
}

if err := s.db.UpdateBTCDelegationState(
ctx, expiredEvent.StakingTxHash, types.DelegationState(expiredEvent.NewState),
ctx, expiredEvent.StakingTxHash, types.StateUnbonding,
); err != nil {
return types.NewError(
http.StatusInternalServerError,
Expand Down
71 changes: 71 additions & 0 deletions internal/services/expiry-checker.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package services

import (
"context"
"fmt"
"net/http"

"github.com/babylonlabs-io/babylon-staking-indexer/internal/types"
"github.com/babylonlabs-io/babylon-staking-indexer/internal/utils/poller"
"github.com/rs/zerolog/log"
)

func (s *Service) StartExpiryChecker(ctx context.Context) {
expiryCheckerPoller := poller.NewPoller(
s.cfg.Poller.ExpiryCheckerPollingInterval,
s.checkExpiry,
)
go expiryCheckerPoller.Start(ctx)
}

func (s *Service) checkExpiry(ctx context.Context) *types.Error {
btcTip, err := s.btc.GetBlockCount()
if err != nil {
return types.NewInternalServiceError(
fmt.Errorf("failed to get BTC tip height: %w", err),
)
}

expiredDelegations, err := s.db.FindExpiredDelegations(ctx, uint64(btcTip))
if err != nil {
return types.NewInternalServiceError(
fmt.Errorf("failed to find expired delegations: %w", err),
)
}

for _, delegation := range expiredDelegations {
delegation, err := s.db.GetBTCDelegationByStakingTxHash(ctx, delegation.StakingTxHashHex)
if err != nil {
return types.NewError(
http.StatusInternalServerError,
types.InternalServiceError,
fmt.Errorf("failed to get BTC delegation by staking tx hash: %w", err),
)
}

// previous state should be unbonding
if delegation.State != types.StateUnbonding {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this validation is too strong which will leads to issues when we have duplicated entries in db upon re-bootstrap of the service.

We have this set of methods in the phase-1 which i think we can re-use in phase-2 https://github.com/babylonlabs-io/staking-api-service/blob/main/internal/shared/utils/state_transition.go#L34
i.e ignore if the state is already beyond the withdrawable state

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i see, sure i have removed the check.
this might required lot of change in code for the outdated/qualified state transitions, would prefer to do in separate pr

#29

return types.NewError(
http.StatusInternalServerError,
types.InternalServiceError,
fmt.Errorf("BTC delegation is not in unbonding state"),
)
}

if err := s.db.UpdateBTCDelegationState(ctx, delegation.StakingTxHashHex, types.StateWithdrawable); err != nil {
log.Error().Err(err).Msg("Error updating BTC delegation state to withdrawable")
return types.NewInternalServiceError(
fmt.Errorf("failed to update BTC delegation state to withdrawable: %w", err),
)
}

if err := s.db.DeleteExpiredDelegation(ctx, delegation.StakingTxHashHex); err != nil {
gusin13 marked this conversation as resolved.
Show resolved Hide resolved
log.Error().Err(err).Msg("Error deleting expired delegation")
return types.NewInternalServiceError(
fmt.Errorf("failed to delete expired delegation: %w", err),
)
}
}

return nil
}
2 changes: 2 additions & 0 deletions internal/services/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ func NewService(
func (s *Service) StartIndexerSync(ctx context.Context) {
// Sync global parameters
s.SyncGlobalParams(ctx)
// Start the expiry checker
s.StartExpiryChecker(ctx)
// Start the bootstrap process
s.BootstrapBbn(ctx)
// Start the websocket event subscription process
Expand Down
14 changes: 7 additions & 7 deletions internal/types/state.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ package types
type DelegationState string

const (
StatePending DelegationState = "PENDING"
StateVerified DelegationState = "VERIFIED"
StateActive DelegationState = "ACTIVE"
StateUnbonding DelegationState = "UNBONDING"
StateWithdrawn DelegationState = "WITHDRAWN"
StateSlashed DelegationState = "SLASHED"
StateUnbonded DelegationState = "UNBONDED"
StatePending DelegationState = "PENDING"
StateVerified DelegationState = "VERIFIED"
StateActive DelegationState = "ACTIVE"
StateUnbonding DelegationState = "UNBONDING"
StateWithdrawable DelegationState = "WITHDRAWABLE"
StateWithdrawn DelegationState = "WITHDRAWN"
StateSlashed DelegationState = "SLASHED"
)

func (s DelegationState) String() string {
Expand Down
11 changes: 11 additions & 0 deletions internal/types/timelock_tx_type.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package types

type TimeLockTxType string

const (
ExpiredTxType TimeLockTxType = "EXPIRED"
)

func (t TimeLockTxType) String() string {
return string(t)
}
66 changes: 66 additions & 0 deletions tests/mocks/mock_db_client.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading