diff --git a/services/wallet/api.go b/services/wallet/api.go index d8c30e26ad6..c86ab1f9f2f 100644 --- a/services/wallet/api.go +++ b/services/wallet/api.go @@ -37,6 +37,7 @@ import ( "github.com/status-im/status-go/services/wallet/router" "github.com/status-im/status-go/services/wallet/router/fees" "github.com/status-im/status-go/services/wallet/thirdparty" + "github.com/status-im/status-go/services/wallet/thirdparty/efp" "github.com/status-im/status-go/services/wallet/token" tokenTypes "github.com/status-im/status-go/services/wallet/token/types" "github.com/status-im/status-go/services/wallet/tokenbalances" @@ -826,3 +827,30 @@ func (api *API) UnsubscribeFromLeaderboard() error { logutils.ZapLogger().Debug("call to UnsubscribeFromLeaderboard") return api.s.leaderboardService.UnsubscribeFromLeaderboard() } + +// GetFollowingAddresses fetches the list of addresses that the given user is following via EFP +func (api *API) GetFollowingAddresses(ctx context.Context, userAddress common.Address, search string, limit, offset int) ([]efp.FollowingAddress, error) { + logutils.ZapLogger().Debug("call to GetFollowingAddresses", + zap.String("userAddress", userAddress.Hex()), + zap.String("search", search), + zap.Int("limit", limit), + zap.Int("offset", offset)) + + if api.s.followingManager == nil { + return nil, errors.New("following manager not initialized") + } + + return api.s.followingManager.FetchFollowingAddresses(ctx, userAddress, search, limit, offset) +} + +// GetFollowingStats fetches the stats (following count) for a user +func (api *API) GetFollowingStats(ctx context.Context, userAddress common.Address) (int, error) { + logutils.ZapLogger().Debug("call to GetFollowingStats", + zap.String("userAddress", userAddress.Hex())) + + if api.s.followingManager == nil { + return 0, errors.New("following manager not initialized") + } + + return api.s.followingManager.FetchFollowingStats(ctx, userAddress) +} diff --git a/services/wallet/following/manager.go b/services/wallet/following/manager.go new file mode 100644 index 00000000000..74da398c719 --- /dev/null +++ b/services/wallet/following/manager.go @@ -0,0 +1,93 @@ +package following + +import ( + "context" + "errors" + "time" + + "go.uber.org/zap" + + "github.com/ethereum/go-ethereum/common" + + "github.com/status-im/status-go/services/wallet/thirdparty/efp" +) + +// Manager handles following address operations using EFP provider +type Manager struct { + provider efp.FollowingDataProvider + logger *zap.Logger +} + +// NewManager creates a new following manager with the provided EFP provider +func NewManager(provider efp.FollowingDataProvider, logger *zap.Logger) *Manager { + return &Manager{ + provider: provider, + logger: logger, + } +} + +// FetchFollowingAddresses fetches the list of addresses that the given user is following +func (m *Manager) FetchFollowingAddresses(ctx context.Context, userAddress common.Address, search string, limit, offset int) ([]efp.FollowingAddress, error) { + m.logger.Debug("following.Manager.FetchFollowingAddresses", + zap.String("userAddress", userAddress.Hex()), + zap.String("search", search), + zap.Int("limit", limit), + zap.Int("offset", offset), + ) + + if m.provider == nil { + return nil, errors.New("EFP provider not initialized") + } + + if !m.provider.IsConnected() { + m.logger.Warn("EFP provider not connected", zap.String("providerID", m.provider.ID())) + return []efp.FollowingAddress{}, nil + } + + startTime := time.Now() + addresses, err := m.provider.FetchFollowingAddresses(ctx, userAddress, search, limit, offset) + duration := time.Since(startTime) + + m.logger.Debug("following.Manager.FetchFollowingAddresses completed", + zap.String("userAddress", userAddress.Hex()), + zap.String("providerID", m.provider.ID()), + zap.Int("addresses.len", len(addresses)), + zap.Duration("duration", duration), + zap.Error(err), + ) + + if err != nil { + return nil, err + } + + return addresses, nil +} + +// FetchFollowingStats fetches the stats (following count) for a user +func (m *Manager) FetchFollowingStats(ctx context.Context, userAddress common.Address) (int, error) { + m.logger.Debug("following.Manager.FetchFollowingStats", + zap.String("userAddress", userAddress.Hex()), + ) + + if m.provider == nil { + return 0, errors.New("EFP provider not initialized") + } + + if !m.provider.IsConnected() { + m.logger.Warn("EFP provider not connected", zap.String("providerID", m.provider.ID())) + return 0, nil + } + + count, err := m.provider.FetchFollowingStats(ctx, userAddress) + if err != nil { + m.logger.Error("following.Manager.FetchFollowingStats error", zap.Error(err)) + return 0, err + } + + m.logger.Debug("following.Manager.FetchFollowingStats completed", + zap.String("userAddress", userAddress.Hex()), + zap.Int("count", count), + ) + + return count, nil +} diff --git a/services/wallet/following/manager_test.go b/services/wallet/following/manager_test.go new file mode 100644 index 00000000000..c695276d27a --- /dev/null +++ b/services/wallet/following/manager_test.go @@ -0,0 +1,117 @@ +package following + +import ( + "errors" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + "go.uber.org/zap" + + "github.com/status-im/status-go/services/wallet/thirdparty/efp" + mock_efp "github.com/status-im/status-go/services/wallet/thirdparty/efp/mock" +) + +func TestFetchFollowingAddressesSuccess(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + ctx := t.Context() + userAddress := common.HexToAddress("0x742d35cc6cf4c7c7") + + expected := []efp.FollowingAddress{ + { + Address: common.HexToAddress("0x983110309620D911731Ac0932219af06091b6744"), + ENSName: "vitalik.eth", + }, + } + + mockProvider := mock_efp.NewMockFollowingDataProvider(mockCtrl) + mockProvider.EXPECT().ID().Return("efp").AnyTimes() + mockProvider.EXPECT().IsConnected().Return(true) + mockProvider.EXPECT().FetchFollowingAddresses(ctx, userAddress, "", 10, 0).Return(expected, nil) + + manager := NewManager(mockProvider, zap.NewNop()) + + result, err := manager.FetchFollowingAddresses(ctx, userAddress, "", 10, 0) + + require.NoError(t, err) + require.Len(t, result, 1) + require.Equal(t, expected[0].Address, result[0].Address) + require.Equal(t, expected[0].ENSName, result[0].ENSName) +} + +func TestFetchFollowingStatsSuccess(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + ctx := t.Context() + userAddress := common.HexToAddress("0x742d35cc6cf4c7c7") + expectedCount := 150 + + mockProvider := mock_efp.NewMockFollowingDataProvider(mockCtrl) + mockProvider.EXPECT().IsConnected().Return(true) + mockProvider.EXPECT().FetchFollowingStats(ctx, userAddress).Return(expectedCount, nil) + + manager := NewManager(mockProvider, zap.NewNop()) + + result, err := manager.FetchFollowingStats(ctx, userAddress) + + require.NoError(t, err) + require.Equal(t, expectedCount, result) +} + +func TestFetchFollowingAddressesProviderNotConnected(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + ctx := t.Context() + userAddress := common.HexToAddress("0x742d35cc6cf4c7c7") + + mockProvider := mock_efp.NewMockFollowingDataProvider(mockCtrl) + mockProvider.EXPECT().IsConnected().Return(false) + mockProvider.EXPECT().ID().Return("efp").AnyTimes() + + manager := NewManager(mockProvider, zap.NewNop()) + + result, err := manager.FetchFollowingAddresses(ctx, userAddress, "", 10, 0) + + require.NoError(t, err) + require.Len(t, result, 0) +} + +func TestFetchFollowingAddressesProviderError(t *testing.T) { + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + ctx := t.Context() + userAddress := common.HexToAddress("0x742d35cc6cf4c7c7") + expectedError := errors.New("provider error") + + mockProvider := mock_efp.NewMockFollowingDataProvider(mockCtrl) + mockProvider.EXPECT().ID().Return("efp").AnyTimes() + mockProvider.EXPECT().IsConnected().Return(true) + mockProvider.EXPECT().FetchFollowingAddresses(ctx, userAddress, "", 10, 0).Return(nil, expectedError) + + manager := NewManager(mockProvider, zap.NewNop()) + + result, err := manager.FetchFollowingAddresses(ctx, userAddress, "", 10, 0) + + require.Error(t, err) + require.Nil(t, result) + require.Equal(t, expectedError, err) +} + +func TestFetchFollowingAddressesNoProvider(t *testing.T) { + ctx := t.Context() + userAddress := common.HexToAddress("0x742d35cc6cf4c7c7") + + manager := NewManager(nil, zap.NewNop()) + + result, err := manager.FetchFollowingAddresses(ctx, userAddress, "", 10, 0) + + require.Error(t, err) + require.Nil(t, result) + require.Contains(t, err.Error(), "EFP provider not initialized") +} diff --git a/services/wallet/service.go b/services/wallet/service.go index f60d2ddf95c..12e86f67532 100644 --- a/services/wallet/service.go +++ b/services/wallet/service.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "time" "github.com/golang/protobuf/proto" @@ -41,6 +42,7 @@ import ( collectibles_ownership "github.com/status-im/status-go/services/wallet/collectibles/ownership" "github.com/status-im/status-go/services/wallet/community" "github.com/status-im/status-go/services/wallet/currency" + "github.com/status-im/status-go/services/wallet/following" "github.com/status-im/status-go/services/wallet/leaderboard" "github.com/status-im/status-go/services/wallet/market" "github.com/status-im/status-go/services/wallet/onramp" @@ -51,6 +53,7 @@ import ( activityfetcher_alchemy "github.com/status-im/status-go/services/wallet/thirdparty/activity/alchemy" "github.com/status-im/status-go/services/wallet/thirdparty/collectibles/alchemy" "github.com/status-im/status-go/services/wallet/thirdparty/collectibles/rarible" + "github.com/status-im/status-go/services/wallet/thirdparty/efp" "github.com/status-im/status-go/services/wallet/thirdparty/market/coingecko" "github.com/status-im/status-go/services/wallet/token" "github.com/status-im/status-go/services/wallet/transfer" @@ -115,6 +118,7 @@ func NewService( var collectibleProviders thirdparty.CollectibleProviders = thirdparty.CollectibleProviders{} var pathProcessors []pathprocessor.PathProcessor = []pathprocessor.PathProcessor{} var leaderboardConfig leaderboard.ServiceConfig = leaderboard.NewDefaultServiceConfig() + var followingManager *following.Manager if thirdpartyServicesEnabled { @@ -174,6 +178,24 @@ func NewService( pathProcessors = buildPathProcessors(rpcClient, transactor, tokenManager, ensResolver, featureFlags) leaderboardConfig = leaderboard.NewLeaderboardConfig(config.WalletConfig.MarketDataProxyConfig) + + // EFP (Ethereum Follow Protocol) provider + efpHTTPClient := thirdparty.NewHTTPClient( + thirdparty.WithDetailedTimeouts( + 5*time.Second, // dialTimeout + 5*time.Second, // tlsHandshakeTimeout + 5*time.Second, // responseHeaderTimeout + 20*time.Second, // requestTimeout + ), + thirdparty.WithMaxRetries(5), + ) + efpClient := efp.NewClient(efpHTTPClient) + followingManager = following.NewManager(efpClient, logutils.ZapLogger().Named("FollowingManager")) + } + + // Initialize followingManager with nil provider if third-party services are disabled + if followingManager == nil { + followingManager = following.NewManager(nil, logutils.ZapLogger().Named("FollowingManager")) } cryptoOnRampManager := onramp.NewManager(cryptoOnRampProviders) @@ -281,6 +303,7 @@ func NewService( cryptoOnRampManager: cryptoOnRampManager, collectiblesManager: collectiblesManager, collectibles: collectibles, + followingManager: followingManager, gethManager: gethManager, marketManager: marketManager, transactor: transactor, @@ -379,6 +402,7 @@ type Service struct { cryptoOnRampManager *onramp.Manager collectiblesManager *collectibles.Manager collectibles *collectibles.Service + followingManager *following.Manager gethManager *accsmanagement.AccountsManager marketManager *market.Manager transactor *transactions.Transactor diff --git a/services/wallet/thirdparty/efp/client.go b/services/wallet/thirdparty/efp/client.go new file mode 100644 index 00000000000..02594a0c887 --- /dev/null +++ b/services/wallet/thirdparty/efp/client.go @@ -0,0 +1,160 @@ +package efp + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + + "github.com/ethereum/go-ethereum/common" + + "github.com/status-im/status-go/services/wallet/thirdparty" +) + +const baseURL = "https://api.ethfollow.xyz/api/v1" + +// ENSData represents ENS information from the EFP API +type ENSData struct { + Name string `json:"name"` + Address string `json:"address"` + Avatar string `json:"avatar"` + Records map[string]string `json:"records"` + UpdatedAt string `json:"updated_at"` +} + +// EFPFollowingRecord represents a single following record from the EFP API +type EFPFollowingRecord struct { + Version int `json:"version"` + RecordType string `json:"record_type"` + Data string `json:"data"` // Ethereum address + Tags []string `json:"tags"` + ENS *ENSData `json:"ens"` // Nullable ENS data +} + +// EFPFollowingResponse represents the response from the EFP following endpoint +type EFPFollowingResponse struct { + Following []EFPFollowingRecord `json:"following"` +} + +// EFPStatsResponse represents the stats from the EFP API +type EFPStatsResponse struct { + FollowingCount int `json:"following_count"` + FollowersCount int `json:"followers_count"` +} + +// FollowingAddress represents a processed following address for internal use +type FollowingAddress struct { + Address common.Address `json:"address"` + Tags []string `json:"tags"` + ENSName string `json:"ensName"` // ENS name from API + Avatar string `json:"avatar"` // Avatar URL from API + Records map[string]string `json:"records"` // Social links and other ENS records +} + +type Client struct { + httpClient *thirdparty.HTTPClient + baseURL string +} + +func NewClient(httpClient *thirdparty.HTTPClient) *Client { + return &Client{ + httpClient: httpClient, + baseURL: baseURL, + } +} + +func (c *Client) ID() string { + return "efp" +} + +func (c *Client) IsConnected() bool { + // For now, always return true since we don't have connection status tracking + // This can be enhanced later with proper connection status management + return true +} + +// FetchFollowingAddresses fetches the list of addresses that the given user is following +func (c *Client) FetchFollowingAddresses(ctx context.Context, userAddress common.Address, search string, limit, offset int) ([]FollowingAddress, error) { + var urlStr string + + if search != "" { + // Search returns all results (no pagination) + urlStr = fmt.Sprintf("%s/users/%s/searchFollowing?include=ens&sort=latest&term=%s", + c.baseURL, userAddress.Hex(), url.QueryEscape(search)) + } else { + // Regular listing uses pagination + if limit <= 0 { + limit = 10 + } + if limit > 100 { + limit = 100 + } + if offset < 0 { + offset = 0 + } + urlStr = fmt.Sprintf("%s/users/%s/following?include=ens&limit=%d&offset=%d&sort=latest", + c.baseURL, userAddress.Hex(), limit, offset) + } + + response, err := c.httpClient.DoGetRequest(ctx, urlStr, nil) + if err != nil { + return nil, err + } + + return handleFollowingResponse(response) +} + +// FetchFollowingStats fetches the stats (following/followers count) for a user +func (c *Client) FetchFollowingStats(ctx context.Context, userAddress common.Address) (int, error) { + urlStr := fmt.Sprintf("%s/users/%s/stats", c.baseURL, userAddress.Hex()) + + response, err := c.httpClient.DoGetRequest(ctx, urlStr, nil) + if err != nil { + return 0, err + } + + var statsResponse EFPStatsResponse + err = json.Unmarshal(response, &statsResponse) + if err != nil { + return 0, fmt.Errorf("failed to unmarshal EFP stats response: %w", err) + } + + return statsResponse.FollowingCount, nil +} + +func handleFollowingResponse(response []byte) ([]FollowingAddress, error) { + var efpResponse EFPFollowingResponse + err := json.Unmarshal(response, &efpResponse) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal EFP response: %w - %s", err, string(response)) + } + + result := make([]FollowingAddress, 0, len(efpResponse.Following)) + for _, record := range efpResponse.Following { + // Only process address records + if record.RecordType != "address" { + continue + } + + // Parse the address + if !common.IsHexAddress(record.Data) { + continue // Skip invalid addresses + } + + followingAddr := FollowingAddress{ + Address: common.HexToAddress(record.Data), + Tags: record.Tags, + } + + // Include ENS data if available + if record.ENS != nil { + followingAddr.ENSName = record.ENS.Name + followingAddr.Avatar = record.ENS.Avatar + followingAddr.Records = record.ENS.Records + } + + result = append(result, followingAddr) + } + + return result, nil +} diff --git a/services/wallet/thirdparty/efp/client_test.go b/services/wallet/thirdparty/efp/client_test.go new file mode 100644 index 00000000000..aec28007658 --- /dev/null +++ b/services/wallet/thirdparty/efp/client_test.go @@ -0,0 +1,175 @@ +package efp + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + "github.com/status-im/status-go/services/wallet/thirdparty" +) + +func setupTest(t *testing.T, response []byte) (*httptest.Server, func()) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + _, err := w.Write(response) + if err != nil { + return + } + })) + + return srv, func() { + srv.Close() + } +} + +func TestFetchFollowingAddressesPagination(t *testing.T) { + expected := EFPFollowingResponse{ + Following: []EFPFollowingRecord{ + { + Version: 1, + RecordType: "address", + Data: "0x983110309620D911731Ac0932219af06091b6744", + Tags: []string{"ens", "efp"}, + ENS: &ENSData{ + Name: "vitalik.eth", + Avatar: "https://example.com/avatar.png", + Records: map[string]string{ + "com.twitter": "vitalikbuterin", + }, + }, + }, + { + Version: 1, + RecordType: "address", + Data: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", + Tags: []string{"friend"}, + }, + }, + } + + response, err := json.Marshal(expected) + require.NoError(t, err) + srv, stop := setupTest(t, response) + defer stop() + + efpClient := &Client{ + httpClient: thirdparty.NewHTTPClient(), + baseURL: srv.URL, + } + + userAddress := common.HexToAddress("0x742d35cc6cf4c7c7") + addresses, err := efpClient.FetchFollowingAddresses(t.Context(), userAddress, "", 10, 0) + + require.NoError(t, err) + require.Len(t, addresses, 2) + require.Equal(t, "vitalik.eth", addresses[0].ENSName) + require.Equal(t, "https://example.com/avatar.png", addresses[0].Avatar) + require.Equal(t, "vitalikbuterin", addresses[0].Records["com.twitter"]) + require.Equal(t, common.HexToAddress("0x983110309620D911731Ac0932219af06091b6744"), addresses[0].Address) +} + +func TestFetchFollowingAddressesSearch(t *testing.T) { + expected := EFPFollowingResponse{ + Following: []EFPFollowingRecord{ + { + Version: 1, + RecordType: "address", + Data: "0x983110309620D911731Ac0932219af06091b6744", + Tags: []string{"ens"}, + ENS: &ENSData{ + Name: "vitalik.eth", + }, + }, + }, + } + + response, err := json.Marshal(expected) + require.NoError(t, err) + srv, stop := setupTest(t, response) + defer stop() + + efpClient := &Client{ + httpClient: thirdparty.NewHTTPClient(), + baseURL: srv.URL, + } + + userAddress := common.HexToAddress("0x742d35cc6cf4c7c7") + addresses, err := efpClient.FetchFollowingAddresses(t.Context(), userAddress, "vitalik", 0, 0) + + require.NoError(t, err) + require.Len(t, addresses, 1) + require.Equal(t, "vitalik.eth", addresses[0].ENSName) +} + +func TestFetchFollowingStats(t *testing.T) { + expected := EFPStatsResponse{ + FollowingCount: 150, + FollowersCount: 42, + } + + response, err := json.Marshal(expected) + require.NoError(t, err) + srv, stop := setupTest(t, response) + defer stop() + + efpClient := &Client{ + httpClient: thirdparty.NewHTTPClient(), + baseURL: srv.URL, + } + + userAddress := common.HexToAddress("0x742d35cc6cf4c7c7") + count, err := efpClient.FetchFollowingStats(t.Context(), userAddress) + + require.NoError(t, err) + require.Equal(t, 150, count) +} + +func TestFetchFollowingAddressesError(t *testing.T) { + // Test with malformed JSON response + resp := []byte("{invalid json") + srv, stop := setupTest(t, resp) + defer stop() + + efpClient := &Client{ + httpClient: thirdparty.NewHTTPClient(), + baseURL: srv.URL, + } + + userAddress := common.HexToAddress("0x742d35cc6cf4c7c7") + _, err := efpClient.FetchFollowingAddresses(t.Context(), userAddress, "", 10, 0) + + require.Error(t, err) +} + +func TestClientID(t *testing.T) { + httpClient := thirdparty.NewHTTPClient( + thirdparty.WithDetailedTimeouts( + 5*time.Second, + 5*time.Second, + 5*time.Second, + 20*time.Second, + ), + thirdparty.WithMaxRetries(5), + ) + efpClient := NewClient(httpClient) + require.Equal(t, "efp", efpClient.ID()) +} + +func TestClientIsConnected(t *testing.T) { + httpClient := thirdparty.NewHTTPClient( + thirdparty.WithDetailedTimeouts( + 5*time.Second, + 5*time.Second, + 5*time.Second, + 20*time.Second, + ), + thirdparty.WithMaxRetries(5), + ) + efpClient := NewClient(httpClient) + require.True(t, efpClient.IsConnected()) +} diff --git a/services/wallet/thirdparty/efp/types.go b/services/wallet/thirdparty/efp/types.go new file mode 100644 index 00000000000..f7d691aa2ac --- /dev/null +++ b/services/wallet/thirdparty/efp/types.go @@ -0,0 +1,17 @@ +package efp + +import ( + "context" + + "github.com/ethereum/go-ethereum/common" +) + +//go:generate go tool mockgen -package=mock_efp -source=types.go -destination=mock/mock_efp.go + +// FollowingDataProvider defines the interface for providers that can fetch following addresses +type FollowingDataProvider interface { + ID() string + IsConnected() bool + FetchFollowingAddresses(ctx context.Context, userAddress common.Address, search string, limit, offset int) ([]FollowingAddress, error) + FetchFollowingStats(ctx context.Context, userAddress common.Address) (int, error) +}