From 17d5c536d4cab82f7863df538022687e14e871eb Mon Sep 17 00:00:00 2001 From: Aaron-Desktop-K <24273561+caveman-eth@users.noreply.github.com> Date: Fri, 3 Oct 2025 02:08:32 +0300 Subject: [PATCH 1/4] feat: Add EFP following address support Introduces Ethereum Follow Protocol (EFP) integration for fetching following addresses and stats. Adds a following manager, EFP client, and related API endpoints, along with tests for both manager and client functionality. --- services/wallet/api.go | 28 +++ services/wallet/following/manager.go | 96 ++++++++++ services/wallet/following/manager_test.go | 116 ++++++++++++ services/wallet/service.go | 11 ++ services/wallet/thirdparty/efp/client.go | 175 ++++++++++++++++++ services/wallet/thirdparty/efp/client_test.go | 154 +++++++++++++++ services/wallet/thirdparty/efp/types.go | 17 ++ 7 files changed, 597 insertions(+) create mode 100644 services/wallet/following/manager.go create mode 100644 services/wallet/following/manager_test.go create mode 100644 services/wallet/thirdparty/efp/client.go create mode 100644 services/wallet/thirdparty/efp/client_test.go create mode 100644 services/wallet/thirdparty/efp/types.go 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..6a6c15eb9c0 --- /dev/null +++ b/services/wallet/following/manager.go @@ -0,0 +1,96 @@ +package following + +import ( + "context" + "time" + + "go.uber.org/zap" + + "github.com/ethereum/go-ethereum/common" + + "github.com/status-im/status-go/logutils" + "github.com/status-im/status-go/services/wallet/thirdparty/efp" +) + +// Manager handles following address operations using EFP providers +type Manager struct { + providers []efp.FollowingDataProvider +} + +// NewManager creates a new following manager with the provided EFP providers +func NewManager(providers []efp.FollowingDataProvider) *Manager { + return &Manager{ + providers: providers, + } +} + +// FetchFollowingAddresses fetches the list of addresses that the given user is following +// Uses the first available provider (can be enhanced later with fallback logic) +func (m *Manager) FetchFollowingAddresses(ctx context.Context, userAddress common.Address, search string, limit, offset int) ([]efp.FollowingAddress, error) { + logutils.ZapLogger().Debug("following.Manager.FetchFollowingAddresses", + zap.String("userAddress", userAddress.Hex()), + zap.String("search", search), + zap.Int("limit", limit), + zap.Int("offset", offset), + zap.Int("providers.len", len(m.providers)), + ) + + if len(m.providers) == 0 { + return []efp.FollowingAddress{}, nil + } + + // Use the first provider (EFP client) + provider := m.providers[0] + if !provider.IsConnected() { + logutils.ZapLogger().Warn("EFP provider not connected", zap.String("providerID", provider.ID())) + return []efp.FollowingAddress{}, nil + } + + startTime := time.Now() + addresses, err := provider.FetchFollowingAddresses(ctx, userAddress, search, limit, offset) + duration := time.Since(startTime) + + logutils.ZapLogger().Debug("following.Manager.FetchFollowingAddresses completed", + zap.String("userAddress", userAddress.Hex()), + zap.String("providerID", 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) { + logutils.ZapLogger().Debug("following.Manager.FetchFollowingStats", + zap.String("userAddress", userAddress.Hex()), + ) + + if len(m.providers) == 0 { + return 0, nil + } + + provider := m.providers[0] + if !provider.IsConnected() { + logutils.ZapLogger().Warn("EFP provider not connected", zap.String("providerID", provider.ID())) + return 0, nil + } + + count, err := provider.FetchFollowingStats(ctx, userAddress) + if err != nil { + logutils.ZapLogger().Error("following.Manager.FetchFollowingStats error", zap.Error(err)) + return 0, err + } + + logutils.ZapLogger().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..360460307c0 --- /dev/null +++ b/services/wallet/following/manager_test.go @@ -0,0 +1,116 @@ +package following + +import ( + "context" + "errors" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "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 := context.TODO() + 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([]efp.FollowingDataProvider{mockProvider}) + + 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 := context.TODO() + 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([]efp.FollowingDataProvider{mockProvider}) + + 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 := context.TODO() + userAddress := common.HexToAddress("0x742d35cc6cf4c7c7") + + mockProvider := mock_efp.NewMockFollowingDataProvider(mockCtrl) + mockProvider.EXPECT().IsConnected().Return(false) + mockProvider.EXPECT().ID().Return("efp").AnyTimes() + + manager := NewManager([]efp.FollowingDataProvider{mockProvider}) + + 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 := context.TODO() + 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([]efp.FollowingDataProvider{mockProvider}) + + result, err := manager.FetchFollowingAddresses(ctx, userAddress, "", 10, 0) + + require.Error(t, err) + require.Nil(t, result) + require.Equal(t, expectedError, err) +} + +func TestFetchFollowingAddressesNoProviders(t *testing.T) { + ctx := context.TODO() + userAddress := common.HexToAddress("0x742d35cc6cf4c7c7") + + manager := NewManager([]efp.FollowingDataProvider{}) + + result, err := manager.FetchFollowingAddresses(ctx, userAddress, "", 10, 0) + + require.NoError(t, err) + require.Len(t, result, 0) +} diff --git a/services/wallet/service.go b/services/wallet/service.go index f60d2ddf95c..e450bd2bee9 100644 --- a/services/wallet/service.go +++ b/services/wallet/service.go @@ -41,6 +41,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 +52,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" @@ -246,6 +248,13 @@ func NewService( collectiblesOwnershipController, collectiblesPublisher) + // EFP (Ethereum Follow Protocol) providers + efpClient := efp.NewClient() + followingProviders := []efp.FollowingDataProvider{ + efpClient, + } + followingManager := following.NewManager(followingProviders) + activity := activity.NewService(db, accountsDB, tokenManager, collectiblesManager, feed) router := router.NewRouter(rpcClient, transactor, tokenManager, tokenBalancesFetcher, marketManager, collectibles, @@ -281,6 +290,7 @@ func NewService( cryptoOnRampManager: cryptoOnRampManager, collectiblesManager: collectiblesManager, collectibles: collectibles, + followingManager: followingManager, gethManager: gethManager, marketManager: marketManager, transactor: transactor, @@ -379,6 +389,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..6a8f52a63c2 --- /dev/null +++ b/services/wallet/thirdparty/efp/client.go @@ -0,0 +1,175 @@ +package efp + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "time" + + "github.com/ethereum/go-ethereum/common" + + "github.com/status-im/status-go/services/wallet/thirdparty" +) + +const baseURL = "https://api.ethfollow.xyz/api/v1" + +const ( + requestDelay = 100 * time.Millisecond +) + +// 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() *Client { + httpClient := thirdparty.NewHTTPClient( + thirdparty.WithDetailedTimeouts( + 5*time.Second, // dialTimeout + 5*time.Second, // tlsHandshakeTimeout + 5*time.Second, // responseHeaderTimeout + 20*time.Second, // requestTimeout + ), + thirdparty.WithMaxRetries(5), + ) + + 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..fb7a49e0b70 --- /dev/null +++ b/services/wallet/thirdparty/efp/client_test.go @@ -0,0 +1,154 @@ +package efp + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "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, _ := json.Marshal(expected) + srv, stop := setupTest(t, response) + defer stop() + + efpClient := &Client{ + httpClient: thirdparty.NewHTTPClient(), + baseURL: srv.URL, + } + + userAddress := common.HexToAddress("0x742d35cc6cf4c7c7") + addresses, err := efpClient.FetchFollowingAddresses(context.Background(), 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, _ := json.Marshal(expected) + srv, stop := setupTest(t, response) + defer stop() + + efpClient := &Client{ + httpClient: thirdparty.NewHTTPClient(), + baseURL: srv.URL, + } + + userAddress := common.HexToAddress("0x742d35cc6cf4c7c7") + addresses, err := efpClient.FetchFollowingAddresses(context.Background(), 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, _ := json.Marshal(expected) + srv, stop := setupTest(t, response) + defer stop() + + efpClient := &Client{ + httpClient: thirdparty.NewHTTPClient(), + baseURL: srv.URL, + } + + userAddress := common.HexToAddress("0x742d35cc6cf4c7c7") + count, err := efpClient.FetchFollowingStats(context.Background(), 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(context.Background(), userAddress, "", 10, 0) + + require.Error(t, err) +} + +func TestClientID(t *testing.T) { + efpClient := NewClient() + require.Equal(t, "efp", efpClient.ID()) +} + +func TestClientIsConnected(t *testing.T) { + efpClient := NewClient() + 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..26e032a8ace --- /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 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) +} From 0c01b9425f4c6afd722fad97467602177eab5fdc Mon Sep 17 00:00:00 2001 From: Aaron-Desktop-K <24273561+caveman-eth@users.noreply.github.com> Date: Fri, 7 Nov 2025 20:37:18 +0200 Subject: [PATCH 2/4] fix: mockgen command in efp types.go Changed the go:generate directive to explicitly use 'go tool mockgen' instead of just 'mockgen' for generating mocks. This may improve compatibility or clarity in environments where the tool is not globally installed. --- services/wallet/thirdparty/efp/types.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/wallet/thirdparty/efp/types.go b/services/wallet/thirdparty/efp/types.go index 26e032a8ace..f7d691aa2ac 100644 --- a/services/wallet/thirdparty/efp/types.go +++ b/services/wallet/thirdparty/efp/types.go @@ -6,7 +6,7 @@ import ( "github.com/ethereum/go-ethereum/common" ) -//go:generate mockgen -package=mock_efp -source=types.go -destination=mock/mock_efp.go +//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 { From fcf0e5635c3c13e50b24e6cb9ccc8bceb394104f Mon Sep 17 00:00:00 2001 From: Aaron-Desktop-K <24273561+caveman-eth@users.noreply.github.com> Date: Wed, 12 Nov 2025 18:37:50 +0200 Subject: [PATCH 3/4] fix(efp): use single EFP provider, initialization test changes, etc Refactored the following.Manager to accept a single EFP provider and logger instead of a slice of providers. Updated initialization in service.go and all related tests. Adjusted EFP client to require an HTTP client on construction, improving dependency injection and testability. --- services/wallet/following/manager.go | 51 +++++++++---------- services/wallet/following/manager_test.go | 29 ++++++----- services/wallet/service.go | 27 +++++++--- services/wallet/thirdparty/efp/client.go | 17 +------ services/wallet/thirdparty/efp/client_test.go | 41 +++++++++++---- 5 files changed, 91 insertions(+), 74 deletions(-) diff --git a/services/wallet/following/manager.go b/services/wallet/following/manager.go index 6a6c15eb9c0..74da398c719 100644 --- a/services/wallet/following/manager.go +++ b/services/wallet/following/manager.go @@ -2,57 +2,55 @@ package following import ( "context" + "errors" "time" "go.uber.org/zap" "github.com/ethereum/go-ethereum/common" - "github.com/status-im/status-go/logutils" "github.com/status-im/status-go/services/wallet/thirdparty/efp" ) -// Manager handles following address operations using EFP providers +// Manager handles following address operations using EFP provider type Manager struct { - providers []efp.FollowingDataProvider + provider efp.FollowingDataProvider + logger *zap.Logger } -// NewManager creates a new following manager with the provided EFP providers -func NewManager(providers []efp.FollowingDataProvider) *Manager { +// NewManager creates a new following manager with the provided EFP provider +func NewManager(provider efp.FollowingDataProvider, logger *zap.Logger) *Manager { return &Manager{ - providers: providers, + provider: provider, + logger: logger, } } // FetchFollowingAddresses fetches the list of addresses that the given user is following -// Uses the first available provider (can be enhanced later with fallback logic) func (m *Manager) FetchFollowingAddresses(ctx context.Context, userAddress common.Address, search string, limit, offset int) ([]efp.FollowingAddress, error) { - logutils.ZapLogger().Debug("following.Manager.FetchFollowingAddresses", + m.logger.Debug("following.Manager.FetchFollowingAddresses", zap.String("userAddress", userAddress.Hex()), zap.String("search", search), zap.Int("limit", limit), zap.Int("offset", offset), - zap.Int("providers.len", len(m.providers)), ) - if len(m.providers) == 0 { - return []efp.FollowingAddress{}, nil + if m.provider == nil { + return nil, errors.New("EFP provider not initialized") } - // Use the first provider (EFP client) - provider := m.providers[0] - if !provider.IsConnected() { - logutils.ZapLogger().Warn("EFP provider not connected", zap.String("providerID", provider.ID())) + 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 := provider.FetchFollowingAddresses(ctx, userAddress, search, limit, offset) + addresses, err := m.provider.FetchFollowingAddresses(ctx, userAddress, search, limit, offset) duration := time.Since(startTime) - logutils.ZapLogger().Debug("following.Manager.FetchFollowingAddresses completed", + m.logger.Debug("following.Manager.FetchFollowingAddresses completed", zap.String("userAddress", userAddress.Hex()), - zap.String("providerID", provider.ID()), + zap.String("providerID", m.provider.ID()), zap.Int("addresses.len", len(addresses)), zap.Duration("duration", duration), zap.Error(err), @@ -67,27 +65,26 @@ func (m *Manager) FetchFollowingAddresses(ctx context.Context, userAddress commo // FetchFollowingStats fetches the stats (following count) for a user func (m *Manager) FetchFollowingStats(ctx context.Context, userAddress common.Address) (int, error) { - logutils.ZapLogger().Debug("following.Manager.FetchFollowingStats", + m.logger.Debug("following.Manager.FetchFollowingStats", zap.String("userAddress", userAddress.Hex()), ) - if len(m.providers) == 0 { - return 0, nil + if m.provider == nil { + return 0, errors.New("EFP provider not initialized") } - provider := m.providers[0] - if !provider.IsConnected() { - logutils.ZapLogger().Warn("EFP provider not connected", zap.String("providerID", provider.ID())) + if !m.provider.IsConnected() { + m.logger.Warn("EFP provider not connected", zap.String("providerID", m.provider.ID())) return 0, nil } - count, err := provider.FetchFollowingStats(ctx, userAddress) + count, err := m.provider.FetchFollowingStats(ctx, userAddress) if err != nil { - logutils.ZapLogger().Error("following.Manager.FetchFollowingStats error", zap.Error(err)) + m.logger.Error("following.Manager.FetchFollowingStats error", zap.Error(err)) return 0, err } - logutils.ZapLogger().Debug("following.Manager.FetchFollowingStats completed", + m.logger.Debug("following.Manager.FetchFollowingStats completed", zap.String("userAddress", userAddress.Hex()), zap.Int("count", count), ) diff --git a/services/wallet/following/manager_test.go b/services/wallet/following/manager_test.go index 360460307c0..c695276d27a 100644 --- a/services/wallet/following/manager_test.go +++ b/services/wallet/following/manager_test.go @@ -1,13 +1,13 @@ package following import ( - "context" "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" @@ -17,7 +17,7 @@ func TestFetchFollowingAddressesSuccess(t *testing.T) { mockCtrl := gomock.NewController(t) defer mockCtrl.Finish() - ctx := context.TODO() + ctx := t.Context() userAddress := common.HexToAddress("0x742d35cc6cf4c7c7") expected := []efp.FollowingAddress{ @@ -32,7 +32,7 @@ func TestFetchFollowingAddressesSuccess(t *testing.T) { mockProvider.EXPECT().IsConnected().Return(true) mockProvider.EXPECT().FetchFollowingAddresses(ctx, userAddress, "", 10, 0).Return(expected, nil) - manager := NewManager([]efp.FollowingDataProvider{mockProvider}) + manager := NewManager(mockProvider, zap.NewNop()) result, err := manager.FetchFollowingAddresses(ctx, userAddress, "", 10, 0) @@ -46,7 +46,7 @@ func TestFetchFollowingStatsSuccess(t *testing.T) { mockCtrl := gomock.NewController(t) defer mockCtrl.Finish() - ctx := context.TODO() + ctx := t.Context() userAddress := common.HexToAddress("0x742d35cc6cf4c7c7") expectedCount := 150 @@ -54,7 +54,7 @@ func TestFetchFollowingStatsSuccess(t *testing.T) { mockProvider.EXPECT().IsConnected().Return(true) mockProvider.EXPECT().FetchFollowingStats(ctx, userAddress).Return(expectedCount, nil) - manager := NewManager([]efp.FollowingDataProvider{mockProvider}) + manager := NewManager(mockProvider, zap.NewNop()) result, err := manager.FetchFollowingStats(ctx, userAddress) @@ -66,14 +66,14 @@ func TestFetchFollowingAddressesProviderNotConnected(t *testing.T) { mockCtrl := gomock.NewController(t) defer mockCtrl.Finish() - ctx := context.TODO() + 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([]efp.FollowingDataProvider{mockProvider}) + manager := NewManager(mockProvider, zap.NewNop()) result, err := manager.FetchFollowingAddresses(ctx, userAddress, "", 10, 0) @@ -85,7 +85,7 @@ func TestFetchFollowingAddressesProviderError(t *testing.T) { mockCtrl := gomock.NewController(t) defer mockCtrl.Finish() - ctx := context.TODO() + ctx := t.Context() userAddress := common.HexToAddress("0x742d35cc6cf4c7c7") expectedError := errors.New("provider error") @@ -94,7 +94,7 @@ func TestFetchFollowingAddressesProviderError(t *testing.T) { mockProvider.EXPECT().IsConnected().Return(true) mockProvider.EXPECT().FetchFollowingAddresses(ctx, userAddress, "", 10, 0).Return(nil, expectedError) - manager := NewManager([]efp.FollowingDataProvider{mockProvider}) + manager := NewManager(mockProvider, zap.NewNop()) result, err := manager.FetchFollowingAddresses(ctx, userAddress, "", 10, 0) @@ -103,14 +103,15 @@ func TestFetchFollowingAddressesProviderError(t *testing.T) { require.Equal(t, expectedError, err) } -func TestFetchFollowingAddressesNoProviders(t *testing.T) { - ctx := context.TODO() +func TestFetchFollowingAddressesNoProvider(t *testing.T) { + ctx := t.Context() userAddress := common.HexToAddress("0x742d35cc6cf4c7c7") - manager := NewManager([]efp.FollowingDataProvider{}) + manager := NewManager(nil, zap.NewNop()) result, err := manager.FetchFollowingAddresses(ctx, userAddress, "", 10, 0) - require.NoError(t, err) - require.Len(t, result, 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 e450bd2bee9..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" @@ -117,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 { @@ -176,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) @@ -248,13 +268,6 @@ func NewService( collectiblesOwnershipController, collectiblesPublisher) - // EFP (Ethereum Follow Protocol) providers - efpClient := efp.NewClient() - followingProviders := []efp.FollowingDataProvider{ - efpClient, - } - followingManager := following.NewManager(followingProviders) - activity := activity.NewService(db, accountsDB, tokenManager, collectiblesManager, feed) router := router.NewRouter(rpcClient, transactor, tokenManager, tokenBalancesFetcher, marketManager, collectibles, diff --git a/services/wallet/thirdparty/efp/client.go b/services/wallet/thirdparty/efp/client.go index 6a8f52a63c2..02594a0c887 100644 --- a/services/wallet/thirdparty/efp/client.go +++ b/services/wallet/thirdparty/efp/client.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "net/url" - "time" "github.com/ethereum/go-ethereum/common" @@ -14,10 +13,6 @@ import ( const baseURL = "https://api.ethfollow.xyz/api/v1" -const ( - requestDelay = 100 * time.Millisecond -) - // ENSData represents ENS information from the EFP API type ENSData struct { Name string `json:"name"` @@ -61,17 +56,7 @@ type Client struct { baseURL string } -func NewClient() *Client { - httpClient := thirdparty.NewHTTPClient( - thirdparty.WithDetailedTimeouts( - 5*time.Second, // dialTimeout - 5*time.Second, // tlsHandshakeTimeout - 5*time.Second, // responseHeaderTimeout - 20*time.Second, // requestTimeout - ), - thirdparty.WithMaxRetries(5), - ) - +func NewClient(httpClient *thirdparty.HTTPClient) *Client { return &Client{ httpClient: httpClient, baseURL: baseURL, diff --git a/services/wallet/thirdparty/efp/client_test.go b/services/wallet/thirdparty/efp/client_test.go index fb7a49e0b70..aec28007658 100644 --- a/services/wallet/thirdparty/efp/client_test.go +++ b/services/wallet/thirdparty/efp/client_test.go @@ -1,11 +1,11 @@ package efp import ( - "context" "encoding/json" "net/http" "net/http/httptest" "testing" + "time" "github.com/ethereum/go-ethereum/common" "github.com/stretchr/testify/require" @@ -52,7 +52,8 @@ func TestFetchFollowingAddressesPagination(t *testing.T) { }, } - response, _ := json.Marshal(expected) + response, err := json.Marshal(expected) + require.NoError(t, err) srv, stop := setupTest(t, response) defer stop() @@ -62,7 +63,7 @@ func TestFetchFollowingAddressesPagination(t *testing.T) { } userAddress := common.HexToAddress("0x742d35cc6cf4c7c7") - addresses, err := efpClient.FetchFollowingAddresses(context.Background(), userAddress, "", 10, 0) + addresses, err := efpClient.FetchFollowingAddresses(t.Context(), userAddress, "", 10, 0) require.NoError(t, err) require.Len(t, addresses, 2) @@ -87,7 +88,8 @@ func TestFetchFollowingAddressesSearch(t *testing.T) { }, } - response, _ := json.Marshal(expected) + response, err := json.Marshal(expected) + require.NoError(t, err) srv, stop := setupTest(t, response) defer stop() @@ -97,7 +99,7 @@ func TestFetchFollowingAddressesSearch(t *testing.T) { } userAddress := common.HexToAddress("0x742d35cc6cf4c7c7") - addresses, err := efpClient.FetchFollowingAddresses(context.Background(), userAddress, "vitalik", 0, 0) + addresses, err := efpClient.FetchFollowingAddresses(t.Context(), userAddress, "vitalik", 0, 0) require.NoError(t, err) require.Len(t, addresses, 1) @@ -110,7 +112,8 @@ func TestFetchFollowingStats(t *testing.T) { FollowersCount: 42, } - response, _ := json.Marshal(expected) + response, err := json.Marshal(expected) + require.NoError(t, err) srv, stop := setupTest(t, response) defer stop() @@ -120,7 +123,7 @@ func TestFetchFollowingStats(t *testing.T) { } userAddress := common.HexToAddress("0x742d35cc6cf4c7c7") - count, err := efpClient.FetchFollowingStats(context.Background(), userAddress) + count, err := efpClient.FetchFollowingStats(t.Context(), userAddress) require.NoError(t, err) require.Equal(t, 150, count) @@ -138,17 +141,35 @@ func TestFetchFollowingAddressesError(t *testing.T) { } userAddress := common.HexToAddress("0x742d35cc6cf4c7c7") - _, err := efpClient.FetchFollowingAddresses(context.Background(), userAddress, "", 10, 0) + _, err := efpClient.FetchFollowingAddresses(t.Context(), userAddress, "", 10, 0) require.Error(t, err) } func TestClientID(t *testing.T) { - efpClient := NewClient() + 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) { - efpClient := NewClient() + 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()) } From ad932c1b5bf7d55309fc67f03e8f62defc2b7141 Mon Sep 17 00:00:00 2001 From: Aaron-Desktop <24273561+caveman-eth@users.noreply.github.com> Date: Fri, 21 Nov 2025 14:17:57 -0300 Subject: [PATCH 4/4] fix(efp): change api endpoint domain --- services/wallet/thirdparty/efp/client.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/wallet/thirdparty/efp/client.go b/services/wallet/thirdparty/efp/client.go index 02594a0c887..9a6ce5847c4 100644 --- a/services/wallet/thirdparty/efp/client.go +++ b/services/wallet/thirdparty/efp/client.go @@ -11,7 +11,7 @@ import ( "github.com/status-im/status-go/services/wallet/thirdparty" ) -const baseURL = "https://api.ethfollow.xyz/api/v1" +const baseURL = "https://data.ethfollow.xyz/api/v1" // ENSData represents ENS information from the EFP API type ENSData struct {