diff --git a/backend/backend.go b/backend/backend.go index b04554519f..0c580eeee1 100644 --- a/backend/backend.go +++ b/backend/backend.go @@ -639,6 +639,28 @@ func (backend *Backend) ManualReconnect(reconnectETH bool) { } } +// SetAccountActivity marks an account as foreground-active for short-lived background refreshes. +func (backend *Backend) SetAccountActivity(accountCode accountsTypes.Code, active bool) error { + for _, account := range backend.Accounts() { + if account.Config().Config.Code != accountCode { + continue + } + ethAccount, ok := account.(*eth.Account) + if !ok { + return nil + } + if active && backend.environment != nil && backend.environment.UsingMobileData() { + active = false + } + backend.ethupdater.SetAccountActivity(ethAccount, active) + return nil + } + if !active { + return nil + } + return errp.Newf("unknown account code %q", accountCode) +} + // Testing returns whether this backend is for testing only. func (backend *Backend) Testing() bool { return backend.testing diff --git a/backend/coins/eth/account.go b/backend/coins/eth/account.go index c32dfb6669..383fa52524 100644 --- a/backend/coins/eth/account.go +++ b/backend/coins/eth/account.go @@ -80,6 +80,7 @@ type Account struct { // updateLock covers balance, blockNumber, nextNonce, transactions and activeTxProposal. updateLock locker.Locker balance coin.Amount + rawBalance coin.Amount blockNumber *big.Int transactions []*accounts.TransactionData @@ -111,6 +112,7 @@ func NewAccount( signingConfiguration: nil, httpClient: httpClient, balance: coin.NewAmountFromInt64(0), + rawBalance: coin.NewAmountFromInt64(0), enqueueUpdateCh: enqueueUpdateCh, @@ -411,8 +413,9 @@ func (account *Account) Update( } } + account.rawBalance = coin.NewAmount(balance) pendingAmount := pendingTxsAmount(outgoingTransactionsData, account.coin.erc20Token != nil) - account.balance = coin.NewAmount(balance.Sub(balance, pendingAmount)) + account.balance = coin.NewAmount(new(big.Int).Sub(balance, pendingAmount)) if account.initDone != nil { account.initDone() diff --git a/backend/coins/eth/updater.go b/backend/coins/eth/updater.go index 2a84396a6f..5126c8a647 100644 --- a/backend/coins/eth/updater.go +++ b/backend/coins/eth/updater.go @@ -12,6 +12,7 @@ import ( "github.com/BitBoxSwiss/bitbox-wallet-app/backend/accounts" "github.com/BitBoxSwiss/bitbox-wallet-app/backend/coins/eth/etherscan" "github.com/BitBoxSwiss/bitbox-wallet-app/util/errp" + "github.com/BitBoxSwiss/bitbox-wallet-app/util/locker" "github.com/BitBoxSwiss/bitbox-wallet-app/util/logging" "github.com/ethereum/go-ethereum/common" "github.com/sirupsen/logrus" @@ -21,6 +22,11 @@ import ( // pollInterval is the interval at which the account is polled for updates. var pollInterval = 5 * time.Minute +var activeProbeInterval = time.Minute +var activeProbeLeaseDuration = 90 * time.Second +var activeProbeRetryDelay = 30 * time.Second +var activeProbeBackoffDuration = 5 * time.Minute + // BalanceAndBlockNumberFetcher is an interface that defines a method to fetch balances for a list of addresses, // as well as the block number for a chain. // @@ -50,7 +56,7 @@ type Updater struct { quit chan struct{} // enqueueUpdateForAccount is used to enqueue an update for a specific ETH account. - enqueueUpdateForAccount <-chan *Account + enqueueUpdateForAccount chan *Account // updateETHAccountsCh is used to trigger an update of all ETH accounts. updateETHAccountsCh chan struct{} @@ -62,6 +68,11 @@ type Updater struct { // updateAccounts is a function that updates all ETH accounts. updateAccounts func() error + + activeAccountsLock locker.Locker + activeAccountLeases map[*Account]time.Time + activeProbeBackoffUntil time.Time + timeNow func() time.Time } // NewUpdater creates a new Updater instance. @@ -79,6 +90,8 @@ func NewUpdater( etherscanRateLimiter: etherscanRateLimiter, updateAccounts: updateETHAccounts, log: logging.Get().WithGroup("ethupdater"), + activeAccountLeases: map[*Account]time.Time{}, + timeNow: time.Now, } } @@ -92,6 +105,77 @@ func (u *Updater) EnqueueUpdateForAllAccounts() { u.updateETHAccountsCh <- struct{}{} } +// SetAccountActivity refreshes or clears the foreground activity lease for an ETH account. +func (u *Updater) SetAccountActivity(account *Account, active bool) { + if account == nil { + return + } + defer u.activeAccountsLock.Lock()() + if !active || account.isClosed() { + delete(u.activeAccountLeases, account) + return + } + u.activeAccountLeases[account] = u.timeNow().Add(activeProbeLeaseDuration) +} + +func (u *Updater) activeAccounts() []*Account { + defer u.activeAccountsLock.Lock()() + now := u.timeNow() + accounts := []*Account{} + for account, expiresAt := range u.activeAccountLeases { + if !expiresAt.After(now) || account.isClosed() { + delete(u.activeAccountLeases, account) + continue + } + accounts = append(accounts, account) + } + return accounts +} + +func (u *Updater) activeProbeBackoffActive() bool { + defer u.activeAccountsLock.RLock()() + return u.timeNow().Before(u.activeProbeBackoffUntil) +} + +func (u *Updater) setActiveProbeBackoff() { + defer u.activeAccountsLock.Lock()() + u.activeProbeBackoffUntil = u.timeNow().Add(activeProbeBackoffDuration) +} + +func (u *Updater) scheduleAccountUpdate(account *Account, delay time.Duration) { + if u.enqueueUpdateForAccount == nil { + return + } + go func() { + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-u.quit: + return + case <-timer.C: + } + select { + case <-u.quit: + case u.enqueueUpdateForAccount <- account: + } + }() +} + +func (u *Updater) probeActiveAccounts() { + if u.activeProbeBackoffActive() { + return + } + accountsByChainID := map[string][]*Account{} + for _, account := range u.activeAccounts() { + chainID := account.ETHCoin().ChainIDstr() + accountsByChainID[chainID] = append(accountsByChainID[chainID], account) + } + for chainID, ethAccounts := range accountsByChainID { + etherScanClient := etherscan.NewEtherScan(chainID, u.etherscanClient, u.etherscanRateLimiter) + u.ProbeActiveAccountBalances(ethAccounts, etherScanClient) + } +} + // PollBalances updates the balances of all ETH accounts. // It does that in three different cases: // - When a timer triggers the update. @@ -99,6 +183,7 @@ func (u *Updater) EnqueueUpdateForAllAccounts() { // - When a specific account is updated through EnqueueUpdateForAccount. func (u *Updater) PollBalances() { timer := time.After(0) + activeProbeTimer := time.After(activeProbeInterval) updateAll := func() { if err := u.updateAccounts(); err != nil { @@ -126,14 +211,117 @@ func (u *Updater) PollBalances() { case <-timer: go updateAll() timer = time.After(pollInterval) + case <-activeProbeTimer: + go u.probeActiveAccounts() + activeProbeTimer = time.After(activeProbeInterval) + } + } + } + +} + +func (account *Account) remoteBalance() *big.Int { + defer account.updateLock.RLock()() + return account.rawBalance.BigInt() +} + +// ProbeActiveAccountBalances cheaply checks active account balances and triggers a full update when +// a remote balance changed. +func (u *Updater) ProbeActiveAccountBalances( + ethAccounts []*Account, + etherScanClient BalanceAndBlockNumberFetcher, +) { + if len(ethAccounts) == 0 || u.activeProbeBackoffActive() { + return + } + + activeAccountsSet := map[*Account]struct{}{} + for _, account := range u.activeAccounts() { + activeAccountsSet[account] = struct{}{} + } + + nativeAccountsByAddress := map[common.Address][]*Account{} + nativeAddresses := []common.Address{} + changedAccounts := []*Account{} + changedAccountBalances := map[*Account]*big.Int{} + addChangedAccount := func(account *Account, remoteBalance *big.Int) { + if account.remoteBalance().Cmp(remoteBalance) == 0 { + return + } + if _, ok := changedAccountBalances[account]; ok { + return + } + changedAccountBalances[account] = new(big.Int).Set(remoteBalance) + changedAccounts = append(changedAccounts, account) + } + + for _, account := range ethAccounts { + if _, ok := activeAccountsSet[account]; !ok { + continue + } + if account.isClosed() { + u.SetAccountActivity(account, false) + continue + } + address, err := account.Address() + if err != nil { + u.log.WithError(err).Errorf("Could not get address for account %s", account.Config().Config.Code) + continue + } + if IsERC20(account) { + balance, err := account.coin.client.ERC20Balance(account.address.Address, account.coin.erc20Token) + if err != nil { + u.log.WithError(err).Errorf("Could not probe ERC20 balance for address %s", address.Address.Hex()) + u.setActiveProbeBackoff() + continue } + addChangedAccount(account, balance) + continue + } + if _, ok := nativeAccountsByAddress[address.Address]; !ok { + nativeAddresses = append(nativeAddresses, address.Address) } + nativeAccountsByAddress[address.Address] = append(nativeAccountsByAddress[address.Address], account) } + if len(nativeAddresses) > 0 { + balances, err := etherScanClient.Balances(context.TODO(), nativeAddresses) + if err != nil { + u.log.WithError(err).Error("Could not probe ETH account balances") + u.setActiveProbeBackoff() + } else { + for address, accounts := range nativeAccountsByAddress { + balance, ok := balances[address] + if !ok { + u.log.Errorf("Could not find probed balance for address %s", address.Hex()) + continue + } + for _, account := range accounts { + addChangedAccount(account, balance) + } + } + } + } + + if len(changedAccounts) == 0 { + return + } + u.updateBalancesAndBlockNumber(changedAccounts, etherScanClient, changedAccountBalances) + for _, account := range changedAccounts { + u.scheduleAccountUpdate(account, activeProbeRetryDelay) + } } // UpdateBalancesAndBlockNumber updates the balances of the accounts in the provided slice. func (u *Updater) UpdateBalancesAndBlockNumber(ethAccounts []*Account, etherScanClient BalanceAndBlockNumberFetcher) { + u.updateBalancesAndBlockNumber(ethAccounts, etherScanClient, nil) +} + +func (u *Updater) updateBalancesAndBlockNumber( + ethAccounts []*Account, + etherScanClient BalanceAndBlockNumberFetcher, + prefetchedBalances map[*Account]*big.Int, +) { if len(ethAccounts) == 0 { return } @@ -157,15 +345,22 @@ func (u *Updater) UpdateBalancesAndBlockNumber(ethAccounts []*Account, etherScan continue } if !IsERC20(account) { + if _, ok := prefetchedBalances[account]; ok { + continue + } ethNonErc20Addresses = append(ethNonErc20Addresses, address.Address) } } updateNonERC20 := true - balances, err := etherScanClient.Balances(context.TODO(), ethNonErc20Addresses) - if err != nil { - u.log.WithError(err).Error("Could not get balances for ETH accounts") - updateNonERC20 = false + balances := map[common.Address]*big.Int{} + if len(ethNonErc20Addresses) > 0 || prefetchedBalances == nil { + var err error + balances, err = etherScanClient.Balances(context.TODO(), ethNonErc20Addresses) + if err != nil { + u.log.WithError(err).Error("Could not get balances for ETH accounts") + updateNonERC20 = false + } } blockNumber, err := etherScanClient.BlockNumber(context.TODO()) @@ -189,28 +384,32 @@ func (u *Updater) UpdateBalancesAndBlockNumber(ethAccounts []*Account, etherScan account.SetOffline(err) } var balance *big.Int - switch { - case IsERC20(account): - var err error - balance, err = account.coin.client.ERC20Balance(account.address.Address, account.coin.erc20Token) - if err != nil { - u.log.WithError(err).Errorf("Could not get ERC20 balance for address %s", address.Address.Hex()) - account.SetOffline(err) - } - case updateNonERC20: - var ok bool - balance, ok = balances[address.Address] - if !ok { - errMsg := fmt.Sprintf("Could not find balance for address %s", address.Address.Hex()) + if prefetchedBalance, ok := prefetchedBalances[account]; ok { + balance = new(big.Int).Set(prefetchedBalance) + } else { + switch { + case IsERC20(account): + var err error + balance, err = account.coin.client.ERC20Balance(account.address.Address, account.coin.erc20Token) + if err != nil { + u.log.WithError(err).Errorf("Could not get ERC20 balance for address %s", address.Address.Hex()) + account.SetOffline(err) + } + case updateNonERC20: + var ok bool + balance, ok = balances[address.Address] + if !ok { + errMsg := fmt.Sprintf("Could not find balance for address %s", address.Address.Hex()) + u.log.Error(errMsg) + account.SetOffline(errp.Newf(errMsg)) + } + default: + // If we get there, this is a non-erc20 account and we failed getting balances. + // If we couldn't get the balances for non-erc20 accounts, we mark them as offline + errMsg := fmt.Sprintf("Could not get balance for address %s", address.Address.Hex()) u.log.Error(errMsg) account.SetOffline(errp.Newf(errMsg)) } - default: - // If we get there, this is a non-erc20 account and we failed getting balances. - // If we couldn't get the balances for non-erc20 accounts, we mark them as offline - errMsg := fmt.Sprintf("Could not get balance for address %s", address.Address.Hex()) - u.log.Error(errMsg) - account.SetOffline(errp.Newf(errMsg)) } if account.Offline() != nil { diff --git a/backend/coins/eth/updater_internal_test.go b/backend/coins/eth/updater_internal_test.go new file mode 100644 index 0000000000..77cc6b8cd2 --- /dev/null +++ b/backend/coins/eth/updater_internal_test.go @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: Apache-2.0 + +package eth + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestActiveAccountLease(t *testing.T) { + now := time.Unix(1000, 0) + updater := NewUpdater(nil, nil, nil, nil) + updater.timeNow = func() time.Time { return now } + + account := &Account{} + updater.SetAccountActivity(account, true) + require.Equal(t, []*Account{account}, updater.activeAccounts()) + + now = now.Add(activeProbeLeaseDuration + time.Second) + require.Empty(t, updater.activeAccounts()) + + updater.SetAccountActivity(account, true) + updater.SetAccountActivity(account, false) + require.Empty(t, updater.activeAccounts()) +} diff --git a/backend/coins/eth/updater_test.go b/backend/coins/eth/updater_test.go index 75ffcb1230..3f7b9b500d 100644 --- a/backend/coins/eth/updater_test.go +++ b/backend/coins/eth/updater_test.go @@ -244,6 +244,65 @@ func TestUpdateBalancesWithError(t *testing.T) { } +func TestProbeActiveBalancesOnlyActiveAccounts(t *testing.T) { + activeAccount := newAccount(t, nil, false) + defer activeAccount.Close() + inactiveAccount := newAccount(t, nil, false) + defer inactiveAccount.Close() + + activeAddress, err := activeAccount.Address() + require.NoError(t, err) + + fetcher := &mocks.BalanceAndBlockNumberFetcherMock{ + BalancesFunc: func(ctx context.Context, addresses []common.Address) (map[common.Address]*big.Int, error) { + require.Equal(t, []common.Address{activeAddress.Address}, addresses) + return map[common.Address]*big.Int{ + activeAddress.Address: big.NewInt(0), + }, nil + }, + BlockNumberFunc: func(ctx context.Context) (*big.Int, error) { + require.Fail(t, "unchanged probe should not trigger a full update") + return nil, nil + }, + } + + updater := eth.NewUpdater(nil, nil, nil, nil) + updater.SetAccountActivity(activeAccount, true) + updater.ProbeActiveAccountBalances([]*eth.Account{activeAccount, inactiveAccount}, fetcher) + + require.Len(t, fetcher.BalancesCalls(), 1) + require.Empty(t, fetcher.BlockNumberCalls()) +} + +func TestProbeActiveBalancesTriggersUpdateOnBalanceChange(t *testing.T) { + account := newAccount(t, nil, false) + defer account.Close() + + address, err := account.Address() + require.NoError(t, err) + + remoteBalance := big.NewInt(1000) + fetcher := &mocks.BalanceAndBlockNumberFetcherMock{ + BalancesFunc: func(ctx context.Context, addresses []common.Address) (map[common.Address]*big.Int, error) { + require.Equal(t, []common.Address{address.Address}, addresses) + return map[common.Address]*big.Int{ + address.Address: new(big.Int).Set(remoteBalance), + }, nil + }, + BlockNumberFunc: func(ctx context.Context) (*big.Int, error) { + return big.NewInt(101), nil + }, + } + + updater := eth.NewUpdater(nil, nil, nil, nil) + updater.SetAccountActivity(account, true) + updater.ProbeActiveAccountBalances([]*eth.Account{account}, fetcher) + + require.Len(t, fetcher.BalancesCalls(), 1) + require.Len(t, fetcher.BlockNumberCalls(), 1) + assertAccountBalance(t, account, remoteBalance) +} + func makeConfirmedTx(id string) *accounts.TransactionData { amount := coin.NewAmountFromInt64(1) return &accounts.TransactionData{ diff --git a/backend/handlers/handlers.go b/backend/handlers/handlers.go index 04d48c5fef..7b8948c543 100644 --- a/backend/handlers/handlers.go +++ b/backend/handlers/handlers.go @@ -103,6 +103,7 @@ type Backend interface { CanAddAccount(coinpkg.Code, keystore.Keystore) (string, bool) CreateAndPersistAccountConfig(coinCode coinpkg.Code, name string, keystore keystore.Keystore) (accountsTypes.Code, error) SetAccountActive(accountCode accountsTypes.Code, active bool) error + SetAccountActivity(accountCode accountsTypes.Code, active bool) error SetTokenActive(accountCode accountsTypes.Code, tokenCode string, active bool) error SetAccountReceiveScriptType(accountCode accountsTypes.Code, scriptType signing.ScriptType) error RenameAccount(accountCode accountsTypes.Code, name string) error @@ -224,6 +225,7 @@ func NewHandlers( getAPIRouterNoError(apiRouter)("/swap/accounts", handlers.getSwapAccounts).Methods("GET") getAPIRouterNoError(apiRouter)("/swap/status", handlers.getSwapStatus).Methods("GET") getAPIRouterNoError(apiRouter)("/accounts/balance-summary", handlers.getAccountsBalanceSummary).Methods("GET") + getAPIRouterNoError(apiRouter)("/account/{accountCode}/activity", handlers.postAccountActivity).Methods("POST") getAPIRouterNoError(apiRouter)("/set-account-active", handlers.postSetAccountActive).Methods("POST") getAPIRouterNoError(apiRouter)("/set-token-active", handlers.postSetTokenActive).Methods("POST") getAPIRouterNoError(apiRouter)("/set-account-receive-script-type", handlers.postSetAccountReceiveScriptType).Methods("POST") @@ -909,6 +911,26 @@ func (handlers *Handlers) getAccountsBalanceSummary(*http.Request) interface{} { return response{Success: true, TotalBalance: totalBalance} } +func (handlers *Handlers) postAccountActivity(r *http.Request) interface{} { + var jsonBody struct { + Active bool `json:"active"` + } + + type response struct { + Success bool `json:"success"` + ErrorMessage string `json:"errorMessage,omitempty"` + } + + if err := json.NewDecoder(r.Body).Decode(&jsonBody); err != nil { + return response{Success: false, ErrorMessage: err.Error()} + } + accountCode := accountsTypes.Code(mux.Vars(r)["accountCode"]) + if err := handlers.backend.SetAccountActivity(accountCode, jsonBody.Active); err != nil { + return response{Success: false, ErrorMessage: err.Error()} + } + return response{Success: true} +} + func (handlers *Handlers) postSetAccountActive(r *http.Request) interface{} { var jsonBody struct { AccountCode accountsTypes.Code `json:"accountCode"` diff --git a/frontends/web/src/api/account.ts b/frontends/web/src/api/account.ts index cd1b909998..b001ba435c 100644 --- a/frontends/web/src/api/account.ts +++ b/frontends/web/src/api/account.ts @@ -113,6 +113,13 @@ export const getEthAccountCodeAndNameByAddress = (address: string): Promise => { + return apiPost(`account/${code}/activity`, { active }); +}; + export type TStatus = { disabled: boolean; synced: boolean; diff --git a/frontends/web/src/hooks/account-activity.ts b/frontends/web/src/hooks/account-activity.ts new file mode 100644 index 0000000000..6a6a757bb2 --- /dev/null +++ b/frontends/web/src/hooks/account-activity.ts @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { useEffect } from 'react'; +import * as accountApi from '@/api/account'; + +const activityRefreshMs = 30_000; + +export const useAccountActivity = ( + code: accountApi.AccountCode, + enabled = true, +) => { + useEffect(() => { + if (!enabled) { + return; + } + + const setActive = (active: boolean) => { + accountApi.setAccountActivity(code, active).catch(console.error); + }; + + const refresh = () => { + if (document.visibilityState === 'visible') { + setActive(true); + } + }; + + const handleVisibilityChange = () => { + setActive(document.visibilityState === 'visible'); + }; + + refresh(); + const intervalID = window.setInterval(refresh, activityRefreshMs); + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + window.clearInterval(intervalID); + document.removeEventListener('visibilitychange', handleVisibilityChange); + setActive(false); + }; + }, [code, enabled]); +}; diff --git a/frontends/web/src/routes/account/account.tsx b/frontends/web/src/routes/account/account.tsx index 15428391cd..a239795bda 100644 --- a/frontends/web/src/routes/account/account.tsx +++ b/frontends/web/src/routes/account/account.tsx @@ -14,6 +14,7 @@ import { GuidedContent, GuideWrapper, Header, Main } from '@/components/layout'; import { Spinner } from '@/components/spinner/Spinner'; import { Message } from '@/components/message/message'; import { useLoad, useSubscribe, useSync } from '@/hooks/api'; +import { useAccountActivity } from '@/hooks/account-activity'; import { useBitsurance } from '@/hooks/bitsurance'; import { useDebounce } from '@/hooks/debounce'; import { useScrollIntoView } from '@/hooks/scroll-into-view'; @@ -22,7 +23,7 @@ import { ActionButtons } from './actionButtons'; import { Insured } from './components/insuredtag'; import { AccountGuide } from './guide'; import { BuyReceiveCTA } from './info/buy-receive-cta'; -import { isBitcoinBased } from './utils'; +import { isBitcoinBased, isEthereumBased } from './utils'; import { MultilineMarkup } from '@/utils/markup'; import { Dialog } from '@/components/dialog/dialog'; import { A } from '@/components/anchor/anchor'; @@ -91,6 +92,9 @@ const RemountAccount = ({ const supportedVendors = useLoad(getMarketVendors(code), [code]); const account = accounts && accounts.find(acct => acct.code === code); + const isEthereumAccount = account !== undefined && isEthereumBased(account.coinCode); + + useAccountActivity(code, isEthereumAccount); const { insured, uncoveredFunds, clearUncoveredFunds } = useBitsurance(code, account); diff --git a/frontends/web/src/routes/account/receive/receive.tsx b/frontends/web/src/routes/account/receive/receive.tsx index 63a64181a6..ef328445cb 100644 --- a/frontends/web/src/routes/account/receive/receive.tsx +++ b/frontends/web/src/routes/account/receive/receive.tsx @@ -4,6 +4,7 @@ import React, { useContext, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { AppContext } from '@/contexts/AppContext'; import { useLoad } from '@/hooks/api'; +import { useAccountActivity } from '@/hooks/account-activity'; import { UseBackButton } from '@/hooks/backbutton'; import * as accountApi from '@/api/account'; import { setAccountReceiveScriptType } from '@/api/backend'; @@ -139,7 +140,9 @@ export const Receive = ({ const [currentAddressIndex, setCurrentAddressIndex] = useState(0); const account = accounts.find(({ code: accountCode }) => accountCode === code); + const isEthereumAccount = account !== undefined && isEthereumBased(account.coinCode); const insured = account?.bitsuranceStatus === 'active'; + useAccountActivity(code, isEthereumAccount); // first array index: address types. second array index: unused addresses of that address type. const receiveAddresses = useLoad(accountApi.getReceiveAddressList(code));