diff --git a/pkg/chain/ethereum/tbtc.go b/pkg/chain/ethereum/tbtc.go index 9cce628ce0..3fd3ce6172 100644 --- a/pkg/chain/ethereum/tbtc.go +++ b/pkg/chain/ethereum/tbtc.go @@ -2363,9 +2363,3 @@ func (tc *TbtcChain) GetRedemptionDelay( func (tc *TbtcChain) GetDepositMinAge() (uint32, error) { return tc.walletProposalValidator.DEPOSITMINAGE() } - -func (tc *TbtcChain) IsOperatorUnstaking() (bool, error) { - // TODO: Implement by checking if the operator has deauthorized their entire - // stake. - return false, nil -} diff --git a/pkg/tbtc/chain.go b/pkg/tbtc/chain.go index 1b0a0c7c2a..1b579e326b 100644 --- a/pkg/tbtc/chain.go +++ b/pkg/tbtc/chain.go @@ -24,14 +24,6 @@ const ( Challenge ) -// StakingChain defines the subset of the TBTC chain interface that pertains to -// the staking activities. -type StakingChain interface { - // IsOperatorUnstaking checks if the operator is unstaking. It returns true - // if the operator has deauthorized their entire stake, false otherwise. - IsOperatorUnstaking() (bool, error) -} - // GroupSelectionChain defines the subset of the TBTC chain interface that // pertains to the group selection activities. type GroupSelectionChain interface { @@ -539,7 +531,6 @@ type Chain interface { GetBlockHashByNumber(blockNumber uint64) ([32]byte, error) sortition.Chain - StakingChain GroupSelectionChain DistributedKeyGenerationChain InactivityClaimChain diff --git a/pkg/tbtc/chain_test.go b/pkg/tbtc/chain_test.go index 95e250bd5e..6e293f23dc 100644 --- a/pkg/tbtc/chain_test.go +++ b/pkg/tbtc/chain_test.go @@ -27,7 +27,10 @@ import ( "github.com/keep-network/keep-core/pkg/tecdsa/dkg" ) -const localChainOperatorID = chain.OperatorID(1) +const ( + localChainOperatorID = chain.OperatorID(1) + stakingProvider = chain.Address("0x1111111111111111111111111111111111111111") +) type movingFundsParameters = struct { txMaxTotalFee uint64 @@ -108,6 +111,9 @@ type localChain struct { movingFundsParametersMutex sync.Mutex movingFundsParameters movingFundsParameters + eligibleStakesMutex sync.Mutex + eligibleStakes map[chain.Address]*big.Int + blockCounter chain.BlockCounter operatorPrivateKey *operator.PrivateKey } @@ -185,11 +191,26 @@ func (lc *localChain) setBlockHashByNumber( } func (lc *localChain) OperatorToStakingProvider() (chain.Address, bool, error) { - panic("unsupported") + return stakingProvider, true, nil } func (lc *localChain) EligibleStake(stakingProvider chain.Address) (*big.Int, error) { - panic("unsupported") + lc.eligibleStakesMutex.Lock() + defer lc.eligibleStakesMutex.Unlock() + + eligibleStake, ok := lc.eligibleStakes[stakingProvider] + if !ok { + return nil, fmt.Errorf("eligible stake not found") + } + + return eligibleStake, nil +} + +func (lc *localChain) setOperatorsEligibleStake(stake *big.Int) { + lc.eligibleStakesMutex.Lock() + defer lc.eligibleStakesMutex.Unlock() + + lc.eligibleStakes[stakingProvider] = stake } func (lc *localChain) IsPoolLocked() (bool, error) { @@ -216,11 +237,6 @@ func (lc *localChain) IsEligibleForRewards() (bool, error) { panic("unsupported") } -func (lc *localChain) IsOperatorUnstaking() (bool, error) { - // TODO: Implement and use in unit tests. - return false, nil -} - func (lc *localChain) CanRestoreRewardEligibility() (bool, error) { panic("unsupported") } @@ -1402,6 +1418,7 @@ func ConnectWithKey( movedFundsSweepProposalValidations: make(map[[32]byte]bool), heartbeatProposalValidations: make(map[[16]byte]bool), depositRequests: make(map[[32]byte]*DepositChainRequest), + eligibleStakes: make(map[chain.Address]*big.Int), blockCounter: blockCounter, operatorPrivateKey: operatorPrivateKey, } diff --git a/pkg/tbtc/heartbeat.go b/pkg/tbtc/heartbeat.go index 65821f65a4..770d055879 100644 --- a/pkg/tbtc/heartbeat.go +++ b/pkg/tbtc/heartbeat.go @@ -123,9 +123,12 @@ func newHeartbeatAction( func (ha *heartbeatAction) execute() error { // Do not execute the heartbeat action if the operator is unstaking. - isUnstaking, err := ha.chain.IsOperatorUnstaking() + isUnstaking, err := ha.isOperatorUnstaking() if err != nil { - return fmt.Errorf("failed to check if the operator is unstaking") + return fmt.Errorf( + "failed to check if the operator is unstaking [%v]", + err, + ) } if isUnstaking { @@ -247,6 +250,33 @@ func (ha *heartbeatAction) actionType() WalletActionType { return ActionHeartbeat } +func (ha *heartbeatAction) isOperatorUnstaking() (bool, error) { + stakingProvider, isRegistered, err := ha.chain.OperatorToStakingProvider() + if err != nil { + return false, fmt.Errorf( + "failed to get staking provider for operator [%v]", + err, + ) + } + + if !isRegistered { + return false, fmt.Errorf("staking provider not registered for operator") + } + + // Eligible stake is defined as the currently authorized stake minus the + // pending authorization decrease. + eligibleStake, err := ha.chain.EligibleStake(stakingProvider) + if err != nil { + return false, fmt.Errorf( + "failed to check eligible stake for operator [%v]", + err, + ) + } + + // The operator is considered unstaking if their eligible stake is `0`. + return eligibleStake.Cmp(big.NewInt(0)) == 0, nil +} + // heartbeatFailureCounter holds counters keeping track of consecutive // heartbeat failures. Each wallet has a separate counter. The key used in // the map is the uncompressed public key (with 04 prefix) of the wallet. diff --git a/pkg/tbtc/heartbeat_test.go b/pkg/tbtc/heartbeat_test.go index 92cd2e0d3c..6946904201 100644 --- a/pkg/tbtc/heartbeat_test.go +++ b/pkg/tbtc/heartbeat_test.go @@ -46,6 +46,7 @@ func TestHeartbeatAction_HappyPath(t *testing.T) { } hostChain := Connect() + hostChain.setOperatorsEligibleStake(big.NewInt(100000)) hostChain.setHeartbeatProposalValidationResult(proposal, true) // Set the active operators count to the minimum required value. @@ -102,6 +103,67 @@ func TestHeartbeatAction_HappyPath(t *testing.T) { ) } +func TestHeartbeatAction_OperatorUnstaking(t *testing.T) { + walletPublicKeyHex, err := hex.DecodeString( + "0471e30bca60f6548d7b42582a478ea37ada63b402af7b3ddd57f0c95bb6843175" + + "aa0d2053a91a050a6797d85c38f2909cb7027f2344a01986aa2f9f8ca7a0c289", + ) + if err != nil { + t.Fatal(err) + } + + startBlock := uint64(10) + expiryBlock := startBlock + heartbeatTotalProposalValidityBlocks + + proposal := &HeartbeatProposal{ + Message: [16]byte{ + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + }, + } + + heartbeatFailureCounter := newHeartbeatFailureCounter() + + hostChain := Connect() + hostChain.setOperatorsEligibleStake(big.NewInt(0)) + hostChain.setHeartbeatProposalValidationResult(proposal, true) + + // Set the active operators count to the minimum required value. + mockExecutor := &mockHeartbeatSigningExecutor{} + mockExecutor.activeOperatorsCount = heartbeatSigningMinimumActiveOperators + + inactivityClaimExecutor := &mockInactivityClaimExecutor{} + + action := newHeartbeatAction( + logger, + hostChain, + wallet{ + publicKey: unmarshalPublicKey(walletPublicKeyHex), + }, + mockExecutor, + proposal, + heartbeatFailureCounter, + inactivityClaimExecutor, + startBlock, + expiryBlock, + func(ctx context.Context, blockHeight uint64) error { + return nil + }, + ) + + err = action.execute() + if err != nil { + t.Fatal(err) + } + + testutils.AssertBigIntsEqual( + t, + "message to sign", + nil, // sign not called + mockExecutor.requestedMessage, + ) +} + func TestHeartbeatAction_Failure_SigningError(t *testing.T) { walletPublicKeyHex, err := hex.DecodeString( "0471e30bca60f6548d7b42582a478ea37ada63b402af7b3ddd57f0c95bb6843175" + @@ -126,6 +188,7 @@ func TestHeartbeatAction_Failure_SigningError(t *testing.T) { heartbeatFailureCounter := newHeartbeatFailureCounter() hostChain := Connect() + hostChain.setOperatorsEligibleStake(big.NewInt(100000)) hostChain.setHeartbeatProposalValidationResult(proposal, true) mockExecutor := &mockHeartbeatSigningExecutor{} @@ -196,6 +259,7 @@ func TestHeartbeatAction_Failure_TooFewActiveOperators(t *testing.T) { heartbeatFailureCounter := newHeartbeatFailureCounter() hostChain := Connect() + hostChain.setOperatorsEligibleStake(big.NewInt(100000)) hostChain.setHeartbeatProposalValidationResult(proposal, true) // Set the active operators count just below the required number. @@ -276,6 +340,7 @@ func TestHeartbeatAction_Failure_CounterExceeded(t *testing.T) { heartbeatFailureCounter.increment(walletPublicKeyStr) hostChain := Connect() + hostChain.setOperatorsEligibleStake(big.NewInt(100000)) hostChain.setHeartbeatProposalValidationResult(proposal, true) mockExecutor := &mockHeartbeatSigningExecutor{} @@ -355,6 +420,7 @@ func TestHeartbeatAction_Failure_InactivityExecutionFailure(t *testing.T) { heartbeatFailureCounter.increment(walletPublicKeyStr) hostChain := Connect() + hostChain.setOperatorsEligibleStake(big.NewInt(100000)) hostChain.setHeartbeatProposalValidationResult(proposal, true) mockExecutor := &mockHeartbeatSigningExecutor{}