From afb5e861432823c3ec72b6d3d57a21bac405f31a Mon Sep 17 00:00:00 2001 From: jiahuihu Date: Tue, 21 Jul 2026 10:43:22 -0400 Subject: [PATCH 01/15] feat(config): add Blend config (cache TTLs, earn-pools allowlist path) Co-Authored-By: Claude Fable 5 --- cmd/serve/serve.go | 11 +++++++++++ internal/config/config.go | 21 +++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/cmd/serve/serve.go b/cmd/serve/serve.go index e3e086e..9deb591 100644 --- a/cmd/serve/serve.go +++ b/cmd/serve/serve.go @@ -45,6 +45,12 @@ func (s *ServeCmd) Command() *cobra.Command { if d, m := s.Cfg.AppConfig.AccountHistoryDefaultLimit, s.Cfg.AppConfig.AccountHistoryMaxLimit; d <= 0 || m <= 0 || d > m || m > handlers.AccountHistoryUpstreamMaxLimit { return fmt.Errorf("--account-history-default-limit=%d / --account-history-max-limit=%d must be positive, default <= max, and max <= %d", d, m, handlers.AccountHistoryUpstreamMaxLimit) } + if n := s.Cfg.BlendConfig.PositionsCacheTTLSeconds; n < 0 { + return fmt.Errorf("--blend-positions-cache-ttl-seconds=%d must be >= 0", n) + } + if n := s.Cfg.BlendConfig.CatalogCacheTTLSeconds; n < 0 { + return fmt.Errorf("--blend-catalog-cache-ttl-seconds=%d must be >= 0", n) + } if _, err := auth.ParseMode(s.Cfg.AppConfig.AuthMode); err != nil { return fmt.Errorf("--auth-mode: %w", err) } @@ -136,6 +142,11 @@ func (s *ServeCmd) Command() *cobra.Command { cmd.Flags().StringVar(&s.Cfg.WalletBackendConfig.PubnetSigningKey, "wallet-backend-pubnet-signing-key", "", "Wallet backend pubnet JWT signing key (Stellar secret key)") cmd.Flags().StringVar(&s.Cfg.WalletBackendConfig.TestnetSigningKey, "wallet-backend-testnet-signing-key", "", "Wallet backend testnet JWT signing key (Stellar secret key)") + // Blend Config (positions/catalog endpoints backed by wallet-backend's Blend GraphQL) + cmd.Flags().IntVar(&s.Cfg.BlendConfig.PositionsCacheTTLSeconds, "blend-positions-cache-ttl-seconds", 30, "TTL for cached per-address Blend position responses in Redis (seconds)") + cmd.Flags().IntVar(&s.Cfg.BlendConfig.CatalogCacheTTLSeconds, "blend-catalog-cache-ttl-seconds", 60, "TTL for the cached per-network Blend market views (pools, earn options) in Redis (seconds)") + cmd.Flags().StringVar(&s.Cfg.BlendConfig.EarnPoolsConfigPath, "earn-pools-config-path", "", "Path to the JSON allowlist of Blend pool contract IDs offered in the Earn flow; curates earn-options only, never user positions. Empty disables curation.") + // Token Prices Config cmd.Flags().StringVar(&s.Cfg.PricesConfig.StellarExpertPubnetURL, "stellar-expert-pubnet-url", "https://api.stellar.expert/explorer/public", "Stellar Expert base URL for pubnet") cmd.Flags().StringVar(&s.Cfg.PricesConfig.StellarExpertTestnetURL, "stellar-expert-testnet-url", "https://api.stellar.expert/explorer/testnet", "Stellar Expert base URL for testnet") diff --git a/internal/config/config.go b/internal/config/config.go index 6435b2f..c0bcebd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,6 +17,7 @@ type Config struct { BlockaidConfig BlockaidConfig CoinbaseConfig CoinbaseConfig WalletBackendConfig WalletBackendConfig + BlendConfig BlendConfig } type AppConfig struct { @@ -146,3 +147,23 @@ type WalletBackendConfig struct { PubnetSigningKey string TestnetSigningKey string } + +// BlendConfig tunes the Blend positions/catalog endpoints, which are served +// from wallet-backend's Blend GraphQL surface (URLs and signing keys come +// from WalletBackendConfig). +type BlendConfig struct { + // PositionsCacheTTLSeconds is the Redis TTL for per-address position + // responses. User-visible staleness is this TTL plus wallet-backend's + // own ingestion lag, so keep it short. + PositionsCacheTTLSeconds int + // CatalogCacheTTLSeconds is the Redis TTL for the address-independent + // market views (pools, earn options). One cache entry per network serves + // every user. + CatalogCacheTTLSeconds int + // EarnPoolsConfigPath points to a JSON allowlist of pool contract IDs + // per network that Freighter offers for new deposits. It curates the + // earn-options endpoint only — user positions are never filtered by it, + // since users may hold positions in non-curated pools. Empty path + // disables curation (all pools are offered). + EarnPoolsConfigPath string +} From dc49a2f0ca26439f7b3430c18ce234ff732425fa Mon Sep 17 00:00:00 2001 From: jiahuihu Date: Wed, 22 Jul 2026 11:46:55 -0400 Subject: [PATCH 02/15] feat(services): add Blend GraphQL client (positions, pools, earn options) --- internal/services/wallet_backend_blend.go | 227 +++++++++++++ .../wallet_backend_blend_live_test.go | 58 ++++ .../services/wallet_backend_blend_test.go | 300 ++++++++++++++++++ internal/types/blend.go | 130 ++++++++ internal/types/interfaces.go | 10 + internal/utils/mocks.go | 44 +++ 6 files changed, 769 insertions(+) create mode 100644 internal/services/wallet_backend_blend.go create mode 100644 internal/services/wallet_backend_blend_live_test.go create mode 100644 internal/services/wallet_backend_blend_test.go create mode 100644 internal/types/blend.go diff --git a/internal/services/wallet_backend_blend.go b/internal/services/wallet_backend_blend.go new file mode 100644 index 0000000..c32a51f --- /dev/null +++ b/internal/services/wallet_backend_blend.go @@ -0,0 +1,227 @@ +// ABOUTME: Blend GraphQL queries against wallet-backend: account positions, the +// ABOUTME: pool catalog, and earn options, via a hand-rolled signed GraphQL POST. +package services + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/stellar/wallet-backend/pkg/wbclient" + + "github.com/stellar/freighter-backend-v2/internal/types" +) + +// The Blend queries are issued as raw GraphQL documents over wbclient's +// transport pieces (BaseURL, JWT RequestSigner, shared HTTPClient) because +// the SDK's own executeGraphQL is unexported and has no Blend methods yet. +// Field lists mirror internal/serve/graphql/schema/blend.graphqls on the +// wallet-backend Blend branch; re-verify them against the merged schema +// when that stack lands. +const ( + wbGraphQLPath = "/graphql/query" + + // wbSignTimeout matches the JWT expiry wbclient.Client.request uses. + wbSignTimeout = 5 * time.Second + + blendPositionsQuery = `query FreighterBlendPositions($address: String!) { + accountByAddress(address: $address) { + blendPositions { + pools { + poolAddress + poolName + usdValue + suppliedUsd + borrowedUsd + netApy + reserves { + assetContractId + tokenName + tokenSymbol + tokenDecimals + suppliedTokens + collateralTokens + borrowedTokens + suppliedUsd + borrowedUsd + supplyApy + borrowApy + emissionsApr + interestEarned + emissionsEarnedBlnd + emissionsEarnedUsd + priceUsd + } + } + } + } +}` + + blendPoolsQuery = `query FreighterBlendPools { + blendPools { + address + name + status + suppliedUsd + borrowedUsd + interestApy + netApy + reserves { + assetContractId + tokenName + tokenSymbol + tokenDecimals + enabled + utilization + supplyApy + borrowApy + emissionsSupplyApr + suppliedUsd + borrowedUsd + priceUsd + } + } +}` + + blendEarnOptionsQuery = `query FreighterBlendEarnOptions { + blendEarnOptions { + assetContractId + tokenName + tokenSymbol + tokenDecimals + pools { + poolAddress + poolName + supplyApy + emissionsSupplyApr + suppliedUsd + } + } +}` +) + +// Root-field wrappers for each query document. +type blendPositionsData struct { + AccountByAddress *struct { + BlendPositions types.BlendAccountPositions `json:"blendPositions"` + } `json:"accountByAddress"` +} + +type blendPoolsData struct { + BlendPools []types.BlendPool `json:"blendPools"` +} + +type blendEarnOptionsData struct { + BlendEarnOptions []types.BlendEarnOption `json:"blendEarnOptions"` +} + +// GetBlendPositions returns the account's Blend positions. accountByAddress +// resolving to null (an account wallet-backend has never indexed) returns +// empty positions rather than an error: for this read, "unknown account" and +// "no positions" are the same client-facing fact. +func (w *walletBackendService) GetBlendPositions(ctx context.Context, address, network string) (_ *types.BlendAccountPositions, err error) { + start := time.Now() + defer func() { w.recordWBCall("GetBlendPositions", network, start, err) }() + + data, err := wbGraphQL[blendPositionsData](ctx, w, network, "GetBlendPositions", blendPositionsQuery, map[string]interface{}{"address": address}) + if err != nil { + return nil, err + } + if data.AccountByAddress == nil { + return &types.BlendAccountPositions{Pools: []types.BlendPoolPosition{}}, nil + } + positions := data.AccountByAddress.BlendPositions + if positions.Pools == nil { + positions.Pools = []types.BlendPoolPosition{} + } + return &positions, nil +} + +// GetBlendPools returns the pool-wide catalog. Always a non-nil slice. +func (w *walletBackendService) GetBlendPools(ctx context.Context, network string) (_ []types.BlendPool, err error) { + start := time.Now() + defer func() { w.recordWBCall("GetBlendPools", network, start, err) }() + + data, err := wbGraphQL[blendPoolsData](ctx, w, network, "GetBlendPools", blendPoolsQuery, nil) + if err != nil { + return nil, err + } + if data.BlendPools == nil { + return []types.BlendPool{}, nil + } + return data.BlendPools, nil +} + +// GetBlendEarnOptions returns the asset-first earn catalog. Always a non-nil +// slice. +func (w *walletBackendService) GetBlendEarnOptions(ctx context.Context, network string) (_ []types.BlendEarnOption, err error) { + start := time.Now() + defer func() { w.recordWBCall("GetBlendEarnOptions", network, start, err) }() + + data, err := wbGraphQL[blendEarnOptionsData](ctx, w, network, "GetBlendEarnOptions", blendEarnOptionsQuery, nil) + if err != nil { + return nil, err + } + if data.BlendEarnOptions == nil { + return []types.BlendEarnOption{}, nil + } + return data.BlendEarnOptions, nil +} + +// wbGraphQL executes one GraphQL document against the network's +// wallet-backend and unmarshals the response's data into T. It mirrors +// wbclient.Client.request (same path, JWT signing, and error vocabulary: +// "unexpected statusCode=" / "GraphQL error:" so classifyWBError and the +// handlers' translateServiceError treat raw-document calls exactly like SDK +// calls). A free function because Go methods cannot be generic. +func wbGraphQL[T any](ctx context.Context, w *walletBackendService, network, method, query string, variables map[string]interface{}) (*T, error) { + client := w.configureNetworkClient(network) + if client == nil { + return nil, fmt.Errorf("wallet backend client not configured for network: %s", network) + } + + body, err := json.Marshal(wbclient.GraphQLRequest{Query: query, Variables: variables}) + if err != nil { + return nil, fmt.Errorf("marshalling %s request: %w", method, err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, client.BaseURL+wbGraphQLPath, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("creating %s request: %w", method, err) + } + if client.RequestSigner != nil { + if signErr := client.RequestSigner.SignHTTPRequest(req, wbSignTimeout); signErr != nil { + return nil, fmt.Errorf("signing %s request: %w", method, signErr) + } + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.HTTPClient.Do(req) + if err != nil { + return nil, classifyWBError(fmt.Errorf("sending %s request: %w", method, err)) + } + defer resp.Body.Close() //nolint:errcheck + + if resp.StatusCode != http.StatusOK { + snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return nil, classifyWBError(fmt.Errorf("%s: unexpected statusCode=%d, body=%s", method, resp.StatusCode, snippet)) + } + + var envelope wbclient.GraphQLResponse + if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil { + return nil, fmt.Errorf("parsing %s response body: %w", method, err) + } + if len(envelope.Errors) > 0 { + return nil, classifyWBError(fmt.Errorf("%s: GraphQL error: %s", method, envelope.Errors[0].Message)) + } + + var data T + if err := json.Unmarshal(envelope.Data, &data); err != nil { + return nil, fmt.Errorf("unmarshaling %s data: %w", method, err) + } + return &data, nil +} diff --git a/internal/services/wallet_backend_blend_live_test.go b/internal/services/wallet_backend_blend_live_test.go new file mode 100644 index 0000000..1d1a4f1 --- /dev/null +++ b/internal/services/wallet_backend_blend_live_test.go @@ -0,0 +1,58 @@ +// ABOUTME: Env-gated live smoke test for the Blend GraphQL client against a real +// ABOUTME: wallet-backend (skipped unless WB_LIVE_URL is set; safe no-op in CI). +package services + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" +) + +// TestLiveBlendQueries exercises the three Blend client methods against a +// live wallet-backend (e.g. a port-forwarded dev instance) and writes the +// decoded responses to WB_LIVE_OUT for inspection/fixtures. Skipped unless +// WB_LIVE_URL is set. +func TestLiveBlendQueries(t *testing.T) { + url := os.Getenv("WB_LIVE_URL") + if url == "" { + t.Skip("WB_LIVE_URL not set; live test skipped") + } + key := os.Getenv("WB_LIVE_KEY") + outDir := os.Getenv("WB_LIVE_OUT") + + svc, err := NewWalletBackendService("", url, "", key, 1, nil) + if err != nil { + t.Fatalf("constructing service: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + dump := func(name string, v any, err error) { + if err != nil { + t.Errorf("%s error: %v", name, err) + return + } + blob, _ := json.MarshalIndent(v, "", " ") + t.Logf("%s: %d bytes", name, len(blob)) + if outDir != "" { + _ = os.WriteFile(filepath.Join(outDir, name+".json"), blob, 0o644) + } + } + + pools, err := svc.GetBlendPools(ctx, "TESTNET") + dump("blend_pools", pools, err) + + options, err := svc.GetBlendEarnOptions(ctx, "TESTNET") + dump("blend_earn_options", options, err) + + address := os.Getenv("WB_LIVE_ADDRESS") + if address == "" { + address = "GDW6QB3BFPQ3I4LH752JD2HYADFM2T4RVRCEUNCCH7MICWZR67NL5552" + } + positions, err := svc.GetBlendPositions(ctx, address, "TESTNET") + dump("blend_positions", positions, err) +} diff --git a/internal/services/wallet_backend_blend_test.go b/internal/services/wallet_backend_blend_test.go new file mode 100644 index 0000000..43025b4 --- /dev/null +++ b/internal/services/wallet_backend_blend_test.go @@ -0,0 +1,300 @@ +// ABOUTME: Tests for the Blend GraphQL client methods against httptest fakes, +// ABOUTME: covering decode (incl. null Floats), auth signing, and error mapping. +package services + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stellar/wallet-backend/pkg/wbclient" + + "github.com/stellar/freighter-backend-v2/internal/metrics" + "github.com/stellar/freighter-backend-v2/internal/types" +) + +const blendTestAddress = "GDW6QB3BFPQ3I4LH752JD2HYADFM2T4RVRCEUNCCH7MICWZR67NL5552" + +// newBlendTestService wires a walletBackendService whose testnet client +// points at the given httptest server, with no request signer. +func newBlendTestService(t *testing.T, handler http.HandlerFunc) *walletBackendService { + t.Helper() + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + return &walletBackendService{ + testnetClient: wbclient.NewClient(server.URL, nil), + maxBalanceConcurrency: 1, + } +} + +// graphqlEnvelope wraps data as a GraphQL success response body. +func graphqlEnvelope(t *testing.T, data string) []byte { + t.Helper() + return []byte(`{"data":` + data + `}`) +} + +func TestGetBlendPositionsDecode(t *testing.T) { + var gotPath string + var gotBody wbclient.GraphQLRequest + svc := newBlendTestService(t, func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + require.NoError(t, json.NewDecoder(r.Body).Decode(&gotBody)) + // One pool, two reserves. The second reserve is null-heavy: unpriced + // asset (all USD/APY fields null, priceUsd null) and no registry + // metadata (tokenName/tokenSymbol/tokenDecimals null) — decode must + // yield nil pointers, never zeroes. + _, _ = w.Write(graphqlEnvelope(t, `{ + "accountByAddress": { + "blendPositions": { + "pools": [{ + "poolAddress": "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD", + "poolName": "Fixed Pool V2", + "usdValue": 77876.27, + "suppliedUsd": 674117.02, + "borrowedUsd": 596240.75, + "netApy": -0.029, + "reserves": [ + { + "assetContractId": "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75", + "tokenName": "USD Coin", + "tokenSymbol": "USDC", + "tokenDecimals": 7, + "suppliedTokens": "1000000000", + "collateralTokens": "5563385856000", + "borrowedTokens": "4953691474632", + "suppliedUsd": 556438.59, + "borrowedUsd": 495221.33, + "supplyApy": 0.0741, + "borrowApy": 0.1151, + "emissionsApr": 0.002, + "interestEarned": "6843215", + "emissionsEarnedBlnd": "12345678", + "emissionsEarnedUsd": 0.53, + "priceUsd": 1.0 + }, + { + "assetContractId": "CBZPEXQLJCUS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI99", + "tokenName": null, + "tokenSymbol": null, + "tokenDecimals": null, + "suppliedTokens": "68", + "collateralTokens": "0", + "borrowedTokens": "0", + "suppliedUsd": null, + "borrowedUsd": null, + "supplyApy": null, + "borrowApy": null, + "emissionsApr": null, + "interestEarned": "0", + "emissionsEarnedBlnd": "0", + "emissionsEarnedUsd": null, + "priceUsd": null + } + ] + }] + } + } + }`)) + }) + + positions, err := svc.GetBlendPositions(context.Background(), blendTestAddress, types.TESTNET) + require.NoError(t, err) + + assert.Equal(t, wbGraphQLPath, gotPath) + assert.Contains(t, gotBody.Query, "FreighterBlendPositions") + assert.Equal(t, blendTestAddress, gotBody.Variables["address"]) + + require.Len(t, positions.Pools, 1) + pool := positions.Pools[0] + require.NotNil(t, pool.PoolName) + assert.Equal(t, "Fixed Pool V2", *pool.PoolName) + require.NotNil(t, pool.NetAPY) + assert.InDelta(t, -0.029, *pool.NetAPY, 1e-9) + + require.Len(t, pool.Reserves, 2) + priced, unpriced := pool.Reserves[0], pool.Reserves[1] + + // Token amounts stay full-precision strings. + assert.Equal(t, "5563385856000", priced.CollateralTokens) + assert.Equal(t, "6843215", priced.InterestEarned) + require.NotNil(t, priced.SupplyAPY) + assert.InDelta(t, 0.0741, *priced.SupplyAPY, 1e-9) + + // Null Floats and null registry metadata decode to nil, not zero. + assert.Nil(t, unpriced.SuppliedUSD) + assert.Nil(t, unpriced.SupplyAPY) + assert.Nil(t, unpriced.PriceUSD) + assert.Nil(t, unpriced.TokenSymbol) + assert.Nil(t, unpriced.TokenDecimals) + assert.Equal(t, "68", unpriced.SuppliedTokens) +} + +func TestGetBlendPositionsUnknownAccountIsEmpty(t *testing.T) { + svc := newBlendTestService(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(graphqlEnvelope(t, `{"accountByAddress": null}`)) + }) + + positions, err := svc.GetBlendPositions(context.Background(), blendTestAddress, types.TESTNET) + require.NoError(t, err) + require.NotNil(t, positions) + assert.NotNil(t, positions.Pools) + assert.Empty(t, positions.Pools) +} + +func TestGetBlendPoolsDecode(t *testing.T) { + var gotBody wbclient.GraphQLRequest + svc := newBlendTestService(t, func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, json.NewDecoder(r.Body).Decode(&gotBody)) + _, _ = w.Write(graphqlEnvelope(t, `{ + "blendPools": [ + { + "address": "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD", + "name": null, + "status": 1, + "suppliedUsd": 2100000.5, + "borrowedUsd": 900000.25, + "interestApy": 0.043, + "netApy": 0.047, + "reserves": [{ + "assetContractId": "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75", + "tokenName": "USD Coin", + "tokenSymbol": "USDC", + "tokenDecimals": 7, + "enabled": true, + "utilization": 0.62, + "supplyApy": 0.043, + "borrowApy": 0.061, + "emissionsSupplyApr": 0.008, + "suppliedUsd": 1500000.0, + "borrowedUsd": 930000.0, + "priceUsd": 1.0 + }] + }, + { + "address": "CCCCIQSDILITHMM7PBSLVDT5MISSY7R26MNZXCX4H7J5JQ5FPIYOGYFS", + "name": "Second Pool", + "status": null, + "suppliedUsd": null, + "borrowedUsd": null, + "interestApy": null, + "netApy": null, + "reserves": [] + } + ] + }`)) + }) + + pools, err := svc.GetBlendPools(context.Background(), types.TESTNET) + require.NoError(t, err) + assert.Contains(t, gotBody.Query, "FreighterBlendPools") + + require.Len(t, pools, 2) + require.NotNil(t, pools[0].Status) + assert.Equal(t, types.BlendPoolStatusActive, *pools[0].Status) + assert.Nil(t, pools[0].Name) + require.Len(t, pools[0].Reserves, 1) + assert.True(t, pools[0].Reserves[0].Enabled) + + // Not-yet-ingested pool: status and all totals null. + assert.Nil(t, pools[1].Status) + assert.Nil(t, pools[1].SuppliedUSD) + assert.Empty(t, pools[1].Reserves) +} + +func TestGetBlendEarnOptionsDecode(t *testing.T) { + svc := newBlendTestService(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(graphqlEnvelope(t, `{ + "blendEarnOptions": [{ + "assetContractId": "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75", + "tokenName": "USD Coin", + "tokenSymbol": "USDC", + "tokenDecimals": 7, + "pools": [ + {"poolAddress": "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD", "poolName": "Fixed Pool V2", "supplyApy": 0.043, "emissionsSupplyApr": 0.008, "suppliedUsd": 1500000.0}, + {"poolAddress": "CCCCIQSDILITHMM7PBSLVDT5MISSY7R26MNZXCX4H7J5JQ5FPIYOGYFS", "poolName": null, "supplyApy": 0.032, "emissionsSupplyApr": null, "suppliedUsd": null} + ] + }] + }`)) + }) + + options, err := svc.GetBlendEarnOptions(context.Background(), types.TESTNET) + require.NoError(t, err) + + require.Len(t, options, 1) + require.Len(t, options[0].Pools, 2) + require.NotNil(t, options[0].Pools[0].EmissionsSupplyAPR) + assert.InDelta(t, 0.008, *options[0].Pools[0].EmissionsSupplyAPR, 1e-9) + assert.Nil(t, options[0].Pools[1].EmissionsSupplyAPR) +} + +func TestBlendGraphQLErrorClassification(t *testing.T) { + t.Run("GraphQL errors array becomes graphql_error", func(t *testing.T) { + svc := newBlendTestService(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"errors":[{"message":"Cannot query field \"blendPools\" on type \"Query\"."}]}`)) + }) + + _, err := svc.GetBlendPools(context.Background(), types.TESTNET) + require.Error(t, err) + var upErr *metrics.UpstreamError + require.ErrorAs(t, err, &upErr) + assert.Equal(t, "graphql_error", upErr.Kind) + assert.Contains(t, err.Error(), "blendPools") + }) + + t.Run("non-200 becomes http_error with code", func(t *testing.T) { + svc := newBlendTestService(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write([]byte("upstream sad")) + }) + + _, err := svc.GetBlendPositions(context.Background(), blendTestAddress, types.TESTNET) + require.Error(t, err) + var upErr *metrics.UpstreamError + require.ErrorAs(t, err, &upErr) + assert.Equal(t, "http_error", upErr.Kind) + assert.Equal(t, http.StatusBadGateway, upErr.Code) + }) + + t.Run("malformed data payload is a decode error", func(t *testing.T) { + svc := newBlendTestService(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data": {"blendPools": "not-a-list"}}`)) + }) + + _, err := svc.GetBlendPools(context.Background(), types.TESTNET) + require.Error(t, err) + assert.Contains(t, err.Error(), "unmarshaling GetBlendPools data") + }) +} + +// headerSigner is a fake auth.HTTPRequestSigner that stamps a header so the +// test can assert the signing hook runs for raw GraphQL documents. +type headerSigner struct{} + +func (headerSigner) SignHTTPRequest(req *http.Request, _ time.Duration) error { + req.Header.Set("Authorization", "Bearer test-jwt") + return nil +} + +func TestBlendGraphQLRequestIsSigned(t *testing.T) { + var gotAuth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + _, _ = w.Write([]byte(`{"data":{"blendPools":[]}}`)) + })) + t.Cleanup(server.Close) + + client := wbclient.NewClient(server.URL, headerSigner{}) + svc := &walletBackendService{testnetClient: client, maxBalanceConcurrency: 1} + + pools, err := svc.GetBlendPools(context.Background(), types.TESTNET) + require.NoError(t, err) + assert.Empty(t, pools) + assert.NotNil(t, pools) + assert.Equal(t, "Bearer test-jwt", gotAuth) +} diff --git a/internal/types/blend.go b/internal/types/blend.go new file mode 100644 index 0000000..55b0a2e --- /dev/null +++ b/internal/types/blend.go @@ -0,0 +1,130 @@ +// ABOUTME: Decode types for wallet-backend's Blend v2 GraphQL surface (positions, +// ABOUTME: pool catalog, earn options), mirroring blend.graphqls field for field. +package types + +// Conventions, from the wallet-backend schema (blend.graphqls): +// - USD/APY values are nullable Float: null means "uncomputable" (an oracle +// price is missing or >24h stale — the pool contract itself rejects prices +// past that age); a genuinely zero balance is 0, not null. Decoded as +// *float64 and propagated as null, never rendered as 0. +// - On-chain token amounts are non-null String at full precision. Passed +// through verbatim; never float-parsed. +// - tokenName/tokenSymbol/tokenDecimals come from the contract_tokens +// metadata registry and are nullable; display falls back to a truncated +// contract address. +// +// Backstop fields/types are deliberately not modeled (out of scope for v1); +// the query documents don't select them, so decoding never sees them. + +// BlendAccountPositions is Account.blendPositions: one account's lending, +// collateral, and borrowing positions across every Blend v2 pool it touched. +type BlendAccountPositions struct { + Pools []BlendPoolPosition `json:"pools"` +} + +// BlendPoolPosition rolls up an account's reserve positions within one pool. +// USDValue is supplied minus borrowed; NetAPY is the account's net rate for +// this pool, netted against borrow interest. +type BlendPoolPosition struct { + PoolAddress string `json:"poolAddress"` + PoolName *string `json:"poolName"` + USDValue *float64 `json:"usdValue"` + SuppliedUSD *float64 `json:"suppliedUsd"` + BorrowedUSD *float64 `json:"borrowedUsd"` + NetAPY *float64 `json:"netApy"` + Reserves []BlendReservePosition `json:"reserves"` +} + +// BlendReservePosition is an account's position in one reserve of a pool. +// Token amounts are underlying-asset amounts at rates projected to now. +// InterestEarned is lifetime interest in underlying tokens (survives full +// exit — a zero-balance row still carries realized earnings; liquidations +// adjust the basis so the figure stays interest-only). EmissionsEarnedBLND +// is claimable (uncollected) BLND across the reserve's emission streams. +type BlendReservePosition struct { + AssetContractID string `json:"assetContractId"` + TokenName *string `json:"tokenName"` + TokenSymbol *string `json:"tokenSymbol"` + TokenDecimals *int32 `json:"tokenDecimals"` + SuppliedTokens string `json:"suppliedTokens"` + CollateralTokens string `json:"collateralTokens"` + BorrowedTokens string `json:"borrowedTokens"` + SuppliedUSD *float64 `json:"suppliedUsd"` + BorrowedUSD *float64 `json:"borrowedUsd"` + SupplyAPY *float64 `json:"supplyApy"` + BorrowAPY *float64 `json:"borrowApy"` + EmissionsAPR *float64 `json:"emissionsApr"` + InterestEarned string `json:"interestEarned"` + EmissionsEarnedBLND string `json:"emissionsEarnedBlnd"` + EmissionsEarnedUSD *float64 `json:"emissionsEarnedUsd"` + PriceUSD *float64 `json:"priceUsd"` +} + +// BlendPool is one pool in the pool-wide catalog (Query.blendPools), +// independent of any account. SuppliedUSD/BorrowedUSD are strict-null: a +// missing price on any reserve makes the pool total uncomputable. +// InterestAPY is the supplied-USD-weighted supply rate (interest only); +// NetAPY additionally folds in BLND emissions — supply-side yield, not +// netted against borrows. +type BlendPool struct { + Address string `json:"address"` + Name *string `json:"name"` + Status *int32 `json:"status"` + SuppliedUSD *float64 `json:"suppliedUsd"` + BorrowedUSD *float64 `json:"borrowedUsd"` + InterestAPY *float64 `json:"interestApy"` + NetAPY *float64 `json:"netApy"` + Reserves []BlendReserve `json:"reserves"` +} + +// Blend on-chain pool status values (BlendPool.Status). 0-3 accept supply +// (deposits); 0-1 also allow borrowing; 4-6 reject both. Status is null +// until the pool's config entry has been ingested. +const ( + BlendPoolStatusAdminActive int32 = 0 + BlendPoolStatusActive int32 = 1 + BlendPoolStatusAdminOnIce int32 = 2 + BlendPoolStatusOnIce int32 = 3 + BlendPoolStatusAdminFrozen int32 = 4 + BlendPoolStatusFrozen int32 = 5 + BlendPoolStatusSetup int32 = 6 +) + +// BlendReserve is a pool-wide reserve catalog row: rates and totals as of +// now, no per-account data. +type BlendReserve struct { + AssetContractID string `json:"assetContractId"` + TokenName *string `json:"tokenName"` + TokenSymbol *string `json:"tokenSymbol"` + TokenDecimals *int32 `json:"tokenDecimals"` + Enabled bool `json:"enabled"` + Utilization *float64 `json:"utilization"` + SupplyAPY *float64 `json:"supplyApy"` + BorrowAPY *float64 `json:"borrowApy"` + EmissionsSupplyAPR *float64 `json:"emissionsSupplyApr"` + SuppliedUSD *float64 `json:"suppliedUsd"` + BorrowedUSD *float64 `json:"borrowedUsd"` + PriceUSD *float64 `json:"priceUsd"` +} + +// BlendEarnOption is one entry of Query.blendEarnOptions: an asset with at +// least one enabled reserve in a pool that currently accepts supply. +// Upstream already excludes disabled reserves and supply-rejecting pools +// (status >= 4 or not yet ingested). +type BlendEarnOption struct { + AssetContractID string `json:"assetContractId"` + TokenName *string `json:"tokenName"` + TokenSymbol *string `json:"tokenSymbol"` + TokenDecimals *int32 `json:"tokenDecimals"` + Pools []BlendEarnPoolOption `json:"pools"` +} + +// BlendEarnPoolOption is one pool's offer for an earn option's asset. +// SupplyAPY + EmissionsSupplyAPR is the emissions-inclusive earn headline. +type BlendEarnPoolOption struct { + PoolAddress string `json:"poolAddress"` + PoolName *string `json:"poolName"` + SupplyAPY *float64 `json:"supplyApy"` + EmissionsSupplyAPR *float64 `json:"emissionsSupplyApr"` + SuppliedUSD *float64 `json:"suppliedUsd"` +} diff --git a/internal/types/interfaces.go b/internal/types/interfaces.go index ce83004..c7f94ac 100644 --- a/internal/types/interfaces.go +++ b/internal/types/interfaces.go @@ -49,6 +49,16 @@ type WalletBackendService interface { GetHealth(ctx context.Context, network string) (GetHealthResponse, error) GetBalancesByAccountAddresses(ctx context.Context, addresses []string, network string) (interface{}, error) GetAccountTransactions(ctx context.Context, address, network string, params AccountHistoryParams) (*PaginatedResponse[*AccountTransaction], error) + // GetBlendPositions returns the account's Blend v2 positions across every + // pool it touched. An account unknown to wallet-backend returns empty + // positions, not an error — indistinguishable from "no positions" by + // design. + GetBlendPositions(ctx context.Context, address, network string) (*BlendAccountPositions, error) + // GetBlendPools returns the pool-wide Blend catalog (no account data). + GetBlendPools(ctx context.Context, network string) ([]BlendPool, error) + // GetBlendEarnOptions returns the asset-first earn catalog, pre-filtered + // upstream to enabled reserves in supply-accepting pools. + GetBlendEarnOptions(ctx context.Context, network string) ([]BlendEarnOption, error) } // StellarExpertAsset is the subset of the Stellar Expert /asset/{id} response diff --git a/internal/utils/mocks.go b/internal/utils/mocks.go index f8d3916..9bf96ea 100644 --- a/internal/utils/mocks.go +++ b/internal/utils/mocks.go @@ -120,6 +120,17 @@ type MockWalletBackendService struct { GetAccountTransactionsError error // GetAccountTransactionsFunc overrides Result/Error when set. GetAccountTransactionsFunc func(ctx context.Context, address, network string, params types.AccountHistoryParams) (*types.PaginatedResponse[*types.AccountTransaction], error) + + // Blend method stubs follow the same Result/Error/Func precedence. + GetBlendPositionsResult *types.BlendAccountPositions + GetBlendPositionsError error + GetBlendPositionsFunc func(ctx context.Context, address, network string) (*types.BlendAccountPositions, error) + + GetBlendPoolsResult []types.BlendPool + GetBlendPoolsError error + + GetBlendEarnOptionsResult []types.BlendEarnOption + GetBlendEarnOptionsError error } func (m *MockWalletBackendService) Name() string { @@ -153,6 +164,39 @@ func (m *MockWalletBackendService) GetAccountTransactions(ctx context.Context, a return m.GetAccountTransactionsResult, nil } +func (m *MockWalletBackendService) GetBlendPositions(ctx context.Context, address, network string) (*types.BlendAccountPositions, error) { + if m.GetBlendPositionsFunc != nil { + return m.GetBlendPositionsFunc(ctx, address, network) + } + if m.GetBlendPositionsError != nil { + return nil, m.GetBlendPositionsError + } + if m.GetBlendPositionsResult != nil { + return m.GetBlendPositionsResult, nil + } + return &types.BlendAccountPositions{Pools: []types.BlendPoolPosition{}}, nil +} + +func (m *MockWalletBackendService) GetBlendPools(ctx context.Context, network string) ([]types.BlendPool, error) { + if m.GetBlendPoolsError != nil { + return nil, m.GetBlendPoolsError + } + if m.GetBlendPoolsResult != nil { + return m.GetBlendPoolsResult, nil + } + return []types.BlendPool{}, nil +} + +func (m *MockWalletBackendService) GetBlendEarnOptions(ctx context.Context, network string) ([]types.BlendEarnOption, error) { + if m.GetBlendEarnOptionsError != nil { + return nil, m.GetBlendEarnOptionsError + } + if m.GetBlendEarnOptionsResult != nil { + return m.GetBlendEarnOptionsResult, nil + } + return []types.BlendEarnOption{}, nil +} + type MockPricesService struct { GetPricesFunc func(ctx context.Context, tokens []string, network string) (map[string]*types.PriceEntry, error) GetPricesOverride map[string]*types.PriceEntry From e687a208a98853398d667c51cf3a36d4d8d7ea6e Mon Sep 17 00:00:00 2001 From: jiahuihu Date: Wed, 22 Jul 2026 14:54:51 -0400 Subject: [PATCH 03/15] feat(api): add GET /accounts/{address}/positions --- internal/api/handlers/account_positions.go | 52 ++++ .../api/handlers/account_positions_test.go | 96 +++++++ internal/api/serve.go | 9 + internal/services/positions.go | 230 ++++++++++++++++ internal/services/positions_test.go | 245 ++++++++++++++++++ internal/types/positions.go | 117 +++++++++ internal/utils/mocks.go | 22 ++ 7 files changed, 771 insertions(+) create mode 100644 internal/api/handlers/account_positions.go create mode 100644 internal/api/handlers/account_positions_test.go create mode 100644 internal/services/positions.go create mode 100644 internal/services/positions_test.go create mode 100644 internal/types/positions.go diff --git a/internal/api/handlers/account_positions.go b/internal/api/handlers/account_positions.go new file mode 100644 index 0000000..11ee1d6 --- /dev/null +++ b/internal/api/handlers/account_positions.go @@ -0,0 +1,52 @@ +// ABOUTME: Handler for GET /api/v1/accounts/{address}/positions — an account's +// ABOUTME: DeFi positions (Blend), served from the positions service. +package handlers + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/stellar/go/strkey" + + "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 accountPositionsContextTimeout = 10 * time.Second + +type AccountPositionsHandler struct { + PositionsService types.PositionsService +} + +func NewAccountPositionsHandler(positionsService types.PositionsService) *AccountPositionsHandler { + return &AccountPositionsHandler{PositionsService: positionsService} +} + +// GetAccountPositions handles GET /api/v1/accounts/{address}/positions. +// An account with no positions (including one unknown to the indexer) +// returns 200 with an empty positions list, not 404. +func (h *AccountPositionsHandler) GetAccountPositions(w http.ResponseWriter, r *http.Request) error { + ctx, cancel := context.WithTimeout(r.Context(), accountPositionsContextTimeout) + 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")) + } + + address := r.PathValue("address") + if _, err := strkey.Decode(strkey.VersionByteAccountID, address); err != nil { + return httperror.BadRequest(fmt.Sprintf("invalid Stellar address %s: %s", address, err.Error()), err) + } + + positions, err := h.PositionsService.GetAccountPositions(ctx, address, network) + if err != nil { + return translateServiceError(r.Context(), err, "account positions", address, network) + } + + return response.OK(w, HttpResponse{Data: positions}) +} diff --git a/internal/api/handlers/account_positions_test.go b/internal/api/handlers/account_positions_test.go new file mode 100644 index 0000000..e00a322 --- /dev/null +++ b/internal/api/handlers/account_positions_test.go @@ -0,0 +1,96 @@ +// ABOUTME: Handler tests for GET /api/v1/accounts/{address}/positions: +// ABOUTME: validation, error translation, and the success envelope. +package handlers + +import ( + "encoding/json" + "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" +) + +const positionsTestAddress = "GDW6QB3BFPQ3I4LH752JD2HYADFM2T4RVRCEUNCCH7MICWZR67NL5552" + +func servePositions(t *testing.T, svc types.PositionsService, target string) *httptest.ResponseRecorder { + t.Helper() + handler := NewAccountPositionsHandler(svc) + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v1/accounts/{address}/positions", CustomHandler(handler.GetAccountPositions)) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil)) + return rec +} + +func TestGetAccountPositionsSuccess(t *testing.T) { + total := 3619.27 + svc := &utils.MockPositionsService{ + GetAccountPositionsResult: &types.AccountPositions{ + TotalValueUSD: &total, + Positions: []types.PoolPosition{{ + Protocol: "blend", + ID: "CCEBVDYMCCECIVWVOJSKUNLTVDIRLTRUCVZDVLKXKQZWSCF3DVQGJVIX", + }}, + }, + } + + rec := servePositions(t, svc, "/api/v1/accounts/"+positionsTestAddress+"/positions?network=TESTNET") + require.Equal(t, http.StatusOK, rec.Code) + + var body struct { + Data types.AccountPositions `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) + require.NotNil(t, body.Data.TotalValueUSD) + assert.InDelta(t, 3619.27, *body.Data.TotalValueUSD, 1e-9) + require.Len(t, body.Data.Positions, 1) + assert.Equal(t, "blend", body.Data.Positions[0].Protocol) +} + +func TestGetAccountPositionsValidation(t *testing.T) { + svc := &utils.MockPositionsService{} + + t.Run("invalid network", func(t *testing.T) { + rec := servePositions(t, svc, "/api/v1/accounts/"+positionsTestAddress+"/positions?network=DOGENET") + assert.Equal(t, http.StatusBadRequest, rec.Code) + }) + + t.Run("missing network", func(t *testing.T) { + rec := servePositions(t, svc, "/api/v1/accounts/"+positionsTestAddress+"/positions") + assert.Equal(t, http.StatusBadRequest, rec.Code) + }) + + t.Run("invalid address", func(t *testing.T) { + rec := servePositions(t, svc, "/api/v1/accounts/not-an-address/positions?network=TESTNET") + assert.Equal(t, http.StatusBadRequest, rec.Code) + }) +} + +func TestGetAccountPositionsErrorTranslation(t *testing.T) { + t.Run("upstream error maps to 502", func(t *testing.T) { + svc := &utils.MockPositionsService{ + GetAccountPositionsError: &metrics.UpstreamError{Kind: "graphql_error", Err: errors.New("boom")}, + } + rec := servePositions(t, svc, "/api/v1/accounts/"+positionsTestAddress+"/positions?network=TESTNET") + assert.Equal(t, http.StatusBadGateway, rec.Code) + }) + + t.Run("unclassified error maps to 500", func(t *testing.T) { + svc := &utils.MockPositionsService{GetAccountPositionsError: errors.New("wat")} + rec := servePositions(t, svc, "/api/v1/accounts/"+positionsTestAddress+"/positions?network=TESTNET") + assert.Equal(t, http.StatusInternalServerError, rec.Code) + }) +} + +func TestGetAccountPositionsEmptyIs200(t *testing.T) { + rec := servePositions(t, &utils.MockPositionsService{}, "/api/v1/accounts/"+positionsTestAddress+"/positions?network=TESTNET") + require.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), `"positions":[]`) +} diff --git a/internal/api/serve.go b/internal/api/serve.go index 0375fd5..8f663e3 100644 --- a/internal/api/serve.go +++ b/internal/api/serve.go @@ -222,6 +222,14 @@ func (s *ApiServer) routes() ([]route, error) { } whoamiHandler := handlers.NewWhoamiHandler() + positionsService := services.NewPositionsService( + s.walletBackendService, + s.redis, + time.Duration(s.cfg.BlendConfig.PositionsCacheTTLSeconds)*time.Second, + s.appMetrics.Service, + ) + accountPositionsHandler := handlers.NewAccountPositionsHandler(positionsService) + return []route{ // Health/liveness/readiness probes: gated=false, registered BARE — never // wrapped by Auth. K8s and the docker-compose wget healthcheck cannot present @@ -243,6 +251,7 @@ func (s *ApiServer) routes() ([]route, error) { {http.MethodPost, "/api/v1/accounts/balances", handlers.CustomHandler(accountBalancesHandler.GetAccountBalances), true}, {http.MethodPost, "/api/v1/token-prices", handlers.CustomHandler(tokenPricesHandler.GetPrices), true}, {http.MethodGet, "/api/v1/accounts/{address}/transactions", handlers.CustomHandler(accountHistoryHandler.GetAccountTransactions), true}, + {http.MethodGet, "/api/v1/accounts/{address}/positions", handlers.CustomHandler(accountPositionsHandler.GetAccountPositions), true}, {http.MethodGet, "/api/v1/auth/whoami", handlers.CustomHandler(whoamiHandler.Whoami), true}, }, nil } diff --git a/internal/services/positions.go b/internal/services/positions.go new file mode 100644 index 0000000..eba2579 --- /dev/null +++ b/internal/services/positions.go @@ -0,0 +1,230 @@ +// ABOUTME: Positions service: maps wallet-backend Blend positions into the +// ABOUTME: frontend-shaped account positions response, with per-address caching. +package services + +import ( + "context" + "fmt" + "math" + "math/big" + "strings" + "time" + + "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 ( + positionsServiceName = "positions" + + defaultPositionsCacheTTL = 30 * time.Second + + // positionsCacheKeyPrefix versions the cached response shape; bump on + // breaking changes so stale entries die at the key level. + positionsCacheKeyPrefix = "blend:positions:v1" +) + +type positionsService struct { + walletBackend types.WalletBackendService + redis *store.RedisStore + cacheTTL time.Duration + svcMetrics *metrics.Service +} + +// NewPositionsService wires the positions view. redis may be nil; every +// request then bypasses the cache and hits wallet-backend. +func NewPositionsService(walletBackend types.WalletBackendService, redis *store.RedisStore, cacheTTL time.Duration, m *metrics.Service) types.PositionsService { + if cacheTTL <= 0 { + cacheTTL = defaultPositionsCacheTTL + } + return &positionsService{ + walletBackend: walletBackend, + redis: redis, + cacheTTL: cacheTTL, + svcMetrics: m, + } +} + +func (p *positionsService) Name() string { return positionsServiceName } + +// GetAccountPositions returns the account's positions, cached per +// (network, address) for cacheTTL. User-visible staleness is the TTL plus +// wallet-backend's own ingestion lag. +func (p *positionsService) GetAccountPositions(ctx context.Context, address, network string) (_ *types.AccountPositions, err error) { + start := time.Now() + defer func() { + metrics.Record(p.svcMetrics, positionsServiceName, "GetAccountPositions", network, time.Since(start).Seconds(), err) + }() + + cacheKey := fmt.Sprintf("%s:%s:%s", positionsCacheKeyPrefix, strings.ToLower(network), address) + if p.redis != nil { + hits, cacheErr := p.redis.MGetJSON(ctx, []string{cacheKey}, func() any { return &types.AccountPositions{} }) + if cacheErr != nil { + // Cache trouble must not fail the request; fall through to upstream. + logger.ErrorWithContext(ctx, "positions cache read failed", "error", cacheErr) + } else if hit, ok := hits[cacheKey].(*types.AccountPositions); ok { + return hit, nil + } + } + + upstream, err := p.walletBackend.GetBlendPositions(ctx, address, network) + if err != nil { + return nil, err + } + + result := mapAccountPositions(upstream) + + if p.redis != nil { + if cacheErr := p.redis.SetJSON(ctx, cacheKey, result, p.cacheTTL); cacheErr != nil { + logger.ErrorWithContext(ctx, "positions cache write failed", "error", cacheErr) + } + } + return result, nil +} + +// mapAccountPositions shapes the upstream Blend positions into the response. +func mapAccountPositions(upstream *types.BlendAccountPositions) *types.AccountPositions { + positions := make([]types.PoolPosition, 0, len(upstream.Pools)) + for _, pool := range upstream.Pools { + positions = append(positions, types.PoolPosition{ + Protocol: "blend", + ID: pool.PoolAddress, + Name: pool.PoolName, + NetUSD: pool.USDValue, + SuppliedUSD: pool.SuppliedUSD, + BorrowedUSD: pool.BorrowedUSD, + NetAPY: pool.NetAPY, + Blend: mapBlendDetail(pool.Reserves), + }) + } + + total, netAPY := accountAggregate(upstream.Pools) + return &types.AccountPositions{ + TotalValueUSD: total, + NetAPY: netAPY, + Positions: positions, + } +} + +// mapBlendDetail turns reserve positions into display rows. Reserves with no +// balance on a side produce no row for that side; upstream deliberately +// emits fully-exited (all-zero) reserve rows to carry earnings history, and +// those are filtered here. +func mapBlendDetail(reserves []types.BlendReservePosition) *types.BlendPositionDetail { + detail := &types.BlendPositionDetail{ + Supply: []types.BlendSupplyRow{}, + Borrow: []types.BlendBorrowRow{}, + } + for _, r := range reserves { + supplied := parseRawAmount(r.SuppliedTokens) + collateral := parseRawAmount(r.CollateralTokens) + borrowed := parseRawAmount(r.BorrowedTokens) + + if supplied.Sign() > 0 || collateral.Sign() > 0 { + total := new(big.Int).Add(supplied, collateral) + detail.Supply = append(detail.Supply, types.BlendSupplyRow{ + AssetID: r.AssetContractID, + Symbol: r.TokenSymbol, + Name: r.TokenName, + Decimals: r.TokenDecimals, + SuppliedTokens: supplied.String(), + CollateralTokens: collateral.String(), + TotalTokens: total.String(), + USDValue: r.SuppliedUSD, + APY: r.SupplyAPY, + EmissionsAPR: r.EmissionsAPR, + InterestEarned: r.InterestEarned, + InterestEarnedUSD: tokensToUSD(r.InterestEarned, r.TokenDecimals, r.PriceUSD), + ClaimableBLND: r.EmissionsEarnedBLND, + ClaimableUSD: r.EmissionsEarnedUSD, + PriceUSD: r.PriceUSD, + }) + } + if borrowed.Sign() > 0 { + detail.Borrow = append(detail.Borrow, types.BlendBorrowRow{ + AssetID: r.AssetContractID, + Symbol: r.TokenSymbol, + Name: r.TokenName, + Decimals: r.TokenDecimals, + BorrowedTokens: borrowed.String(), + USDValue: r.BorrowedUSD, + APY: r.BorrowAPY, + PriceUSD: r.PriceUSD, + }) + } + } + return detail +} + +// accountAggregate computes the header figures from the per-pool summaries. +// +// TotalValueUSD: Σ pool usdValue with strict null propagation (any +// unavailable pool value nulls the total — an undercounted "total" is worse +// than an honest null), mirroring upstream's convention for pool totals. +// 0 for an account with no pools. +// +// NetAPY: mean of pool netApy weighted by pool usdValue — the weight basis +// that makes rate × base reproduce the per-pool dollar earnings under the +// upstream's current netApy definition. Null when any input is unavailable +// or the weighted base is zero. Both rules are pending confirmation with +// the wallet-backend team; each is isolated here so a decision lands as a +// one-line change. +func accountAggregate(pools []types.BlendPoolPosition) (total *float64, netAPY *float64) { + if len(pools) == 0 { + zero := 0.0 + return &zero, nil + } + + sum := 0.0 + apyNumerator := 0.0 + apyKnown := true + for _, pool := range pools { + if pool.USDValue == nil { + return nil, nil + } + sum += *pool.USDValue + if pool.NetAPY == nil { + apyKnown = false + continue + } + apyNumerator += *pool.NetAPY * *pool.USDValue + } + + total = &sum + if apyKnown && sum != 0 { + apy := apyNumerator / sum + if !math.IsInf(apy, 0) && !math.IsNaN(apy) { + netAPY = &apy + } + } + return total, netAPY +} + +// parseRawAmount parses an upstream raw-unit token amount. Upstream declares +// these non-null integer strings; anything unparseable is treated as zero so +// one bad row cannot fail the whole response. +func parseRawAmount(s string) *big.Int { + v, ok := new(big.Int).SetString(s, 10) + if !ok { + return big.NewInt(0) + } + return v +} + +// tokensToUSD converts a raw-unit token amount to USD at the given price. +// Null when decimals or price are unavailable — never a fabricated zero. +func tokensToUSD(rawAmount string, decimals *int32, priceUSD *float64) *float64 { + if decimals == nil || priceUSD == nil { + return nil + } + raw, ok := new(big.Float).SetString(rawAmount) + if !ok { + return nil + } + scale := new(big.Float).SetFloat64(math.Pow10(int(*decimals))) + tokens := new(big.Float).Quo(raw, scale) + usd, _ := new(big.Float).Mul(tokens, big.NewFloat(*priceUSD)).Float64() + return &usd +} diff --git a/internal/services/positions_test.go b/internal/services/positions_test.go new file mode 100644 index 0000000..10fdf4f --- /dev/null +++ b/internal/services/positions_test.go @@ -0,0 +1,245 @@ +// ABOUTME: Tests for the positions service mapper: row filtering, earnings +// ABOUTME: conversion, null propagation, and the account-level aggregate. +package services + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stellar/freighter-backend-v2/internal/types" + "github.com/stellar/freighter-backend-v2/internal/utils" +) + +func f64(v float64) *float64 { return &v } +func str(s string) *string { return &s } +func i32(v int32) *int32 { return &v } + +// reserveFixture mirrors the shape observed on the live testnet dev instance +// (user account GDW6QB3B...): XLM held entirely as collateral with real +// earned interest, USDC as collateral, a dust wBTC borrow, and a fully-exited +// wETH row that must not become a display row. +func reserveFixture() []types.BlendReservePosition { + return []types.BlendReservePosition{ + { + AssetContractID: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + TokenSymbol: nil, // XLM SAC missing from the registry, observed live + TokenDecimals: i32(7), + SuppliedTokens: "0", + CollateralTokens: "67125489343", + BorrowedTokens: "0", + SuppliedUSD: f64(2819.270552406), + SupplyAPY: f64(3.240617830176194), + EmissionsAPR: f64(0), + InterestEarned: "2125489343", + EmissionsEarnedBLND: "0", + PriceUSD: f64(0.42), + }, + { + AssetContractID: "CCYM3TPDGQODFOC2OQDND6C7SKHO3TWD37CYN35I6K66JO5X3SUANEHN", + TokenSymbol: str("USDC"), + TokenDecimals: i32(7), + SuppliedTokens: "0", + CollateralTokens: "8000168408", + BorrowedTokens: "0", + SuppliedUSD: f64(800.0168408), + SupplyAPY: f64(0.00104137909709201), + InterestEarned: "168408", + EmissionsEarnedBLND: "0", + PriceUSD: f64(1), + }, + { + AssetContractID: "CBWBTCWBTCWBTCWBTCWBTCWBTCWBTCWBTCWBTCWBTCWBTCWBTCWBTC1", + TokenSymbol: str("wBTC"), + TokenDecimals: i32(7), + SuppliedTokens: "0", + CollateralTokens: "0", + BorrowedTokens: "2", + BorrowedUSD: f64(0.02), + BorrowAPY: f64(4.72998834498228), + InterestEarned: "0", + EmissionsEarnedBLND: "0", + PriceUSD: f64(100000), + }, + { + // Fully-exited row: upstream emits it for earnings history; it + // must not appear in either display list. + AssetContractID: "CBWETHWETHWETHWETHWETHWETHWETHWETHWETHWETHWETHWETHWETH1", + TokenSymbol: str("wETH"), + TokenDecimals: i32(7), + SuppliedTokens: "0", + CollateralTokens: "0", + BorrowedTokens: "0", + SuppliedUSD: f64(0), + InterestEarned: "0", + EmissionsEarnedBLND: "0", + PriceUSD: f64(4000), + }, + } +} + +func TestMapBlendDetailRows(t *testing.T) { + detail := mapBlendDetail(reserveFixture()) + + // wETH's all-zero row is filtered; XLM and USDC become supply rows. + require.Len(t, detail.Supply, 2) + require.Len(t, detail.Borrow, 1) + + xlm := detail.Supply[0] + assert.Nil(t, xlm.Symbol) // registry gap passes through; client truncates asset_id + assert.Equal(t, "0", xlm.SuppliedTokens) + assert.Equal(t, "67125489343", xlm.CollateralTokens) + assert.Equal(t, "67125489343", xlm.TotalTokens) + + // interest_earned_usd = raw / 10^decimals × price: + // 2125489343 / 1e7 × 0.42 = 89.27 (the live account's real figure). + require.NotNil(t, xlm.InterestEarnedUSD) + assert.InDelta(t, 89.2705524, *xlm.InterestEarnedUSD, 1e-4) + + usdc := detail.Supply[1] + require.NotNil(t, usdc.InterestEarnedUSD) + assert.InDelta(t, 0.0168408, *usdc.InterestEarnedUSD, 1e-9) + + wbtc := detail.Borrow[0] + assert.Equal(t, "2", wbtc.BorrowedTokens) + require.NotNil(t, wbtc.USDValue) + assert.InDelta(t, 0.02, *wbtc.USDValue, 1e-9) +} + +func TestMapBlendDetailNullSafety(t *testing.T) { + rows := []types.BlendReservePosition{{ + AssetContractID: "CUNPRICED", + TokenDecimals: nil, // no registry entry + SuppliedTokens: "100", + CollateralTokens: "0", + BorrowedTokens: "0", + SuppliedUSD: nil, // no oracle price + InterestEarned: "50", + EmissionsEarnedBLND: "0", + PriceUSD: nil, + }} + detail := mapBlendDetail(rows) + + require.Len(t, detail.Supply, 1) + row := detail.Supply[0] + assert.Nil(t, row.USDValue) + // Missing decimals/price make the USD conversion unavailable, not zero. + assert.Nil(t, row.InterestEarnedUSD) + // The raw token figures still pass through. + assert.Equal(t, "50", row.InterestEarned) + assert.Equal(t, "100", row.TotalTokens) +} + +func TestAccountAggregate(t *testing.T) { + pool := func(usd, apy *float64) types.BlendPoolPosition { + return types.BlendPoolPosition{USDValue: usd, NetAPY: apy} + } + + t.Run("weighted mean across pools", func(t *testing.T) { + total, apy := accountAggregate([]types.BlendPoolPosition{ + pool(f64(9000), f64(0.05)), + pool(f64(1000), f64(0.01)), + }) + require.NotNil(t, total) + assert.InDelta(t, 10000, *total, 1e-9) + require.NotNil(t, apy) + assert.InDelta(t, 0.046, *apy, 1e-9) // (9000×5% + 1000×1%) / 10000 + }) + + t.Run("strict null: one unpriced pool nulls the header", func(t *testing.T) { + total, apy := accountAggregate([]types.BlendPoolPosition{ + pool(f64(9000), f64(0.05)), + pool(nil, nil), + }) + assert.Nil(t, total) + assert.Nil(t, apy) + }) + + t.Run("null netApy nulls the rate but keeps the total", func(t *testing.T) { + total, apy := accountAggregate([]types.BlendPoolPosition{ + pool(f64(9000), f64(0.05)), + pool(f64(1000), nil), + }) + require.NotNil(t, total) + assert.InDelta(t, 10000, *total, 1e-9) + assert.Nil(t, apy) + }) + + t.Run("no positions is a genuine zero, apy null", func(t *testing.T) { + total, apy := accountAggregate(nil) + require.NotNil(t, total) + assert.Equal(t, 0.0, *total) + assert.Nil(t, apy) + }) + + t.Run("zero net base yields null apy, zero total", func(t *testing.T) { + total, apy := accountAggregate([]types.BlendPoolPosition{ + pool(f64(0), f64(0.05)), + }) + require.NotNil(t, total) + assert.Equal(t, 0.0, *total) + assert.Nil(t, apy) + }) +} + +func TestGetAccountPositionsMapsAndPassesThrough(t *testing.T) { + name := "TestnetV2" + mockWB := &utils.MockWalletBackendService{ + GetBlendPositionsResult: &types.BlendAccountPositions{ + Pools: []types.BlendPoolPosition{{ + PoolAddress: "CCEBVDYMCCECIVWVOJSKUNLTVDIRLTRUCVZDVLKXKQZWSCF3DVQGJVIX", + PoolName: &name, + USDValue: f64(3619.267393206), + SuppliedUSD: f64(3619.287393206), + BorrowedUSD: f64(0.02), + NetAPY: f64(2.5245032003462415), + Reserves: reserveFixture(), + }}, + }, + } + svc := NewPositionsService(mockWB, nil, 0, nil) + + got, err := svc.GetAccountPositions(context.Background(), "GDW6QB3BFPQ3I4LH752JD2HYADFM2T4RVRCEUNCCH7MICWZR67NL5552", types.TESTNET) + require.NoError(t, err) + + require.Len(t, got.Positions, 1) + row := got.Positions[0] + assert.Equal(t, "blend", row.Protocol) + assert.Equal(t, "CCEBVDYMCCECIVWVOJSKUNLTVDIRLTRUCVZDVLKXKQZWSCF3DVQGJVIX", row.ID) + require.NotNil(t, row.Name) + assert.Equal(t, "TestnetV2", *row.Name) + require.NotNil(t, row.NetUSD) + assert.InDelta(t, 3619.267393206, *row.NetUSD, 1e-9) + require.NotNil(t, row.Blend) + assert.Len(t, row.Blend.Supply, 2) + assert.Len(t, row.Blend.Borrow, 1) + + // Single pool: the header mirrors the pool figures. + require.NotNil(t, got.TotalValueUSD) + assert.InDelta(t, 3619.267393206, *got.TotalValueUSD, 1e-9) + require.NotNil(t, got.NetAPY) + assert.InDelta(t, 2.5245032003462415, *got.NetAPY, 1e-9) +} + +func TestGetAccountPositionsEmptyAccount(t *testing.T) { + svc := NewPositionsService(&utils.MockWalletBackendService{}, nil, 0, nil) + + got, err := svc.GetAccountPositions(context.Background(), "GDW6QB3BFPQ3I4LH752JD2HYADFM2T4RVRCEUNCCH7MICWZR67NL5552", types.TESTNET) + require.NoError(t, err) + assert.NotNil(t, got.Positions) + assert.Empty(t, got.Positions) + require.NotNil(t, got.TotalValueUSD) + assert.Equal(t, 0.0, *got.TotalValueUSD) + assert.Nil(t, got.NetAPY) +} + +func TestGetAccountPositionsUpstreamError(t *testing.T) { + upErr := errors.New("wallet-backend on fire") + svc := NewPositionsService(&utils.MockWalletBackendService{GetBlendPositionsError: upErr}, nil, 0, nil) + + _, err := svc.GetAccountPositions(context.Background(), "GDW6QB3BFPQ3I4LH752JD2HYADFM2T4RVRCEUNCCH7MICWZR67NL5552", types.TESTNET) + assert.ErrorIs(t, err, upErr) +} diff --git a/internal/types/positions.go b/internal/types/positions.go new file mode 100644 index 0000000..dbdeb8a --- /dev/null +++ b/internal/types/positions.go @@ -0,0 +1,117 @@ +// ABOUTME: Response types for GET /api/v1/accounts/{address}/positions — the +// ABOUTME: frontend-shaped view of an account's DeFi positions (Blend only today). +package types + +import "context" + +// AccountPositions is the response body for the account positions endpoint. +// One payload powers both the Position Home screen (header + per-pool rows) +// and the Position Details screen (per-asset rows inside each pool): an +// account holds positions in at most a handful of pools, so full detail is +// always returned. +// +// Number conventions follow the wallet-backend upstream: USD/APY values are +// nullable JSON numbers where null means "unavailable" (no fresh oracle +// price), never zero; a genuinely zero value is 0. On-chain token amounts +// are full-precision integer strings in the asset's smallest unit (scale by +// Decimals for display). +type AccountPositions struct { + // TotalValueUSD is the account's net position value across pools + // (Σ pool NetUSD). Strict null propagation: if any pool's value is + // unavailable the total is null rather than a silent undercount — + // matching upstream's own convention for pool totals. 0 when the + // account has no positions. + TotalValueUSD *float64 `json:"total_value_usd"` + // NetAPY is the NetUSD-weighted mean of the pools' net APYs; null when + // any input is unavailable or the account has no priced value to weight. + NetAPY *float64 `json:"net_apy"` + // Positions has one row per (protocol, pool). Always non-nil; empty when + // the account has no DeFi positions (including accounts unknown to the + // indexer — indistinguishable by design). + Positions []PoolPosition `json:"positions"` +} + +// PoolPosition is one pool row. The common fields render a Position Home row +// for any protocol; protocol-specific detail lives under a key named after +// the protocol (only "blend" today), so adding a protocol later is additive. +type PoolPosition struct { + Protocol string `json:"protocol"` + // ID is the pool's contract address. + ID string `json:"id"` + // Name is the pool's display name; null when the upstream metadata + // registry has no entry (clients fall back to a truncated ID). + Name *string `json:"name"` + // NetUSD is supplied minus borrowed for this pool. + NetUSD *float64 `json:"net_usd"` + SuppliedUSD *float64 `json:"supplied_usd"` + BorrowedUSD *float64 `json:"borrowed_usd"` + // NetAPY is the account's net rate in this pool, as computed upstream. + NetAPY *float64 `json:"net_apy"` + Blend *BlendPositionDetail `json:"blend,omitempty"` +} + +// BlendPositionDetail is the Blend-specific detail for one pool. Reserve +// rows with no current balance on either side are filtered out (upstream +// emits fully-exited rows to carry realized-earnings history; the display +// list only shows live positions). +type BlendPositionDetail struct { + // Supply has one row per asset the account deposits (plain supply and + // supply-as-collateral combined; the split is preserved per row). + Supply []BlendSupplyRow `json:"supply"` + // Borrow has one row per asset the account owes. + Borrow []BlendBorrowRow `json:"borrow"` +} + +// BlendSupplyRow is one asset the account supplies in a pool. +type BlendSupplyRow struct { + // AssetID is the asset's contract address. + AssetID string `json:"asset_id"` + // Symbol/Name/Decimals are nullable registry metadata. + Symbol *string `json:"symbol"` + Name *string `json:"name"` + Decimals *int32 `json:"decimals"` + // SuppliedTokens is the plain-supply portion, CollateralTokens the + // portion posted as collateral; TotalTokens is their sum. All raw units. + SuppliedTokens string `json:"supplied_tokens"` + CollateralTokens string `json:"collateral_tokens"` + TotalTokens string `json:"total_tokens"` + // USDValue is the current USD value of TotalTokens. + USDValue *float64 `json:"usd_value"` + // APY is the current supply interest rate; EmissionsAPR is the BLND + // emission rate on the supply side. + APY *float64 `json:"apy"` + EmissionsAPR *float64 `json:"emissions_apr"` + // InterestEarned is lifetime interest in raw token units (pure + // interest: token-denominated upstream, so asset price movement never + // contaminates it). InterestEarnedUSD converts it at today's price; + // null when the price or decimals are unavailable. + InterestEarned string `json:"interest_earned"` + InterestEarnedUSD *float64 `json:"interest_earned_usd"` + // ClaimableBLND is uncollected BLND emissions in raw units; + // ClaimableUSD is its upstream-computed USD value. + ClaimableBLND string `json:"claimable_blnd"` + ClaimableUSD *float64 `json:"claimable_usd"` + // PriceUSD is the pool oracle's per-unit price for this asset. + PriceUSD *float64 `json:"price_usd"` +} + +// BlendBorrowRow is one asset the account borrows in a pool. +type BlendBorrowRow struct { + AssetID string `json:"asset_id"` + Symbol *string `json:"symbol"` + Name *string `json:"name"` + Decimals *int32 `json:"decimals"` + // BorrowedTokens is the debt in raw token units. + BorrowedTokens string `json:"borrowed_tokens"` + // USDValue is the current USD value of the debt. + USDValue *float64 `json:"usd_value"` + // APY is the current borrow interest rate. + APY *float64 `json:"apy"` + PriceUSD *float64 `json:"price_usd"` +} + +// PositionsService assembles the account positions view. +type PositionsService interface { + Service + GetAccountPositions(ctx context.Context, address, network string) (*AccountPositions, error) +} diff --git a/internal/utils/mocks.go b/internal/utils/mocks.go index 9bf96ea..474c3a6 100644 --- a/internal/utils/mocks.go +++ b/internal/utils/mocks.go @@ -197,6 +197,28 @@ func (m *MockWalletBackendService) GetBlendEarnOptions(ctx context.Context, netw return []types.BlendEarnOption{}, nil } +// MockPositionsService stubs types.PositionsService for handler tests. +type MockPositionsService struct { + GetAccountPositionsResult *types.AccountPositions + GetAccountPositionsError error + GetAccountPositionsFunc func(ctx context.Context, address, network string) (*types.AccountPositions, error) +} + +func (m *MockPositionsService) Name() string { return "mock-positions" } + +func (m *MockPositionsService) GetAccountPositions(ctx context.Context, address, network string) (*types.AccountPositions, error) { + if m.GetAccountPositionsFunc != nil { + return m.GetAccountPositionsFunc(ctx, address, network) + } + if m.GetAccountPositionsError != nil { + return nil, m.GetAccountPositionsError + } + if m.GetAccountPositionsResult != nil { + return m.GetAccountPositionsResult, nil + } + return &types.AccountPositions{Positions: []types.PoolPosition{}}, nil +} + type MockPricesService struct { GetPricesFunc func(ctx context.Context, tokens []string, network string) (map[string]*types.PriceEntry, error) GetPricesOverride map[string]*types.PriceEntry From 4f89c53a52411119aa62c6ad6413e1a3df34a9cc Mon Sep 17 00:00:00 2001 From: jiahuihu Date: Wed, 22 Jul 2026 15:41:19 -0400 Subject: [PATCH 04/15] feat(api): add Blend market catalog endpoints (pools, earn options) --- internal/api/handlers/blend_catalog.go | 61 ++++++ internal/api/handlers/blend_catalog_test.go | 87 ++++++++ internal/api/serve.go | 14 ++ internal/services/blend_catalog.go | 228 ++++++++++++++++++++ internal/services/blend_catalog_test.go | 178 +++++++++++++++ internal/types/blend_catalog.go | 97 +++++++++ internal/utils/mocks.go | 31 +++ 7 files changed, 696 insertions(+) create mode 100644 internal/api/handlers/blend_catalog.go create mode 100644 internal/api/handlers/blend_catalog_test.go create mode 100644 internal/services/blend_catalog.go create mode 100644 internal/services/blend_catalog_test.go create mode 100644 internal/types/blend_catalog.go 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 8f663e3..e757d11 100644 --- a/internal/api/serve.go +++ b/internal/api/serve.go @@ -230,6 +230,18 @@ func (s *ApiServer) routes() ([]route, error) { ) accountPositionsHandler := handlers.NewAccountPositionsHandler(positionsService) + 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 @@ -252,6 +264,8 @@ func (s *ApiServer) routes() ([]route, error) { {http.MethodPost, "/api/v1/token-prices", handlers.CustomHandler(tokenPricesHandler.GetPrices), true}, {http.MethodGet, "/api/v1/accounts/{address}/transactions", handlers.CustomHandler(accountHistoryHandler.GetAccountTransactions), true}, {http.MethodGet, "/api/v1/accounts/{address}/positions", handlers.CustomHandler(accountPositionsHandler.GetAccountPositions), true}, + {http.MethodGet, "/api/v1/protocols/blend/pools", handlers.CustomHandler(blendCatalogHandler.GetPools), true}, + {http.MethodGet, "/api/v1/protocols/blend/earn-options", handlers.CustomHandler(blendCatalogHandler.GetEarnOptions), true}, {http.MethodGet, "/api/v1/auth/whoami", handlers.CustomHandler(whoamiHandler.Whoami), true}, }, nil } diff --git a/internal/services/blend_catalog.go b/internal/services/blend_catalog.go new file mode 100644 index 0000000..382d5f0 --- /dev/null +++ b/internal/services/blend_catalog.go @@ -0,0 +1,228 @@ +// ABOUTME: Blend market-catalog service: pool and earn-option views from +// ABOUTME: wallet-backend, with per-network caching and earn-pool curation. +package services + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + "time" + + "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" + blendEarnCacheKeyPrefix = "blend:earn: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 returns the earn catalog, allowlist-filtered and cached per +// network (the cache stores the post-filter result). +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) + }() + + cacheKey := fmt.Sprintf("%s:%s", blendEarnCacheKeyPrefix, strings.ToLower(network)) + if cached, ok := cacheGet[types.BlendEarnOptionsCatalog](ctx, b.redis, cacheKey); ok { + return cached, nil + } + + options, err := b.walletBackend.GetBlendEarnOptions(ctx, network) + if err != nil { + return nil, err + } + + result := &types.BlendEarnOptionsCatalog{Options: mapEarnOptions(options, b.allowlist[strings.ToUpper(network)])} + cacheSet(ctx, b.redis, cacheKey, result, b.cacheTTL) + return result, nil +} + +func mapCatalogPools(pools []types.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: p.Status, + SuppliedUSD: p.SuppliedUSD, + BorrowedUSD: p.BorrowedUSD, + InterestAPY: p.InterestAPY, + NetAPY: p.NetAPY, + Reserves: reserves, + }) + } + return out +} + +// mapEarnOptions shapes the earn catalog, dropping pools outside the +// allowlist (when one is configured) and assets left with no pools. +func mapEarnOptions(options []types.BlendEarnOption, allowed map[string]bool) []types.BlendEarnAssetOption { + out := make([]types.BlendEarnAssetOption, 0, len(options)) + for _, option := range options { + pools := make([]types.BlendEarnPool, 0, len(option.Pools)) + for _, p := range option.Pools { + if allowed != nil && !allowed[p.PoolAddress] { + continue + } + pools = append(pools, types.BlendEarnPool{ + ID: p.PoolAddress, + Name: p.PoolName, + SupplyAPY: p.SupplyAPY, + EmissionsSupplyAPR: p.EmissionsSupplyAPR, + SuppliedUSD: p.SuppliedUSD, + }) + } + if len(pools) == 0 { + continue + } + out = append(out, types.BlendEarnAssetOption{ + AssetID: option.AssetContractID, + Symbol: option.TokenSymbol, + Name: option.TokenName, + Decimals: option.TokenDecimals, + Pools: pools, + }) + } + return out +} + +// 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..c99cfcf --- /dev/null +++ b/internal/services/blend_catalog_test.go @@ -0,0 +1,178 @@ +// ABOUTME: Tests for the Blend catalog service: allowlist loading/curation, +// ABOUTME: catalog mapping passthrough, and error propagation. +package services + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stellar/freighter-backend-v2/internal/types" + "github.com/stellar/freighter-backend-v2/internal/utils" +) + +const ( + curatedPool = "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD" + uncuratedPool = "CCCCIQSDILITHMM7PBSLVDT5MISSY7R26MNZXCX4H7J5JQ5FPIYOGYFS" +) + +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 earnOptionsFixture() []types.BlendEarnOption { + usdc, xlm := "USDC", "XLM" + return []types.BlendEarnOption{ + { + AssetContractID: "CUSDC", + TokenSymbol: &usdc, + Pools: []types.BlendEarnPoolOption{ + {PoolAddress: curatedPool, SupplyAPY: f64(0.043)}, + {PoolAddress: uncuratedPool, SupplyAPY: f64(0.032)}, + }, + }, + { + // Every pool for this asset is uncurated: the asset must drop. + AssetContractID: "CXLM", + TokenSymbol: &xlm, + Pools: []types.BlendEarnPoolOption{ + {PoolAddress: uncuratedPool, SupplyAPY: f64(0.001)}, + }, + }, + } +} + +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 TestGetEarnOptionsCuration(t *testing.T) { + mockWB := &utils.MockWalletBackendService{GetBlendEarnOptionsResult: earnOptionsFixture()} + + t.Run("allowlist filters pools and drops emptied assets", func(t *testing.T) { + path := writeAllowlist(t, `{"TESTNET": ["`+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) + + // XLM (only uncurated pools) is gone; USDC keeps only the curated pool. + require.Len(t, got.Options, 1) + assert.Equal(t, "CUSDC", got.Options[0].AssetID) + require.Len(t, got.Options[0].Pools, 1) + assert.Equal(t, curatedPool, got.Options[0].Pools[0].ID) + }) + + t.Run("no allowlist passes everything through", 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) + require.Len(t, got.Options, 2) + assert.Len(t, got.Options[0].Pools, 2) + }) + + t.Run("allowlist for another network filters everything", 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) + // TESTNET has no allowlist entry -> allowed set is nil for that + // network -> no curation applies there. + assert.Len(t, got.Options, 2) + }) +} + +func TestGetPoolsMapping(t *testing.T) { + name := "Fixed Pool V2" + usdc := "USDC" + mockWB := &utils.MockWalletBackendService{ + GetBlendPoolsResult: []types.BlendPool{{ + Address: curatedPool, + Name: &name, + Status: i32(types.BlendPoolStatusActive), + SuppliedUSD: f64(2100000.5), + InterestAPY: f64(0.043), + NetAPY: f64(0.047), + Reserves: []types.BlendReserve{{ + AssetContractID: "CUSDC", + TokenSymbol: &usdc, + Enabled: true, + Utilization: f64(0.62), + SupplyAPY: f64(0.043), + EmissionsSupplyAPR: f64(0.008), + PriceUSD: f64(1.0), + }}, + }, { + // Not-yet-ingested pool: everything null. + Address: uncuratedPool, + Reserves: []types.BlendReserve{}, + }}, + } + svc, err := NewBlendCatalogService(mockWB, nil, 0, "", nil) + require.NoError(t, err) + + got, err := svc.GetPools(context.Background(), types.TESTNET) + require.NoError(t, err) + + require.Len(t, got.Pools, 2) + pool := got.Pools[0] + assert.Equal(t, curatedPool, pool.ID) + require.NotNil(t, pool.Status) + assert.Equal(t, types.BlendPoolStatusActive, *pool.Status) + require.Len(t, pool.Reserves, 1) + assert.True(t, pool.Reserves[0].Enabled) + require.NotNil(t, pool.Reserves[0].EmissionsSupplyAPR) + assert.InDelta(t, 0.008, *pool.Reserves[0].EmissionsSupplyAPR, 1e-9) + + // The pools catalog is never allowlist-filtered. + assert.Equal(t, uncuratedPool, got.Pools[1].ID) + assert.Nil(t, got.Pools[1].Status) + assert.NotNil(t, got.Pools[1].Reserves) +} + +func TestCatalogUpstreamErrors(t *testing.T) { + upErr := errors.New("wallet-backend down") + svc, err := NewBlendCatalogService(&utils.MockWalletBackendService{ + GetBlendPoolsError: upErr, + GetBlendEarnOptionsError: upErr, + }, nil, 0, "", nil) + require.NoError(t, err) + + _, err = svc.GetPools(context.Background(), types.TESTNET) + assert.ErrorIs(t, err, upErr) + _, 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..39bc3c9 --- /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 raw on-chain pool status (0 Admin Active, 1 Active, + // 2 Admin On-Ice, 3 On-Ice, 4 Admin Frozen, 5 Frozen, 6 Setup; + // 0-3 accept deposits, 0-1 also allow borrowing). Null until the pool's + // config has been ingested. + Status *int32 `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. Upstream already excludes disabled reserves and +// pools that reject deposits; freighter additionally filters pools 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 upstream (supplied USD descending). 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 474c3a6..f718471 100644 --- a/internal/utils/mocks.go +++ b/internal/utils/mocks.go @@ -219,6 +219,37 @@ func (m *MockPositionsService) GetAccountPositions(ctx context.Context, address, return &types.AccountPositions{Positions: []types.PoolPosition{}}, 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 From 143bfb8073e28157c17273e22bd73819ff1bce02 Mon Sep 17 00:00:00 2001 From: jiahuihu Date: Wed, 22 Jul 2026 15:49:18 -0400 Subject: [PATCH 05/15] test(serve): cover Blend cache-TTL startup validation --- cmd/serve/serve_test.go | 52 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/cmd/serve/serve_test.go b/cmd/serve/serve_test.go index 3dc7074..8729106 100644 --- a/cmd/serve/serve_test.go +++ b/cmd/serve/serve_test.go @@ -188,6 +188,58 @@ func TestServeCmd_RejectsNegativePriceFetchTimeout(t *testing.T) { assert.Contains(t, err.Error(), "--price-fetch-timeout-seconds=-1 must be >= 0") } +func TestServeCmd_ValidatesBlendCacheTTLs(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + args []string + wantErr string + }{ + { + name: "rejects negative positions cache TTL", + args: []string{"--blend-positions-cache-ttl-seconds", "-1"}, + wantErr: "--blend-positions-cache-ttl-seconds=-1 must be >= 0", + }, + { + name: "rejects negative catalog cache TTL", + args: []string{"--blend-catalog-cache-ttl-seconds", "-30"}, + wantErr: "--blend-catalog-cache-ttl-seconds=-30 must be >= 0", + }, + { + // Zero is the documented boundary: accepted by validation + // (consumers substitute their own defaults for non-positive TTLs). + name: "accepts zero for both TTLs", + args: []string{ + "--blend-positions-cache-ttl-seconds", "0", + "--blend-catalog-cache-ttl-seconds", "0", + "--database-url", "postgres://localhost/test", + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + serveCmd := &ServeCmd{Cfg: &config.Config{}} + cmd := serveCmd.Command() + cmd.RunE = func(*cobra.Command, []string) error { return nil } + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs(tc.args) + + err := cmd.Execute() + if tc.wantErr == "" { + require.NoError(t, err) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + }) + } +} + func TestServeCmd_RejectsAccountHistoryMaxLimitAbove100(t *testing.T) { t.Parallel() From bb1d01241463e4d1b90297a0d16f0a19c7a6f289 Mon Sep 17 00:00:00 2001 From: jiahuihu Date: Thu, 23 Jul 2026 17:26:44 -0400 Subject: [PATCH 06/15] feat(services): adopt latest Blend schema (split emissions, status enum, drop earn options) --- internal/services/wallet_backend_blend.go | 43 +--------- .../wallet_backend_blend_live_test.go | 3 - .../services/wallet_backend_blend_test.go | 39 +++------ internal/types/blend.go | 83 ++++++++----------- internal/types/interfaces.go | 3 - internal/utils/mocks.go | 13 --- 6 files changed, 48 insertions(+), 136 deletions(-) diff --git a/internal/services/wallet_backend_blend.go b/internal/services/wallet_backend_blend.go index c32a51f..55eb85a 100644 --- a/internal/services/wallet_backend_blend.go +++ b/internal/services/wallet_backend_blend.go @@ -1,5 +1,5 @@ -// ABOUTME: Blend GraphQL queries against wallet-backend: account positions, the -// ABOUTME: pool catalog, and earn options, via a hand-rolled signed GraphQL POST. +// ABOUTME: Blend GraphQL queries against wallet-backend: account positions and +// ABOUTME: the pool catalog, via a hand-rolled signed GraphQL POST. package services import ( @@ -50,7 +50,8 @@ const ( borrowedUsd supplyApy borrowApy - emissionsApr + emissionsSupplyApr + emissionsBorrowApr interestEarned emissionsEarnedBlnd emissionsEarnedUsd @@ -86,22 +87,6 @@ const ( } } }` - - blendEarnOptionsQuery = `query FreighterBlendEarnOptions { - blendEarnOptions { - assetContractId - tokenName - tokenSymbol - tokenDecimals - pools { - poolAddress - poolName - supplyApy - emissionsSupplyApr - suppliedUsd - } - } -}` ) // Root-field wrappers for each query document. @@ -115,10 +100,6 @@ type blendPoolsData struct { BlendPools []types.BlendPool `json:"blendPools"` } -type blendEarnOptionsData struct { - BlendEarnOptions []types.BlendEarnOption `json:"blendEarnOptions"` -} - // GetBlendPositions returns the account's Blend positions. accountByAddress // resolving to null (an account wallet-backend has never indexed) returns // empty positions rather than an error: for this read, "unknown account" and @@ -156,22 +137,6 @@ func (w *walletBackendService) GetBlendPools(ctx context.Context, network string return data.BlendPools, nil } -// GetBlendEarnOptions returns the asset-first earn catalog. Always a non-nil -// slice. -func (w *walletBackendService) GetBlendEarnOptions(ctx context.Context, network string) (_ []types.BlendEarnOption, err error) { - start := time.Now() - defer func() { w.recordWBCall("GetBlendEarnOptions", network, start, err) }() - - data, err := wbGraphQL[blendEarnOptionsData](ctx, w, network, "GetBlendEarnOptions", blendEarnOptionsQuery, nil) - if err != nil { - return nil, err - } - if data.BlendEarnOptions == nil { - return []types.BlendEarnOption{}, nil - } - return data.BlendEarnOptions, nil -} - // wbGraphQL executes one GraphQL document against the network's // wallet-backend and unmarshals the response's data into T. It mirrors // wbclient.Client.request (same path, JWT signing, and error vocabulary: diff --git a/internal/services/wallet_backend_blend_live_test.go b/internal/services/wallet_backend_blend_live_test.go index 1d1a4f1..2639ba8 100644 --- a/internal/services/wallet_backend_blend_live_test.go +++ b/internal/services/wallet_backend_blend_live_test.go @@ -46,9 +46,6 @@ func TestLiveBlendQueries(t *testing.T) { pools, err := svc.GetBlendPools(ctx, "TESTNET") dump("blend_pools", pools, err) - options, err := svc.GetBlendEarnOptions(ctx, "TESTNET") - dump("blend_earn_options", options, err) - address := os.Getenv("WB_LIVE_ADDRESS") if address == "" { address = "GDW6QB3BFPQ3I4LH752JD2HYADFM2T4RVRCEUNCCH7MICWZR67NL5552" diff --git a/internal/services/wallet_backend_blend_test.go b/internal/services/wallet_backend_blend_test.go index 43025b4..b719f14 100644 --- a/internal/services/wallet_backend_blend_test.go +++ b/internal/services/wallet_backend_blend_test.go @@ -72,7 +72,8 @@ func TestGetBlendPositionsDecode(t *testing.T) { "borrowedUsd": 495221.33, "supplyApy": 0.0741, "borrowApy": 0.1151, - "emissionsApr": 0.002, + "emissionsSupplyApr": 0.002, + "emissionsBorrowApr": 0.001, "interestEarned": "6843215", "emissionsEarnedBlnd": "12345678", "emissionsEarnedUsd": 0.53, @@ -90,7 +91,8 @@ func TestGetBlendPositionsDecode(t *testing.T) { "borrowedUsd": null, "supplyApy": null, "borrowApy": null, - "emissionsApr": null, + "emissionsSupplyApr": null, + "emissionsBorrowApr": null, "interestEarned": "0", "emissionsEarnedBlnd": "0", "emissionsEarnedUsd": null, @@ -125,10 +127,15 @@ func TestGetBlendPositionsDecode(t *testing.T) { assert.Equal(t, "6843215", priced.InterestEarned) require.NotNil(t, priced.SupplyAPY) assert.InDelta(t, 0.0741, *priced.SupplyAPY, 1e-9) + require.NotNil(t, priced.EmissionsSupplyAPR) + assert.InDelta(t, 0.002, *priced.EmissionsSupplyAPR, 1e-9) + require.NotNil(t, priced.EmissionsBorrowAPR) + assert.InDelta(t, 0.001, *priced.EmissionsBorrowAPR, 1e-9) // Null Floats and null registry metadata decode to nil, not zero. assert.Nil(t, unpriced.SuppliedUSD) assert.Nil(t, unpriced.SupplyAPY) + assert.Nil(t, unpriced.EmissionsSupplyAPR) assert.Nil(t, unpriced.PriceUSD) assert.Nil(t, unpriced.TokenSymbol) assert.Nil(t, unpriced.TokenDecimals) @@ -156,7 +163,7 @@ func TestGetBlendPoolsDecode(t *testing.T) { { "address": "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD", "name": null, - "status": 1, + "status": "ACTIVE", "suppliedUsd": 2100000.5, "borrowedUsd": 900000.25, "interestApy": 0.043, @@ -207,32 +214,6 @@ func TestGetBlendPoolsDecode(t *testing.T) { assert.Empty(t, pools[1].Reserves) } -func TestGetBlendEarnOptionsDecode(t *testing.T) { - svc := newBlendTestService(t, func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write(graphqlEnvelope(t, `{ - "blendEarnOptions": [{ - "assetContractId": "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75", - "tokenName": "USD Coin", - "tokenSymbol": "USDC", - "tokenDecimals": 7, - "pools": [ - {"poolAddress": "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD", "poolName": "Fixed Pool V2", "supplyApy": 0.043, "emissionsSupplyApr": 0.008, "suppliedUsd": 1500000.0}, - {"poolAddress": "CCCCIQSDILITHMM7PBSLVDT5MISSY7R26MNZXCX4H7J5JQ5FPIYOGYFS", "poolName": null, "supplyApy": 0.032, "emissionsSupplyApr": null, "suppliedUsd": null} - ] - }] - }`)) - }) - - options, err := svc.GetBlendEarnOptions(context.Background(), types.TESTNET) - require.NoError(t, err) - - require.Len(t, options, 1) - require.Len(t, options[0].Pools, 2) - require.NotNil(t, options[0].Pools[0].EmissionsSupplyAPR) - assert.InDelta(t, 0.008, *options[0].Pools[0].EmissionsSupplyAPR, 1e-9) - assert.Nil(t, options[0].Pools[1].EmissionsSupplyAPR) -} - func TestBlendGraphQLErrorClassification(t *testing.T) { t.Run("GraphQL errors array becomes graphql_error", func(t *testing.T) { svc := newBlendTestService(t, func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/types/blend.go b/internal/types/blend.go index 55b0a2e..96cad75 100644 --- a/internal/types/blend.go +++ b/internal/types/blend.go @@ -1,5 +1,5 @@ -// ABOUTME: Decode types for wallet-backend's Blend v2 GraphQL surface (positions, -// ABOUTME: pool catalog, earn options), mirroring blend.graphqls field for field. +// ABOUTME: Decode types for wallet-backend's Blend v2 GraphQL surface (positions +// ABOUTME: and pool catalog), mirroring blend.graphqls field for field. package types // Conventions, from the wallet-backend schema (blend.graphqls): @@ -23,8 +23,10 @@ type BlendAccountPositions struct { } // BlendPoolPosition rolls up an account's reserve positions within one pool. -// USDValue is supplied minus borrowed; NetAPY is the account's net rate for -// this pool, netted against borrow interest. +// USDValue is supplied minus borrowed. NetAPY nets supply earnings against +// borrow interest over TOTAL SUPPLIED USD — the blend-sdk-js convention the +// Blend UI shows: (Σ supplied·supplyApy − Σ borrowed·borrowApy) / Σ supplied; +// 0 for a debt-only position, null when any reserve lacks a fresh price. type BlendPoolPosition struct { PoolAddress string `json:"poolAddress"` PoolName *string `json:"poolName"` @@ -42,18 +44,23 @@ type BlendPoolPosition struct { // adjust the basis so the figure stays interest-only). EmissionsEarnedBLND // is claimable (uncollected) BLND across the reserve's emission streams. type BlendReservePosition struct { - AssetContractID string `json:"assetContractId"` - TokenName *string `json:"tokenName"` - TokenSymbol *string `json:"tokenSymbol"` - TokenDecimals *int32 `json:"tokenDecimals"` - SuppliedTokens string `json:"suppliedTokens"` - CollateralTokens string `json:"collateralTokens"` - BorrowedTokens string `json:"borrowedTokens"` - SuppliedUSD *float64 `json:"suppliedUsd"` - BorrowedUSD *float64 `json:"borrowedUsd"` - SupplyAPY *float64 `json:"supplyApy"` - BorrowAPY *float64 `json:"borrowApy"` - EmissionsAPR *float64 `json:"emissionsApr"` + AssetContractID string `json:"assetContractId"` + TokenName *string `json:"tokenName"` + TokenSymbol *string `json:"tokenSymbol"` + TokenDecimals *int32 `json:"tokenDecimals"` + SuppliedTokens string `json:"suppliedTokens"` + CollateralTokens string `json:"collateralTokens"` + BorrowedTokens string `json:"borrowedTokens"` + SuppliedUSD *float64 `json:"suppliedUsd"` + BorrowedUSD *float64 `json:"borrowedUsd"` + SupplyAPY *float64 `json:"supplyApy"` + BorrowAPY *float64 `json:"borrowApy"` + // EmissionsSupplyAPR / EmissionsBorrowAPR are the reserve's POOL-WIDE + // per-side emission-stream APRs (not scaled to this account's holding): + // 0 when the side has no active stream, null when the stream is active + // but a price is unavailable. + EmissionsSupplyAPR *float64 `json:"emissionsSupplyApr"` + EmissionsBorrowAPR *float64 `json:"emissionsBorrowApr"` InterestEarned string `json:"interestEarned"` EmissionsEarnedBLND string `json:"emissionsEarnedBlnd"` EmissionsEarnedUSD *float64 `json:"emissionsEarnedUsd"` @@ -69,7 +76,7 @@ type BlendReservePosition struct { type BlendPool struct { Address string `json:"address"` Name *string `json:"name"` - Status *int32 `json:"status"` + Status *string `json:"status"` SuppliedUSD *float64 `json:"suppliedUsd"` BorrowedUSD *float64 `json:"borrowedUsd"` InterestAPY *float64 `json:"interestApy"` @@ -77,17 +84,17 @@ type BlendPool struct { Reserves []BlendReserve `json:"reserves"` } -// Blend on-chain pool status values (BlendPool.Status). 0-3 accept supply -// (deposits); 0-1 also allow borrowing; 4-6 reject both. Status is null -// until the pool's config entry has been ingested. +// BlendPoolStatus enum values (BlendPool.Status). The first four accept +// supply (deposits); the first two also allow borrowing; the rest reject +// both. Status is null until the pool's config entry has been ingested. const ( - BlendPoolStatusAdminActive int32 = 0 - BlendPoolStatusActive int32 = 1 - BlendPoolStatusAdminOnIce int32 = 2 - BlendPoolStatusOnIce int32 = 3 - BlendPoolStatusAdminFrozen int32 = 4 - BlendPoolStatusFrozen int32 = 5 - BlendPoolStatusSetup int32 = 6 + BlendPoolStatusAdminActive = "ADMIN_ACTIVE" + BlendPoolStatusActive = "ACTIVE" + BlendPoolStatusAdminOnIce = "ADMIN_ON_ICE" + BlendPoolStatusOnIce = "ON_ICE" + BlendPoolStatusAdminFrozen = "ADMIN_FROZEN" + BlendPoolStatusFrozen = "FROZEN" + BlendPoolStatusSetup = "SETUP" ) // BlendReserve is a pool-wide reserve catalog row: rates and totals as of @@ -106,25 +113,3 @@ type BlendReserve struct { BorrowedUSD *float64 `json:"borrowedUsd"` PriceUSD *float64 `json:"priceUsd"` } - -// BlendEarnOption is one entry of Query.blendEarnOptions: an asset with at -// least one enabled reserve in a pool that currently accepts supply. -// Upstream already excludes disabled reserves and supply-rejecting pools -// (status >= 4 or not yet ingested). -type BlendEarnOption struct { - AssetContractID string `json:"assetContractId"` - TokenName *string `json:"tokenName"` - TokenSymbol *string `json:"tokenSymbol"` - TokenDecimals *int32 `json:"tokenDecimals"` - Pools []BlendEarnPoolOption `json:"pools"` -} - -// BlendEarnPoolOption is one pool's offer for an earn option's asset. -// SupplyAPY + EmissionsSupplyAPR is the emissions-inclusive earn headline. -type BlendEarnPoolOption struct { - PoolAddress string `json:"poolAddress"` - PoolName *string `json:"poolName"` - SupplyAPY *float64 `json:"supplyApy"` - EmissionsSupplyAPR *float64 `json:"emissionsSupplyApr"` - SuppliedUSD *float64 `json:"suppliedUsd"` -} diff --git a/internal/types/interfaces.go b/internal/types/interfaces.go index c7f94ac..4705972 100644 --- a/internal/types/interfaces.go +++ b/internal/types/interfaces.go @@ -56,9 +56,6 @@ type WalletBackendService interface { GetBlendPositions(ctx context.Context, address, network string) (*BlendAccountPositions, error) // GetBlendPools returns the pool-wide Blend catalog (no account data). GetBlendPools(ctx context.Context, network string) ([]BlendPool, error) - // GetBlendEarnOptions returns the asset-first earn catalog, pre-filtered - // upstream to enabled reserves in supply-accepting pools. - GetBlendEarnOptions(ctx context.Context, network string) ([]BlendEarnOption, error) } // StellarExpertAsset is the subset of the Stellar Expert /asset/{id} response diff --git a/internal/utils/mocks.go b/internal/utils/mocks.go index 9bf96ea..e64170f 100644 --- a/internal/utils/mocks.go +++ b/internal/utils/mocks.go @@ -128,9 +128,6 @@ type MockWalletBackendService struct { GetBlendPoolsResult []types.BlendPool GetBlendPoolsError error - - GetBlendEarnOptionsResult []types.BlendEarnOption - GetBlendEarnOptionsError error } func (m *MockWalletBackendService) Name() string { @@ -187,16 +184,6 @@ func (m *MockWalletBackendService) GetBlendPools(ctx context.Context, network st return []types.BlendPool{}, nil } -func (m *MockWalletBackendService) GetBlendEarnOptions(ctx context.Context, network string) ([]types.BlendEarnOption, error) { - if m.GetBlendEarnOptionsError != nil { - return nil, m.GetBlendEarnOptionsError - } - if m.GetBlendEarnOptionsResult != nil { - return m.GetBlendEarnOptionsResult, nil - } - return []types.BlendEarnOption{}, nil -} - type MockPricesService struct { GetPricesFunc func(ctx context.Context, tokens []string, network string) (map[string]*types.PriceEntry, error) GetPricesOverride map[string]*types.PriceEntry From f025e70fd6862eece1e8d743fe3d4a9a555abcfa Mon Sep 17 00:00:00 2001 From: jiahuihu Date: Fri, 24 Jul 2026 15:35:56 -0400 Subject: [PATCH 07/15] feat(services): consume wbclient's typed Blend API, drop hand-rolled GraphQL --- go.mod | 34 +-- go.sum | 100 +++---- internal/services/wallet_backend_blend.go | 189 ++---------- .../services/wallet_backend_blend_test.go | 273 ++++++------------ internal/types/blend.go | 115 -------- internal/types/interfaces.go | 5 +- internal/utils/mocks.go | 16 +- 7 files changed, 203 insertions(+), 529 deletions(-) delete mode 100644 internal/types/blend.go diff --git a/go.mod b/go.mod index 224a08a..658f441 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.25.9 require ( github.com/alitto/pond/v2 v2.5.0 - github.com/creachadair/jrpc2 v1.2.0 + github.com/creachadair/jrpc2 v1.3.3 github.com/deckarep/golang-set/v2 v2.8.0 github.com/docker/go-connections v0.5.0 github.com/golang-jwt/jwt/v5 v5.2.3 @@ -16,13 +16,13 @@ require ( github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 github.com/stellar/go v0.0.0-20250903085211-00c0b06cd7cc - github.com/stellar/go-stellar-sdk v0.5.0 - github.com/stellar/wallet-backend v0.0.0-20260706184421-b59b28d33bd0 + github.com/stellar/go-stellar-sdk v0.6.1-0.20260625225930-6181cdf8bda5 + github.com/stellar/wallet-backend v0.0.0-20260724191841-6e4e3e36ff75 github.com/stretchr/testify v1.11.1 github.com/testcontainers/testcontainers-go v0.37.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.37.0 github.com/testcontainers/testcontainers-go/modules/redis v0.37.0 - golang.org/x/sync v0.19.0 + golang.org/x/sync v0.20.0 ) require ( @@ -36,7 +36,7 @@ require ( github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v1.0.0-rc.1 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect - github.com/creachadair/mds v0.13.4 // indirect + github.com/creachadair/mds v0.25.10 // indirect github.com/creack/pty v1.1.24 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect @@ -60,7 +60,7 @@ require ( github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/klauspost/compress v1.18.0 // indirect + github.com/klauspost/compress v1.18.1 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.10 // indirect @@ -80,8 +80,8 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.66.1 // indirect - github.com/prometheus/procfs v0.16.1 // indirect + github.com/prometheus/common v0.67.2 // indirect + github.com/prometheus/procfs v0.19.2 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/segmentio/go-loggly v0.5.1-0.20171222203950-eb91657e62b2 // indirect github.com/shirou/gopsutil/v4 v4.25.1 // indirect @@ -89,24 +89,24 @@ require ( github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect - github.com/stellar/go-xdr v0.0.0-20260312225820-cc2b0611aabf // indirect - github.com/stretchr/objx v0.5.2 // indirect + github.com/stellar/go-xdr v0.0.0-20260529210834-0bf8f4956364 // indirect + github.com/stretchr/objx v0.5.3 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect go.opentelemetry.io/otel v1.38.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 // indirect go.opentelemetry.io/otel/metric v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect - go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.48.0 // indirect - golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect + golang.org/x/crypto v0.52.0 // indirect + golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gotest.tools/v3 v3.5.2 // indirect diff --git a/go.sum b/go.sum index 41b352a..ab72026 100644 --- a/go.sum +++ b/go.sum @@ -25,10 +25,10 @@ github.com/containerd/platforms v1.0.0-rc.1/go.mod h1:J71L7B+aiM5SdIEqmd9wp6THLV github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/creachadair/jrpc2 v1.2.0 h1:SXr0OgnwM0X18P+HccJP0uT3KGSDk/BCSRlJBvE2bMY= -github.com/creachadair/jrpc2 v1.2.0/go.mod h1:66uKSdr6tR5ZeNvkIjDSbbVUtOv0UhjS/vcd8ECP7Iw= -github.com/creachadair/mds v0.13.4 h1:RgU0MhiVqkzp6/xtNWhK6Pw7tDeaVuGFtA0UA2RBYvY= -github.com/creachadair/mds v0.13.4/go.mod h1:4vrFYUzTXMJpMBU+OA292I6IUxKWCCfZkgXg+/kBZMo= +github.com/creachadair/jrpc2 v1.3.3 h1:v+qxzRhHBInD5JFFmCyQ5l0gq60Sneg3zH+QraT+2q8= +github.com/creachadair/jrpc2 v1.3.3/go.mod h1:79Ws3bltA8gWyDLVSzKsLnGZJWirKuTCnS7nbewwWzQ= +github.com/creachadair/mds v0.25.10 h1:9k9JB35D1xhOCFl0liBhagBBp8fWWkKZrA7UXsfoHtA= +github.com/creachadair/mds v0.25.10/go.mod h1:4hatI3hRM+qhzuAmqPRFvaBM8mONkS7nsLxkcuTYUIs= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -99,8 +99,8 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= +github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -160,14 +160,14 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= -github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= -github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= -github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= +github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= +github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= +github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= github.com/redis/go-redis/v9 v9.16.0 h1:OotgqgLSRCmzfqChbQyG1PHC3tLNR89DG4jdOERSEP4= github.com/redis/go-redis/v9 v9.16.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rubenv/sql-migrate v1.8.0 h1:dXnYiJk9k3wetp7GfQbKJcPHjVJL6YK19tKj8t2Ns0o= github.com/rubenv/sql-migrate v1.8.0/go.mod h1:F2bGFBwCU+pnmbtNYDeKvSuvL6lBVtXDXUUv5t+u1qw= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -194,15 +194,15 @@ github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stellar/go v0.0.0-20250903085211-00c0b06cd7cc h1:iRveajcdqBdOlp5U6ZCts9dr1J4VM3LJ8sVnpHYkoVE= github.com/stellar/go v0.0.0-20250903085211-00c0b06cd7cc/go.mod h1:ac8hwpljbFXC3Sf9nGfqBXXEvAEdnNRqQHGqP7QN8oY= -github.com/stellar/go-stellar-sdk v0.5.0 h1:xpOO+ZTyvGz54wTm7pwl2Gf1e6lZl0ExrJ/tKb+Roj4= -github.com/stellar/go-stellar-sdk v0.5.0/go.mod h1:tLKAQPxa2I5UvGMabBbUXcY3fmgYnfDudrMeK7CDX4w= -github.com/stellar/go-xdr v0.0.0-20260312225820-cc2b0611aabf h1:GY1RVbX3Hg7poPXEf6yojjP0hyypvgUgZmCqQU9D0xg= -github.com/stellar/go-xdr v0.0.0-20260312225820-cc2b0611aabf/go.mod h1:If+U9Z1W5xU97VrOgJandQT+2dN7/iOpkCrxBJEyF80= -github.com/stellar/wallet-backend v0.0.0-20260706184421-b59b28d33bd0 h1:JcFMOImqbijqoOgiAxv0IDfZufOWEZsLk4+wX5LMBQg= -github.com/stellar/wallet-backend v0.0.0-20260706184421-b59b28d33bd0/go.mod h1:BTE9yIWReB6J2jwSNCYmDSSVzgkkRtcEwmKaPdF+E7E= +github.com/stellar/go-stellar-sdk v0.6.1-0.20260625225930-6181cdf8bda5 h1:mFC9kqxTmHDNXWI78aPTgcvDADEsJOJSr4SRcEbMTvc= +github.com/stellar/go-stellar-sdk v0.6.1-0.20260625225930-6181cdf8bda5/go.mod h1:IkcqcrE9UQi7n/1y+MxKB+7qzdjG1T2kGOD7Ss8dqjw= +github.com/stellar/go-xdr v0.0.0-20260529210834-0bf8f4956364 h1:gOKrfuWdZ92LFlv0TAwgZ7OsWKeBsOMDlGLyFgduI1w= +github.com/stellar/go-xdr v0.0.0-20260529210834-0bf8f4956364/go.mod h1:If+U9Z1W5xU97VrOgJandQT+2dN7/iOpkCrxBJEyF80= +github.com/stellar/wallet-backend v0.0.0-20260724191841-6e4e3e36ff75 h1:DIdVP5OT2pp4W/XJS4JKXayQR86xx5WxaWYTPsDZXIE= +github.com/stellar/wallet-backend v0.0.0-20260724191841-6e4e3e36ff75/go.mod h1:0p+xdNHIBE5EYezv37NRbndf+o5vvNBkPrXwyMpe+vI= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= @@ -225,10 +225,10 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0 h1:UP6IpuHFkUgOQL9FFQFrZ+5LiwhhYRbi7VZSIx6Nj5s= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.56.0/go.mod h1:qxuZLtbq5QDtdeSHsS7bcf6EH6uO6jUAgk764zd3rhM= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24= @@ -239,36 +239,38 @@ go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgf go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= -go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= -golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -278,16 +280,16 @@ golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= -golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= -golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= @@ -296,13 +298,13 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 h1:ToEetK57OidYuqD4Q5w+vfEnPvPpuTwedCNVohYJfNk= -google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0 h1:0UOBWO4dC+e51ui0NFKSPbkHHiQ4TmrEfEZMLDyRmY8= -google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0/go.mod h1:8ytArBbtOy2xfht+y2fqKd5DRDJRUQhqbyEnQ4bDChs= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 h1:MAKi5q709QWfnkkpNQ0M12hYJ1+e8qYVDyowc4U1XZM= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= -google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= +google.golang.org/genproto v0.0.0-20251029180050-ab9386a59fda h1:fQ3VVQ11pb84nu0o/8wD6oZq13Q6+HK30P+9GSRlrqk= +google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda h1:+2XxjfsAu6vqFxwGBRcHiMaDCuZiqXGDUDVWVtrFAnE= +google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda h1:i/Q+bfisr7gq6feoJnS/DlpdwEL4ihp41fvRiM3Ork0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= +google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/services/wallet_backend_blend.go b/internal/services/wallet_backend_blend.go index 55eb85a..ebb1197 100644 --- a/internal/services/wallet_backend_blend.go +++ b/internal/services/wallet_backend_blend.go @@ -1,192 +1,59 @@ -// ABOUTME: Blend GraphQL queries against wallet-backend: account positions and -// ABOUTME: the pool catalog, via a hand-rolled signed GraphQL POST. +// ABOUTME: Blend methods on the wallet-backend service: account positions and +// ABOUTME: the pool catalog, via the wbclient SDK's typed Blend API. package services import ( - "bytes" "context" - "encoding/json" + "errors" "fmt" - "io" - "net/http" "time" "github.com/stellar/wallet-backend/pkg/wbclient" - - "github.com/stellar/freighter-backend-v2/internal/types" + wbtypes "github.com/stellar/wallet-backend/pkg/wbclient/types" ) -// The Blend queries are issued as raw GraphQL documents over wbclient's -// transport pieces (BaseURL, JWT RequestSigner, shared HTTPClient) because -// the SDK's own executeGraphQL is unexported and has no Blend methods yet. -// Field lists mirror internal/serve/graphql/schema/blend.graphqls on the -// wallet-backend Blend branch; re-verify them against the merged schema -// when that stack lands. -const ( - wbGraphQLPath = "/graphql/query" - - // wbSignTimeout matches the JWT expiry wbclient.Client.request uses. - wbSignTimeout = 5 * time.Second - - blendPositionsQuery = `query FreighterBlendPositions($address: String!) { - accountByAddress(address: $address) { - blendPositions { - pools { - poolAddress - poolName - usdValue - suppliedUsd - borrowedUsd - netApy - reserves { - assetContractId - tokenName - tokenSymbol - tokenDecimals - suppliedTokens - collateralTokens - borrowedTokens - suppliedUsd - borrowedUsd - supplyApy - borrowApy - emissionsSupplyApr - emissionsBorrowApr - interestEarned - emissionsEarnedBlnd - emissionsEarnedUsd - priceUsd - } - } - } - } -}` - - blendPoolsQuery = `query FreighterBlendPools { - blendPools { - address - name - status - suppliedUsd - borrowedUsd - interestApy - netApy - reserves { - assetContractId - tokenName - tokenSymbol - tokenDecimals - enabled - utilization - supplyApy - borrowApy - emissionsSupplyApr - suppliedUsd - borrowedUsd - priceUsd - } - } -}` -) - -// Root-field wrappers for each query document. -type blendPositionsData struct { - AccountByAddress *struct { - BlendPositions types.BlendAccountPositions `json:"blendPositions"` - } `json:"accountByAddress"` -} - -type blendPoolsData struct { - BlendPools []types.BlendPool `json:"blendPools"` -} - -// GetBlendPositions returns the account's Blend positions. accountByAddress -// resolving to null (an account wallet-backend has never indexed) returns -// empty positions rather than an error: for this read, "unknown account" and -// "no positions" are the same client-facing fact. -func (w *walletBackendService) GetBlendPositions(ctx context.Context, address, network string) (_ *types.BlendAccountPositions, err error) { +// GetBlendPositions returns the account's Blend positions. The SDK reports an +// account unknown to the indexer as wbclient.ErrAccountNotFound; for this +// read, "unknown account" and "no positions" are the same client-facing fact, +// so both normalize to empty positions. +func (w *walletBackendService) GetBlendPositions(ctx context.Context, address, network string) (_ *wbtypes.BlendAccountPositions, err error) { start := time.Now() defer func() { w.recordWBCall("GetBlendPositions", network, start, err) }() - data, err := wbGraphQL[blendPositionsData](ctx, w, network, "GetBlendPositions", blendPositionsQuery, map[string]interface{}{"address": address}) - if err != nil { - return nil, err + client := w.configureNetworkClient(network) + if client == nil { + return nil, fmt.Errorf("wallet backend client not configured for network: %s", network) } - if data.AccountByAddress == nil { - return &types.BlendAccountPositions{Pools: []types.BlendPoolPosition{}}, nil + + positions, err := client.GetAccountBlendPositions(ctx, address) + if err != nil { + if errors.Is(err, wbclient.ErrAccountNotFound) { + return &wbtypes.BlendAccountPositions{Pools: []wbtypes.BlendPoolPosition{}}, nil + } + return nil, classifyWBError(err) } - positions := data.AccountByAddress.BlendPositions if positions.Pools == nil { - positions.Pools = []types.BlendPoolPosition{} + positions.Pools = []wbtypes.BlendPoolPosition{} } - return &positions, nil + return positions, nil } -// GetBlendPools returns the pool-wide catalog. Always a non-nil slice. -func (w *walletBackendService) GetBlendPools(ctx context.Context, network string) (_ []types.BlendPool, err error) { +// GetBlendPools returns the pool-wide Blend catalog. Always a non-nil slice. +func (w *walletBackendService) GetBlendPools(ctx context.Context, network string) (_ []wbtypes.BlendPool, err error) { start := time.Now() defer func() { w.recordWBCall("GetBlendPools", network, start, err) }() - data, err := wbGraphQL[blendPoolsData](ctx, w, network, "GetBlendPools", blendPoolsQuery, nil) - if err != nil { - return nil, err - } - if data.BlendPools == nil { - return []types.BlendPool{}, nil - } - return data.BlendPools, nil -} - -// wbGraphQL executes one GraphQL document against the network's -// wallet-backend and unmarshals the response's data into T. It mirrors -// wbclient.Client.request (same path, JWT signing, and error vocabulary: -// "unexpected statusCode=" / "GraphQL error:" so classifyWBError and the -// handlers' translateServiceError treat raw-document calls exactly like SDK -// calls). A free function because Go methods cannot be generic. -func wbGraphQL[T any](ctx context.Context, w *walletBackendService, network, method, query string, variables map[string]interface{}) (*T, error) { client := w.configureNetworkClient(network) if client == nil { return nil, fmt.Errorf("wallet backend client not configured for network: %s", network) } - body, err := json.Marshal(wbclient.GraphQLRequest{Query: query, Variables: variables}) - if err != nil { - return nil, fmt.Errorf("marshalling %s request: %w", method, err) - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, client.BaseURL+wbGraphQLPath, bytes.NewReader(body)) - if err != nil { - return nil, fmt.Errorf("creating %s request: %w", method, err) - } - if client.RequestSigner != nil { - if signErr := client.RequestSigner.SignHTTPRequest(req, wbSignTimeout); signErr != nil { - return nil, fmt.Errorf("signing %s request: %w", method, signErr) - } - } - req.Header.Set("Content-Type", "application/json") - - resp, err := client.HTTPClient.Do(req) + pools, err := client.GetBlendPools(ctx) if err != nil { - return nil, classifyWBError(fmt.Errorf("sending %s request: %w", method, err)) - } - defer resp.Body.Close() //nolint:errcheck - - if resp.StatusCode != http.StatusOK { - snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) - return nil, classifyWBError(fmt.Errorf("%s: unexpected statusCode=%d, body=%s", method, resp.StatusCode, snippet)) + return nil, classifyWBError(err) } - - var envelope wbclient.GraphQLResponse - if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil { - return nil, fmt.Errorf("parsing %s response body: %w", method, err) - } - if len(envelope.Errors) > 0 { - return nil, classifyWBError(fmt.Errorf("%s: GraphQL error: %s", method, envelope.Errors[0].Message)) - } - - var data T - if err := json.Unmarshal(envelope.Data, &data); err != nil { - return nil, fmt.Errorf("unmarshaling %s data: %w", method, err) + if pools == nil { + return []wbtypes.BlendPool{}, nil } - return &data, nil + return pools, nil } diff --git a/internal/services/wallet_backend_blend_test.go b/internal/services/wallet_backend_blend_test.go index b719f14..ce3c2b9 100644 --- a/internal/services/wallet_backend_blend_test.go +++ b/internal/services/wallet_backend_blend_test.go @@ -1,5 +1,5 @@ -// ABOUTME: Tests for the Blend GraphQL client methods against httptest fakes, -// ABOUTME: covering decode (incl. null Floats), auth signing, and error mapping. +// ABOUTME: Tests for the Blend wallet-backend service methods, exercising the +// ABOUTME: wrapper policies (normalization, error classification) through fake servers. package services import ( @@ -14,6 +14,7 @@ import ( "github.com/stretchr/testify/require" "github.com/stellar/wallet-backend/pkg/wbclient" + wbtypes "github.com/stellar/wallet-backend/pkg/wbclient/types" "github.com/stellar/freighter-backend-v2/internal/metrics" "github.com/stellar/freighter-backend-v2/internal/types" @@ -33,208 +34,125 @@ func newBlendTestService(t *testing.T, handler http.HandlerFunc) *walletBackendS } } -// graphqlEnvelope wraps data as a GraphQL success response body. -func graphqlEnvelope(t *testing.T, data string) []byte { - t.Helper() - return []byte(`{"data":` + data + `}`) -} +func TestGetBlendPositions(t *testing.T) { + ctx := context.Background() -func TestGetBlendPositionsDecode(t *testing.T) { - var gotPath string - var gotBody wbclient.GraphQLRequest - svc := newBlendTestService(t, func(w http.ResponseWriter, r *http.Request) { - gotPath = r.URL.Path - require.NoError(t, json.NewDecoder(r.Body).Decode(&gotBody)) - // One pool, two reserves. The second reserve is null-heavy: unpriced - // asset (all USD/APY fields null, priceUsd null) and no registry - // metadata (tokenName/tokenSymbol/tokenDecimals null) — decode must - // yield nil pointers, never zeroes. - _, _ = w.Write(graphqlEnvelope(t, `{ - "accountByAddress": { - "blendPositions": { - "pools": [{ - "poolAddress": "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD", - "poolName": "Fixed Pool V2", - "usdValue": 77876.27, - "suppliedUsd": 674117.02, - "borrowedUsd": 596240.75, - "netApy": -0.029, - "reserves": [ - { - "assetContractId": "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75", - "tokenName": "USD Coin", - "tokenSymbol": "USDC", - "tokenDecimals": 7, - "suppliedTokens": "1000000000", - "collateralTokens": "5563385856000", - "borrowedTokens": "4953691474632", - "suppliedUsd": 556438.59, - "borrowedUsd": 495221.33, - "supplyApy": 0.0741, - "borrowApy": 0.1151, - "emissionsSupplyApr": 0.002, - "emissionsBorrowApr": 0.001, - "interestEarned": "6843215", - "emissionsEarnedBlnd": "12345678", - "emissionsEarnedUsd": 0.53, - "priceUsd": 1.0 - }, - { - "assetContractId": "CBZPEXQLJCUS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI99", + t.Run("decodes positions through the SDK", func(t *testing.T) { + svc := newBlendTestService(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data": { + "accountByAddress": { + "blendPositions": { + "pools": [{ + "poolAddress": "CCEBVDYM32YNYCVNRXQKDFFPISJJCV557CDZEIRBEE4NCV4KHPQ44HGF", + "poolName": "TestnetV2", + "usdValue": 3619.27, + "suppliedUsd": 3619.29, + "borrowedUsd": 0.02, + "netApy": 0.025, + "claimedBlnd": "0", + "reserves": [{ + "assetContractId": "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", "tokenName": null, "tokenSymbol": null, - "tokenDecimals": null, - "suppliedTokens": "68", - "collateralTokens": "0", + "tokenDecimals": 7, + "suppliedTokens": "0", + "collateralTokens": "67125489343", "borrowedTokens": "0", - "suppliedUsd": null, + "suppliedUsd": 2819.27, "borrowedUsd": null, - "supplyApy": null, + "supplyApy": 3.24, "borrowApy": null, - "emissionsSupplyApr": null, + "emissionsSupplyApr": 0, "emissionsBorrowApr": null, - "interestEarned": "0", + "interestEarned": "2125489343", + "interestPaid": "0", "emissionsEarnedBlnd": "0", "emissionsEarnedUsd": null, - "priceUsd": null - } - ] - }] + "priceUsd": 0.42 + }] + }], + "backstop": [], + "backstopClaimedLp": "0" + } } - } - }`)) - }) - - positions, err := svc.GetBlendPositions(context.Background(), blendTestAddress, types.TESTNET) - require.NoError(t, err) - - assert.Equal(t, wbGraphQLPath, gotPath) - assert.Contains(t, gotBody.Query, "FreighterBlendPositions") - assert.Equal(t, blendTestAddress, gotBody.Variables["address"]) - - require.Len(t, positions.Pools, 1) - pool := positions.Pools[0] - require.NotNil(t, pool.PoolName) - assert.Equal(t, "Fixed Pool V2", *pool.PoolName) - require.NotNil(t, pool.NetAPY) - assert.InDelta(t, -0.029, *pool.NetAPY, 1e-9) - - require.Len(t, pool.Reserves, 2) - priced, unpriced := pool.Reserves[0], pool.Reserves[1] + }}`)) + }) - // Token amounts stay full-precision strings. - assert.Equal(t, "5563385856000", priced.CollateralTokens) - assert.Equal(t, "6843215", priced.InterestEarned) - require.NotNil(t, priced.SupplyAPY) - assert.InDelta(t, 0.0741, *priced.SupplyAPY, 1e-9) - require.NotNil(t, priced.EmissionsSupplyAPR) - assert.InDelta(t, 0.002, *priced.EmissionsSupplyAPR, 1e-9) - require.NotNil(t, priced.EmissionsBorrowAPR) - assert.InDelta(t, 0.001, *priced.EmissionsBorrowAPR, 1e-9) + positions, err := svc.GetBlendPositions(ctx, blendTestAddress, types.TESTNET) + require.NoError(t, err) + + require.Len(t, positions.Pools, 1) + pool := positions.Pools[0] + require.NotNil(t, pool.NetApy) + assert.InDelta(t, 0.025, *pool.NetApy, 1e-9) + require.Len(t, pool.Reserves, 1) + reserve := pool.Reserves[0] + // Registry gaps and unpriced sides decode to nil, never zero. + assert.Nil(t, reserve.TokenSymbol) + assert.Nil(t, reserve.EmissionsBorrowApr) + assert.Equal(t, "67125489343", reserve.CollateralTokens) + assert.Equal(t, "2125489343", reserve.InterestEarned) + }) - // Null Floats and null registry metadata decode to nil, not zero. - assert.Nil(t, unpriced.SuppliedUSD) - assert.Nil(t, unpriced.SupplyAPY) - assert.Nil(t, unpriced.EmissionsSupplyAPR) - assert.Nil(t, unpriced.PriceUSD) - assert.Nil(t, unpriced.TokenSymbol) - assert.Nil(t, unpriced.TokenDecimals) - assert.Equal(t, "68", unpriced.SuppliedTokens) -} + t.Run("unknown account normalizes to empty positions", func(t *testing.T) { + svc := newBlendTestService(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"data": {"accountByAddress": null}}`)) + }) -func TestGetBlendPositionsUnknownAccountIsEmpty(t *testing.T) { - svc := newBlendTestService(t, func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write(graphqlEnvelope(t, `{"accountByAddress": null}`)) + positions, err := svc.GetBlendPositions(ctx, blendTestAddress, types.TESTNET) + require.NoError(t, err) + require.NotNil(t, positions) + assert.NotNil(t, positions.Pools) + assert.Empty(t, positions.Pools) }) - - positions, err := svc.GetBlendPositions(context.Background(), blendTestAddress, types.TESTNET) - require.NoError(t, err) - require.NotNil(t, positions) - assert.NotNil(t, positions.Pools) - assert.Empty(t, positions.Pools) } -func TestGetBlendPoolsDecode(t *testing.T) { - var gotBody wbclient.GraphQLRequest +func TestGetBlendPools(t *testing.T) { + ctx := context.Background() + svc := newBlendTestService(t, func(w http.ResponseWriter, r *http.Request) { - require.NoError(t, json.NewDecoder(r.Body).Decode(&gotBody)) - _, _ = w.Write(graphqlEnvelope(t, `{ - "blendPools": [ - { - "address": "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD", - "name": null, - "status": "ACTIVE", - "suppliedUsd": 2100000.5, - "borrowedUsd": 900000.25, - "interestApy": 0.043, - "netApy": 0.047, - "reserves": [{ - "assetContractId": "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75", - "tokenName": "USD Coin", - "tokenSymbol": "USDC", - "tokenDecimals": 7, - "enabled": true, - "utilization": 0.62, - "supplyApy": 0.043, - "borrowApy": 0.061, - "emissionsSupplyApr": 0.008, - "suppliedUsd": 1500000.0, - "borrowedUsd": 930000.0, - "priceUsd": 1.0 - }] - }, - { - "address": "CCCCIQSDILITHMM7PBSLVDT5MISSY7R26MNZXCX4H7J5JQ5FPIYOGYFS", - "name": "Second Pool", - "status": null, - "suppliedUsd": null, - "borrowedUsd": null, - "interestApy": null, - "netApy": null, - "reserves": [] - } - ] - }`)) + var req wbclient.GraphQLRequest + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + assert.Contains(t, req.Query, "blendPools") + _, _ = w.Write([]byte(`{"data": {"blendPools": [{ + "address": "CCEBVDYM32YNYCVNRXQKDFFPISJJCV557CDZEIRBEE4NCV4KHPQ44HGF", + "name": "TestnetV2", + "status": "ACTIVE", + "suppliedUsd": 2100000.5, + "reserves": [] + }]}}`)) }) - pools, err := svc.GetBlendPools(context.Background(), types.TESTNET) + pools, err := svc.GetBlendPools(ctx, types.TESTNET) require.NoError(t, err) - assert.Contains(t, gotBody.Query, "FreighterBlendPools") - - require.Len(t, pools, 2) + require.Len(t, pools, 1) require.NotNil(t, pools[0].Status) - assert.Equal(t, types.BlendPoolStatusActive, *pools[0].Status) - assert.Nil(t, pools[0].Name) - require.Len(t, pools[0].Reserves, 1) - assert.True(t, pools[0].Reserves[0].Enabled) - - // Not-yet-ingested pool: status and all totals null. - assert.Nil(t, pools[1].Status) - assert.Nil(t, pools[1].SuppliedUSD) - assert.Empty(t, pools[1].Reserves) + assert.Equal(t, wbtypes.BlendPoolStatusActive, *pools[0].Status) + assert.True(t, pools[0].Status.AcceptsSupply()) } -func TestBlendGraphQLErrorClassification(t *testing.T) { - t.Run("GraphQL errors array becomes graphql_error", func(t *testing.T) { +func TestBlendErrorClassification(t *testing.T) { + ctx := context.Background() + + t.Run("GraphQL errors classify as graphql_error", func(t *testing.T) { svc := newBlendTestService(t, func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"errors":[{"message":"Cannot query field \"blendPools\" on type \"Query\"."}]}`)) }) - _, err := svc.GetBlendPools(context.Background(), types.TESTNET) + _, err := svc.GetBlendPools(ctx, types.TESTNET) require.Error(t, err) var upErr *metrics.UpstreamError require.ErrorAs(t, err, &upErr) assert.Equal(t, "graphql_error", upErr.Kind) - assert.Contains(t, err.Error(), "blendPools") }) - t.Run("non-200 becomes http_error with code", func(t *testing.T) { + t.Run("non-200 classifies as http_error with code", func(t *testing.T) { svc := newBlendTestService(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadGateway) _, _ = w.Write([]byte("upstream sad")) }) - _, err := svc.GetBlendPositions(context.Background(), blendTestAddress, types.TESTNET) + _, err := svc.GetBlendPositions(ctx, blendTestAddress, types.TESTNET) require.Error(t, err) var upErr *metrics.UpstreamError require.ErrorAs(t, err, &upErr) @@ -242,19 +160,16 @@ func TestBlendGraphQLErrorClassification(t *testing.T) { assert.Equal(t, http.StatusBadGateway, upErr.Code) }) - t.Run("malformed data payload is a decode error", func(t *testing.T) { - svc := newBlendTestService(t, func(w http.ResponseWriter, r *http.Request) { - _, _ = w.Write([]byte(`{"data": {"blendPools": "not-a-list"}}`)) - }) - - _, err := svc.GetBlendPools(context.Background(), types.TESTNET) + t.Run("unconfigured network errors without a request", func(t *testing.T) { + svc := &walletBackendService{maxBalanceConcurrency: 1} + _, err := svc.GetBlendPools(ctx, types.PUBLIC) require.Error(t, err) - assert.Contains(t, err.Error(), "unmarshaling GetBlendPools data") + assert.Contains(t, err.Error(), "not configured") }) } // headerSigner is a fake auth.HTTPRequestSigner that stamps a header so the -// test can assert the signing hook runs for raw GraphQL documents. +// test can assert the SDK's signing hook runs for Blend calls. type headerSigner struct{} func (headerSigner) SignHTTPRequest(req *http.Request, _ time.Duration) error { @@ -262,7 +177,7 @@ func (headerSigner) SignHTTPRequest(req *http.Request, _ time.Duration) error { return nil } -func TestBlendGraphQLRequestIsSigned(t *testing.T) { +func TestBlendRequestIsSigned(t *testing.T) { var gotAuth string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotAuth = r.Header.Get("Authorization") @@ -270,12 +185,14 @@ func TestBlendGraphQLRequestIsSigned(t *testing.T) { })) t.Cleanup(server.Close) - client := wbclient.NewClient(server.URL, headerSigner{}) - svc := &walletBackendService{testnetClient: client, maxBalanceConcurrency: 1} + svc := &walletBackendService{ + testnetClient: wbclient.NewClient(server.URL, headerSigner{}), + maxBalanceConcurrency: 1, + } pools, err := svc.GetBlendPools(context.Background(), types.TESTNET) require.NoError(t, err) - assert.Empty(t, pools) assert.NotNil(t, pools) + assert.Empty(t, pools) assert.Equal(t, "Bearer test-jwt", gotAuth) } diff --git a/internal/types/blend.go b/internal/types/blend.go deleted file mode 100644 index 96cad75..0000000 --- a/internal/types/blend.go +++ /dev/null @@ -1,115 +0,0 @@ -// ABOUTME: Decode types for wallet-backend's Blend v2 GraphQL surface (positions -// ABOUTME: and pool catalog), mirroring blend.graphqls field for field. -package types - -// Conventions, from the wallet-backend schema (blend.graphqls): -// - USD/APY values are nullable Float: null means "uncomputable" (an oracle -// price is missing or >24h stale — the pool contract itself rejects prices -// past that age); a genuinely zero balance is 0, not null. Decoded as -// *float64 and propagated as null, never rendered as 0. -// - On-chain token amounts are non-null String at full precision. Passed -// through verbatim; never float-parsed. -// - tokenName/tokenSymbol/tokenDecimals come from the contract_tokens -// metadata registry and are nullable; display falls back to a truncated -// contract address. -// -// Backstop fields/types are deliberately not modeled (out of scope for v1); -// the query documents don't select them, so decoding never sees them. - -// BlendAccountPositions is Account.blendPositions: one account's lending, -// collateral, and borrowing positions across every Blend v2 pool it touched. -type BlendAccountPositions struct { - Pools []BlendPoolPosition `json:"pools"` -} - -// BlendPoolPosition rolls up an account's reserve positions within one pool. -// USDValue is supplied minus borrowed. NetAPY nets supply earnings against -// borrow interest over TOTAL SUPPLIED USD — the blend-sdk-js convention the -// Blend UI shows: (Σ supplied·supplyApy − Σ borrowed·borrowApy) / Σ supplied; -// 0 for a debt-only position, null when any reserve lacks a fresh price. -type BlendPoolPosition struct { - PoolAddress string `json:"poolAddress"` - PoolName *string `json:"poolName"` - USDValue *float64 `json:"usdValue"` - SuppliedUSD *float64 `json:"suppliedUsd"` - BorrowedUSD *float64 `json:"borrowedUsd"` - NetAPY *float64 `json:"netApy"` - Reserves []BlendReservePosition `json:"reserves"` -} - -// BlendReservePosition is an account's position in one reserve of a pool. -// Token amounts are underlying-asset amounts at rates projected to now. -// InterestEarned is lifetime interest in underlying tokens (survives full -// exit — a zero-balance row still carries realized earnings; liquidations -// adjust the basis so the figure stays interest-only). EmissionsEarnedBLND -// is claimable (uncollected) BLND across the reserve's emission streams. -type BlendReservePosition struct { - AssetContractID string `json:"assetContractId"` - TokenName *string `json:"tokenName"` - TokenSymbol *string `json:"tokenSymbol"` - TokenDecimals *int32 `json:"tokenDecimals"` - SuppliedTokens string `json:"suppliedTokens"` - CollateralTokens string `json:"collateralTokens"` - BorrowedTokens string `json:"borrowedTokens"` - SuppliedUSD *float64 `json:"suppliedUsd"` - BorrowedUSD *float64 `json:"borrowedUsd"` - SupplyAPY *float64 `json:"supplyApy"` - BorrowAPY *float64 `json:"borrowApy"` - // EmissionsSupplyAPR / EmissionsBorrowAPR are the reserve's POOL-WIDE - // per-side emission-stream APRs (not scaled to this account's holding): - // 0 when the side has no active stream, null when the stream is active - // but a price is unavailable. - EmissionsSupplyAPR *float64 `json:"emissionsSupplyApr"` - EmissionsBorrowAPR *float64 `json:"emissionsBorrowApr"` - InterestEarned string `json:"interestEarned"` - EmissionsEarnedBLND string `json:"emissionsEarnedBlnd"` - EmissionsEarnedUSD *float64 `json:"emissionsEarnedUsd"` - PriceUSD *float64 `json:"priceUsd"` -} - -// BlendPool is one pool in the pool-wide catalog (Query.blendPools), -// independent of any account. SuppliedUSD/BorrowedUSD are strict-null: a -// missing price on any reserve makes the pool total uncomputable. -// InterestAPY is the supplied-USD-weighted supply rate (interest only); -// NetAPY additionally folds in BLND emissions — supply-side yield, not -// netted against borrows. -type BlendPool struct { - Address string `json:"address"` - Name *string `json:"name"` - Status *string `json:"status"` - SuppliedUSD *float64 `json:"suppliedUsd"` - BorrowedUSD *float64 `json:"borrowedUsd"` - InterestAPY *float64 `json:"interestApy"` - NetAPY *float64 `json:"netApy"` - Reserves []BlendReserve `json:"reserves"` -} - -// BlendPoolStatus enum values (BlendPool.Status). The first four accept -// supply (deposits); the first two also allow borrowing; the rest reject -// both. Status is null until the pool's config entry has been ingested. -const ( - BlendPoolStatusAdminActive = "ADMIN_ACTIVE" - BlendPoolStatusActive = "ACTIVE" - BlendPoolStatusAdminOnIce = "ADMIN_ON_ICE" - BlendPoolStatusOnIce = "ON_ICE" - BlendPoolStatusAdminFrozen = "ADMIN_FROZEN" - BlendPoolStatusFrozen = "FROZEN" - BlendPoolStatusSetup = "SETUP" -) - -// BlendReserve is a pool-wide reserve catalog row: rates and totals as of -// now, no per-account data. -type BlendReserve struct { - AssetContractID string `json:"assetContractId"` - TokenName *string `json:"tokenName"` - TokenSymbol *string `json:"tokenSymbol"` - TokenDecimals *int32 `json:"tokenDecimals"` - Enabled bool `json:"enabled"` - Utilization *float64 `json:"utilization"` - SupplyAPY *float64 `json:"supplyApy"` - BorrowAPY *float64 `json:"borrowApy"` - EmissionsSupplyAPR *float64 `json:"emissionsSupplyApr"` - SuppliedUSD *float64 `json:"suppliedUsd"` - BorrowedUSD *float64 `json:"borrowedUsd"` - PriceUSD *float64 `json:"priceUsd"` -} diff --git a/internal/types/interfaces.go b/internal/types/interfaces.go index 4705972..c743520 100644 --- a/internal/types/interfaces.go +++ b/internal/types/interfaces.go @@ -6,6 +6,7 @@ import ( "github.com/stellar/go-stellar-sdk/txnbuild" "github.com/stellar/go-stellar-sdk/xdr" + wbtypes "github.com/stellar/wallet-backend/pkg/wbclient/types" ) const ( @@ -53,9 +54,9 @@ type WalletBackendService interface { // pool it touched. An account unknown to wallet-backend returns empty // positions, not an error — indistinguishable from "no positions" by // design. - GetBlendPositions(ctx context.Context, address, network string) (*BlendAccountPositions, error) + GetBlendPositions(ctx context.Context, address, network string) (*wbtypes.BlendAccountPositions, error) // GetBlendPools returns the pool-wide Blend catalog (no account data). - GetBlendPools(ctx context.Context, network string) ([]BlendPool, error) + GetBlendPools(ctx context.Context, network string) ([]wbtypes.BlendPool, error) } // StellarExpertAsset is the subset of the Stellar Expert /asset/{id} response diff --git a/internal/utils/mocks.go b/internal/utils/mocks.go index e64170f..b2c5dde 100644 --- a/internal/utils/mocks.go +++ b/internal/utils/mocks.go @@ -3,6 +3,8 @@ package utils import ( "context" + wbtypes "github.com/stellar/wallet-backend/pkg/wbclient/types" + "github.com/stellar/freighter-backend-v2/internal/types" "github.com/stellar/go-stellar-sdk/clients/rpcclient" "github.com/stellar/go-stellar-sdk/txnbuild" @@ -122,11 +124,11 @@ type MockWalletBackendService struct { GetAccountTransactionsFunc func(ctx context.Context, address, network string, params types.AccountHistoryParams) (*types.PaginatedResponse[*types.AccountTransaction], error) // Blend method stubs follow the same Result/Error/Func precedence. - GetBlendPositionsResult *types.BlendAccountPositions + GetBlendPositionsResult *wbtypes.BlendAccountPositions GetBlendPositionsError error - GetBlendPositionsFunc func(ctx context.Context, address, network string) (*types.BlendAccountPositions, error) + GetBlendPositionsFunc func(ctx context.Context, address, network string) (*wbtypes.BlendAccountPositions, error) - GetBlendPoolsResult []types.BlendPool + GetBlendPoolsResult []wbtypes.BlendPool GetBlendPoolsError error } @@ -161,7 +163,7 @@ func (m *MockWalletBackendService) GetAccountTransactions(ctx context.Context, a return m.GetAccountTransactionsResult, nil } -func (m *MockWalletBackendService) GetBlendPositions(ctx context.Context, address, network string) (*types.BlendAccountPositions, error) { +func (m *MockWalletBackendService) GetBlendPositions(ctx context.Context, address, network string) (*wbtypes.BlendAccountPositions, error) { if m.GetBlendPositionsFunc != nil { return m.GetBlendPositionsFunc(ctx, address, network) } @@ -171,17 +173,17 @@ func (m *MockWalletBackendService) GetBlendPositions(ctx context.Context, addres if m.GetBlendPositionsResult != nil { return m.GetBlendPositionsResult, nil } - return &types.BlendAccountPositions{Pools: []types.BlendPoolPosition{}}, nil + return &wbtypes.BlendAccountPositions{Pools: []wbtypes.BlendPoolPosition{}}, nil } -func (m *MockWalletBackendService) GetBlendPools(ctx context.Context, network string) ([]types.BlendPool, error) { +func (m *MockWalletBackendService) GetBlendPools(ctx context.Context, network string) ([]wbtypes.BlendPool, error) { if m.GetBlendPoolsError != nil { return nil, m.GetBlendPoolsError } if m.GetBlendPoolsResult != nil { return m.GetBlendPoolsResult, nil } - return []types.BlendPool{}, nil + return []wbtypes.BlendPool{}, nil } type MockPricesService struct { From 7973633cae27d521ade7102eff737b856dca3bd8 Mon Sep 17 00:00:00 2001 From: jiahuihu Date: Fri, 24 Jul 2026 15:48:47 -0400 Subject: [PATCH 08/15] feat(api): adapt positions mapper to the wbclient SDK types and final schema --- internal/services/positions.go | 62 ++++++++------- internal/services/positions_test.go | 112 ++++++++++++++++------------ internal/types/positions.go | 20 +++-- 3 files changed, 110 insertions(+), 84 deletions(-) diff --git a/internal/services/positions.go b/internal/services/positions.go index eba2579..03be24f 100644 --- a/internal/services/positions.go +++ b/internal/services/positions.go @@ -10,6 +10,8 @@ import ( "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" @@ -85,17 +87,17 @@ func (p *positionsService) GetAccountPositions(ctx context.Context, address, net } // mapAccountPositions shapes the upstream Blend positions into the response. -func mapAccountPositions(upstream *types.BlendAccountPositions) *types.AccountPositions { +func mapAccountPositions(upstream *wbtypes.BlendAccountPositions) *types.AccountPositions { positions := make([]types.PoolPosition, 0, len(upstream.Pools)) for _, pool := range upstream.Pools { positions = append(positions, types.PoolPosition{ Protocol: "blend", ID: pool.PoolAddress, Name: pool.PoolName, - NetUSD: pool.USDValue, - SuppliedUSD: pool.SuppliedUSD, - BorrowedUSD: pool.BorrowedUSD, - NetAPY: pool.NetAPY, + NetUSD: pool.UsdValue, + SuppliedUSD: pool.SuppliedUsd, + BorrowedUSD: pool.BorrowedUsd, + NetAPY: pool.NetApy, Blend: mapBlendDetail(pool.Reserves), }) } @@ -112,7 +114,7 @@ func mapAccountPositions(upstream *types.BlendAccountPositions) *types.AccountPo // balance on a side produce no row for that side; upstream deliberately // emits fully-exited (all-zero) reserve rows to carry earnings history, and // those are filtered here. -func mapBlendDetail(reserves []types.BlendReservePosition) *types.BlendPositionDetail { +func mapBlendDetail(reserves []wbtypes.BlendReservePosition) *types.BlendPositionDetail { detail := &types.BlendPositionDetail{ Supply: []types.BlendSupplyRow{}, Borrow: []types.BlendBorrowRow{}, @@ -132,14 +134,14 @@ func mapBlendDetail(reserves []types.BlendReservePosition) *types.BlendPositionD SuppliedTokens: supplied.String(), CollateralTokens: collateral.String(), TotalTokens: total.String(), - USDValue: r.SuppliedUSD, - APY: r.SupplyAPY, - EmissionsAPR: r.EmissionsAPR, + USDValue: r.SuppliedUsd, + APY: r.SupplyApy, + EmissionsAPR: r.EmissionsSupplyApr, InterestEarned: r.InterestEarned, - InterestEarnedUSD: tokensToUSD(r.InterestEarned, r.TokenDecimals, r.PriceUSD), - ClaimableBLND: r.EmissionsEarnedBLND, - ClaimableUSD: r.EmissionsEarnedUSD, - PriceUSD: r.PriceUSD, + InterestEarnedUSD: tokensToUSD(r.InterestEarned, r.TokenDecimals, r.PriceUsd), + ClaimableBLND: r.EmissionsEarnedBlnd, + ClaimableUSD: r.EmissionsEarnedUsd, + PriceUSD: r.PriceUsd, }) } if borrowed.Sign() > 0 { @@ -149,9 +151,10 @@ func mapBlendDetail(reserves []types.BlendReservePosition) *types.BlendPositionD Name: r.TokenName, Decimals: r.TokenDecimals, BorrowedTokens: borrowed.String(), - USDValue: r.BorrowedUSD, - APY: r.BorrowAPY, - PriceUSD: r.PriceUSD, + USDValue: r.BorrowedUsd, + APY: r.BorrowApy, + EmissionsAPR: r.EmissionsBorrowApr, + PriceUSD: r.PriceUsd, }) } } @@ -165,36 +168,37 @@ func mapBlendDetail(reserves []types.BlendReservePosition) *types.BlendPositionD // than an honest null), mirroring upstream's convention for pool totals. // 0 for an account with no pools. // -// NetAPY: mean of pool netApy weighted by pool usdValue — the weight basis -// that makes rate × base reproduce the per-pool dollar earnings under the -// upstream's current netApy definition. Null when any input is unavailable -// or the weighted base is zero. Both rules are pending confirmation with -// the wallet-backend team; each is isolated here so a decision lands as a -// one-line change. -func accountAggregate(pools []types.BlendPoolPosition) (total *float64, netAPY *float64) { +// NetAPY: mean of pool netApy weighted by pool suppliedUsd — the base the +// upstream rate is defined over (blend-sdk-js: net dollars / total +// supplied), so rate × base reproduces the per-pool dollar earnings. Null +// when any pool's netApy or suppliedUsd is unavailable or the supplied base +// is zero. +func accountAggregate(pools []wbtypes.BlendPoolPosition) (total *float64, netAPY *float64) { if len(pools) == 0 { zero := 0.0 return &zero, nil } sum := 0.0 + suppliedSum := 0.0 apyNumerator := 0.0 apyKnown := true for _, pool := range pools { - if pool.USDValue == nil { + if pool.UsdValue == nil { return nil, nil } - sum += *pool.USDValue - if pool.NetAPY == nil { + sum += *pool.UsdValue + if pool.NetApy == nil || pool.SuppliedUsd == nil { apyKnown = false continue } - apyNumerator += *pool.NetAPY * *pool.USDValue + apyNumerator += *pool.NetApy * *pool.SuppliedUsd + suppliedSum += *pool.SuppliedUsd } total = &sum - if apyKnown && sum != 0 { - apy := apyNumerator / sum + if apyKnown && suppliedSum != 0 { + apy := apyNumerator / suppliedSum if !math.IsInf(apy, 0) && !math.IsNaN(apy) { netAPY = &apy } diff --git a/internal/services/positions_test.go b/internal/services/positions_test.go index 10fdf4f..77e7d41 100644 --- a/internal/services/positions_test.go +++ b/internal/services/positions_test.go @@ -10,6 +10,8 @@ import ( "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" ) @@ -20,10 +22,11 @@ func i32(v int32) *int32 { return &v } // reserveFixture mirrors the shape observed on the live testnet dev instance // (user account GDW6QB3B...): XLM held entirely as collateral with real -// earned interest, USDC as collateral, a dust wBTC borrow, and a fully-exited -// wETH row that must not become a display row. -func reserveFixture() []types.BlendReservePosition { - return []types.BlendReservePosition{ +// earned interest, USDC as collateral, a dust wBTC borrow with a borrow-side +// emission stream, and a fully-exited wETH row that must not become a +// display row. +func reserveFixture() []wbtypes.BlendReservePosition { + return []wbtypes.BlendReservePosition{ { AssetContractID: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", TokenSymbol: nil, // XLM SAC missing from the registry, observed live @@ -31,12 +34,12 @@ func reserveFixture() []types.BlendReservePosition { SuppliedTokens: "0", CollateralTokens: "67125489343", BorrowedTokens: "0", - SuppliedUSD: f64(2819.270552406), - SupplyAPY: f64(3.240617830176194), - EmissionsAPR: f64(0), + SuppliedUsd: f64(2819.270552406), + SupplyApy: f64(3.240617830176194), + EmissionsSupplyApr: f64(0), InterestEarned: "2125489343", - EmissionsEarnedBLND: "0", - PriceUSD: f64(0.42), + EmissionsEarnedBlnd: "0", + PriceUsd: f64(0.42), }, { AssetContractID: "CCYM3TPDGQODFOC2OQDND6C7SKHO3TWD37CYN35I6K66JO5X3SUANEHN", @@ -45,24 +48,25 @@ func reserveFixture() []types.BlendReservePosition { SuppliedTokens: "0", CollateralTokens: "8000168408", BorrowedTokens: "0", - SuppliedUSD: f64(800.0168408), - SupplyAPY: f64(0.00104137909709201), + SuppliedUsd: f64(800.0168408), + SupplyApy: f64(0.00104137909709201), InterestEarned: "168408", - EmissionsEarnedBLND: "0", - PriceUSD: f64(1), + EmissionsEarnedBlnd: "0", + PriceUsd: f64(1), }, { - AssetContractID: "CBWBTCWBTCWBTCWBTCWBTCWBTCWBTCWBTCWBTCWBTCWBTCWBTCWBTC1", + AssetContractID: "CAP5AMC2OHNVREO66DFIN6DHJMPOBAJ2KCDDIMFBR7WWJH5RZBFM3UEI", TokenSymbol: str("wBTC"), TokenDecimals: i32(7), SuppliedTokens: "0", CollateralTokens: "0", BorrowedTokens: "2", - BorrowedUSD: f64(0.02), - BorrowAPY: f64(4.72998834498228), + BorrowedUsd: f64(0.02), + BorrowApy: f64(4.72998834498228), + EmissionsBorrowApr: f64(0.001), InterestEarned: "0", - EmissionsEarnedBLND: "0", - PriceUSD: f64(100000), + EmissionsEarnedBlnd: "0", + PriceUsd: f64(100000), }, { // Fully-exited row: upstream emits it for earnings history; it @@ -73,10 +77,10 @@ func reserveFixture() []types.BlendReservePosition { SuppliedTokens: "0", CollateralTokens: "0", BorrowedTokens: "0", - SuppliedUSD: f64(0), + SuppliedUsd: f64(0), InterestEarned: "0", - EmissionsEarnedBLND: "0", - PriceUSD: f64(4000), + EmissionsEarnedBlnd: "0", + PriceUsd: f64(4000), }, } } @@ -107,19 +111,21 @@ func TestMapBlendDetailRows(t *testing.T) { assert.Equal(t, "2", wbtc.BorrowedTokens) require.NotNil(t, wbtc.USDValue) assert.InDelta(t, 0.02, *wbtc.USDValue, 1e-9) + require.NotNil(t, wbtc.EmissionsAPR) + assert.InDelta(t, 0.001, *wbtc.EmissionsAPR, 1e-9) } func TestMapBlendDetailNullSafety(t *testing.T) { - rows := []types.BlendReservePosition{{ + rows := []wbtypes.BlendReservePosition{{ AssetContractID: "CUNPRICED", TokenDecimals: nil, // no registry entry SuppliedTokens: "100", CollateralTokens: "0", BorrowedTokens: "0", - SuppliedUSD: nil, // no oracle price + SuppliedUsd: nil, // no oracle price InterestEarned: "50", - EmissionsEarnedBLND: "0", - PriceUSD: nil, + EmissionsEarnedBlnd: "0", + PriceUsd: nil, }} detail := mapBlendDetail(rows) @@ -134,34 +140,44 @@ func TestMapBlendDetailNullSafety(t *testing.T) { } func TestAccountAggregate(t *testing.T) { - pool := func(usd, apy *float64) types.BlendPoolPosition { - return types.BlendPoolPosition{USDValue: usd, NetAPY: apy} + pool := func(usd, supplied, apy *float64) wbtypes.BlendPoolPosition { + return wbtypes.BlendPoolPosition{UsdValue: usd, SuppliedUsd: supplied, NetApy: apy} } - t.Run("weighted mean across pools", func(t *testing.T) { - total, apy := accountAggregate([]types.BlendPoolPosition{ - pool(f64(9000), f64(0.05)), - pool(f64(1000), f64(0.01)), + t.Run("supplied-weighted mean across pools", func(t *testing.T) { + total, apy := accountAggregate([]wbtypes.BlendPoolPosition{ + pool(f64(8000), f64(9000), f64(0.05)), + pool(f64(900), f64(1000), f64(0.01)), }) require.NotNil(t, total) - assert.InDelta(t, 10000, *total, 1e-9) + assert.InDelta(t, 8900, *total, 1e-9) // the total sums net values... require.NotNil(t, apy) - assert.InDelta(t, 0.046, *apy, 1e-9) // (9000×5% + 1000×1%) / 10000 + assert.InDelta(t, 0.046, *apy, 1e-9) // ...but the rate weights by supplied }) t.Run("strict null: one unpriced pool nulls the header", func(t *testing.T) { - total, apy := accountAggregate([]types.BlendPoolPosition{ - pool(f64(9000), f64(0.05)), - pool(nil, nil), + total, apy := accountAggregate([]wbtypes.BlendPoolPosition{ + pool(f64(9000), f64(9000), f64(0.05)), + pool(nil, nil, nil), }) assert.Nil(t, total) assert.Nil(t, apy) }) t.Run("null netApy nulls the rate but keeps the total", func(t *testing.T) { - total, apy := accountAggregate([]types.BlendPoolPosition{ - pool(f64(9000), f64(0.05)), - pool(f64(1000), nil), + total, apy := accountAggregate([]wbtypes.BlendPoolPosition{ + pool(f64(9000), f64(9000), f64(0.05)), + pool(f64(1000), f64(1000), nil), + }) + require.NotNil(t, total) + assert.InDelta(t, 10000, *total, 1e-9) + assert.Nil(t, apy) + }) + + t.Run("null suppliedUsd nulls the rate but keeps the total", func(t *testing.T) { + total, apy := accountAggregate([]wbtypes.BlendPoolPosition{ + pool(f64(9000), f64(9000), f64(0.05)), + pool(f64(1000), nil, f64(0.01)), }) require.NotNil(t, total) assert.InDelta(t, 10000, *total, 1e-9) @@ -175,9 +191,9 @@ func TestAccountAggregate(t *testing.T) { assert.Nil(t, apy) }) - t.Run("zero net base yields null apy, zero total", func(t *testing.T) { - total, apy := accountAggregate([]types.BlendPoolPosition{ - pool(f64(0), f64(0.05)), + t.Run("zero supplied base yields null apy", func(t *testing.T) { + total, apy := accountAggregate([]wbtypes.BlendPoolPosition{ + pool(f64(0), f64(0), f64(0.05)), }) require.NotNil(t, total) assert.Equal(t, 0.0, *total) @@ -188,14 +204,14 @@ func TestAccountAggregate(t *testing.T) { func TestGetAccountPositionsMapsAndPassesThrough(t *testing.T) { name := "TestnetV2" mockWB := &utils.MockWalletBackendService{ - GetBlendPositionsResult: &types.BlendAccountPositions{ - Pools: []types.BlendPoolPosition{{ + GetBlendPositionsResult: &wbtypes.BlendAccountPositions{ + Pools: []wbtypes.BlendPoolPosition{{ PoolAddress: "CCEBVDYMCCECIVWVOJSKUNLTVDIRLTRUCVZDVLKXKQZWSCF3DVQGJVIX", PoolName: &name, - USDValue: f64(3619.267393206), - SuppliedUSD: f64(3619.287393206), - BorrowedUSD: f64(0.02), - NetAPY: f64(2.5245032003462415), + UsdValue: f64(3619.267393206), + SuppliedUsd: f64(3619.287393206), + BorrowedUsd: f64(0.02), + NetApy: f64(2.5245032003462415), Reserves: reserveFixture(), }}, }, diff --git a/internal/types/positions.go b/internal/types/positions.go index dbdeb8a..f1d6f65 100644 --- a/internal/types/positions.go +++ b/internal/types/positions.go @@ -22,8 +22,9 @@ type AccountPositions struct { // matching upstream's own convention for pool totals. 0 when the // account has no positions. TotalValueUSD *float64 `json:"total_value_usd"` - // NetAPY is the NetUSD-weighted mean of the pools' net APYs; null when - // any input is unavailable or the account has no priced value to weight. + // NetAPY is the supplied-USD-weighted mean of the pools' net APYs + // (matching the base the per-pool rate is defined over); null when any + // input is unavailable or there is no supplied value to weight. NetAPY *float64 `json:"net_apy"` // Positions has one row per (protocol, pool). Always non-nil; empty when // the account has no DeFi positions (including accounts unknown to the @@ -45,7 +46,9 @@ type PoolPosition struct { NetUSD *float64 `json:"net_usd"` SuppliedUSD *float64 `json:"supplied_usd"` BorrowedUSD *float64 `json:"borrowed_usd"` - // NetAPY is the account's net rate in this pool, as computed upstream. + // NetAPY is the account's net rate in this pool as computed upstream: + // supply earnings minus borrow interest over TOTAL SUPPLIED USD (the + // blend-sdk-js convention the Blend UI shows). NetAPY *float64 `json:"net_apy"` Blend *BlendPositionDetail `json:"blend,omitempty"` } @@ -78,7 +81,8 @@ type BlendSupplyRow struct { // USDValue is the current USD value of TotalTokens. USDValue *float64 `json:"usd_value"` // APY is the current supply interest rate; EmissionsAPR is the BLND - // emission rate on the supply side. + // emission rate on the reserve's supply side (a pool-wide stream rate: + // 0 = no active stream, null = stream active but unpriceable). APY *float64 `json:"apy"` EmissionsAPR *float64 `json:"emissions_apr"` // InterestEarned is lifetime interest in raw token units (pure @@ -105,9 +109,11 @@ type BlendBorrowRow struct { BorrowedTokens string `json:"borrowed_tokens"` // USDValue is the current USD value of the debt. USDValue *float64 `json:"usd_value"` - // APY is the current borrow interest rate. - APY *float64 `json:"apy"` - PriceUSD *float64 `json:"price_usd"` + // APY is the current borrow interest rate; EmissionsAPR is the BLND + // emission rate on the reserve's borrow side. + APY *float64 `json:"apy"` + EmissionsAPR *float64 `json:"emissions_apr"` + PriceUSD *float64 `json:"price_usd"` } // PositionsService assembles the account positions view. From 47281f64ae3830df3b3a2d819100dfa04ac3e33e Mon Sep 17 00:00:00 2001 From: jiahuihu Date: Fri, 24 Jul 2026 15:55:21 -0400 Subject: [PATCH 09/15] feat(api): derive Blend earn options from the pools catalog --- internal/services/blend_catalog.go | 132 +++++++++++-------- internal/services/blend_catalog_test.go | 162 ++++++++++++++---------- internal/types/blend_catalog.go | 16 +-- 3 files changed, 182 insertions(+), 128 deletions(-) diff --git a/internal/services/blend_catalog.go b/internal/services/blend_catalog.go index 382d5f0..188f077 100644 --- a/internal/services/blend_catalog.go +++ b/internal/services/blend_catalog.go @@ -1,5 +1,6 @@ -// ABOUTME: Blend market-catalog service: pool and earn-option views from -// ABOUTME: wallet-backend, with per-network caching and earn-pool curation. +// 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 (wallet-backend serves no earn query). package services import ( @@ -7,9 +8,12 @@ import ( "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" @@ -22,7 +26,6 @@ const ( defaultCatalogCacheTTL = 60 * time.Second blendPoolsCacheKeyPrefix = "blend:pools:v1" - blendEarnCacheKeyPrefix = "blend:earn:v1" ) // earnPoolsAllowlist maps network name (PUBLIC/TESTNET) to the set of pool @@ -113,30 +116,28 @@ func (b *blendCatalogService) GetPools(ctx context.Context, network string) (_ * return result, nil } -// GetEarnOptions returns the earn catalog, allowlist-filtered and cached per -// network (the cache stores the post-filter result). +// 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) }() - cacheKey := fmt.Sprintf("%s:%s", blendEarnCacheKeyPrefix, strings.ToLower(network)) - if cached, ok := cacheGet[types.BlendEarnOptionsCatalog](ctx, b.redis, cacheKey); ok { - return cached, nil - } - - options, err := b.walletBackend.GetBlendEarnOptions(ctx, network) + catalog, err := b.GetPools(ctx, network) if err != nil { return nil, err } - result := &types.BlendEarnOptionsCatalog{Options: mapEarnOptions(options, b.allowlist[strings.ToUpper(network)])} - cacheSet(ctx, b.redis, cacheKey, result, b.cacheTTL) - return result, nil + return &types.BlendEarnOptionsCatalog{ + Options: deriveEarnOptions(catalog.Pools, b.allowlist[strings.ToUpper(network)]), + }, nil } -func mapCatalogPools(pools []types.BlendPool) []types.BlendCatalogPool { +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)) @@ -148,58 +149,89 @@ func mapCatalogPools(pools []types.BlendPool) []types.BlendCatalogPool { 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, + 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: p.Status, - SuppliedUSD: p.SuppliedUSD, - BorrowedUSD: p.BorrowedUSD, - InterestAPY: p.InterestAPY, - NetAPY: p.NetAPY, + Status: (*string)(p.Status), + SuppliedUSD: p.SuppliedUsd, + BorrowedUSD: p.BorrowedUsd, + InterestAPY: p.InterestApy, + NetAPY: p.NetApy, Reserves: reserves, }) } return out } -// mapEarnOptions shapes the earn catalog, dropping pools outside the -// allowlist (when one is configured) and assets left with no pools. -func mapEarnOptions(options []types.BlendEarnOption, allowed map[string]bool) []types.BlendEarnAssetOption { - out := make([]types.BlendEarnAssetOption, 0, len(options)) - for _, option := range options { - pools := make([]types.BlendEarnPool, 0, len(option.Pools)) - for _, p := range option.Pools { - if allowed != nil && !allowed[p.PoolAddress] { +// 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), mirroring the ordering the +// upstream earn query used before it was removed. +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 } - pools = append(pools, types.BlendEarnPool{ - ID: p.PoolAddress, - Name: p.PoolName, - SupplyAPY: p.SupplyAPY, - EmissionsSupplyAPR: p.EmissionsSupplyAPR, - SuppliedUSD: p.SuppliedUSD, + 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, }) } - if len(pools) == 0 { - continue - } - out = append(out, types.BlendEarnAssetOption{ - AssetID: option.AssetContractID, - Symbol: option.TokenSymbol, - Name: option.TokenName, - Decimals: option.TokenDecimals, - Pools: pools, + } + + 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) } - return out + 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 diff --git a/internal/services/blend_catalog_test.go b/internal/services/blend_catalog_test.go index c99cfcf..de68b18 100644 --- a/internal/services/blend_catalog_test.go +++ b/internal/services/blend_catalog_test.go @@ -1,5 +1,5 @@ -// ABOUTME: Tests for the Blend catalog service: allowlist loading/curation, -// ABOUTME: catalog mapping passthrough, and error propagation. +// ABOUTME: Tests for the Blend catalog service: allowlist loading, catalog +// ABOUTME: mapping, and the earn-options derivation (filtering, grouping, order). package services import ( @@ -12,6 +12,8 @@ import ( "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" ) @@ -19,6 +21,7 @@ import ( const ( curatedPool = "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD" uncuratedPool = "CCCCIQSDILITHMM7PBSLVDT5MISSY7R26MNZXCX4H7J5JQ5FPIYOGYFS" + frozenPool = "CFROZENFROZENFROZENFROZENFROZENFROZENFROZENFROZENFROZEN" ) func writeAllowlist(t *testing.T, content string) string { @@ -28,23 +31,43 @@ func writeAllowlist(t *testing.T, content string) string { return path } -func earnOptionsFixture() []types.BlendEarnOption { - usdc, xlm := "USDC", "XLM" - return []types.BlendEarnOption{ +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)}, + }, + }, { - AssetContractID: "CUSDC", - TokenSymbol: &usdc, - Pools: []types.BlendEarnPoolOption{ - {PoolAddress: curatedPool, SupplyAPY: f64(0.043)}, - {PoolAddress: uncuratedPool, SupplyAPY: f64(0.032)}, + 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)}, }, }, { - // Every pool for this asset is uncurated: the asset must drop. - AssetContractID: "CXLM", - TokenSymbol: &xlm, - Pools: []types.BlendEarnPoolOption{ - {PoolAddress: uncuratedPool, SupplyAPY: f64(0.001)}, + Address: "CPENDINGPOOLNOSTATUSYET", + Status: nil, // config not ingested: excluded + Reserves: []wbtypes.BlendReserve{ + {AssetContractID: "CUSDC", TokenSymbol: &usdc, Enabled: true, SupplyApy: f64(0.5), SuppliedUsd: f64(5)}, }, }, } @@ -74,105 +97,104 @@ func TestLoadEarnPoolsAllowlist(t *testing.T) { }) } -func TestGetEarnOptionsCuration(t *testing.T) { - mockWB := &utils.MockWalletBackendService{GetBlendEarnOptionsResult: earnOptionsFixture()} +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": ["`+curatedPool+`"]}`) + 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) - // XLM (only uncurated pools) is gone; USDC keeps only the curated pool. require.Len(t, got.Options, 1) - assert.Equal(t, "CUSDC", got.Options[0].AssetID) require.Len(t, got.Options[0].Pools, 1) - assert.Equal(t, curatedPool, got.Options[0].Pools[0].ID) + assert.Equal(t, uncuratedPool, got.Options[0].Pools[0].ID) }) - t.Run("no allowlist passes everything through", func(t *testing.T) { - svc, err := NewBlendCatalogService(mockWB, nil, 0, "", nil) + 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, 2) + require.Len(t, got.Options, 1) assert.Len(t, got.Options[0].Pools, 2) }) - t.Run("allowlist for another network filters everything", func(t *testing.T) { - path := writeAllowlist(t, `{"PUBLIC": ["`+curatedPool+`"]}`) - svc, err := NewBlendCatalogService(mockWB, nil, 0, path, nil) + 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) - // TESTNET has no allowlist entry -> allowed set is nil for that - // network -> no curation applies there. - assert.Len(t, got.Options, 2) + 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) { - name := "Fixed Pool V2" - usdc := "USDC" - mockWB := &utils.MockWalletBackendService{ - GetBlendPoolsResult: []types.BlendPool{{ - Address: curatedPool, - Name: &name, - Status: i32(types.BlendPoolStatusActive), - SuppliedUSD: f64(2100000.5), - InterestAPY: f64(0.043), - NetAPY: f64(0.047), - Reserves: []types.BlendReserve{{ - AssetContractID: "CUSDC", - TokenSymbol: &usdc, - Enabled: true, - Utilization: f64(0.62), - SupplyAPY: f64(0.043), - EmissionsSupplyAPR: f64(0.008), - PriceUSD: f64(1.0), - }}, - }, { - // Not-yet-ingested pool: everything null. - Address: uncuratedPool, - Reserves: []types.BlendReserve{}, - }}, - } - svc, err := NewBlendCatalogService(mockWB, nil, 0, "", nil) + 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) - require.Len(t, got.Pools, 2) + // 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, types.BlendPoolStatusActive, *pool.Status) - require.Len(t, pool.Reserves, 1) + assert.Equal(t, string(wbtypes.BlendPoolStatusActive), *pool.Status) + require.Len(t, pool.Reserves, 2) assert.True(t, pool.Reserves[0].Enabled) - require.NotNil(t, pool.Reserves[0].EmissionsSupplyAPR) - assert.InDelta(t, 0.008, *pool.Reserves[0].EmissionsSupplyAPR, 1e-9) - - // The pools catalog is never allowlist-filtered. - assert.Equal(t, uncuratedPool, got.Pools[1].ID) - assert.Nil(t, got.Pools[1].Status) - assert.NotNil(t, got.Pools[1].Reserves) + 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, - GetBlendEarnOptionsError: upErr, - }, nil, 0, "", nil) + 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 index 39bc3c9..6b24021 100644 --- a/internal/types/blend_catalog.go +++ b/internal/types/blend_catalog.go @@ -21,11 +21,11 @@ type BlendCatalogPool struct { ID string `json:"id"` // Name is null when the metadata registry has no entry. Name *string `json:"name"` - // Status is the raw on-chain pool status (0 Admin Active, 1 Active, - // 2 Admin On-Ice, 3 On-Ice, 4 Admin Frozen, 5 Frozen, 6 Setup; - // 0-3 accept deposits, 0-1 also allow borrowing). Null until the pool's - // config has been ingested. - Status *int32 `json:"status"` + // 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"` @@ -60,9 +60,9 @@ type BlendCatalogReserve struct { // 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. Upstream already excludes disabled reserves and -// pools that reject deposits; freighter additionally filters pools through -// the operator-curated allowlist when one is configured. +// 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. From eb3d81e0f86bfa3997692c3cd617cd8c4609d749 Mon Sep 17 00:00:00 2001 From: jiahuihu Date: Mon, 27 Jul 2026 13:10:28 -0400 Subject: [PATCH 10/15] feat(config): drop positions cache TTL, add earn-pools allowlist config --- cmd/serve/serve.go | 4 ---- cmd/serve/serve_test.go | 10 ++-------- configs/earn-pools.json | 8 ++++++++ internal/config/config.go | 4 ---- 4 files changed, 10 insertions(+), 16 deletions(-) create mode 100644 configs/earn-pools.json diff --git a/cmd/serve/serve.go b/cmd/serve/serve.go index 9deb591..ceb7df4 100644 --- a/cmd/serve/serve.go +++ b/cmd/serve/serve.go @@ -45,9 +45,6 @@ func (s *ServeCmd) Command() *cobra.Command { if d, m := s.Cfg.AppConfig.AccountHistoryDefaultLimit, s.Cfg.AppConfig.AccountHistoryMaxLimit; d <= 0 || m <= 0 || d > m || m > handlers.AccountHistoryUpstreamMaxLimit { return fmt.Errorf("--account-history-default-limit=%d / --account-history-max-limit=%d must be positive, default <= max, and max <= %d", d, m, handlers.AccountHistoryUpstreamMaxLimit) } - if n := s.Cfg.BlendConfig.PositionsCacheTTLSeconds; n < 0 { - return fmt.Errorf("--blend-positions-cache-ttl-seconds=%d must be >= 0", n) - } if n := s.Cfg.BlendConfig.CatalogCacheTTLSeconds; n < 0 { return fmt.Errorf("--blend-catalog-cache-ttl-seconds=%d must be >= 0", n) } @@ -143,7 +140,6 @@ func (s *ServeCmd) Command() *cobra.Command { cmd.Flags().StringVar(&s.Cfg.WalletBackendConfig.TestnetSigningKey, "wallet-backend-testnet-signing-key", "", "Wallet backend testnet JWT signing key (Stellar secret key)") // Blend Config (positions/catalog endpoints backed by wallet-backend's Blend GraphQL) - cmd.Flags().IntVar(&s.Cfg.BlendConfig.PositionsCacheTTLSeconds, "blend-positions-cache-ttl-seconds", 30, "TTL for cached per-address Blend position responses in Redis (seconds)") cmd.Flags().IntVar(&s.Cfg.BlendConfig.CatalogCacheTTLSeconds, "blend-catalog-cache-ttl-seconds", 60, "TTL for the cached per-network Blend market views (pools, earn options) in Redis (seconds)") cmd.Flags().StringVar(&s.Cfg.BlendConfig.EarnPoolsConfigPath, "earn-pools-config-path", "", "Path to the JSON allowlist of Blend pool contract IDs offered in the Earn flow; curates earn-options only, never user positions. Empty disables curation.") diff --git a/cmd/serve/serve_test.go b/cmd/serve/serve_test.go index 8729106..763c985 100644 --- a/cmd/serve/serve_test.go +++ b/cmd/serve/serve_test.go @@ -188,7 +188,7 @@ func TestServeCmd_RejectsNegativePriceFetchTimeout(t *testing.T) { assert.Contains(t, err.Error(), "--price-fetch-timeout-seconds=-1 must be >= 0") } -func TestServeCmd_ValidatesBlendCacheTTLs(t *testing.T) { +func TestServeCmd_ValidatesBlendCatalogCacheTTL(t *testing.T) { t.Parallel() testCases := []struct { @@ -196,11 +196,6 @@ func TestServeCmd_ValidatesBlendCacheTTLs(t *testing.T) { args []string wantErr string }{ - { - name: "rejects negative positions cache TTL", - args: []string{"--blend-positions-cache-ttl-seconds", "-1"}, - wantErr: "--blend-positions-cache-ttl-seconds=-1 must be >= 0", - }, { name: "rejects negative catalog cache TTL", args: []string{"--blend-catalog-cache-ttl-seconds", "-30"}, @@ -209,9 +204,8 @@ func TestServeCmd_ValidatesBlendCacheTTLs(t *testing.T) { { // Zero is the documented boundary: accepted by validation // (consumers substitute their own defaults for non-positive TTLs). - name: "accepts zero for both TTLs", + name: "accepts zero TTL", args: []string{ - "--blend-positions-cache-ttl-seconds", "0", "--blend-catalog-cache-ttl-seconds", "0", "--database-url", "postgres://localhost/test", }, diff --git a/configs/earn-pools.json b/configs/earn-pools.json new file mode 100644 index 0000000..459254e --- /dev/null +++ b/configs/earn-pools.json @@ -0,0 +1,8 @@ +{ + "PUBLIC": [ + "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD" + ], + "TESTNET": [ + "CCEBVDYM32YNYCVNRXQKDFFPISJJCV557CDZEIRBEE4NCV4KHPQ44HGF" + ] +} diff --git a/internal/config/config.go b/internal/config/config.go index c0bcebd..9f19534 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -152,10 +152,6 @@ type WalletBackendConfig struct { // from wallet-backend's Blend GraphQL surface (URLs and signing keys come // from WalletBackendConfig). type BlendConfig struct { - // PositionsCacheTTLSeconds is the Redis TTL for per-address position - // responses. User-visible staleness is this TTL plus wallet-backend's - // own ingestion lag, so keep it short. - PositionsCacheTTLSeconds int // CatalogCacheTTLSeconds is the Redis TTL for the address-independent // market views (pools, earn options). One cache entry per network serves // every user. From 20f7f91703cd90050611ce6b45ea7882b7e33888 Mon Sep 17 00:00:00 2001 From: jiahuihu Date: Mon, 27 Jul 2026 16:52:54 -0400 Subject: [PATCH 11/15] feat(api): fetch positions per request, drop Redis caching --- internal/api/serve.go | 7 +--- internal/services/positions.go | 55 +++++------------------------ internal/services/positions_test.go | 6 ++-- 3 files changed, 12 insertions(+), 56 deletions(-) diff --git a/internal/api/serve.go b/internal/api/serve.go index 8f663e3..c455df9 100644 --- a/internal/api/serve.go +++ b/internal/api/serve.go @@ -222,12 +222,7 @@ func (s *ApiServer) routes() ([]route, error) { } whoamiHandler := handlers.NewWhoamiHandler() - positionsService := services.NewPositionsService( - s.walletBackendService, - s.redis, - time.Duration(s.cfg.BlendConfig.PositionsCacheTTLSeconds)*time.Second, - s.appMetrics.Service, - ) + positionsService := services.NewPositionsService(s.walletBackendService, s.appMetrics.Service) accountPositionsHandler := handlers.NewAccountPositionsHandler(positionsService) return []route{ diff --git a/internal/services/positions.go b/internal/services/positions.go index 03be24f..86668d2 100644 --- a/internal/services/positions.go +++ b/internal/services/positions.go @@ -1,89 +1,50 @@ // ABOUTME: Positions service: maps wallet-backend Blend positions into the -// ABOUTME: frontend-shaped account positions response, with per-address caching. +// ABOUTME: frontend-shaped account positions response. package services import ( "context" - "fmt" "math" "math/big" - "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 ( - positionsServiceName = "positions" - - defaultPositionsCacheTTL = 30 * time.Second - - // positionsCacheKeyPrefix versions the cached response shape; bump on - // breaking changes so stale entries die at the key level. - positionsCacheKeyPrefix = "blend:positions:v1" -) +const positionsServiceName = "positions" type positionsService struct { walletBackend types.WalletBackendService - redis *store.RedisStore - cacheTTL time.Duration svcMetrics *metrics.Service } -// NewPositionsService wires the positions view. redis may be nil; every -// request then bypasses the cache and hits wallet-backend. -func NewPositionsService(walletBackend types.WalletBackendService, redis *store.RedisStore, cacheTTL time.Duration, m *metrics.Service) types.PositionsService { - if cacheTTL <= 0 { - cacheTTL = defaultPositionsCacheTTL - } +// NewPositionsService wires the positions view. +func NewPositionsService(walletBackend types.WalletBackendService, m *metrics.Service) types.PositionsService { return &positionsService{ walletBackend: walletBackend, - redis: redis, - cacheTTL: cacheTTL, svcMetrics: m, } } func (p *positionsService) Name() string { return positionsServiceName } -// GetAccountPositions returns the account's positions, cached per -// (network, address) for cacheTTL. User-visible staleness is the TTL plus -// wallet-backend's own ingestion lag. +// GetAccountPositions returns the account's positions, fetched from +// wallet-backend on every request (like balances): no caching, so a fresh +// deposit is visible as soon as the indexer ingests it. func (p *positionsService) GetAccountPositions(ctx context.Context, address, network string) (_ *types.AccountPositions, err error) { start := time.Now() defer func() { metrics.Record(p.svcMetrics, positionsServiceName, "GetAccountPositions", network, time.Since(start).Seconds(), err) }() - cacheKey := fmt.Sprintf("%s:%s:%s", positionsCacheKeyPrefix, strings.ToLower(network), address) - if p.redis != nil { - hits, cacheErr := p.redis.MGetJSON(ctx, []string{cacheKey}, func() any { return &types.AccountPositions{} }) - if cacheErr != nil { - // Cache trouble must not fail the request; fall through to upstream. - logger.ErrorWithContext(ctx, "positions cache read failed", "error", cacheErr) - } else if hit, ok := hits[cacheKey].(*types.AccountPositions); ok { - return hit, nil - } - } - upstream, err := p.walletBackend.GetBlendPositions(ctx, address, network) if err != nil { return nil, err } - - result := mapAccountPositions(upstream) - - if p.redis != nil { - if cacheErr := p.redis.SetJSON(ctx, cacheKey, result, p.cacheTTL); cacheErr != nil { - logger.ErrorWithContext(ctx, "positions cache write failed", "error", cacheErr) - } - } - return result, nil + return mapAccountPositions(upstream), nil } // mapAccountPositions shapes the upstream Blend positions into the response. diff --git a/internal/services/positions_test.go b/internal/services/positions_test.go index 77e7d41..fb1d696 100644 --- a/internal/services/positions_test.go +++ b/internal/services/positions_test.go @@ -216,7 +216,7 @@ func TestGetAccountPositionsMapsAndPassesThrough(t *testing.T) { }}, }, } - svc := NewPositionsService(mockWB, nil, 0, nil) + svc := NewPositionsService(mockWB, nil) got, err := svc.GetAccountPositions(context.Background(), "GDW6QB3BFPQ3I4LH752JD2HYADFM2T4RVRCEUNCCH7MICWZR67NL5552", types.TESTNET) require.NoError(t, err) @@ -241,7 +241,7 @@ func TestGetAccountPositionsMapsAndPassesThrough(t *testing.T) { } func TestGetAccountPositionsEmptyAccount(t *testing.T) { - svc := NewPositionsService(&utils.MockWalletBackendService{}, nil, 0, nil) + svc := NewPositionsService(&utils.MockWalletBackendService{}, nil) got, err := svc.GetAccountPositions(context.Background(), "GDW6QB3BFPQ3I4LH752JD2HYADFM2T4RVRCEUNCCH7MICWZR67NL5552", types.TESTNET) require.NoError(t, err) @@ -254,7 +254,7 @@ func TestGetAccountPositionsEmptyAccount(t *testing.T) { func TestGetAccountPositionsUpstreamError(t *testing.T) { upErr := errors.New("wallet-backend on fire") - svc := NewPositionsService(&utils.MockWalletBackendService{GetBlendPositionsError: upErr}, nil, 0, nil) + svc := NewPositionsService(&utils.MockWalletBackendService{GetBlendPositionsError: upErr}, nil) _, err := svc.GetAccountPositions(context.Background(), "GDW6QB3BFPQ3I4LH752JD2HYADFM2T4RVRCEUNCCH7MICWZR67NL5552", types.TESTNET) assert.ErrorIs(t, err, upErr) From 5726316f56089ea3b5d5a33abe01474767ddafde Mon Sep 17 00:00:00 2001 From: jiahuihu Date: Mon, 27 Jul 2026 17:00:36 -0400 Subject: [PATCH 12/15] feat(api): multi-address positions endpoint --- internal/api/handlers/account_positions.go | 33 +++++---- .../api/handlers/account_positions_test.go | 69 ++++++++++++------- internal/api/serve.go | 10 ++- internal/services/positions.go | 62 +++++++++++++---- internal/services/positions_test.go | 28 +++++--- internal/types/positions.go | 9 ++- internal/utils/mocks.go | 22 +++--- 7 files changed, 153 insertions(+), 80 deletions(-) diff --git a/internal/api/handlers/account_positions.go b/internal/api/handlers/account_positions.go index 11ee1d6..0bd7642 100644 --- a/internal/api/handlers/account_positions.go +++ b/internal/api/handlers/account_positions.go @@ -1,5 +1,5 @@ -// ABOUTME: Handler for GET /api/v1/accounts/{address}/positions — an account's -// ABOUTME: DeFi positions (Blend), served from the positions service. +// ABOUTME: Handler for POST /api/v1/accounts/positions — DeFi positions (Blend) +// ABOUTME: for a list of accounts, mirroring the balances endpoint's contract. package handlers import ( @@ -9,8 +9,6 @@ import ( "net/http" "time" - "github.com/stellar/go/strkey" - "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" @@ -20,16 +18,18 @@ const accountPositionsContextTimeout = 10 * time.Second type AccountPositionsHandler struct { PositionsService types.PositionsService + MaxAddresses int } -func NewAccountPositionsHandler(positionsService types.PositionsService) *AccountPositionsHandler { - return &AccountPositionsHandler{PositionsService: positionsService} +func NewAccountPositionsHandler(positionsService types.PositionsService, maxAddresses int) *AccountPositionsHandler { + return &AccountPositionsHandler{PositionsService: positionsService, MaxAddresses: maxAddresses} } -// GetAccountPositions handles GET /api/v1/accounts/{address}/positions. -// An account with no positions (including one unknown to the indexer) -// returns 200 with an empty positions list, not 404. -func (h *AccountPositionsHandler) GetAccountPositions(w http.ResponseWriter, r *http.Request) error { +// GetAccountsPositions handles POST /api/v1/accounts/positions. The request +// body is {"addresses": [...]}, identical to the balances endpoint. Accounts +// with no positions (including accounts unknown to the indexer) return empty +// positions inside a 200, not an error. +func (h *AccountPositionsHandler) GetAccountsPositions(w http.ResponseWriter, r *http.Request) error { ctx, cancel := context.WithTimeout(r.Context(), accountPositionsContextTimeout) defer cancel() @@ -38,14 +38,17 @@ func (h *AccountPositionsHandler) GetAccountPositions(w http.ResponseWriter, r * return httperror.BadRequest(fmt.Sprintf("invalid network: must be %s or %s", types.PUBLIC, types.TESTNET), errors.New("invalid network")) } - address := r.PathValue("address") - if _, err := strkey.Decode(strkey.VersionByteAccountID, address); err != nil { - return httperror.BadRequest(fmt.Sprintf("invalid Stellar address %s: %s", address, err.Error()), err) + req, validationErr := validateAccountBalancesRequest(r, h.MaxAddresses) + if validationErr != nil { + return validationErr } - positions, err := h.PositionsService.GetAccountPositions(ctx, address, network) + positions, err := h.PositionsService.GetAccountsPositions(ctx, req.Addresses, network) if err != nil { - return translateServiceError(r.Context(), err, "account positions", address, network) + // address is intentionally empty: this is a multi-address fan-out and + // a top-level error here is systemic (per-address "no positions" + // outcomes are already normalized inside a 200). + return translateServiceError(r.Context(), err, "account positions", "", network) } return response.OK(w, HttpResponse{Data: positions}) diff --git a/internal/api/handlers/account_positions_test.go b/internal/api/handlers/account_positions_test.go index e00a322..930fcf4 100644 --- a/internal/api/handlers/account_positions_test.go +++ b/internal/api/handlers/account_positions_test.go @@ -1,4 +1,4 @@ -// ABOUTME: Handler tests for GET /api/v1/accounts/{address}/positions: +// ABOUTME: Handler tests for POST /api/v1/accounts/positions: request // ABOUTME: validation, error translation, and the success envelope. package handlers @@ -7,6 +7,7 @@ import ( "errors" "net/http" "net/http/httptest" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -19,78 +20,94 @@ import ( const positionsTestAddress = "GDW6QB3BFPQ3I4LH752JD2HYADFM2T4RVRCEUNCCH7MICWZR67NL5552" -func servePositions(t *testing.T, svc types.PositionsService, target string) *httptest.ResponseRecorder { +func servePositions(t *testing.T, svc types.PositionsService, target, body string) *httptest.ResponseRecorder { t.Helper() - handler := NewAccountPositionsHandler(svc) + handler := NewAccountPositionsHandler(svc, 100) mux := http.NewServeMux() - mux.HandleFunc("GET /api/v1/accounts/{address}/positions", CustomHandler(handler.GetAccountPositions)) + mux.HandleFunc("POST /api/v1/accounts/positions", CustomHandler(handler.GetAccountsPositions)) rec := httptest.NewRecorder() - mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil)) + mux.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, target, strings.NewReader(body))) return rec } -func TestGetAccountPositionsSuccess(t *testing.T) { +func TestGetAccountsPositionsSuccess(t *testing.T) { total := 3619.27 svc := &utils.MockPositionsService{ - GetAccountPositionsResult: &types.AccountPositions{ + GetAccountsPositionsResult: []*types.AccountPositions{{ + Address: positionsTestAddress, TotalValueUSD: &total, Positions: []types.PoolPosition{{ Protocol: "blend", ID: "CCEBVDYMCCECIVWVOJSKUNLTVDIRLTRUCVZDVLKXKQZWSCF3DVQGJVIX", }}, - }, + }}, } - rec := servePositions(t, svc, "/api/v1/accounts/"+positionsTestAddress+"/positions?network=TESTNET") + rec := servePositions(t, svc, "/api/v1/accounts/positions?network=TESTNET", `{"addresses": ["`+positionsTestAddress+`"]}`) require.Equal(t, http.StatusOK, rec.Code) var body struct { - Data types.AccountPositions `json:"data"` + Data []types.AccountPositions `json:"data"` } require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body)) - require.NotNil(t, body.Data.TotalValueUSD) - assert.InDelta(t, 3619.27, *body.Data.TotalValueUSD, 1e-9) - require.Len(t, body.Data.Positions, 1) - assert.Equal(t, "blend", body.Data.Positions[0].Protocol) + require.Len(t, body.Data, 1) + assert.Equal(t, positionsTestAddress, body.Data[0].Address) + require.NotNil(t, body.Data[0].TotalValueUSD) + assert.InDelta(t, 3619.27, *body.Data[0].TotalValueUSD, 1e-9) + require.Len(t, body.Data[0].Positions, 1) + assert.Equal(t, "blend", body.Data[0].Positions[0].Protocol) } -func TestGetAccountPositionsValidation(t *testing.T) { +func TestGetAccountsPositionsValidation(t *testing.T) { svc := &utils.MockPositionsService{} + valid := `{"addresses": ["` + positionsTestAddress + `"]}` t.Run("invalid network", func(t *testing.T) { - rec := servePositions(t, svc, "/api/v1/accounts/"+positionsTestAddress+"/positions?network=DOGENET") + rec := servePositions(t, svc, "/api/v1/accounts/positions?network=DOGENET", valid) assert.Equal(t, http.StatusBadRequest, rec.Code) }) t.Run("missing network", func(t *testing.T) { - rec := servePositions(t, svc, "/api/v1/accounts/"+positionsTestAddress+"/positions") + rec := servePositions(t, svc, "/api/v1/accounts/positions", valid) + assert.Equal(t, http.StatusBadRequest, rec.Code) + }) + + t.Run("empty addresses", func(t *testing.T) { + rec := servePositions(t, svc, "/api/v1/accounts/positions?network=TESTNET", `{"addresses": []}`) assert.Equal(t, http.StatusBadRequest, rec.Code) }) t.Run("invalid address", func(t *testing.T) { - rec := servePositions(t, svc, "/api/v1/accounts/not-an-address/positions?network=TESTNET") + rec := servePositions(t, svc, "/api/v1/accounts/positions?network=TESTNET", `{"addresses": ["nope"]}`) + assert.Equal(t, http.StatusBadRequest, rec.Code) + }) + + t.Run("invalid json", func(t *testing.T) { + rec := servePositions(t, svc, "/api/v1/accounts/positions?network=TESTNET", `{`) assert.Equal(t, http.StatusBadRequest, rec.Code) }) } -func TestGetAccountPositionsErrorTranslation(t *testing.T) { +func TestGetAccountsPositionsErrorTranslation(t *testing.T) { + body := `{"addresses": ["` + positionsTestAddress + `"]}` + t.Run("upstream error maps to 502", func(t *testing.T) { svc := &utils.MockPositionsService{ - GetAccountPositionsError: &metrics.UpstreamError{Kind: "graphql_error", Err: errors.New("boom")}, + GetAccountsPositionsError: &metrics.UpstreamError{Kind: "graphql_error", Err: errors.New("boom")}, } - rec := servePositions(t, svc, "/api/v1/accounts/"+positionsTestAddress+"/positions?network=TESTNET") + rec := servePositions(t, svc, "/api/v1/accounts/positions?network=TESTNET", body) assert.Equal(t, http.StatusBadGateway, rec.Code) }) t.Run("unclassified error maps to 500", func(t *testing.T) { - svc := &utils.MockPositionsService{GetAccountPositionsError: errors.New("wat")} - rec := servePositions(t, svc, "/api/v1/accounts/"+positionsTestAddress+"/positions?network=TESTNET") + svc := &utils.MockPositionsService{GetAccountsPositionsError: errors.New("wat")} + rec := servePositions(t, svc, "/api/v1/accounts/positions?network=TESTNET", body) assert.Equal(t, http.StatusInternalServerError, rec.Code) }) } -func TestGetAccountPositionsEmptyIs200(t *testing.T) { - rec := servePositions(t, &utils.MockPositionsService{}, "/api/v1/accounts/"+positionsTestAddress+"/positions?network=TESTNET") +func TestGetAccountsPositionsEmptyIs200(t *testing.T) { + rec := servePositions(t, &utils.MockPositionsService{}, "/api/v1/accounts/positions?network=TESTNET", `{"addresses": ["`+positionsTestAddress+`"]}`) require.Equal(t, http.StatusOK, rec.Code) - assert.Contains(t, rec.Body.String(), `"positions":[]`) + assert.Contains(t, rec.Body.String(), `"data":[]`) } diff --git a/internal/api/serve.go b/internal/api/serve.go index c455df9..b657c4c 100644 --- a/internal/api/serve.go +++ b/internal/api/serve.go @@ -222,8 +222,12 @@ func (s *ApiServer) routes() ([]route, error) { } whoamiHandler := handlers.NewWhoamiHandler() - positionsService := services.NewPositionsService(s.walletBackendService, s.appMetrics.Service) - accountPositionsHandler := handlers.NewAccountPositionsHandler(positionsService) + positionsService := services.NewPositionsService( + s.walletBackendService, + s.cfg.AppConfig.WalletBackendBalanceConcurrency, + s.appMetrics.Service, + ) + accountPositionsHandler := handlers.NewAccountPositionsHandler(positionsService, s.cfg.AppConfig.MaxBalanceAddresses) return []route{ // Health/liveness/readiness probes: gated=false, registered BARE — never @@ -246,7 +250,7 @@ func (s *ApiServer) routes() ([]route, error) { {http.MethodPost, "/api/v1/accounts/balances", handlers.CustomHandler(accountBalancesHandler.GetAccountBalances), true}, {http.MethodPost, "/api/v1/token-prices", handlers.CustomHandler(tokenPricesHandler.GetPrices), true}, {http.MethodGet, "/api/v1/accounts/{address}/transactions", handlers.CustomHandler(accountHistoryHandler.GetAccountTransactions), true}, - {http.MethodGet, "/api/v1/accounts/{address}/positions", handlers.CustomHandler(accountPositionsHandler.GetAccountPositions), true}, + {http.MethodPost, "/api/v1/accounts/positions", handlers.CustomHandler(accountPositionsHandler.GetAccountsPositions), true}, {http.MethodGet, "/api/v1/auth/whoami", handlers.CustomHandler(whoamiHandler.Whoami), true}, }, nil } diff --git a/internal/services/positions.go b/internal/services/positions.go index 86668d2..a32939d 100644 --- a/internal/services/positions.go +++ b/internal/services/positions.go @@ -8,43 +8,75 @@ import ( "math/big" "time" + "golang.org/x/sync/errgroup" + wbtypes "github.com/stellar/wallet-backend/pkg/wbclient/types" "github.com/stellar/freighter-backend-v2/internal/metrics" "github.com/stellar/freighter-backend-v2/internal/types" + "github.com/stellar/freighter-backend-v2/internal/utils" ) -const positionsServiceName = "positions" +const ( + positionsServiceName = "positions" + + defaultPositionsConcurrency = 10 +) type positionsService struct { - walletBackend types.WalletBackendService - svcMetrics *metrics.Service + walletBackend types.WalletBackendService + maxConcurrency int + svcMetrics *metrics.Service } -// NewPositionsService wires the positions view. -func NewPositionsService(walletBackend types.WalletBackendService, m *metrics.Service) types.PositionsService { +// NewPositionsService wires the positions view. maxConcurrency caps the +// per-request fan-out goroutines, like the balances fan-out. +func NewPositionsService(walletBackend types.WalletBackendService, maxConcurrency int, m *metrics.Service) types.PositionsService { + if maxConcurrency <= 0 { + maxConcurrency = defaultPositionsConcurrency + } return &positionsService{ - walletBackend: walletBackend, - svcMetrics: m, + walletBackend: walletBackend, + maxConcurrency: maxConcurrency, + svcMetrics: m, } } func (p *positionsService) Name() string { return positionsServiceName } -// GetAccountPositions returns the account's positions, fetched from -// wallet-backend on every request (like balances): no caching, so a fresh -// deposit is visible as soon as the indexer ingests it. -func (p *positionsService) GetAccountPositions(ctx context.Context, address, network string) (_ *types.AccountPositions, err error) { +// GetAccountsPositions returns positions for each unique requested address, +// fetched from wallet-backend on every request (like balances): no caching, +// so a fresh deposit is visible as soon as the indexer ingests it. Unknown +// accounts are normal per-address outcomes (empty positions, already +// normalized by the wallet-backend service); any other failure is systemic +// and fails the whole request. +func (p *positionsService) GetAccountsPositions(ctx context.Context, addresses []string, network string) (_ []*types.AccountPositions, err error) { start := time.Now() defer func() { - metrics.Record(p.svcMetrics, positionsServiceName, "GetAccountPositions", network, time.Since(start).Seconds(), err) + metrics.Record(p.svcMetrics, positionsServiceName, "GetAccountsPositions", network, time.Since(start).Seconds(), err) }() - upstream, err := p.walletBackend.GetBlendPositions(ctx, address, network) - if err != nil { + unique := utils.DedupePreserveOrder(addresses) + results := make([]*types.AccountPositions, len(unique)) + + g, gctx := errgroup.WithContext(ctx) + g.SetLimit(p.maxConcurrency) + for i, addr := range unique { + g.Go(func() error { + upstream, fetchErr := p.walletBackend.GetBlendPositions(gctx, addr, network) + if fetchErr != nil { + return fetchErr + } + entry := mapAccountPositions(upstream) + entry.Address = addr + results[i] = entry + return nil + }) + } + if err = g.Wait(); err != nil { return nil, err } - return mapAccountPositions(upstream), nil + return results, nil } // mapAccountPositions shapes the upstream Blend positions into the response. diff --git a/internal/services/positions_test.go b/internal/services/positions_test.go index fb1d696..a893245 100644 --- a/internal/services/positions_test.go +++ b/internal/services/positions_test.go @@ -201,7 +201,7 @@ func TestAccountAggregate(t *testing.T) { }) } -func TestGetAccountPositionsMapsAndPassesThrough(t *testing.T) { +func TestGetAccountsPositionsMapsAndPassesThrough(t *testing.T) { name := "TestnetV2" mockWB := &utils.MockWalletBackendService{ GetBlendPositionsResult: &wbtypes.BlendAccountPositions{ @@ -216,10 +216,13 @@ func TestGetAccountPositionsMapsAndPassesThrough(t *testing.T) { }}, }, } - svc := NewPositionsService(mockWB, nil) + svc := NewPositionsService(mockWB, 0, nil) - got, err := svc.GetAccountPositions(context.Background(), "GDW6QB3BFPQ3I4LH752JD2HYADFM2T4RVRCEUNCCH7MICWZR67NL5552", types.TESTNET) + results, err := svc.GetAccountsPositions(context.Background(), []string{"GDW6QB3BFPQ3I4LH752JD2HYADFM2T4RVRCEUNCCH7MICWZR67NL5552"}, types.TESTNET) require.NoError(t, err) + require.Len(t, results, 1) + got := results[0] + assert.Equal(t, "GDW6QB3BFPQ3I4LH752JD2HYADFM2T4RVRCEUNCCH7MICWZR67NL5552", got.Address) require.Len(t, got.Positions, 1) row := got.Positions[0] @@ -240,11 +243,17 @@ func TestGetAccountPositionsMapsAndPassesThrough(t *testing.T) { assert.InDelta(t, 2.5245032003462415, *got.NetAPY, 1e-9) } -func TestGetAccountPositionsEmptyAccount(t *testing.T) { - svc := NewPositionsService(&utils.MockWalletBackendService{}, nil) +func TestGetAccountsPositionsEmptyAccountAndDedupe(t *testing.T) { + svc := NewPositionsService(&utils.MockWalletBackendService{}, 0, nil) - got, err := svc.GetAccountPositions(context.Background(), "GDW6QB3BFPQ3I4LH752JD2HYADFM2T4RVRCEUNCCH7MICWZR67NL5552", types.TESTNET) + // Duplicates collapse, first-seen order preserved — like balances. + results, err := svc.GetAccountsPositions(context.Background(), []string{ + "GDW6QB3BFPQ3I4LH752JD2HYADFM2T4RVRCEUNCCH7MICWZR67NL5552", + "GDW6QB3BFPQ3I4LH752JD2HYADFM2T4RVRCEUNCCH7MICWZR67NL5552", + }, types.TESTNET) require.NoError(t, err) + require.Len(t, results, 1) + got := results[0] assert.NotNil(t, got.Positions) assert.Empty(t, got.Positions) require.NotNil(t, got.TotalValueUSD) @@ -252,10 +261,11 @@ func TestGetAccountPositionsEmptyAccount(t *testing.T) { assert.Nil(t, got.NetAPY) } -func TestGetAccountPositionsUpstreamError(t *testing.T) { +func TestGetAccountsPositionsUpstreamError(t *testing.T) { upErr := errors.New("wallet-backend on fire") - svc := NewPositionsService(&utils.MockWalletBackendService{GetBlendPositionsError: upErr}, nil) + svc := NewPositionsService(&utils.MockWalletBackendService{GetBlendPositionsError: upErr}, 0, nil) - _, err := svc.GetAccountPositions(context.Background(), "GDW6QB3BFPQ3I4LH752JD2HYADFM2T4RVRCEUNCCH7MICWZR67NL5552", types.TESTNET) + // A systemic failure for any address fails the whole request. + _, err := svc.GetAccountsPositions(context.Background(), []string{"GDW6QB3BFPQ3I4LH752JD2HYADFM2T4RVRCEUNCCH7MICWZR67NL5552"}, types.TESTNET) assert.ErrorIs(t, err, upErr) } diff --git a/internal/types/positions.go b/internal/types/positions.go index f1d6f65..4cb8f2d 100644 --- a/internal/types/positions.go +++ b/internal/types/positions.go @@ -16,6 +16,9 @@ import "context" // are full-precision integer strings in the asset's smallest unit (scale by // Decimals for display). type AccountPositions struct { + // Address is the account this entry describes; one entry per requested + // address, in first-seen request order (duplicates collapsed). + Address string `json:"address"` // TotalValueUSD is the account's net position value across pools // (Σ pool NetUSD). Strict null propagation: if any pool's value is // unavailable the total is null rather than a silent undercount — @@ -119,5 +122,9 @@ type BlendBorrowRow struct { // PositionsService assembles the account positions view. type PositionsService interface { Service - GetAccountPositions(ctx context.Context, address, network string) (*AccountPositions, error) + // GetAccountsPositions fans out one wallet-backend positions fetch per + // unique address, mirroring the balances endpoint's semantics: unknown + // accounts are normal per-address outcomes (empty positions); any + // systemic upstream failure fails the whole request. + GetAccountsPositions(ctx context.Context, addresses []string, network string) ([]*AccountPositions, error) } diff --git a/internal/utils/mocks.go b/internal/utils/mocks.go index 8cdee9f..3f7c617 100644 --- a/internal/utils/mocks.go +++ b/internal/utils/mocks.go @@ -188,24 +188,24 @@ func (m *MockWalletBackendService) GetBlendPools(ctx context.Context, network st // MockPositionsService stubs types.PositionsService for handler tests. type MockPositionsService struct { - GetAccountPositionsResult *types.AccountPositions - GetAccountPositionsError error - GetAccountPositionsFunc func(ctx context.Context, address, network string) (*types.AccountPositions, error) + GetAccountsPositionsResult []*types.AccountPositions + GetAccountsPositionsError error + GetAccountsPositionsFunc func(ctx context.Context, addresses []string, network string) ([]*types.AccountPositions, error) } func (m *MockPositionsService) Name() string { return "mock-positions" } -func (m *MockPositionsService) GetAccountPositions(ctx context.Context, address, network string) (*types.AccountPositions, error) { - if m.GetAccountPositionsFunc != nil { - return m.GetAccountPositionsFunc(ctx, address, network) +func (m *MockPositionsService) GetAccountsPositions(ctx context.Context, addresses []string, network string) ([]*types.AccountPositions, error) { + if m.GetAccountsPositionsFunc != nil { + return m.GetAccountsPositionsFunc(ctx, addresses, network) } - if m.GetAccountPositionsError != nil { - return nil, m.GetAccountPositionsError + if m.GetAccountsPositionsError != nil { + return nil, m.GetAccountsPositionsError } - if m.GetAccountPositionsResult != nil { - return m.GetAccountPositionsResult, nil + if m.GetAccountsPositionsResult != nil { + return m.GetAccountsPositionsResult, nil } - return &types.AccountPositions{Positions: []types.PoolPosition{}}, nil + return []*types.AccountPositions{}, nil } type MockPricesService struct { From c65d9c07536df326e62c4f55daacde7aff458ac8 Mon Sep 17 00:00:00 2001 From: jiahuihu Date: Mon, 27 Jul 2026 17:18:37 -0400 Subject: [PATCH 13/15] feat(api): render backstop positions --- internal/services/positions.go | 61 +++++++++++++++++++----- internal/services/positions_test.go | 73 ++++++++++++++++++++++++++--- internal/types/positions.go | 34 ++++++++++++++ 3 files changed, 151 insertions(+), 17 deletions(-) diff --git a/internal/services/positions.go b/internal/services/positions.go index a32939d..594dee2 100644 --- a/internal/services/positions.go +++ b/internal/services/positions.go @@ -95,14 +95,44 @@ func mapAccountPositions(upstream *wbtypes.BlendAccountPositions) *types.Account }) } - total, netAPY := accountAggregate(upstream.Pools) + total, netAPY := accountAggregate(upstream.Pools, upstream.Backstop) return &types.AccountPositions{ TotalValueUSD: total, NetAPY: netAPY, Positions: positions, + Backstop: mapBackstop(upstream.Backstop), } } +// mapBackstop shapes the account's backstop deposits. Render-only in v1: +// initiating backstop deposits is out of scope, but existing positions are +// the user's money and must be visible. +func mapBackstop(backstop []wbtypes.BlendBackstopPosition) []types.BlendBackstopRow { + rows := make([]types.BlendBackstopRow, 0, len(backstop)) + for _, b := range backstop { + q4w := make([]types.BlendQ4WRow, 0, len(b.Q4W)) + for _, q := range b.Q4W { + q4w = append(q4w, types.BlendQ4WRow{ + Amount: q.Amount, + LPTokens: q.LpTokens, + USDValue: q.UsdValue, + Expiration: q.Expiration, + }) + } + rows = append(rows, types.BlendBackstopRow{ + PoolID: b.PoolAddress, + PoolName: b.PoolName, + Shares: b.Shares, + LPTokens: b.LpTokens, + USDValue: b.UsdValue, + ClaimableBLND: b.EmissionsEarnedBlnd, + ClaimableUSD: b.EmissionsEarnedUsd, + Q4W: q4w, + }) + } + return rows +} + // mapBlendDetail turns reserve positions into display rows. Reserves with no // balance on a side produce no row for that side; upstream deliberately // emits fully-exited (all-zero) reserve rows to carry earnings history, and @@ -154,20 +184,23 @@ func mapBlendDetail(reserves []wbtypes.BlendReservePosition) *types.BlendPositio return detail } -// accountAggregate computes the header figures from the per-pool summaries. +// accountAggregate computes the header figures from the per-pool summaries +// and backstop deposits. // -// TotalValueUSD: Σ pool usdValue with strict null propagation (any -// unavailable pool value nulls the total — an undercounted "total" is worse -// than an honest null), mirroring upstream's convention for pool totals. -// 0 for an account with no pools. +// TotalValueUSD: Σ pool usdValue + Σ backstop usdValue, with strict null +// propagation (any unavailable value nulls the total — an undercounted +// "total" is worse than an honest null), mirroring upstream's convention +// for pool totals. 0 for an account with no positions. // // NetAPY: mean of pool netApy weighted by pool suppliedUsd — the base the // upstream rate is defined over (blend-sdk-js: net dollars / total -// supplied), so rate × base reproduces the per-pool dollar earnings. Null -// when any pool's netApy or suppliedUsd is unavailable or the supplied base -// is zero. -func accountAggregate(pools []wbtypes.BlendPoolPosition) (total *float64, netAPY *float64) { - if len(pools) == 0 { +// supplied), so rate × base reproduces the per-pool dollar earnings. +// Backstop deposits carry no interest APY (they earn BLND emissions, +// reported per row), so they contribute to the total but not the rate. +// Null when any pool's netApy or suppliedUsd is unavailable or the supplied +// base is zero. +func accountAggregate(pools []wbtypes.BlendPoolPosition, backstop []wbtypes.BlendBackstopPosition) (total *float64, netAPY *float64) { + if len(pools) == 0 && len(backstop) == 0 { zero := 0.0 return &zero, nil } @@ -188,6 +221,12 @@ func accountAggregate(pools []wbtypes.BlendPoolPosition) (total *float64, netAPY apyNumerator += *pool.NetApy * *pool.SuppliedUsd suppliedSum += *pool.SuppliedUsd } + for _, b := range backstop { + if b.UsdValue == nil { + return nil, nil + } + sum += *b.UsdValue + } total = &sum if apyKnown && suppliedSum != 0 { diff --git a/internal/services/positions_test.go b/internal/services/positions_test.go index a893245..95a31ec 100644 --- a/internal/services/positions_test.go +++ b/internal/services/positions_test.go @@ -148,7 +148,7 @@ func TestAccountAggregate(t *testing.T) { total, apy := accountAggregate([]wbtypes.BlendPoolPosition{ pool(f64(8000), f64(9000), f64(0.05)), pool(f64(900), f64(1000), f64(0.01)), - }) + }, nil) require.NotNil(t, total) assert.InDelta(t, 8900, *total, 1e-9) // the total sums net values... require.NotNil(t, apy) @@ -159,7 +159,7 @@ func TestAccountAggregate(t *testing.T) { total, apy := accountAggregate([]wbtypes.BlendPoolPosition{ pool(f64(9000), f64(9000), f64(0.05)), pool(nil, nil, nil), - }) + }, nil) assert.Nil(t, total) assert.Nil(t, apy) }) @@ -168,7 +168,7 @@ func TestAccountAggregate(t *testing.T) { total, apy := accountAggregate([]wbtypes.BlendPoolPosition{ pool(f64(9000), f64(9000), f64(0.05)), pool(f64(1000), f64(1000), nil), - }) + }, nil) require.NotNil(t, total) assert.InDelta(t, 10000, *total, 1e-9) assert.Nil(t, apy) @@ -178,14 +178,14 @@ func TestAccountAggregate(t *testing.T) { total, apy := accountAggregate([]wbtypes.BlendPoolPosition{ pool(f64(9000), f64(9000), f64(0.05)), pool(f64(1000), nil, f64(0.01)), - }) + }, nil) require.NotNil(t, total) assert.InDelta(t, 10000, *total, 1e-9) assert.Nil(t, apy) }) t.Run("no positions is a genuine zero, apy null", func(t *testing.T) { - total, apy := accountAggregate(nil) + total, apy := accountAggregate(nil, nil) require.NotNil(t, total) assert.Equal(t, 0.0, *total) assert.Nil(t, apy) @@ -194,11 +194,72 @@ func TestAccountAggregate(t *testing.T) { t.Run("zero supplied base yields null apy", func(t *testing.T) { total, apy := accountAggregate([]wbtypes.BlendPoolPosition{ pool(f64(0), f64(0), f64(0.05)), - }) + }, nil) require.NotNil(t, total) assert.Equal(t, 0.0, *total) assert.Nil(t, apy) }) + + t.Run("backstop value joins the total but not the rate", func(t *testing.T) { + total, apy := accountAggregate( + []wbtypes.BlendPoolPosition{pool(f64(9000), f64(9000), f64(0.05))}, + []wbtypes.BlendBackstopPosition{{UsdValue: f64(500)}}, + ) + require.NotNil(t, total) + assert.InDelta(t, 9500, *total, 1e-9) + require.NotNil(t, apy) + assert.InDelta(t, 0.05, *apy, 1e-9) // rate unchanged by backstop + }) + + t.Run("unpriced backstop nulls the total (strict)", func(t *testing.T) { + total, apy := accountAggregate( + []wbtypes.BlendPoolPosition{pool(f64(9000), f64(9000), f64(0.05))}, + []wbtypes.BlendBackstopPosition{{UsdValue: nil}}, + ) + assert.Nil(t, total) + assert.Nil(t, apy) + }) + + t.Run("backstop-only account totals without a rate", func(t *testing.T) { + total, apy := accountAggregate(nil, + []wbtypes.BlendBackstopPosition{{UsdValue: f64(500)}}, + ) + require.NotNil(t, total) + assert.InDelta(t, 500, *total, 1e-9) + assert.Nil(t, apy) + }) +} + +func TestMapBackstop(t *testing.T) { + name := "TestnetV2" + rows := mapBackstop([]wbtypes.BlendBackstopPosition{{ + PoolAddress: "CCEBVDYM32YNYCVNRXQKDFFPISJJCV557CDZEIRBEE4NCV4KHPQ44HGF", + PoolName: &name, + Shares: "1000000", + LpTokens: "1100000", + UsdValue: f64(52.5), + EmissionsEarnedBlnd: "42", + EmissionsEarnedUsd: f64(0.001), + Q4W: []wbtypes.BlendQ4W{{ + Amount: "5000", + Expiration: 1760000000, + LpTokens: "5500", + UsdValue: f64(0.26), + }}, + }}) + + require.Len(t, rows, 1) + row := rows[0] + assert.Equal(t, "1000000", row.Shares) + assert.Equal(t, "1100000", row.LPTokens) + require.NotNil(t, row.USDValue) + assert.InDelta(t, 52.5, *row.USDValue, 1e-9) + assert.Equal(t, "42", row.ClaimableBLND) + require.Len(t, row.Q4W, 1) + assert.EqualValues(t, 1760000000, row.Q4W[0].Expiration) + + // Empty input still yields a non-nil slice for the JSON contract. + assert.NotNil(t, mapBackstop(nil)) } func TestGetAccountsPositionsMapsAndPassesThrough(t *testing.T) { diff --git a/internal/types/positions.go b/internal/types/positions.go index 4cb8f2d..b4284b2 100644 --- a/internal/types/positions.go +++ b/internal/types/positions.go @@ -33,6 +33,10 @@ type AccountPositions struct { // the account has no DeFi positions (including accounts unknown to the // indexer — indistinguishable by design). Positions []PoolPosition `json:"positions"` + // Backstop lists the account's Blend backstop deposits, one row per + // backed pool. Render-only in v1 (initiating backstop deposits is out of + // scope). Always non-nil. + Backstop []BlendBackstopRow `json:"backstop"` } // PoolPosition is one pool row. The common fields render a Position Home row @@ -119,6 +123,36 @@ type BlendBorrowRow struct { PriceUSD *float64 `json:"price_usd"` } +// BlendBackstopRow is the account's backstop deposit in one pool: first-loss +// capital earning BLND emissions. Shares is the ACTIVE (non-queued) share +// balance; LPTokens/USDValue value the whole deposit including +// queued-for-withdrawal shares (queued shares keep earning pool interest and +// remain slashable until withdrawn). +type BlendBackstopRow struct { + // PoolID is the pool this backstop deposit backs. + PoolID string `json:"pool_id"` + PoolName *string `json:"pool_name"` + // Shares (active) and LPTokens (whole deposit) are raw integer strings. + Shares string `json:"shares"` + LPTokens string `json:"lp_tokens"` + USDValue *float64 `json:"usd_value"` + // ClaimableBLND is uncollected BLND emissions (raw units); ClaimableUSD + // its upstream-computed value. + ClaimableBLND string `json:"claimable_blnd"` + ClaimableUSD *float64 `json:"claimable_usd"` + // Q4W lists queued withdrawals. Always non-nil. + Q4W []BlendQ4WRow `json:"q4w"` +} + +// BlendQ4WRow is one queued backstop withdrawal, unlocking at Expiration +// (unix seconds). Amount is in backstop shares. +type BlendQ4WRow struct { + Amount string `json:"amount"` + LPTokens string `json:"lp_tokens"` + USDValue *float64 `json:"usd_value"` + Expiration int64 `json:"expiration"` +} + // PositionsService assembles the account positions view. type PositionsService interface { Service From d832465ecd8dace48dc56dc01008484b39eec954 Mon Sep 17 00:00:00 2001 From: jiahuihu Date: Wed, 29 Jul 2026 16:54:15 -0400 Subject: [PATCH 14/15] feat(docs): drop stale upstream earn-query refs in Blend catalog comments --- internal/services/blend_catalog.go | 5 ++--- internal/types/blend_catalog.go | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/internal/services/blend_catalog.go b/internal/services/blend_catalog.go index 188f077..95e4cc4 100644 --- a/internal/services/blend_catalog.go +++ b/internal/services/blend_catalog.go @@ -1,6 +1,6 @@ // 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 (wallet-backend serves no earn query). +// ABOUTME: from the pools catalog. package services import ( @@ -176,8 +176,7 @@ func mapCatalogPools(pools []wbtypes.BlendPool) []types.BlendCatalogPool { // 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), mirroring the ordering the -// upstream earn query used before it was removed. +// 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 { diff --git a/internal/types/blend_catalog.go b/internal/types/blend_catalog.go index 6b24021..15cb4e1 100644 --- a/internal/types/blend_catalog.go +++ b/internal/types/blend_catalog.go @@ -75,7 +75,7 @@ type BlendEarnAssetOption struct { Symbol *string `json:"symbol"` Name *string `json:"name"` Decimals *int32 `json:"decimals"` - // Pools is ordered by upstream (supplied USD descending). The + // Pools is ordered by supplied USD descending (unpriced last). The // emissions-inclusive earn headline is SupplyAPY + EmissionsSupplyAPR. Pools []BlendEarnPool `json:"pools"` } From 38887816cc457cc36eb374a43dc33a3c8a6336dc Mon Sep 17 00:00:00 2001 From: jiahuihu Date: Fri, 31 Jul 2026 21:35:22 -0400 Subject: [PATCH 15/15] test(api): cover positions/blend routes in the wallet-backend-routes-enabled gate --- internal/api/serve_test.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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") }