diff --git a/internal/api/handlers/blend_catalog.go b/internal/api/handlers/blend_catalog.go new file mode 100644 index 0000000..54d7795 --- /dev/null +++ b/internal/api/handlers/blend_catalog.go @@ -0,0 +1,61 @@ +// ABOUTME: Handlers for the Blend market-catalog endpoints: +// ABOUTME: GET /protocols/blend/pools and GET /protocols/blend/earn-options. +package handlers + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/stellar/freighter-backend-v2/internal/api/httperror" + response "github.com/stellar/freighter-backend-v2/internal/api/httpresponse" + "github.com/stellar/freighter-backend-v2/internal/types" +) + +const blendCatalogContextTimeout = 10 * time.Second + +type BlendCatalogHandler struct { + CatalogService types.BlendCatalogService +} + +func NewBlendCatalogHandler(catalogService types.BlendCatalogService) *BlendCatalogHandler { + return &BlendCatalogHandler{CatalogService: catalogService} +} + +// GetPools handles GET /api/v1/protocols/blend/pools: the pool-wide market +// catalog, independent of any account. +func (h *BlendCatalogHandler) GetPools(w http.ResponseWriter, r *http.Request) error { + ctx, cancel := context.WithTimeout(r.Context(), blendCatalogContextTimeout) + defer cancel() + + network := r.URL.Query().Get("network") + if !isValidWalletBackendNetwork(network) { + return httperror.BadRequest(fmt.Sprintf("invalid network: must be %s or %s", types.PUBLIC, types.TESTNET), errors.New("invalid network")) + } + + pools, err := h.CatalogService.GetPools(ctx, network) + if err != nil { + return translateServiceError(r.Context(), err, "blend pools", "", network) + } + return response.OK(w, HttpResponse{Data: pools}) +} + +// GetEarnOptions handles GET /api/v1/protocols/blend/earn-options: the +// asset-first "where can I earn this" catalog, allowlist-curated. +func (h *BlendCatalogHandler) GetEarnOptions(w http.ResponseWriter, r *http.Request) error { + ctx, cancel := context.WithTimeout(r.Context(), blendCatalogContextTimeout) + defer cancel() + + network := r.URL.Query().Get("network") + if !isValidWalletBackendNetwork(network) { + return httperror.BadRequest(fmt.Sprintf("invalid network: must be %s or %s", types.PUBLIC, types.TESTNET), errors.New("invalid network")) + } + + options, err := h.CatalogService.GetEarnOptions(ctx, network) + if err != nil { + return translateServiceError(r.Context(), err, "blend earn options", "", network) + } + return response.OK(w, HttpResponse{Data: options}) +} diff --git a/internal/api/handlers/blend_catalog_test.go b/internal/api/handlers/blend_catalog_test.go new file mode 100644 index 0000000..0073c2f --- /dev/null +++ b/internal/api/handlers/blend_catalog_test.go @@ -0,0 +1,87 @@ +// ABOUTME: Handler tests for the Blend catalog endpoints: validation, error +// ABOUTME: translation, and success envelopes for pools and earn options. +package handlers + +import ( + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stellar/freighter-backend-v2/internal/metrics" + "github.com/stellar/freighter-backend-v2/internal/types" + "github.com/stellar/freighter-backend-v2/internal/utils" +) + +func serveCatalog(t *testing.T, svc types.BlendCatalogService, target string) *httptest.ResponseRecorder { + t.Helper() + handler := NewBlendCatalogHandler(svc) + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v1/protocols/blend/pools", CustomHandler(handler.GetPools)) + mux.HandleFunc("GET /api/v1/protocols/blend/earn-options", CustomHandler(handler.GetEarnOptions)) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil)) + return rec +} + +func TestBlendCatalogSuccess(t *testing.T) { + name := "Fixed Pool V2" + svc := &utils.MockBlendCatalogService{ + GetPoolsResult: &types.BlendPoolsCatalog{ + Pools: []types.BlendCatalogPool{{ID: "CPOOL", Name: &name, Reserves: []types.BlendCatalogReserve{}}}, + }, + GetEarnOptionsResult: &types.BlendEarnOptionsCatalog{ + Options: []types.BlendEarnAssetOption{{AssetID: "CUSDC", Pools: []types.BlendEarnPool{{ID: "CPOOL"}}}}, + }, + } + + rec := serveCatalog(t, svc, "/api/v1/protocols/blend/pools?network=TESTNET") + require.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), `"id":"CPOOL"`) + assert.Contains(t, rec.Body.String(), `"name":"Fixed Pool V2"`) + + rec = serveCatalog(t, svc, "/api/v1/protocols/blend/earn-options?network=TESTNET") + require.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), `"asset_id":"CUSDC"`) +} + +func TestBlendCatalogValidation(t *testing.T) { + svc := &utils.MockBlendCatalogService{} + for _, target := range []string{ + "/api/v1/protocols/blend/pools", + "/api/v1/protocols/blend/pools?network=NOPE", + "/api/v1/protocols/blend/earn-options", + "/api/v1/protocols/blend/earn-options?network=NOPE", + } { + rec := serveCatalog(t, svc, target) + assert.Equal(t, http.StatusBadRequest, rec.Code, target) + } +} + +func TestBlendCatalogErrorTranslation(t *testing.T) { + svc := &utils.MockBlendCatalogService{ + GetPoolsError: &metrics.UpstreamError{Kind: "http_error", Code: 502, Err: errors.New("boom")}, + GetEarnOptionsError: errors.New("wat"), + } + + rec := serveCatalog(t, svc, "/api/v1/protocols/blend/pools?network=TESTNET") + assert.Equal(t, http.StatusBadGateway, rec.Code) + + rec = serveCatalog(t, svc, "/api/v1/protocols/blend/earn-options?network=TESTNET") + assert.Equal(t, http.StatusInternalServerError, rec.Code) +} + +func TestBlendCatalogEmptyIs200(t *testing.T) { + svc := &utils.MockBlendCatalogService{} + + rec := serveCatalog(t, svc, "/api/v1/protocols/blend/pools?network=TESTNET") + require.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), `"pools":[]`) + + rec = serveCatalog(t, svc, "/api/v1/protocols/blend/earn-options?network=TESTNET") + require.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), `"options":[]`) +} diff --git a/internal/api/serve.go b/internal/api/serve.go index 9659c54..cc2d411 100644 --- a/internal/api/serve.go +++ b/internal/api/serve.go @@ -235,6 +235,18 @@ func (s *ApiServer) routes() ([]route, error) { ) accountPositionsHandler := handlers.NewAccountPositionsHandler(positionsService, s.cfg.AppConfig.MaxBalanceAddresses) + blendCatalogService, err := services.NewBlendCatalogService( + s.walletBackendService, + s.redis, + time.Duration(s.cfg.BlendConfig.CatalogCacheTTLSeconds)*time.Second, + s.cfg.BlendConfig.EarnPoolsConfigPath, + s.appMetrics.Service, + ) + if err != nil { + return nil, fmt.Errorf("init blend catalog service: %w", err) + } + blendCatalogHandler := handlers.NewBlendCatalogHandler(blendCatalogService) + return []route{ // Health/liveness/readiness probes: gated=false, registered BARE — never // wrapped by Auth. K8s and the docker-compose wget healthcheck cannot present @@ -255,12 +267,12 @@ func (s *ApiServer) routes() ([]route, error) { {http.MethodGet, "/api/v1/feature-flags", handlers.CustomHandler(featureFlagsHandler.GetFeatureFlags), true, true}, // The wallet-backend-fronted routes, config-gated together by // --wallet-backend-routes-enabled. These are the routes that touch - // walletBackendService (balances, history, and positions), and all fail - // identically when it is unconfigured: configureNetworkClient returns nil - // and the handler errors before any network call, so every request 500s. - // wallet-backend is configured only in dev, so they are disabled in - // production until that upstream is wired up. enabled=false leaves every - // path 404ing. + // walletBackendService (balances, history, positions, and the Blend market + // catalog), and all fail identically when it is unconfigured: + // configureNetworkClient returns nil and the handler errors before any network + // call, so every request 500s. wallet-backend is configured only in dev, so + // they are disabled in production until that upstream is wired up. + // enabled=false leaves every path 404ing. // // They share one flag deliberately: they share one dependency and one failure // mode, so there is no state where enabling a subset is correct. If a route @@ -269,6 +281,8 @@ func (s *ApiServer) routes() ([]route, error) { {http.MethodPost, "/api/v1/accounts/balances", handlers.CustomHandler(accountBalancesHandler.GetAccountBalances), true, s.cfg.AppConfig.WalletBackendRoutesEnabled}, {http.MethodGet, "/api/v1/accounts/{address}/transactions", handlers.CustomHandler(accountHistoryHandler.GetAccountTransactions), true, s.cfg.AppConfig.WalletBackendRoutesEnabled}, {http.MethodPost, "/api/v1/accounts/positions", handlers.CustomHandler(accountPositionsHandler.GetAccountsPositions), true, s.cfg.AppConfig.WalletBackendRoutesEnabled}, + {http.MethodGet, "/api/v1/protocols/blend/pools", handlers.CustomHandler(blendCatalogHandler.GetPools), true, s.cfg.AppConfig.WalletBackendRoutesEnabled}, + {http.MethodGet, "/api/v1/protocols/blend/earn-options", handlers.CustomHandler(blendCatalogHandler.GetEarnOptions), true, s.cfg.AppConfig.WalletBackendRoutesEnabled}, {http.MethodPost, "/api/v1/token-prices", handlers.CustomHandler(tokenPricesHandler.GetPrices), true, true}, {http.MethodGet, "/api/v1/auth/whoami", handlers.CustomHandler(whoamiHandler.Whoami), true, true}, diff --git a/internal/api/serve_test.go b/internal/api/serve_test.go index 8c2f449..ee314ff 100644 --- a/internal/api/serve_test.go +++ b/internal/api/serve_test.go @@ -187,7 +187,7 @@ func TestApiServer_initHandlers_RegistersAccountHistoryRoutes(t *testing.T) { } // walletBackendRoutes is every route gated by --wallet-backend-routes-enabled. -// Both tests below iterate it, so adding a third wallet-backend-fronted route +// Both tests below iterate it, so adding another wallet-backend-fronted route // extends the on/off coverage by one line here rather than being silently missed. // The {address} wildcard is pre-substituted: auth and registration both run before // path-parameter validation, so any non-empty segment reaches the assertion. @@ -199,6 +199,8 @@ var walletBackendRoutes = []struct { {"balances", http.MethodPost, "/api/v1/accounts/balances"}, {"account-history", http.MethodGet, "/api/v1/accounts/GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF/transactions"}, {"positions", http.MethodPost, "/api/v1/accounts/positions"}, + {"blend-pools", http.MethodGet, "/api/v1/protocols/blend/pools"}, + {"blend-earn-options", http.MethodGet, "/api/v1/protocols/blend/earn-options"}, } // TestApiServer_initHandlers_WalletBackendRoutesDisabledNotRegistered pins the off @@ -251,7 +253,7 @@ func TestApiServer_initHandlers_WalletBackendRoutesEnabledStayGated(t *testing.T } // TestApiServer_initHandlers_WalletBackendRoutesGatedTogether pins the "one flag, -// every route" decision. All three share a dependency and a failure mode, so a +// every route" decision. All five share a dependency and a failure mode, so a // change that gated only some — leaving the rest publicly 500ing in prd, which is // the exact bug this flag exists to close — would otherwise pass every test above. func TestApiServer_initHandlers_WalletBackendRoutesGatedTogether(t *testing.T) { @@ -273,6 +275,8 @@ func TestApiServer_initHandlers_WalletBackendRoutesGatedTogether(t *testing.T) { "POST /api/v1/accounts/balances": true, "GET /api/v1/accounts/{address}/transactions": true, "POST /api/v1/accounts/positions": true, + "GET /api/v1/protocols/blend/pools": true, + "GET /api/v1/protocols/blend/earn-options": true, }, disabled, "exactly the wallet-backend-fronted routes must be disabled by the flag") } diff --git a/internal/services/blend_catalog.go b/internal/services/blend_catalog.go new file mode 100644 index 0000000..95e4cc4 --- /dev/null +++ b/internal/services/blend_catalog.go @@ -0,0 +1,259 @@ +// ABOUTME: Blend market-catalog service: pool and earn-option views, with +// ABOUTME: per-network caching and earn-pool curation. Earn options are derived +// ABOUTME: from the pools catalog. +package services + +import ( + "context" + "encoding/json" + "fmt" + "os" + "sort" + "strings" + "time" + + wbtypes "github.com/stellar/wallet-backend/pkg/wbclient/types" + + "github.com/stellar/freighter-backend-v2/internal/logger" + "github.com/stellar/freighter-backend-v2/internal/metrics" + "github.com/stellar/freighter-backend-v2/internal/store" + "github.com/stellar/freighter-backend-v2/internal/types" +) + +const ( + blendCatalogServiceName = "blend-catalog" + + defaultCatalogCacheTTL = 60 * time.Second + + blendPoolsCacheKeyPrefix = "blend:pools:v1" +) + +// earnPoolsAllowlist maps network name (PUBLIC/TESTNET) to the set of pool +// contract addresses Freighter offers in the Earn flow. It curates the +// earn-options endpoint only: the pools catalog and user positions are never +// filtered, since users may hold positions in non-curated pools. A nil +// allowlist (no config file) disables curation. +type earnPoolsAllowlist map[string]map[string]bool + +// loadEarnPoolsAllowlist reads the JSON allowlist: +// +// {"PUBLIC": ["CPOOL..."], "TESTNET": ["CPOOL..."]} +// +// An empty path returns nil (curation disabled). A missing or malformed +// file is a startup error: silently serving every pool when the operator +// configured a curated list would be worse than failing fast. +func loadEarnPoolsAllowlist(path string) (earnPoolsAllowlist, error) { + if path == "" { + return nil, nil + } + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading earn pools allowlist %s: %w", path, err) + } + var raw map[string][]string + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("parsing earn pools allowlist %s: %w", path, err) + } + allowlist := make(earnPoolsAllowlist, len(raw)) + for network, pools := range raw { + set := make(map[string]bool, len(pools)) + for _, pool := range pools { + set[pool] = true + } + allowlist[strings.ToUpper(network)] = set + } + return allowlist, nil +} + +type blendCatalogService struct { + walletBackend types.WalletBackendService + redis *store.RedisStore + cacheTTL time.Duration + allowlist earnPoolsAllowlist + svcMetrics *metrics.Service +} + +// NewBlendCatalogService wires the market views. redis may be nil (no +// caching); allowlistPath may be empty (no earn curation). +func NewBlendCatalogService(walletBackend types.WalletBackendService, redis *store.RedisStore, cacheTTL time.Duration, allowlistPath string, m *metrics.Service) (types.BlendCatalogService, error) { + allowlist, err := loadEarnPoolsAllowlist(allowlistPath) + if err != nil { + return nil, err + } + if cacheTTL <= 0 { + cacheTTL = defaultCatalogCacheTTL + } + return &blendCatalogService{ + walletBackend: walletBackend, + redis: redis, + cacheTTL: cacheTTL, + allowlist: allowlist, + svcMetrics: m, + }, nil +} + +func (b *blendCatalogService) Name() string { return blendCatalogServiceName } + +// GetPools returns the unfiltered pool catalog, cached per network. +func (b *blendCatalogService) GetPools(ctx context.Context, network string) (_ *types.BlendPoolsCatalog, err error) { + start := time.Now() + defer func() { + metrics.Record(b.svcMetrics, blendCatalogServiceName, "GetPools", network, time.Since(start).Seconds(), err) + }() + + cacheKey := fmt.Sprintf("%s:%s", blendPoolsCacheKeyPrefix, strings.ToLower(network)) + if cached, ok := cacheGet[types.BlendPoolsCatalog](ctx, b.redis, cacheKey); ok { + return cached, nil + } + + pools, err := b.walletBackend.GetBlendPools(ctx, network) + if err != nil { + return nil, err + } + + result := &types.BlendPoolsCatalog{Pools: mapCatalogPools(pools)} + cacheSet(ctx, b.redis, cacheKey, result, b.cacheTTL) + return result, nil +} + +// GetEarnOptions derives the earn catalog from the pools catalog: one entry +// per asset with at least one enabled reserve in a pool whose status accepts +// deposits, filtered through the operator allowlist. It reads through +// GetPools, so both endpoints share one upstream query and one cache entry +// per network; the derivation itself is cheap enough to run per request. +func (b *blendCatalogService) GetEarnOptions(ctx context.Context, network string) (_ *types.BlendEarnOptionsCatalog, err error) { + start := time.Now() + defer func() { + metrics.Record(b.svcMetrics, blendCatalogServiceName, "GetEarnOptions", network, time.Since(start).Seconds(), err) + }() + + catalog, err := b.GetPools(ctx, network) + if err != nil { + return nil, err + } + + return &types.BlendEarnOptionsCatalog{ + Options: deriveEarnOptions(catalog.Pools, b.allowlist[strings.ToUpper(network)]), + }, nil +} + +func mapCatalogPools(pools []wbtypes.BlendPool) []types.BlendCatalogPool { + out := make([]types.BlendCatalogPool, 0, len(pools)) + for _, p := range pools { + reserves := make([]types.BlendCatalogReserve, 0, len(p.Reserves)) + for _, r := range p.Reserves { + reserves = append(reserves, types.BlendCatalogReserve{ + AssetID: r.AssetContractID, + Symbol: r.TokenSymbol, + Name: r.TokenName, + Decimals: r.TokenDecimals, + Enabled: r.Enabled, + Utilization: r.Utilization, + SupplyAPY: r.SupplyApy, + BorrowAPY: r.BorrowApy, + EmissionsSupplyAPR: r.EmissionsSupplyApr, + SuppliedUSD: r.SuppliedUsd, + BorrowedUSD: r.BorrowedUsd, + PriceUSD: r.PriceUsd, + }) + } + out = append(out, types.BlendCatalogPool{ + ID: p.Address, + Name: p.Name, + Status: (*string)(p.Status), + SuppliedUSD: p.SuppliedUsd, + BorrowedUSD: p.BorrowedUsd, + InterestAPY: p.InterestApy, + NetAPY: p.NetApy, + Reserves: reserves, + }) + } + return out +} + +// deriveEarnOptions groups supply-eligible reserves asset-first: pools whose +// status accepts deposits (per the SDK's status table; null status means the +// pool's config is not yet ingested and is excluded), reserves that are +// enabled, and — when an allowlist is configured — pools Freighter curates. +// Assets are ordered by asset id; each asset's pools by supplied USD +// descending (unpriced last, id tie-break). +func deriveEarnOptions(pools []types.BlendCatalogPool, allowed map[string]bool) []types.BlendEarnAssetOption { + byAsset := make(map[string]*types.BlendEarnAssetOption) + for _, pool := range pools { + if pool.Status == nil || !wbtypes.BlendPoolStatus(*pool.Status).AcceptsSupply() { + continue + } + if allowed != nil && !allowed[pool.ID] { + continue + } + for _, r := range pool.Reserves { + if !r.Enabled { + continue + } + option, ok := byAsset[r.AssetID] + if !ok { + option = &types.BlendEarnAssetOption{ + AssetID: r.AssetID, + Symbol: r.Symbol, + Name: r.Name, + Decimals: r.Decimals, + Pools: []types.BlendEarnPool{}, + } + byAsset[r.AssetID] = option + } + option.Pools = append(option.Pools, types.BlendEarnPool{ + ID: pool.ID, + Name: pool.Name, + SupplyAPY: r.SupplyAPY, + EmissionsSupplyAPR: r.EmissionsSupplyAPR, + SuppliedUSD: r.SuppliedUSD, + }) + } + } + + options := make([]types.BlendEarnAssetOption, 0, len(byAsset)) + for _, option := range byAsset { + sort.Slice(option.Pools, func(i, j int) bool { + a, b := option.Pools[i], option.Pools[j] + switch { + case a.SuppliedUSD == nil && b.SuppliedUSD == nil: + return a.ID < b.ID + case a.SuppliedUSD == nil: + return false + case b.SuppliedUSD == nil: + return true + case *a.SuppliedUSD != *b.SuppliedUSD: + return *a.SuppliedUSD > *b.SuppliedUSD + } + return a.ID < b.ID + }) + options = append(options, *option) + } + sort.Slice(options, func(i, j int) bool { return options[i].AssetID < options[j].AssetID }) + return options +} + +// cacheGet fetches and decodes one cached value. Misses and cache errors +// both report ok=false; cache trouble is logged, never fatal. +func cacheGet[T any](ctx context.Context, redis *store.RedisStore, key string) (*T, bool) { + if redis == nil { + return nil, false + } + hits, err := redis.MGetJSON(ctx, []string{key}, func() any { return new(T) }) + if err != nil { + logger.ErrorWithContext(ctx, "blend catalog cache read failed", "key", key, "error", err) + return nil, false + } + hit, ok := hits[key].(*T) + return hit, ok +} + +// cacheSet stores one value best-effort; failures are logged and ignored. +func cacheSet(ctx context.Context, redis *store.RedisStore, key string, value any, ttl time.Duration) { + if redis == nil { + return + } + if err := redis.SetJSON(ctx, key, value, ttl); err != nil { + logger.ErrorWithContext(ctx, "blend catalog cache write failed", "key", key, "error", err) + } +} diff --git a/internal/services/blend_catalog_test.go b/internal/services/blend_catalog_test.go new file mode 100644 index 0000000..de68b18 --- /dev/null +++ b/internal/services/blend_catalog_test.go @@ -0,0 +1,200 @@ +// ABOUTME: Tests for the Blend catalog service: allowlist loading, catalog +// ABOUTME: mapping, and the earn-options derivation (filtering, grouping, order). +package services + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + wbtypes "github.com/stellar/wallet-backend/pkg/wbclient/types" + + "github.com/stellar/freighter-backend-v2/internal/types" + "github.com/stellar/freighter-backend-v2/internal/utils" +) + +const ( + curatedPool = "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD" + uncuratedPool = "CCCCIQSDILITHMM7PBSLVDT5MISSY7R26MNZXCX4H7J5JQ5FPIYOGYFS" + frozenPool = "CFROZENFROZENFROZENFROZENFROZENFROZENFROZENFROZENFROZEN" +) + +func writeAllowlist(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "earn-pools.json") + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) + return path +} + +func status(s wbtypes.BlendPoolStatus) *wbtypes.BlendPoolStatus { return &s } + +// poolsFixture: two supply-accepting pools sharing USDC (for grouping and +// ordering), one frozen pool, one not-yet-ingested pool (null status), and +// a disabled reserve — every exclusion rule the derivation must apply. +func poolsFixture() []wbtypes.BlendPool { + usdc, xlm, name1, name2 := "USDC", "XLM", "Big Pool", "Small Pool" + return []wbtypes.BlendPool{ + { + Address: curatedPool, + Name: &name1, + Status: status(wbtypes.BlendPoolStatusActive), + Reserves: []wbtypes.BlendReserve{ + {AssetContractID: "CUSDC", TokenSymbol: &usdc, Enabled: true, SupplyApy: f64(0.043), EmissionsSupplyApr: f64(0.008), SuppliedUsd: f64(1500000)}, + {AssetContractID: "CXLM", TokenSymbol: &xlm, Enabled: false, SupplyApy: f64(0.001), SuppliedUsd: f64(99999)}, // disabled: excluded + }, + }, + { + Address: uncuratedPool, + Name: &name2, + Status: status(wbtypes.BlendPoolStatusOnIce), // on-ice still accepts supply + Reserves: []wbtypes.BlendReserve{ + {AssetContractID: "CUSDC", TokenSymbol: &usdc, Enabled: true, SupplyApy: f64(0.032), SuppliedUsd: f64(200000)}, + }, + }, + { + Address: frozenPool, + Status: status(wbtypes.BlendPoolStatusFrozen), // rejects deposits: excluded + Reserves: []wbtypes.BlendReserve{ + {AssetContractID: "CUSDC", TokenSymbol: &usdc, Enabled: true, SupplyApy: f64(9.9), SuppliedUsd: f64(1)}, + }, + }, + { + Address: "CPENDINGPOOLNOSTATUSYET", + Status: nil, // config not ingested: excluded + Reserves: []wbtypes.BlendReserve{ + {AssetContractID: "CUSDC", TokenSymbol: &usdc, Enabled: true, SupplyApy: f64(0.5), SuppliedUsd: f64(5)}, + }, + }, + } +} + +func TestLoadEarnPoolsAllowlist(t *testing.T) { + t.Run("empty path disables curation", func(t *testing.T) { + allowlist, err := loadEarnPoolsAllowlist("") + require.NoError(t, err) + assert.Nil(t, allowlist) + }) + + t.Run("missing file fails fast", func(t *testing.T) { + _, err := loadEarnPoolsAllowlist("/nope/earn-pools.json") + assert.Error(t, err) + }) + + t.Run("malformed json fails fast", func(t *testing.T) { + _, err := loadEarnPoolsAllowlist(writeAllowlist(t, `{"TESTNET": "not-a-list"}`)) + assert.Error(t, err) + }) + + t.Run("network keys are case-insensitive", func(t *testing.T) { + allowlist, err := loadEarnPoolsAllowlist(writeAllowlist(t, `{"testnet": ["`+curatedPool+`"]}`)) + require.NoError(t, err) + assert.True(t, allowlist["TESTNET"][curatedPool]) + }) +} + +func TestGetEarnOptionsDerivation(t *testing.T) { + mockWB := &utils.MockWalletBackendService{GetBlendPoolsResult: poolsFixture()} + + t.Run("derives supply-eligible assets with ordered pools", func(t *testing.T) { + svc, err := NewBlendCatalogService(mockWB, nil, 0, "", nil) + require.NoError(t, err) + + got, err := svc.GetEarnOptions(context.Background(), types.TESTNET) + require.NoError(t, err) + + // Only USDC survives: XLM's reserve is disabled, and the frozen and + // not-yet-ingested pools are excluded entirely. + require.Len(t, got.Options, 1) + usdc := got.Options[0] + assert.Equal(t, "CUSDC", usdc.AssetID) + + // Both supply-accepting pools offer it, larger supplied USD first. + require.Len(t, usdc.Pools, 2) + assert.Equal(t, curatedPool, usdc.Pools[0].ID) + assert.Equal(t, uncuratedPool, usdc.Pools[1].ID) + require.NotNil(t, usdc.Pools[0].EmissionsSupplyAPR) + assert.InDelta(t, 0.008, *usdc.Pools[0].EmissionsSupplyAPR, 1e-9) + }) + + t.Run("allowlist filters pools and drops emptied assets", func(t *testing.T) { + path := writeAllowlist(t, `{"TESTNET": ["`+uncuratedPool+`"]}`) + svc, err := NewBlendCatalogService(mockWB, nil, 0, path, nil) + require.NoError(t, err) + + got, err := svc.GetEarnOptions(context.Background(), types.TESTNET) + require.NoError(t, err) + + require.Len(t, got.Options, 1) + require.Len(t, got.Options[0].Pools, 1) + assert.Equal(t, uncuratedPool, got.Options[0].Pools[0].ID) + }) + + t.Run("allowlist for another network does not curate this one", func(t *testing.T) { + path := writeAllowlist(t, `{"PUBLIC": ["`+curatedPool+`"]}`) + svc, err := NewBlendCatalogService(mockWB, nil, 0, path, nil) + require.NoError(t, err) + + got, err := svc.GetEarnOptions(context.Background(), types.TESTNET) + require.NoError(t, err) + require.Len(t, got.Options, 1) + assert.Len(t, got.Options[0].Pools, 2) + }) + + t.Run("unpriced pools sort last with id tie-break", func(t *testing.T) { + usdc := "USDC" + svc, err := NewBlendCatalogService(&utils.MockWalletBackendService{ + GetBlendPoolsResult: []wbtypes.BlendPool{ + {Address: "CBBB", Status: status(wbtypes.BlendPoolStatusActive), Reserves: []wbtypes.BlendReserve{{AssetContractID: "CUSDC", TokenSymbol: &usdc, Enabled: true}}}, + {Address: "CAAA", Status: status(wbtypes.BlendPoolStatusActive), Reserves: []wbtypes.BlendReserve{{AssetContractID: "CUSDC", TokenSymbol: &usdc, Enabled: true}}}, + {Address: "CCCC", Status: status(wbtypes.BlendPoolStatusActive), Reserves: []wbtypes.BlendReserve{{AssetContractID: "CUSDC", TokenSymbol: &usdc, Enabled: true, SuppliedUsd: f64(10)}}}, + }, + }, nil, 0, "", nil) + require.NoError(t, err) + + got, err := svc.GetEarnOptions(context.Background(), types.TESTNET) + require.NoError(t, err) + require.Len(t, got.Options, 1) + pools := got.Options[0].Pools + require.Len(t, pools, 3) + assert.Equal(t, "CCCC", pools[0].ID) // priced first + assert.Equal(t, "CAAA", pools[1].ID) // unpriced, id order + assert.Equal(t, "CBBB", pools[2].ID) + }) +} + +func TestGetPoolsMapping(t *testing.T) { + svc, err := NewBlendCatalogService(&utils.MockWalletBackendService{GetBlendPoolsResult: poolsFixture()}, nil, 0, "", nil) + require.NoError(t, err) + + got, err := svc.GetPools(context.Background(), types.TESTNET) + require.NoError(t, err) + + // The pools catalog is never filtered: all four pools pass through, + // including frozen and not-yet-ingested ones. + require.Len(t, got.Pools, 4) + pool := got.Pools[0] + assert.Equal(t, curatedPool, pool.ID) + require.NotNil(t, pool.Status) + assert.Equal(t, string(wbtypes.BlendPoolStatusActive), *pool.Status) + require.Len(t, pool.Reserves, 2) + assert.True(t, pool.Reserves[0].Enabled) + assert.False(t, pool.Reserves[1].Enabled) + assert.Nil(t, got.Pools[3].Status) +} + +func TestCatalogUpstreamErrors(t *testing.T) { + upErr := errors.New("wallet-backend down") + svc, err := NewBlendCatalogService(&utils.MockWalletBackendService{GetBlendPoolsError: upErr}, nil, 0, "", nil) + require.NoError(t, err) + + _, err = svc.GetPools(context.Background(), types.TESTNET) + assert.ErrorIs(t, err, upErr) + // Earn options derive from pools, so they surface the same failure. + _, err = svc.GetEarnOptions(context.Background(), types.TESTNET) + assert.ErrorIs(t, err, upErr) +} diff --git a/internal/types/blend_catalog.go b/internal/types/blend_catalog.go new file mode 100644 index 0000000..15cb4e1 --- /dev/null +++ b/internal/types/blend_catalog.go @@ -0,0 +1,97 @@ +// ABOUTME: Response types for the Blend market-catalog endpoints: +// ABOUTME: GET /protocols/blend/pools and GET /protocols/blend/earn-options. +package types + +import "context" + +// BlendPoolsCatalog is the response body for the pools endpoint: the +// pool-wide market view (no account data), serving the Pool Details screen. +// Number conventions match the positions endpoint: USD/APY are nullable +// JSON numbers (null = no fresh oracle price), token metadata is nullable +// registry data. +type BlendPoolsCatalog struct { + // Pools is every Blend pool known to the indexer, unfiltered. Always + // non-nil. + Pools []BlendCatalogPool `json:"pools"` +} + +// BlendCatalogPool is one pool in the market catalog. +type BlendCatalogPool struct { + // ID is the pool's contract address. + ID string `json:"id"` + // Name is null when the metadata registry has no entry. + Name *string `json:"name"` + // Status is the pool's operational status as the upstream enum name + // (ADMIN_ACTIVE, ACTIVE, ADMIN_ON_ICE, ON_ICE, ADMIN_FROZEN, FROZEN, + // SETUP; the first four accept deposits, the first two also allow + // borrowing). Null until the pool's config has been ingested. + Status *string `json:"status"` + // SuppliedUSD/BorrowedUSD are pool-wide totals with strict null + // propagation upstream: one unpriced reserve nulls the pool total. + SuppliedUSD *float64 `json:"supplied_usd"` + BorrowedUSD *float64 `json:"borrowed_usd"` + // InterestAPY is the supplied-USD-weighted supply rate (interest only). + // NetAPY additionally includes BLND emissions; it is a supply-side + // yield, not netted against the pool's borrow side. + InterestAPY *float64 `json:"interest_apy"` + NetAPY *float64 `json:"net_apy"` + // Reserves lists the pool's assets with current market rates. + Reserves []BlendCatalogReserve `json:"reserves"` +} + +// BlendCatalogReserve is one (pool, asset) market row. +type BlendCatalogReserve struct { + AssetID string `json:"asset_id"` + Symbol *string `json:"symbol"` + Name *string `json:"name"` + Decimals *int32 `json:"decimals"` + // Enabled is the reserve's own on/off flag, independent of pool status. + Enabled bool `json:"enabled"` + // Utilization is borrowed/supplied, clamped at 100% upstream. + Utilization *float64 `json:"utilization"` + SupplyAPY *float64 `json:"supply_apy"` + BorrowAPY *float64 `json:"borrow_apy"` + // EmissionsSupplyAPR is the BLND emission rate on the supply side. + EmissionsSupplyAPR *float64 `json:"emissions_supply_apr"` + SuppliedUSD *float64 `json:"supplied_usd"` + BorrowedUSD *float64 `json:"borrowed_usd"` + PriceUSD *float64 `json:"price_usd"` +} + +// BlendEarnOptionsCatalog is the response body for the earn-options +// endpoint: "where can I earn this asset", serving the Earn select-token and +// select-pool screens. Derived from the pools catalog: disabled reserves and +// deposit-rejecting pools are excluded, and pools are filtered through the +// operator-curated allowlist when one is configured. +type BlendEarnOptionsCatalog struct { + // Options has one entry per earnable asset. Always non-nil; assets whose + // every pool was removed by the allowlist are dropped. + Options []BlendEarnAssetOption `json:"options"` +} + +// BlendEarnAssetOption is one earnable asset and the pools offering it. +type BlendEarnAssetOption struct { + AssetID string `json:"asset_id"` + Symbol *string `json:"symbol"` + Name *string `json:"name"` + Decimals *int32 `json:"decimals"` + // Pools is ordered by supplied USD descending (unpriced last). The + // emissions-inclusive earn headline is SupplyAPY + EmissionsSupplyAPR. + Pools []BlendEarnPool `json:"pools"` +} + +// BlendEarnPool is one pool's offer for an asset. +type BlendEarnPool struct { + ID string `json:"id"` + Name *string `json:"name"` + SupplyAPY *float64 `json:"supply_apy"` + EmissionsSupplyAPR *float64 `json:"emissions_supply_apr"` + SuppliedUSD *float64 `json:"supplied_usd"` +} + +// BlendCatalogService serves the address-independent Blend market views. +type BlendCatalogService interface { + Service + GetPools(ctx context.Context, network string) (*BlendPoolsCatalog, error) + GetEarnOptions(ctx context.Context, network string) (*BlendEarnOptionsCatalog, error) +} diff --git a/internal/utils/mocks.go b/internal/utils/mocks.go index 3f7c617..6cdce42 100644 --- a/internal/utils/mocks.go +++ b/internal/utils/mocks.go @@ -208,6 +208,37 @@ func (m *MockPositionsService) GetAccountsPositions(ctx context.Context, address return []*types.AccountPositions{}, nil } +// MockBlendCatalogService stubs types.BlendCatalogService for handler tests. +type MockBlendCatalogService struct { + GetPoolsResult *types.BlendPoolsCatalog + GetPoolsError error + + GetEarnOptionsResult *types.BlendEarnOptionsCatalog + GetEarnOptionsError error +} + +func (m *MockBlendCatalogService) Name() string { return "mock-blend-catalog" } + +func (m *MockBlendCatalogService) GetPools(ctx context.Context, network string) (*types.BlendPoolsCatalog, error) { + if m.GetPoolsError != nil { + return nil, m.GetPoolsError + } + if m.GetPoolsResult != nil { + return m.GetPoolsResult, nil + } + return &types.BlendPoolsCatalog{Pools: []types.BlendCatalogPool{}}, nil +} + +func (m *MockBlendCatalogService) GetEarnOptions(ctx context.Context, network string) (*types.BlendEarnOptionsCatalog, error) { + if m.GetEarnOptionsError != nil { + return nil, m.GetEarnOptionsError + } + if m.GetEarnOptionsResult != nil { + return m.GetEarnOptionsResult, nil + } + return &types.BlendEarnOptionsCatalog{Options: []types.BlendEarnAssetOption{}}, nil +} + type MockPricesService struct { GetPricesFunc func(ctx context.Context, tokens []string, network string) (map[string]*types.PriceEntry, error) GetPricesOverride map[string]*types.PriceEntry