Files
Igor Sirotin f9cc782a6f refactor: move services to pkg/services
Part of the Go project layout migration, item 31.

Pure move plus import-path rewrite across 687 files. No API or behaviour
change.

The services keep their grouping under pkg/services/<name> rather than
being promoted to pkg/<name>: 27 top-level directories in pkg/ would read
worse than what we have, and the grouping is what makes "an RPC service"
identifiable at a glance.

Paths that follow the move: the logosstorage test target and generate
step, the two wallet token-list tools, the migration-order check (and the
pre-rebase hook symlinked to it), and the storage env helper.

refs #7067
2026-08-21 10:11:05 +02:00

432 lines
13 KiB
Go

package wallet
//go:generate go tool mockgen -package=mock_reader -source=reader.go -destination=mock/reader/reader.go
import (
"context"
"math"
"math/big"
"slices"
"sync"
"sync/atomic"
"time"
"go.uber.org/zap"
"github.com/bep/debounce"
"golang.org/x/exp/maps"
"github.com/status-im/go-wallet-sdk/pkg/tokens/types"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/event"
"github.com/status-im/go-wallet-sdk/pkg/balance/multistandardfetcher"
"github.com/status-im/go-wallet-sdk/pkg/contracts/erc20"
"github.com/status-im/go-wallet-sdk/pkg/eventlog"
"github.com/status-im/status-go/internal/logutils"
"github.com/status-im/status-go/internal/panics"
"github.com/status-im/status-go/pkg/pubsub"
walletcommon "github.com/status-im/status-go/pkg/services/wallet/common"
"github.com/status-im/status-go/pkg/services/wallet/market"
"github.com/status-im/status-go/pkg/services/wallet/multistandardbalance"
"github.com/status-im/status-go/pkg/services/wallet/token"
tokentypes "github.com/status-im/status-go/pkg/services/wallet/token/types"
"github.com/status-im/status-go/pkg/services/wallet/tokenbalances"
"github.com/status-im/status-go/pkg/services/wallet/transferdetector"
"github.com/status-im/status-go/pkg/services/wallet/walletevent"
)
// WalletTickReload emitted every 15mn to reload the wallet balance and history
const EventWalletTickReload walletevent.EventType = "wallet-tick-reload"
const reloadDebounceTime = 5 * time.Second
type AccountAddress = tokenbalances.AccountAddress
type ContractAddress = tokenbalances.ContractAddress
type ReaderInterface interface {
Start() error
Stop()
GetCachedBalances(chainIDs []uint64, addresses []common.Address) (map[common.Address][]tokentypes.StorageToken, error)
GetLastTokenUpdateTimestamps() map[common.Address]int64
}
func NewReader(
tokenManager token.ManagerInterface,
marketManager *market.Manager,
walletFeed *event.Feed,
multistandardBalancePublisher *pubsub.Publisher,
tokenBalancesStorage tokenbalances.Storage,
transferDetectorPublisher *pubsub.Publisher) *Reader {
return &Reader{
tokenManager: tokenManager,
marketManager: marketManager,
multistandardBalancePublisher: multistandardBalancePublisher,
tokenBalancesStorage: tokenBalancesStorage,
transferDetectorPublisher: transferDetectorPublisher,
walletFeed: walletFeed,
reloadDebounceFn: debounce.New(reloadDebounceTime),
}
}
type Reader struct {
tokenManager token.ManagerInterface
marketManager *market.Manager
multistandardBalancePublisher *pubsub.Publisher
transferDetectorPublisher *pubsub.Publisher
tokenBalancesStorage tokenbalances.Storage
walletFeed *event.Feed
lastWalletTokenUpdateTimestamp sync.Map
reloadDebounceFn func(f func())
firstReloadPending atomic.Bool
stopCh chan struct{}
}
func (r *Reader) Start() error {
if r.stopCh != nil {
return nil
}
r.stopCh = make(chan struct{})
// Start balance change watcher
r.startBalanceChangeWatcher()
// Start transfer detection watcher
r.startTransferDetectionWatcher()
return nil
}
func (r *Reader) Stop() {
if r.stopCh == nil {
return
}
close(r.stopCh)
r.stopCh = nil
r.firstReloadPending.Store(false)
r.lastWalletTokenUpdateTimestamp = sync.Map{}
}
func (r *Reader) IsRunning() bool {
return r.stopCh != nil
}
func (r *Reader) triggerWalletReload() {
r.walletFeed.Send(walletevent.Event{
Type: EventWalletTickReload,
})
}
func (r *Reader) startBalanceChangeWatcher() {
if r.multistandardBalancePublisher == nil {
return
}
ch, unsub := pubsub.Subscribe[multistandardbalance.EventBalanceFetchFinished](r.multistandardBalancePublisher, 10)
go func() {
defer panics.LogOnPanic()
defer unsub()
for {
select {
case <-r.stopCh:
return
case event, ok := <-ch:
if !ok {
return
}
switch event.ResultType {
case multistandardfetcher.ResultTypeNative, multistandardfetcher.ResultTypeERC20:
if !event.BalanceChanged && event.OldState.FetchedAt != multistandardbalance.NeverFetched {
continue
}
if event.OldState.FetchedAt == multistandardbalance.NeverFetched {
r.firstReloadPending.Store(true)
}
r.refreshBalanceCache(context.TODO(), []uint64{event.Key.ChainID}, []common.Address{event.Key.Account})
}
}
}
}()
}
func (r *Reader) startTransferDetectionWatcher() {
if r.transferDetectorPublisher == nil {
return
}
ch, unsub := pubsub.Subscribe[transferdetector.EventTransferDetectionFinished](r.transferDetectorPublisher, 10)
go func() {
defer panics.LogOnPanic()
defer unsub()
for {
select {
case <-r.stopCh:
return
case msg, ok := <-ch:
if !ok {
return
}
for _, event := range msg.Events {
switch event.EventKey {
case eventlog.ERC20Transfer:
unpackedEvent, ok := event.Unpacked.(erc20.Erc20Transfer)
if !ok {
logutils.ZapLogger().Error("failed to unpack ERC20Transfer event")
continue
}
err := r.processERC20TransferEvent(msg.ChainID, unpackedEvent)
if err != nil {
logutils.ZapLogger().Error("failed to process ERC20Transfer event", zap.Error(err))
}
}
}
}
}
}()
}
func (r *Reader) processERC20TransferEvent(chainID uint64, event erc20.Erc20Transfer) error {
// Find token in db or if this is a community token, find its metadata
token, err := r.tokenManager.FindOrCreateTokenByAddress(context.TODO(), chainID, event.Raw.Address)
if err != nil {
return err
}
if token.CommunityData != nil {
// Only add community tokens to the previously owned tokens list,
// not any other spam token the account might've received.
_, err = r.tokenManager.MarkAsPreviouslyOwnedToken(token, event.To)
if err != nil {
return err
}
}
return nil
}
func tokensToBalancesPerChain(cachedTokens map[common.Address][]tokentypes.StorageToken) (map[uint64]map[common.Address]map[common.Address]*big.Int, error) {
cachedBalancesPerChain := map[uint64]map[common.Address]map[common.Address]*big.Int{}
for address, tokens := range cachedTokens {
for _, token := range tokens {
if _, ok := cachedBalancesPerChain[token.TokenChainID]; !ok {
cachedBalancesPerChain[token.TokenChainID] = map[common.Address]map[common.Address]*big.Int{}
}
if _, ok := cachedBalancesPerChain[token.TokenChainID][address]; !ok {
cachedBalancesPerChain[token.TokenChainID][address] = map[common.Address]*big.Int{}
}
bigBalance, ok := new(big.Int).SetString(token.RawBalance, 10)
if !ok {
return nil, ErrBigIntSetFromString(token.RawBalance)
}
cachedBalancesPerChain[token.TokenChainID][address][token.TokenAddress] = bigBalance
}
}
return cachedBalancesPerChain, nil
}
func (r *Reader) balancesToTokensByAddress(addresses []common.Address, allTokens []*tokentypes.Token,
balances map[uint64]map[common.Address]map[common.Address]*big.Int, cachedTokens map[common.Address][]tokentypes.StorageToken,
) map[common.Address][]tokentypes.StorageToken {
result := make(map[common.Address][]tokentypes.StorageToken)
for _, address := range addresses {
for _, token := range allTokens {
isMandatoryToken := slices.Contains(walletcommon.MandatoryTokens(), token.Key())
_, ok := balances[token.ChainID][address][token.Address]
hasError := !ok
hexBalance := &big.Int{}
if tokenBalance := balances[token.ChainID][address][token.Address]; tokenBalance != nil && len(tokenBalance.Bytes()) <= 32 {
// Balances should be represented by a uint256 (32 bytes). Some spam tokens return
// fake values larger than that, so we ignore them.
hexBalance = tokenBalance
}
balance := big.NewFloat(0.0)
if hexBalance != nil {
balance = new(big.Float).Quo(
new(big.Float).SetInt(hexBalance),
big.NewFloat(math.Pow(10, float64(token.Decimals))),
)
}
isVisible := balance.Cmp(big.NewFloat(0.0)) > 0 || isCachedToken(cachedTokens, address, token)
if !isVisible && !isMandatoryToken {
continue
}
walletToken := tokentypes.StorageToken{
TokenAddress: token.Address,
TokenChainID: token.ChainID,
RawBalance: hexBalance.String(),
Balance: balance,
HasError: hasError,
}
result[address] = append(result[address], walletToken)
}
}
return result
}
// GetLastTokenUpdateTimestamps returns last timestamps of successful token updates
func (r *Reader) GetLastTokenUpdateTimestamps() map[common.Address]int64 {
result := make(map[common.Address]int64)
r.lastWalletTokenUpdateTimestamp.Range(func(key, value interface{}) bool {
addr, ok1 := key.(common.Address)
timestamp, ok2 := value.(int64)
if ok1 && ok2 {
result[addr] = timestamp
}
return true
})
return result
}
func isCachedToken(cachedTokens map[common.Address][]tokentypes.StorageToken, address common.Address, token *tokentypes.Token) bool {
if tokens, ok := cachedTokens[address]; ok {
for _, t := range tokens {
if types.TokenKey(t.TokenChainID, t.TokenAddress) != token.Key() {
continue
}
return true
}
}
return false
}
// getCachedWalletTokensWithoutMarketData returns the latest fetched balances, minus
// price information
func (r *Reader) getCachedWalletTokensWithoutMarketData() (map[common.Address][]tokentypes.StorageToken, error) {
return r.tokenManager.GetCachedBalances()
}
func (r *Reader) updateTokenUpdateTimestamp(addresses []common.Address) {
for _, address := range addresses {
r.lastWalletTokenUpdateTimestamp.Store(address, time.Now().Unix())
}
}
func (r *Reader) refreshBalanceCache(ctx context.Context, chainIDs []uint64, addresses []common.Address) {
cachedTokens, err := r.getCachedWalletTokensWithoutMarketData()
if err != nil {
logutils.ZapLogger().Error("failed to get cached tokens", zap.Error(err))
return
}
allTokens, err := r.tokenManager.GetTokensByChains(chainIDs)
if err != nil {
logutils.ZapLogger().Error("failed to get tokens list", zap.Error(err))
return
}
balances, err := r.tokenBalancesStorage.GetBalances(ctx, allTokens, addresses)
if err != nil {
logutils.ZapLogger().Error("failed to update balances", zap.Error(err))
return
}
tokens := r.balancesToTokensByAddress(addresses, allTokens, balances, cachedTokens)
err = r.tokenManager.CacheBalances(tokens)
if err != nil {
logutils.ZapLogger().Error("failed to save tokens", zap.Error(err)) // Do not return error, as it is not critical
}
r.updateTokenUpdateTimestamp(addresses)
if r.firstReloadPending.CompareAndSwap(true, false) {
go func() {
defer panics.LogOnPanic()
r.triggerWalletReload()
}()
} else {
r.reloadDebounceFn(r.triggerWalletReload)
}
return
}
func (r *Reader) GetCachedBalances(chainIDs []uint64, addresses []common.Address) (map[common.Address][]tokentypes.StorageToken, error) {
cachedTokens, err := r.getCachedWalletTokensWithoutMarketData()
if err != nil {
return nil, err
}
balances, err := tokensToBalancesPerChain(cachedTokens)
if err != nil {
return nil, err
}
tokensOfInterest := make(map[string]struct{}, 0)
for _, cachedToken := range cachedTokens {
for _, token := range cachedToken {
if !slices.Contains(chainIDs, token.TokenChainID) {
continue
}
tokensOfInterest[types.TokenKey(token.TokenChainID, token.TokenAddress)] = struct{}{}
}
}
// add mandatory tokens for chainIDs to tokensOfInterest if not already present
for _, chainID := range chainIDs {
mandatoryTokens := walletcommon.MandatoryTokensByChainID(chainID)
for _, tokenKey := range mandatoryTokens {
if _, ok := tokensOfInterest[tokenKey]; ok {
continue
}
tokensOfInterest[tokenKey] = struct{}{}
}
}
allTokens, err := r.tokenManager.GetTokensByKeys(maps.Keys(tokensOfInterest))
if err != nil {
return nil, err
}
if storageBalances, storageErr := r.tokenBalancesStorage.GetBalances(context.Background(), allTokens, addresses); storageErr != nil {
logutils.ZapLogger().Error("failed to get live storage balances", zap.Error(storageErr))
} else {
balances = mergeFetchedBalances(balances, storageBalances)
}
return r.balancesToTokensByAddress(addresses, allTokens, balances, cachedTokens), nil
}
func mergeFetchedBalances(cached, fetched map[uint64]map[common.Address]map[common.Address]*big.Int) map[uint64]map[common.Address]map[common.Address]*big.Int {
if fetched == nil {
return cached
}
if cached == nil {
return fetched
}
for chainID, accounts := range fetched {
if cached[chainID] == nil {
cached[chainID] = make(map[common.Address]map[common.Address]*big.Int)
}
for account, tokens := range accounts {
if cached[chainID][account] == nil {
cached[chainID][account] = make(map[common.Address]*big.Int)
}
for tokenAddress, balance := range tokens {
cached[chainID][account][tokenAddress] = balance
}
}
}
return cached
}