diff --git a/services/wallet/api.go b/services/wallet/api.go index 2fa15b223..84c9d85b9 100644 --- a/services/wallet/api.go +++ b/services/wallet/api.go @@ -406,6 +406,11 @@ func (api *API) GetSuggestedFees(ctx context.Context, chainID uint64) (*Suggeste return api.s.feesManager.suggestedFees(ctx, chainID) } +func (api *API) GetEstimatedLatestBlockNumber(ctx context.Context, chainID uint64) (uint64, error) { + log.Debug("call to GetEstimatedLatestBlockNumber, chainID:", chainID) + return api.s.blockChainState.GetEstimatedLatestBlockNumber(ctx, chainID) +} + func (api *API) GetTransactionEstimatedTime(ctx context.Context, chainID uint64, maxFeePerGas *big.Float) (TransactionEstimation, error) { log.Debug("call to getTransactionEstimatedTime") return api.s.feesManager.transactionEstimatedTime(ctx, chainID, maxFeePerGas), nil diff --git a/services/wallet/blockchainstate.go b/services/wallet/blockchainstate.go new file mode 100644 index 000000000..fcb59b0f6 --- /dev/null +++ b/services/wallet/blockchainstate.go @@ -0,0 +1,149 @@ +package wallet + +import ( + "context" + "sync" + "time" + + "github.com/ethereum/go-ethereum/log" + + "github.com/status-im/status-go/multiaccounts/accounts" + "github.com/status-im/status-go/rpc" + "github.com/status-im/status-go/services/wallet/async" + "github.com/status-im/status-go/services/wallet/common" +) + +const ( + fetchLatestBlockNumbersInterval = 10 * time.Minute +) + +type fetchLatestBlockNumberCommand struct { + state *BlockChainState + rpcClient *rpc.Client + accountsDB *accounts.Database +} + +func (c *fetchLatestBlockNumberCommand) Command() async.Command { + return async.InfiniteCommand{ + Interval: fetchLatestBlockNumbersInterval, + Runable: c.Run, + }.Run +} + +func (c *fetchLatestBlockNumberCommand) Run(parent context.Context) (err error) { + log.Debug("start fetchLatestBlockNumberCommand") + + networks, err := c.rpcClient.NetworkManager.Get(false) + if err != nil { + return nil + } + areTestNetworksEnabled, err := c.accountsDB.GetTestNetworksEnabled() + if err != nil { + return + } + ctx := context.Background() + for _, network := range networks { + if network.IsTest != areTestNetworksEnabled { + continue + } + _, _ = c.state.fetchLatestBlockNumber(ctx, network.ChainID) + } + return nil +} + +type LatestBlockData struct { + blockNumber uint64 + timestamp time.Time + blockDuration time.Duration +} + +type BlockChainState struct { + rpcClient *rpc.Client + accountsDB *accounts.Database + blkMu sync.RWMutex + latestBlockNumbers map[uint64]LatestBlockData + group *async.Group + cancelFn context.CancelFunc + sinceFn func(time.Time) time.Duration +} + +func NewBlockChainState(rpcClient *rpc.Client, accountsDb *accounts.Database) *BlockChainState { + return &BlockChainState{ + rpcClient: rpcClient, + accountsDB: accountsDb, + blkMu: sync.RWMutex{}, + latestBlockNumbers: make(map[uint64]LatestBlockData), + sinceFn: time.Since, + } +} + +func (s *BlockChainState) Start() { + ctx, cancel := context.WithCancel(context.Background()) + s.cancelFn = cancel + s.group = async.NewGroup(ctx) + + command := &fetchLatestBlockNumberCommand{ + state: s, + accountsDB: s.accountsDB, + rpcClient: s.rpcClient, + } + s.group.Add(command.Command()) +} + +func (s *BlockChainState) Stop() { + if s.cancelFn != nil { + s.cancelFn() + s.cancelFn = nil + } + if s.group != nil { + s.group.Stop() + s.group.Wait() + s.group = nil + } +} + +func (s *BlockChainState) GetEstimatedLatestBlockNumber(ctx context.Context, chainID uint64) (uint64, error) { + blockNumber, ok := s.estimateLatestBlockNumber(chainID) + if ok { + return blockNumber, nil + } + return s.fetchLatestBlockNumber(ctx, chainID) +} + +func (s *BlockChainState) fetchLatestBlockNumber(ctx context.Context, chainID uint64) (uint64, error) { + client, err := s.rpcClient.EthClient(chainID) + if err != nil { + return 0, err + } + blockNumber, err := client.BlockNumber(ctx) + if err != nil { + return 0, err + } + blockDuration, found := common.AverageBlockDurationForChain[common.ChainID(chainID)] + if !found { + blockDuration = common.AverageBlockDurationForChain[common.ChainID(common.UnknownChainID)] + } + s.setLatestBlockDataForChain(chainID, LatestBlockData{ + blockNumber: blockNumber, + timestamp: time.Now(), + blockDuration: blockDuration, + }) + return blockNumber, nil +} + +func (s *BlockChainState) setLatestBlockDataForChain(chainID uint64, latestBlockData LatestBlockData) { + s.blkMu.Lock() + defer s.blkMu.Unlock() + s.latestBlockNumbers[chainID] = latestBlockData +} + +func (s *BlockChainState) estimateLatestBlockNumber(chainID uint64) (uint64, bool) { + s.blkMu.RLock() + defer s.blkMu.RUnlock() + blockData, ok := s.latestBlockNumbers[chainID] + if !ok { + return 0, false + } + timeDiff := s.sinceFn(blockData.timestamp) + return blockData.blockNumber + uint64((timeDiff / blockData.blockDuration)), true +} diff --git a/services/wallet/blockchainstate_test.go b/services/wallet/blockchainstate_test.go new file mode 100644 index 000000000..30e8356a7 --- /dev/null +++ b/services/wallet/blockchainstate_test.go @@ -0,0 +1,46 @@ +package wallet + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +var mockupTime = time.Unix(946724400, 0) // 2000-01-01 12:00:00 + +func mockupSince(t time.Time) time.Duration { + return mockupTime.Sub(t) +} + +func setupTestState(t *testing.T) (s *BlockChainState) { + state := NewBlockChainState(nil, nil) + state.sinceFn = mockupSince + return state +} + +func TestEstimateLatestBlockNumber(t *testing.T) { + state := setupTestState(t) + + state.setLatestBlockDataForChain(1, LatestBlockData{ + blockNumber: uint64(100), + timestamp: mockupTime.Add(-31 * time.Second), + blockDuration: 10 * time.Second, + }) + + state.setLatestBlockDataForChain(2, LatestBlockData{ + blockNumber: uint64(200), + timestamp: mockupTime.Add(-5 * time.Second), + blockDuration: 12 * time.Second, + }) + + val, ok := state.estimateLatestBlockNumber(1) + require.True(t, ok) + require.Equal(t, uint64(103), val) + val, ok = state.estimateLatestBlockNumber(2) + require.True(t, ok) + require.Equal(t, uint64(200), val) + val, ok = state.estimateLatestBlockNumber(3) + require.False(t, ok) + require.Equal(t, uint64(0), val) +} diff --git a/services/wallet/common/const.go b/services/wallet/common/const.go index 2bef7e765..34be71da0 100644 --- a/services/wallet/common/const.go +++ b/services/wallet/common/const.go @@ -1,5 +1,7 @@ package common +import "time" + type ChainID uint64 const ( @@ -12,3 +14,13 @@ const ( ArbitrumMainnet uint64 = 42161 ArbitrumGoerli uint64 = 421613 ) + +var AverageBlockDurationForChain = map[ChainID]time.Duration{ + ChainID(UnknownChainID): time.Duration(12000) * time.Millisecond, + ChainID(EthereumMainnet): time.Duration(12000) * time.Millisecond, + ChainID(EthereumGoerli): time.Duration(12000) * time.Millisecond, + ChainID(OptimismMainnet): time.Duration(400) * time.Millisecond, + ChainID(OptimismGoerli): time.Duration(2000) * time.Millisecond, + ChainID(ArbitrumMainnet): time.Duration(300) * time.Millisecond, + ChainID(ArbitrumGoerli): time.Duration(1500) * time.Millisecond, +} diff --git a/services/wallet/history/balance.go b/services/wallet/history/balance.go index bff1983cb..b7f88eb49 100644 --- a/services/wallet/history/balance.go +++ b/services/wallet/history/balance.go @@ -10,6 +10,8 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/core/types" + + w_common "github.com/status-im/status-go/services/wallet/common" ) type Balance struct { @@ -17,18 +19,9 @@ type Balance struct { } const ( - defaultChains = uint64(0) - aDay = time.Duration(24) * time.Hour + aDay = time.Duration(24) * time.Hour ) -var averageBlockDurationForChain = map[uint64]time.Duration{ - defaultChains: time.Duration(12000) * time.Millisecond, - 10: time.Duration(400) * time.Millisecond, // Optimism - 420: time.Duration(2000) * time.Millisecond, // Optimism Testnet - 42161: time.Duration(300) * time.Millisecond, // Arbitrum - 421611: time.Duration(1500) * time.Millisecond, // Arbitrum Testnet -} - // Must have a common divisor to share common blocks and increase the cache hit const ( twiceADayStride time.Duration = time.Duration(12) * time.Hour @@ -78,9 +71,9 @@ var timeIntervalToStrideDuration = map[TimeInterval]time.Duration{ } func strideBlockCount(timeInterval TimeInterval, chainID uint64) int { - blockDuration, found := averageBlockDurationForChain[chainID] + blockDuration, found := w_common.AverageBlockDurationForChain[w_common.ChainID(chainID)] if !found { - blockDuration = averageBlockDurationForChain[defaultChains] + blockDuration = w_common.AverageBlockDurationForChain[w_common.ChainID(w_common.UnknownChainID)] } return int(timeIntervalToStrideDuration[timeInterval] / blockDuration) diff --git a/services/wallet/service.go b/services/wallet/service.go index 8c967d52e..583f698b1 100644 --- a/services/wallet/service.go +++ b/services/wallet/service.go @@ -102,6 +102,7 @@ func NewService( reader := NewReader(rpcClient, tokenManager, marketManager, accountsDB, NewPersistence(db), feed) history := history.NewService(db, accountsDB, feed, rpcClient, tokenManager, marketManager) currency := currency.NewService(db, feed, tokenManager, marketManager) + blockChainState := NewBlockChainState(rpcClient, accountsDB) openseaHTTPClient := opensea.NewHTTPClient() openseaClient := opensea.NewClient(config.WalletConfig.OpenseaAPIKey, openseaHTTPClient, feed) @@ -165,6 +166,7 @@ func NewService( currency: currency, activity: activity, decoder: NewDecoder(), + blockChainState: blockChainState, } } @@ -195,6 +197,7 @@ type Service struct { currency *currency.Service activity *activity.Service decoder *Decoder + blockChainState *BlockChainState } // Start signals transmitter. @@ -204,6 +207,7 @@ func (s *Service) Start() error { err := s.signals.Start() s.history.Start() s.collectibles.Start() + s.blockChainState.Start() s.started = true return err } @@ -223,6 +227,7 @@ func (s *Service) Stop() error { s.history.Stop() s.activity.Stop() s.collectibles.Stop() + s.blockChainState.Stop() s.started = false log.Info("wallet stopped") return nil