Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions cmd/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,13 @@ func initTbtcFlags(cmd *cobra.Command, cfg *config.Config) {
false,
"Disable legacy ECDSA sortition pool monitoring for FROST-only deployments.",
)

cmd.Flags().BoolVar(
&cfg.Tbtc.DisableFrostSortitionPoolMonitoring,
"tbtc.disableFrostSortitionPoolMonitoring",
false,
"Disable FROST sortition pool monitoring for operators that manage FROST pool membership out of band.",
)
}

// Initialize flags for Maintainer configuration.
Expand Down
9 changes: 9 additions & 0 deletions cmd/flags_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,15 @@ var cmdFlagsTests = map[string]struct {
expectedValueFromFlag: true,
defaultValue: false,
},
"tbtc.disableFrostSortitionPoolMonitoring": {
readValueFunc: func(c *config.Config) interface{} {
return c.Tbtc.DisableFrostSortitionPoolMonitoring
},
flagName: "--tbtc.disableFrostSortitionPoolMonitoring",
flagValue: "", // don't provide any value
expectedValueFromFlag: true,
defaultValue: false,
},
"maintainer.bitcoinDifficulty": {
readValueFunc: func(c *config.Config) interface{} { return c.Maintainer.BitcoinDifficulty.Enabled },
flagName: "--bitcoinDifficulty",
Expand Down
245 changes: 245 additions & 0 deletions pkg/chain/ethereum/tbtc_sortition_chain_views.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
package ethereum

import (
"fmt"
"math/big"

"github.com/ethereum/go-ethereum/accounts/abi/bind"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"

"github.com/keep-network/keep-core/pkg/chain"
ecdsacontract "github.com/keep-network/keep-core/pkg/chain/ethereum/ecdsa/gen/contract"
"github.com/keep-network/keep-core/pkg/sortition"
)

// The seven sortition.Chain methods that TbtcChain routes through
// hasFrostAuthorization() are inherently a process-global switch: a single
// MonitorPool loop reads them and therefore maintains exactly one pool. During
// the ECDSA->FROST migration an operator participates in BOTH the legacy ECDSA
// and the FROST sortition pools at once (existing ECDSA wallets keep draining
// while FROST is live), and each pool needs its own maintenance loop. These two
// views bind the sortition.Chain surface to one specific pool/registry with NO
// hasFrostAuthorization() switch, so the caller can run one MonitorPool per pool.
//
// They are used ONLY by the pool-monitoring loops; TbtcChain's own
// sortition.Chain methods (consumed by the heartbeat and other callers) are left
// unchanged, so this introduces no behavior change outside the monitor loops.
// GetOperatorID stays bound to whichever pool the view targets, matching the
// per-pool member IDs (the legacy view's GetOperatorID equals TbtcChain's
// deliberately-ECDSA-bound GetOperatorID).

// sortitionPoolView implements the pool-bound subset of sortition.Chain shared by
// both views. The FROST and ECDSA sortition pools are the same contract type
// (ecdsacontract.EcdsaSortitionPool), distinct instances, so these seven methods
// are identical apart from which pool instance they target.
type sortitionPoolView struct {
pool *ecdsacontract.EcdsaSortitionPool
operatorAddress common.Address
}

func (v *sortitionPoolView) IsPoolLocked() (bool, error) {
return v.pool.IsLocked()
}

func (v *sortitionPoolView) IsEligibleForRewards() (bool, error) {
return v.pool.IsEligibleForRewards(v.operatorAddress)
}

func (v *sortitionPoolView) CanRestoreRewardEligibility() (bool, error) {
return v.pool.CanRestoreRewardEligibility(v.operatorAddress)
}

func (v *sortitionPoolView) RestoreRewardEligibility() error {
_, err := v.pool.RestoreRewardEligibility(v.operatorAddress)
return err
}

func (v *sortitionPoolView) IsChaosnetActive() (bool, error) {
return v.pool.IsChaosnetActive()
}

func (v *sortitionPoolView) IsBetaOperator() (bool, error) {
return v.pool.IsBetaOperator(v.operatorAddress)
}

func (v *sortitionPoolView) GetOperatorID(
operatorAddress chain.Address,
) (chain.OperatorID, error) {
return getOperatorID(v.pool, common.HexToAddress(operatorAddress.String()))
}

// ecdsaSortitionChain is a sortition.Chain bound explicitly to the legacy ECDSA
// WalletRegistry and sortition pool. Its registry methods mirror the non-FROST
// branch of the corresponding TbtcChain methods.
type ecdsaSortitionChain struct {
sortitionPoolView
walletRegistry *ecdsacontract.WalletRegistry
}

func (c *ecdsaSortitionChain) OperatorToStakingProvider() (chain.Address, bool, error) {
stakingProvider, err := c.walletRegistry.OperatorToStakingProvider(c.operatorAddress)
if err != nil {
return "", false, fmt.Errorf(
"failed to map operator [%v] to a staking provider: [%v]",
c.operatorAddress,
err,
)
}

if (stakingProvider == common.Address{}) {
return "", false, nil
}

return chain.Address(stakingProvider.Hex()), true, nil
}

func (c *ecdsaSortitionChain) EligibleStake(
stakingProvider chain.Address,
) (*big.Int, error) {
eligibleStake, err := c.walletRegistry.EligibleStake(
common.HexToAddress(stakingProvider.String()),
)
if err != nil {
return nil, fmt.Errorf(
"failed to get eligible stake for staking provider %s: [%w]",
stakingProvider,
err,
)
}

return eligibleStake, nil
}

func (c *ecdsaSortitionChain) IsOperatorInPool() (bool, error) {
return c.walletRegistry.IsOperatorInPool(c.operatorAddress)
}

func (c *ecdsaSortitionChain) IsOperatorUpToDate() (bool, error) {
return c.walletRegistry.IsOperatorUpToDate(c.operatorAddress)
}

func (c *ecdsaSortitionChain) JoinSortitionPool() error {
_, err := c.walletRegistry.JoinSortitionPool()
return err
}

func (c *ecdsaSortitionChain) UpdateOperatorStatus() error {
_, err := c.walletRegistry.UpdateOperatorStatus(c.operatorAddress)
return err
}

// frostSortitionChain is a sortition.Chain bound explicitly to the FROST
// WalletRegistry and sortition pool. Its registry methods mirror the FROST
// branch of the corresponding TbtcChain methods, including the explicit CallOpts
// and the transaction-mutex/nonce-serialized submission helper (shared with the
// ECDSA registry binding, so the two monitor loops never race on the operator
// account nonce).
type frostSortitionChain struct {
sortitionPoolView
tc *TbtcChain
}

func (c *frostSortitionChain) OperatorToStakingProvider() (chain.Address, bool, error) {
stakingProvider, err := c.tc.frostWalletRegistry.OperatorToStakingProvider(
&bind.CallOpts{From: c.operatorAddress},
c.operatorAddress,
)
if err != nil {
return "", false, fmt.Errorf(
"failed to map operator [%v] to a staking provider: [%v]",
c.operatorAddress,
err,
)
}

if (stakingProvider == common.Address{}) {
return "", false, nil
}

return chain.Address(stakingProvider.Hex()), true, nil
}

func (c *frostSortitionChain) EligibleStake(
stakingProvider chain.Address,
) (*big.Int, error) {
eligibleStake, err := c.tc.frostWalletRegistry.EligibleStake(
&bind.CallOpts{From: c.operatorAddress},
common.HexToAddress(stakingProvider.String()),
)
if err != nil {
return nil, fmt.Errorf(
"failed to get eligible stake for staking provider %s: [%w]",
stakingProvider,
err,
)
}

return eligibleStake, nil
}

func (c *frostSortitionChain) IsOperatorInPool() (bool, error) {
return c.tc.frostWalletRegistry.IsOperatorInPool(
&bind.CallOpts{From: c.operatorAddress},
c.operatorAddress,
)
}

func (c *frostSortitionChain) IsOperatorUpToDate() (bool, error) {
return c.tc.frostWalletRegistry.IsOperatorUpToDate(
&bind.CallOpts{From: c.operatorAddress},
c.operatorAddress,
)
}

func (c *frostSortitionChain) JoinSortitionPool() error {
return c.tc.submitFrostWalletRegistryTransaction(
"joinSortitionPool",
func(opts *bind.TransactOpts) (*types.Transaction, error) {
return c.tc.frostWalletRegistry.JoinSortitionPool(opts)
},
)
}

func (c *frostSortitionChain) UpdateOperatorStatus() error {
return c.tc.submitFrostWalletRegistryTransaction(
"updateOperatorStatus",
func(opts *bind.TransactOpts) (*types.Transaction, error) {
return c.tc.frostWalletRegistry.UpdateOperatorStatus(
opts,
c.operatorAddress,
)
},
)
}

// LegacyECDSASortitionChain returns a sortition.Chain bound explicitly to the
// legacy ECDSA sortition pool, for ECDSA pool monitoring during the FROST
// migration drain (independent of whether FROST authorization is configured).
func (tc *TbtcChain) LegacyECDSASortitionChain() sortition.Chain {
return &ecdsaSortitionChain{
sortitionPoolView: sortitionPoolView{
pool: tc.sortitionPool,
operatorAddress: tc.key.Address,
},
walletRegistry: tc.walletRegistry,
}
}

// FrostSortitionChain returns a sortition.Chain bound explicitly to the FROST
// sortition pool, and a flag reporting whether FROST authorization is configured
// for this node. When the flag is false, the returned chain is nil and FROST
// pool monitoring must not be started.
func (tc *TbtcChain) FrostSortitionChain() (sortition.Chain, bool) {
if !tc.hasFrostAuthorization() {
return nil, false
}

return &frostSortitionChain{
sortitionPoolView: sortitionPoolView{
pool: tc.frostSortitionPool,
operatorAddress: tc.key.Address,
},
tc: tc,
}, true
}
116 changes: 116 additions & 0 deletions pkg/chain/ethereum/tbtc_sortition_chain_views_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package ethereum

import (
"testing"

"github.com/ethereum/go-ethereum/accounts/keystore"
"github.com/ethereum/go-ethereum/common"

ecdsacontract "github.com/keep-network/keep-core/pkg/chain/ethereum/ecdsa/gen/contract"
frostabi "github.com/keep-network/keep-core/pkg/chain/ethereum/frost/gen/abi"
)

// The pool-monitoring defect these views fix is a WRONG-POOL binding: a single
// hasFrostAuthorization()-switched monitor maintained the FROST pool while
// labeled "legacy ECDSA" (and, post-cutover, nothing). These tests pin each view
// to its intended pool/registry so a regression that crosses the wiring fails.

func newTbtcChainForSortitionViewTest(withFrost bool) (
tc *TbtcChain,
ecdsaPool *ecdsacontract.EcdsaSortitionPool,
frostPool *ecdsacontract.EcdsaSortitionPool,
operatorAddress common.Address,
) {
operatorAddress = common.HexToAddress(
"0x1111111111111111111111111111111111111111",
)
ecdsaPool = &ecdsacontract.EcdsaSortitionPool{}
frostPool = &ecdsacontract.EcdsaSortitionPool{}

tc = &TbtcChain{
baseChain: &baseChain{
key: &keystore.Key{Address: operatorAddress},
},
walletRegistry: &ecdsacontract.WalletRegistry{},
sortitionPool: ecdsaPool,
}
if withFrost {
tc.frostWalletRegistry = &frostabi.FrostWalletRegistry{}
tc.frostSortitionPool = frostPool
}

return tc, ecdsaPool, frostPool, operatorAddress
}

func TestLegacyECDSASortitionChainBindsToECDSAPool(t *testing.T) {
// Even with FROST configured, the legacy view must bind to the ECDSA pool --
// this is the exact crossing the switched monitor got wrong.
tc, ecdsaPool, frostPool, operatorAddress := newTbtcChainForSortitionViewTest(true)

view := tc.LegacyECDSASortitionChain()

ecdsaView, ok := view.(*ecdsaSortitionChain)
if !ok {
t.Fatalf("expected *ecdsaSortitionChain, got [%T]", view)
}
if ecdsaView.pool != ecdsaPool {
t.Fatal("legacy view must target the ECDSA sortition pool")
}
if ecdsaView.pool == frostPool {
t.Fatal("legacy view must NOT target the FROST sortition pool")
}
if ecdsaView.walletRegistry != tc.walletRegistry {
t.Fatal("legacy view must target the ECDSA wallet registry")
}
if ecdsaView.operatorAddress != operatorAddress {
t.Fatalf(
"unexpected operator address\nexpected: [%v]\nactual: [%v]",
operatorAddress,
ecdsaView.operatorAddress,
)
}
}

func TestFrostSortitionChainBindsToFrostPoolWhenConfigured(t *testing.T) {
tc, ecdsaPool, frostPool, operatorAddress := newTbtcChainForSortitionViewTest(true)

view, configured := tc.FrostSortitionChain()

if !configured {
t.Fatal("FROST view must be configured when FROST contracts are set")
}
frostView, ok := view.(*frostSortitionChain)
if !ok {
t.Fatalf("expected *frostSortitionChain, got [%T]", view)
}
if frostView.pool != frostPool {
t.Fatal("FROST view must target the FROST sortition pool")
}
if frostView.pool == ecdsaPool {
t.Fatal("FROST view must NOT target the ECDSA sortition pool")
}
if frostView.tc != tc {
t.Fatal("FROST view must reference the owning chain for tx submission")
}
if frostView.operatorAddress != operatorAddress {
t.Fatalf(
"unexpected operator address\nexpected: [%v]\nactual: [%v]",
operatorAddress,
frostView.operatorAddress,
)
}
}

func TestFrostSortitionChainUnconfiguredWhenFrostAbsent(t *testing.T) {
// No FROST contracts -> the FROST monitor loop must not start.
tc, _, _, _ := newTbtcChainForSortitionViewTest(false)

view, configured := tc.FrostSortitionChain()

if configured {
t.Fatal("FROST view must be unconfigured when FROST contracts are absent")
}
if view != nil {
t.Fatal("FROST view must be nil when FROST is not configured")
}
}
Loading
Loading