Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
afb5e86
feat(config): add Blend config (cache TTLs, earn-pools allowlist path)
JiahuiWho Jul 21, 2026
dc49a2f
feat(services): add Blend GraphQL client (positions, pools, earn opti…
JiahuiWho Jul 22, 2026
e687a20
feat(api): add GET /accounts/{address}/positions
JiahuiWho Jul 22, 2026
4f89c53
feat(api): add Blend market catalog endpoints (pools, earn options)
JiahuiWho Jul 22, 2026
143bfb8
test(serve): cover Blend cache-TTL startup validation
JiahuiWho Jul 22, 2026
f7b0b3c
Merge branch 'feat/blend-positions-api-1' into feat/blend-positions-a…
JiahuiWho Jul 23, 2026
323e49e
Merge branch 'feat/blend-positions-api-2' into feat/blend-positions-a…
JiahuiWho Jul 23, 2026
90b15d3
Merge branch 'feat/blend-positions-api-3' into feat/blend-positions-a…
JiahuiWho Jul 23, 2026
bb1d012
feat(services): adopt latest Blend schema (split emissions, status en…
JiahuiWho Jul 23, 2026
f025e70
feat(services): consume wbclient's typed Blend API, drop hand-rolled …
JiahuiWho Jul 24, 2026
4377003
Merge branch 'feat/blend-positions-api-2' into feat/blend-positions-a…
JiahuiWho Jul 24, 2026
7973633
feat(api): adapt positions mapper to the wbclient SDK types and final…
JiahuiWho Jul 24, 2026
97c9161
Merge branch 'feat/blend-positions-api-3' into feat/blend-positions-a…
JiahuiWho Jul 24, 2026
47281f6
feat(api): derive Blend earn options from the pools catalog
JiahuiWho Jul 24, 2026
eb3d81e
feat(config): drop positions cache TTL, add earn-pools allowlist config
JiahuiWho Jul 27, 2026
5db3e5e
Merge branch 'feat/blend-positions-api-1' into feat/blend-positions-a…
JiahuiWho Jul 27, 2026
5ebcba9
Merge branch 'feat/blend-positions-api-2' into feat/blend-positions-a…
JiahuiWho Jul 27, 2026
20f7f91
feat(api): fetch positions per request, drop Redis caching
JiahuiWho Jul 27, 2026
5726316
feat(api): multi-address positions endpoint
JiahuiWho Jul 27, 2026
c65d9c0
feat(api): render backstop positions
JiahuiWho Jul 27, 2026
1db5eed
Merge branch 'feat/blend-positions-api-3' into feat/blend-positions-a…
JiahuiWho Jul 29, 2026
d832465
feat(docs): drop stale upstream earn-query refs in Blend catalog comm…
JiahuiWho Jul 29, 2026
a1f982e
Merge remote-tracking branch 'sdf/main-blend' into feat/blend-positio…
JiahuiWho Aug 1, 2026
3888781
test(api): cover positions/blend routes in the wallet-backend-routes-…
JiahuiWho Aug 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions internal/api/handlers/blend_catalog.go
Original file line number Diff line number Diff line change
@@ -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})
}
87 changes: 87 additions & 0 deletions internal/api/handlers/blend_catalog_test.go
Original file line number Diff line number Diff line change
@@ -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":[]`)
}
26 changes: 20 additions & 6 deletions internal/api/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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},
Expand Down
8 changes: 6 additions & 2 deletions internal/api/serve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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")
}

Expand Down
Loading
Loading