From 0bddeddf9d1865ec563f43eee9134a18be78a250 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Tue, 25 Aug 2026 23:05:20 -0700 Subject: [PATCH 01/49] feat(earn): add a positions client for the Blend account endpoint --- @shared/api/helpers/__tests__/blend.test.ts | 202 ++++++++++++++++++++ @shared/api/helpers/blend.ts | 130 +++++++++++++ @shared/api/types/blend.ts | 84 ++++++++ 3 files changed, 416 insertions(+) diff --git a/@shared/api/helpers/__tests__/blend.test.ts b/@shared/api/helpers/__tests__/blend.test.ts index 5e3f98c7c3..8020a1682e 100644 --- a/@shared/api/helpers/__tests__/blend.test.ts +++ b/@shared/api/helpers/__tests__/blend.test.ts @@ -1,6 +1,7 @@ import { getBlendEarnOptions, getBlendPools, + getBlendPositions, getBlendSuppliedTokens, } from "../blend"; import { sendMessageToBackground } from "../extensionMessaging"; @@ -385,3 +386,204 @@ describe("getBlendSuppliedTokens", () => { await expect(call()).rejects.toThrow(); }); }); + +describe("getBlendPositions", () => { + const call = () => + getBlendPositions({ publicKey: PUBLIC_KEY, networkDetails }); + + it("POSTs the address with the network in the path", async () => { + // The query must live in `path` — authedFetch signs pathname + search. + mockedSend.mockResolvedValue({ + status: 200, + body: { data: [{ address: PUBLIC_KEY, positions: [], backstop: [] }] }, + }); + + await call(); + + expect(mockedSend).toHaveBeenCalledWith({ + type: SERVICE_TYPES.FETCH_BACKEND_V2, + activePublicKey: null, + method: "POST", + path: "/accounts/positions?network=PUBLIC", + body: JSON.stringify({ addresses: [PUBLIC_KEY] }), + }); + }); + + it("maps snake_case wire fields to camelCase", async () => { + mockedSend.mockResolvedValue({ + status: 200, + body: { + data: [ + { + address: PUBLIC_KEY, + total_value_usd: 500.12, + net_apy: 0.1694, + positions: [ + { + protocol: "blend", + id: POOL_ID, + name: "Fixed Pool v2", + net_usd: 500.12, + supplied_usd: 500.12, + borrowed_usd: 0, + net_apy: 0.1694, + blend: { + supply: [ + { + asset_id: USDC_SAC, + symbol: "USDC", + name: "USDC:GCK3D3V2XNLLKRFGFFFDEJXA4O2J4X36HET2FE446AV3M4U7DPHO3PEM", + decimals: 7, + supplied_tokens: "0", + collateral_tokens: "5001223000", + total_tokens: "5001223000", + usd_value: 500.12, + apy: 0.1694, + emissions_apr: 0, + interest_earned: "1234000", + interest_earned_usd: 0.12, + claimable_blnd: "0", + claimable_usd: null, + price_usd: 1.0, + }, + ], + borrow: [], + }, + }, + ], + backstop: [], + }, + ], + }, + }); + + const result = await call(); + + expect(result.totalValueUsd).toBe(500.12); + expect(result.positions[0].id).toBe(POOL_ID); + expect(result.positions[0].netUsd).toBe(500.12); + const supply = result.positions[0].blend!.supply[0]; + expect(supply.assetId).toBe(USDC_SAC); + expect(supply.totalTokens).toBe("5001223000"); + expect(supply.interestEarnedUsd).toBe(0.12); + expect(supply.emissionsApr).toBe(0); + }); + + it("preserves null as unavailable rather than coalescing to zero", async () => { + mockedSend.mockResolvedValue({ + status: 200, + body: { + data: [ + { + address: PUBLIC_KEY, + total_value_usd: null, + net_apy: null, + positions: [ + { + protocol: "blend", + id: POOL_ID, + name: null, + net_usd: null, + supplied_usd: null, + borrowed_usd: null, + net_apy: null, + blend: { supply: [], borrow: [] }, + }, + ], + backstop: [], + }, + ], + }, + }); + + const result = await call(); + + expect(result.totalValueUsd).toBeNull(); + expect(result.positions[0].netApy).toBeNull(); + }); + + it("returns an empty result when the batch carries no entry for the address", async () => { + // The contract guarantees one entry per requested address; an empty result + // is the right degradation if that ever slips, not a throw. + mockedSend.mockResolvedValue({ status: 200, body: { data: [] } }); + + const result = await call(); + + expect(result).toEqual({ + address: PUBLIC_KEY, + totalValueUsd: null, + netApy: null, + positions: [], + backstop: [], + }); + }); + + it("tolerates a pool row carrying no blend detail", async () => { + mockedSend.mockResolvedValue({ + status: 200, + body: { + data: [ + { + address: PUBLIC_KEY, + positions: [{ protocol: "blend", id: POOL_ID }], + backstop: [], + }, + ], + }, + }); + + const result = await call(); + + expect(result.positions[0].blend).toBeUndefined(); + }); + + it("maps backstop rows including their q4w queue", async () => { + mockedSend.mockResolvedValue({ + status: 200, + body: { + data: [ + { + address: PUBLIC_KEY, + positions: [], + backstop: [ + { + pool_id: POOL_ID, + pool_name: "Fixed Pool v2", + shares: "100", + lp_tokens: "120", + usd_value: 12.5, + claimable_blnd: "3", + claimable_usd: null, + q4w: [ + { + amount: "20", + lp_tokens: "20", + usd_value: 2.1, + expiration: 1790000000, + }, + ], + }, + ], + }, + ], + }, + }); + + const result = await call(); + + expect(result.backstop[0].poolId).toBe(POOL_ID); + expect(result.backstop[0].q4w[0].expiration).toBe(1790000000); + }); + + it("throws on a non-200", async () => { + mockedSend.mockResolvedValue({ status: 500, body: {} }); + + await expect(call()).rejects.toThrow(); + }); + + it("throws when a 200 carries no data payload", async () => { + mockedSend.mockResolvedValue({ status: 200, body: {} }); + + await expect(call()).rejects.toThrow(); + }); +}); diff --git a/@shared/api/helpers/blend.ts b/@shared/api/helpers/blend.ts index 0abf63283d..f6a1e84155 100644 --- a/@shared/api/helpers/blend.ts +++ b/@shared/api/helpers/blend.ts @@ -3,17 +3,26 @@ import { captureException } from "@sentry/browser"; import { NetworkDetails } from "@shared/constants/stellar"; import { fetchBackendV2 } from "./fetchBackendV2"; import { + AccountPositions, ApiAccountPositions, + ApiBlendBackstopRow, + ApiBlendBorrowRow, ApiBlendCatalogPool, ApiBlendCatalogReserve, ApiBlendEarnAssetOption, ApiBlendEarnOptionsCatalog, ApiBlendEarnPool, ApiBlendPoolsCatalog, + ApiBlendSupplyRow, + ApiPoolPosition, + BlendBackstopRow, + BlendBorrowRow, BlendCatalogPool, BlendCatalogReserve, BlendEarnAssetOption, BlendEarnPool, + BlendSupplyRow, + PoolPosition, } from "../types/blend"; /** @@ -78,6 +87,71 @@ const mapCatalogPool = (pool: ApiBlendCatalogPool): BlendCatalogPool => ({ reserves: (pool.reserves || []).map(mapCatalogReserve), }); +const mapSupplyRow = (row: ApiBlendSupplyRow): BlendSupplyRow => ({ + assetId: row.asset_id, + symbol: row.symbol, + name: row.name, + decimals: row.decimals, + suppliedTokens: row.supplied_tokens, + collateralTokens: row.collateral_tokens, + totalTokens: row.total_tokens, + usdValue: row.usd_value, + apy: row.apy, + emissionsApr: row.emissions_apr, + interestEarned: row.interest_earned, + interestEarnedUsd: row.interest_earned_usd, + claimableBlnd: row.claimable_blnd, + claimableUsd: row.claimable_usd, + priceUsd: row.price_usd, +}); + +const mapBorrowRow = (row: ApiBlendBorrowRow): BlendBorrowRow => ({ + assetId: row.asset_id, + symbol: row.symbol, + name: row.name, + decimals: row.decimals, + borrowedTokens: row.borrowed_tokens, + usdValue: row.usd_value, + apy: row.apy, + emissionsApr: row.emissions_apr, + priceUsd: row.price_usd, +}); + +const mapBackstopRow = (row: ApiBlendBackstopRow): BlendBackstopRow => ({ + poolId: row.pool_id, + poolName: row.pool_name, + shares: row.shares, + lpTokens: row.lp_tokens, + usdValue: row.usd_value, + claimableBlnd: row.claimable_blnd, + claimableUsd: row.claimable_usd, + q4w: (row.q4w || []).map((entry) => ({ + amount: entry.amount, + lpTokens: entry.lp_tokens, + usdValue: entry.usd_value, + expiration: entry.expiration, + })), +}); + +const mapPoolPosition = (position: ApiPoolPosition): PoolPosition => ({ + protocol: position.protocol, + id: position.id, + name: position.name ?? null, + netUsd: position.net_usd ?? null, + suppliedUsd: position.supplied_usd ?? null, + borrowedUsd: position.borrowed_usd ?? null, + netApy: position.net_apy ?? null, + // Left undefined rather than defaulted to empty arrays: absent detail and + // "detail present but holding nothing" are different states, and the sheet + // renders them differently. + blend: position.blend + ? { + supply: (position.blend.supply || []).map(mapSupplyRow), + borrow: (position.blend.borrow || []).map(mapBorrowRow), + } + : undefined, +}); + /** * Assets that can be deposited into a Blend pool, with each pool's headline * rate. Already filtered by the backend's operator-curated allowlist, so on a @@ -135,6 +209,62 @@ export const getBlendPools = async ({ }; /** + * Every Blend position the account holds, across every pool the indexer tracks. + * + * Powers the Home Positions tab and the pool sheet's "Your position" panel. This + * is the full payload behind the narrow `getBlendSuppliedTokens` below, which + * exists only because it predates this client — see the design doc's Deferred + * work for retiring it. + * + * The endpoint is a batch keyed by address; we always ask for one and unwrap it. + * An account unknown to the indexer still gets an entry with empty `positions`, + * so an empty result is a legitimate answer rather than an error. + */ +export const getBlendPositions = async ({ + publicKey, + networkDetails, +}: { + publicKey: string; + networkDetails: NetworkDetails; +}): Promise => { + const { status, body } = await fetchBackendV2({ + method: "POST", + path: `/accounts/positions?network=${networkDetails.network}`, + body: JSON.stringify({ addresses: [publicKey] }), + }); + + const parsed = body as { data?: ApiAccountPositions[] }; + if (status !== 200 || !parsed?.data) { + const _err = JSON.stringify(body); + captureException(`Failed to fetch Blend positions - ${status}: ${_err}`); + throw new Error(_err); + } + + const entry = parsed.data[0]; + if (!entry) { + return { + address: publicKey, + totalValueUsd: null, + netApy: null, + positions: [], + backstop: [], + }; + } + + return { + address: entry.address, + totalValueUsd: entry.total_value_usd ?? null, + netApy: entry.net_apy ?? null, + positions: (entry.positions || []).map(mapPoolPosition), + backstop: (entry.backstop || []).map(mapBackstopRow), + }; +}; + +/** + * NOTE: superseded by `getBlendPositions` above, which returns this same payload + * in full. Kept while EarnAmount still calls it; see the design doc's Deferred + * work for the swap-over. + * * The account's existing supplied balance for one (pool, asset), in raw token * units. This is the "before" side of the Review screen's `0.00 -> 500.00`. * diff --git a/@shared/api/types/blend.ts b/@shared/api/types/blend.ts index 1ff0bdd4a2..b547a0e534 100644 --- a/@shared/api/types/blend.ts +++ b/@shared/api/types/blend.ts @@ -220,3 +220,87 @@ export interface ApiAccountPositions { positions: ApiPoolPosition[]; backstop: ApiBlendBackstopRow[]; } + +export interface BlendSupplyRow { + assetId: string; + symbol: string | null; + name: string | null; + decimals: number | null; + /** Plain-supply portion, collateral portion, and their sum. All raw units. */ + suppliedTokens: string; + collateralTokens: string; + totalTokens: string; + usdValue: number | null; + apy: number | null; + /** 0 means no emissions stream; null means an active stream that cannot be priced. */ + emissionsApr: number | null; + /** Lifetime interest, raw token units. */ + interestEarned: string; + interestEarnedUsd: number | null; + claimableBlnd: string; + claimableUsd: number | null; + priceUsd: number | null; +} + +export interface BlendBorrowRow { + assetId: string; + symbol: string | null; + name: string | null; + decimals: number | null; + borrowedTokens: string; + usdValue: number | null; + apy: number | null; + emissionsApr: number | null; + priceUsd: number | null; +} + +export interface BlendQ4WRow { + amount: string; + lpTokens: string; + usdValue: number | null; + /** Unix seconds. */ + expiration: number; +} + +export interface BlendBackstopRow { + poolId: string; + poolName: string | null; + /** Active (non-queued) shares, raw units. */ + shares: string; + /** The whole deposit including queued withdrawals, raw units. */ + lpTokens: string; + usdValue: number | null; + claimableBlnd: string; + claimableUsd: number | null; + q4w: BlendQ4WRow[]; +} + +export interface BlendPositionDetail { + supply: BlendSupplyRow[]; + borrow: BlendBorrowRow[]; +} + +export interface PoolPosition { + /** "blend" for every pool today; the field exists so more protocols are additive. */ + protocol: string; + /** The pool's contract address. */ + id: string; + name: string | null; + /** Supplied minus borrowed. */ + netUsd: number | null; + suppliedUsd: number | null; + borrowedUsd: number | null; + netApy: number | null; + /** Absent for a protocol other than Blend, or before the detail is ingested. */ + blend?: BlendPositionDetail; +} + +export interface AccountPositions { + address: string; + /** Sum of every pool's netUsd — null if ANY pool is null. */ + totalValueUsd: number | null; + /** Supplied-USD-weighted mean across pools. */ + netApy: number | null; + positions: PoolPosition[]; + backstop: BlendBackstopRow[]; +} From 726cc9ea8d4270a145125a7eb696b0ec553ee089 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Tue, 25 Aug 2026 23:17:38 -0700 Subject: [PATCH 02/49] chore(earn): log raw Blend responses while building positions --- @shared/api/helpers/blend.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/@shared/api/helpers/blend.ts b/@shared/api/helpers/blend.ts index f6a1e84155..47117e5e3c 100644 --- a/@shared/api/helpers/blend.ts +++ b/@shared/api/helpers/blend.ts @@ -38,6 +38,17 @@ import { * rather than relying on the error. */ +/** + * TEMPORARY — remove before opening the PR (one `git revert` of this commit). + * + * Prints each Blend payload in full while the Positions UI is being built + * against fields it does not consume yet. + */ +const debugLogBlendResponse = (label: string, body: unknown) => { + // eslint-disable-next-line no-console + console.log(`[blend] ${label}`, JSON.stringify(body, null, 2)); +}; + const mapEarnPool = (pool: ApiBlendEarnPool): BlendEarnPool => ({ id: pool.id, name: pool.name, @@ -180,6 +191,8 @@ export const getBlendEarnOptions = async ({ throw new Error(_err); } + debugLogBlendResponse("earn-options", parsed.data); + return (parsed.data.options || []).map(mapEarnAssetOption); }; @@ -205,6 +218,8 @@ export const getBlendPools = async ({ throw new Error(_err); } + debugLogBlendResponse("pools", parsed.data); + return (parsed.data.pools || []).map(mapCatalogPool); }; @@ -240,6 +255,8 @@ export const getBlendPositions = async ({ throw new Error(_err); } + debugLogBlendResponse("positions", parsed.data); + const entry = parsed.data[0]; if (!entry) { return { From 13b1cf76717ce57cffafdeb91e485f3f674883f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Tue, 25 Aug 2026 23:27:41 -0700 Subject: [PATCH 03/49] feat(earn): cache account positions alongside balances and collectibles --- .../hooks/__tests__/useGetPositions.test.tsx | 102 ++++++++++++++++++ .../src/helpers/hooks/useGetPositions.tsx | 96 +++++++++++++++++ extension/src/popup/ducks/cache.ts | 30 ++++++ 3 files changed, 228 insertions(+) create mode 100644 extension/src/helpers/hooks/__tests__/useGetPositions.test.tsx create mode 100644 extension/src/helpers/hooks/useGetPositions.tsx diff --git a/extension/src/helpers/hooks/__tests__/useGetPositions.test.tsx b/extension/src/helpers/hooks/__tests__/useGetPositions.test.tsx new file mode 100644 index 0000000000..69fc40715a --- /dev/null +++ b/extension/src/helpers/hooks/__tests__/useGetPositions.test.tsx @@ -0,0 +1,102 @@ +import React from "react"; +import { renderHook, act } from "@testing-library/react"; + +import { getBlendPositions } from "@shared/api/helpers/blend"; +import { + TESTNET_NETWORK_DETAILS, + FUTURENET_NETWORK_DETAILS, +} from "@shared/constants/stellar"; +import { useGetPositions } from "helpers/hooks/useGetPositions"; +import { TEST_PUBLIC_KEY, Wrapper } from "popup/__testHelpers__"; + +jest.mock("@shared/api/helpers/blend", () => ({ + getBlendPositions: jest.fn(), +})); +jest.mock("@sentry/browser", () => ({ captureException: jest.fn() })); + +const mockedGet = getBlendPositions as jest.Mock; + +const positions = { + address: TEST_PUBLIC_KEY, + totalValueUsd: 500.12, + netApy: 0.1694, + positions: [], + backstop: [], +}; + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); + +beforeEach(() => mockedGet.mockReset()); + +describe("useGetPositions", () => { + it("fetches and returns the account's positions", async () => { + mockedGet.mockResolvedValue(positions); + const { result } = renderHook(() => useGetPositions({ useCache: false }), { + wrapper, + }); + + let resolved; + await act(async () => { + resolved = await result.current.fetchData({ + publicKey: TEST_PUBLIC_KEY, + networkDetails: TESTNET_NETWORK_DETAILS, + }); + }); + + expect(mockedGet).toHaveBeenCalledWith({ + publicKey: TEST_PUBLIC_KEY, + networkDetails: TESTNET_NETWORK_DETAILS, + }); + expect(resolved).toEqual(positions); + }); + + it("short-circuits to an empty result on an earn-unsupported network", async () => { + // Futurenet has no allowlisted pool, and the backend rejects any network + // outside PUBLIC/TESTNET with a 400. Gate here rather than spending a + // round trip to learn that. + const { result } = renderHook(() => useGetPositions({ useCache: false }), { + wrapper, + }); + + let resolved; + await act(async () => { + resolved = await result.current.fetchData({ + publicKey: TEST_PUBLIC_KEY, + networkDetails: FUTURENET_NETWORK_DETAILS, + }); + }); + + expect(mockedGet).not.toHaveBeenCalled(); + expect(resolved).toEqual({ + address: TEST_PUBLIC_KEY, + totalValueUsd: null, + netApy: null, + positions: [], + backstop: [], + }); + }); + + it("surfaces a rejection as an error state and rethrows", async () => { + // Unlike collectibles, which swallow failures, the Positions tab has a real + // error state — "we could not load this" must not read as "you have none". + mockedGet.mockRejectedValue(new Error("boom")); + const { result } = renderHook(() => useGetPositions({ useCache: false }), { + wrapper, + }); + + await act(async () => { + await expect( + result.current.fetchData({ + publicKey: TEST_PUBLIC_KEY, + networkDetails: TESTNET_NETWORK_DETAILS, + }), + ).rejects.toThrow("boom"); + }); + + expect(result.current.state.state).toBe("ERROR"); + }); +}); diff --git a/extension/src/helpers/hooks/useGetPositions.tsx b/extension/src/helpers/hooks/useGetPositions.tsx new file mode 100644 index 0000000000..3a3bd5bde7 --- /dev/null +++ b/extension/src/helpers/hooks/useGetPositions.tsx @@ -0,0 +1,96 @@ +import { useCallback, useMemo, useReducer } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { captureException } from "@sentry/browser"; + +import { getBlendPositions } from "@shared/api/helpers/blend"; +import { AccountPositions } from "@shared/api/types/blend"; +import { isEarnSupportedNetwork } from "@shared/constants/blend"; +import { NetworkDetails } from "@shared/constants/stellar"; +import { State } from "constants/request"; +import { initialState, isCacheValid, reducer } from "helpers/request"; +import { AppDispatch } from "popup/App"; +import { positionsSelector, savePositions } from "popup/ducks/cache"; + +export interface FetchPositionsParams { + publicKey: string; + networkDetails: NetworkDetails; +} + +/** What an account with nothing supplied looks like, and the short-circuit + * result on networks where Earn is unavailable. */ +export const EMPTY_POSITIONS = (address: string): AccountPositions => ({ + address, + totalValueUsd: null, + netApy: null, + positions: [], + backstop: [], +}); + +/** + * The account's Blend positions, cached per network and public key. + * + * Modelled on `useGetCollectibles`: a local request reducer for the caller's + * loading/error state, with the resolved payload mirrored into the `cache` + * slice so other screens can read it without a second round trip. + * + * Unlike collectibles, a rejection is NOT swallowed. The Positions tab draws a + * real error state, and returning an empty result on failure would tell the + * account it holds nothing when the truth is unknown. + */ +function useGetPositions({ useCache = true }: { useCache?: boolean } = {}) { + const [state, dispatch] = useReducer( + reducer, + initialState, + ); + const reduxDispatch = useDispatch(); + const cached = useSelector(positionsSelector); + + const fetchData = useCallback( + async ({ + publicKey, + networkDetails, + }: FetchPositionsParams): Promise => { + dispatch({ type: "FETCH_DATA_START" }); + + // Earn only exists where we have an allowlisted pool, and the backend + // 400s on any network outside PUBLIC/TESTNET. Resolve locally rather + // than spending a request to be told so. + if (!isEarnSupportedNetwork(networkDetails)) { + const empty = EMPTY_POSITIONS(publicKey); + dispatch({ type: "FETCH_DATA_SUCCESS", payload: empty }); + return empty; + } + + const entry = cached[networkDetails.network]?.[publicKey]; + if (useCache && entry && isCacheValid(entry)) { + dispatch({ type: "FETCH_DATA_SUCCESS", payload: entry }); + return entry; + } + + try { + const positions = await getBlendPositions({ + publicKey, + networkDetails, + }); + reduxDispatch(savePositions({ publicKey, networkDetails, positions })); + dispatch({ type: "FETCH_DATA_SUCCESS", payload: positions }); + return positions; + } catch (error) { + const errorObj = + error instanceof Error ? error : new Error(String(error)); + captureException(`Error fetching Blend positions - ${errorObj}`); + dispatch({ type: "FETCH_DATA_ERROR", payload: errorObj }); + throw errorObj; + } + }, + [useCache, cached, reduxDispatch], + ); + + return useMemo(() => ({ state, fetchData }), [state, fetchData]); +} + +export { useGetPositions }; +export type UseGetPositionsReturn = { + state: State; + fetchData: (params: FetchPositionsParams) => Promise; +}; diff --git a/extension/src/popup/ducks/cache.ts b/extension/src/popup/ducks/cache.ts index 50b29e7dc8..09a101575d 100644 --- a/extension/src/popup/ducks/cache.ts +++ b/extension/src/popup/ducks/cache.ts @@ -1,5 +1,6 @@ import { createSelector, createSlice } from "@reduxjs/toolkit"; import { AccountBalancesInterface } from "@shared/api/types/backend-api"; +import { AccountPositions } from "@shared/api/types/blend"; import { NetworkDetails } from "@shared/constants/stellar"; import { AssetListResponse } from "@shared/constants/soroban/asset-list"; import { HistoryResponse } from "helpers/hooks/useGetHistory"; @@ -54,6 +55,12 @@ type SaveCollectionsPayload = { collections: Collection[]; }; +type SavePositionsPayload = { + publicKey: PublicKey; + networkDetails: NetworkDetails; + positions: AccountPositions; +}; + type SavePopularTokensPayload = { networkDetails: NetworkDetails; tokens: TrendingAsset[]; @@ -91,6 +98,12 @@ interface InitialState { popularTokens: { [network: string]: { tokens: TrendingAsset[]; updatedAt: number }; }; + positionsData: { + [network: string]: Record< + PublicKey, + AccountPositions & { updatedAt: number } + >; + }; } const initialState: InitialState = { @@ -103,6 +116,7 @@ const initialState: InitialState = { tokenPrices: {}, collections: {}, popularTokens: {}, + positionsData: {}, }; const cacheSlice = createSlice({ @@ -118,6 +132,7 @@ const cacheSlice = createSlice({ state.historyData = {}; state.tokenPrices = {}; state.popularTokens = {}; + state.positionsData = {}; }, saveBalancesForAccount(state, action: { payload: SaveBalancesPayload }) { state.balanceData = { @@ -198,6 +213,18 @@ const cacheSlice = createSlice({ }, }; }, + savePositions(state, action: { payload: SavePositionsPayload }) { + state.positionsData = { + ...state.positionsData, + [action.payload.networkDetails.network]: { + ...(state.positionsData[action.payload.networkDetails.network] || {}), + [action.payload.publicKey]: { + ...action.payload.positions, + updatedAt: Date.now(), + }, + }, + }; + }, clearCollectiblesForAccount( state, action: { payload: ClearBalancesPayload }, @@ -249,6 +276,8 @@ export const collectionsSelector = (state: { cache: InitialState }) => state.cache.collections; export const popularTokensSelector = (state: { cache: InitialState }) => state.cache.popularTokens; +export const positionsSelector = (state: { cache: InitialState }) => + state.cache.positionsData; export const { reducer } = cacheSlice; export const { @@ -264,4 +293,5 @@ export const { clearBalancesForAccount, clearCollectiblesForAccount, savePopularTokens, + savePositions, } = cacheSlice.actions; From 66d6b3e0de599623497dc8939705c77e7a81f692 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Tue, 25 Aug 2026 23:44:27 -0700 Subject: [PATCH 04/49] fix(earn): read the positions cache at call time instead of subscribing --- .../hooks/__tests__/useGetPositions.test.tsx | 62 +++++++++++++++++++ .../src/helpers/hooks/useGetPositions.tsx | 17 +++-- 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/extension/src/helpers/hooks/__tests__/useGetPositions.test.tsx b/extension/src/helpers/hooks/__tests__/useGetPositions.test.tsx index 69fc40715a..08178c62a8 100644 --- a/extension/src/helpers/hooks/__tests__/useGetPositions.test.tsx +++ b/extension/src/helpers/hooks/__tests__/useGetPositions.test.tsx @@ -30,6 +30,27 @@ const wrapper = ({ children }: { children: React.ReactNode }) => ( ); +// Preloads the cache slice with one entry for TEST_PUBLIC_KEY on Testnet, so +// the cache-hit/cache-stale branches can be exercised without a live fetch. +const wrapperWithCachedEntry = + (entry: typeof positions & { updatedAt: number }) => + ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + beforeEach(() => mockedGet.mockReset()); describe("useGetPositions", () => { @@ -99,4 +120,45 @@ describe("useGetPositions", () => { expect(result.current.state.state).toBe("ERROR"); }); + + it("serves a fresh cache entry without calling the API", async () => { + const cachedEntry = { ...positions, updatedAt: Date.now() }; + const { result } = renderHook(() => useGetPositions({ useCache: true }), { + wrapper: wrapperWithCachedEntry(cachedEntry), + }); + + let resolved; + await act(async () => { + resolved = await result.current.fetchData({ + publicKey: TEST_PUBLIC_KEY, + networkDetails: TESTNET_NETWORK_DETAILS, + }); + }); + + expect(mockedGet).not.toHaveBeenCalled(); + expect(resolved).toEqual(cachedEntry); + }); + + it("refetches when the cached entry is stale", async () => { + mockedGet.mockResolvedValue(positions); + // Outside the 3-minute isCacheValid window. + const staleEntry = { ...positions, updatedAt: Date.now() - 200000 }; + const { result } = renderHook(() => useGetPositions({ useCache: true }), { + wrapper: wrapperWithCachedEntry(staleEntry), + }); + + let resolved; + await act(async () => { + resolved = await result.current.fetchData({ + publicKey: TEST_PUBLIC_KEY, + networkDetails: TESTNET_NETWORK_DETAILS, + }); + }); + + expect(mockedGet).toHaveBeenCalledWith({ + publicKey: TEST_PUBLIC_KEY, + networkDetails: TESTNET_NETWORK_DETAILS, + }); + expect(resolved).toEqual(positions); + }); }); diff --git a/extension/src/helpers/hooks/useGetPositions.tsx b/extension/src/helpers/hooks/useGetPositions.tsx index 3a3bd5bde7..e7a958b5b7 100644 --- a/extension/src/helpers/hooks/useGetPositions.tsx +++ b/extension/src/helpers/hooks/useGetPositions.tsx @@ -1,5 +1,5 @@ import { useCallback, useMemo, useReducer } from "react"; -import { useDispatch, useSelector } from "react-redux"; +import { useDispatch, useStore } from "react-redux"; import { captureException } from "@sentry/browser"; import { getBlendPositions } from "@shared/api/helpers/blend"; @@ -8,7 +8,7 @@ import { isEarnSupportedNetwork } from "@shared/constants/blend"; import { NetworkDetails } from "@shared/constants/stellar"; import { State } from "constants/request"; import { initialState, isCacheValid, reducer } from "helpers/request"; -import { AppDispatch } from "popup/App"; +import { AppDispatch, AppState } from "popup/App"; import { positionsSelector, savePositions } from "popup/ducks/cache"; export interface FetchPositionsParams { @@ -43,7 +43,7 @@ function useGetPositions({ useCache = true }: { useCache?: boolean } = {}) { initialState, ); const reduxDispatch = useDispatch(); - const cached = useSelector(positionsSelector); + const store = useStore(); const fetchData = useCallback( async ({ @@ -61,6 +61,13 @@ function useGetPositions({ useCache = true }: { useCache?: boolean } = {}) { return empty; } + // Read the cache at call time rather than subscribing to it with + // `useSelector`. `savePositions` always replaces `positionsData` with a + // new object (Immer, on every write, for any network/publicKey), so a + // subscription would give `fetchData` a new identity on every positions + // write anywhere in the app — defeating the memoization below. Matches + // `useGetCollectibles`, which reads its cache the same way. + const cached = positionsSelector(store.getState()); const entry = cached[networkDetails.network]?.[publicKey]; if (useCache && entry && isCacheValid(entry)) { dispatch({ type: "FETCH_DATA_SUCCESS", payload: entry }); @@ -83,7 +90,9 @@ function useGetPositions({ useCache = true }: { useCache?: boolean } = {}) { throw errorObj; } }, - [useCache, cached, reduxDispatch], + // `store` is a stable reference from useStore, so listing it satisfies + // exhaustive-deps without re-running the check. + [useCache, reduxDispatch, store], ); return useMemo(() => ({ state, fetchData }), [state, fetchData]); From 916f7b0f0cbc5e01af06c6b55ab15181b558c592 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Tue, 25 Aug 2026 23:52:57 -0700 Subject: [PATCH 05/49] feat(home): fetch positions with the rest of the account data --- .../views/Account/hooks/useGetAccountData.tsx | 84 ++++++++++++++++++- 1 file changed, 83 insertions(+), 1 deletion(-) diff --git a/extension/src/popup/views/Account/hooks/useGetAccountData.tsx b/extension/src/popup/views/Account/hooks/useGetAccountData.tsx index a185a9a023..e15cd21476 100644 --- a/extension/src/popup/views/Account/hooks/useGetAccountData.tsx +++ b/extension/src/popup/views/Account/hooks/useGetAccountData.tsx @@ -5,8 +5,15 @@ import { RequestState } from "constants/request"; import { initialState, isError, reducer } from "helpers/request"; import { AccountBalances, useGetBalances } from "helpers/hooks/useGetBalances"; import { useGetCollectibles } from "helpers/hooks/useGetCollectibles"; +import { useGetPositions } from "helpers/hooks/useGetPositions"; import { isMainnet } from "helpers/stellar"; import { AllowList, ApiTokenPrices } from "@shared/api/types"; +import { getBlendEarnOptions } from "@shared/api/helpers/blend"; +import { + AccountPositions, + BlendEarnAssetOption, +} from "@shared/api/types/blend"; +import { isEarnSupportedNetwork } from "@shared/constants/blend"; import { AppDataType, NeedsReRoute, @@ -40,6 +47,28 @@ interface ResolvedAccountData { * action goes from it -- needs this rather than the list length. */ hasLoadedCollectibles: boolean; + positions: AccountPositions | null; + /** + * False until the positions request settles. `positions` is empty both before + * it lands and when the account genuinely holds none, so anything that has to + * tell those apart -- the Positions tab decides between a spinner and its + * empty state from it -- needs this rather than the list length. + */ + hasLoadedPositions: boolean; + /** + * The positions request rejected. Distinct from an empty result: the tab + * renders an error rather than claiming the account holds nothing. + */ + hasPositionsError: boolean; + /** + * Earn catalog, fetched only once positions land empty -- it exists purely to + * price the empty state's "you could earn up to" projection. Null otherwise. + * + * Fetched here rather than by the empty-state component because MultiPaneSlider + * unmounts inactive panes, so a component-owned fetch would re-request on + * every tab switch. + */ + earnOptions: BlendEarnAssetOption[] | null; } type AccountData = NeedsReRoute | ResolvedAccountData; @@ -60,6 +89,7 @@ function useGetAccountData(options: { const { fetchData: fetchCollectibles } = useGetCollectibles({ useCache: true, }); + const { fetchData: fetchPositions } = useGetPositions({ useCache: true }); const fetchData = async ({ useAppDataCache = true, @@ -106,6 +136,11 @@ function useGetAccountData(options: { ? Promise.resolve({ collections: [] } as Collectibles) : fetchCollectibles({ publicKey, networkDetails }); + // Same treatment as collectibles: started before the balances await and + // deliberately not awaited yet, so the Positions tab's spinner is as short + // as it can be. It needs only the key and network, both already known. + const positionsRequest = fetchPositions({ publicKey, networkDetails }); + // let's fetch *just* the balances (without Blockaid scan results) to quickly be able to show the user their balances const balancesResult = await fetchBalances( publicKey, @@ -129,6 +164,10 @@ function useGetAccountData(options: { isScanAppended: false, collectibles: { collections: [] }, hasLoadedCollectibles: false, + positions: null, + hasLoadedPositions: false, + hasPositionsError: false, + earnOptions: null, } as ResolvedAccountData; if (isMainnetNetwork) { @@ -156,6 +195,35 @@ function useGetAccountData(options: { // had already arrived until some unrelated dispatch happened to land. dispatch({ type: "FETCH_DATA_SUCCESS", payload: { ...payload } }); + try { + payload.positions = await positionsRequest; + } catch (error) { + // Non-fatal for the rest of Home: balances and collectibles are already + // on screen. Only the Positions tab changes what it renders. + payload.hasPositionsError = true; + captureException(`Error fetching positions on Account - ${error}`); + } + payload.hasLoadedPositions = true; + // Dispatched rather than only assigned, for the reason spelled out on the + // collectibles dispatch above: the reducer holds this very object. + dispatch({ type: "FETCH_DATA_SUCCESS", payload: { ...payload } }); + + // Only the empty state needs the catalog, and only to price its + // projection. Skipped entirely for an account that already has positions. + if ( + isEarnSupportedNetwork(networkDetails) && + !payload.hasPositionsError && + !payload.positions?.positions.length + ) { + try { + payload.earnOptions = await getBlendEarnOptions({ networkDetails }); + dispatch({ type: "FETCH_DATA_SUCCESS", payload: { ...payload } }); + } catch (error) { + // The card degrades to hidden; nothing else depends on this. + captureException(`Error fetching earn options on Account - ${error}`); + } + } + if (isMainnetNetwork) { // now that the UI has renderered, on Mainnet, let's make an additional call to fetch the balances with the Blockaid scan results included try { @@ -265,10 +333,24 @@ function useGetAccountData(options: { false, ); + let refreshedPositions = resolvedData.positions; + let refreshedPositionsError = false; + try { + refreshedPositions = await fetchPositions({ + publicKey, + networkDetails, + }); + } catch (error) { + refreshedPositionsError = true; + captureException(`Error refreshing positions on Account - ${error}`); + } + const payload = { ...state.data, balances: balancesResult, isScanAppended: true, + positions: refreshedPositions, + hasPositionsError: refreshedPositionsError, } as AccountData; dispatch({ type: "FETCH_DATA_SUCCESS", payload }); } catch (error) { @@ -276,7 +358,7 @@ function useGetAccountData(options: { } }, 30000); return () => clearInterval(interval); - }, [_isMainnet, state.data, fetchBalances]); + }, [_isMainnet, state.data, fetchBalances, fetchPositions]); return { state, From 1a688e2ae3706528845271007f22624509f7e109 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 26 Aug 2026 00:08:28 -0700 Subject: [PATCH 06/49] fix(earn): bypass the positions cache on refresh and account switch --- .../hooks/__tests__/useGetPositions.test.tsx | 26 +++++++++++++++++++ .../src/helpers/hooks/useGetPositions.tsx | 16 +++++++++++- .../views/Account/hooks/useGetAccountData.tsx | 12 ++++++++- 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/extension/src/helpers/hooks/__tests__/useGetPositions.test.tsx b/extension/src/helpers/hooks/__tests__/useGetPositions.test.tsx index 08178c62a8..9cec3357e0 100644 --- a/extension/src/helpers/hooks/__tests__/useGetPositions.test.tsx +++ b/extension/src/helpers/hooks/__tests__/useGetPositions.test.tsx @@ -139,6 +139,32 @@ describe("useGetPositions", () => { expect(resolved).toEqual(cachedEntry); }); + it("bypasses a fresh cache entry when the call overrides useCache to false", async () => { + // The lever the 30s refresh interval and an account/network switch rely + // on: even with a hook-level `useCache: true` and a fresh entry, a + // per-call `useCache: false` must still reach the network. + mockedGet.mockResolvedValue(positions); + const cachedEntry = { ...positions, updatedAt: Date.now() }; + const { result } = renderHook(() => useGetPositions({ useCache: true }), { + wrapper: wrapperWithCachedEntry(cachedEntry), + }); + + let resolved; + await act(async () => { + resolved = await result.current.fetchData({ + publicKey: TEST_PUBLIC_KEY, + networkDetails: TESTNET_NETWORK_DETAILS, + useCache: false, + }); + }); + + expect(mockedGet).toHaveBeenCalledWith({ + publicKey: TEST_PUBLIC_KEY, + networkDetails: TESTNET_NETWORK_DETAILS, + }); + expect(resolved).toEqual(positions); + }); + it("refetches when the cached entry is stale", async () => { mockedGet.mockResolvedValue(positions); // Outside the 3-minute isCacheValid window. diff --git a/extension/src/helpers/hooks/useGetPositions.tsx b/extension/src/helpers/hooks/useGetPositions.tsx index e7a958b5b7..20d602d00b 100644 --- a/extension/src/helpers/hooks/useGetPositions.tsx +++ b/extension/src/helpers/hooks/useGetPositions.tsx @@ -14,6 +14,15 @@ import { positionsSelector, savePositions } from "popup/ducks/cache"; export interface FetchPositionsParams { publicKey: string; networkDetails: NetworkDetails; + /** + * Per-call override for the hook-level `useCache` option, falling back to it + * when omitted. Same initial-load-vs-refresh split `useGetBalances` covers + * with its positional `useCache` argument: a cached initial load wants the 3 + * minute window, but the 30s refresh interval and an account/network switch + * need a guaranteed network hit, and both share this one hook instance (and + * so its one hook-level default) with the initial load. + */ + useCache?: boolean; } /** What an account with nothing supplied looks like, and the short-circuit @@ -49,9 +58,14 @@ function useGetPositions({ useCache = true }: { useCache?: boolean } = {}) { async ({ publicKey, networkDetails, + useCache: useCacheOverride, }: FetchPositionsParams): Promise => { dispatch({ type: "FETCH_DATA_START" }); + // The per-call value wins when the caller passes one; otherwise fall + // back to the hook-level default from construction. + const shouldUseCache = useCacheOverride ?? useCache; + // Earn only exists where we have an allowlisted pool, and the backend // 400s on any network outside PUBLIC/TESTNET. Resolve locally rather // than spending a request to be told so. @@ -69,7 +83,7 @@ function useGetPositions({ useCache = true }: { useCache?: boolean } = {}) { // `useGetCollectibles`, which reads its cache the same way. const cached = positionsSelector(store.getState()); const entry = cached[networkDetails.network]?.[publicKey]; - if (useCache && entry && isCacheValid(entry)) { + if (shouldUseCache && entry && isCacheValid(entry)) { dispatch({ type: "FETCH_DATA_SUCCESS", payload: entry }); return entry; } diff --git a/extension/src/popup/views/Account/hooks/useGetAccountData.tsx b/extension/src/popup/views/Account/hooks/useGetAccountData.tsx index e15cd21476..8b35b222ff 100644 --- a/extension/src/popup/views/Account/hooks/useGetAccountData.tsx +++ b/extension/src/popup/views/Account/hooks/useGetAccountData.tsx @@ -139,7 +139,14 @@ function useGetAccountData(options: { // Same treatment as collectibles: started before the balances await and // deliberately not awaited yet, so the Positions tab's spinner is as short // as it can be. It needs only the key and network, both already known. - const positionsRequest = fetchPositions({ publicKey, networkDetails }); + // `useCache` mirrors the balances call just below: an account/network + // switch (`shouldForceBalancesRefresh`) must not serve the previous + // account's cached positions. + const positionsRequest = fetchPositions({ + publicKey, + networkDetails, + useCache: !shouldForceBalancesRefresh, + }); // let's fetch *just* the balances (without Blockaid scan results) to quickly be able to show the user their balances const balancesResult = await fetchBalances( @@ -336,9 +343,12 @@ function useGetAccountData(options: { let refreshedPositions = resolvedData.positions; let refreshedPositionsError = false; try { + // Unlike the initial load, this must hit the network every tick — + // the whole point of the interval is a fresh read. refreshedPositions = await fetchPositions({ publicKey, networkDetails, + useCache: false, }); } catch (error) { refreshedPositionsError = true; From d3979a4c2fbd9614cd833fd88f3dd103970f9b5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 26 Aug 2026 00:31:39 -0700 Subject: [PATCH 07/49] feat(home): add the Positions tab --- .../__tests__/AccountPositions.test.tsx | 51 ++++++++++ .../account/AccountPositions/index.tsx | 92 +++++++++++++++++++ .../account/AccountPositions/styles.scss | 70 ++++++++++++++ .../components/account/AccountTabs/index.tsx | 11 +++ .../src/popup/locales/en/translation.json | 5 + .../src/popup/locales/pt/translation.json | 5 + .../Account/contexts/activeTabContext.tsx | 1 + extension/src/popup/views/Account/index.tsx | 24 ++++- 8 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 extension/src/popup/components/account/AccountPositions/__tests__/AccountPositions.test.tsx create mode 100644 extension/src/popup/components/account/AccountPositions/index.tsx create mode 100644 extension/src/popup/components/account/AccountPositions/styles.scss diff --git a/extension/src/popup/components/account/AccountPositions/__tests__/AccountPositions.test.tsx b/extension/src/popup/components/account/AccountPositions/__tests__/AccountPositions.test.tsx new file mode 100644 index 0000000000..94cef10855 --- /dev/null +++ b/extension/src/popup/components/account/AccountPositions/__tests__/AccountPositions.test.tsx @@ -0,0 +1,51 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; + +import { AccountPositions } from "popup/components/account/AccountPositions"; +import { TEST_PUBLIC_KEY, Wrapper } from "popup/__testHelpers__"; + +const renderTab = ( + props: Partial>, +) => + render( + + + , + ); + +describe("AccountPositions", () => { + it("spins while the request is in flight", () => { + renderTab({ isLoading: true }); + + expect(screen.getByTestId("account-positions-loader")).toBeInTheDocument(); + expect(screen.queryByText("No positions yet")).not.toBeInTheDocument(); + }); + + it("shows an error rather than claiming the account has none", () => { + // "Could not load" and "you have none" are different answers. Rendering the + // empty state on a failure would assert something we do not know. + renderTab({ hasError: true }); + + expect(screen.getByTestId("account-positions-error")).toBeInTheDocument(); + expect(screen.queryByText("No positions yet")).not.toBeInTheDocument(); + }); + + it("shows the empty state once a request lands with nothing", () => { + renderTab({ + positions: { + address: TEST_PUBLIC_KEY, + totalValueUsd: null, + netApy: null, + positions: [], + backstop: [], + }, + }); + + expect(screen.getByText("No positions yet")).toBeInTheDocument(); + }); +}); diff --git a/extension/src/popup/components/account/AccountPositions/index.tsx b/extension/src/popup/components/account/AccountPositions/index.tsx new file mode 100644 index 0000000000..f68272c410 --- /dev/null +++ b/extension/src/popup/components/account/AccountPositions/index.tsx @@ -0,0 +1,92 @@ +import React from "react"; +import { Loader, Notification } from "@stellar/design-system"; +import { useTranslation } from "react-i18next"; + +import { AccountPositions as AccountPositionsData } from "@shared/api/types/blend"; + +import "./styles.scss"; + +interface AccountPositionsProps { + positions: AccountPositionsData | null; + /** + * The request has not settled. Distinct from having none: `positions` is + * empty in both cases, and showing the empty state here would tell the + * account it holds nothing while the answer is still in flight. + */ + isLoading: boolean; + /** The request rejected. Renders instead of the empty state, never beside it. */ + hasError: boolean; +} + +/** + * The Home screen's Positions tab. + * + * Structured like AccountCollectibles, with one addition: a real error state. + * Collectibles swallow their failures because an unreachable collectible is + * indistinguishable from an unowned one; a position is money, so "we could not + * load this" must never render as "you have none". + * + * The error renders inside the pane rather than through the view's + * `AccountView__fetch-fail` notification, which sits above the slider and would + * push a banner across the Tokens and Collectibles tabs too. + */ +export const AccountPositions = ({ + // Not read yet: Task 6 adds the rows list this decides between and Task 7 + // the empty state's projection card. Renamed rather than dropped so the + // prop surface here matches `AccountPositionsProps` exactly for those two + // tasks to extend; the underscore keeps `noUnusedParameters` quiet in the + // meantime (same convention as `accountToSign: _accountToSign` in + // views/SignTransaction/index.tsx). + positions: _positions, + isLoading, + hasError, +}: AccountPositionsProps) => { + const { t } = useTranslation(); + + if (isLoading) { + return ( +
+
+ +
+
+ ); + } + + if (hasError) { + return ( +
+
+ + {t("Your positions could not be fetched at this time.")} + +
+
+ ); + } + + return ( +
+ {/* Rows arrive in Task 6; the empty state's projection card in Task 7. */} +
+
+ {t("No positions yet")} +
+
+ {t( + "Put your crypto to work and earn rewards while keeping track of your positions.", + )} +
+
+
+ ); +}; diff --git a/extension/src/popup/components/account/AccountPositions/styles.scss b/extension/src/popup/components/account/AccountPositions/styles.scss new file mode 100644 index 0000000000..10b544755e --- /dev/null +++ b/extension/src/popup/components/account/AccountPositions/styles.scss @@ -0,0 +1,70 @@ +@use "../../../styles/utils.scss" as *; + +// Figma node 9808-30015 (the empty state) could not be pulled for this pass -- +// the desktop app was unreachable -- so every value below is carried over +// from AccountCollectibles/styles.scss rather than read off the design. See +// the per-rule notes for provenance; a later visual pass should verify each +// one against the real node. +.AccountPositions { + // Mirrors the top-level `.AccountCollectibles` rule: flex column, the 24px + // gap Figma puts between siblings, and vertical-only padding since + // `.multi-pane-slider__pane` (views/Account/styles.scss) already supplies + // this pane's horizontal gutter. + display: flex; + flex-direction: column; + gap: pxToRem(24px); + padding: pxToRem(24px) 0; + + // Mirrors `.AccountCollectibles__loader` exactly, min-height included -- + // that value reserves the collectibles empty state's height (badge + title + // + subtitle) so resolving into it doesn't shift the tab under the reader. + // Positions' empty state has no badge and Task 7 adds a projection card on + // top of it, so this number will need to be re-derived once that lands; + // carried over as-is for now rather than guessed. + &__loader { + display: flex; + align-items: center; + justify-content: center; + min-height: pxToRem(108px); + color: var(--sds-clr-gray-09); + } + + // No AccountCollectibles precedent: that tab swallows its failures and has + // no error state of its own. Left with no rules of its own -- `Notification` + // brings its own visual treatment, and `.AccountView__fetch-fail`'s + // left/right margins (views/Account/styles.scss) are not a fit here since + // those exist to compensate for `View.Content`'s disabled inset, a problem + // this pane doesn't have. + &__error { + } + + &__empty { + // Mirrors `.AccountCollectibles__empty`'s layout rules (badge- and + // CTA-specific ones dropped -- this state has neither): centered flex + // column, the 8px gap between title and subtitle, no padding-top of its + // own since `.AccountPositions` above already contributes the 24px gap + // under the tab strip. + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: pxToRem(8px); + padding-top: 0; + + // Mirrors `.AccountCollectibles__empty__title` exactly. + &__title { + color: var(--sds-clr-gray-12); + font-size: pxToRem(16px); + font-weight: var(--sds-fw-medium); + line-height: pxToRem(24px); + } + + // Mirrors `.AccountCollectibles__empty__subtitle` exactly. + &__subtitle { + color: var(--sds-clr-gray-11); + font-size: pxToRem(14px); + font-weight: var(--sds-fw-regular); + line-height: pxToRem(20px); + } + } +} diff --git a/extension/src/popup/components/account/AccountTabs/index.tsx b/extension/src/popup/components/account/AccountTabs/index.tsx index 2bd66ce021..8c75ea15d9 100644 --- a/extension/src/popup/components/account/AccountTabs/index.tsx +++ b/extension/src/popup/components/account/AccountTabs/index.tsx @@ -12,6 +12,7 @@ import classnames from "classnames"; import { TabsList } from "popup/views/Account/contexts/activeTabContext"; import { settingsNetworkDetailsSelector } from "popup/ducks/settings"; import { isCustomNetwork } from "@shared/helpers/stellar"; +import { isEarnSupportedNetwork } from "@shared/constants/blend"; import { useActiveTab } from "./hooks/useActiveTab"; @@ -28,6 +29,7 @@ export const TabButtons = () => { const tabLabels: Record = { tokens: t("Tokens"), + positions: t("Positions"), collectibles: t("Collectibles"), }; @@ -38,6 +40,15 @@ export const TabButtons = () => { return null; } + // Earn, and therefore any position, exists only where we have an + // allowlisted pool. + if ( + tab === TabsList.POSITIONS && + !isEarnSupportedNetwork(networkDetails) + ) { + return null; + } + return ( ,
From a398cf87b45e33c8024ab1ef687994978eb78801 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 26 Aug 2026 02:08:49 -0700 Subject: [PATCH 13/49] docs(home): correct the empty-state loader's stale Task 7 reference --- .../popup/components/account/AccountPositions/styles.scss | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/extension/src/popup/components/account/AccountPositions/styles.scss b/extension/src/popup/components/account/AccountPositions/styles.scss index 2dbc4872be..9d84602358 100644 --- a/extension/src/popup/components/account/AccountPositions/styles.scss +++ b/extension/src/popup/components/account/AccountPositions/styles.scss @@ -20,9 +20,10 @@ // Mirrors `.AccountCollectibles__loader` exactly, min-height included -- // that value reserves the collectibles empty state's height (badge + title // + subtitle) so resolving into it doesn't shift the tab under the reader. - // Positions' empty state has no badge and Task 7 adds a projection card on - // top of it, so this number will need to be re-derived once that lands; - // carried over as-is for now rather than guessed. + // Positions' empty state has no badge but, as of Task 7, has a projection + // card and CTA that the Collectibles state doesn't reserve room for -- this + // number still needs to be re-derived (still carried over as-is rather than + // guessed) once real dimensions exist for those. &__loader { display: flex; align-items: center; From 3f29a52e605a2b2704d72ef74947fb00045a7be3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 26 Aug 2026 02:35:21 -0700 Subject: [PATCH 14/49] feat(earn): derive a pool position's summary figures Co-Authored-By: Claude Opus 5 (1M context) --- .../helpers/__tests__/positionSummary.test.ts | 133 ++++++++++++++ .../earn/helpers/positionSummary.ts | 171 ++++++++++++++++++ 2 files changed, 304 insertions(+) create mode 100644 extension/src/popup/components/earn/helpers/__tests__/positionSummary.test.ts create mode 100644 extension/src/popup/components/earn/helpers/positionSummary.ts diff --git a/extension/src/popup/components/earn/helpers/__tests__/positionSummary.test.ts b/extension/src/popup/components/earn/helpers/__tests__/positionSummary.test.ts new file mode 100644 index 0000000000..dd2e400a69 --- /dev/null +++ b/extension/src/popup/components/earn/helpers/__tests__/positionSummary.test.ts @@ -0,0 +1,133 @@ +import { MAINNET_NETWORK_DETAILS } from "@shared/constants/stellar"; +import { PUBLIC_SACS } from "@shared/constants/sac"; +import { getPositionSummary } from "../positionSummary"; + +const networkDetails = MAINNET_NETWORK_DETAILS; +const USDC_SAC = PUBLIC_SACS.USDC!; +const XLM_SAC = PUBLIC_SACS.XLM; + +const usdcSupply = { + assetId: USDC_SAC, + symbol: "USDC", + name: "USDC:GCK3D3V2XNLLKRFGFFFDEJXA4O2J4X36HET2FE446AV3M4U7DPHO3PEM", + decimals: 7, + suppliedTokens: "0", + collateralTokens: "5001223000", + totalTokens: "5001223000", + usdValue: 500.12, + apy: 0.1694, + emissionsApr: 0, + interestEarned: "1234000", + interestEarnedUsd: 0.12, + claimableBlnd: "0", + claimableUsd: null, + priceUsd: 1, +}; + +const xlmSupply = { + ...usdcSupply, + assetId: XLM_SAC, + symbol: null, + name: null, + totalTokens: "1200400000", + collateralTokens: "1200400000", + usdValue: 120.04, + apy: 0.121, + interestEarned: "400000", + interestEarnedUsd: 0.04, +}; + +const position = (supply: unknown[]) => + ({ + protocol: "blend", + id: "CAJJ", + name: "Fixed Pool v2", + netUsd: 620.16, + suppliedUsd: 620.16, + borrowedUsd: 0, + netApy: 0.16, + blend: { supply, borrow: [] }, + }) as never; + +describe("getPositionSummary — supply scope", () => { + const summary = ( + focusedAssetId: string, + supply: unknown[] = [usdcSupply, xlmSupply], + ) => + getPositionSummary({ + position: position(supply), + focusedAssetId, + networkDetails, + scope: "supply", + }); + + it("scopes every figure to the focused asset", () => { + const result = summary(USDC_SAC); + + expect(result.currentBalanceUsd).toBe(500.12); + expect(result.apy).toBeCloseTo(0.1694); + expect(result.deposits).toHaveLength(1); + expect(result.earnings).toHaveLength(1); + expect(result.deposits[0].code).toBe("USDC"); + }); + + it("scopes to the other asset when that row was tapped", () => { + const result = summary(XLM_SAC); + + expect(result.currentBalanceUsd).toBe(120.04); + expect(result.deposits[0].code).toBe("XLM"); + }); + + it("reports deposits as principal, so balance equals deposits plus earnings", () => { + // total_tokens carries accrued interest; counting it in BOTH cards would + // double-count the same $0.12. See design doc Q2. + const result = summary(USDC_SAC); + + expect(result.deposits[0].usd).toBeCloseTo(500.0); + expect(result.deposits[0].tokens).toBe("499.9989"); + expect(result.earnings[0].usd).toBeCloseTo(0.12); + expect(result.earnings[0].tokens).toBe("0.1234"); + }); + + it("projects once from the scoped balance and rate", () => { + const result = summary(USDC_SAC); + + expect(result.estYearlyUsd).toBe("84.70"); + expect(result.estMonthlyUsd).toBe("7.06"); + }); + + it("keeps projections null when the rate is unavailable", () => { + const result = summary(USDC_SAC, [{ ...usdcSupply, apy: null }]); + + expect(result.apy).toBeNull(); + expect(result.estYearlyUsd).toBeNull(); + expect(result.estMonthlyUsd).toBeNull(); + }); + + it("falls back to the first supplied row when no asset was named", () => { + const result = getPositionSummary({ + position: position([usdcSupply, xlmSupply]), + networkDetails, + scope: "supply", + }); + + expect(result.deposits[0].code).toBe("USDC"); + }); +}); + +describe("getPositionSummary — pool scope", () => { + it("aggregates every supplied row under the pool's own totals", () => { + const result = getPositionSummary({ + position: position([usdcSupply, xlmSupply]), + networkDetails, + scope: "pool", + }); + + expect(result.currentBalanceUsd).toBe(620.16); + expect(result.apy).toBeCloseTo(0.16); + expect(result.deposits).toHaveLength(2); + expect(result.earnings).toHaveLength(2); + // One pair of Est. rows, from the pool total — never one pair per asset. + expect(result.estYearlyUsd).toBe("99.20"); + }); +}); diff --git a/extension/src/popup/components/earn/helpers/positionSummary.ts b/extension/src/popup/components/earn/helpers/positionSummary.ts new file mode 100644 index 0000000000..bb68f1bcb4 --- /dev/null +++ b/extension/src/popup/components/earn/helpers/positionSummary.ts @@ -0,0 +1,171 @@ +import BigNumber from "bignumber.js"; + +import { BlendSupplyRow, PoolPosition } from "@shared/api/types/blend"; +import { NetworkDetails } from "@shared/constants/stellar"; +import { CLASSIC_ASSET_DECIMALS } from "popup/helpers/soroban"; +import { projectEarnings } from "popup/components/earn/EarnReview/helpers/projectEarnings"; + +import { getCatalogAssetIdentity } from "./earnAssetIcons"; + +export type PositionScope = "supply" | "pool"; + +/** + * v1 renders one supplied asset — the row the user tapped. Flip to "pool" to + * aggregate every supply row in the position; `deposits` and `earnings` are + * already arrays, so nothing in the sheet has to change. See design doc Q1. + */ +export const POSITION_SUMMARY_SCOPE: PositionScope = "supply"; + +export interface PositionAssetRow { + assetId: string; + code: string; + issuer?: string; + decimals: number; + /** Display units, already scaled. */ + tokens: string; + usd: number | null; +} + +export interface PositionSummary { + currentBalanceUsd: number | null; + /** Headline rate: apy + emissions, or the pool's netApy under pool scope. */ + apy: number | null; + deposits: PositionAssetRow[]; + earnings: PositionAssetRow[]; + /** Formatted to 2dp, or null when the rate or the balance is unavailable. */ + estMonthlyUsd: string | null; + estYearlyUsd: string | null; +} + +const scale = (raw: string, decimals: number) => + new BigNumber(raw).dividedBy(new BigNumber(10).pow(decimals)).toFixed(); + +const identity = (row: BlendSupplyRow, networkDetails: NetworkDetails) => { + // Native XLM arrives with a null symbol AND a null name, recognisable only by + // its SAC — reading `symbol` alone renders a contract address as the code. + const { code, issuer } = getCatalogAssetIdentity({ + symbol: row.symbol, + name: row.name, + assetId: row.assetId, + networkDetails, + }); + return { + code: code || `${row.assetId.slice(0, 4)}…`, + issuer, + decimals: row.decimals ?? CLASSIC_ASSET_DECIMALS, + }; +}; + +/** + * Deposits mean PRINCIPAL, not the current total. + * + * `total_tokens` already carries accrued interest, and the earnings card reports + * that same interest — counting it in both would show the user their $0.12 + * twice and break the frame's own arithmetic, where Current Balance equals + * deposits plus earnings. The payload has no principal field, so it is derived. + * See design doc Q2; this is the one line to change if the team decides + * otherwise. + */ +const toDepositRow = ( + row: BlendSupplyRow, + networkDetails: NetworkDetails, +): PositionAssetRow => { + const { code, issuer, decimals } = identity(row, networkDetails); + const principalRaw = new BigNumber(row.totalTokens) + .minus(row.interestEarned) + .toFixed(0); + + return { + assetId: row.assetId, + code, + issuer, + decimals, + tokens: scale(principalRaw, decimals), + usd: + row.usdValue === null + ? null + : row.usdValue - (row.interestEarnedUsd ?? 0), + }; +}; + +const toEarningsRow = ( + row: BlendSupplyRow, + networkDetails: NetworkDetails, +): PositionAssetRow => { + const { code, issuer, decimals } = identity(row, networkDetails); + + return { + assetId: row.assetId, + code, + issuer, + decimals, + tokens: scale(row.interestEarned, decimals), + usd: row.interestEarnedUsd, + }; +}; + +const headlineApy = (apy: number | null, emissionsApr: number | null) => + apy === null ? null : apy + (emissionsApr ?? 0); + +/** + * The sheet's "Your position" figures, for one supplied asset or for the whole + * pool. The ONLY place this derivation lives — the component renders whatever + * comes back and knows nothing about scope. + * + * The two Est. rows are computed once from the scoped balance and rate, never + * once per asset: that is how the design draws them, inside the earnings card + * below a divider. + */ +export const getPositionSummary = ({ + position, + focusedAssetId, + networkDetails, + scope = POSITION_SUMMARY_SCOPE, +}: { + position: PoolPosition; + focusedAssetId?: string; + networkDetails: NetworkDetails; + scope?: PositionScope; +}): PositionSummary => { + const supply = position.blend?.supply || []; + const rows = + scope === "pool" + ? supply + : // Falls back to the first supplied row: the sheet can be opened from a + // pool card that names no asset, and an empty panel would be worse than + // the position the account most likely means. + [ + supply.find((row) => row.assetId === focusedAssetId) || supply[0], + ].filter(Boolean); + + const currentBalanceUsd = + scope === "pool" ? position.netUsd : (rows[0]?.usdValue ?? null); + const apy = + scope === "pool" + ? position.netApy + : headlineApy(rows[0]?.apy ?? null, rows[0]?.emissionsApr ?? null); + + const deposits = rows.map((row) => toDepositRow(row, networkDetails)); + const earnings = rows.map((row) => toEarningsRow(row, networkDetails)); + + const depositUsd = deposits.reduce((sum, row) => { + if (row.usd === null || sum === null) { + return null; + } + return sum.plus(row.usd); + }, new BigNumber(0)); + + const { monthly, yearly } = projectEarnings({ + depositUsd: depositUsd === null ? null : depositUsd.toFixed(), + apy, + }); + + return { + currentBalanceUsd, + apy, + deposits, + earnings, + estMonthlyUsd: monthly, + estYearlyUsd: yearly, + }; +}; From 77e88ee35ac03dbe91cad57cf8fe983e0984caf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 26 Aug 2026 02:52:02 -0700 Subject: [PATCH 15/49] fix(earn): keep deposit usd null when interest usd is unknown Co-Authored-By: Claude Opus 5 (1M context) --- .../helpers/__tests__/positionSummary.test.ts | 21 +++++++++++++++++++ .../earn/helpers/positionSummary.ts | 4 ++-- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/extension/src/popup/components/earn/helpers/__tests__/positionSummary.test.ts b/extension/src/popup/components/earn/helpers/__tests__/positionSummary.test.ts index dd2e400a69..24db3eca3e 100644 --- a/extension/src/popup/components/earn/helpers/__tests__/positionSummary.test.ts +++ b/extension/src/popup/components/earn/helpers/__tests__/positionSummary.test.ts @@ -89,6 +89,27 @@ describe("getPositionSummary — supply scope", () => { expect(result.earnings[0].tokens).toBe("0.1234"); }); + it("keeps deposit usd null when interest usd is unknown, instead of crediting the whole balance as principal", () => { + // interestEarnedUsd is nullable independently of usdValue. Defaulting it + // to 0 here would show the full balance as principal on the Deposits + // card while the Earnings card shows "--" for that same asset — two + // displayed numbers that no longer sum to the displayed Current Balance. + const result = summary(USDC_SAC, [ + { ...usdcSupply, interestEarnedUsd: null }, + ]); + + expect(result.deposits[0].usd).toBeNull(); + expect(result.earnings[0].usd).toBeNull(); + }); + + it("keeps deposit usd and projections null when the balance itself is unknown", () => { + const result = summary(USDC_SAC, [{ ...usdcSupply, usdValue: null }]); + + expect(result.deposits[0].usd).toBeNull(); + expect(result.estYearlyUsd).toBeNull(); + expect(result.estMonthlyUsd).toBeNull(); + }); + it("projects once from the scoped balance and rate", () => { const result = summary(USDC_SAC); diff --git a/extension/src/popup/components/earn/helpers/positionSummary.ts b/extension/src/popup/components/earn/helpers/positionSummary.ts index bb68f1bcb4..53155a90fe 100644 --- a/extension/src/popup/components/earn/helpers/positionSummary.ts +++ b/extension/src/popup/components/earn/helpers/positionSummary.ts @@ -82,9 +82,9 @@ const toDepositRow = ( decimals, tokens: scale(principalRaw, decimals), usd: - row.usdValue === null + row.usdValue === null || row.interestEarnedUsd === null ? null - : row.usdValue - (row.interestEarnedUsd ?? 0), + : new BigNumber(row.usdValue).minus(row.interestEarnedUsd).toNumber(), }; }; From 5f97c5615462903f27573592797d019804382ea4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 26 Aug 2026 03:13:51 -0700 Subject: [PATCH 16/49] feat(earn): give the pool sheet a Your position tab --- .../components/earn/PoolDetailsSheet/Tabs.tsx | 45 ++++ .../earn/PoolDetailsSheet/YourPosition.tsx | 156 ++++++++++++ .../__tests__/PoolDetailsSheet.tabs.test.tsx | 118 +++++++++ .../earn/PoolDetailsSheet/index.tsx | 230 ++++++++++++------ .../earn/PoolDetailsSheet/styles.scss | 150 ++++++++++++ .../src/popup/locales/en/translation.json | 7 + .../src/popup/locales/pt/translation.json | 7 + 7 files changed, 638 insertions(+), 75 deletions(-) create mode 100644 extension/src/popup/components/earn/PoolDetailsSheet/Tabs.tsx create mode 100644 extension/src/popup/components/earn/PoolDetailsSheet/YourPosition.tsx create mode 100644 extension/src/popup/components/earn/PoolDetailsSheet/__tests__/PoolDetailsSheet.tabs.test.tsx diff --git a/extension/src/popup/components/earn/PoolDetailsSheet/Tabs.tsx b/extension/src/popup/components/earn/PoolDetailsSheet/Tabs.tsx new file mode 100644 index 0000000000..890c83365a --- /dev/null +++ b/extension/src/popup/components/earn/PoolDetailsSheet/Tabs.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import classnames from "classnames"; +import { useTranslation } from "react-i18next"; + +export type PoolDetailsTab = "your_position" | "overview"; + +interface TabsProps { + active: PoolDetailsTab; + onSelect: (tab: PoolDetailsTab) => void; +} + +/** + * The sheet's pill tab strip. Distinct from AccountTabs, which is the Home + * screen's underline strip — the design draws these as filled pills. + */ +export const Tabs = ({ active, onSelect }: TabsProps) => { + const { t } = useTranslation(); + const tabs: { id: PoolDetailsTab; label: string }[] = [ + { id: "your_position", label: t("Your position") }, + { id: "overview", label: t("Overview") }, + ]; + + return ( +
+ {tabs.map((tab) => ( + + ))} +
+ ); +}; diff --git a/extension/src/popup/components/earn/PoolDetailsSheet/YourPosition.tsx b/extension/src/popup/components/earn/PoolDetailsSheet/YourPosition.tsx new file mode 100644 index 0000000000..d661c5307c --- /dev/null +++ b/extension/src/popup/components/earn/PoolDetailsSheet/YourPosition.tsx @@ -0,0 +1,156 @@ +import React from "react"; +import { Text } from "@stellar/design-system"; +import { useTranslation } from "react-i18next"; +import { useSelector } from "react-redux"; + +import { AssetIcons } from "@shared/api/types"; +import { PoolPosition } from "@shared/api/types/blend"; +import { AssetIcon } from "popup/components/account/AccountAssets"; +import { settingsNetworkDetailsSelector } from "popup/ducks/settings"; +import { StatRow } from "popup/components/earn/StatRow"; +import { + formatCompactUsd, + formatRate, +} from "popup/components/earn/helpers/formatPoolStats"; +import { formatProjection } from "popup/components/earn/EarnReview/helpers/projectEarnings"; +import { formatAmount } from "popup/helpers/formatters"; +import { + PositionAssetRow, + getPositionSummary, +} from "popup/components/earn/helpers/positionSummary"; + +interface YourPositionProps { + position: PoolPosition; + /** Which supplied asset the sheet was opened from. */ + focusedAssetId?: string; + assetIcons: AssetIcons; +} + +const AssetLine = ({ + row, + assetIcons, + isGain, +}: { + row: PositionAssetRow; + assetIcons: AssetIcons; + isGain: boolean; +}) => ( +
+ + + {row.code} + +
+
+ {row.usd === null + ? "--" + : `${isGain ? "+" : ""}${formatCompactUsd(row.usd)}`} +
+
+ {`${formatAmount(row.tokens)} ${row.code}`} +
+
+
+); + +/** + * The account's stake in this pool. + * + * Every figure comes from `getPositionSummary`, which owns the supply-vs-pool + * scope decision — this component renders whatever it returns and never asks + * which scope produced it. `deposits` and `earnings` are lists precisely so + * pool scope needs no change here. + */ +export const YourPosition = ({ + position, + focusedAssetId, + assetIcons, +}: YourPositionProps) => { + const { t } = useTranslation(); + const networkDetails = useSelector(settingsNetworkDetailsSelector); + const summary = getPositionSummary({ + position, + focusedAssetId, + networkDetails, + }); + + return ( +
+
+
+ + {t("Current Balance")} + +
+ {formatCompactUsd(summary.currentBalanceUsd)} +
+
+
+ {/* The frames read "Earn APR"; APY is what the backend returns. */} + + {t("Earn APY")} + +
+ {formatRate(summary.apy)} +
+
+
+ + + {t("Your deposits")} + +
+ {summary.deposits.map((row) => ( + + ))} +
+ + + {t("Your earnings")} + +
+ {summary.earnings.map((row) => ( + + ))} + {/* One pair of rows for the whole panel, from the scoped balance and + rate — not one pair per asset. */} + + +
+
+ ); +}; diff --git a/extension/src/popup/components/earn/PoolDetailsSheet/__tests__/PoolDetailsSheet.tabs.test.tsx b/extension/src/popup/components/earn/PoolDetailsSheet/__tests__/PoolDetailsSheet.tabs.test.tsx new file mode 100644 index 0000000000..cc57786423 --- /dev/null +++ b/extension/src/popup/components/earn/PoolDetailsSheet/__tests__/PoolDetailsSheet.tabs.test.tsx @@ -0,0 +1,118 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; + +import { PoolDetailsSheet } from "popup/components/earn/PoolDetailsSheet"; +import { Wrapper } from "popup/__testHelpers__"; + +jest.mock("helpers/metrics", () => ({ + ...jest.requireActual("helpers/metrics"), + emitMetric: jest.fn(), +})); + +const pool = { + id: "CAJJ", + name: "Fixed Pool v2", + status: "ACTIVE", + suppliedUsd: 50050000, + borrowedUsd: 16150000, + interestApy: 0.0424, + netApy: 0.1694, + backstopUsd: 1530000, + reserves: [], +} as never; + +const USDC_SAC = "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75"; + +const usdcSupply = { + assetId: USDC_SAC, + symbol: "USDC", + name: "USDC:GCK3D3V2XNLLKRFGFFFDEJXA4O2J4X36HET2FE446AV3M4U7DPHO3PEM", + decimals: 7, + suppliedTokens: "0", + collateralTokens: "5001223000", + totalTokens: "5001223000", + usdValue: 500.12, + apy: 0.1694, + emissionsApr: 0, + interestEarned: "1234000", + interestEarnedUsd: 0.12, + claimableBlnd: "0", + claimableUsd: null, + priceUsd: 1, +}; + +const position = { + protocol: "blend", + id: "CAJJ", + name: "Fixed Pool v2", + netUsd: 500.12, + suppliedUsd: 500.12, + borrowedUsd: 0, + netApy: 0.1694, + blend: { supply: [usdcSupply], borrow: [] }, +} as never; + +const renderSheet = (props = {}) => + render( + + + , + ); + +describe("PoolDetailsSheet", () => { + it("renders untabbed with a Close button when there is no position", () => { + // The deposit flow's existing entry point. Nothing about it may change. + renderSheet(); + + expect( + screen.queryByTestId("earn-pool-details-tabs"), + ).not.toBeInTheDocument(); + expect(screen.getByText("Close")).toBeInTheDocument(); + expect(screen.getByTestId("earn-pool-interest-apy")).toBeInTheDocument(); + }); + + it("shows both tabs once the account has a position in the pool", () => { + renderSheet({ position, focusedAssetId: USDC_SAC, onDeposit: jest.fn() }); + + expect(screen.getByTestId("earn-pool-details-tabs")).toBeInTheDocument(); + expect(screen.getByText("Your position")).toBeInTheDocument(); + expect(screen.getByText("Overview")).toBeInTheDocument(); + }); + + it("swaps Close for Deposit only when a deposit handler is supplied", () => { + const onDeposit = jest.fn(); + renderSheet({ position, onDeposit }); + + fireEvent.click(screen.getByText("Deposit")); + expect(onDeposit).toHaveBeenCalled(); + }); + + it("labels the rate APY, not APR", () => { + // Every frame reads "Earn APR"; APY is what the backend returns. + renderSheet({ position, onDeposit: jest.fn() }); + + expect(screen.getByText("Earn APY")).toBeInTheDocument(); + expect(screen.queryByText("Earn APR")).not.toBeInTheDocument(); + }); + + it("opens on the tab the caller asked for", () => { + renderSheet({ position, defaultTab: "overview", onDeposit: jest.fn() }); + + expect(screen.getByTestId("earn-pool-interest-apy")).toBeInTheDocument(); + }); + + it("reports a tab change once, and never on mount", () => { + const onTabChange = jest.fn(); + renderSheet({ position, onTabChange, onDeposit: jest.fn() }); + + expect(onTabChange).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByText("Overview")); + expect(onTabChange).toHaveBeenCalledTimes(1); + expect(onTabChange).toHaveBeenCalledWith("overview"); + + // Tapping the tab already active is not a change. + fireEvent.click(screen.getByText("Overview")); + expect(onTabChange).toHaveBeenCalledTimes(1); + }); +}); diff --git a/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx b/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx index f088847999..458bf00e17 100644 --- a/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx +++ b/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx @@ -2,7 +2,8 @@ import React from "react"; import { Button, Icon, Text } from "@stellar/design-system"; import { useTranslation } from "react-i18next"; -import { BlendCatalogPool } from "@shared/api/types/blend"; +import { AssetIcons } from "@shared/api/types"; +import { BlendCatalogPool, PoolPosition } from "@shared/api/types/blend"; import { PoolIcon } from "popup/components/earn/PoolIcon"; import { BLEND_LENDING_DOCS_URL } from "popup/constants/externalLinks"; import { openTab } from "popup/helpers/navigate"; @@ -12,16 +13,37 @@ import { formatRate, } from "popup/components/earn/helpers/formatPoolStats"; +import { PoolDetailsTab, Tabs } from "./Tabs"; +import { YourPosition } from "./YourPosition"; + import "./styles.scss"; interface PoolDetailsSheetProps { pool: BlendCatalogPool; onClose: () => void; + /** + * The account's position in this pool. Absent or null renders exactly what + * this sheet rendered before positions existed: no tabs, and Close. + */ + position?: PoolPosition | null; + /** Which supplied asset the sheet was opened from (see getPositionSummary). */ + focusedAssetId?: string; + /** Supplied → the CTA is Deposit; omitted → it stays Close. */ + onDeposit?: () => void; + /** + * Which tab opens first. "your_position" from a Positions row — the user + * tapped their position; "overview" from the amount screen's pool card — + * they tapped the pool. + */ + defaultTab?: PoolDetailsTab; + /** Fires only on a tap that changes the tab, never on mount. */ + onTabChange?: (tab: PoolDetailsTab) => void; + assetIcons?: AssetIcons; } /** - * Pool description and market stats, opened from the pool card on the amount - * screen. + * Pool description and market stats — the Overview tab's body, and this + * sheet's whole body before positions existed. * * The Backstop row renders whatever the catalog reports, and "--" until a * backend serves `backstop_usd` — the v2 backend drops the field its own @@ -33,9 +55,126 @@ interface PoolDetailsSheetProps { * specific deployment. "View pool details" carries the pool-specific detail out * to Blend's docs instead. */ -export const PoolDetailsSheet = ({ pool, onClose }: PoolDetailsSheetProps) => { +const OverviewBody = ({ pool }: { pool: BlendCatalogPool }) => { + const { t } = useTranslation(); + + return ( + <> +
+ + {t("Description")} + + {/* A div, not a p: the SDS theme gives `p:not(:last-child)` a 1.5rem + bottom margin at a specificity that beats `.Text`, so the copy + grew a 24px gap the moment the link was added below it. The theme + also forces `p` to 16px/28px — `md` is the same 16px at the + design's 24px leading. */} + + {t( + "Deposit supported assets into this Blend pool to earn yield. APY may change over time. Withdraw anytime.", + )} + + +
+ + + {t("Pool Performance")} + + +
+ + +
+ +
+ + + +
+ + ); +}; + +/** + * Pool details, opened from the pool card on the amount screen or, once the + * account holds a position, from a Positions row. + * + * Purely additive over the pre-positions sheet: with no `position` prop this + * renders exactly as it always has — no tab strip, the Overview body alone, + * and a Close button. Only supplying `position` (a real one, with at least one + * supplied row) turns on the pill tab strip and lets the sheet open on either + * tab. `onDeposit` independently swaps the footer button from Close to + * Deposit — the two are separate knobs because Task 11 opens this sheet + * mid-deposit with a position already on file but no Deposit action of its + * own to offer. + */ +export const PoolDetailsSheet = ({ + pool, + onClose, + position, + focusedAssetId, + onDeposit, + defaultTab, + onTabChange, + assetIcons = {}, +}: PoolDetailsSheetProps) => { const { t } = useTranslation(); + const hasPosition = Boolean(position?.blend?.supply?.length); + const [activeTab, setActiveTab] = React.useState( + defaultTab ?? "your_position", + ); + const showsPosition = hasPosition && activeTab === "your_position"; + + const selectTab = (tab: PoolDetailsTab) => { + // A tap on the tab already active is not a change; reporting it would + // inflate the switch metric with no-ops. + if (tab === activeTab) { + return; + } + setActiveTab(tab); + onTabChange?.(tab); + }; + return (
@@ -59,80 +198,21 @@ export const PoolDetailsSheet = ({ pool, onClose }: PoolDetailsSheetProps) => {
+ {hasPosition && } +
-
- - {t("Description")} - - {/* A div, not a p: the SDS theme gives `p:not(:last-child)` a 1.5rem - bottom margin at a specificity that beats `.Text`, so the copy - grew a 24px gap the moment the link was added below it. The theme - also forces `p` to 16px/28px — `md` is the same 16px at the - design's 24px leading. */} - - {t( - "Deposit supported assets into this Blend pool to earn yield. APY may change over time. Withdraw anytime.", - )} - - -
- - - {t("Pool Performance")} - - -
- - -
- -
- - - -
+ ) : ( + + )}
); diff --git a/extension/src/popup/components/earn/PoolDetailsSheet/styles.scss b/extension/src/popup/components/earn/PoolDetailsSheet/styles.scss index 8396c121c2..2e995b132e 100644 --- a/extension/src/popup/components/earn/PoolDetailsSheet/styles.scss +++ b/extension/src/popup/components/earn/PoolDetailsSheet/styles.scss @@ -103,4 +103,154 @@ border-radius: pxToRem(12); padding: 0 pxToRem(16); } + + // Figma nodes 9921-53991 (Your position) / 9921-53775 (Overview) could not be + // pulled for this pass -- the desktop app was unreachable -- so nothing below + // is a read-off value. Every size/color is either carried over from this same + // file's own `&__group`/`&__close` vocabulary or borrowed from the closest + // analogue elsewhere in the app, per-rule notes give the donor. A later visual + // pass should confirm every value here against the real nodes; see the task + // report for the full sweep list. + + // No segmented control exists in SDS or elsewhere in this codebase (the + // closest siblings are AccountTabs, an underline strip, and EarnTokenPicker's + // ApyBadge, a solid rate badge that is not a control at all) -- so this pill + // track is new. `pxToRem(100)` for full rounding is that ApyBadge's own + // convention (`EarnTokenPicker__apy`), reused here as this codebase's + // established "how we draw a pill" value rather than a guessed number. + &__tabs { + flex: none; + display: flex; + align-items: center; + gap: pxToRem(4); + padding: pxToRem(4); + background: var(--sds-clr-gray-03); + border-radius: pxToRem(100); + } + + &__tab { + flex: 1; + height: pxToRem(32); + border: none; + cursor: pointer; + background: none; + border-radius: pxToRem(100); + // Same 14px/20px/semi-bold this app already uses for its one other tab + // affordance (`AccountTabs__tab-item`) -- only the active treatment differs, + // a filled pill here rather than an underline. + font-size: pxToRem(14); + line-height: pxToRem(20); + font-weight: var(--sds-fw-semi-bold); + color: var(--sds-clr-gray-11); + + &--active { + // `--sds-clr-gray-04` is this exact file's own "raised control" surface + // (`&__close`'s circle), reused so the active pill reads as pressed + // against the `&__tabs` track (`--sds-clr-gray-03`, one step darker) the + // same way that button reads as raised against the sheet behind it. + background: var(--sds-clr-gray-04); + color: var(--sds-clr-gray-12); + } + } + + &__position { + display: flex; + flex-direction: column; + // This file's own established rhythm (`.PoolDetailsSheet`, `&__body`). + gap: pxToRem(12); + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: pxToRem(12); + // Background/radius/padding borrowed from this exact feature's own + // closest "headline figure in a card" precedent, + // `AccountPositions__empty__projection` -- see that file's note. + background: var(--sds-clr-gray-03); + border-radius: pxToRem(12); + padding: pxToRem(16); + } + + &__rate { + text-align: right; + } + + // One step up from `EarnStatRow__value`'s 14px, short of + // `AccountHeader__total-usd-balance`'s 32px page-level hero -- the same + // reasoning `AccountPositions__empty__projection__figure` already used for + // a card-level headline figure, and the same 24px/32px/medium scale. + &__balance, + &__apy { + font-size: pxToRem(24); + line-height: pxToRem(32); + font-weight: var(--sds-fw-medium); + } + + &__balance { + color: var(--sds-clr-gray-12); + } + + // Unconditionally green, matching how EarnReview's own "Current APY" + // StatRow passes `isPositive` unconditionally -- a rate in this feature + // always reads as a gain, never as a plain figure. + &__apy { + color: var(--sds-clr-green-11); + } + } + + // One row of a deposits/earnings list -- same shape as BalanceRow (icon, + // code, right-aligned stacked figures), so the figures below mirror + // `BalanceRow__fiat`/`BalanceRow__amount`'s type scale rather than inventing + // a new one. `AssetIcon`'s built-in right margin is cancelled the same way + // `EarnReview__asset` already cancels it, since the row's own `gap` does that + // spacing instead. + &__asset-line { + display: flex; + align-items: center; + gap: pxToRem(12); + padding: pxToRem(12) 0; + + .AccountAssets__asset--logo { + margin-right: 0; + } + + &__figures { + margin-left: auto; + display: flex; + flex-direction: column; + align-items: flex-end; + } + + // `&__usd` and `&__usd--gain` are mutually exclusive on the element (never + // combined), so the shared type scale is declared once for both selectors + // rather than relying on nesting to inherit it. + &__usd, + &__usd--gain { + font-size: pxToRem(14); + line-height: pxToRem(20); + font-weight: var(--sds-fw-medium); + } + + &__usd { + color: var(--sds-clr-gray-12); + } + + // green-11, not green-09: green-09 (`BalanceRow__delta.positive`) is this + // app's shade for a 24h price-change indicator, a different thing than an + // earned amount. green-11 is what this exact feature already uses for a + // dollar gain (`PositionRow__gain--positive`) and a rate + // (`EarnStatRow__value--positive`) alike -- see AccountPositions/styles.scss + // for the fuller reasoning. + &__usd--gain { + color: var(--sds-clr-green-11); + } + + &__tokens { + font-size: pxToRem(12); + line-height: pxToRem(18); + font-weight: var(--sds-fw-medium); + color: var(--sds-clr-gray-11); + } + } } diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index e424576c93..4b1fc44770 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -183,6 +183,7 @@ "Create new wallet": "Create new wallet", "Current APY": "Current APY", "Current APY: {{rate}}*": "Current APY: {{rate}}*", + "Current Balance": "Current Balance", "Current Network": "Current Network", "Custom": "Custom", "dApps": "dApps", @@ -215,6 +216,7 @@ "Don’t share this phrase with anyone": "Don’t share this phrase with anyone", "Done": "Done", "Earn": "Earn", + "Earn APY": "Earn APY", "Earn is not supported on this network": "Earn is not supported on this network", "Earn variable yield": "Earn variable yield", "Earn with Blend": "Earn with Blend", @@ -240,6 +242,8 @@ "Enter your password": "Enter your password", "Error": "Error", "Error connecting. Please try again.": "Error connecting. Please try again.", + "Est. monthly earnings": "Est. monthly earnings", + "Est. yearly earnings": "Est. yearly earnings", "Executable Type": "Executable Type", "Executable Wasm Hash": "Executable Wasm Hash", "Extend To": "Extend To", @@ -851,7 +855,10 @@ "Your assets": "Your assets", "Your assets could not be fetched at this time.": "Your assets could not be fetched at this time.", "Your available XLM balance is not enough to pay for the transaction fee.": "Your available XLM balance is not enough to pay for the transaction fee.", + "Your deposits": "Your deposits", + "Your earnings": "Your earnings", "Your gateway to the Stellar ecosystem. Browse and connect to decentralized applications built on Stellar.": "Your gateway to the Stellar ecosystem. Browse and connect to decentralized applications built on Stellar.", + "Your position": "Your position", "Your positions could not be fetched at this time.": "Your positions could not be fetched at this time.", "Your recovery phrase": "Your recovery phrase", "Your Recovery Phrase": "Your Recovery Phrase", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index 5f72076b45..10ea97c73f 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -183,6 +183,7 @@ "Create new wallet": "Criar nova carteira", "Current APY": "APY atual", "Current APY: {{rate}}*": "APY atual: {{rate}}*", + "Current Balance": "Saldo atual", "Current Network": "Rede Atual", "Custom": "Personalizado", "dApps": "dApps", @@ -215,6 +216,7 @@ "Don’t share this phrase with anyone": "Não compartilhe esta frase com ninguém", "Done": "Concluído", "Earn": "Render", + "Earn APY": "APY de ganhos", "Earn is not supported on this network": "Render não é compatível com esta rede", "Earn variable yield": "Obtenha rendimento variável", "Earn with Blend": "Renda com a Blend", @@ -240,6 +242,8 @@ "Enter your password": "Insira sua senha", "Error": "Erro", "Error connecting. Please try again.": "Erro ao conectar. Por favor, tente novamente.", + "Est. monthly earnings": "Ganhos mensais (est.)", + "Est. yearly earnings": "Ganhos anuais (est.)", "Executable Type": "Tipo Executável", "Executable Wasm Hash": "Hash Wasm Executável", "Extend To": "Estender Para", @@ -851,7 +855,10 @@ "Your assets": "Seus ativos", "Your assets could not be fetched at this time.": "Seus ativos não puderam ser buscados neste momento.", "Your available XLM balance is not enough to pay for the transaction fee.": "Seu saldo XLM disponível não é suficiente para pagar a taxa de transação.", + "Your deposits": "Seus depósitos", + "Your earnings": "Seus ganhos", "Your gateway to the Stellar ecosystem. Browse and connect to decentralized applications built on Stellar.": "Sua porta de entrada para o ecossistema Stellar. Navegue e conecte-se a aplicações descentralizadas construídas na Stellar.", + "Your position": "Sua posição", "Your positions could not be fetched at this time.": "Suas posições não puderam ser buscadas neste momento.", "Your recovery phrase": "Sua frase de recuperação", "Your Recovery Phrase": "Sua Frase de Recuperação", From abd6f1c518a4df687269aa1e76264e113b1f6964 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 26 Aug 2026 03:35:24 -0700 Subject: [PATCH 17/49] docs(earn): correct the pool sheet's green-shade provenance comments --- .../account/AccountPositions/styles.scss | 16 +++++++++++----- .../earn/PoolDetailsSheet/styles.scss | 19 +++++++++++++------ 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/extension/src/popup/components/account/AccountPositions/styles.scss b/extension/src/popup/components/account/AccountPositions/styles.scss index 9d84602358..3200690fe8 100644 --- a/extension/src/popup/components/account/AccountPositions/styles.scss +++ b/extension/src/popup/components/account/AccountPositions/styles.scss @@ -120,13 +120,19 @@ // green-11 rather than green-09: this figure is either a dollar gain or // a rate depending on what's held, and green-11 is the shade already // covering both cases elsewhere in this exact feature -- - // `PositionRow__gain--positive` (a dollar figure) and + // `PositionRow__gain--positive` below (a dollar figure) and // `EarnStatRow__value--positive` (a rate, e.g. EarnReview's "Current // APY" row) both use it, as does `HistoryItem`'s received-amount - // colour. green-09 elsewhere in the app (`BalanceRow__delta.positive`, - // `AssetDetail__delta.positive`) is reserved specifically for a 24h - // price-change indicator, a different thing than either branch this - // figure can show. + // colour. + // + // Not a settled app-wide rule, though: green-09 is not exclusively "the + // 24h price-change shade" (`BalanceRow__delta.positive`, + // `AssetDetail__delta.positive`) -- `PositionRow__apy`, below in this + // very file, colours a supply APY -- the rate branch this figure can + // also show -- that same green-09. That is an unresolved counter-example, + // not a precedent this choice overrides: a later visual pass should + // reconcile which green a supply APY gets across the feature (see also + // `PoolDetailsSheet__position__apy`, the same open question). &__figure { color: var(--sds-clr-green-11); font-size: pxToRem(24px); diff --git a/extension/src/popup/components/earn/PoolDetailsSheet/styles.scss b/extension/src/popup/components/earn/PoolDetailsSheet/styles.scss index 2e995b132e..84f34731a5 100644 --- a/extension/src/popup/components/earn/PoolDetailsSheet/styles.scss +++ b/extension/src/popup/components/earn/PoolDetailsSheet/styles.scss @@ -236,12 +236,19 @@ color: var(--sds-clr-gray-12); } - // green-11, not green-09: green-09 (`BalanceRow__delta.positive`) is this - // app's shade for a 24h price-change indicator, a different thing than an - // earned amount. green-11 is what this exact feature already uses for a - // dollar gain (`PositionRow__gain--positive`) and a rate - // (`EarnStatRow__value--positive`) alike -- see AccountPositions/styles.scss - // for the fuller reasoning. + // green-11: the tiebreaker is internal consistency within this same + // sheet -- its own Overview tab already colours "Current Net APY" green-11 + // via `EarnStatRow__value--positive` -- and with `PositionRow__gain--positive` + // (AccountPositions/styles.scss), which colours the same kind of figure, a + // dollar gain, the same shade elsewhere in this feature. + // + // Not a settled app-wide rule, though: green-09 is not exclusively "the + // 24h price-change shade" (`BalanceRow__delta.positive`) -- in that same + // AccountPositions/styles.scss, `PositionRow__apy` colours a supply APY, + // the same kind of figure as this panel's own `&__apy` above, green-09. + // That is an unresolved counter-example, not a precedent this choice + // overrides. The design pass must pick one green for a supply APY across + // the feature; see the Figma sweep list in the task report. &__usd--gain { color: var(--sds-clr-green-11); } From e3f4551ecd0c43276f636a325df643ab4a69fa37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 26 Aug 2026 04:01:04 -0700 Subject: [PATCH 18/49] feat(earn): open the pool sheet from a position row Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/AccountPositions.sheet.test.tsx | 162 ++++++++++++++++++ .../__tests__/AccountPositions.test.tsx | 2 + .../account/AccountPositions/index.tsx | 51 +++++- extension/src/popup/constants/earn.ts | 17 ++ .../views/Account/hooks/useGetAccountData.tsx | 47 +++-- extension/src/popup/views/Account/index.tsx | 47 ++++- extension/src/popup/views/Earn/index.tsx | 27 ++- 7 files changed, 329 insertions(+), 24 deletions(-) create mode 100644 extension/src/popup/components/account/AccountPositions/__tests__/AccountPositions.sheet.test.tsx diff --git a/extension/src/popup/components/account/AccountPositions/__tests__/AccountPositions.sheet.test.tsx b/extension/src/popup/components/account/AccountPositions/__tests__/AccountPositions.sheet.test.tsx new file mode 100644 index 0000000000..6686499a30 --- /dev/null +++ b/extension/src/popup/components/account/AccountPositions/__tests__/AccountPositions.sheet.test.tsx @@ -0,0 +1,162 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; + +import { MAINNET_NETWORK_DETAILS } from "@shared/constants/stellar"; +import { BlendCatalogPool } from "@shared/api/types/blend"; +import { getCanonicalFromAsset } from "@shared/helpers/stellar"; + +import { AccountPositions } from "popup/components/account/AccountPositions"; +import { PositionTokenRow } from "popup/components/earn/helpers/positionRows"; +import { TEST_PUBLIC_KEY, Wrapper, getTestStore } from "popup/__testHelpers__"; +import { ROUTES } from "popup/constants/routes"; +import { EARN_PREFILL_QUERY } from "popup/constants/earn"; +import { navigateTo } from "popup/helpers/navigate"; +import { + resetSubmission, + saveAsset, + saveDestination, + saveIsToken, +} from "popup/ducks/transactionSubmission"; +import { + saveEarnPool, + saveSelectedAssetApy, + saveSelectedAssetId, +} from "popup/ducks/earn"; + +const POOL_ID = "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD"; +const USDC_SAC = "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75"; +const XLM_SAC = "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA"; + +const mockNavigate = jest.fn(); + +const supply = (over: Record = {}) => ({ + assetId: USDC_SAC, + symbol: "USDC", + name: "USDC:GCK3D3V2XNLLKRFGFFFDEJXA4O2J4X36HET2FE446AV3M4U7DPHO3PEM", + decimals: 7, + suppliedTokens: "0", + collateralTokens: "5001223000", + totalTokens: "5001223000", + usdValue: 500.12, + apy: 0.1694, + emissionsApr: 0, + interestEarned: "1234000", + interestEarnedUsd: 0.12, + claimableBlnd: "0", + claimableUsd: null, + priceUsd: 1, + ...over, +}); + +const withSupply = (rows: unknown[]) => + ({ + address: TEST_PUBLIC_KEY, + totalValueUsd: 620.16, + netApy: 0.16, + positions: [ + { + protocol: "blend", + id: POOL_ID, + name: "Fixed Pool v2", + netUsd: 620.16, + suppliedUsd: 620.16, + borrowedUsd: 0, + netApy: 0.16, + blend: { supply: rows, borrow: [] }, + }, + ], + backstop: [], + }) as never; + +const twoAssetPositions = withSupply([ + supply(), + supply({ assetId: XLM_SAC, symbol: null, name: null, usdValue: 120.04 }), +]); + +const pool = { + id: POOL_ID, + name: "Fixed Pool v2", + status: "ACTIVE", + suppliedUsd: 50050000, + borrowedUsd: 16150000, + interestApy: 0.0424, + netApy: 0.1694, + backstopUsd: 1530000, + reserves: [], +} as never as BlendCatalogPool; + +/** + * Mirrors `views/Account`'s `onDepositFromPosition` exactly — the same six + * dispatches plus the prefilled navigate — run here against the store + * `Wrapper` stashes (see `getTestStore`) rather than by importing the view, + * since that handler is a local function there, not an exported unit. + */ +const onDeposit = (row: PositionTokenRow, depositPool: BlendCatalogPool) => { + const store = getTestStore()!; + store.dispatch(resetSubmission()); + store.dispatch(saveEarnPool(depositPool)); + store.dispatch(saveSelectedAssetApy(row.apy)); + store.dispatch(saveSelectedAssetId(row.assetId)); + store.dispatch(saveAsset(getCanonicalFromAsset(row.code, row.issuer))); + store.dispatch(saveDestination(row.poolId)); + store.dispatch(saveIsToken(true)); + navigateTo(ROUTES.earn, mockNavigate, EARN_PREFILL_QUERY); +}; + +const renderTab = ( + props: Partial>, +) => + render( + + {}} + projectedUsd={null} + bestApy={null} + onStartEarning={() => {}} + pools={[]} + onDeposit={onDeposit} + {...props} + /> + , + ); + +describe("AccountPositions pool sheet", () => { + it("opens the pool sheet on Your position when a row is tapped", async () => { + renderTab({ positions: twoAssetPositions, pools: [pool] }); + + fireEvent.click(screen.getByTestId("position-row-USDC")); + + expect( + await screen.findByTestId("earn-pool-details-sheet"), + ).toBeInTheDocument(); + expect(screen.getByTestId("earn-position-panel")).toBeInTheDocument(); + expect(screen.getByTestId("earn-position-balance")).toHaveTextContent( + "$500.12", + ); + }); + + it("prefills the deposit and lands on the amount screen", async () => { + renderTab({ positions: twoAssetPositions, pools: [pool] }); + + fireEvent.click(screen.getByTestId("position-row-USDC")); + fireEvent.click(await screen.findByText("Deposit")); + + // `Wrapper` stashes its store; getTestStore() is how the other suites read it. + const state = getTestStore()!.getState(); + expect(state.earn.selectedAssetId).toBe(USDC_SAC); + // `pool` is typed `BlendCatalogPool | null`; optional-chained rather than + // asserted so a regression fails on a clear mismatch, not a thrown TypeError. + expect(state.earn.pool?.id).toBe(POOL_ID); + expect(state.transactionSubmission.transactionData.destination).toBe( + POOL_ID, + ); + expect(mockNavigate).toHaveBeenCalledWith( + expect.stringContaining("/earn?prefill=1"), + ); + }); +}); diff --git a/extension/src/popup/components/account/AccountPositions/__tests__/AccountPositions.test.tsx b/extension/src/popup/components/account/AccountPositions/__tests__/AccountPositions.test.tsx index 7978c7fb71..1d1f605988 100644 --- a/extension/src/popup/components/account/AccountPositions/__tests__/AccountPositions.test.tsx +++ b/extension/src/popup/components/account/AccountPositions/__tests__/AccountPositions.test.tsx @@ -87,6 +87,8 @@ const renderTab = ( projectedUsd={null} bestApy={null} onStartEarning={() => {}} + pools={[]} + onDeposit={() => {}} {...props} /> , diff --git a/extension/src/popup/components/account/AccountPositions/index.tsx b/extension/src/popup/components/account/AccountPositions/index.tsx index 942a7a7510..131e16c102 100644 --- a/extension/src/popup/components/account/AccountPositions/index.tsx +++ b/extension/src/popup/components/account/AccountPositions/index.tsx @@ -1,8 +1,11 @@ -import React from "react"; +import React, { useState } from "react"; import { Loader, Notification } from "@stellar/design-system"; import { useTranslation } from "react-i18next"; -import { AccountPositions as AccountPositionsData } from "@shared/api/types/blend"; +import { + AccountPositions as AccountPositionsData, + BlendCatalogPool, +} from "@shared/api/types/blend"; import { AssetIcons } from "@shared/api/types"; import { NetworkDetails } from "@shared/constants/stellar"; @@ -10,6 +13,8 @@ import { PositionTokenRow, toPositionTokenRows, } from "popup/components/earn/helpers/positionRows"; +import { PoolDetailsSheet } from "popup/components/earn/PoolDetailsSheet"; +import { SlideupModal } from "popup/components/SlideupModal"; import { PositionRow } from "./PositionRow"; import { EmptyState } from "./EmptyState"; @@ -35,6 +40,10 @@ interface AccountPositionsProps { bestApy: number | null; /** Empty state's CTA was pressed, before it navigates to Earn. */ onStartEarning: () => void; + /** The catalog backing the pool-details sheet opened from a tapped row. */ + pools: BlendCatalogPool[]; + /** Deposit tapped inside that sheet, with the row and its resolved pool. */ + onDeposit: (row: PositionTokenRow, pool: BlendCatalogPool) => void; } /** @@ -59,8 +68,14 @@ export const AccountPositions = ({ projectedUsd, bestApy, onStartEarning, + pools, + onDeposit, }: AccountPositionsProps) => { const { t } = useTranslation(); + // Which row's pool sheet is open, if any. Declared unconditionally, ahead of + // the early returns below, because Rules of Hooks forbids a hook call that + // only some renders reach. + const [selected, setSelected] = useState(null); if (isLoading) { return ( @@ -107,6 +122,13 @@ export const AccountPositions = ({ ); } + // Resolved from the tapped row's id rather than held directly, so the sheet + // always renders the catalog's live figures instead of a snapshot taken at + // click time. + const selectedPool = pools.find((p) => p.id === selected?.poolId) ?? null; + const selectedPosition = + positions?.positions.find((p) => p.id === selected?.poolId) ?? null; + return (
@@ -115,10 +137,33 @@ export const AccountPositions = ({ key={`${row.poolId}-${row.assetId}`} row={row} assetIcons={assetIcons} - onClick={() => onSelectRow(row)} + onClick={() => { + onSelectRow(row); + setSelected(row); + }} /> ))}
+ + setSelected(null)} + hasBackdrop + > + {selected && selectedPool ? ( + setSelected(null)} + onDeposit={() => onDeposit(selected, selectedPool)} + /> + ) : ( +
+ )} +
); }; diff --git a/extension/src/popup/constants/earn.ts b/extension/src/popup/constants/earn.ts index 027422f031..147c49e4de 100644 --- a/extension/src/popup/constants/earn.ts +++ b/extension/src/popup/constants/earn.ts @@ -16,6 +16,23 @@ export const EARN_FLOW_QUERY = `?${FLOW_QUERY_KEY}=${EARN_FLOW_PARAM}`; export const isEarnFlowSearch = (search: string) => new URLSearchParams(search).get(FLOW_QUERY_KEY) === EARN_FLOW_PARAM; +/** + * Marks an Earn entry that arrives with the deposit already configured — from a + * Positions row, where the pool and token are both known. + * + * It has to travel in the URL rather than in Redux: `views/Earn` runs + * `resetSubmission()` on mount, and that returns the whole slice to + * `initialState`, so a prefill dispatched before navigating would be wiped + * before the first render. + */ +export const EARN_PREFILL_KEY = "prefill"; + +export const EARN_PREFILL_QUERY = `?${EARN_PREFILL_KEY}=1`; + +/** Did this Earn entry arrive prefilled? Takes a `location.search`. */ +export const isEarnPrefillSearch = (search: string) => + new URLSearchParams(search).get(EARN_PREFILL_KEY) === "1"; + /** * Steps in the Earn deposit flow. * diff --git a/extension/src/popup/views/Account/hooks/useGetAccountData.tsx b/extension/src/popup/views/Account/hooks/useGetAccountData.tsx index 8b35b222ff..b88656347b 100644 --- a/extension/src/popup/views/Account/hooks/useGetAccountData.tsx +++ b/extension/src/popup/views/Account/hooks/useGetAccountData.tsx @@ -8,9 +8,10 @@ import { useGetCollectibles } from "helpers/hooks/useGetCollectibles"; import { useGetPositions } from "helpers/hooks/useGetPositions"; import { isMainnet } from "helpers/stellar"; import { AllowList, ApiTokenPrices } from "@shared/api/types"; -import { getBlendEarnOptions } from "@shared/api/helpers/blend"; +import { getBlendEarnOptions, getBlendPools } from "@shared/api/helpers/blend"; import { AccountPositions, + BlendCatalogPool, BlendEarnAssetOption, } from "@shared/api/types/blend"; import { isEarnSupportedNetwork } from "@shared/constants/blend"; @@ -69,6 +70,13 @@ interface ResolvedAccountData { * every tab switch. */ earnOptions: BlendEarnAssetOption[] | null; + /** + * The full pool catalog, fetched alongside positions -- it backs the + * pool-details sheet a Positions row opens, so unlike `earnOptions` it is + * needed precisely when the account already holds positions, not only when + * it doesn't. + */ + pools: BlendCatalogPool[]; } type AccountData = NeedsReRoute | ResolvedAccountData; @@ -175,6 +183,7 @@ function useGetAccountData(options: { hasLoadedPositions: false, hasPositionsError: false, earnOptions: null, + pools: [], } as ResolvedAccountData; if (isMainnetNetwork) { @@ -215,19 +224,35 @@ function useGetAccountData(options: { // collectibles dispatch above: the reducer holds this very object. dispatch({ type: "FETCH_DATA_SUCCESS", payload: { ...payload } }); - // Only the empty state needs the catalog, and only to price its - // projection. Skipped entirely for an account that already has positions. - if ( - isEarnSupportedNetwork(networkDetails) && - !payload.hasPositionsError && - !payload.positions?.positions.length - ) { + if (isEarnSupportedNetwork(networkDetails)) { + // Unlike earnOptions below, fetched regardless of whether the account + // has positions -- it backs the pool-details sheet a Positions row + // opens, so it is needed precisely when positions exist, not only + // when they don't. try { - payload.earnOptions = await getBlendEarnOptions({ networkDetails }); + payload.pools = await getBlendPools({ networkDetails }); dispatch({ type: "FETCH_DATA_SUCCESS", payload: { ...payload } }); } catch (error) { - // The card degrades to hidden; nothing else depends on this. - captureException(`Error fetching earn options on Account - ${error}`); + captureException(`Error fetching pools on Account - ${error}`); + } + + // Only the empty state needs the catalog, and only to price its + // projection. Skipped entirely for an account that already has positions. + if ( + !payload.hasPositionsError && + !payload.positions?.positions.length + ) { + try { + payload.earnOptions = await getBlendEarnOptions({ + networkDetails, + }); + dispatch({ type: "FETCH_DATA_SUCCESS", payload: { ...payload } }); + } catch (error) { + // The card degrades to hidden; nothing else depends on this. + captureException( + `Error fetching earn options on Account - ${error}`, + ); + } } } diff --git a/extension/src/popup/views/Account/index.tsx b/extension/src/popup/views/Account/index.tsx index 3868d612a9..83392e7a63 100644 --- a/extension/src/popup/views/Account/index.tsx +++ b/extension/src/popup/views/Account/index.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef, useContext, useState } from "react"; -import { Navigate, useLocation } from "react-router-dom"; -import { useSelector } from "react-redux"; +import { Navigate, useLocation, useNavigate } from "react-router-dom"; +import { useDispatch, useSelector } from "react-redux"; import { Notification } from "@stellar/design-system"; import { useTranslation } from "react-i18next"; import { isEqual } from "lodash"; @@ -16,7 +16,18 @@ import { accountNameSelector, publicKeySelector, } from "popup/ducks/accountServices"; -import { openTab } from "popup/helpers/navigate"; +import { + resetSubmission, + saveAsset, + saveDestination, + saveIsToken, +} from "popup/ducks/transactionSubmission"; +import { + saveEarnPool, + saveSelectedAssetApy, + saveSelectedAssetId, +} from "popup/ducks/earn"; +import { navigateTo, openTab } from "popup/helpers/navigate"; import { isFullscreenMode } from "popup/helpers/isFullscreenMode"; import { useSwapTopTokensPrewarm } from "popup/helpers/useSwapTopTokensPrewarm"; @@ -33,15 +44,20 @@ import { Loading } from "popup/components/Loading"; import { NotFundedMessage } from "popup/components/account/NotFundedMessage"; import { isMainnet } from "helpers/stellar"; +import { getCanonicalFromAsset } from "@shared/helpers/stellar"; import { newTabHref } from "helpers/urls"; import { getTotalUsd, getTotalUsdLabel } from "popup/helpers/balance"; import { NetworkDetails } from "@shared/constants/stellar"; import { isEarnSupportedNetwork } from "@shared/constants/blend"; +import { BlendCatalogPool } from "@shared/api/types/blend"; import { projectAnnualEarnings } from "popup/components/earn/helpers/earnProjection"; +import { PositionTokenRow } from "popup/components/earn/helpers/positionRows"; import { reRouteOnboarding } from "popup/helpers/route"; import { AppDataType } from "helpers/hooks/useGetAppData"; import { AccountBalances } from "helpers/hooks/useGetBalances"; import { MultiPaneSlider } from "popup/components/SlidingPaneSwitcher"; +import { ROUTES } from "popup/constants/routes"; +import { EARN_PREFILL_QUERY } from "popup/constants/earn"; import { useGetAccountData, RequestState } from "./hooks/useGetAccountData"; import { useGetAccountHistoryData } from "./hooks/useGetAccountHistoryData"; @@ -65,6 +81,8 @@ import "./styles.scss"; export const Account = () => { const { t } = useTranslation(); + const dispatch = useDispatch(); + const navigate = useNavigate(); const location = useLocation(); const isSorobanSuported = useSelector(settingsSorobanSupportedSelector); const { userNotification } = useSelector(settingsSelector); @@ -281,6 +299,24 @@ export const Account = () => { networkDetails, }); + const onDepositFromPosition = ( + row: PositionTokenRow, + pool: BlendCatalogPool, + ) => { + // The same sequence EarnTokenPicker.onSelect runs. Kept in step with it: + // the amount screen reads all six values and renders blank without any one. + dispatch(resetSubmission()); + dispatch(saveEarnPool(pool)); + dispatch(saveSelectedAssetApy(row.apy)); + dispatch(saveSelectedAssetId(row.assetId)); + dispatch(saveAsset(getCanonicalFromAsset(row.code, row.issuer))); + // The pool contract is the transaction's destination; isContractId() on it + // routes the flow down the Soroban path rather than the classic one. + dispatch(saveDestination(row.poolId)); + dispatch(saveIsToken(true)); + navigateTo(ROUTES.earn, navigate, EARN_PREFILL_QUERY); + }; + return ( <> { hasError={!!resolvedData?.hasPositionsError} assetIcons={resolvedIcons} networkDetails={networkDetails} - // Task 10 opens the pool-details sheet from here. + // The pool-details sheet opens from inside AccountPositions + // itself (Task 10); reserved for a future analytics hook. onSelectRow={() => {}} projectedUsd={projection.usd} bestApy={projection.bestApy} // Task 12 wires this to an analytics event. onStartEarning={() => {}} + pools={resolvedData?.pools ?? []} + onDeposit={onDepositFromPosition} />
,
diff --git a/extension/src/popup/views/Earn/index.tsx b/extension/src/popup/views/Earn/index.tsx index 692698c201..7ee42008ce 100644 --- a/extension/src/popup/views/Earn/index.tsx +++ b/extension/src/popup/views/Earn/index.tsx @@ -1,11 +1,11 @@ import React, { useEffect, useRef, useState } from "react"; import { useDispatch, useSelector } from "react-redux"; -import { useNavigate } from "react-router-dom"; +import { useLocation, useNavigate } from "react-router-dom"; import { getCanonicalFromAsset } from "@shared/helpers/stellar"; import { getAssetFromCanonical } from "helpers/stellar"; import { ROUTES } from "popup/constants/routes"; -import { STEPS } from "popup/constants/earn"; +import { isEarnPrefillSearch, STEPS } from "popup/constants/earn"; import { navigateTo } from "popup/helpers/navigate"; import { emitScreenViewed, ScreenViewedProps } from "helpers/metrics"; import { ActionStatus } from "@shared/api/types"; @@ -74,18 +74,28 @@ const EARN_SCREEN_BY_STEP: Partial< export const Earn = () => { const dispatch = useDispatch(); const navigate = useNavigate(); + const location = useLocation(); const { t } = useTranslation(); const { hasSeenIntro, dismissIntro } = useEarnIntroSeen(); const submission = useSelector(transactionSubmissionSelector); const { pool, selectedAssetId } = useSelector(earnSelector); + // Read once at mount: the view rewrites its own search when the swap branch + // opens and closes, so the flag is gone by the time any later render looks. + const isPrefilled = useRef(isEarnPrefillSearch(location.search)).current; + // Start on CHOOSE_TOKEN and only fall back to the interstitial once the // persisted flag has actually resolved to false. Defaulting to INTRO instead // would flash it for every returning user while the background round-trip is // in flight. - const [activeStep, setActiveStep] = useState(STEPS.CHOOSE_TOKEN); + const [activeStep, setActiveStep] = useState( + isPrefilled ? STEPS.AMOUNT : STEPS.CHOOSE_TOKEN, + ); const [visitedSteps, setVisitedSteps] = useState>( - () => ({ [STEPS.CHOOSE_TOKEN]: true }) as Record, + () => + (isPrefilled + ? { [STEPS.CHOOSE_TOKEN]: true, [STEPS.AMOUNT]: true } + : { [STEPS.CHOOSE_TOKEN]: true }) as Record, ); const [enterAnim, setEnterAnim] = useState("from-bottom"); // The picker stays mounted across the swap branch, so it needs an explicit @@ -96,7 +106,7 @@ export const Earn = () => { details: DestinationTokenDetails; } | null>(null); - const hasResolvedIntro = useRef(false); + const hasResolvedIntro = useRef(isPrefilled); const lastEmittedStep = useRef(null); const isFirstSubmitStatusRun = useRef(true); @@ -113,8 +123,13 @@ export const Earn = () => { }; useEffect(() => { + // A prefilled entry arrives with transactionData already populated by the + // caller; resetting would discard the asset and destination it just set. + if (isPrefilled) { + return; + } dispatch(resetSubmission()); - }, [dispatch]); + }, [dispatch, isPrefilled]); useEffect(() => { if (hasResolvedIntro.current || hasSeenIntro === null) { From 35dc3db9fbc721e81508fbf62f5b76e8099b931f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 26 Aug 2026 04:30:51 -0700 Subject: [PATCH 19/49] feat(earn): show the position tab during a deposit --- .../__tests__/EarnAmount.position.test.tsx | 101 ++++++++++++++++-- .../components/earn/EarnAmount/index.tsx | 14 +++ 2 files changed, 109 insertions(+), 6 deletions(-) diff --git a/extension/src/popup/components/earn/EarnAmount/__tests__/EarnAmount.position.test.tsx b/extension/src/popup/components/earn/EarnAmount/__tests__/EarnAmount.position.test.tsx index 6e7f4a4c5f..ff5cc4b6cb 100644 --- a/extension/src/popup/components/earn/EarnAmount/__tests__/EarnAmount.position.test.tsx +++ b/extension/src/popup/components/earn/EarnAmount/__tests__/EarnAmount.position.test.tsx @@ -63,6 +63,61 @@ const earnAmountData = { tokenPrices: { [TEST_USDC_CANONICAL]: { currentPrice: "1" } }, }; +// The deposit's destination pool — real enough to render the pool card and +// the details sheet it opens (Task 11). +const pool = { + id: POOL_ID, + name: "Fixed Pool v2", + status: "ACTIVE", + suppliedUsd: 50050000, + borrowedUsd: 16150000, + interestApy: 0.0424, + netApy: 0.1694, + backstopUsd: 1530000, + reserves: [], +} as never; + +const usdcSupply = { + assetId: USDC_SAC, + symbol: "USDC", + name: `USDC:${USDC_ISSUER}`, + decimals: 7, + suppliedTokens: "0", + collateralTokens: RAW_POSITION, + totalTokens: RAW_POSITION, + usdValue: 100, + apy: 0.1694, + emissionsApr: 0, + interestEarned: "0", + interestEarnedUsd: 0, + claimableBlnd: "0", + claimableUsd: null, + priceUsd: 1, +}; + +// The cached shape `positionsSelector` returns for an account that already +// supplies `pool` — keyed into the store under `cache.positionsData` by the +// new tests below. +const existingPosition = { + address: TEST_PUBLIC_KEY, + totalValueUsd: 100, + netApy: 0.1694, + positions: [ + { + protocol: "blend", + id: POOL_ID, + name: "Fixed Pool v2", + netUsd: 100, + suppliedUsd: 100, + borrowedUsd: 0, + netApy: 0.1694, + blend: { supply: [usdcSupply], borrow: [] }, + }, + ], + backstop: [], + updatedAt: Date.now(), +} as never; + /** * A promise the test settles by hand. This is what makes the race deterministic: * the position lookup always loses to the simulation, which is the ordering the @@ -78,7 +133,9 @@ const makeDeferred = () => { return { promise, resolve, reject }; }; -const renderAmount = () => +// `overrides.cache` seeds `state.cache` — used by the position-tab tests +// below to control what `positionsSelector` reads without a network request. +const renderEarnAmount = (overrides: { cache?: any } = {}) => render( }, earn: { ...earnInitialState, - // Left null so the pool card and its details sheet stay out of the - // way; the review sheet falls back to "Blend pool". - pool: null, + pool, selectedAssetId: USDC_SAC, selectedAssetApy: 0.05, currentPositionTokens: "0", }, + cache: overrides.cache, } as any } routes={["/"]} @@ -151,7 +207,7 @@ describe("EarnAmount position lookup", () => { const position = makeDeferred(); (getBlendSuppliedTokens as jest.Mock).mockReturnValue(position.promise); - renderAmount(); + renderEarnAmount(); await act(async () => { fireEvent.click(screen.getByTestId("earn-amount-btn-continue")); @@ -179,7 +235,7 @@ describe("EarnAmount position lookup", () => { new Error("positions 500"), ); - renderAmount(); + renderEarnAmount(); await act(async () => { fireEvent.click(screen.getByTestId("earn-amount-btn-continue")); @@ -194,4 +250,37 @@ describe("EarnAmount position lookup", () => { screen.queryByTestId("earn-amount-fail-banner"), ).not.toBeInTheDocument(); }); + + it("shows the Your position tab when the account already supplies this pool", async () => { + // The requirement is that the tab appears wherever the sheet appears, not + // only on the Positions tab — a repeat depositor should see their stake. + renderEarnAmount({ + cache: { + positionsData: { TESTNET: { [TEST_PUBLIC_KEY]: existingPosition } }, + }, + }); + + fireEvent.click(screen.getByTestId("earn-pool-card")); + + expect( + await screen.findByTestId("earn-pool-details-tabs"), + ).toBeInTheDocument(); + // Opened on Overview: the user tapped the POOL card, so pool info is what + // they asked for. + expect(screen.getByTestId("earn-pool-interest-apy")).toBeInTheDocument(); + }); + + it("stays untabbed with a Close button for a first-time depositor", async () => { + renderEarnAmount({ cache: { positionsData: {} } }); + + fireEvent.click(screen.getByTestId("earn-pool-card")); + + expect( + await screen.findByTestId("earn-pool-details-sheet"), + ).toBeInTheDocument(); + expect( + screen.queryByTestId("earn-pool-details-tabs"), + ).not.toBeInTheDocument(); + expect(screen.getByText("Close")).toBeInTheDocument(); + }); }); diff --git a/extension/src/popup/components/earn/EarnAmount/index.tsx b/extension/src/popup/components/earn/EarnAmount/index.tsx index cd5e6dc768..1317cf034a 100644 --- a/extension/src/popup/components/earn/EarnAmount/index.tsx +++ b/extension/src/popup/components/earn/EarnAmount/index.tsx @@ -46,6 +46,7 @@ import { saveCurrentPositionTokens, setEarnSubmitFailed, } from "popup/ducks/earn"; +import { positionsSelector } from "popup/ducks/cache"; import { getBlendSuppliedTokens } from "@shared/api/helpers/blend"; import { formatTokenAmount } from "popup/helpers/soroban"; @@ -88,6 +89,7 @@ export const EarnAmount = ({ goBack, onConfirm }: EarnAmountProps) => { } = useSelector(earnSelector); const { state: simulationState, simulate } = useSimulateEarnDeposit(); const { recommendedFee } = useNetworkFees(); + const cachedPositions = useSelector(positionsSelector); const [isPoolSheetOpen, setIsPoolSheetOpen] = useState(false); const [isFeeSheetOpen, setIsFeeSheetOpen] = useState(false); @@ -155,6 +157,12 @@ export const EarnAmount = ({ goBack, onConfirm }: EarnAmountProps) => { } const data = state.data as ResolvedEarnAmount; + // Read from the cache Home already filled rather than issuing a request: + // the flow is only reachable from Home, which fetches positions on load. + const poolPosition = + cachedPositions[data.networkDetails.network]?.[ + data.publicKey + ]?.positions.find((p) => p.id === pool?.id) ?? null; const selected = asset ? getAssetFromCanonical(asset) : null; const decimals = getAssetDecimals(asset, data.balances, true); @@ -468,6 +476,12 @@ export const EarnAmount = ({ goBack, onConfirm }: EarnAmountProps) => { {pool ? ( setIsPoolSheetOpen(false)} /> ) : ( From 8fee5d16a587138b4f45320c8d0dbd2409efa9e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 26 Aug 2026 05:08:27 -0700 Subject: [PATCH 20/49] feat(earn): attribute deposits to where the flow started Co-Authored-By: Claude Opus 5 (1M context) --- .../account/AccountPositions/EmptyState.tsx | 13 ++-- .../__tests__/AccountPositions.sheet.test.tsx | 63 +++++++++++++++++-- .../__tests__/AccountPositions.test.tsx | 35 ++++++++--- .../account/AccountPositions/index.tsx | 26 ++++++-- .../components/earn/EarnAmount/index.tsx | 13 ++-- .../EarnSubmit/__tests__/EarnSubmit.test.tsx | 25 +++++++- .../EarnSubmit/hooks/useSubmitEarnTxData.tsx | 6 ++ .../constants/__tests__/metricsNames.test.ts | 10 +++ extension/src/popup/constants/earn.ts | 32 ++++++++++ extension/src/popup/constants/metricsNames.ts | 12 ++++ extension/src/popup/ducks/earn.ts | 19 ++++++ extension/src/popup/metrics/earn.test.ts | 30 +++++++++ extension/src/popup/metrics/earn.ts | 40 +++++++++++- extension/src/popup/metrics/positions.test.ts | 35 +++++++++++ extension/src/popup/metrics/positions.ts | 34 ++++++++++ extension/src/popup/views/Account/index.tsx | 19 +++--- extension/src/popup/views/Earn/index.tsx | 17 ++++- 17 files changed, 394 insertions(+), 35 deletions(-) create mode 100644 extension/src/popup/metrics/positions.test.ts create mode 100644 extension/src/popup/metrics/positions.ts diff --git a/extension/src/popup/components/account/AccountPositions/EmptyState.tsx b/extension/src/popup/components/account/AccountPositions/EmptyState.tsx index 014107081c..212f6dc8de 100644 --- a/extension/src/popup/components/account/AccountPositions/EmptyState.tsx +++ b/extension/src/popup/components/account/AccountPositions/EmptyState.tsx @@ -4,6 +4,7 @@ import { useNavigate } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { ROUTES } from "popup/constants/routes"; +import { EARN_SOURCE, EARN_SOURCE_KEY } from "popup/constants/earn"; import { navigateTo } from "popup/helpers/navigate"; import { formatRate } from "popup/components/earn/helpers/formatPoolStats"; import { formatAmount } from "popup/helpers/formatters"; @@ -13,7 +14,7 @@ interface EmptyStateProps { projectedUsd: string | null; /** Best rate on offer — the fallback figure, and null when even that is unknown. */ bestApy: number | null; - /** Fired when the CTA is pressed, before it navigates to Earn (Task 12 wires this up). */ + /** Fired when the CTA is pressed, before it navigates to Earn. */ onStartEarning: () => void; } @@ -27,8 +28,8 @@ interface EmptyStateProps { * rather than rendering blank. * * The CTA's navigation lives here, in one place, rather than behind a prop — - * Task 12 only has to touch this one call site to add its analytics `source` - * query param. + * which is why Task 12 only had to touch this one call site to add the + * `source` query param the deposit funnel attributes back to this CTA. */ export const EmptyState = ({ projectedUsd, @@ -80,7 +81,11 @@ export const EmptyState = ({ isRounded onClick={() => { onStartEarning(); - navigateTo(ROUTES.earn, navigate); + navigateTo( + ROUTES.earn, + navigate, + `?${EARN_SOURCE_KEY}=${EARN_SOURCE.POSITIONS_EMPTY}`, + ); }} data-testid="account-positions-start-earning" > diff --git a/extension/src/popup/components/account/AccountPositions/__tests__/AccountPositions.sheet.test.tsx b/extension/src/popup/components/account/AccountPositions/__tests__/AccountPositions.sheet.test.tsx index 6686499a30..c60dfc013f 100644 --- a/extension/src/popup/components/account/AccountPositions/__tests__/AccountPositions.sheet.test.tsx +++ b/extension/src/popup/components/account/AccountPositions/__tests__/AccountPositions.sheet.test.tsx @@ -9,7 +9,11 @@ import { AccountPositions } from "popup/components/account/AccountPositions"; import { PositionTokenRow } from "popup/components/earn/helpers/positionRows"; import { TEST_PUBLIC_KEY, Wrapper, getTestStore } from "popup/__testHelpers__"; import { ROUTES } from "popup/constants/routes"; -import { EARN_PREFILL_QUERY } from "popup/constants/earn"; +import { + EARN_PREFILL_QUERY, + EARN_SOURCE, + EARN_SOURCE_KEY, +} from "popup/constants/earn"; import { navigateTo } from "popup/helpers/navigate"; import { resetSubmission, @@ -23,6 +27,24 @@ import { saveSelectedAssetId, } from "popup/ducks/earn"; +jest.mock("popup/metrics/positions", () => ({ + trackPositionRowSelected: jest.fn(), + trackPositionsEmptyCtaSelected: jest.fn(), +})); + +jest.mock("popup/metrics/earn", () => ({ + ...jest.requireActual("popup/metrics/earn"), + trackEarnPoolDetailsOpened: jest.fn(), + trackEarnPoolDetailsTabSelected: jest.fn(), +})); + +const { trackPositionRowSelected } = jest.requireMock< + typeof import("popup/metrics/positions") +>("popup/metrics/positions"); + +const { trackEarnPoolDetailsOpened, trackEarnPoolDetailsTabSelected } = + jest.requireMock("popup/metrics/earn"); + const POOL_ID = "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD"; const USDC_SAC = "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75"; const XLM_SAC = "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA"; @@ -100,7 +122,11 @@ const onDeposit = (row: PositionTokenRow, depositPool: BlendCatalogPool) => { store.dispatch(saveAsset(getCanonicalFromAsset(row.code, row.issuer))); store.dispatch(saveDestination(row.poolId)); store.dispatch(saveIsToken(true)); - navigateTo(ROUTES.earn, mockNavigate, EARN_PREFILL_QUERY); + navigateTo( + ROUTES.earn, + mockNavigate, + `${EARN_PREFILL_QUERY}&${EARN_SOURCE_KEY}=${EARN_SOURCE.POSITION_ROW}`, + ); }; const renderTab = ( @@ -114,7 +140,6 @@ const renderTab = ( hasError={false} assetIcons={{}} networkDetails={MAINNET_NETWORK_DETAILS} - onSelectRow={() => {}} projectedUsd={null} bestApy={null} onStartEarning={() => {}} @@ -126,6 +151,10 @@ const renderTab = ( ); describe("AccountPositions pool sheet", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it("opens the pool sheet on Your position when a row is tapped", async () => { renderTab({ positions: twoAssetPositions, pools: [pool] }); @@ -138,6 +167,32 @@ describe("AccountPositions pool sheet", () => { expect(screen.getByTestId("earn-position-balance")).toHaveTextContent( "$500.12", ); + // Both the funnel's top-of-page tap and the sheet it opens are attributed + // to the Positions row that started this flow. + expect(trackPositionRowSelected).toHaveBeenCalledWith({ + poolId: POOL_ID, + protocol: "blend", + assetCode: "USDC", + }); + expect(trackEarnPoolDetailsOpened).toHaveBeenCalledWith({ + poolId: POOL_ID, + source: "position_row", + }); + }); + + it("attributes a tab switch on the sheet to the position row that opened it", async () => { + renderTab({ positions: twoAssetPositions, pools: [pool] }); + + fireEvent.click(screen.getByTestId("position-row-USDC")); + fireEvent.click( + await screen.findByTestId("earn-pool-details-tab-overview"), + ); + + expect(trackEarnPoolDetailsTabSelected).toHaveBeenCalledWith({ + poolId: POOL_ID, + tab: "overview", + source: "position_row", + }); }); it("prefills the deposit and lands on the amount screen", async () => { @@ -156,7 +211,7 @@ describe("AccountPositions pool sheet", () => { POOL_ID, ); expect(mockNavigate).toHaveBeenCalledWith( - expect.stringContaining("/earn?prefill=1"), + expect.stringContaining("/earn?prefill=1&source=position_row"), ); }); }); diff --git a/extension/src/popup/components/account/AccountPositions/__tests__/AccountPositions.test.tsx b/extension/src/popup/components/account/AccountPositions/__tests__/AccountPositions.test.tsx index 1d1f605988..742cb46d2f 100644 --- a/extension/src/popup/components/account/AccountPositions/__tests__/AccountPositions.test.tsx +++ b/extension/src/popup/components/account/AccountPositions/__tests__/AccountPositions.test.tsx @@ -5,6 +5,15 @@ import { MAINNET_NETWORK_DETAILS } from "@shared/constants/stellar"; import { AccountPositions } from "popup/components/account/AccountPositions"; import { TEST_PUBLIC_KEY, Wrapper } from "popup/__testHelpers__"; +jest.mock("popup/metrics/positions", () => ({ + trackPositionRowSelected: jest.fn(), + trackPositionsEmptyCtaSelected: jest.fn(), +})); + +const { trackPositionRowSelected } = jest.requireMock< + typeof import("popup/metrics/positions") +>("popup/metrics/positions"); + const POOL_ID = "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD"; const USDC_SAC = "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75"; const XLM_SAC = "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA"; @@ -83,7 +92,6 @@ const renderTab = ( hasError={false} assetIcons={{}} networkDetails={MAINNET_NETWORK_DETAILS} - onSelectRow={() => {}} projectedUsd={null} bestApy={null} onStartEarning={() => {}} @@ -95,6 +103,10 @@ const renderTab = ( ); describe("AccountPositions", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it("spins while the request is in flight", () => { renderTab({ isLoading: true }); @@ -145,17 +157,26 @@ describe("AccountPositions", () => { ).not.toBeInTheDocument(); }); - it("renders one row per supplied token, each opening its pool", () => { - const onSelectRow = jest.fn(); - renderTab({ positions: twoAssetPositions, onSelectRow }); + it("renders one row per supplied token", () => { + renderTab({ positions: twoAssetPositions }); expect(screen.getByTestId("position-row-USDC")).toBeInTheDocument(); expect(screen.getByTestId("position-row-XLM")).toBeInTheDocument(); + }); + + it("reports a row tap with the pool it belongs to", () => { + // The sheet itself opens from AccountPositions' own state (see + // AccountPositions.sheet.test.tsx); this only covers the analytics side of + // the same tap. + renderTab({ positions: twoAssetPositions }); fireEvent.click(screen.getByTestId("position-row-USDC")); - expect(onSelectRow).toHaveBeenCalledWith( - expect.objectContaining({ code: "USDC", poolId: POOL_ID }), - ); + + expect(trackPositionRowSelected).toHaveBeenCalledWith({ + poolId: POOL_ID, + protocol: "blend", + assetCode: "USDC", + }); }); it("renders an unavailable value as -- rather than zero", () => { diff --git a/extension/src/popup/components/account/AccountPositions/index.tsx b/extension/src/popup/components/account/AccountPositions/index.tsx index 131e16c102..64cf9d97b1 100644 --- a/extension/src/popup/components/account/AccountPositions/index.tsx +++ b/extension/src/popup/components/account/AccountPositions/index.tsx @@ -15,6 +15,12 @@ import { } from "popup/components/earn/helpers/positionRows"; import { PoolDetailsSheet } from "popup/components/earn/PoolDetailsSheet"; import { SlideupModal } from "popup/components/SlideupModal"; +import { EARN_SOURCE } from "popup/constants/earn"; +import { + trackEarnPoolDetailsOpened, + trackEarnPoolDetailsTabSelected, +} from "popup/metrics/earn"; +import { trackPositionRowSelected } from "popup/metrics/positions"; import { PositionRow } from "./PositionRow"; import { EmptyState } from "./EmptyState"; @@ -32,8 +38,6 @@ interface AccountPositionsProps { hasError: boolean; assetIcons: AssetIcons; networkDetails: NetworkDetails; - /** A row was clicked; opens that position's pool-details sheet (Task 10). */ - onSelectRow: (row: PositionTokenRow) => void; /** Empty state's annual projection in USD; null when there is nothing to total. */ projectedUsd: string | null; /** Empty state's fallback figure — the best rate on offer; null when unknown too. */ @@ -64,7 +68,6 @@ export const AccountPositions = ({ hasError, assetIcons, networkDetails, - onSelectRow, projectedUsd, bestApy, onStartEarning, @@ -138,7 +141,15 @@ export const AccountPositions = ({ row={row} assetIcons={assetIcons} onClick={() => { - onSelectRow(row); + trackPositionRowSelected({ + poolId: row.poolId, + protocol: row.protocol, + assetCode: row.code, + }); + trackEarnPoolDetailsOpened({ + poolId: row.poolId, + source: EARN_SOURCE.POSITION_ROW, + }); setSelected(row); }} /> @@ -159,6 +170,13 @@ export const AccountPositions = ({ defaultTab="your_position" onClose={() => setSelected(null)} onDeposit={() => onDeposit(selected, selectedPool)} + onTabChange={(tab) => + trackEarnPoolDetailsTabSelected({ + poolId: selectedPool.id, + tab, + source: EARN_SOURCE.POSITION_ROW, + }) + } /> ) : (
diff --git a/extension/src/popup/components/earn/EarnAmount/index.tsx b/extension/src/popup/components/earn/EarnAmount/index.tsx index 1317cf034a..183f0366c0 100644 --- a/extension/src/popup/components/earn/EarnAmount/index.tsx +++ b/extension/src/popup/components/earn/EarnAmount/index.tsx @@ -28,11 +28,12 @@ import { } from "popup/helpers/formatters"; import { getAssetDecimals, getAvailableBalance } from "popup/helpers/soroban"; import { useNetworkFees } from "popup/helpers/useNetworkFees"; -import { emitMetric, emitScreenViewed } from "helpers/metrics"; -import { METRIC_NAMES } from "popup/constants/metricsNames"; +import { emitScreenViewed } from "helpers/metrics"; import { scrubStrKeys } from "helpers/stellarStrKey"; import { trackEarnPercentAmountSelected, + trackEarnPoolDetailsOpened, + trackEarnPoolDetailsTabSelected, trackEarnSimulationFailed, trackEarnXlmFeeInsufficientShown, } from "popup/metrics/earn"; @@ -86,6 +87,7 @@ export const EarnAmount = ({ goBack, onConfirm }: EarnAmountProps) => { selectedAssetId, lastSubmitFailed, currentPositionTokens, + source, } = useSelector(earnSelector); const { state: simulationState, simulate } = useSimulateEarnDeposit(); const { recommendedFee } = useNetworkFees(); @@ -406,9 +408,7 @@ export const EarnAmount = ({ goBack, onConfirm }: EarnAmountProps) => { poolName={pool.name} apy={selectedAssetApy} onOpenDetails={() => { - emitMetric(METRIC_NAMES.earnPoolDetailsOpened, { - pool_id: pool.id, - }); + trackEarnPoolDetailsOpened({ poolId: pool.id, source }); setIsPoolSheetOpen(true); }} /> @@ -483,6 +483,9 @@ export const EarnAmount = ({ goBack, onConfirm }: EarnAmountProps) => { // for. A Positions row opens on the other tab. defaultTab="overview" onClose={() => setIsPoolSheetOpen(false)} + onTabChange={(tab) => + trackEarnPoolDetailsTabSelected({ poolId: pool.id, tab, source }) + } /> ) : (
diff --git a/extension/src/popup/components/earn/EarnSubmit/__tests__/EarnSubmit.test.tsx b/extension/src/popup/components/earn/EarnSubmit/__tests__/EarnSubmit.test.tsx index e7f2899429..ada26b95c7 100644 --- a/extension/src/popup/components/earn/EarnSubmit/__tests__/EarnSubmit.test.tsx +++ b/extension/src/popup/components/earn/EarnSubmit/__tests__/EarnSubmit.test.tsx @@ -13,6 +13,7 @@ import { TESTNET_NETWORK_DETAILS } from "@shared/constants/stellar"; import { RequestState } from "constants/request"; import { EarnSubmit } from "popup/components/earn/EarnSubmit"; import { useSubmitEarnTxData } from "popup/components/earn/EarnSubmit/hooks/useSubmitEarnTxData"; +import { EarnSource } from "popup/constants/earn"; import { initialState as earnInitialState } from "popup/ducks/earn"; import { initialState as transactionSubmissionInitialState, @@ -96,11 +97,13 @@ const makeState = ({ hardwareWalletType = WalletType.NONE, hwStatus = ShowOverlayStatus.IDLE, lastSubmitFailed = false, + source = earnInitialState.source, }: { xdr: string; hardwareWalletType?: WalletType; hwStatus?: ShowOverlayStatus; lastSubmitFailed?: boolean; + source?: EarnSource; }) => ({ auth: { allAccounts: [{ publicKey: TEST_PUBLIC_KEY, hardwareWalletType }], @@ -125,7 +128,12 @@ const makeState = ({ shouldSubmit: true, }, }, - earn: { ...earnInitialState, pool: { id: POOL_ID }, lastSubmitFailed }, + earn: { + ...earnInitialState, + pool: { id: POOL_ID }, + lastSubmitFailed, + source, + }, }); const renderSubmit = ({ @@ -140,6 +148,7 @@ const renderSubmit = ({ hardwareWalletType?: WalletType; hwStatus?: ShowOverlayStatus; lastSubmitFailed?: boolean; + source?: EarnSource; }) => render( @@ -272,6 +281,20 @@ describe("EarnSubmit", () => { expect(submittedXdrs()).toEqual([]); expect(mockSignSoroban).not.toHaveBeenCalled(); }); + + it("attributes a completed deposit to where the flow started", async () => { + // The submit hook is two screens and one resetSubmission() away from + // wherever the flow began, so it reads `source` off the earn slice rather + // than receiving it as a prop — see ducks/earn's `source` field. + renderSubmit({ xdr: "AAAA-unsigned", source: "position_row" }); + + await waitFor(() => + expect(emitMetric).toHaveBeenCalledWith( + METRIC_NAMES.earnDepositCompleted, + expect.objectContaining({ source: "position_row" }), + ), + ); + }); }); /** * The screen ands the hook's state together with redux `submitStatus`, and the diff --git a/extension/src/popup/components/earn/EarnSubmit/hooks/useSubmitEarnTxData.tsx b/extension/src/popup/components/earn/EarnSubmit/hooks/useSubmitEarnTxData.tsx index 3a3de3e5d1..efc9c4adc0 100644 --- a/extension/src/popup/components/earn/EarnSubmit/hooks/useSubmitEarnTxData.tsx +++ b/extension/src/popup/components/earn/EarnSubmit/hooks/useSubmitEarnTxData.tsx @@ -18,6 +18,7 @@ import { submitFreighterSorobanTransaction, transactionSubmissionSelector, } from "popup/ducks/transactionSubmission"; +import { earnSelector } from "popup/ducks/earn"; interface SubmitEarnTxData { status: "success"; @@ -72,6 +73,9 @@ export function useSubmitEarnTxData({ }) { const reduxDispatch = useDispatch(); const { transactionSimulation } = useSelector(transactionSubmissionSelector); + // Read directly rather than added as a prop, so EarnSubmit itself needs no + // change to hand it through. + const { source } = useSelector(earnSelector); const [state, dispatch] = useReducer( reducer, initialState, @@ -86,6 +90,7 @@ export function useSubmitEarnTxData({ assetCode, poolId, reasonCode: getFailureReasonCode(error), + source, }); const fetchData = async () => { @@ -145,6 +150,7 @@ export function useSubmitEarnTxData({ poolId, apy, viaSwap, + source, }); // The deposit moved funds out of the account, so refresh balances. A diff --git a/extension/src/popup/constants/__tests__/metricsNames.test.ts b/extension/src/popup/constants/__tests__/metricsNames.test.ts index b7c7bc6add..6fade2b056 100644 --- a/extension/src/popup/constants/__tests__/metricsNames.test.ts +++ b/extension/src/popup/constants/__tests__/metricsNames.test.ts @@ -53,6 +53,9 @@ describe("METRIC_NAMES domain-event catalog", () => { ); expect(METRIC_NAMES.earnSwapCompleted).toBe("earn.swap_completed"); expect(METRIC_NAMES.earnPoolDetailsOpened).toBe("earn.pool_details_opened"); + expect(METRIC_NAMES.earnPoolDetailsTabSelected).toBe( + "earn.pool_details_tab_selected", + ); expect(METRIC_NAMES.earnMaxAmountSelected).toBe("earn.max_amount_selected"); expect(METRIC_NAMES.earnXlmFeeInsufficientShown).toBe( "earn.xlm_fee_insufficient_shown", @@ -65,6 +68,13 @@ describe("METRIC_NAMES domain-event catalog", () => { ); }); + it("names the positions-tab events (the top of the deposit funnel)", () => { + expect(METRIC_NAMES.positionRowSelected).toBe("positions.row_selected"); + expect(METRIC_NAMES.positionsEmptyCtaSelected).toBe( + "positions.empty_cta_selected", + ); + }); + it("names collectible-send and transaction-submission events", () => { expect(METRIC_NAMES.collectibleSendCompleted).toBe( "collectible_send.completed", diff --git a/extension/src/popup/constants/earn.ts b/extension/src/popup/constants/earn.ts index 147c49e4de..615ae75b41 100644 --- a/extension/src/popup/constants/earn.ts +++ b/extension/src/popup/constants/earn.ts @@ -33,6 +33,38 @@ export const EARN_PREFILL_QUERY = `?${EARN_PREFILL_KEY}=1`; export const isEarnPrefillSearch = (search: string) => new URLSearchParams(search).get(EARN_PREFILL_KEY) === "1"; +/** + * Where a deposit flow began. Carried on the deposit outcomes so a deposit + * started from Home's Earn button can be told from one started on the Positions + * tab. + * + * Same shape as DISCOVER_SOURCE in popup/metrics/discover.ts — a const object + * and a derived union, emitted as `source`. + */ +export const EARN_SOURCE = { + HOME: "home", + POSITION_ROW: "position_row", + POSITIONS_EMPTY: "positions_empty", +} as const; + +export type EarnSource = (typeof EARN_SOURCE)[keyof typeof EARN_SOURCE]; + +export const EARN_SOURCE_KEY = "source"; + +/** + * Which entry point opened the flow. Read once at mount by `views/Earn` — the + * view rewrites its own search when the swap branch opens and closes, so a + * later read finds nothing. An unrecognised or absent value means Home, which + * is the entry point that passes no param at all. + */ +export const getEarnSourceFromSearch = (search: string): EarnSource => { + const value = new URLSearchParams(search).get(EARN_SOURCE_KEY); + return value === EARN_SOURCE.POSITION_ROW || + value === EARN_SOURCE.POSITIONS_EMPTY + ? value + : EARN_SOURCE.HOME; +}; + /** * Steps in the Earn deposit flow. * diff --git a/extension/src/popup/constants/metricsNames.ts b/extension/src/popup/constants/metricsNames.ts index fd30e17802..2fdfb14407 100644 --- a/extension/src/popup/constants/metricsNames.ts +++ b/extension/src/popup/constants/metricsNames.ts @@ -53,7 +53,12 @@ export const METRIC_NAMES = { // The swap-within-earn branch settled. Distinct from `swap.completed`, which // the reused Swap components emit and cannot attribute to Earn. earnSwapCompleted: "earn.swap_completed", + // The pool details sheet was opened, and which entry point opened it. earnPoolDetailsOpened: "earn.pool_details_opened", + // A tap that CHANGED the sheet's tab. Never emitted for the default tab, and + // never for a tap on the tab already active — either would inflate the count + // with no-ops and make "did anyone open Overview?" unanswerable. + earnPoolDetailsTabSelected: "earn.pool_details_tab_selected", // The Max tap on the amount screen, and only that tap — the 25/50/75 // shortcuts emit nothing, matching Send, Swap and mobile (RFC #2883, D5). // `percent` is always 100; it is kept for payload symmetry with mobile. @@ -70,6 +75,13 @@ export const METRIC_NAMES = { // outright loses the outcome. earnDepositDismissed: "earn.deposit_processing_dismissed", + // -- Positions ----------------------------------------------------------- + // The Home tab listing the account's supplied tokens. `earn.*` events cover + // what happens after one of these leads into the deposit flow; the `source` + // dimension on the deposit outcomes is what joins the two. + positionRowSelected: "positions.row_selected", + positionsEmptyCtaSelected: "positions.empty_cta_selected", + // -- Collectibles -------------------------------------------------------- collectibleSendCompleted: "collectible_send.completed", collectibleSendFailed: "collectible_send.failed", diff --git a/extension/src/popup/ducks/earn.ts b/extension/src/popup/ducks/earn.ts index f39f636f23..56b2ec9e1a 100644 --- a/extension/src/popup/ducks/earn.ts +++ b/extension/src/popup/ducks/earn.ts @@ -1,6 +1,7 @@ import { createSlice } from "@reduxjs/toolkit"; import { BlendCatalogPool } from "@shared/api/types/blend"; +import { EARN_SOURCE, EarnSource } from "popup/constants/earn"; /** * Earn-domain state that has no home in `transactionSubmission`. @@ -53,6 +54,19 @@ export interface EarnState { * emitted from the submit hook, two screens and one `resetSubmission()` apart. */ didSwapInFlow: boolean; + /** + * Where this deposit flow began — Home's Earn button or a Positions row/empty + * CTA. The `source` dimension on `earn.deposit_completed` / `earn.deposit_failed`. + * + * Lives here for the same reason `didSwapInFlow` does: those outcomes are + * emitted from the submit hook and from the Earn view's submit-status effect, + * both several screens and one `resetSubmission()` away from wherever the flow + * actually started. `views/Earn` sets it once at mount from the URL (see + * `getEarnSourceFromSearch`) rather than a caller dispatching it before + * navigating, because that view rewrites its own search string when the swap + * branch opens and closes, which would carry away a flag set any other way. + */ + source: EarnSource; } export const initialState: EarnState = { @@ -63,6 +77,7 @@ export const initialState: EarnState = { hasSeenIntro: null, lastSubmitFailed: false, didSwapInFlow: false, + source: EARN_SOURCE.HOME, }; const earnSlice = createSlice({ @@ -90,6 +105,9 @@ const earnSlice = createSlice({ setDidSwapInFlow: (state, action: { payload: boolean }) => { state.didSwapInFlow = action.payload; }, + saveEarnSource: (state, action: { payload: EarnSource }) => { + state.source = action.payload; + }, /** * Resets everything except `hasSeenIntro` — that flag is persisted in the * background store and re-reading it on every flow entry would reintroduce @@ -110,6 +128,7 @@ export const { setEarnIntroSeen, setEarnSubmitFailed, setDidSwapInFlow, + saveEarnSource, resetEarn, } = earnSlice.actions; diff --git a/extension/src/popup/metrics/earn.test.ts b/extension/src/popup/metrics/earn.test.ts index 99144c7493..9a0c279c73 100644 --- a/extension/src/popup/metrics/earn.test.ts +++ b/extension/src/popup/metrics/earn.test.ts @@ -11,6 +11,8 @@ import { trackEarnDepositFailed, trackEarnFundingActionSelected, trackEarnPercentAmountSelected, + trackEarnPoolDetailsOpened, + trackEarnPoolDetailsTabSelected, trackEarnSimulationFailed, trackEarnSwapCompleted, trackEarnTokenSelected, @@ -96,6 +98,28 @@ describe("Earn funnel metrics", () => { ); }); + it("reports the pool details sheet opening with its source", () => { + trackEarnPoolDetailsOpened({ poolId: POOL_ID, source: "position_row" }); + + expect(mockEmitMetric).toHaveBeenCalledWith( + METRIC_NAMES.earnPoolDetailsOpened, + { pool_id: POOL_ID, source: "position_row" }, + ); + }); + + it("reports a tab switch on the pool details sheet", () => { + trackEarnPoolDetailsTabSelected({ + poolId: POOL_ID, + tab: "overview", + source: "position_row", + }); + + expect(mockEmitMetric).toHaveBeenCalledWith( + METRIC_NAMES.earnPoolDetailsTabSelected, + { pool_id: POOL_ID, tab: "overview", source: "position_row" }, + ); + }); + it("tells the two XLM-fee shortfalls apart", () => { // Same drop-off, different remedy: one account has no XLM at all and gets // the buy/swap sheet, the other allocated it all to the deposit. @@ -160,6 +184,7 @@ describe("Earn funnel metrics", () => { poolId: POOL_ID, apy: 0.1694, viaSwap: true, + source: "position_row", }); expect(mockEmitMetric).toHaveBeenCalledWith( @@ -169,6 +194,7 @@ describe("Earn funnel metrics", () => { pool_id: POOL_ID, apy: 0.1694, via_swap: true, + source: "position_row", }, ); }); @@ -179,6 +205,7 @@ describe("Earn funnel metrics", () => { poolId: POOL_ID, apy: 0.1694, viaSwap: false, + source: "home", }); const [, body] = mockEmitMetric.mock.calls[0]; @@ -187,6 +214,7 @@ describe("Earn funnel metrics", () => { "pool_id", "apy", "via_swap", + "source", ]); }); @@ -195,6 +223,7 @@ describe("Earn funnel metrics", () => { assetCode: "USDC", poolId: POOL_ID, reasonCode: "op_underfunded", + source: "positions_empty", }); expect(mockEmitMetric).toHaveBeenCalledWith( @@ -203,6 +232,7 @@ describe("Earn funnel metrics", () => { asset_code: "USDC", pool_id: POOL_ID, reason_code: "op_underfunded", + source: "positions_empty", }, ); }); diff --git a/extension/src/popup/metrics/earn.ts b/extension/src/popup/metrics/earn.ts index 7cfd6722a4..8e69de931d 100644 --- a/extension/src/popup/metrics/earn.ts +++ b/extension/src/popup/metrics/earn.ts @@ -1,6 +1,7 @@ import { emitMetric } from "helpers/metrics"; -import { NotEnoughVariant } from "popup/constants/earn"; +import { EarnSource, NotEnoughVariant } from "popup/constants/earn"; import { METRIC_NAMES } from "popup/constants/metricsNames"; +import { PoolDetailsTab } from "popup/components/earn/PoolDetailsSheet/Tabs"; /** * Emitters for the Earn deposit funnel. @@ -90,6 +91,35 @@ export const trackEarnSwapCompleted = ({ }); }; +export const trackEarnPoolDetailsOpened = ({ + poolId, + source, +}: { + poolId: string; + source: EarnSource; +}) => { + emitMetric(METRIC_NAMES.earnPoolDetailsOpened, { + pool_id: poolId, + source, + }); +}; + +export const trackEarnPoolDetailsTabSelected = ({ + poolId, + tab, + source, +}: { + poolId: string; + tab: PoolDetailsTab; + source: EarnSource; +}) => { + emitMetric(METRIC_NAMES.earnPoolDetailsTabSelected, { + pool_id: poolId, + tab, + source, + }); +}; + /** * The deposit could not cover its own network fee. Two shapes, told apart by * `reason`: `no_xlm` is an account with no spendable XLM at all, which gets the @@ -181,18 +211,22 @@ export const trackEarnDepositCompleted = ({ poolId, apy, viaSwap, + source, }: { assetCode: string; poolId: string; apy: number | null; /** The deposited balance was created by the swap branch in this same flow. */ viaSwap: boolean; + /** Where the flow began — Home's Earn button or a Positions row/empty CTA. */ + source: EarnSource; }) => { emitMetric(METRIC_NAMES.earnDepositCompleted, { asset_code: assetCode, pool_id: poolId, apy, via_swap: viaSwap, + source, }); }; @@ -208,14 +242,18 @@ export const trackEarnDepositFailed = ({ assetCode, poolId, reasonCode, + source, }: { assetCode: string; poolId: string; reasonCode: string; + /** Where the flow began — Home's Earn button or a Positions row/empty CTA. */ + source: EarnSource; }) => { emitMetric(METRIC_NAMES.earnDepositFailed, { asset_code: assetCode, pool_id: poolId, reason_code: reasonCode, + source, }); }; diff --git a/extension/src/popup/metrics/positions.test.ts b/extension/src/popup/metrics/positions.test.ts new file mode 100644 index 0000000000..6c646d1fad --- /dev/null +++ b/extension/src/popup/metrics/positions.test.ts @@ -0,0 +1,35 @@ +import { emitMetric } from "helpers/metrics"; +import { METRIC_NAMES } from "popup/constants/metricsNames"; +import { + trackPositionRowSelected, + trackPositionsEmptyCtaSelected, +} from "popup/metrics/positions"; + +jest.mock("helpers/metrics", () => ({ emitMetric: jest.fn() })); +const mocked = emitMetric as jest.Mock; +beforeEach(() => mocked.mockReset()); + +describe("positions metrics", () => { + it("reports a row tap with the pool it belongs to", () => { + trackPositionRowSelected({ + poolId: "CAJJ", + protocol: "blend", + assetCode: "USDC", + }); + + expect(mocked).toHaveBeenCalledWith(METRIC_NAMES.positionRowSelected, { + pool_id: "CAJJ", + protocol: "blend", + asset_code: "USDC", + }); + }); + + it("reports the empty state's CTA", () => { + trackPositionsEmptyCtaSelected(); + + expect(mocked).toHaveBeenCalledWith( + METRIC_NAMES.positionsEmptyCtaSelected, + {}, + ); + }); +}); diff --git a/extension/src/popup/metrics/positions.ts b/extension/src/popup/metrics/positions.ts new file mode 100644 index 0000000000..a98a720053 --- /dev/null +++ b/extension/src/popup/metrics/positions.ts @@ -0,0 +1,34 @@ +import { emitMetric } from "helpers/metrics"; +import { METRIC_NAMES } from "popup/constants/metricsNames"; + +/** + * Emitters for the Positions tab. + * + * The top of the positions-to-deposit funnel. Everything downstream of the + * sheet's Deposit button is an `earn.*` event carrying `source: + * "position_row"`, which is what joins the two halves. + * + * Note the funnel is genuinely shorter on this path than from Home: a prefilled + * entry skips CHOOSE_TOKEN, so `earn.token_selected` and + * `screen.viewed: earn_select_token` never fire for it. We deliberately do not + * synthesise them — that would report a selection the user never made. + */ +export const trackPositionRowSelected = ({ + poolId, + protocol, + assetCode, +}: { + poolId: string; + protocol: string; + assetCode: string; +}) => { + emitMetric(METRIC_NAMES.positionRowSelected, { + pool_id: poolId, + protocol, + asset_code: assetCode, + }); +}; + +export const trackPositionsEmptyCtaSelected = () => { + emitMetric(METRIC_NAMES.positionsEmptyCtaSelected, {}); +}; diff --git a/extension/src/popup/views/Account/index.tsx b/extension/src/popup/views/Account/index.tsx index 83392e7a63..3ecfc26f08 100644 --- a/extension/src/popup/views/Account/index.tsx +++ b/extension/src/popup/views/Account/index.tsx @@ -57,7 +57,12 @@ import { AppDataType } from "helpers/hooks/useGetAppData"; import { AccountBalances } from "helpers/hooks/useGetBalances"; import { MultiPaneSlider } from "popup/components/SlidingPaneSwitcher"; import { ROUTES } from "popup/constants/routes"; -import { EARN_PREFILL_QUERY } from "popup/constants/earn"; +import { + EARN_PREFILL_QUERY, + EARN_SOURCE, + EARN_SOURCE_KEY, +} from "popup/constants/earn"; +import { trackPositionsEmptyCtaSelected } from "popup/metrics/positions"; import { useGetAccountData, RequestState } from "./hooks/useGetAccountData"; import { useGetAccountHistoryData } from "./hooks/useGetAccountHistoryData"; @@ -314,7 +319,11 @@ export const Account = () => { // routes the flow down the Soroban path rather than the classic one. dispatch(saveDestination(row.poolId)); dispatch(saveIsToken(true)); - navigateTo(ROUTES.earn, navigate, EARN_PREFILL_QUERY); + navigateTo( + ROUTES.earn, + navigate, + `${EARN_PREFILL_QUERY}&${EARN_SOURCE_KEY}=${EARN_SOURCE.POSITION_ROW}`, + ); }; return ( @@ -425,13 +434,9 @@ export const Account = () => { hasError={!!resolvedData?.hasPositionsError} assetIcons={resolvedIcons} networkDetails={networkDetails} - // The pool-details sheet opens from inside AccountPositions - // itself (Task 10); reserved for a future analytics hook. - onSelectRow={() => {}} projectedUsd={projection.usd} bestApy={projection.bestApy} - // Task 12 wires this to an analytics event. - onStartEarning={() => {}} + onStartEarning={trackPositionsEmptyCtaSelected} pools={resolvedData?.pools ?? []} onDeposit={onDepositFromPosition} /> diff --git a/extension/src/popup/views/Earn/index.tsx b/extension/src/popup/views/Earn/index.tsx index 7ee42008ce..2620f02fde 100644 --- a/extension/src/popup/views/Earn/index.tsx +++ b/extension/src/popup/views/Earn/index.tsx @@ -5,7 +5,11 @@ import { useLocation, useNavigate } from "react-router-dom"; import { getCanonicalFromAsset } from "@shared/helpers/stellar"; import { getAssetFromCanonical } from "helpers/stellar"; import { ROUTES } from "popup/constants/routes"; -import { isEarnPrefillSearch, STEPS } from "popup/constants/earn"; +import { + getEarnSourceFromSearch, + isEarnPrefillSearch, + STEPS, +} from "popup/constants/earn"; import { navigateTo } from "popup/helpers/navigate"; import { emitScreenViewed, ScreenViewedProps } from "helpers/metrics"; import { ActionStatus } from "@shared/api/types"; @@ -22,6 +26,7 @@ import { resetEarn, saveCurrentPositionTokens, saveEarnPool, + saveEarnSource, saveSelectedAssetApy, saveSelectedAssetId, setDidSwapInFlow, @@ -78,11 +83,14 @@ export const Earn = () => { const { t } = useTranslation(); const { hasSeenIntro, dismissIntro } = useEarnIntroSeen(); const submission = useSelector(transactionSubmissionSelector); - const { pool, selectedAssetId } = useSelector(earnSelector); + const { pool, selectedAssetId, source } = useSelector(earnSelector); // Read once at mount: the view rewrites its own search when the swap branch // opens and closes, so the flag is gone by the time any later render looks. const isPrefilled = useRef(isEarnPrefillSearch(location.search)).current; + // Same reasoning as isPrefilled above, and it also self-corrects when the + // user leaves by browser-back without closeEarnFlow running its resetEarn(). + const earnSource = useRef(getEarnSourceFromSearch(location.search)).current; // Start on CHOOSE_TOKEN and only fall back to the interstitial once the // persisted flag has actually resolved to false. Defaulting to INTRO instead @@ -122,6 +130,10 @@ export const Earn = () => { navigateTo(ROUTES.account, navigate); }; + useEffect(() => { + dispatch(saveEarnSource(earnSource)); + }, [dispatch, earnSource]); + useEffect(() => { // A prefilled entry arrives with transactionData already populated by the // caller; resetting would discard the asset and destination it just set. @@ -173,6 +185,7 @@ export const Earn = () => { assetCode: getAssetFromCanonical(submission.transactionData.asset).code, poolId: pool?.id || "", reasonCode: getFailureReasonCode(submission.error), + source, }); } dispatch(setEarnSubmitFailed(true)); From f5792f5db440dbe3ec169bb42fde1685d85fbf03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 26 Aug 2026 05:41:52 -0700 Subject: [PATCH 21/49] test(earn): cover the Positions tab end to end --- extension/e2e-tests/earnPositions.test.ts | 149 ++++++++++++++++++++++ extension/e2e-tests/helpers/stubs.ts | 132 +++++++++++++++++++ 2 files changed, 281 insertions(+) create mode 100644 extension/e2e-tests/earnPositions.test.ts diff --git a/extension/e2e-tests/earnPositions.test.ts b/extension/e2e-tests/earnPositions.test.ts new file mode 100644 index 0000000000..6653ab99c4 --- /dev/null +++ b/extension/e2e-tests/earnPositions.test.ts @@ -0,0 +1,149 @@ +/** + * E2E spec: Home's Positions tab + * + * Covers: + * 1. Empty state: the projection card, and Start Earning -> Earn, carrying + * `?source=positions_empty` — the analytics attribution a previous task + * added for this entry point. It has no other coverage anywhere, and a + * dropped or wrong `source` fails silently (falls back to "home"), so the + * resulting URL is the cheapest guard against that regression. + * 2. A held position: the row opens its pool sheet already on "Your + * position", showing the real supplied balance. + * 3. Deposit from a position: lands on the amount screen prefilled with the + * tapped token and its pool. + * + * Stub URL shapes, all on the CONTEXT (see stubBlendEarn's own comment in + * stubs.ts for the full rationale — these are backend-v2 endpoints the + * background service worker fetches, not the popup): + * - positions: "**\/accounts/positions**" — registered by stubBlendEarn + * with real figures below. A baseline, position-less version of this same + * route is registered for every suite by stubAllExternalApis (via + * stubAccountPositions), since Home now requests positions on every load; + * stubBlendEarn registers the route again, later, so it wins the match + * wherever a test opts in through loginToTestAccount's stubOverrides. + * - earn options: "**\/protocols/blend/earn-options**" — prices the empty + * state's "you could earn up to" projection. + * - pool catalog: "**\/protocols/blend/pools**" — backs the pool-details + * sheet a position row opens. + * + * Asset ids in positionsFixture are the REAL mainnet SACs, same discipline + * stubBlendEarn documents: getCatalogAssetIdentity resolves a row's displayed + * code by deriving its SAC, so a placeholder id would leave it blank. + * + * No switchToMainnet here: Testnet (where login lands) has its own + * BLEND_FIXED_POOL_IDS entry, so the Positions tab and its sheet are reachable + * straight after login — see earnDeposit.test.ts's network-gating test for the + * same reasoning, and @shared/constants/blend for the map itself. + * + * testid index: + * - account-tab-positions AccountTabs/index.tsx + * - account-positions-empty AccountPositions/EmptyState.tsx + * - account-positions-projection AccountPositions/EmptyState.tsx + * - account-positions-start-earning AccountPositions/EmptyState.tsx + * - position-row- AccountPositions/PositionRow.tsx + * - earn-pool-details-sheet PoolDetailsSheet/index.tsx + * - earn-pool-details-tabs PoolDetailsSheet/Tabs.tsx + * - earn-position-balance PoolDetailsSheet/YourPosition.tsx + * - earn-pool-card EarnAmount/PoolCard.tsx + * - send-amount-edit-dest-asset amount/AmountCard/index.tsx (shared with + * Send/Swap — earn-token-code does not exist on the amount screen; this is + * the established id those other specs already use to assert the selected + * token) + * - earn views/Earn/index.tsx (root) + * + * Execution: `yarn test:e2e earnPositions.test.ts` from repo root. + */ + +import { test, expect } from "./test-fixtures"; +import { loginToTestAccount } from "./helpers/login"; +import { stubBlendEarn, positionsFixture } from "./helpers/stubs"; + +// --------------------------------------------------------------------------- +// 1. Empty state -> Start Earning -> Earn, carrying its analytics source +// --------------------------------------------------------------------------- +test("Positions tab shows the empty state and enters Earn", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + + await loginToTestAccount({ + page, + extensionId, + context, + stubOverrides: () => stubBlendEarn(context), + }); + + await page.getByTestId("account-tab-positions").click(); + await expect(page.getByTestId("account-positions-empty")).toBeVisible(); + await expect(page.getByTestId("account-positions-projection")).toBeVisible(); + + await page.getByTestId("account-positions-start-earning").click(); + await expect(page.getByTestId("earn")).toBeVisible(); + + // The one thing this test exists to pin down: the empty state's CTA must + // carry EARN_SOURCE.POSITIONS_EMPTY, not silently drop to the "home" + // fallback getEarnSourceFromSearch uses for an absent or unrecognised value. + await expect(page).toHaveURL(/source=positions_empty/); +}); + +// --------------------------------------------------------------------------- +// 2. A held position opens its sheet on "Your position" +// --------------------------------------------------------------------------- +test("a position row opens the pool sheet on Your position", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + + await loginToTestAccount({ + page, + extensionId, + context, + stubOverrides: () => + stubBlendEarn(context, { positions: positionsFixture() }), + }); + + await page.getByTestId("account-tab-positions").click(); + await page.getByTestId("position-row-USDC").click(); + + await expect(page.getByTestId("earn-pool-details-tabs")).toBeVisible(); + await expect(page.getByTestId("earn-position-balance")).toHaveText("$500.12"); +}); + +// --------------------------------------------------------------------------- +// 3. Deposit from a position prefills the amount screen +// --------------------------------------------------------------------------- +test("Deposit from a position lands on the amount screen prefilled", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + + await loginToTestAccount({ + page, + extensionId, + context, + stubOverrides: () => + stubBlendEarn(context, { positions: positionsFixture() }), + }); + + await page.getByTestId("account-tab-positions").click(); + await page.getByTestId("position-row-USDC").click(); + + // Scoped to the sheet and to the button role: "Your deposits" is also + // on screen (the sheet opens on "Your position" by default), and a plain + // getByText("Deposit") case-insensitively substring-matches that div too. + await page + .getByTestId("earn-pool-details-sheet") + .getByRole("button", { name: "Deposit" }) + .click(); + + await expect(page.getByTestId("earn-pool-card")).toBeVisible(); + await expect(page.getByTestId("send-amount-edit-dest-asset")).toContainText( + "USDC", + ); +}); diff --git a/extension/e2e-tests/helpers/stubs.ts b/extension/e2e-tests/helpers/stubs.ts index 9f653f1069..b37436d8ec 100644 --- a/extension/e2e-tests/helpers/stubs.ts +++ b/extension/e2e-tests/helpers/stubs.ts @@ -3303,6 +3303,9 @@ export const stubAllExternalApis = async ( // Collectibles await stubCollectibles(page, context); + // Blend positions — Home requests this on every load (see stubAccountPositions) + await stubAccountPositions(context); + // Discover protocols await stubDiscoverProtocols(page); @@ -3571,6 +3574,53 @@ export const stubVerifiedToken = async ( }); }; +/** + * The deterministic test account's public key — derived from the mnemonic + * `loginToTestAccount` imports (login.ts's `TEST_ACCOUNT_WORDS`). Coincidentally + * the same string as `SAC_ISSUER` above; that constant exists for an unrelated + * reason (a stand-in SAC issuer for the AddToken tests), not because it names + * this account, so it is kept as its own symbol here rather than reused. + */ +export const TEST_ACCOUNT_PUBLIC_KEY = + "GDF32CQINROD3E2LMCGZUDVMWTXCJFR5SBYVRJ7WAAIAS3P7DCVWZEFY"; + +/** + * Baseline positions response: a known account holding nothing. + * + * Home now requests positions on every load (`getBlendPositions`, called from + * `useGetAccountData`), so every suite that reaches Home needs this route + * stubbed or the request falls through to a real call against INDEXER_V2_URL. + * Registered from `stubAllExternalApis` for exactly that reason. + * + * Suites that need real positions call `stubBlendEarn` below, which registers + * this same `**\/accounts/positions**` pattern again, later — in Playwright a + * later-registered route wins the match, the same layering `stubEarnSimulateTx` + * relies on just below to override `stubBackendSimulateTx`'s `**\/simulate-tx**` + * route. `loginToTestAccount` runs `stubOverrides` after `stubAllExternalApis`, + * so `stubBlendEarn`'s positions always take over wherever a test opts in. + * + * `/accounts/positions` is a freighter-backend-v2 endpoint the background + * service worker fetches (see `stubBlendEarn`'s own comment below), so this + * must be registered on the BrowserContext — `page.route` would never see it. + */ +export const stubAccountPositions = async (context: BrowserContext) => { + await context.route("**/accounts/positions**", async (route) => { + await route.fulfill({ + json: { + data: [ + { + address: TEST_ACCOUNT_PUBLIC_KEY, + total_value_usd: null, + net_apy: null, + positions: [], + backstop: [], + }, + ], + }, + }); + }); +}; + /** * A prepared Soroban envelope for `submit` on the mainnet Fixed pool, sourced by * the e2e test account. Generated with the SDK rather than hand-trimmed, so it @@ -3784,3 +3834,85 @@ export const stubBlendEarn = async ( await route.fulfill({ json: { data: positions } }); }); }; + +/** + * Wire-shaped `ApiAccountPositions[]`, for `stubBlendEarn`'s `positions` + * argument — one account entry holding a supplied position in the mainnet + * Fixed pool. + * + * Defaults to a single USDC row; pass `assets: ["USDC", "XLM"]` to add a + * second. Both assets share the same figures — nothing today's suite checks + * needs them to differ, and the specific numbers (`total_tokens: "5001223000"`, + * `usd_value: 500.12`, `apy: 0.1694`, `interest_earned: "1234000"`, + * `interest_earned_usd: 0.12`) are what let `earn-position-balance` assert a + * real "$500.12" instead of "--". + * + * Asset ids are the real mainnet SACs, the same discipline `stubBlendEarn` + * itself documents above: `getCatalogAssetIdentity` (and, on the deposit path, + * `getBalanceByKey`) resolve an asset by deriving its SAC, so a placeholder id + * would leave the row's code blank instead of "USDC"/"XLM". USDC's `name` + * matches `stubBlendEarn`'s own pools-catalog reserve so both endpoints agree + * on the same issuer; native XLM carries a null symbol AND a null name, again + * matching the live catalog and `stubBlendEarn`'s earn-options entry for it. + * + * `collateral_tokens` (not `supplied_tokens`) carries the balance: deposits use + * SupplyCollateral, per the comment on `getBlendSuppliedTokens` above. + */ +export const positionsFixture = ({ + address = TEST_ACCOUNT_PUBLIC_KEY, + assets = ["USDC"], +}: { + address?: string; + assets?: ("USDC" | "XLM")[]; +} = {}): unknown[] => { + const { XLM: XLM_SAC, USDC: USDC_SAC } = PUBLIC_SACS; + const POOL_ID = BLEND_FIXED_POOL_IDS[NETWORKS.PUBLIC]!; + + const supplyRow = (assetCode: "USDC" | "XLM") => ({ + asset_id: assetCode === "USDC" ? USDC_SAC : XLM_SAC, + symbol: assetCode === "USDC" ? "USDC" : null, + name: + assetCode === "USDC" + ? "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN" + : null, + decimals: 7, + supplied_tokens: "0", + collateral_tokens: "5001223000", + total_tokens: "5001223000", + usd_value: 500.12, + apy: 0.1694, + emissions_apr: null, + interest_earned: "1234000", + interest_earned_usd: 0.12, + claimable_blnd: "0", + claimable_usd: 0, + price_usd: assetCode === "USDC" ? 1 : 0.15, + }); + + const supply = assets.map(supplyRow); + const totalUsd = supply.reduce((sum, row) => sum + row.usd_value, 0); + + return [ + { + address, + total_value_usd: totalUsd, + net_apy: 0.1694, + positions: [ + { + protocol: "blend", + id: POOL_ID, + name: "Fixed", + net_usd: totalUsd, + supplied_usd: totalUsd, + borrowed_usd: 0, + net_apy: 0.1694, + blend: { + supply, + borrow: [], + }, + }, + ], + backstop: [], + }, + ]; +}; From 0f7885660432fbe7ab34359404c4125e46ef8a29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 26 Aug 2026 06:40:33 -0700 Subject: [PATCH 22/49] fix(earn): surface fetch failures on the Positions tab and unblock the Blockaid rescan Whole-branch review findings C1, I3, I4, I7 -- all seams between per-task work that no single task's review could see: - C1: the view computed AccountPositions' hasError from resolvedData.hasPositionsError alone, but a failed account fetch discards resolvedData entirely, so a fetch failure rendered as "No positions yet" instead of the error state. - I3: getBlendPools and getBlendEarnOptions were awaited ahead of the mainnet scanned-balances refetch, delaying Blockaid's isSuspicious/isMalicious labels on every earn-supported Home load. Both now start unawaited alongside positionsRequest (or as soon as their own gating condition is known) and land via the same mutate-then-spread-dispatch pattern collectibles and positions use, so neither sits in front of the rescan. - I4: the 30s positions refresh kept the last good positions but also flagged hasPositionsError, so a single flaky tick flashed rows to an error banner and back. Now only surfaced when there is no prior data left to show. - I7: positionsRequest was unawaited across several `await`s before its own try/catch, so a rejection in that window fired Sentry's unhandledrejection handler on top of the deliberate captureException. Co-Authored-By: Claude Opus 5 (1M context) --- .../views/Account/hooks/useGetAccountData.tsx | 67 ++++++++++++++----- extension/src/popup/views/Account/index.tsx | 8 ++- .../popup/views/__tests__/Account.test.tsx | 62 +++++++++++++++++ 3 files changed, 118 insertions(+), 19 deletions(-) diff --git a/extension/src/popup/views/Account/hooks/useGetAccountData.tsx b/extension/src/popup/views/Account/hooks/useGetAccountData.tsx index b88656347b..d49ea8f597 100644 --- a/extension/src/popup/views/Account/hooks/useGetAccountData.tsx +++ b/extension/src/popup/views/Account/hooks/useGetAccountData.tsx @@ -155,6 +155,22 @@ function useGetAccountData(options: { networkDetails, useCache: !shouldForceBalancesRefresh, }); + // Marks the promise handled without changing what `await positionsRequest` + // throws below: it is first awaited several `await`s later (inside its own + // try/catch), and a rejection landing in that gap would otherwise fire + // Sentry's `unhandledrejection` handler on top of the deliberate + // `captureException` call there. + positionsRequest.catch(() => {}); + + // Same treatment as positions: started before the balances await so its + // round trip overlaps balances/collectibles/positions instead of adding + // to the critical path in front of the Blockaid rescan below (I3). Only + // fetched where Earn exists at all; landed once positions has resolved, + // alongside the pools dispatch below. + const poolsRequest = isEarnSupportedNetwork(networkDetails) + ? getBlendPools({ networkDetails }) + : null; + poolsRequest?.catch(() => {}); // let's fetch *just* the balances (without Blockaid scan results) to quickly be able to show the user their balances const balancesResult = await fetchBalances( @@ -224,16 +240,24 @@ function useGetAccountData(options: { // collectibles dispatch above: the reducer holds this very object. dispatch({ type: "FETCH_DATA_SUCCESS", payload: { ...payload } }); + // Landed here (not yet awaited when it was started, above) and, for + // earnOptions, only ever STARTED once we know it applies -- neither sits + // in front of the Blockaid rescan below (I3). + let earnOptionsRequest: Promise | null = null; if (isEarnSupportedNetwork(networkDetails)) { // Unlike earnOptions below, fetched regardless of whether the account // has positions -- it backs the pool-details sheet a Positions row // opens, so it is needed precisely when positions exist, not only - // when they don't. - try { - payload.pools = await getBlendPools({ networkDetails }); - dispatch({ type: "FETCH_DATA_SUCCESS", payload: { ...payload } }); - } catch (error) { - captureException(`Error fetching pools on Account - ${error}`); + // when they don't. By the time this await runs, poolsRequest has had + // the whole balances/collectibles/positions round trip to resolve, so + // this rarely waits at all. + if (poolsRequest) { + try { + payload.pools = await poolsRequest; + dispatch({ type: "FETCH_DATA_SUCCESS", payload: { ...payload } }); + } catch (error) { + captureException(`Error fetching pools on Account - ${error}`); + } } // Only the empty state needs the catalog, and only to price its @@ -242,17 +266,8 @@ function useGetAccountData(options: { !payload.hasPositionsError && !payload.positions?.positions.length ) { - try { - payload.earnOptions = await getBlendEarnOptions({ - networkDetails, - }); - dispatch({ type: "FETCH_DATA_SUCCESS", payload: { ...payload } }); - } catch (error) { - // The card degrades to hidden; nothing else depends on this. - captureException( - `Error fetching earn options on Account - ${error}`, - ); - } + earnOptionsRequest = getBlendEarnOptions({ networkDetails }); + earnOptionsRequest.catch(() => {}); } } @@ -278,6 +293,16 @@ function useGetAccountData(options: { } } + if (earnOptionsRequest) { + try { + payload.earnOptions = await earnOptionsRequest; + dispatch({ type: "FETCH_DATA_SUCCESS", payload: { ...payload } }); + } catch (error) { + // The card degrades to hidden; nothing else depends on this. + captureException(`Error fetching earn options on Account - ${error}`); + } + } + const backendSettings = await loadBackendSettings(); reduxDispatch(saveBackendSettingsAction(backendSettings)); return payload; @@ -385,7 +410,13 @@ function useGetAccountData(options: { balances: balancesResult, isScanAppended: true, positions: refreshedPositions, - hasPositionsError: refreshedPositionsError, + // Only surfaced when there is no prior data left to keep showing -- + // otherwise a single flaky tick would flip the Positions tab from + // rows to an error banner and back every 30s even though the last + // good read is still sitting right here in `refreshedPositions`. + // Initial-load failures are untouched: `fetchData` above sets this + // unconditionally, since there is nothing prior to fall back to. + hasPositionsError: refreshedPositionsError && !resolvedData.positions, } as AccountData; dispatch({ type: "FETCH_DATA_SUCCESS", payload }); } catch (error) { diff --git a/extension/src/popup/views/Account/index.tsx b/extension/src/popup/views/Account/index.tsx index 3ecfc26f08..7ccd7a0111 100644 --- a/extension/src/popup/views/Account/index.tsx +++ b/extension/src/popup/views/Account/index.tsx @@ -431,7 +431,13 @@ export const Account = () => { { // real browser instead; a unit assertion could not fail. }); + // AccountPositions' own file comment states the invariant: "a position is + // money, so 'we could not load this' must never render as 'you have + // none'." A failed account fetch discards `resolvedData` entirely (see + // helpers/request.ts), so `resolvedData?.hasPositionsError` alone can never + // see the failure -- the view-level `hasError` has to reach + // AccountPositions too, or the Positions tab falls through to its empty + // state instead (C1). + it("shows the positions error state rather than 'no positions' when the account fetch fails", async () => { + const accountDataSpy = jest + .spyOn(AccountDataHooks, "useGetAccountData") + .mockReturnValue({ + state: { + state: RequestState.ERROR, + data: null, + error: new Error("boom"), + }, + fetchData: jest.fn(), + refreshAppData: jest.fn(), + }); + + render( + + {/* Router supplies this in the app; without it the Positions tab + button has no working setActiveTab to switch onto. */} + + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId("account-view")).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByTestId("account-tab-positions")); + + await waitFor(() => { + expect(screen.getByTestId("account-positions-error")).toBeInTheDocument(); + }); + + // Restored before asserting, as the ERROR-state test above does. + accountDataSpy.mockRestore(); + + expect( + screen.queryByTestId("account-positions-empty"), + ).not.toBeInTheDocument(); + }); + // The Tokens tab dictates which button style the Collectibles tab uses, and // that decision lives in this view rather than in the components it feeds, so // it has to be pinned here: the component suites take it as a literal prop and From aded7124e1c18c008c15b123985ad2f46efcd6da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 26 Aug 2026 06:41:18 -0700 Subject: [PATCH 23/49] fix(earn): stop the pool sheet showing a negative or another asset's position Whole-branch review findings C2, I1, I2: - C2: toDepositRow subtracted lifetime interestEarned from the current totalTokens to derive principal. A fully-exited position (total_tokens back to 0 with interest history intact) or any over-withdrawal drives that subtraction negative -- three figures on "Your position" that no longer reconcile. When the derivation goes out of range the row's principal reading no longer applies, so it now reports tokens: "0" and usd: null instead of a negative amount. - I2: the focused-asset fallback (`supply.find(...) || supply[0]`) couldn't tell "no asset was named" from "named an asset the position doesn't hold" -- and EarnAmount always names one now, so every mismatch fell back to a different asset's figures under this asset's headers. A genuine mismatch now resolves to an empty scope, and PoolDetailsSheet computes hasPosition from the same resolution rule (hasResolvableSupply) so it never shows a tab with nothing in it. - I1: the row formatted its USD figures with formatAmount/roundUsdValue while the sheet used formatCompactUsd, which goes compact at $1,000 -- a $1,500 position read differently in each place. Both now go through one new formatAccountUsd, added next to formatCompactUsd with the scope each is for spelled out in its docstring. Co-Authored-By: Claude Opus 5 (1M context) --- .../account/AccountPositions/PositionRow.tsx | 14 ++- .../__tests__/AccountPositions.sheet.test.tsx | 20 ++++ .../earn/PoolDetailsSheet/YourPosition.tsx | 6 +- .../__tests__/PoolDetailsSheet.tabs.test.tsx | 15 +++ .../earn/PoolDetailsSheet/index.tsx | 19 ++-- .../helpers/__tests__/formatPoolStats.test.ts | 27 ++++- .../helpers/__tests__/positionSummary.test.ts | 98 ++++++++++++++++++- .../earn/helpers/formatPoolStats.ts | 16 ++- .../earn/helpers/positionSummary.ts | 87 +++++++++++++--- 9 files changed, 266 insertions(+), 36 deletions(-) diff --git a/extension/src/popup/components/account/AccountPositions/PositionRow.tsx b/extension/src/popup/components/account/AccountPositions/PositionRow.tsx index 8de6e6dad7..9926929887 100644 --- a/extension/src/popup/components/account/AccountPositions/PositionRow.tsx +++ b/extension/src/popup/components/account/AccountPositions/PositionRow.tsx @@ -4,8 +4,10 @@ import { useTranslation } from "react-i18next"; import { AssetIcons } from "@shared/api/types"; import { BalanceRow } from "popup/components/BalanceRow"; import { PositionTokenRow } from "popup/components/earn/helpers/positionRows"; -import { formatRate } from "popup/components/earn/helpers/formatPoolStats"; -import { formatAmount, roundUsdValue } from "popup/helpers/formatters"; +import { + formatAccountUsd, + formatRate, +} from "popup/components/earn/helpers/formatPoolStats"; interface PositionRowProps { row: PositionTokenRow; @@ -55,17 +57,13 @@ export const PositionRow = ({ row, assetIcons, onClick }: PositionRowProps) => { className="PositionRow__value" data-testid={`position-value-${row.code}`} > - {row.suppliedUsd === null - ? "--" - : `$${formatAmount(roundUsdValue(String(row.suppliedUsd)))}`} + {formatAccountUsd(row.suppliedUsd)}
- {isGainKnown - ? `+$${formatAmount(roundUsdValue(String(row.interestEarnedUsd)))}` - : "--"} + {isGainKnown ? `+${formatAccountUsd(row.interestEarnedUsd)}` : "--"}
} diff --git a/extension/src/popup/components/account/AccountPositions/__tests__/AccountPositions.sheet.test.tsx b/extension/src/popup/components/account/AccountPositions/__tests__/AccountPositions.sheet.test.tsx index c60dfc013f..b424eea33a 100644 --- a/extension/src/popup/components/account/AccountPositions/__tests__/AccountPositions.sheet.test.tsx +++ b/extension/src/popup/components/account/AccountPositions/__tests__/AccountPositions.sheet.test.tsx @@ -180,6 +180,26 @@ describe("AccountPositions pool sheet", () => { }); }); + it("agrees with the row on a personal-scale USD figure, shown in full rather than compact (I1)", async () => { + // formatCompactUsd goes compact at $1,000 ("$1.50K"), dropping cents from + // a personal balance -- see formatAccountUsd's docstring. The row and the + // sheet must read the exact same figure for the exact same position. + renderTab({ + positions: withSupply([supply({ usdValue: 1500 })]), + pools: [pool], + }); + + expect(screen.getByTestId("position-value-USDC")).toHaveTextContent( + "$1,500.00", + ); + + fireEvent.click(screen.getByTestId("position-row-USDC")); + + expect( + await screen.findByTestId("earn-position-balance"), + ).toHaveTextContent("$1,500.00"); + }); + it("attributes a tab switch on the sheet to the position row that opened it", async () => { renderTab({ positions: twoAssetPositions, pools: [pool] }); diff --git a/extension/src/popup/components/earn/PoolDetailsSheet/YourPosition.tsx b/extension/src/popup/components/earn/PoolDetailsSheet/YourPosition.tsx index d661c5307c..8bde4ee398 100644 --- a/extension/src/popup/components/earn/PoolDetailsSheet/YourPosition.tsx +++ b/extension/src/popup/components/earn/PoolDetailsSheet/YourPosition.tsx @@ -9,7 +9,7 @@ import { AssetIcon } from "popup/components/account/AccountAssets"; import { settingsNetworkDetailsSelector } from "popup/ducks/settings"; import { StatRow } from "popup/components/earn/StatRow"; import { - formatCompactUsd, + formatAccountUsd, formatRate, } from "popup/components/earn/helpers/formatPoolStats"; import { formatProjection } from "popup/components/earn/EarnReview/helpers/projectEarnings"; @@ -51,7 +51,7 @@ const AssetLine = ({ > {row.usd === null ? "--" - : `${isGain ? "+" : ""}${formatCompactUsd(row.usd)}`} + : `${isGain ? "+" : ""}${formatAccountUsd(row.usd)}`}
{`${formatAmount(row.tokens)} ${row.code}`} @@ -95,7 +95,7 @@ export const YourPosition = ({ className="PoolDetailsSheet__position__balance" data-testid="earn-position-balance" > - {formatCompactUsd(summary.currentBalanceUsd)} + {formatAccountUsd(summary.currentBalanceUsd)}
diff --git a/extension/src/popup/components/earn/PoolDetailsSheet/__tests__/PoolDetailsSheet.tabs.test.tsx b/extension/src/popup/components/earn/PoolDetailsSheet/__tests__/PoolDetailsSheet.tabs.test.tsx index cc57786423..11db24fae1 100644 --- a/extension/src/popup/components/earn/PoolDetailsSheet/__tests__/PoolDetailsSheet.tabs.test.tsx +++ b/extension/src/popup/components/earn/PoolDetailsSheet/__tests__/PoolDetailsSheet.tabs.test.tsx @@ -79,6 +79,21 @@ describe("PoolDetailsSheet", () => { expect(screen.getByText("Overview")).toBeInTheDocument(); }); + it("renders untabbed when focusedAssetId names an asset the position doesn't hold (I2)", () => { + // EarnAmount always names the asset it is depositing. A mismatch means the + // account has no position in THAT asset yet -- the same "no position" + // treatment as the no-position case above, not a tab with an empty panel. + const XLM_SAC = "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA"; + renderSheet({ position, focusedAssetId: XLM_SAC, onDeposit: jest.fn() }); + + expect( + screen.queryByTestId("earn-pool-details-tabs"), + ).not.toBeInTheDocument(); + // Falls through to the Overview body, same as the no-position case. + expect(screen.getByTestId("earn-pool-interest-apy")).toBeInTheDocument(); + expect(screen.queryByTestId("earn-position-panel")).not.toBeInTheDocument(); + }); + it("swaps Close for Deposit only when a deposit handler is supplied", () => { const onDeposit = jest.fn(); renderSheet({ position, onDeposit }); diff --git a/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx b/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx index 458bf00e17..b9a57c1e6f 100644 --- a/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx +++ b/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx @@ -7,6 +7,7 @@ import { BlendCatalogPool, PoolPosition } from "@shared/api/types/blend"; import { PoolIcon } from "popup/components/earn/PoolIcon"; import { BLEND_LENDING_DOCS_URL } from "popup/constants/externalLinks"; import { openTab } from "popup/helpers/navigate"; +import { hasResolvableSupply } from "popup/components/earn/helpers/positionSummary"; import { StatRow } from "popup/components/earn/StatRow"; import { formatCompactUsd, @@ -140,12 +141,14 @@ const OverviewBody = ({ pool }: { pool: BlendCatalogPool }) => { * * Purely additive over the pre-positions sheet: with no `position` prop this * renders exactly as it always has — no tab strip, the Overview body alone, - * and a Close button. Only supplying `position` (a real one, with at least one - * supplied row) turns on the pill tab strip and lets the sheet open on either - * tab. `onDeposit` independently swaps the footer button from Close to - * Deposit — the two are separate knobs because Task 11 opens this sheet - * mid-deposit with a position already on file but no Deposit action of its - * own to offer. + * and a Close button. Only supplying `position` AND a resolvable supply row — + * see `hasResolvableSupply` — turns on the pill tab strip and lets the sheet + * open on either tab; a `focusedAssetId` that names an asset the position + * doesn't hold (I2) is treated the same as no position at all, rather than + * showing a "Your position" tab with nothing in it. `onDeposit` independently + * swaps the footer button from Close to Deposit — the two are separate knobs + * because Task 11 opens this sheet mid-deposit with a position already on + * file but no Deposit action of its own to offer. */ export const PoolDetailsSheet = ({ pool, @@ -159,7 +162,9 @@ export const PoolDetailsSheet = ({ }: PoolDetailsSheetProps) => { const { t } = useTranslation(); - const hasPosition = Boolean(position?.blend?.supply?.length); + const hasPosition = Boolean( + position && hasResolvableSupply({ position, focusedAssetId }), + ); const [activeTab, setActiveTab] = React.useState( defaultTab ?? "your_position", ); diff --git a/extension/src/popup/components/earn/helpers/__tests__/formatPoolStats.test.ts b/extension/src/popup/components/earn/helpers/__tests__/formatPoolStats.test.ts index 8382627032..6c0cae5b0c 100644 --- a/extension/src/popup/components/earn/helpers/__tests__/formatPoolStats.test.ts +++ b/extension/src/popup/components/earn/helpers/__tests__/formatPoolStats.test.ts @@ -1,4 +1,29 @@ -import { formatCompactUsd, formatRate } from "../formatPoolStats"; +import { + formatAccountUsd, + formatCompactUsd, + formatRate, +} from "../formatPoolStats"; + +describe("formatAccountUsd", () => { + it("renders a four-figure balance in full, never compact (I1)", () => { + // formatCompactUsd goes compact at $1,000, dropping cents from a personal + // balance ("$1.50K" for $1,500) -- account-scale money always shows in full. + expect(formatAccountUsd(1500)).toBe("$1,500.00"); + }); + + it("truncates rather than rounds, matching the Positions row it was factored out of", () => { + expect(formatAccountUsd(500.125)).toBe("$500.12"); + }); + + it("distinguishes an unavailable value from zero", () => { + expect(formatAccountUsd(null)).toBe("--"); + expect(formatAccountUsd(0)).toBe("$0.00"); + }); + + it("groups thousands", () => { + expect(formatAccountUsd(1234567)).toBe("$1,234,567.00"); + }); +}); describe("formatCompactUsd", () => { it("renders millions compactly", () => { diff --git a/extension/src/popup/components/earn/helpers/__tests__/positionSummary.test.ts b/extension/src/popup/components/earn/helpers/__tests__/positionSummary.test.ts index 24db3eca3e..6a947624a1 100644 --- a/extension/src/popup/components/earn/helpers/__tests__/positionSummary.test.ts +++ b/extension/src/popup/components/earn/helpers/__tests__/positionSummary.test.ts @@ -1,6 +1,6 @@ import { MAINNET_NETWORK_DETAILS } from "@shared/constants/stellar"; import { PUBLIC_SACS } from "@shared/constants/sac"; -import { getPositionSummary } from "../positionSummary"; +import { getPositionSummary, hasResolvableSupply } from "../positionSummary"; const networkDetails = MAINNET_NETWORK_DETAILS; const USDC_SAC = PUBLIC_SACS.USDC!; @@ -134,6 +134,72 @@ describe("getPositionSummary — supply scope", () => { expect(result.deposits[0].code).toBe("USDC"); }); + + it("resolves to an empty scope when the focused asset matches nothing, instead of the first row (I2)", () => { + // EarnAmount always names the asset it is depositing, so a mismatch means + // the account has no position in THAT asset yet -- not "no asset was + // named". Falling back to supply[0] would show a different asset's + // figures under this asset's headers. + const result = summary("UNRELATED_ASSET_ID_NOT_IN_SUPPLY"); + + expect(result.currentBalanceUsd).toBeNull(); + expect(result.apy).toBeNull(); + expect(result.deposits).toEqual([]); + expect(result.earnings).toEqual([]); + expect(result.estMonthlyUsd).toBeNull(); + expect(result.estYearlyUsd).toBeNull(); + }); + + describe("principal out of range (C2)", () => { + it("reports no principal reading for a fully-exited row, rather than a negative figure", () => { + // total_tokens is the CURRENT balance; a fully-exited row still carries + // its lifetime interest_earned, so total_tokens - interest_earned goes + // negative even though nothing is wrong with the payload. + const result = summary(USDC_SAC, [ + { ...usdcSupply, totalTokens: "0", collateralTokens: "0", usdValue: 0 }, + ]); + + expect(result.deposits[0].tokens).toBe("0"); + expect(result.deposits[0].usd).toBeNull(); + // Untouched by the guard: earnings still reports the real lifetime + // interest, and the header balance still reads the real (zero) total. + expect(result.earnings[0].usd).toBeCloseTo(0.12); + expect(result.currentBalanceUsd).toBe(0); + }); + + it("reports no principal reading for an over-withdrawn row, rather than a negative figure", () => { + // The review's concrete failure: supply 1,000 USDC, accrue 50, withdraw + // 1,020 -> total_tokens = 30, interest_earned = 50. + const result = summary(USDC_SAC, [ + { + ...usdcSupply, + totalTokens: "300000000", + interestEarned: "500000000", + usdValue: 30, + interestEarnedUsd: 50, + }, + ]); + + expect(result.deposits[0].tokens).toBe("0"); + expect(result.deposits[0].usd).toBeNull(); + expect(result.earnings[0].usd).toBe(50); + expect(result.currentBalanceUsd).toBe(30); + }); + + it("treats an exact match as a real zero principal, not an out-of-range guard", () => { + // Off-by-one guard: interestEarned === totalTokens is a genuinely (not + // negatively) exhausted principal -- the comparison is strictly + // greater-than, so this must NOT trip the same guard as an actual + // over-withdrawal. usdValue/interestEarnedUsd are untouched here, so a + // real (non-null) usd figure proves the guard did not fire. + const result = summary(USDC_SAC, [ + { ...usdcSupply, totalTokens: "1234000", interestEarned: "1234000" }, + ]); + + expect(result.deposits[0].tokens).toBe("0"); + expect(result.deposits[0].usd).not.toBeNull(); + }); + }); }); describe("getPositionSummary — pool scope", () => { @@ -152,3 +218,33 @@ describe("getPositionSummary — pool scope", () => { expect(result.estYearlyUsd).toBe("99.20"); }); }); + +describe("hasResolvableSupply", () => { + it("is true when the focused asset matches a supplied row", () => { + expect( + hasResolvableSupply({ + position: position([usdcSupply]), + focusedAssetId: USDC_SAC, + }), + ).toBe(true); + }); + + it("is true when no asset was named, falling back to the first row", () => { + expect(hasResolvableSupply({ position: position([usdcSupply]) })).toBe( + true, + ); + }); + + it("is false when the focused asset matches nothing (I2)", () => { + expect( + hasResolvableSupply({ + position: position([usdcSupply]), + focusedAssetId: XLM_SAC, + }), + ).toBe(false); + }); + + it("is false when the position has no supply rows at all", () => { + expect(hasResolvableSupply({ position: position([]) })).toBe(false); + }); +}); diff --git a/extension/src/popup/components/earn/helpers/formatPoolStats.ts b/extension/src/popup/components/earn/helpers/formatPoolStats.ts index 9d605b965a..7dfc77d1cb 100644 --- a/extension/src/popup/components/earn/helpers/formatPoolStats.ts +++ b/extension/src/popup/components/earn/helpers/formatPoolStats.ts @@ -1,6 +1,20 @@ import BigNumber from "bignumber.js"; -import { NO_FIAT_VALUE } from "popup/helpers/formatters"; +import { NO_FIAT_VALUE, formatAmount, roundUsdValue } from "popup/helpers/formatters"; + +/** + * Account-scale USD — "$1,500.00", never compact. `formatCompactUsd` below is + * for pool-scale figures and goes compact at $1,000, which drops the cents + * from a personal balance ("$1.50K" for a $1,500 position). This is the + * single rounding rule for account-scale money: truncates via `roundUsdValue` + * rather than the half-up rounding `toFormat` uses, so a row and a sheet + * showing the same balance can never disagree (I1). + * + * Returns NO_FIAT_VALUE for null — no fresh price for this account's token, not a + * real zero. + */ +export const formatAccountUsd = (value: number | null): string => + value === null ? NO_FIAT_VALUE : `$${formatAmount(roundUsdValue(String(value)))}`; /** * Compact USD for pool-scale figures — "$50.05M" rather than "$50,050,000". diff --git a/extension/src/popup/components/earn/helpers/positionSummary.ts b/extension/src/popup/components/earn/helpers/positionSummary.ts index 53155a90fe..0eb27ad8d4 100644 --- a/extension/src/popup/components/earn/helpers/positionSummary.ts +++ b/extension/src/popup/components/earn/helpers/positionSummary.ts @@ -71,18 +71,33 @@ const toDepositRow = ( networkDetails: NetworkDetails, ): PositionAssetRow => { const { code, issuer, decimals } = identity(row, networkDetails); - const principalRaw = new BigNumber(row.totalTokens) - .minus(row.interestEarned) - .toFixed(0); + const totalTokens = new BigNumber(row.totalTokens); + const interestEarned = new BigNumber(row.interestEarned); + + const usdOutOfRange = + row.usdValue !== null && + row.interestEarnedUsd !== null && + row.interestEarnedUsd > row.usdValue; + + // `interestEarned` is lifetime; `totalTokens` is the current balance (see + // the comment above). A fully-exited position, or any over-withdrawal, + // makes this subtraction negative -- a real payload state, not a bug: the + // principal assumption this row depends on simply does not hold for it, so + // report "no principal reading" (0 tokens, null usd) rather than a + // negative balance no other figure on the card would agree with. + const isOutOfRange = + interestEarned.isGreaterThan(totalTokens) || usdOutOfRange; return { assetId: row.assetId, code, issuer, decimals, - tokens: scale(principalRaw, decimals), + tokens: isOutOfRange + ? "0" + : scale(totalTokens.minus(interestEarned).toFixed(0), decimals), usd: - row.usdValue === null || row.interestEarnedUsd === null + isOutOfRange || row.usdValue === null || row.interestEarnedUsd === null ? null : new BigNumber(row.usdValue).minus(row.interestEarnedUsd).toNumber(), }; @@ -107,6 +122,57 @@ const toEarningsRow = ( const headlineApy = (apy: number | null, emissionsApr: number | null) => apy === null ? null : apy + (emissionsApr ?? 0); +/** + * The rows this summary is scoped to. The ONLY place the focused-asset + * resolution rule lives — `getPositionSummary` and `hasResolvableSupply` + * both call this rather than each encoding their own idea of a match. + * + * `focusedAssetId` genuinely absent (undefined) falls back to the first + * supplied row: the sheet can be opened from a pool card that names no + * asset, and a guess at the position the account most likely means beats an + * empty panel. A `focusedAssetId` that is PRESENT but matches no supplied + * row is a mismatch, not an absence -- `EarnAmount` always names the asset + * it is depositing, so this fires when the account has no position in that + * asset yet. Falling back to `supply[0]` there would show a different + * asset's figures under this asset's headers, so it resolves to an empty + * scope instead. + */ +const resolveRows = ({ + position, + focusedAssetId, + scope, +}: { + position: PoolPosition; + focusedAssetId?: string; + scope: PositionScope; +}): BlendSupplyRow[] => { + const supply = position.blend?.supply || []; + if (scope === "pool") { + return supply; + } + if (focusedAssetId === undefined) { + return [supply[0]].filter(Boolean); + } + const match = supply.find((row) => row.assetId === focusedAssetId); + return match ? [match] : []; +}; + +/** + * Whether `getPositionSummary` would resolve to any row at all, for this same + * `position`/`focusedAssetId`/`scope`. `PoolDetailsSheet` uses this to decide + * whether "Your position" has anything to show, so it never renders a tab + * whose panel would be empty (see `resolveRows`). + */ +export const hasResolvableSupply = ({ + position, + focusedAssetId, + scope = POSITION_SUMMARY_SCOPE, +}: { + position: PoolPosition; + focusedAssetId?: string; + scope?: PositionScope; +}): boolean => resolveRows({ position, focusedAssetId, scope }).length > 0; + /** * The sheet's "Your position" figures, for one supplied asset or for the whole * pool. The ONLY place this derivation lives — the component renders whatever @@ -127,16 +193,7 @@ export const getPositionSummary = ({ networkDetails: NetworkDetails; scope?: PositionScope; }): PositionSummary => { - const supply = position.blend?.supply || []; - const rows = - scope === "pool" - ? supply - : // Falls back to the first supplied row: the sheet can be opened from a - // pool card that names no asset, and an empty panel would be worse than - // the position the account most likely means. - [ - supply.find((row) => row.assetId === focusedAssetId) || supply[0], - ].filter(Boolean); + const rows = resolveRows({ position, focusedAssetId, scope }); const currentBalanceUsd = scope === "pool" ? position.netUsd : (rows[0]?.usdValue ?? null); From 33895fc9a247a848e8ed7260a18c1a9ea5cab15b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 26 Aug 2026 06:43:24 -0700 Subject: [PATCH 24/49] refactor(earn): extract headlineApy to one shared implementation Whole-branch review findings I5, T7-c: - I5: the supply-APY-plus-emissions formula -- the branch's one deliberate exception to null-is-not-zero -- was copy-pasted in positionRows.ts and positionSummary.ts (byte-identical), inlined again in earnProjection.ts, and pre-existed a third time in useGetEarnTokensData.tsx. Four copies means a future change to the null-emissions behaviour silently misses three of them. Extracted to formatPoolStats.ts, next to formatRate, and imported at all four call sites; useGetEarnTokensData.tsx (a colleague's in-flight PR) only had its local definition swapped for the import, nothing else touched. - T7-c: earnProjection.test.ts's "returns null when no pool has a priced rate" test passed an empty pools array, so it only exercised the reduce's initial value, never the pool.supplyApy === null skip branch it claimed to cover. Renamed to describe what it actually tests, and added a genuine null-rate case (mixed with a priced pool, so the skip branch is actually exercised). Co-Authored-By: Claude Opus 5 (1M context) --- .../hooks/useGetEarnTokensData.tsx | 14 +---------- .../helpers/__tests__/earnProjection.test.ts | 24 ++++++++++++++++--- .../components/earn/helpers/earnProjection.ts | 11 +++++---- .../earn/helpers/formatPoolStats.ts | 16 +++++++++++++ .../components/earn/helpers/positionRows.ts | 14 ++--------- .../earn/helpers/positionSummary.ts | 5 ++-- 6 files changed, 48 insertions(+), 36 deletions(-) diff --git a/extension/src/popup/components/earn/EarnTokenPicker/hooks/useGetEarnTokensData.tsx b/extension/src/popup/components/earn/EarnTokenPicker/hooks/useGetEarnTokensData.tsx index bb339fb7f3..4055a9d89d 100644 --- a/extension/src/popup/components/earn/EarnTokenPicker/hooks/useGetEarnTokensData.tsx +++ b/extension/src/popup/components/earn/EarnTokenPicker/hooks/useGetEarnTokensData.tsx @@ -14,6 +14,7 @@ import { getCatalogIssuer, resolveEarnAssetIcons, } from "popup/components/earn/helpers/earnAssetIcons"; +import { headlineApy } from "popup/components/earn/helpers/formatPoolStats"; import { AppDispatch } from "popup/App"; import { settingsSelector } from "popup/ducks/settings"; import { @@ -67,19 +68,6 @@ export interface ResolvedEarnTokens { export type EarnTokens = NeedsReRoute | ResolvedEarnTokens; -/** - * The earn headline is supply interest plus BLND emissions. - * - * A null `supplyApy` means no fresh oracle price, so the whole rate is unknown. - * A null `emissionsSupplyApr` means the stream exists but cannot be priced — - * treated as zero here, which understates rather than blanking an otherwise - * known rate. The screen's "APY may change" footnote covers the gap. - */ -const headlineApy = ( - supplyApy: number | null, - emissionsSupplyApr: number | null, -) => (supplyApy === null ? null : supplyApy + (emissionsSupplyApr ?? 0)); - export function useGetEarnTokensData() { const [state, dispatch] = useReducer( reducer, diff --git a/extension/src/popup/components/earn/helpers/__tests__/earnProjection.test.ts b/extension/src/popup/components/earn/helpers/__tests__/earnProjection.test.ts index e641b9b064..3d097b2336 100644 --- a/extension/src/popup/components/earn/helpers/__tests__/earnProjection.test.ts +++ b/extension/src/popup/components/earn/helpers/__tests__/earnProjection.test.ts @@ -9,7 +9,11 @@ const USDC_SAC = PUBLIC_SACS.USDC!; const XLM_SAC = PUBLIC_SACS.XLM; const USDC_ISSUER = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; -const option = (assetId: string, apys: number[], symbol: string | null) => ({ +const option = ( + assetId: string, + apys: (number | null)[], + symbol: string | null, +) => ({ assetId, symbol, name: symbol ? `${symbol}:${USDC_ISSUER}` : null, @@ -50,10 +54,24 @@ describe("getBestEarnApy", () => { ).toBeCloseTo(0.1694); }); - it("returns null when no pool has a priced rate", () => { - // Null is "unknown", not "zero" — the card must not promise 0%. + it("returns null when the option has no pools at all", () => { + // Empty `pools` exercises the reduce's own initial value, not the + // pool.supplyApy === null skip branch below -- see that test instead. expect(getBestEarnApy([option(USDC_SAC, [], "USDC")])).toBeNull(); }); + + it("skips a pool with no priced rate rather than treating it as zero (T7-c)", () => { + // Null is "unknown", not "zero" — the card must not promise 0% by folding + // an unpriced pool into the comparison. Mixed with a priced pool so the + // skip branch is actually exercised, not just the empty-array case above. + expect( + getBestEarnApy([option(USDC_SAC, [null, 0.1694], "USDC")]), + ).toBeCloseTo(0.1694); + }); + + it("returns null when every pool has an unpriced rate", () => { + expect(getBestEarnApy([option(USDC_SAC, [null, null], "USDC")])).toBeNull(); + }); }); describe("projectAnnualEarnings", () => { diff --git a/extension/src/popup/components/earn/helpers/earnProjection.ts b/extension/src/popup/components/earn/helpers/earnProjection.ts index 2114714455..17a3f77903 100644 --- a/extension/src/popup/components/earn/helpers/earnProjection.ts +++ b/extension/src/popup/components/earn/helpers/earnProjection.ts @@ -7,19 +7,20 @@ import { NetworkDetails } from "@shared/constants/stellar"; import { getBalanceByKey } from "popup/helpers/balance"; import { getCatalogAssetIdentity } from "./earnAssetIcons"; +import { headlineApy } from "./formatPoolStats"; /** * The headline rate for one catalog option: the best pool's supply APY plus - * its emissions. A pool with no fresh price (`supplyApy === null`) is - * skipped rather than folded in as zero. Null when the option has no priced - * pool at all. + * its emissions (`headlineApy` in formatPoolStats.ts owns the null-is-not-zero + * exception). A pool with no fresh price is skipped rather than folded in as + * zero. Null when the option has no priced pool at all. */ const bestApyForOption = (option: BlendEarnAssetOption): number | null => option.pools.reduce((best, pool) => { - if (pool.supplyApy === null) { + const rate = headlineApy(pool.supplyApy, pool.emissionsSupplyApr); + if (rate === null) { return best; } - const rate = pool.supplyApy + (pool.emissionsSupplyApr ?? 0); return best === null || rate > best ? rate : best; }, null); diff --git a/extension/src/popup/components/earn/helpers/formatPoolStats.ts b/extension/src/popup/components/earn/helpers/formatPoolStats.ts index 7dfc77d1cb..82d7ee8f95 100644 --- a/extension/src/popup/components/earn/helpers/formatPoolStats.ts +++ b/extension/src/popup/components/earn/helpers/formatPoolStats.ts @@ -55,3 +55,19 @@ export const formatRate = (rate: number | null): string => rate === null ? NO_FIAT_VALUE : `${new BigNumber(rate).times(100).toFormat(2)}%`; + +/** + * The headline rate is supply interest plus BLND emissions. This is the + * branch's ONE deliberate exception to "null is not zero" (see the other + * helpers on this file, and every call site's own comment): a null `rate` + * means no fresh oracle price, so the whole rate is unknown and stays null. + * A null `emissions` means the stream exists but cannot be priced — treated + * as zero, which understates rather than blanks an otherwise known rate. + * + * One implementation so that a future decision to blank the rate on null + * emissions instead cannot silently miss a call site (I5). + */ +export const headlineApy = ( + rate: number | null, + emissions: number | null, +): number | null => (rate === null ? null : rate + (emissions ?? 0)); diff --git a/extension/src/popup/components/earn/helpers/positionRows.ts b/extension/src/popup/components/earn/helpers/positionRows.ts index 9000c25fd5..f20a74f991 100644 --- a/extension/src/popup/components/earn/helpers/positionRows.ts +++ b/extension/src/popup/components/earn/helpers/positionRows.ts @@ -5,6 +5,7 @@ import { NetworkDetails } from "@shared/constants/stellar"; import { CLASSIC_ASSET_DECIMALS } from "popup/helpers/soroban"; import { getCatalogAssetIdentity } from "./earnAssetIcons"; +import { headlineApy } from "./formatPoolStats"; /** One row on the Positions tab. */ export interface PositionTokenRow { @@ -23,18 +24,6 @@ export interface PositionTokenRow { interestEarnedUsd: number | null; } -/** - * The headline rate is supply interest plus BLND emissions. - * - * A null `apy` means no fresh oracle price, so the whole rate is unknown. A null - * `emissionsApr` means the stream exists but cannot be priced — treated as zero, - * which understates rather than blanking an otherwise known rate. Identical to - * `headlineApy` in useGetEarnTokensData; kept separate because the two read - * different payloads. - */ -const headlineApy = (apy: number | null, emissionsApr: number | null) => - apy === null ? null : apy + (emissionsApr ?? 0); - const toRow = ({ row, poolId, @@ -71,6 +60,7 @@ const toRow = ({ .dividedBy(new BigNumber(10).pow(decimals)) .toFixed(), suppliedUsd: row.usdValue, + // headlineApy: the null-is-not-zero exception -- see formatPoolStats.ts. apy: headlineApy(row.apy, row.emissionsApr), interestEarnedUsd: row.interestEarnedUsd, }; diff --git a/extension/src/popup/components/earn/helpers/positionSummary.ts b/extension/src/popup/components/earn/helpers/positionSummary.ts index 0eb27ad8d4..8f72fcd066 100644 --- a/extension/src/popup/components/earn/helpers/positionSummary.ts +++ b/extension/src/popup/components/earn/helpers/positionSummary.ts @@ -6,6 +6,7 @@ import { CLASSIC_ASSET_DECIMALS } from "popup/helpers/soroban"; import { projectEarnings } from "popup/components/earn/EarnReview/helpers/projectEarnings"; import { getCatalogAssetIdentity } from "./earnAssetIcons"; +import { headlineApy } from "./formatPoolStats"; export type PositionScope = "supply" | "pool"; @@ -119,9 +120,6 @@ const toEarningsRow = ( }; }; -const headlineApy = (apy: number | null, emissionsApr: number | null) => - apy === null ? null : apy + (emissionsApr ?? 0); - /** * The rows this summary is scoped to. The ONLY place the focused-asset * resolution rule lives — `getPositionSummary` and `hasResolvableSupply` @@ -197,6 +195,7 @@ export const getPositionSummary = ({ const currentBalanceUsd = scope === "pool" ? position.netUsd : (rows[0]?.usdValue ?? null); + // headlineApy: the null-is-not-zero exception -- see formatPoolStats.ts. const apy = scope === "pool" ? position.netApy From 57396dc1e49ac6266b701e73ba0237d0adfcb5eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 26 Aug 2026 07:11:28 -0700 Subject: [PATCH 25/49] fix(home): stop the earnOptions dispatch reverting the Blockaid-scanned balances The mainnet rescan block built a throwaway scannedPayload local (balances + isScanAppended: true) and dispatched that, without ever writing those fields back onto payload itself. That was safe only because nothing dispatched from payload again afterward -- but the I3 fix moved the earnOptions landing to run after this block precisely so it would stop blocking the rescan, and that landing dispatches { ...payload }. Since the reducer fully replaces state per dispatch (helpers/request.ts), that silently reverted balances and isScanAppended right back to their pre-scan values. Affects every mainnet account with zero Blend positions (no error, empty list) -- the only population for which earnOptionsRequest is non-null. AccountAssets renders Blockaid's isSuspicious/isMalicious flags from these balances, so a malicious-token warning would appear when the scan lands and then disappear moments later, until the next 30s tick restores it. Fixed by mutating balances/isScanAppended onto payload itself before dispatching, the same pattern collectibles/positions/pools/earnOptions already use elsewhere in this function -- so any dispatch added after this block, now or later, carries the scanned data forward instead of reverting it. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/useGetAccountData.test.tsx | 139 ++++++++++++++++++ .../views/Account/hooks/useGetAccountData.tsx | 17 ++- 2 files changed, 150 insertions(+), 6 deletions(-) create mode 100644 extension/src/popup/views/Account/hooks/__tests__/useGetAccountData.test.tsx diff --git a/extension/src/popup/views/Account/hooks/__tests__/useGetAccountData.test.tsx b/extension/src/popup/views/Account/hooks/__tests__/useGetAccountData.test.tsx new file mode 100644 index 0000000000..720fb760eb --- /dev/null +++ b/extension/src/popup/views/Account/hooks/__tests__/useGetAccountData.test.tsx @@ -0,0 +1,139 @@ +import React from "react"; +import { Provider } from "react-redux"; +import { renderHook, act } from "@testing-library/react"; + +import { MAINNET_NETWORK_DETAILS } from "@shared/constants/stellar"; +import { APPLICATION_STATE } from "@shared/constants/applicationState"; +import { AccountPositions } from "@shared/api/types/blend"; +import { AppDataType } from "helpers/hooks/useGetAppData"; +import { AccountBalances } from "helpers/hooks/useGetBalances"; + +import { makeDummyStore, TEST_PUBLIC_KEY } from "popup/__testHelpers__"; + +import * as GetAppDataHooks from "helpers/hooks/useGetAppData"; +import * as GetBalancesHooks from "helpers/hooks/useGetBalances"; +import * as GetCollectiblesHooks from "helpers/hooks/useGetCollectibles"; +import * as GetPositionsHooks from "helpers/hooks/useGetPositions"; +import * as GetTokenPricesHooks from "helpers/hooks/useGetTokenPrices"; +import * as BlendHelpers from "@shared/api/helpers/blend"; +import * as ApiInternal from "@shared/api/internal"; + +import { useGetAccountData } from "../useGetAccountData"; + +// The first (unscanned) balances read Home shows immediately; distinguished +// from the scanned read below by `icons`, a field neither read nor written +// anywhere else in this flow -- a plain, unambiguous marker of "which read +// survived to the end", standing in for what a real Blockaid flag does on +// `balances[].blockaidData`. +const UNSCANNED_BALANCES = { + balances: [], + isFunded: true, + subentryCount: 1, +} as AccountBalances; + +const SCANNED_BALANCES = { + balances: [], + isFunded: true, + subentryCount: 1, + icons: { scanned: "marker" }, +} as AccountBalances; + +const ZERO_POSITIONS: AccountPositions = { + address: TEST_PUBLIC_KEY, + totalValueUsd: null, + netApy: null, + positions: [], + backstop: [], +}; + +jest.spyOn(GetAppDataHooks, "useGetAppData").mockReturnValue({ + state: {} as any, + fetchData: () => + Promise.resolve({ + type: AppDataType.RESOLVED, + account: { + publicKey: TEST_PUBLIC_KEY, + applicationState: APPLICATION_STATE.MNEMONIC_PHRASE_CONFIRMED, + }, + settings: { + networkDetails: MAINNET_NETWORK_DETAILS, + allowList: {}, + }, + }) as any, +} as any); + +// The 5th positional arg (`shouldSkipScan`) is exactly what separates Home's +// first, quick balances read from the later Blockaid-scanned one -- see +// `useGetAccountData.tsx`'s two `fetchBalances` call sites. +jest.spyOn(GetBalancesHooks, "useGetBalances").mockReturnValue({ + state: {} as any, + fetchData: ( + _publicKey: string, + _isMainnetNetwork: boolean, + _networkDetails: unknown, + _useCache?: boolean, + shouldSkipScan?: boolean, + ) => Promise.resolve(shouldSkipScan ? UNSCANNED_BALANCES : SCANNED_BALANCES), +} as any); + +jest.spyOn(GetCollectiblesHooks, "useGetCollectibles").mockReturnValue({ + state: {} as any, + fetchData: () => Promise.resolve({ collections: [] }), +} as any); + +// Zero positions (not an error) is the exact population the regression +// affects: it is what makes `earnOptionsRequest` non-null, which is what +// lands a dispatch after the mainnet rescan block. +jest.spyOn(GetPositionsHooks, "useGetPositions").mockReturnValue({ + state: {} as any, + fetchData: () => Promise.resolve(ZERO_POSITIONS), +} as any); + +jest.spyOn(GetTokenPricesHooks, "useGetTokenPrices").mockReturnValue({ + state: {} as any, + fetchData: () => Promise.resolve({ tokenPrices: {} }) as any, +} as any); + +jest.spyOn(BlendHelpers, "getBlendPools").mockResolvedValue([]); +jest.spyOn(BlendHelpers, "getBlendEarnOptions").mockResolvedValue([]); + +jest.spyOn(ApiInternal, "loadBackendSettings").mockResolvedValue({ + isSorobanPublicEnabled: true, + isRpcHealthy: true, + userNotification: { enabled: false, message: "" }, +} as any); + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe("useGetAccountData", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + // Regression: I3 moved the earnOptions landing to after the mainnet + // scanned-balances block so it would stop sitting in front of that scan. + // But the scan dispatches a throwaway `scannedPayload` local rather than + // updating `payload` itself, so the earnOptions dispatch that follows + // spreads the still-unscanned `payload` -- and the reducer fully replaces + // state per dispatch (helpers/request.ts), so that silently reverts + // `balances`/`isScanAppended` right after the scan lands. Affects every + // mainnet account with zero Blend positions (no error, empty list) -- the + // only population for which `earnOptionsRequest` is non-null. + it("keeps the Blockaid-scanned balances after the earnOptions dispatch that follows it", async () => { + const { result } = renderHook( + () => useGetAccountData({ showHidden: false, includeIcons: false }), + { wrapper }, + ); + + await act(async () => { + await result.current.fetchData({ useAppDataCache: false }); + }); + + const resolved = result.current.state.data as any; + + expect(resolved.isScanAppended).toBe(true); + expect(resolved.balances).toEqual(SCANNED_BALANCES); + }); +}); diff --git a/extension/src/popup/views/Account/hooks/useGetAccountData.tsx b/extension/src/popup/views/Account/hooks/useGetAccountData.tsx index d49ea8f597..3b28266c17 100644 --- a/extension/src/popup/views/Account/hooks/useGetAccountData.tsx +++ b/extension/src/popup/views/Account/hooks/useGetAccountData.tsx @@ -282,12 +282,17 @@ function useGetAccountData(options: { false, // don't skip the Blockaid scan, ); - const scannedPayload = { - ...payload, - balances: balancesResult, - isScanAppended: true, - } as ResolvedAccountData; - dispatch({ type: "FETCH_DATA_SUCCESS", payload: scannedPayload }); + // Mutated onto `payload` itself -- the same pattern every other + // section above uses -- rather than a throwaway `scannedPayload` + // local. The reducer fully replaces state per dispatch + // (helpers/request.ts: `data: action.payload`), so a throwaway local + // here is only safe as long as nothing dispatches from `payload` + // again afterward. The earnOptions landing below can now run after + // this block (I3), and a `{ ...payload }` there would otherwise + // silently revert these two fields right after the scan lands. + payload.balances = balancesResult as AccountBalances; + payload.isScanAppended = true; + dispatch({ type: "FETCH_DATA_SUCCESS", payload: { ...payload } }); } catch (e) { captureException(`Error fetching scanned balances on Account - ${e}`); } From 2317c20e512e16c7acd1e1fb6a6593c579a51d2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 26 Aug 2026 11:16:31 -0700 Subject: [PATCH 26/49] refactor(earn): use NO_FIAT_VALUE for the positions placeholder dashes Follows the convention a22cfd87 established across the Earn flow. The constant is the same "--" string, so nothing renders differently; this keeps the placeholder in one place so a future change to it cannot miss a call site. Co-Authored-By: Claude Opus 5 --- .../components/account/AccountPositions/PositionRow.tsx | 7 +++++-- .../components/earn/PoolDetailsSheet/YourPosition.tsx | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/extension/src/popup/components/account/AccountPositions/PositionRow.tsx b/extension/src/popup/components/account/AccountPositions/PositionRow.tsx index 9926929887..3e552d44b4 100644 --- a/extension/src/popup/components/account/AccountPositions/PositionRow.tsx +++ b/extension/src/popup/components/account/AccountPositions/PositionRow.tsx @@ -8,6 +8,7 @@ import { formatAccountUsd, formatRate, } from "popup/components/earn/helpers/formatPoolStats"; +import { NO_FIAT_VALUE } from "popup/helpers/formatters"; interface PositionRowProps { row: PositionTokenRow; @@ -47,7 +48,7 @@ export const PositionRow = ({ row, assetIcons, onClick }: PositionRowProps) => { data-testid={`position-apy-${row.code}`} > {row.apy === null - ? "--" + ? NO_FIAT_VALUE : t("{{rate}} APY", { rate: formatRate(row.apy) })} } @@ -63,7 +64,9 @@ export const PositionRow = ({ row, assetIcons, onClick }: PositionRowProps) => { className={`PositionRow__gain ${isPositiveGain ? "PositionRow__gain--positive" : ""}`} data-testid={`position-gain-${row.code}`} > - {isGainKnown ? `+${formatAccountUsd(row.interestEarnedUsd)}` : "--"} + {isGainKnown + ? `+${formatAccountUsd(row.interestEarnedUsd)}` + : NO_FIAT_VALUE}
} diff --git a/extension/src/popup/components/earn/PoolDetailsSheet/YourPosition.tsx b/extension/src/popup/components/earn/PoolDetailsSheet/YourPosition.tsx index 8bde4ee398..f2907fbc53 100644 --- a/extension/src/popup/components/earn/PoolDetailsSheet/YourPosition.tsx +++ b/extension/src/popup/components/earn/PoolDetailsSheet/YourPosition.tsx @@ -13,7 +13,7 @@ import { formatRate, } from "popup/components/earn/helpers/formatPoolStats"; import { formatProjection } from "popup/components/earn/EarnReview/helpers/projectEarnings"; -import { formatAmount } from "popup/helpers/formatters"; +import { NO_FIAT_VALUE, formatAmount } from "popup/helpers/formatters"; import { PositionAssetRow, getPositionSummary, @@ -50,7 +50,7 @@ const AssetLine = ({ data-testid={`earn-position-${isGain ? "earnings" : "deposit"}-usd-${row.code}`} > {row.usd === null - ? "--" + ? NO_FIAT_VALUE : `${isGain ? "+" : ""}${formatAccountUsd(row.usd)}`}
From edb63536d6e16e84c5dbdc34cee97f63da8f509c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 26 Aug 2026 14:04:19 -0700 Subject: [PATCH 27/49] feat(earn): summarise a pool position's totals --- .../__tests__/poolPositionSummary.test.ts | 127 ++++++++++++++++++ .../helpers/__tests__/positionSummary.test.ts | 21 +-- .../earn/helpers/poolPositionSummary.ts | 73 ++++++++++ .../earn/helpers/positionSummary.ts | 47 ++----- 4 files changed, 212 insertions(+), 56 deletions(-) create mode 100644 extension/src/popup/components/earn/helpers/__tests__/poolPositionSummary.test.ts create mode 100644 extension/src/popup/components/earn/helpers/poolPositionSummary.ts diff --git a/extension/src/popup/components/earn/helpers/__tests__/poolPositionSummary.test.ts b/extension/src/popup/components/earn/helpers/__tests__/poolPositionSummary.test.ts new file mode 100644 index 0000000000..a25593be6b --- /dev/null +++ b/extension/src/popup/components/earn/helpers/__tests__/poolPositionSummary.test.ts @@ -0,0 +1,127 @@ +import { getPoolPositionSummary } from "../poolPositionSummary"; + +const supply = (over: Record = {}) => ({ + assetId: "CUSDC", + symbol: "USDC", + name: null, + decimals: 7, + suppliedTokens: "0", + collateralTokens: "0", + totalTokens: "0", + usdValue: 500.12, + apy: 0.1694, + emissionsApr: 0, + interestEarned: "0", + interestEarnedUsd: 0.12, + claimableBlnd: "0", + claimableUsd: null, + priceUsd: 1, + ...over, +}); + +const position = (rows: unknown[], over: Record = {}) => + ({ + protocol: "blend", + id: "CAJJ", + name: "Fixed Pool v2", + netUsd: 1284.32, + suppliedUsd: 1284.32, + borrowedUsd: 0, + netApy: 0.1023, + blend: { supply: rows, borrow: [] }, + ...over, + }) as never; + +describe("getPoolPositionSummary", () => { + it("sums interest across every supplied asset", () => { + const result = getPoolPositionSummary( + position([ + supply({ interestEarnedUsd: 0.12 }), + supply({ interestEarnedUsd: 31.63 }), + ]), + ); + + expect(result.interestUsd).toBeCloseTo(31.75); + expect(result.totalUsd).toBe(1284.32); + expect(result.apy).toBeCloseTo(0.1023); + }); + + it("measures the gain against principal, not the current total", () => { + // R8: measured against principal, not the total. + // 1284.32 - 31.75 = 1252.57 principal; 31.75 / 1252.57 = 2.535% -> the + // 2.54% both frames show. Dividing by the total would give 2.47%. + const result = getPoolPositionSummary( + position([supply({ interestEarnedUsd: 31.75 })]), + ); + + expect(result.gainPercent).toBeCloseTo(0.02535, 5); + }); + + it("treats one unpriced row as making the whole interest total unknown", () => { + const result = getPoolPositionSummary( + position([ + supply({ interestEarnedUsd: 0.12 }), + supply({ interestEarnedUsd: null }), + ]), + ); + + expect(result.interestUsd).toBeNull(); + expect(result.gainPercent).toBeNull(); + }); + + it("keeps an unpriced pool total from producing a gain", () => { + const result = getPoolPositionSummary( + position([supply()], { netUsd: null }), + ); + + expect(result.totalUsd).toBeNull(); + expect(result.gainPercent).toBeNull(); + }); + + it("returns null rather than Infinity when principal is zero", () => { + // A position that is entirely accrued interest: total equals interest, so + // principal is 0 and the ratio is undefined. + const result = getPoolPositionSummary( + position([supply({ interestEarnedUsd: 50 })], { netUsd: 50 }), + ); + + expect(result.gainPercent).toBeNull(); + expect(Number.isFinite(result.gainPercent as number)).toBe(false); + }); + + it("reports zero interest as a real zero, not unknown", () => { + const result = getPoolPositionSummary( + position([supply({ interestEarnedUsd: 0 })]), + ); + + expect(result.interestUsd).toBe(0); + expect(result.gainPercent).toBe(0); + }); + + it("reports zero interest for a position whose detail is present but empty", () => { + // Distinct from the no-detail case below: here we KNOW there is nothing + // supplied, so the interest really is zero rather than unknown. + const result = getPoolPositionSummary(position([])); + + expect(result.interestUsd).toBe(0); + }); + + it("handles a position carrying no blend detail", () => { + const result = getPoolPositionSummary({ + protocol: "blend", + id: "CAJJ", + name: null, + netUsd: null, + suppliedUsd: null, + borrowedUsd: null, + netApy: null, + } as never); + + expect(result).toEqual({ + totalUsd: null, + interestUsd: null, + gainPercent: null, + apy: null, + }); + }); +}); diff --git a/extension/src/popup/components/earn/helpers/__tests__/positionSummary.test.ts b/extension/src/popup/components/earn/helpers/__tests__/positionSummary.test.ts index 6a947624a1..1a66174b5b 100644 --- a/extension/src/popup/components/earn/helpers/__tests__/positionSummary.test.ts +++ b/extension/src/popup/components/earn/helpers/__tests__/positionSummary.test.ts @@ -49,7 +49,7 @@ const position = (supply: unknown[]) => blend: { supply, borrow: [] }, }) as never; -describe("getPositionSummary — supply scope", () => { +describe("getPositionSummary", () => { const summary = ( focusedAssetId: string, supply: unknown[] = [usdcSupply, xlmSupply], @@ -58,7 +58,6 @@ describe("getPositionSummary — supply scope", () => { position: position(supply), focusedAssetId, networkDetails, - scope: "supply", }); it("scopes every figure to the focused asset", () => { @@ -129,7 +128,6 @@ describe("getPositionSummary — supply scope", () => { const result = getPositionSummary({ position: position([usdcSupply, xlmSupply]), networkDetails, - scope: "supply", }); expect(result.deposits[0].code).toBe("USDC"); @@ -202,23 +200,6 @@ describe("getPositionSummary — supply scope", () => { }); }); -describe("getPositionSummary — pool scope", () => { - it("aggregates every supplied row under the pool's own totals", () => { - const result = getPositionSummary({ - position: position([usdcSupply, xlmSupply]), - networkDetails, - scope: "pool", - }); - - expect(result.currentBalanceUsd).toBe(620.16); - expect(result.apy).toBeCloseTo(0.16); - expect(result.deposits).toHaveLength(2); - expect(result.earnings).toHaveLength(2); - // One pair of Est. rows, from the pool total — never one pair per asset. - expect(result.estYearlyUsd).toBe("99.20"); - }); -}); - describe("hasResolvableSupply", () => { it("is true when the focused asset matches a supplied row", () => { expect( diff --git a/extension/src/popup/components/earn/helpers/poolPositionSummary.ts b/extension/src/popup/components/earn/helpers/poolPositionSummary.ts new file mode 100644 index 0000000000..2b0a967c59 --- /dev/null +++ b/extension/src/popup/components/earn/helpers/poolPositionSummary.ts @@ -0,0 +1,73 @@ +import BigNumber from "bignumber.js"; + +import { PoolPosition } from "@shared/api/types/blend"; + +export interface PoolPositionSummary { + /** The pool's total value to this account. Null when unpriced. */ + totalUsd: number | null; + /** + * Lifetime interest across every supplied asset. Null if ANY row is unpriced + * — a partial sum would understate the figure while looking authoritative. + */ + interestUsd: number | null; + /** Interest as a fraction of principal (0.0254 = 2.54%). Null when underivable. */ + gainPercent: number | null; + /** The account's supplied-USD-weighted rate in this pool. */ + apy: number | null; +} + +/** + * The pool-level figures behind a Positions pool card and the My position + * header. + * + * Deliberately separate from `getPositionSummary`, which is supply-scoped and + * answers a different question (what one asset is doing). Keeping them apart + * is what lets each own its totals explicitly instead of sharing a mode flag. + * + * The gain is measured against PRINCIPAL, not the current total: an account + * that put in 1252.57 and now holds 1284.32 has gained 2.54% of what it + * committed, not 2.47% of what it now has. Same convention the sheet already + * uses for deposits. + */ +export const getPoolPositionSummary = ( + position: PoolPosition, +): PoolPositionSummary => { + const supply = position.blend?.supply || []; + const totalUsd = position.netUsd ?? null; + + // A position with no detail at all has UNKNOWN interest, not zero — the + // difference `[].reduce(…, 0)` would silently erase. A position whose detail + // is present but empty genuinely has none, so that case really is 0. + // + // Within a populated list, one unpriced row makes the whole total unknown: + // reduced with a null-poison rather than filtered, so a partial sum can never + // masquerade as complete. + const interestUsd = !position.blend + ? null + : supply.reduce((sum, row) => { + if (sum === null || row.interestEarnedUsd === null) { + return null; + } + return sum + row.interestEarnedUsd; + }, 0); + + const gainPercent = (() => { + if (totalUsd === null || interestUsd === null) { + return null; + } + const principal = new BigNumber(totalUsd).minus(interestUsd); + // A position that is entirely accrued interest has no principal to measure + // against; the ratio is undefined, not infinite. + if (principal.lte(0)) { + return null; + } + return new BigNumber(interestUsd).dividedBy(principal).toNumber(); + })(); + + return { + totalUsd, + interestUsd, + gainPercent, + apy: position.netApy ?? null, + }; +}; diff --git a/extension/src/popup/components/earn/helpers/positionSummary.ts b/extension/src/popup/components/earn/helpers/positionSummary.ts index 8f72fcd066..a1f68557c8 100644 --- a/extension/src/popup/components/earn/helpers/positionSummary.ts +++ b/extension/src/popup/components/earn/helpers/positionSummary.ts @@ -8,15 +8,6 @@ import { projectEarnings } from "popup/components/earn/EarnReview/helpers/projec import { getCatalogAssetIdentity } from "./earnAssetIcons"; import { headlineApy } from "./formatPoolStats"; -export type PositionScope = "supply" | "pool"; - -/** - * v1 renders one supplied asset — the row the user tapped. Flip to "pool" to - * aggregate every supply row in the position; `deposits` and `earnings` are - * already arrays, so nothing in the sheet has to change. See design doc Q1. - */ -export const POSITION_SUMMARY_SCOPE: PositionScope = "supply"; - export interface PositionAssetRow { assetId: string; code: string; @@ -29,7 +20,7 @@ export interface PositionAssetRow { export interface PositionSummary { currentBalanceUsd: number | null; - /** Headline rate: apy + emissions, or the pool's netApy under pool scope. */ + /** Headline rate: apy + emissions. */ apy: number | null; deposits: PositionAssetRow[]; earnings: PositionAssetRow[]; @@ -138,16 +129,11 @@ const toEarningsRow = ( const resolveRows = ({ position, focusedAssetId, - scope, }: { position: PoolPosition; focusedAssetId?: string; - scope: PositionScope; }): BlendSupplyRow[] => { const supply = position.blend?.supply || []; - if (scope === "pool") { - return supply; - } if (focusedAssetId === undefined) { return [supply[0]].filter(Boolean); } @@ -157,49 +143,38 @@ const resolveRows = ({ /** * Whether `getPositionSummary` would resolve to any row at all, for this same - * `position`/`focusedAssetId`/`scope`. `PoolDetailsSheet` uses this to decide - * whether "Your position" has anything to show, so it never renders a tab - * whose panel would be empty (see `resolveRows`). + * `position`/`focusedAssetId`. `PoolDetailsSheet` uses this to decide whether + * "Your position" has anything to show, so it never renders a tab whose panel + * would be empty (see `resolveRows`). */ export const hasResolvableSupply = ({ position, focusedAssetId, - scope = POSITION_SUMMARY_SCOPE, }: { position: PoolPosition; focusedAssetId?: string; - scope?: PositionScope; -}): boolean => resolveRows({ position, focusedAssetId, scope }).length > 0; +}): boolean => resolveRows({ position, focusedAssetId }).length > 0; /** - * The sheet's "Your position" figures, for one supplied asset or for the whole - * pool. The ONLY place this derivation lives — the component renders whatever - * comes back and knows nothing about scope. + * The sheet's "Your position" figures for one supplied asset. * - * The two Est. rows are computed once from the scoped balance and rate, never - * once per asset: that is how the design draws them, inside the earnings card - * below a divider. + * Pool-level totals are a different surface with a different helper — see + * `getPoolPositionSummary`. This one never aggregates. */ export const getPositionSummary = ({ position, focusedAssetId, networkDetails, - scope = POSITION_SUMMARY_SCOPE, }: { position: PoolPosition; focusedAssetId?: string; networkDetails: NetworkDetails; - scope?: PositionScope; }): PositionSummary => { - const rows = resolveRows({ position, focusedAssetId, scope }); + const rows = resolveRows({ position, focusedAssetId }); - const currentBalanceUsd = - scope === "pool" ? position.netUsd : (rows[0]?.usdValue ?? null); + const currentBalanceUsd = rows[0]?.usdValue ?? null; // headlineApy: the null-is-not-zero exception -- see formatPoolStats.ts. - const apy = - scope === "pool" - ? position.netApy - : headlineApy(rows[0]?.apy ?? null, rows[0]?.emissionsApr ?? null); + const apy = headlineApy(rows[0]?.apy ?? null, rows[0]?.emissionsApr ?? null); const deposits = rows.map((row) => toDepositRow(row, networkDetails)); const earnings = rows.map((row) => toEarningsRow(row, networkDetails)); From 9348d374d991e34732b34b72dc6aab0087d5ee77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 26 Aug 2026 14:11:30 -0700 Subject: [PATCH 28/49] docs(earn): stop describing the deleted pool-scope path in YourPosition --- .../earn/PoolDetailsSheet/YourPosition.tsx | 15 ++++++++------- .../helpers/__tests__/poolPositionSummary.test.ts | 1 - 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/extension/src/popup/components/earn/PoolDetailsSheet/YourPosition.tsx b/extension/src/popup/components/earn/PoolDetailsSheet/YourPosition.tsx index f2907fbc53..eb3da670f3 100644 --- a/extension/src/popup/components/earn/PoolDetailsSheet/YourPosition.tsx +++ b/extension/src/popup/components/earn/PoolDetailsSheet/YourPosition.tsx @@ -61,12 +61,13 @@ const AssetLine = ({ ); /** - * The account's stake in this pool. + * The account's stake in this pool, scoped to one supplied asset. * - * Every figure comes from `getPositionSummary`, which owns the supply-vs-pool - * scope decision — this component renders whatever it returns and never asks - * which scope produced it. `deposits` and `earnings` are lists precisely so - * pool scope needs no change here. + * Every figure comes from `getPositionSummary`, which is unconditionally + * supply-scoped and never aggregates — see its own doc comment. `deposits` + * and `earnings` stay lists because resolution can come back empty (a + * focused asset that matches no supplied row) as well as with one row; this + * component maps over them rather than assuming either shape. */ export const YourPosition = ({ position, @@ -138,8 +139,8 @@ export const YourPosition = ({ isGain /> ))} - {/* One pair of rows for the whole panel, from the scoped balance and - rate — not one pair per asset. */} + {/* One pair of rows for the whole panel, from the position's balance + and rate — not one pair per earnings row. */} { ); expect(result.gainPercent).toBeNull(); - expect(Number.isFinite(result.gainPercent as number)).toBe(false); }); it("reports zero interest as a real zero, not unknown", () => { From 811f9f4ffbb89fc2fdda6cb84da23621e71c814c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 26 Aug 2026 14:23:50 -0700 Subject: [PATCH 29/49] feat(home): add a pool card for the Positions tab --- .../account/AccountPositions/PoolCard.tsx | 68 ++++++++++++ .../__tests__/PoolCard.test.tsx | 102 ++++++++++++++++++ .../account/AccountPositions/styles.scss | 46 ++++++++ .../src/popup/locales/en/translation.json | 1 + .../src/popup/locales/pt/translation.json | 1 + 5 files changed, 218 insertions(+) create mode 100644 extension/src/popup/components/account/AccountPositions/PoolCard.tsx create mode 100644 extension/src/popup/components/account/AccountPositions/__tests__/PoolCard.test.tsx diff --git a/extension/src/popup/components/account/AccountPositions/PoolCard.tsx b/extension/src/popup/components/account/AccountPositions/PoolCard.tsx new file mode 100644 index 0000000000..2103d331d1 --- /dev/null +++ b/extension/src/popup/components/account/AccountPositions/PoolCard.tsx @@ -0,0 +1,68 @@ +import React from "react"; +import { Text } from "@stellar/design-system"; +import { useTranslation } from "react-i18next"; + +import { PoolPosition } from "@shared/api/types/blend"; +import { PoolIcon } from "popup/components/earn/PoolIcon"; +import { + formatAccountUsd, + formatRate, +} from "popup/components/earn/helpers/formatPoolStats"; +import { getPoolPositionSummary } from "popup/components/earn/helpers/poolPositionSummary"; +import { NO_FIAT_VALUE } from "popup/helpers/formatters"; + +interface PoolCardProps { + position: PoolPosition; + onClick: () => void; +} + +/** + * One pool on the Positions tab. + * + * Pool-shaped rather than token-shaped: the multi-pool design lists pools, so a + * second pool is one more entry in the caller's map rather than a re-layout. + * The supplied tokens live one level down, inside My position. + */ +export const PoolCard = ({ position, onClick }: PoolCardProps) => { + const { t } = useTranslation(); + const summary = getPoolPositionSummary(position); + + return ( + + ); +}; diff --git a/extension/src/popup/components/account/AccountPositions/__tests__/PoolCard.test.tsx b/extension/src/popup/components/account/AccountPositions/__tests__/PoolCard.test.tsx new file mode 100644 index 0000000000..3c1e05bad9 --- /dev/null +++ b/extension/src/popup/components/account/AccountPositions/__tests__/PoolCard.test.tsx @@ -0,0 +1,102 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; + +import { PoolCard } from "popup/components/account/AccountPositions/PoolCard"; +import { Wrapper } from "popup/__testHelpers__"; + +const supply = (interestEarnedUsd: number | null) => ({ + assetId: "CUSDC", + symbol: "USDC", + name: null, + decimals: 7, + suppliedTokens: "0", + collateralTokens: "0", + totalTokens: "0", + usdValue: 500.12, + apy: 0.1694, + emissionsApr: 0, + interestEarned: "0", + interestEarnedUsd, + claimableBlnd: "0", + claimableUsd: null, + priceUsd: 1, +}); + +const position = (over: Record = {}) => + ({ + protocol: "blend", + id: "CAJJ", + name: "Fixed Pool v2", + netUsd: 1284.32, + suppliedUsd: 1284.32, + borrowedUsd: 0, + netApy: 0.1023, + blend: { supply: [supply(31.75)], borrow: [] }, + ...over, + }) as never; + +const renderCard = (props = {}) => + render( + + + , + ); + +describe("PoolCard", () => { + it("shows the pool, its provider, value, gain and rate", () => { + renderCard(); + + expect(screen.getByTestId("pool-card-CAJJ")).toHaveTextContent( + "Fixed Pool v2", + ); + expect(screen.getByTestId("pool-card-value-CAJJ")).toHaveTextContent( + "$1,284.32", + ); + // The brief's fixture math (31.75 / (1284.32 - 31.75)) works out to + // 0.025347..., which formatRate's half-up rounding renders as "2.53%", + // not the brief's literal "2.54%" -- verified independently with + // BigNumber and by running this test against the real, already-committed + // Task 1 getPoolPositionSummary. Trusting the actual code's output here + // per this task's own instruction to do so on a brief/code conflict. + expect(screen.getByTestId("pool-card-gain-CAJJ")).toHaveTextContent( + "2.53%", + ); + // react-i18next's t() is mocked in this test env to echo its key + // untouched (config/jest/setupTests.tsx) rather than interpolate -- + // matches how the sibling PositionRow test asserts this same + // "{{rate}} APY" pattern (AccountPositions.test.tsx). formatRate's own + // tests cover the actual "10.23%" formatting; this proves the + // available-rate branch is taken. + expect(screen.getByTestId("pool-card-apy-CAJJ")).toHaveTextContent( + "{{rate}} APY", + ); + }); + + it("opens the position when tapped", () => { + const onClick = jest.fn(); + renderCard({ onClick }); + + fireEvent.click(screen.getByTestId("pool-card-CAJJ")); + + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it("renders an unavailable gain as the placeholder, not zero", () => { + renderCard({ + position: position({ blend: { supply: [supply(null)], borrow: [] } }), + }); + + expect(screen.getByTestId("pool-card-gain-CAJJ")).toHaveTextContent("--"); + expect(screen.getByTestId("pool-card-gain-CAJJ")).not.toHaveTextContent( + "0.00%", + ); + }); + + it("falls back to a generic name when the pool is unnamed", () => { + renderCard({ position: position({ name: null }) }); + + expect(screen.getByTestId("pool-card-CAJJ")).toHaveTextContent( + "Blend pool", + ); + }); +}); diff --git a/extension/src/popup/components/account/AccountPositions/styles.scss b/extension/src/popup/components/account/AccountPositions/styles.scss index 3200690fe8..d0c34e3e58 100644 --- a/extension/src/popup/components/account/AccountPositions/styles.scss +++ b/extension/src/popup/components/account/AccountPositions/styles.scss @@ -212,3 +212,49 @@ } } } + +// Task 2 (PoolCard) -- the Positions tab's new one-row-per-pool card, Figma +// node 9808-30558. `get_design_context` and `get_variable_defs` both failed +// for that node ("No node could be found... make sure the document +// containing the node is the active tab") across repeated retries with the +// Figma desktop app open: its active tab held a different frame ("Review", +// node 12897:69076) in the same file, and every 9808-series node +// (9808-30558 here, and 9808-30015 tried as a control) came back +// unreachable the same way. `get_screenshot` failed identically, so there +// was no visual reference either -- not even an eyeballed layout. +// +// Given that, and given this pass's explicit instruction not to derive or +// invent design-token numbers, this block intentionally carries NO padding, +// radius, background, type-scale or colour values -- every one of those +// needs the real node. It only resets the ` diff --git a/extension/src/popup/components/account/AccountPositions/__tests__/PoolCard.test.tsx b/extension/src/popup/components/account/AccountPositions/__tests__/PoolCard.test.tsx index 3c1e05bad9..fa34a204df 100644 --- a/extension/src/popup/components/account/AccountPositions/__tests__/PoolCard.test.tsx +++ b/extension/src/popup/components/account/AccountPositions/__tests__/PoolCard.test.tsx @@ -99,4 +99,12 @@ describe("PoolCard", () => { "Blend pool", ); }); + + it("renders the rate line as a bare -- when the APY is unavailable, not '-- APY'", () => { + renderCard({ position: position({ netApy: null }) }); + + const apy = screen.getByTestId("pool-card-apy-CAJJ"); + expect(apy).toHaveTextContent("--"); + expect(apy).not.toHaveTextContent("APY"); + }); }); diff --git a/extension/src/popup/components/account/AccountPositions/styles.scss b/extension/src/popup/components/account/AccountPositions/styles.scss index d0c34e3e58..f2c71a4646 100644 --- a/extension/src/popup/components/account/AccountPositions/styles.scss +++ b/extension/src/popup/components/account/AccountPositions/styles.scss @@ -231,23 +231,28 @@ // visible border/background, left-aligned, pointer cursor, inherits // typography) -- a semantic default, not a value read off the design. // -// Selectors are scoped under `.AccountPositions` rather than written as a -// bare `.PoolCard` rule: `popup/components/earn/EarnAmount/styles.scss` -// already defines a *different* `.PoolCard` (the deposit flow's own pool -// card, with its own `&__body`/`&__identity` etc.) and this codebase's SCSS -// compiles into one global, unscoped stylesheet (no CSS Modules -- see -// `extension/webpack.common.js`'s `css-loader` rule). An unscoped -// `.PoolCard` here would collide with that unrelated rule at equal -// specificity, with the winner decided only by import order. The compound -// selector avoids that: `.AccountPositions .PoolCard` never resolves to the -// same specificity as EarnAmount's bare `.PoolCard`, and this component is -// always rendered inside `.AccountPositions` (see `index.tsx`), so the -// selector still matches every real render. +// Named `.PositionsPoolCard`, not `.PoolCard`: +// `popup/components/earn/EarnAmount/styles.scss` already defines a bare +// `.PoolCard` (the deposit flow's own, differently-shaped pool card, with +// its own `&__body`/`&__identity`/etc.), and this codebase's SCSS compiles +// into one global, unscoped stylesheet (no CSS Modules -- see +// `extension/webpack.common.js`'s `css-loader` rule). An earlier version of +// this block reused `.PoolCard` and tried to out-scope the collision with +// `.AccountPositions .PoolCard` (higher specificity than the bare rule) -- +// that only wins the cascade for properties BOTH rules declare. Anything +// EarnAmount's `.PoolCard` sets that this block didn't (its +// `flex-direction: column`, its `.PoolIcon { width/height/border-radius }`, +// its `&__identity { margin-right: auto }`) still applied here, since a +// compound selector doesn't stop a property from a lower-specificity rule +// simply because it's absent from the higher-specificity one. Renaming the +// whole class tree removes the collision outright rather than requiring +// this block to mirror every property EarnAmount's rule happens to set, +// today or in the future. // // A follow-up pass MUST pull 9808-30558 for real (padding, radius, // background, the three type scales, and the gain's green) once the Figma // desktop app's active tab can reach it, and fill in the rules below. -.AccountPositions .PoolCard { +.PositionsPoolCard { display: flex; align-items: center; width: 100%; From ac152aa517fd047c6713605a900ee0f40f1cc227 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 26 Aug 2026 14:49:41 -0700 Subject: [PATCH 31/49] feat(home): list pools on the Positions tab --- .../popup/basics/buttons/BackButton/index.tsx | 4 +- .../src/popup/basics/layout/View/index.tsx | 4 + .../popup/components/SubviewHeader/index.tsx | 4 + .../account/AccountPositions/MyPosition.tsx | 50 +++++ .../__tests__/AccountPositions.sheet.test.tsx | 171 +++--------------- .../__tests__/AccountPositions.test.tsx | 114 ++++-------- .../account/AccountPositions/index.tsx | 119 ++++++------ .../account/AccountPositions/styles.scss | 9 + .../src/popup/locales/en/translation.json | 1 + .../src/popup/locales/pt/translation.json | 1 + 10 files changed, 186 insertions(+), 291 deletions(-) create mode 100644 extension/src/popup/components/account/AccountPositions/MyPosition.tsx diff --git a/extension/src/popup/basics/buttons/BackButton/index.tsx b/extension/src/popup/basics/buttons/BackButton/index.tsx index 3db5c99188..6b340a61f0 100644 --- a/extension/src/popup/basics/buttons/BackButton/index.tsx +++ b/extension/src/popup/basics/buttons/BackButton/index.tsx @@ -12,6 +12,7 @@ interface BackButtonProps { customButtonComponent?: React.ReactElement< React.ButtonHTMLAttributes >; + "data-testid"?: string; } export const BackButton = ({ @@ -19,6 +20,7 @@ export const BackButton = ({ customBackIcon, hasBackCopy, customButtonComponent, + "data-testid": dataTestId = "BackButton", }: BackButtonProps) => { const { t } = useTranslation(); const navigate = useNavigate(); @@ -39,7 +41,7 @@ export const BackButton = ({ return (
{customBackIcon || } diff --git a/extension/src/popup/basics/layout/View/index.tsx b/extension/src/popup/basics/layout/View/index.tsx index 69fc490ebe..e9bcf57981 100644 --- a/extension/src/popup/basics/layout/View/index.tsx +++ b/extension/src/popup/basics/layout/View/index.tsx @@ -45,6 +45,8 @@ interface ViewAppHeaderProps { customBackIcon?: React.ReactNode; isAccountHeader?: boolean; children?: React.ReactNode; + /** Forwarded to the back button itself, not this header's own container. */ + backButtonTestId?: string; } const ViewAppHeader: React.FC = ({ @@ -59,6 +61,7 @@ const ViewAppHeader: React.FC = ({ customBackIcon, children, isAccountHeader = false, + backButtonTestId, ...props }: ViewAppHeaderProps) => (
@@ -74,6 +77,7 @@ const ViewAppHeader: React.FC = ({ ) : null} diff --git a/extension/src/popup/components/SubviewHeader/index.tsx b/extension/src/popup/components/SubviewHeader/index.tsx index 04e95f80b7..8d180b4b8d 100644 --- a/extension/src/popup/components/SubviewHeader/index.tsx +++ b/extension/src/popup/components/SubviewHeader/index.tsx @@ -13,6 +13,8 @@ interface SubviewHeaderProps { rightButton?: React.ReactNode; /** Sits in the left slot, after the back button if there is one. */ leftButton?: React.ReactNode; + /** Forwarded to the back/close button itself, not this header's container. */ + "data-testid"?: string; } export const SubviewHeader = ({ @@ -23,6 +25,7 @@ export const SubviewHeader = ({ hasBackButton = true, rightButton, leftButton, + "data-testid": dataTestId, }: SubviewHeaderProps) => ( ); diff --git a/extension/src/popup/components/account/AccountPositions/MyPosition.tsx b/extension/src/popup/components/account/AccountPositions/MyPosition.tsx new file mode 100644 index 0000000000..1b42f0e70d --- /dev/null +++ b/extension/src/popup/components/account/AccountPositions/MyPosition.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { Icon } from "@stellar/design-system"; +import { useTranslation } from "react-i18next"; + +import { AssetIcons } from "@shared/api/types"; +import { BlendCatalogPool, PoolPosition } from "@shared/api/types/blend"; +import { NetworkDetails } from "@shared/constants/stellar"; +import { SubviewHeader } from "popup/components/SubviewHeader"; +import { View } from "popup/basics/layout/View"; + +interface MyPositionProps { + position: PoolPosition; + /** The catalog entry, for the pool-details sheet this screen can open. */ + pool: BlendCatalogPool | null; + assetIcons: AssetIcons; + networkDetails: NetworkDetails; + onClose: () => void; +} + +/** + * One pool position in full: its totals, and the assets supplied to it. + * + * Rendered in place by the Positions tab as a bottom sheet (R2), the same way the + * Tokens tab opens AssetDetail — so the tab stays mounted underneath and + * dismissal costs nothing. An X rather than a back arrow (R3), matching AssetDetail + * and CollectibleDetail. + */ +// `position`, `pool`, `assetIcons` and `networkDetails` are part of the public +// interface Task 4 fills the body with, but this task's shell has nothing to +// do with them yet -- destructuring only `onClose` here (rather than the +// brief's full destructure) avoids a `noUnusedParameters` build failure on +// bindings this task never reads. +export const MyPosition = ({ onClose }: MyPositionProps) => { + const { t } = useTranslation(); + + return ( + + } + customBackAction={onClose} + data-testid="my-position-close" + /> + {/* Totals, About pool and the supplied-asset list arrive in Task 4. */} + +
+ + + ); +}; diff --git a/extension/src/popup/components/account/AccountPositions/__tests__/AccountPositions.sheet.test.tsx b/extension/src/popup/components/account/AccountPositions/__tests__/AccountPositions.sheet.test.tsx index b424eea33a..93980dcf3f 100644 --- a/extension/src/popup/components/account/AccountPositions/__tests__/AccountPositions.sheet.test.tsx +++ b/extension/src/popup/components/account/AccountPositions/__tests__/AccountPositions.sheet.test.tsx @@ -3,53 +3,12 @@ import { render, screen, fireEvent } from "@testing-library/react"; import { MAINNET_NETWORK_DETAILS } from "@shared/constants/stellar"; import { BlendCatalogPool } from "@shared/api/types/blend"; -import { getCanonicalFromAsset } from "@shared/helpers/stellar"; import { AccountPositions } from "popup/components/account/AccountPositions"; -import { PositionTokenRow } from "popup/components/earn/helpers/positionRows"; -import { TEST_PUBLIC_KEY, Wrapper, getTestStore } from "popup/__testHelpers__"; -import { ROUTES } from "popup/constants/routes"; -import { - EARN_PREFILL_QUERY, - EARN_SOURCE, - EARN_SOURCE_KEY, -} from "popup/constants/earn"; -import { navigateTo } from "popup/helpers/navigate"; -import { - resetSubmission, - saveAsset, - saveDestination, - saveIsToken, -} from "popup/ducks/transactionSubmission"; -import { - saveEarnPool, - saveSelectedAssetApy, - saveSelectedAssetId, -} from "popup/ducks/earn"; - -jest.mock("popup/metrics/positions", () => ({ - trackPositionRowSelected: jest.fn(), - trackPositionsEmptyCtaSelected: jest.fn(), -})); - -jest.mock("popup/metrics/earn", () => ({ - ...jest.requireActual("popup/metrics/earn"), - trackEarnPoolDetailsOpened: jest.fn(), - trackEarnPoolDetailsTabSelected: jest.fn(), -})); - -const { trackPositionRowSelected } = jest.requireMock< - typeof import("popup/metrics/positions") ->("popup/metrics/positions"); - -const { trackEarnPoolDetailsOpened, trackEarnPoolDetailsTabSelected } = - jest.requireMock("popup/metrics/earn"); +import { TEST_PUBLIC_KEY, Wrapper } from "popup/__testHelpers__"; const POOL_ID = "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD"; const USDC_SAC = "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75"; -const XLM_SAC = "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA"; - -const mockNavigate = jest.fn(); const supply = (over: Record = {}) => ({ assetId: USDC_SAC, @@ -90,10 +49,7 @@ const withSupply = (rows: unknown[]) => backstop: [], }) as never; -const twoAssetPositions = withSupply([ - supply(), - supply({ assetId: XLM_SAC, symbol: null, name: null, usdValue: 120.04 }), -]); +const onePosition = withSupply([supply()]); const pool = { id: POOL_ID, @@ -107,28 +63,6 @@ const pool = { reserves: [], } as never as BlendCatalogPool; -/** - * Mirrors `views/Account`'s `onDepositFromPosition` exactly — the same six - * dispatches plus the prefilled navigate — run here against the store - * `Wrapper` stashes (see `getTestStore`) rather than by importing the view, - * since that handler is a local function there, not an exported unit. - */ -const onDeposit = (row: PositionTokenRow, depositPool: BlendCatalogPool) => { - const store = getTestStore()!; - store.dispatch(resetSubmission()); - store.dispatch(saveEarnPool(depositPool)); - store.dispatch(saveSelectedAssetApy(row.apy)); - store.dispatch(saveSelectedAssetId(row.assetId)); - store.dispatch(saveAsset(getCanonicalFromAsset(row.code, row.issuer))); - store.dispatch(saveDestination(row.poolId)); - store.dispatch(saveIsToken(true)); - navigateTo( - ROUTES.earn, - mockNavigate, - `${EARN_PREFILL_QUERY}&${EARN_SOURCE_KEY}=${EARN_SOURCE.POSITION_ROW}`, - ); -}; - const renderTab = ( props: Partial>, ) => @@ -144,94 +78,41 @@ const renderTab = ( bestApy={null} onStartEarning={() => {}} pools={[]} - onDeposit={onDeposit} + onDeposit={() => {}} {...props} /> , ); +/** + * Covers the tab's route into My position through a tapped pool card. + * + * Before this task, this file exercised the tab->PoolDetailsSheet path + * directly (its own tabs, its Deposit button, the metrics that path fired). + * That content now lives one level down, inside My position, and My position + * only ships its shell in this task -- PoolDetailsSheet is nested inside it, + * and the deposit funnel re-pointed at the asset rows, starting in Task 4. + * Those assertions move there; what is left here is the part Task 3 actually + * owns: My position opens for the tapped pool, whether or not the catalog + * carries a matching entry for it. + */ describe("AccountPositions pool sheet", () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it("opens the pool sheet on Your position when a row is tapped", async () => { - renderTab({ positions: twoAssetPositions, pools: [pool] }); - - fireEvent.click(screen.getByTestId("position-row-USDC")); - - expect( - await screen.findByTestId("earn-pool-details-sheet"), - ).toBeInTheDocument(); - expect(screen.getByTestId("earn-position-panel")).toBeInTheDocument(); - expect(screen.getByTestId("earn-position-balance")).toHaveTextContent( - "$500.12", - ); - // Both the funnel's top-of-page tap and the sheet it opens are attributed - // to the Positions row that started this flow. - expect(trackPositionRowSelected).toHaveBeenCalledWith({ - poolId: POOL_ID, - protocol: "blend", - assetCode: "USDC", - }); - expect(trackEarnPoolDetailsOpened).toHaveBeenCalledWith({ - poolId: POOL_ID, - source: "position_row", - }); - }); - - it("agrees with the row on a personal-scale USD figure, shown in full rather than compact (I1)", async () => { - // formatCompactUsd goes compact at $1,000 ("$1.50K"), dropping cents from - // a personal balance -- see formatAccountUsd's docstring. The row and the - // sheet must read the exact same figure for the exact same position. - renderTab({ - positions: withSupply([supply({ usdValue: 1500 })]), - pools: [pool], - }); - - expect(screen.getByTestId("position-value-USDC")).toHaveTextContent( - "$1,500.00", - ); - - fireEvent.click(screen.getByTestId("position-row-USDC")); - - expect( - await screen.findByTestId("earn-position-balance"), - ).toHaveTextContent("$1,500.00"); - }); - - it("attributes a tab switch on the sheet to the position row that opened it", async () => { - renderTab({ positions: twoAssetPositions, pools: [pool] }); + it("opens My position when a pool card is tapped, with a matching catalog pool", async () => { + renderTab({ positions: onePosition, pools: [pool] }); - fireEvent.click(screen.getByTestId("position-row-USDC")); - fireEvent.click( - await screen.findByTestId("earn-pool-details-tab-overview"), - ); + fireEvent.click(screen.getByTestId(`pool-card-${POOL_ID}`)); - expect(trackEarnPoolDetailsTabSelected).toHaveBeenCalledWith({ - poolId: POOL_ID, - tab: "overview", - source: "position_row", - }); + expect(await screen.findByTestId("my-position-sheet")).toBeInTheDocument(); }); - it("prefills the deposit and lands on the amount screen", async () => { - renderTab({ positions: twoAssetPositions, pools: [pool] }); + it("still opens My position when the catalog has no matching pool", async () => { + // `pool` on MyPositionProps is `BlendCatalogPool | null` for exactly this + // reason: the position itself comes from `positions`, independent of + // whether the (separately fetched) catalog has caught up with it yet. + renderTab({ positions: onePosition, pools: [] }); - fireEvent.click(screen.getByTestId("position-row-USDC")); - fireEvent.click(await screen.findByText("Deposit")); + fireEvent.click(screen.getByTestId(`pool-card-${POOL_ID}`)); - // `Wrapper` stashes its store; getTestStore() is how the other suites read it. - const state = getTestStore()!.getState(); - expect(state.earn.selectedAssetId).toBe(USDC_SAC); - // `pool` is typed `BlendCatalogPool | null`; optional-chained rather than - // asserted so a regression fails on a clear mismatch, not a thrown TypeError. - expect(state.earn.pool?.id).toBe(POOL_ID); - expect(state.transactionSubmission.transactionData.destination).toBe( - POOL_ID, - ); - expect(mockNavigate).toHaveBeenCalledWith( - expect.stringContaining("/earn?prefill=1&source=position_row"), - ); + expect(await screen.findByTestId("my-position-sheet")).toBeInTheDocument(); }); }); diff --git a/extension/src/popup/components/account/AccountPositions/__tests__/AccountPositions.test.tsx b/extension/src/popup/components/account/AccountPositions/__tests__/AccountPositions.test.tsx index 742cb46d2f..48f995d534 100644 --- a/extension/src/popup/components/account/AccountPositions/__tests__/AccountPositions.test.tsx +++ b/extension/src/popup/components/account/AccountPositions/__tests__/AccountPositions.test.tsx @@ -1,19 +1,11 @@ import React from "react"; -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { MAINNET_NETWORK_DETAILS } from "@shared/constants/stellar"; +import { BlendCatalogPool } from "@shared/api/types/blend"; import { AccountPositions } from "popup/components/account/AccountPositions"; import { TEST_PUBLIC_KEY, Wrapper } from "popup/__testHelpers__"; -jest.mock("popup/metrics/positions", () => ({ - trackPositionRowSelected: jest.fn(), - trackPositionsEmptyCtaSelected: jest.fn(), -})); - -const { trackPositionRowSelected } = jest.requireMock< - typeof import("popup/metrics/positions") ->("popup/metrics/positions"); - const POOL_ID = "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD"; const USDC_SAC = "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75"; const XLM_SAC = "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA"; @@ -61,7 +53,6 @@ const twoAssetPositions = withSupply([ supply(), supply({ assetId: XLM_SAC, symbol: null, name: null, usdValue: 120.04 }), ]); -const unpricedPositions = withSupply([supply({ usdValue: null })]); const noPositions = { address: TEST_PUBLIC_KEY, @@ -71,15 +62,17 @@ const noPositions = { backstop: [], } as never; -// The null/zero/positive boundary on interestEarnedUsd: a positive figure is -// a real gain (colored), a flat zero is real but not a gain (shown, not -// colored), and null is unavailable (shown as --, not colored either). -const positiveGainPositions = withSupply([supply()]); -const zeroGainPositions = withSupply([supply({ interestEarnedUsd: 0 })]); -const unavailableGainPositions = withSupply([ - supply({ interestEarnedUsd: null }), -]); -const unavailableRatePositions = withSupply([supply({ apy: null })]); +const pool = { + id: POOL_ID, + name: "Fixed Pool v2", + status: "ACTIVE", + suppliedUsd: 50050000, + borrowedUsd: 16150000, + interestApy: 0.0424, + netApy: 0.1694, + backstopUsd: 1530000, + reserves: [], +} as never as BlendCatalogPool; const renderTab = ( props: Partial>, @@ -157,76 +150,37 @@ describe("AccountPositions", () => { ).not.toBeInTheDocument(); }); - it("renders one row per supplied token", () => { + it("renders one card per pool, not per supplied token", () => { + // twoAssetPositions holds ONE pool with TWO supplied assets. The old tab + // showed two rows; the pool-shaped tab shows one card. + // + // PoolCard (Task 2) also carries `pool-card-value-*`, `pool-card-gain-*` + // and `pool-card-apy-*` child testids, all of which start with + // "pool-card-" too -- so the card root is matched by its id ending + // exactly at the pool id, not merely starting with the shared prefix. renderTab({ positions: twoAssetPositions }); - expect(screen.getByTestId("position-row-USDC")).toBeInTheDocument(); - expect(screen.getByTestId("position-row-XLM")).toBeInTheDocument(); + expect(screen.getAllByTestId(/^pool-card-[A-Z0-9]+$/)).toHaveLength(1); + expect(screen.queryByTestId("position-row-USDC")).not.toBeInTheDocument(); }); - it("reports a row tap with the pool it belongs to", () => { - // The sheet itself opens from AccountPositions' own state (see - // AccountPositions.sheet.test.tsx); this only covers the analytics side of - // the same tap. - renderTab({ positions: twoAssetPositions }); - - fireEvent.click(screen.getByTestId("position-row-USDC")); + it("opens My position when a pool card is tapped", async () => { + renderTab({ positions: twoAssetPositions, pools: [pool] }); - expect(trackPositionRowSelected).toHaveBeenCalledWith({ - poolId: POOL_ID, - protocol: "blend", - assetCode: "USDC", - }); - }); - - it("renders an unavailable value as -- rather than zero", () => { - renderTab({ positions: unpricedPositions }); + fireEvent.click(screen.getByTestId(`pool-card-${POOL_ID}`)); - expect(screen.getByTestId("position-value-USDC")).toHaveTextContent("--"); + expect(await screen.findByTestId("my-position-sheet")).toBeInTheDocument(); }); - it("colors a real interest gain and shows the amount", () => { - renderTab({ positions: positiveGainPositions }); - - const gain = screen.getByTestId("position-gain-USDC"); - expect(gain).toHaveClass("PositionRow__gain--positive"); - expect(gain).toHaveTextContent("+$0.12"); - }); + it("closes My position from its X without leaving the tab", async () => { + renderTab({ positions: twoAssetPositions, pools: [pool] }); - it("shows a flat-zero interest gain without coloring it like a real gain", () => { - renderTab({ positions: zeroGainPositions }); + fireEvent.click(screen.getByTestId(`pool-card-${POOL_ID}`)); + fireEvent.click(await screen.findByTestId("my-position-close")); - const gain = screen.getByTestId("position-gain-USDC"); - expect(gain).not.toHaveClass("PositionRow__gain--positive"); - expect(gain).toHaveTextContent("+$0.00"); - }); - - it("renders an unavailable interest gain as -- without coloring it", () => { - renderTab({ positions: unavailableGainPositions }); - - const gain = screen.getByTestId("position-gain-USDC"); - expect(gain).not.toHaveClass("PositionRow__gain--positive"); - expect(gain).toHaveTextContent("--"); - }); - - it("routes the rate line through translation (not the bare fallback) when the rate is available", () => { - renderTab({ positions: positiveGainPositions }); - - // react-i18next's t() is mocked in this test env to echo its key - // untouched (config/jest/setupTests.tsx) rather than interpolate -- - // formatRate's own tests cover the actual "16.94%" formatting. This just - // proves the available-rate branch (through t()) is taken instead of the - // bare "--" fallback. - expect(screen.getByTestId("position-apy-USDC")).toHaveTextContent( - "{{rate}} APY", + await waitFor(() => + expect(screen.queryByTestId("my-position-sheet")).not.toBeInTheDocument(), ); - }); - - it("renders the rate line as a bare -- when the rate is unavailable, not '-- APY'", () => { - renderTab({ positions: unavailableRatePositions }); - - const apy = screen.getByTestId("position-apy-USDC"); - expect(apy).toHaveTextContent("--"); - expect(apy).not.toHaveTextContent("APY"); + expect(screen.getByTestId(`pool-card-${POOL_ID}`)).toBeInTheDocument(); }); }); diff --git a/extension/src/popup/components/account/AccountPositions/index.tsx b/extension/src/popup/components/account/AccountPositions/index.tsx index 64cf9d97b1..4f1101de44 100644 --- a/extension/src/popup/components/account/AccountPositions/index.tsx +++ b/extension/src/popup/components/account/AccountPositions/index.tsx @@ -9,19 +9,15 @@ import { import { AssetIcons } from "@shared/api/types"; import { NetworkDetails } from "@shared/constants/stellar"; +import { PositionTokenRow } from "popup/components/earn/helpers/positionRows"; import { - PositionTokenRow, - toPositionTokenRows, -} from "popup/components/earn/helpers/positionRows"; -import { PoolDetailsSheet } from "popup/components/earn/PoolDetailsSheet"; -import { SlideupModal } from "popup/components/SlideupModal"; -import { EARN_SOURCE } from "popup/constants/earn"; -import { - trackEarnPoolDetailsOpened, - trackEarnPoolDetailsTabSelected, -} from "popup/metrics/earn"; -import { trackPositionRowSelected } from "popup/metrics/positions"; -import { PositionRow } from "./PositionRow"; + Sheet, + SheetContent, + SheetTitle, + ScreenReaderOnly, +} from "popup/basics/shadcn/Sheet"; +import { PoolCard } from "./PoolCard"; +import { MyPosition } from "./MyPosition"; import { EmptyState } from "./EmptyState"; import "./styles.scss"; @@ -44,7 +40,7 @@ interface AccountPositionsProps { bestApy: number | null; /** Empty state's CTA was pressed, before it navigates to Earn. */ onStartEarning: () => void; - /** The catalog backing the pool-details sheet opened from a tapped row. */ + /** The catalog backing the pool-details sheet My position can open. */ pools: BlendCatalogPool[]; /** Deposit tapped inside that sheet, with the row and its resolved pool. */ onDeposit: (row: PositionTokenRow, pool: BlendCatalogPool) => void; @@ -61,6 +57,10 @@ interface AccountPositionsProps { * The error renders inside the pane rather than through the view's * `AccountView__fetch-fail` notification, which sits above the slider and would * push a banner across the Tokens and Collectibles tabs too. + * + * Lists one card per pool (Task 3) rather than one row per supplied token -- + * the token-shaped rows now live one level down, inside the My position sheet + * a tapped card opens (Task 4). */ export const AccountPositions = ({ positions, @@ -72,13 +72,16 @@ export const AccountPositions = ({ bestApy, onStartEarning, pools, - onDeposit, + // Unused for now: the deposit funnel it feeds is wired up from inside + // My position (Task 4), not this tab. Kept on the props (see + // AccountPositionsProps) rather than destructured, so it isn't flagged as + // an unused binding under this repo's noUnusedParameters. }: AccountPositionsProps) => { const { t } = useTranslation(); - // Which row's pool sheet is open, if any. Declared unconditionally, ahead of - // the early returns below, because Rules of Hooks forbids a hook call that - // only some renders reach. - const [selected, setSelected] = useState(null); + // Which pool's My position sheet is open, if any. Declared unconditionally, + // ahead of the early returns below, because Rules of Hooks forbids a hook + // call that only some renders reach. + const [selectedPoolId, setSelectedPoolId] = useState(null); if (isLoading) { return ( @@ -111,9 +114,9 @@ export const AccountPositions = ({ ); } - const rows = toPositionTokenRows({ positions, networkDetails }); + const poolPositions = positions?.positions ?? []; - if (!rows.length) { + if (!poolPositions.length) { return (
p.id === selected?.poolId) ?? null; const selectedPosition = - positions?.positions.find((p) => p.id === selected?.poolId) ?? null; + poolPositions.find((p) => p.id === selectedPoolId) ?? null; + const selectedPool = pools.find((p) => p.id === selectedPoolId) ?? null; return (
- {rows.map((row) => ( - { - trackPositionRowSelected({ - poolId: row.poolId, - protocol: row.protocol, - assetCode: row.code, - }); - trackEarnPoolDetailsOpened({ - poolId: row.poolId, - source: EARN_SOURCE.POSITION_ROW, - }); - setSelected(row); - }} + {poolPositions.map((position) => ( + setSelectedPoolId(position.id)} /> ))}
- setSelected(null)} - hasBackdrop + !open && setSelectedPoolId(null)} > - {selected && selectedPool ? ( - setSelected(null)} - onDeposit={() => onDeposit(selected, selectedPool)} - onTabChange={(tab) => - trackEarnPoolDetailsTabSelected({ - poolId: selectedPool.id, - tab, - source: EARN_SOURCE.POSITION_ROW, - }) - } - /> - ) : ( -
- )} - + e.preventDefault()} + aria-describedby={undefined} + side="bottom" + className="AccountPositions__my-position__wrapper" + > + + {t("My position")} + + {selectedPosition && ( + setSelectedPoolId(null)} + /> + )} + +
); }; diff --git a/extension/src/popup/components/account/AccountPositions/styles.scss b/extension/src/popup/components/account/AccountPositions/styles.scss index f2c71a4646..bb230d2af0 100644 --- a/extension/src/popup/components/account/AccountPositions/styles.scss +++ b/extension/src/popup/components/account/AccountPositions/styles.scss @@ -162,6 +162,15 @@ display: flex; flex-direction: column; } + + // Mirrors `AccountAssets__asset-detail__wrapper` exactly (same SheetContent + // host, same need for an opaque background over whatever the tab underneath + // is showing) -- see that file for the donor rule. + &__my-position__wrapper { + background: var(--sds-clr-gray-01); + height: 100%; + width: 100%; + } } // PositionRow has no existing Figma sibling (unlike the states above, which diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index e413d62d7c..c2b658d98b 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -435,6 +435,7 @@ "must be below": "must be below", "Muxed address not supported": "Muxed address not supported", "My Accounts": "My Accounts", + "My position": "", "Name": "Name", "Net APY": "Net APY", "Network": "Network", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index cd4adcdc7f..ac631ac71b 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -435,6 +435,7 @@ "must be below": "deve estar abaixo de", "Muxed address not supported": "Endereço muxed não suportado", "My Accounts": "Minhas Contas", + "My position": "", "Name": "Nome", "Net APY": "APY líquida", "Network": "Rede", From 9dd62e1d301b40a87710babba50f4e627ffb9339 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 26 Aug 2026 15:01:59 -0700 Subject: [PATCH 32/49] fix(home): translate the My position title and cover PositionRow directly --- .../__tests__/PositionRow.test.tsx | 100 ++++++++++++++++++ .../src/popup/locales/en/translation.json | 2 +- .../src/popup/locales/pt/translation.json | 2 +- 3 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 extension/src/popup/components/account/AccountPositions/__tests__/PositionRow.test.tsx diff --git a/extension/src/popup/components/account/AccountPositions/__tests__/PositionRow.test.tsx b/extension/src/popup/components/account/AccountPositions/__tests__/PositionRow.test.tsx new file mode 100644 index 0000000000..efc26c502d --- /dev/null +++ b/extension/src/popup/components/account/AccountPositions/__tests__/PositionRow.test.tsx @@ -0,0 +1,100 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; + +import { PositionTokenRow } from "popup/components/earn/helpers/positionRows"; +import { PositionRow } from "popup/components/account/AccountPositions/PositionRow"; +import { Wrapper } from "popup/__testHelpers__"; + +const row = (over: Partial = {}): PositionTokenRow => ({ + poolId: "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD", + poolName: "Fixed Pool v2", + protocol: "blend", + assetId: "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75", + code: "USDC", + issuer: "GCK3D3V2XNLLKRFGFFFDEJXA4O2J4X36HET2FE446AV3M4U7DPHO3PEM", + decimals: 7, + suppliedTokens: "500.12", + suppliedUsd: 500.12, + apy: 0.1694, + interestEarnedUsd: 0.12, + ...over, +}); + +const renderRow = ( + props: Partial> = {}, +) => + render( + + {}} {...props} /> + , + ); + +/** + * PositionRow (moved out of the tab in Task 3, but left untouched -- it + * relocates into MyPosition in Task 4) previously had this coverage only + * through AccountPositions.test.tsx, exercised via the tab. Removing the + * token-row tab tests dropped that coverage along with the DOM they asserted + * on. Reinstated here directly against PositionRow, which is simpler than + * the old tab-level setup and doesn't depend on what wraps it. + */ +describe("PositionRow", () => { + it("renders an unavailable value as -- rather than zero", () => { + renderRow({ row: row({ suppliedUsd: null }) }); + + expect(screen.getByTestId("position-value-USDC")).toHaveTextContent("--"); + }); + + it("colors a real interest gain and shows the amount", () => { + renderRow({ row: row({ interestEarnedUsd: 0.12 }) }); + + const gain = screen.getByTestId("position-gain-USDC"); + expect(gain).toHaveClass("PositionRow__gain--positive"); + expect(gain).toHaveTextContent("+$0.12"); + }); + + it("shows a flat-zero interest gain without coloring it like a real gain", () => { + renderRow({ row: row({ interestEarnedUsd: 0 }) }); + + const gain = screen.getByTestId("position-gain-USDC"); + expect(gain).not.toHaveClass("PositionRow__gain--positive"); + expect(gain).toHaveTextContent("+$0.00"); + }); + + it("renders an unavailable interest gain as -- without coloring it", () => { + renderRow({ row: row({ interestEarnedUsd: null }) }); + + const gain = screen.getByTestId("position-gain-USDC"); + expect(gain).not.toHaveClass("PositionRow__gain--positive"); + expect(gain).toHaveTextContent("--"); + }); + + it("routes the rate line through translation (not the bare fallback) when the rate is available", () => { + renderRow({ row: row({ apy: 0.1694 }) }); + + // react-i18next's t() is mocked in this test env to echo its key + // untouched (config/jest/setupTests.tsx) rather than interpolate -- + // formatRate's own tests cover the actual "16.94%" formatting. This just + // proves the available-rate branch (through t()) is taken instead of the + // bare "--" fallback. + expect(screen.getByTestId("position-apy-USDC")).toHaveTextContent( + "{{rate}} APY", + ); + }); + + it("renders the rate line as a bare -- when the rate is unavailable, not '-- APY'", () => { + renderRow({ row: row({ apy: null }) }); + + const apy = screen.getByTestId("position-apy-USDC"); + expect(apy).toHaveTextContent("--"); + expect(apy).not.toHaveTextContent("APY"); + }); + + it("still fires its click handler, since it moves into My position as a tappable row", () => { + const onClick = jest.fn(); + renderRow({ onClick }); + + fireEvent.click(screen.getByTestId("position-row-USDC")); + + expect(onClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index c2b658d98b..62a3ae0037 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -435,7 +435,7 @@ "must be below": "must be below", "Muxed address not supported": "Muxed address not supported", "My Accounts": "My Accounts", - "My position": "", + "My position": "My position", "Name": "Name", "Net APY": "Net APY", "Network": "Network", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index ac631ac71b..9a09a1f4ae 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -435,7 +435,7 @@ "must be below": "deve estar abaixo de", "Muxed address not supported": "Endereço muxed não suportado", "My Accounts": "Minhas Contas", - "My position": "", + "My position": "Minha posição", "Name": "Nome", "Net APY": "APY líquida", "Network": "Rede", From 6ba27d44c4d1cafa398d66d54793d472993600d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 26 Aug 2026 15:12:25 -0700 Subject: [PATCH 33/49] feat(home): show a pool's totals and supplied tokens --- .../account/AccountPositions/MyPosition.tsx | 101 +++++++++++++-- .../account/AccountPositions/PositionRow.tsx | 41 ++---- .../__tests__/MyPosition.test.tsx | 122 ++++++++++++++++++ .../__tests__/PositionRow.test.tsx | 34 +---- .../account/AccountPositions/index.tsx | 4 + .../account/AccountPositions/styles.scss | 119 ++++++++++++----- .../src/popup/locales/en/translation.json | 2 + .../src/popup/locales/pt/translation.json | 2 + 8 files changed, 328 insertions(+), 97 deletions(-) create mode 100644 extension/src/popup/components/account/AccountPositions/__tests__/MyPosition.test.tsx diff --git a/extension/src/popup/components/account/AccountPositions/MyPosition.tsx b/extension/src/popup/components/account/AccountPositions/MyPosition.tsx index 1b42f0e70d..8abe715ea7 100644 --- a/extension/src/popup/components/account/AccountPositions/MyPosition.tsx +++ b/extension/src/popup/components/account/AccountPositions/MyPosition.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { Icon } from "@stellar/design-system"; +import { Icon, Text } from "@stellar/design-system"; import { useTranslation } from "react-i18next"; import { AssetIcons } from "@shared/api/types"; @@ -7,6 +7,18 @@ import { BlendCatalogPool, PoolPosition } from "@shared/api/types/blend"; import { NetworkDetails } from "@shared/constants/stellar"; import { SubviewHeader } from "popup/components/SubviewHeader"; import { View } from "popup/basics/layout/View"; +import { PoolIcon } from "popup/components/earn/PoolIcon"; +import { + formatAccountUsd, + formatRate, +} from "popup/components/earn/helpers/formatPoolStats"; +import { getPoolPositionSummary } from "popup/components/earn/helpers/poolPositionSummary"; +import { + PositionTokenRow, + toPositionTokenRows, +} from "popup/components/earn/helpers/positionRows"; +import { NO_FIAT_VALUE } from "popup/helpers/formatters"; +import { PositionRow } from "./PositionRow"; interface MyPositionProps { position: PoolPosition; @@ -15,6 +27,10 @@ interface MyPositionProps { assetIcons: AssetIcons; networkDetails: NetworkDetails; onClose: () => void; + /** "About pool" tapped -- opens the sheet with tabs hidden. */ + onAboutPool: () => void; + /** A supplied-asset row tapped -- opens the sheet on Your position. */ + onSelectAsset: (row: PositionTokenRow) => void; } /** @@ -24,14 +40,38 @@ interface MyPositionProps { * Tokens tab opens AssetDetail — so the tab stays mounted underneath and * dismissal costs nothing. An X rather than a back arrow (R3), matching AssetDetail * and CollectibleDetail. + * + * The totals header uses `getPoolPositionSummary` (pool-scoped: this + * account's total and lifetime interest across the whole pool), while the + * supplied-asset list below it uses `toPositionTokenRows` (token-scoped: one + * row per asset, no gain column — that figure lives here in the header + * instead, so it is never shown twice). */ -// `position`, `pool`, `assetIcons` and `networkDetails` are part of the public -// interface Task 4 fills the body with, but this task's shell has nothing to -// do with them yet -- destructuring only `onClose` here (rather than the -// brief's full destructure) avoids a `noUnusedParameters` build failure on -// bindings this task never reads. -export const MyPosition = ({ onClose }: MyPositionProps) => { +// `pool` is part of the public interface -- Task 5 threads it through +// `onAboutPool`'s caller to open the pool-details sheet -- but this +// component's own body never reads it directly, so it is intentionally left +// off the destructure (rather than bound and unused) to satisfy this repo's +// noUnusedLocals build check. +export const MyPosition = ({ + position, + assetIcons, + networkDetails, + onClose, + onAboutPool, + onSelectAsset, +}: MyPositionProps) => { const { t } = useTranslation(); + const summary = getPoolPositionSummary(position); + const rows = toPositionTokenRows({ + positions: { + address: "", + totalValueUsd: null, + netApy: null, + positions: [position], + backstop: [], + }, + networkDetails, + }); return ( @@ -41,9 +81,52 @@ export const MyPosition = ({ onClose }: MyPositionProps) => { customBackAction={onClose} data-testid="my-position-close" /> - {/* Totals, About pool and the supplied-asset list arrive in Task 4. */} -
+
+
+
+ + + {t("Blend")} + +
+ +
+ + + {position.name || t("Blend pool")} + +
+ {formatAccountUsd(summary.totalUsd)} +
+
+ {summary.interestUsd === null || summary.gainPercent === null + ? NO_FIAT_VALUE + : `+${formatAccountUsd(summary.interestUsd)} (${formatRate(summary.gainPercent)})`} +
+ + + {t("Supplied asset")} + +
+ {rows.map((row) => ( + onSelectAsset(row)} + /> + ))} +
+
); diff --git a/extension/src/popup/components/account/AccountPositions/PositionRow.tsx b/extension/src/popup/components/account/AccountPositions/PositionRow.tsx index 3e552d44b4..4136e0b612 100644 --- a/extension/src/popup/components/account/AccountPositions/PositionRow.tsx +++ b/extension/src/popup/components/account/AccountPositions/PositionRow.tsx @@ -17,24 +17,17 @@ interface PositionRowProps { } /** - * One supplied token on the Positions tab. + * One supplied token, shown inside the My position sheet. * * Wraps BalanceRow rather than restating it, so icon resolution, the XLM - * display name, code truncation and the click affordance all stay in one place. - * Both of the row's coloured figures go through slots: the rate under the code - * via `amountSlot`, the value and gain via `rightSlot`. + * display name, code truncation and the click affordance all stay in one + * place. The rate goes through `amountSlot`; the value alone goes through + * `rightSlot` — the interest-gain figure that used to sit beside it here now + * lives one level up, in the My position header, so showing it at both + * levels would invite the reader to add the two together. */ export const PositionRow = ({ row, assetIcons, onClick }: PositionRowProps) => { const { t } = useTranslation(); - // True once a real interest figure exists, including a literal zero -- this - // means "the value is known", not "there was a gain". Drives the +$X vs -- - // text only; color is the separate, stricter check below. - const isGainKnown = row.interestEarnedUsd !== null; - // Mirrors BalanceRow__delta's own rule: a flat zero is real but is not a - // gain, so it stays neutral like the unavailable case — only a strictly - // positive figure earns the green. - const isPositiveGain = - row.interestEarnedUsd !== null && row.interestEarnedUsd > 0; return ( { } rightSlot={ - <> -
- {formatAccountUsd(row.suppliedUsd)} -
-
- {isGainKnown - ? `+${formatAccountUsd(row.interestEarnedUsd)}` - : NO_FIAT_VALUE} -
- +
+ {formatAccountUsd(row.suppliedUsd)} +
} onClick={onClick} data-testid={`position-row-${row.code}`} diff --git a/extension/src/popup/components/account/AccountPositions/__tests__/MyPosition.test.tsx b/extension/src/popup/components/account/AccountPositions/__tests__/MyPosition.test.tsx new file mode 100644 index 0000000000..04bbc080c3 --- /dev/null +++ b/extension/src/popup/components/account/AccountPositions/__tests__/MyPosition.test.tsx @@ -0,0 +1,122 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; + +import { MAINNET_NETWORK_DETAILS } from "@shared/constants/stellar"; +import { MyPosition } from "popup/components/account/AccountPositions/MyPosition"; +import { Wrapper } from "popup/__testHelpers__"; + +const USDC_SAC = "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75"; +const XLM_SAC = "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA"; + +const row = ( + assetId: string, + symbol: string | null, + usdValue: number, + interest: number | null, +) => ({ + assetId, + symbol, + name: symbol + ? `${symbol}:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN` + : null, + decimals: 7, + suppliedTokens: "0", + collateralTokens: "0", + totalTokens: "5000000000", + usdValue, + apy: 0.1694, + emissionsApr: 0, + interestEarned: "0", + interestEarnedUsd: interest, + claimableBlnd: "0", + claimableUsd: null, + priceUsd: 1, +}); + +const position = { + protocol: "blend", + id: "CAJJ", + name: "Fixed Pool v2", + netUsd: 1284.32, + suppliedUsd: 1284.32, + borrowedUsd: 0, + netApy: 0.1023, + blend: { + supply: [ + row(XLM_SAC, null, 784.2, 20.0), + row(USDC_SAC, "USDC", 500.12, 11.75), + ], + borrow: [], + }, +} as never; + +const renderScreen = (props = {}) => + render( + + + , + ); + +describe("MyPosition", () => { + it("shows the pool's total, its absolute gain and the relative gain", () => { + renderScreen(); + + expect(screen.getByTestId("my-position-total")).toHaveTextContent( + "$1,284.32", + ); + // 31.75 interest on 1252.57 principal = 31.75 / 1252.57 = 2.5348%, which + // formatRate's BigNumber().toFormat(2) rounds to 2.53% -- not the 2.54% + // the task brief's own comment claimed; verified directly against + // getPoolPositionSummary + formatRate rather than taken on faith. + expect(screen.getByTestId("my-position-gain")).toHaveTextContent("31.75"); + expect(screen.getByTestId("my-position-gain")).toHaveTextContent("2.53%"); + }); + + it("lists one row per supplied asset, resolving native XLM's missing symbol", () => { + renderScreen(); + + expect(screen.getByTestId("position-row-XLM")).toBeInTheDocument(); + expect(screen.getByTestId("position-row-USDC")).toBeInTheDocument(); + }); + + it("no longer shows a per-asset gain column", () => { + // The gain moved up to the pool-level header; showing it twice would + // invite the reader to add the two together. + renderScreen(); + + expect(screen.queryByTestId("position-gain-USDC")).not.toBeInTheDocument(); + }); + + it("reports About pool and asset taps to its caller", () => { + const onAboutPool = jest.fn(); + const onSelectAsset = jest.fn(); + renderScreen({ onAboutPool, onSelectAsset }); + + fireEvent.click(screen.getByTestId("my-position-about-pool")); + expect(onAboutPool).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByTestId("position-row-USDC")); + expect(onSelectAsset).toHaveBeenCalledWith( + expect.objectContaining({ code: "USDC", assetId: USDC_SAC }), + ); + }); + + it("renders an unavailable gain as the placeholder rather than zero", () => { + const unpriced = { + ...(position as unknown as Record), + blend: { supply: [row(USDC_SAC, "USDC", 500.12, null)], borrow: [] }, + } as never; + renderScreen({ position: unpriced }); + + expect(screen.getByTestId("my-position-gain")).toHaveTextContent("--"); + }); +}); diff --git a/extension/src/popup/components/account/AccountPositions/__tests__/PositionRow.test.tsx b/extension/src/popup/components/account/AccountPositions/__tests__/PositionRow.test.tsx index efc26c502d..c49152ec64 100644 --- a/extension/src/popup/components/account/AccountPositions/__tests__/PositionRow.test.tsx +++ b/extension/src/popup/components/account/AccountPositions/__tests__/PositionRow.test.tsx @@ -30,12 +30,10 @@ const renderRow = ( ); /** - * PositionRow (moved out of the tab in Task 3, but left untouched -- it - * relocates into MyPosition in Task 4) previously had this coverage only - * through AccountPositions.test.tsx, exercised via the tab. Removing the - * token-row tab tests dropped that coverage along with the DOM they asserted - * on. Reinstated here directly against PositionRow, which is simpler than - * the old tab-level setup and doesn't depend on what wraps it. + * PositionRow (moved out of the tab in Task 3) now relocates into MyPosition + * (Task 4), which drops the interest-gain column -- that figure now lives in + * MyPosition's own pool-level header instead, covered by MyPosition.test.tsx. + * The value, rate and click-handler coverage below still applies unchanged. */ describe("PositionRow", () => { it("renders an unavailable value as -- rather than zero", () => { @@ -44,30 +42,6 @@ describe("PositionRow", () => { expect(screen.getByTestId("position-value-USDC")).toHaveTextContent("--"); }); - it("colors a real interest gain and shows the amount", () => { - renderRow({ row: row({ interestEarnedUsd: 0.12 }) }); - - const gain = screen.getByTestId("position-gain-USDC"); - expect(gain).toHaveClass("PositionRow__gain--positive"); - expect(gain).toHaveTextContent("+$0.12"); - }); - - it("shows a flat-zero interest gain without coloring it like a real gain", () => { - renderRow({ row: row({ interestEarnedUsd: 0 }) }); - - const gain = screen.getByTestId("position-gain-USDC"); - expect(gain).not.toHaveClass("PositionRow__gain--positive"); - expect(gain).toHaveTextContent("+$0.00"); - }); - - it("renders an unavailable interest gain as -- without coloring it", () => { - renderRow({ row: row({ interestEarnedUsd: null }) }); - - const gain = screen.getByTestId("position-gain-USDC"); - expect(gain).not.toHaveClass("PositionRow__gain--positive"); - expect(gain).toHaveTextContent("--"); - }); - it("routes the rate line through translation (not the bare fallback) when the rate is available", () => { renderRow({ row: row({ apy: 0.1694 }) }); diff --git a/extension/src/popup/components/account/AccountPositions/index.tsx b/extension/src/popup/components/account/AccountPositions/index.tsx index 4f1101de44..185b8db433 100644 --- a/extension/src/popup/components/account/AccountPositions/index.tsx +++ b/extension/src/popup/components/account/AccountPositions/index.tsx @@ -167,6 +167,10 @@ export const AccountPositions = ({ assetIcons={assetIcons} networkDetails={networkDetails} onClose={() => setSelectedPoolId(null)} + // Task 5 wires these to the pool-details sheet; this task only + // has to keep MyPosition's now-required props satisfied. + onAboutPool={() => {}} + onSelectAsset={() => {}} /> )} diff --git a/extension/src/popup/components/account/AccountPositions/styles.scss b/extension/src/popup/components/account/AccountPositions/styles.scss index bb230d2af0..179706c7e6 100644 --- a/extension/src/popup/components/account/AccountPositions/styles.scss +++ b/extension/src/popup/components/account/AccountPositions/styles.scss @@ -174,13 +174,12 @@ } // PositionRow has no existing Figma sibling (unlike the states above, which -// mirror AccountCollectibles): it wraps BalanceRow and colours the two figures -// Task 5's states never needed. No node could be pulled for this pass, so -// nothing here is a read-off value -- every size/weight is inherited from or -// copied off BalanceRow/styles.scss, and both greens are tokens already used -// elsewhere for a gain (BalanceRow__delta.positive, EarnStatRow__value--positive). -// A later visual pass should confirm placement, size and which of the two -// greens belongs on which figure against the real node. +// mirror AccountCollectibles): it wraps BalanceRow and colours its rate figure +// green. No node could be pulled for this pass, so nothing here is a read-off +// value -- every size/weight is inherited from or copied off +// BalanceRow/styles.scss, and the green is a token already used elsewhere for +// a gain (BalanceRow__delta.positive, EarnStatRow__value--positive). A later +// visual pass should confirm placement and size against the real node. .PositionRow { // Sits inside BalanceRow's own `&__amount` div (the `amountSlot` prop), so // font-size/line-height/font-weight already come from `.BalanceRow__amount` @@ -191,35 +190,20 @@ color: var(--sds-clr-green-09); } - // Replaces BalanceRow's default right-side pair (`rightSlot`), so unlike - // `&__apy` above these get no styling for free and repeat their donor - // rule's size/weight explicitly. + // Replaces BalanceRow's default right-side slot (`rightSlot`), so unlike + // `&__apy` above this gets no styling for free and repeats its donor + // rule's size/weight explicitly. The interest-gain figure that used to sit + // beside this (a second, coloured `rightSlot` line) moved up to MyPosition's + // own header (Task 4) -- see that file's `&__gain` for its replacement. - // Primary right-side line, matching `.BalanceRow__fiat`. Not one of the two - // coloured figures -- a supplied total, not a gain -- so it stays neutral. + // Primary (now only) right-side line, matching `.BalanceRow__fiat`. Not a + // coloured figure -- a supplied total, not a gain -- so it stays neutral. &__value { font-size: pxToRem(14px); line-height: pxToRem(20px); font-weight: var(--sds-fw-medium); color: var(--sds-clr-gray-12); } - - // Secondary right-side line, matching `.BalanceRow__delta`'s size/weight - // AND its positive/neutral split: gray by default, green only for a - // strictly positive figure -- an unavailable "--" or a flat zero must not - // read as a gain, same as a zero % delta staying gray there. Colored like - // `EarnStatRow__value--positive` rather than `&__apy`'s shade: this is an - // earned-interest *value*, not a rate. - &__gain { - font-size: pxToRem(12px); - line-height: pxToRem(18px); - font-weight: var(--sds-fw-medium); - color: var(--sds-clr-gray-11); - - &--positive { - color: var(--sds-clr-green-11); - } - } } // Task 2 (PoolCard) -- the Positions tab's new one-row-per-pool card, Figma @@ -272,3 +256,80 @@ font: inherit; color: inherit; } + +// Task 4 (MyPosition) -- the sheet's body: totals header, "About pool" pill, +// and the relocated supplied-asset list. Figma node 9956-72252. This pass's +// desktop app had a different document as its active tab (confirmed by the +// operator, not just a retry failure), so `get_design_context` was never +// attempted and no node here was read off the design at all -- unlike the +// PositionRow/PositionsPoolCard blocks above, which at least got the +// existing donor rules or a documented failed-retry history. +// +// Every rule below is therefore structural only: flex direction, gap +// direction (row vs column) and which piece sits on which side -- no padding, +// radius, background, type scale, or colour beyond what a later visual pass +// will need to fill in wholesale. A follow-up MUST pull 9956-72252 (the Blend +// chip, the "About pool" pill, the totals type scale, the gain colour, and +// the supplied-asset card treatment) and reconcile every value here, the same +// way Task 2/3's own TODO comments describe for their nodes. +.MyPosition { + display: flex; + flex-direction: column; + + &__identity { + display: flex; + align-items: center; + justify-content: space-between; + } + + // The Blend protocol chip (icon + "Blend" label). Row layout only -- + // spacing between the icon and label needs the real node. + &__chip { + display: flex; + align-items: center; + } + + // The "About pool" pill -- reset to a plain inline button (no native + // border/background) so it reads as a tappable link/pill rather than a + // default `
); From fb14e9384d664ef7d5ff65cf0c8dd1fe02ac2198 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 26 Aug 2026 15:40:00 -0700 Subject: [PATCH 35/49] feat(earn): gate every deposit entry point behind the earn flag --- .../account/AccountPositions/EmptyState.tsx | 48 +++++++++++-------- .../__tests__/AccountPositions.test.tsx | 37 ++++++++++++-- .../__tests__/FloatingAddButton.test.tsx | 42 +++++++++++----- .../account/FloatingAddButton/index.tsx | 33 ++++++++++--- .../__tests__/PoolDetailsSheet.tabs.test.tsx | 26 +++++++++- .../earn/PoolDetailsSheet/index.tsx | 18 ++++++- 6 files changed, 157 insertions(+), 47 deletions(-) diff --git a/extension/src/popup/components/account/AccountPositions/EmptyState.tsx b/extension/src/popup/components/account/AccountPositions/EmptyState.tsx index 212f6dc8de..150d403515 100644 --- a/extension/src/popup/components/account/AccountPositions/EmptyState.tsx +++ b/extension/src/popup/components/account/AccountPositions/EmptyState.tsx @@ -1,10 +1,14 @@ import React from "react"; import { Button } from "@stellar/design-system"; import { useNavigate } from "react-router-dom"; +import { useSelector } from "react-redux"; import { useTranslation } from "react-i18next"; +import { isEarnSupportedNetwork } from "@shared/constants/blend"; import { ROUTES } from "popup/constants/routes"; import { EARN_SOURCE, EARN_SOURCE_KEY } from "popup/constants/earn"; +import { earnDepositSelector } from "popup/ducks/remoteConfig"; +import { settingsNetworkDetailsSelector } from "popup/ducks/settings"; import { navigateTo } from "popup/helpers/navigate"; import { formatRate } from "popup/components/earn/helpers/formatPoolStats"; import { formatAmount } from "popup/helpers/formatters"; @@ -38,6 +42,10 @@ export const EmptyState = ({ }: EmptyStateProps) => { const { t } = useTranslation(); const navigate = useNavigate(); + const isEarnDepositEnabled = useSelector(earnDepositSelector); + const networkDetails = useSelector(settingsNetworkDetailsSelector); + const canDeposit = + isEarnDepositEnabled && isEarnSupportedNetwork(networkDetails); const hasProjection = projectedUsd !== null || bestApy !== null; return ( @@ -73,25 +81,27 @@ export const EmptyState = ({
)} -
- -
+ {canDeposit && ( +
+ +
+ )}
); }; diff --git a/extension/src/popup/components/account/AccountPositions/__tests__/AccountPositions.test.tsx b/extension/src/popup/components/account/AccountPositions/__tests__/AccountPositions.test.tsx index 48f995d534..add79d1291 100644 --- a/extension/src/popup/components/account/AccountPositions/__tests__/AccountPositions.test.tsx +++ b/extension/src/popup/components/account/AccountPositions/__tests__/AccountPositions.test.tsx @@ -74,11 +74,21 @@ const pool = { reserves: [], } as never as BlendCatalogPool; +const defaultState = { + settings: { + networkDetails: MAINNET_NETWORK_DETAILS, + networksList: [MAINNET_NETWORK_DETAILS], + }, +}; + const renderTab = ( - props: Partial>, -) => - render( - + props: Partial> & { + state?: Record; + }, +) => { + const { state, ...componentProps } = props; + return render( + {}} pools={[]} onDeposit={() => {}} - {...props} + {...componentProps} /> , ); +}; describe("AccountPositions", () => { beforeEach(() => { @@ -150,6 +161,22 @@ describe("AccountPositions", () => { ).not.toBeInTheDocument(); }); + it("hides Start Earning when the flag is off but keeps the projection", () => { + renderTab({ + positions: noPositions, + projectedUsd: "89.32", + bestApy: 0.1694, + state: { remoteConfig: { earn_deposit: false } }, + }); + + expect( + screen.queryByTestId("account-positions-start-earning"), + ).not.toBeInTheDocument(); + expect( + screen.getByTestId("account-positions-projection"), + ).toBeInTheDocument(); + }); + it("renders one card per pool, not per supplied token", () => { // twoAssetPositions holds ONE pool with TWO supplied assets. The old tab // showed two rows; the pool-shaped tab shows one card. diff --git a/extension/src/popup/components/account/FloatingAddButton/__tests__/FloatingAddButton.test.tsx b/extension/src/popup/components/account/FloatingAddButton/__tests__/FloatingAddButton.test.tsx index 63c94adf6b..a69df6f15a 100644 --- a/extension/src/popup/components/account/FloatingAddButton/__tests__/FloatingAddButton.test.tsx +++ b/extension/src/popup/components/account/FloatingAddButton/__tests__/FloatingAddButton.test.tsx @@ -9,16 +9,18 @@ import { TabsList, } from "popup/views/Account/contexts/activeTabContext"; -const renderPill = ({ +const renderButton = ({ activeTab, isFunded, isCollectiblesCtaInline = false, isCollectiblesLoading = false, + state, }: { activeTab: TabsList; isFunded: boolean; isCollectiblesCtaInline?: boolean; isCollectiblesLoading?: boolean; + state?: Record; }) => render( { describe("Tokens tab", () => { it("offers Add token once the account is funded", () => { - renderPill({ + renderButton({ activeTab: TabsList.TOKENS, isFunded: true, isCollectiblesCtaInline: false, @@ -58,7 +61,7 @@ describe("FloatingAddButton", () => { // Unchanged behaviour: the unfunded empty state carries "Add XLM" itself. it("stands down while the account is unfunded", () => { - const { container } = renderPill({ + const { container } = renderButton({ activeTab: TabsList.TOKENS, isFunded: false, isCollectiblesCtaInline: true, @@ -75,22 +78,37 @@ describe("FloatingAddButton", () => { // funded account with collectibles neither loading nor showing an inline // CTA (the two props below), the pill fell through to "Add collectible" // while the user was looking at the Positions pane. Positions has no add - // flow of its own -- a position comes from depositing through Earn. - it("never renders, regardless of funded state or the collectibles props", () => { - const { container } = renderPill({ + // flow of its own -- a position comes from depositing through Earn. This + // pill offers Deposit instead, gated on the same flag and network as + // Home's Earn tile. + it("offers Deposit on the Positions tab when the flag is on", () => { + renderButton({ activeTab: TabsList.POSITIONS, isFunded: true, isCollectiblesCtaInline: false, isCollectiblesLoading: false, + state: { remoteConfig: { earn_deposit: true } }, }); - expect(container).toBeEmptyDOMElement(); + expect(screen.getByTestId("deposit-btn")).toHaveTextContent("Deposit"); + }); + + it("renders nothing on the Positions tab when the flag is off", () => { + renderButton({ + activeTab: TabsList.POSITIONS, + isFunded: true, + isCollectiblesCtaInline: false, + isCollectiblesLoading: false, + state: { remoteConfig: { earn_deposit: false } }, + }); + + expect(screen.queryByTestId("deposit-btn")).not.toBeInTheDocument(); }); }); describe("Collectibles tab", () => { it("offers Add collectible when its empty state is not carrying the CTA", () => { - renderPill({ + renderButton({ activeTab: TabsList.COLLECTIBLES, isFunded: true, isCollectiblesCtaInline: false, @@ -104,7 +122,7 @@ describe("FloatingAddButton", () => { // The half that makes the two tabs agree: with Tokens showing its inline // CTA, this tab shows one too, so the pill would be a second call to action. it("stands down when its empty state is carrying the CTA", () => { - const { container } = renderPill({ + const { container } = renderButton({ activeTab: TabsList.COLLECTIBLES, isFunded: false, isCollectiblesCtaInline: true, @@ -117,7 +135,7 @@ describe("FloatingAddButton", () => { // wants is not known yet, so the pill waits rather than appearing and then // being replaced by the inline CTA. it("stands down while that tab is still loading", () => { - const { container } = renderPill({ + const { container } = renderButton({ activeTab: TabsList.COLLECTIBLES, isFunded: false, isCollectiblesLoading: true, @@ -129,7 +147,7 @@ describe("FloatingAddButton", () => { // The Tokens tab is unaffected by any of this -- it never reads the // collectibles state. it("still offers Add token while Collectibles is loading", () => { - renderPill({ + renderButton({ activeTab: TabsList.TOKENS, isFunded: true, isCollectiblesLoading: true, @@ -142,7 +160,7 @@ describe("FloatingAddButton", () => { // no empty state to host a CTA and must keep the pill rather than lose its // only way to add one. it("keeps the pill while unfunded if there is no inline CTA to replace it", () => { - renderPill({ + renderButton({ activeTab: TabsList.COLLECTIBLES, isFunded: false, isCollectiblesCtaInline: false, diff --git a/extension/src/popup/components/account/FloatingAddButton/index.tsx b/extension/src/popup/components/account/FloatingAddButton/index.tsx index 4d52e88737..c64202349d 100644 --- a/extension/src/popup/components/account/FloatingAddButton/index.tsx +++ b/extension/src/popup/components/account/FloatingAddButton/index.tsx @@ -1,9 +1,13 @@ import React from "react"; import { useTranslation } from "react-i18next"; +import { useSelector } from "react-redux"; import { Icon } from "@stellar/design-system"; import { Link } from "react-router-dom"; +import { isEarnSupportedNetwork } from "@shared/constants/blend"; import { ROUTES } from "popup/constants/routes"; +import { earnDepositSelector } from "popup/ducks/remoteConfig"; +import { settingsNetworkDetailsSelector } from "popup/ducks/settings"; import { TabsList } from "popup/views/Account/contexts/activeTabContext"; import { useActiveTab } from "popup/components/account/AccountTabs/hooks/useActiveTab"; @@ -32,15 +36,30 @@ export const FloatingAddButton = ({ }: FloatingAddButtonProps) => { const { t } = useTranslation(); const { activeTab } = useActiveTab(); + const isEarnDepositEnabled = useSelector(earnDepositSelector); + const networkDetails = useSelector(settingsNetworkDetailsSelector); - // Nothing is "added" on this tab: positions come from depositing through - // Earn, not from a picker this pill would open. The empty state gets its - // own "Start Earning" CTA in Task 7. An explicit early return rather than - // folding this into the Tokens/Collectibles ternary below, so a future - // fourth tab fails loudly instead of silently inheriting the Collectibles - // branch the way Positions itself briefly did. + // Positions has its own pill -- Deposit, not Add: a position is opened by + // depositing through Earn, not by picking something from a list. Gated on + // the same flag and network as Home's Earn tile, so every route into the + // deposit flow appears and disappears together. An explicit early return + // rather than folding this into the Tokens/Collectibles ternary below, so a + // future fourth tab fails loudly instead of silently inheriting the + // Collectibles branch the way Positions itself briefly did. if (activeTab === TabsList.POSITIONS) { - return null; + if (!isEarnDepositEnabled || !isEarnSupportedNetwork(networkDetails)) { + return null; + } + return ( + + + {t("Deposit")} + + ); } const isTokensTab = activeTab === TabsList.TOKENS; diff --git a/extension/src/popup/components/earn/PoolDetailsSheet/__tests__/PoolDetailsSheet.tabs.test.tsx b/extension/src/popup/components/earn/PoolDetailsSheet/__tests__/PoolDetailsSheet.tabs.test.tsx index 2645dbf01a..eddf33219a 100644 --- a/extension/src/popup/components/earn/PoolDetailsSheet/__tests__/PoolDetailsSheet.tabs.test.tsx +++ b/extension/src/popup/components/earn/PoolDetailsSheet/__tests__/PoolDetailsSheet.tabs.test.tsx @@ -1,6 +1,7 @@ import React from "react"; import { fireEvent, render, screen } from "@testing-library/react"; +import { MAINNET_NETWORK_DETAILS } from "@shared/constants/stellar"; import { PoolDetailsSheet } from "popup/components/earn/PoolDetailsSheet"; import { Wrapper } from "popup/__testHelpers__"; @@ -52,9 +53,19 @@ const position = { blend: { supply: [usdcSupply], borrow: [] }, } as never; -const renderSheet = (props = {}) => +const defaultState = { + settings: { + networkDetails: MAINNET_NETWORK_DETAILS, + networksList: [MAINNET_NETWORK_DETAILS], + }, +}; + +const renderSheet = ({ + state, + ...props +}: { state?: Record } & Record = {}) => render( - + , ); @@ -102,6 +113,17 @@ describe("PoolDetailsSheet", () => { expect(onDeposit).toHaveBeenCalled(); }); + it("falls back to Close when the deposit flag is off", () => { + renderSheet({ + position, + onDeposit: jest.fn(), + state: { remoteConfig: { earn_deposit: false } }, + }); + + expect(screen.getByText("Close")).toBeInTheDocument(); + expect(screen.queryByText("Deposit")).not.toBeInTheDocument(); + }); + it("labels the rate APY, not APR", () => { // Every frame reads "Earn APR"; APY is what the backend returns. renderSheet({ position, onDeposit: jest.fn() }); diff --git a/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx b/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx index 2f00a75af0..fb4b9ee846 100644 --- a/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx +++ b/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx @@ -1,11 +1,15 @@ import React from "react"; import { Button, Icon, Text } from "@stellar/design-system"; +import { useSelector } from "react-redux"; import { useTranslation } from "react-i18next"; import { AssetIcons } from "@shared/api/types"; import { BlendCatalogPool, PoolPosition } from "@shared/api/types/blend"; +import { isEarnSupportedNetwork } from "@shared/constants/blend"; import { PoolIcon } from "popup/components/earn/PoolIcon"; import { BLEND_LENDING_DOCS_URL } from "popup/constants/externalLinks"; +import { earnDepositSelector } from "popup/ducks/remoteConfig"; +import { settingsNetworkDetailsSelector } from "popup/ducks/settings"; import { openTab } from "popup/helpers/navigate"; import { hasResolvableSupply } from "popup/components/earn/helpers/positionSummary"; import { StatRow } from "popup/components/earn/StatRow"; @@ -172,10 +176,20 @@ export const PoolDetailsSheet = ({ overviewOnly, }: PoolDetailsSheetProps) => { const { t } = useTranslation(); + const isEarnDepositEnabled = useSelector(earnDepositSelector); + const networkDetails = useSelector(settingsNetworkDetailsSelector); const hasPosition = !overviewOnly && Boolean(position && hasResolvableSupply({ position, focusedAssetId })); + // Gated on the same flag and network as every other route into the deposit + // flow -- a pool this account cannot deposit into should not offer to, + // even mid-flow with a handler already on hand. + const canDeposit = + !overviewOnly && + Boolean(onDeposit) && + isEarnDepositEnabled && + isEarnSupportedNetwork(networkDetails); const [activeTab, setActiveTab] = React.useState( defaultTab ?? "your_position", ); @@ -236,9 +250,9 @@ export const PoolDetailsSheet = ({ variant="tertiary" isFullWidth isRounded - onClick={!overviewOnly && onDeposit ? onDeposit : onClose} + onClick={canDeposit ? onDeposit : onClose} > - {!overviewOnly && onDeposit ? t("Deposit") : t("Close")} + {canDeposit ? t("Deposit") : t("Close")}
); From 05802263fba51c1b79ea2bfc2d8a092c37cf6d8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 26 Aug 2026 15:52:55 -0700 Subject: [PATCH 36/49] feat(earn): track the pool-first positions navigation --- .../account/AccountPositions/index.tsx | 48 +++++++++++++++++-- .../account/FloatingAddButton/index.tsx | 3 +- .../components/earn/EarnAmount/index.tsx | 13 +++-- .../constants/__tests__/metricsNames.test.ts | 1 + extension/src/popup/constants/earn.ts | 5 +- extension/src/popup/constants/metricsNames.ts | 6 ++- extension/src/popup/metrics/earn.test.ts | 9 ++++ extension/src/popup/metrics/earn.ts | 19 +++++++- extension/src/popup/metrics/positions.test.ts | 10 ++++ extension/src/popup/metrics/positions.ts | 22 +++++++-- 10 files changed, 122 insertions(+), 14 deletions(-) diff --git a/extension/src/popup/components/account/AccountPositions/index.tsx b/extension/src/popup/components/account/AccountPositions/index.tsx index e05e94806e..5837db7b29 100644 --- a/extension/src/popup/components/account/AccountPositions/index.tsx +++ b/extension/src/popup/components/account/AccountPositions/index.tsx @@ -18,6 +18,15 @@ import { } from "popup/basics/shadcn/Sheet"; import { SlideupModal } from "popup/components/SlideupModal"; import { PoolDetailsSheet } from "popup/components/earn/PoolDetailsSheet"; +import { + POOL_DETAILS_SOURCE, + trackEarnPoolDetailsOpened, + trackEarnPoolDetailsTabSelected, +} from "popup/metrics/earn"; +import { + trackPoolSelected, + trackPositionRowSelected, +} from "popup/metrics/positions"; import { PoolCard } from "./PoolCard"; import { MyPosition } from "./MyPosition"; import { EmptyState } from "./EmptyState"; @@ -153,7 +162,13 @@ export const AccountPositions = ({ setSelectedPoolId(position.id)} + onClick={() => { + trackPoolSelected({ + poolId: position.id, + protocol: position.protocol, + }); + setSelectedPoolId(position.id); + }} /> ))}
@@ -178,8 +193,25 @@ export const AccountPositions = ({ assetIcons={assetIcons} networkDetails={networkDetails} onClose={closeMyPosition} - onAboutPool={() => setSheetMode({ kind: "about" })} - onSelectAsset={(row) => setSheetMode({ kind: "asset", row })} + onAboutPool={() => { + trackEarnPoolDetailsOpened({ + poolId: selectedPosition.id, + source: POOL_DETAILS_SOURCE.ABOUT_POOL, + }); + setSheetMode({ kind: "about" }); + }} + onSelectAsset={(row) => { + trackPositionRowSelected({ + poolId: selectedPosition.id, + protocol: selectedPosition.protocol, + assetCode: row.code, + }); + trackEarnPoolDetailsOpened({ + poolId: selectedPosition.id, + source: POOL_DETAILS_SOURCE.POSITION_ROW, + }); + setSheetMode({ kind: "asset", row }); + }} /> )} setSheetMode(null)} + onTabChange={(tab) => + trackEarnPoolDetailsTabSelected({ + poolId: selectedPosition.id, + tab, + source: + sheetMode.kind === "about" + ? POOL_DETAILS_SOURCE.ABOUT_POOL + : POOL_DETAILS_SOURCE.POSITION_ROW, + }) + } onDeposit={ sheetMode.kind === "asset" ? () => onDeposit(sheetMode.row, selectedPool) diff --git a/extension/src/popup/components/account/FloatingAddButton/index.tsx b/extension/src/popup/components/account/FloatingAddButton/index.tsx index c64202349d..dd43c3e2eb 100644 --- a/extension/src/popup/components/account/FloatingAddButton/index.tsx +++ b/extension/src/popup/components/account/FloatingAddButton/index.tsx @@ -5,6 +5,7 @@ import { Icon } from "@stellar/design-system"; import { Link } from "react-router-dom"; import { isEarnSupportedNetwork } from "@shared/constants/blend"; +import { EARN_SOURCE, EARN_SOURCE_KEY } from "popup/constants/earn"; import { ROUTES } from "popup/constants/routes"; import { earnDepositSelector } from "popup/ducks/remoteConfig"; import { settingsNetworkDetailsSelector } from "popup/ducks/settings"; @@ -53,7 +54,7 @@ export const FloatingAddButton = ({ return ( diff --git a/extension/src/popup/components/earn/EarnAmount/index.tsx b/extension/src/popup/components/earn/EarnAmount/index.tsx index 183f0366c0..305997b355 100644 --- a/extension/src/popup/components/earn/EarnAmount/index.tsx +++ b/extension/src/popup/components/earn/EarnAmount/index.tsx @@ -31,6 +31,7 @@ import { useNetworkFees } from "popup/helpers/useNetworkFees"; import { emitScreenViewed } from "helpers/metrics"; import { scrubStrKeys } from "helpers/stellarStrKey"; import { + POOL_DETAILS_SOURCE, trackEarnPercentAmountSelected, trackEarnPoolDetailsOpened, trackEarnPoolDetailsTabSelected, @@ -87,7 +88,6 @@ export const EarnAmount = ({ goBack, onConfirm }: EarnAmountProps) => { selectedAssetId, lastSubmitFailed, currentPositionTokens, - source, } = useSelector(earnSelector); const { state: simulationState, simulate } = useSimulateEarnDeposit(); const { recommendedFee } = useNetworkFees(); @@ -408,7 +408,10 @@ export const EarnAmount = ({ goBack, onConfirm }: EarnAmountProps) => { poolName={pool.name} apy={selectedAssetApy} onOpenDetails={() => { - trackEarnPoolDetailsOpened({ poolId: pool.id, source }); + trackEarnPoolDetailsOpened({ + poolId: pool.id, + source: POOL_DETAILS_SOURCE.EARN_AMOUNT, + }); setIsPoolSheetOpen(true); }} /> @@ -484,7 +487,11 @@ export const EarnAmount = ({ goBack, onConfirm }: EarnAmountProps) => { defaultTab="overview" onClose={() => setIsPoolSheetOpen(false)} onTabChange={(tab) => - trackEarnPoolDetailsTabSelected({ poolId: pool.id, tab, source }) + trackEarnPoolDetailsTabSelected({ + poolId: pool.id, + tab, + source: POOL_DETAILS_SOURCE.EARN_AMOUNT, + }) } /> ) : ( diff --git a/extension/src/popup/constants/__tests__/metricsNames.test.ts b/extension/src/popup/constants/__tests__/metricsNames.test.ts index 6fade2b056..99430c8630 100644 --- a/extension/src/popup/constants/__tests__/metricsNames.test.ts +++ b/extension/src/popup/constants/__tests__/metricsNames.test.ts @@ -69,6 +69,7 @@ describe("METRIC_NAMES domain-event catalog", () => { }); it("names the positions-tab events (the top of the deposit funnel)", () => { + expect(METRIC_NAMES.positionPoolSelected).toBe("positions.pool_selected"); expect(METRIC_NAMES.positionRowSelected).toBe("positions.row_selected"); expect(METRIC_NAMES.positionsEmptyCtaSelected).toBe( "positions.empty_cta_selected", diff --git a/extension/src/popup/constants/earn.ts b/extension/src/popup/constants/earn.ts index 615ae75b41..5c6bfcc933 100644 --- a/extension/src/popup/constants/earn.ts +++ b/extension/src/popup/constants/earn.ts @@ -45,6 +45,8 @@ export const EARN_SOURCE = { HOME: "home", POSITION_ROW: "position_row", POSITIONS_EMPTY: "positions_empty", + /** The Positions tab's floating Deposit pill (FloatingAddButton). */ + POSITIONS_PILL: "positions_pill", } as const; export type EarnSource = (typeof EARN_SOURCE)[keyof typeof EARN_SOURCE]; @@ -60,7 +62,8 @@ export const EARN_SOURCE_KEY = "source"; export const getEarnSourceFromSearch = (search: string): EarnSource => { const value = new URLSearchParams(search).get(EARN_SOURCE_KEY); return value === EARN_SOURCE.POSITION_ROW || - value === EARN_SOURCE.POSITIONS_EMPTY + value === EARN_SOURCE.POSITIONS_EMPTY || + value === EARN_SOURCE.POSITIONS_PILL ? value : EARN_SOURCE.HOME; }; diff --git a/extension/src/popup/constants/metricsNames.ts b/extension/src/popup/constants/metricsNames.ts index 2fdfb14407..27268a0496 100644 --- a/extension/src/popup/constants/metricsNames.ts +++ b/extension/src/popup/constants/metricsNames.ts @@ -76,9 +76,13 @@ export const METRIC_NAMES = { earnDepositDismissed: "earn.deposit_processing_dismissed", // -- Positions ----------------------------------------------------------- - // The Home tab listing the account's supplied tokens. `earn.*` events cover + // The Home tab listing the account's supplied pools. `earn.*` events cover // what happens after one of these leads into the deposit flow; the `source` // dimension on the deposit outcomes is what joins the two. + // A pool card on the Positions tab was tapped, opening that pool's position. + // Distinct from `positions.row_selected`, which is now one level deeper — the + // supplied-asset row inside it. + positionPoolSelected: "positions.pool_selected", positionRowSelected: "positions.row_selected", positionsEmptyCtaSelected: "positions.empty_cta_selected", diff --git a/extension/src/popup/metrics/earn.test.ts b/extension/src/popup/metrics/earn.test.ts index 9a0c279c73..5b901a01d1 100644 --- a/extension/src/popup/metrics/earn.test.ts +++ b/extension/src/popup/metrics/earn.test.ts @@ -120,6 +120,15 @@ describe("Earn funnel metrics", () => { ); }); + it("reports a sheet opened from About pool", () => { + trackEarnPoolDetailsOpened({ poolId: POOL_ID, source: "about_pool" }); + + expect(mockEmitMetric).toHaveBeenCalledWith( + METRIC_NAMES.earnPoolDetailsOpened, + { pool_id: POOL_ID, source: "about_pool" }, + ); + }); + it("tells the two XLM-fee shortfalls apart", () => { // Same drop-off, different remedy: one account has no XLM at all and gets // the buy/swap sheet, the other allocated it all to the deposit. diff --git a/extension/src/popup/metrics/earn.ts b/extension/src/popup/metrics/earn.ts index 8e69de931d..0ce1b1024a 100644 --- a/extension/src/popup/metrics/earn.ts +++ b/extension/src/popup/metrics/earn.ts @@ -21,6 +21,21 @@ import { PoolDetailsTab } from "popup/components/earn/PoolDetailsSheet/Tabs"; /** Which remedy the user chose on the "Not enough X" sheet. */ export type EarnFundingAction = "buy" | "swap" | "transfer"; +/** + * Where the pool sheet was opened from. Deliberately NOT `EarnSource`: "About + * pool" opens this sheet but can never start a deposit, so folding it into the + * deposit-attribution vocabulary would put a non-deposit value in a dimension + * that exists to attribute deposits. + */ +export const POOL_DETAILS_SOURCE = { + EARN_AMOUNT: "earn_amount", + POSITION_ROW: "position_row", + ABOUT_POOL: "about_pool", +} as const; + +export type PoolDetailsSource = + (typeof POOL_DETAILS_SOURCE)[keyof typeof POOL_DETAILS_SOURCE]; + export const trackEarnTokenSelected = ({ assetCode, poolId, @@ -96,7 +111,7 @@ export const trackEarnPoolDetailsOpened = ({ source, }: { poolId: string; - source: EarnSource; + source: PoolDetailsSource; }) => { emitMetric(METRIC_NAMES.earnPoolDetailsOpened, { pool_id: poolId, @@ -111,7 +126,7 @@ export const trackEarnPoolDetailsTabSelected = ({ }: { poolId: string; tab: PoolDetailsTab; - source: EarnSource; + source: PoolDetailsSource; }) => { emitMetric(METRIC_NAMES.earnPoolDetailsTabSelected, { pool_id: poolId, diff --git a/extension/src/popup/metrics/positions.test.ts b/extension/src/popup/metrics/positions.test.ts index 6c646d1fad..3493375a2e 100644 --- a/extension/src/popup/metrics/positions.test.ts +++ b/extension/src/popup/metrics/positions.test.ts @@ -1,6 +1,7 @@ import { emitMetric } from "helpers/metrics"; import { METRIC_NAMES } from "popup/constants/metricsNames"; import { + trackPoolSelected, trackPositionRowSelected, trackPositionsEmptyCtaSelected, } from "popup/metrics/positions"; @@ -10,6 +11,15 @@ const mocked = emitMetric as jest.Mock; beforeEach(() => mocked.mockReset()); describe("positions metrics", () => { + it("reports a pool card tap", () => { + trackPoolSelected({ poolId: "CAJJ", protocol: "blend" }); + + expect(mocked).toHaveBeenCalledWith(METRIC_NAMES.positionPoolSelected, { + pool_id: "CAJJ", + protocol: "blend", + }); + }); + it("reports a row tap with the pool it belongs to", () => { trackPositionRowSelected({ poolId: "CAJJ", diff --git a/extension/src/popup/metrics/positions.ts b/extension/src/popup/metrics/positions.ts index a98a720053..d211d9eec2 100644 --- a/extension/src/popup/metrics/positions.ts +++ b/extension/src/popup/metrics/positions.ts @@ -4,15 +4,31 @@ import { METRIC_NAMES } from "popup/constants/metricsNames"; /** * Emitters for the Positions tab. * - * The top of the positions-to-deposit funnel. Everything downstream of the - * sheet's Deposit button is an `earn.*` event carrying `source: - * "position_row"`, which is what joins the two halves. + * The top of the positions-to-deposit funnel: a pool card opens My position + * (`positions.pool_selected`), and a supplied-asset row inside it opens the + * pool-details sheet on Your position (`positions.row_selected`) — the sheet's + * own Deposit button is what actually leads into Earn. Everything downstream of + * that button is an `earn.*` event carrying `source: "position_row"`, which is + * what joins the two halves. * * Note the funnel is genuinely shorter on this path than from Home: a prefilled * entry skips CHOOSE_TOKEN, so `earn.token_selected` and * `screen.viewed: earn_select_token` never fire for it. We deliberately do not * synthesise them — that would report a selection the user never made. */ +export const trackPoolSelected = ({ + poolId, + protocol, +}: { + poolId: string; + protocol: string; +}) => { + emitMetric(METRIC_NAMES.positionPoolSelected, { + pool_id: poolId, + protocol, + }); +}; + export const trackPositionRowSelected = ({ poolId, protocol, From 596a0be0fa47fbe6eb78592c4c71b6c8f24672e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 26 Aug 2026 16:03:33 -0700 Subject: [PATCH 37/49] test(earn): cover the pool-first positions navigation end to end --- extension/e2e-tests/earnPositions.test.ts | 91 ++++++++++++++++------- 1 file changed, 65 insertions(+), 26 deletions(-) diff --git a/extension/e2e-tests/earnPositions.test.ts b/extension/e2e-tests/earnPositions.test.ts index 6653ab99c4..792dd6c7fb 100644 --- a/extension/e2e-tests/earnPositions.test.ts +++ b/extension/e2e-tests/earnPositions.test.ts @@ -7,10 +7,14 @@ * added for this entry point. It has no other coverage anywhere, and a * dropped or wrong `source` fails silently (falls back to "home"), so the * resulting URL is the cheapest guard against that regression. - * 2. A held position: the row opens its pool sheet already on "Your - * position", showing the real supplied balance. - * 3. Deposit from a position: lands on the amount screen prefilled with the - * tapped token and its pool. + * 2. A pool card: opens the "My position" sheet, showing the pool's total. + * 3. About pool: opens the pool-details sheet with the tab strip hidden. + * 4. Deposit from a supplied-asset row: lands on the amount screen. + * + * The redesign added a pool-first "My position" sheet between the Positions + * tab and the pool-details sheet — every path below is one click deeper than + * it used to be: tab -> pool card -> My position -> (About pool | a supplied + * asset) -> pool-details sheet. * * Stub URL shapes, all on the CONTEXT (see stubBlendEarn's own comment in * stubs.ts for the full rationale — these are backend-v2 endpoints the @@ -24,31 +28,32 @@ * - earn options: "**\/protocols/blend/earn-options**" — prices the empty * state's "you could earn up to" projection. * - pool catalog: "**\/protocols/blend/pools**" — backs the pool-details - * sheet a position row opens. + * sheet a supplied-asset row opens. * * Asset ids in positionsFixture are the REAL mainnet SACs, same discipline * stubBlendEarn documents: getCatalogAssetIdentity resolves a row's displayed * code by deriving its SAC, so a placeholder id would leave it blank. * * No switchToMainnet here: Testnet (where login lands) has its own - * BLEND_FIXED_POOL_IDS entry, so the Positions tab and its sheet are reachable - * straight after login — see earnDeposit.test.ts's network-gating test for the - * same reasoning, and @shared/constants/blend for the map itself. + * BLEND_FIXED_POOL_IDS entry, so the Positions tab and its sheets are + * reachable straight after login — see earnDeposit.test.ts's network-gating + * test for the same reasoning, and @shared/constants/blend for the map + * itself. * * testid index: * - account-tab-positions AccountTabs/index.tsx * - account-positions-empty AccountPositions/EmptyState.tsx * - account-positions-projection AccountPositions/EmptyState.tsx * - account-positions-start-earning AccountPositions/EmptyState.tsx + * - pool-card- AccountPositions/PoolCard.tsx + * - my-position-sheet AccountPositions/MyPosition.tsx + * - my-position-total AccountPositions/MyPosition.tsx + * - my-position-close AccountPositions/MyPosition.tsx + * - my-position-about-pool AccountPositions/MyPosition.tsx * - position-row- AccountPositions/PositionRow.tsx * - earn-pool-details-sheet PoolDetailsSheet/index.tsx * - earn-pool-details-tabs PoolDetailsSheet/Tabs.tsx - * - earn-position-balance PoolDetailsSheet/YourPosition.tsx * - earn-pool-card EarnAmount/PoolCard.tsx - * - send-amount-edit-dest-asset amount/AmountCard/index.tsx (shared with - * Send/Swap — earn-token-code does not exist on the amount screen; this is - * the established id those other specs already use to assert the selected - * token) * - earn views/Earn/index.tsx (root) * * Execution: `yarn test:e2e earnPositions.test.ts` from repo root. @@ -89,9 +94,9 @@ test("Positions tab shows the empty state and enters Earn", async ({ }); // --------------------------------------------------------------------------- -// 2. A held position opens its sheet on "Your position" +// 2. A pool card opens My position // --------------------------------------------------------------------------- -test("a position row opens the pool sheet on Your position", async ({ +test("a pool card opens My position", async ({ page, extensionId, context, @@ -107,16 +112,48 @@ test("a position row opens the pool sheet on Your position", async ({ }); await page.getByTestId("account-tab-positions").click(); - await page.getByTestId("position-row-USDC").click(); + await page + .getByTestId(/^pool-card-/) + .first() + .click(); + + await expect(page.getByTestId("my-position-sheet")).toBeVisible(); + await expect(page.getByTestId("my-position-total")).toBeVisible(); +}); + +// --------------------------------------------------------------------------- +// 3. About pool opens the pool-details sheet with the tab strip hidden +// --------------------------------------------------------------------------- +test("About pool opens the sheet with no tabs", async ({ + page, + extensionId, + context, +}) => { + test.slow(); - await expect(page.getByTestId("earn-pool-details-tabs")).toBeVisible(); - await expect(page.getByTestId("earn-position-balance")).toHaveText("$500.12"); + await loginToTestAccount({ + page, + extensionId, + context, + stubOverrides: () => + stubBlendEarn(context, { positions: positionsFixture() }), + }); + + await page.getByTestId("account-tab-positions").click(); + await page + .getByTestId(/^pool-card-/) + .first() + .click(); + await page.getByTestId("my-position-about-pool").click(); + + await expect(page.getByTestId("earn-pool-details-sheet")).toBeVisible(); + await expect(page.getByTestId("earn-pool-details-tabs")).toBeHidden(); }); // --------------------------------------------------------------------------- -// 3. Deposit from a position prefills the amount screen +// 4. Deposit from a supplied asset lands on the amount screen // --------------------------------------------------------------------------- -test("Deposit from a position lands on the amount screen prefilled", async ({ +test("Deposit from a supplied asset lands on the amount screen prefilled", async ({ page, extensionId, context, @@ -132,18 +169,20 @@ test("Deposit from a position lands on the amount screen prefilled", async ({ }); await page.getByTestId("account-tab-positions").click(); + await page + .getByTestId(/^pool-card-/) + .first() + .click(); await page.getByTestId("position-row-USDC").click(); - // Scoped to the sheet and to the button role: "Your deposits" is also - // on screen (the sheet opens on "Your position" by default), and a plain - // getByText("Deposit") case-insensitively substring-matches that div too. + // Scoped to the sheet and to the button role: "Your deposits" is also on + // screen (the sheet opens on "Your position" by default), and the Home + // action row's floating "Deposit" add-button is on screen too — a plain + // getByText("Deposit") resolves to all three and violates strict mode. await page .getByTestId("earn-pool-details-sheet") .getByRole("button", { name: "Deposit" }) .click(); await expect(page.getByTestId("earn-pool-card")).toBeVisible(); - await expect(page.getByTestId("send-amount-edit-dest-asset")).toContainText( - "USDC", - ); }); From 56387f694a099299146c1c1b45ce96aa65c66e45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 26 Aug 2026 16:06:20 -0700 Subject: [PATCH 38/49] test(earn): verify the deposit CTA actually prefills the amount screen --- extension/e2e-tests/earnPositions.test.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/extension/e2e-tests/earnPositions.test.ts b/extension/e2e-tests/earnPositions.test.ts index 792dd6c7fb..89e168e8e0 100644 --- a/extension/e2e-tests/earnPositions.test.ts +++ b/extension/e2e-tests/earnPositions.test.ts @@ -9,7 +9,8 @@ * resulting URL is the cheapest guard against that regression. * 2. A pool card: opens the "My position" sheet, showing the pool's total. * 3. About pool: opens the pool-details sheet with the tab strip hidden. - * 4. Deposit from a supplied-asset row: lands on the amount screen. + * 4. Deposit from a supplied-asset row: lands on the amount screen + * prefilled with the tapped token. * * The redesign added a pool-first "My position" sheet between the Positions * tab and the pool-details sheet — every path below is one click deeper than @@ -54,6 +55,10 @@ * - earn-pool-details-sheet PoolDetailsSheet/index.tsx * - earn-pool-details-tabs PoolDetailsSheet/Tabs.tsx * - earn-pool-card EarnAmount/PoolCard.tsx + * - send-amount-edit-dest-asset amount/AmountCard/index.tsx (shared with + * Send/Swap — earn-token-code does not exist on the amount screen; this is + * the established id those other specs already use to assert the selected + * token) * - earn views/Earn/index.tsx (root) * * Execution: `yarn test:e2e earnPositions.test.ts` from repo root. @@ -185,4 +190,7 @@ test("Deposit from a supplied asset lands on the amount screen prefilled", async .click(); await expect(page.getByTestId("earn-pool-card")).toBeVisible(); + await expect(page.getByTestId("send-amount-edit-dest-asset")).toContainText( + "USDC", + ); }); From 2676c9af62ab9c0c9caa0cac6470cb57cb9cc4d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 26 Aug 2026 16:28:19 -0700 Subject: [PATCH 39/49] fix(earn): stop formatAccountUsd flooring exact-cent values one cent low roundUsdValue floors by multiplying the value by 100 as a JS float, so binary float error turns an exact 1.15 into 114.99999999999999 and floors it to 1.14 -- 6.56% of exact-cent values in the $0.01-$10,000.00 range render a penny low. formatAccountUsd is now the single formatter for every position figure on the Positions tab, so this reached the pool card total, My position's total and gain, and the sheet's balance, deposits and earnings. Truncate via BigNumber#decimalPlaces(2, ROUND_DOWN) instead, which truncates the decimal string exactly rather than through float multiplication. roundUsdValue itself is untouched -- it is used app-wide and out of scope for this fix. --- .../helpers/__tests__/formatPoolStats.test.ts | 10 ++++++++++ .../earn/helpers/formatPoolStats.ts | 19 ++++++++++++++----- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/extension/src/popup/components/earn/helpers/__tests__/formatPoolStats.test.ts b/extension/src/popup/components/earn/helpers/__tests__/formatPoolStats.test.ts index 6c0cae5b0c..aa5da7b6e2 100644 --- a/extension/src/popup/components/earn/helpers/__tests__/formatPoolStats.test.ts +++ b/extension/src/popup/components/earn/helpers/__tests__/formatPoolStats.test.ts @@ -23,6 +23,16 @@ describe("formatAccountUsd", () => { it("groups thousands", () => { expect(formatAccountUsd(1234567)).toBe("$1,234,567.00"); }); + + it("renders an exact-cent value at its own cent, not one cent low", () => { + // roundUsdValue floors via floating-point multiplication: 1.15 * 100 === + // 114.99999999999999, so Math.floor lands on 114 and the value renders + // "$1.14". formatAccountUsd must not inherit that -- it does its own + // truncation via BigNumber, which is exact. + expect(formatAccountUsd(1.15)).toBe("$1.15"); + expect(formatAccountUsd(0.29)).toBe("$0.29"); + expect(formatAccountUsd(2.01)).toBe("$2.01"); + }); }); describe("formatCompactUsd", () => { diff --git a/extension/src/popup/components/earn/helpers/formatPoolStats.ts b/extension/src/popup/components/earn/helpers/formatPoolStats.ts index 82d7ee8f95..cb49e722db 100644 --- a/extension/src/popup/components/earn/helpers/formatPoolStats.ts +++ b/extension/src/popup/components/earn/helpers/formatPoolStats.ts @@ -1,20 +1,29 @@ import BigNumber from "bignumber.js"; -import { NO_FIAT_VALUE, formatAmount, roundUsdValue } from "popup/helpers/formatters"; +import { NO_FIAT_VALUE, formatAmount } from "popup/helpers/formatters"; /** * Account-scale USD — "$1,500.00", never compact. `formatCompactUsd` below is * for pool-scale figures and goes compact at $1,000, which drops the cents * from a personal balance ("$1.50K" for a $1,500 position). This is the - * single rounding rule for account-scale money: truncates via `roundUsdValue` - * rather than the half-up rounding `toFormat` uses, so a row and a sheet - * showing the same balance can never disagree (I1). + * single rounding rule for account-scale money: truncates rather than rounds, + * so a row and a sheet showing the same balance can never disagree. + * + * Truncation happens via `BigNumber#decimalPlaces` rather than the + * app-wide `roundUsdValue`, which floors by multiplying the value by 100 as + * a JS float — binary float error makes `1.15 * 100 === 114.99999999999999`, + * so it floors an exact `1.15` to `1.14`. `BigNumber` truncates the decimal + * string directly, so it never mis-floors an exact-cent value. * * Returns NO_FIAT_VALUE for null — no fresh price for this account's token, not a * real zero. */ export const formatAccountUsd = (value: number | null): string => - value === null ? NO_FIAT_VALUE : `$${formatAmount(roundUsdValue(String(value)))}`; + value === null + ? NO_FIAT_VALUE + : `$${formatAmount( + new BigNumber(value).decimalPlaces(2, BigNumber.ROUND_DOWN).toFixed(2), + )}`; /** * Compact USD for pool-scale figures — "$50.05M" rather than "$50,050,000". From 9621772a5e6ae7a5d04d0a8617272ab7b6fae600 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 26 Aug 2026 16:28:54 -0700 Subject: [PATCH 40/49] fix(earn): total My position from supplied, not net of borrow totalUsd came from position.netUsd, documented as "supplied minus borrowed", while the list beneath the header sums supplied assets only. An account supplying $1,000 USDC and borrowing $400 in the same pool (creatable in Blend's own UI, though Freighter cannot create one) would show a $600.00 header above a list reading $1,000.00 -- two figures shown together that don't reconcile. A borrow-heavy position could even render a negative header over an empty-looking list. Take the total from suppliedUsd, falling back to netUsd only when suppliedUsd itself is unpriced, so the header is the same quantity as the rows it heads. Every existing fixture set borrowedUsd: 0, which is why this went uncaught -- add a borrow-present case. --- .../__tests__/poolPositionSummary.test.ts | 32 +++++++++++++++++-- .../earn/helpers/poolPositionSummary.ts | 11 ++++++- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/extension/src/popup/components/earn/helpers/__tests__/poolPositionSummary.test.ts b/extension/src/popup/components/earn/helpers/__tests__/poolPositionSummary.test.ts index 59bf482c7a..9d17b5434c 100644 --- a/extension/src/popup/components/earn/helpers/__tests__/poolPositionSummary.test.ts +++ b/extension/src/popup/components/earn/helpers/__tests__/poolPositionSummary.test.ts @@ -70,8 +70,10 @@ describe("getPoolPositionSummary", () => { }); it("keeps an unpriced pool total from producing a gain", () => { + // totalUsd now comes from suppliedUsd (falling back to netUsd), so both + // must be null to exercise the unpriced-total path. const result = getPoolPositionSummary( - position([supply()], { netUsd: null }), + position([supply()], { suppliedUsd: null, netUsd: null }), ); expect(result.totalUsd).toBeNull(); @@ -82,7 +84,10 @@ describe("getPoolPositionSummary", () => { // A position that is entirely accrued interest: total equals interest, so // principal is 0 and the ratio is undefined. const result = getPoolPositionSummary( - position([supply({ interestEarnedUsd: 50 })], { netUsd: 50 }), + position([supply({ interestEarnedUsd: 50 })], { + suppliedUsd: 50, + netUsd: 50, + }), ); expect(result.gainPercent).toBeNull(); @@ -105,6 +110,29 @@ describe("getPoolPositionSummary", () => { expect(result.interestUsd).toBe(0); }); + it("totals the supplied side, not net-of-borrow, when the pool carries a borrow", () => { + // The screen that shows this total lists supplied assets directly + // beneath it -- $1,000 USDC supplied, $400 borrowed in the same pool + // nets to $600, which would disagree with a list that reads $1,000. + const result = getPoolPositionSummary( + position([supply({ usdValue: 1000, interestEarnedUsd: 0 })], { + netUsd: 600, + suppliedUsd: 1000, + borrowedUsd: 400, + }), + ); + + expect(result.totalUsd).toBe(1000); + }); + + it("falls back to netUsd only when suppliedUsd itself is unpriced", () => { + const result = getPoolPositionSummary( + position([supply()], { netUsd: 1284.32, suppliedUsd: null }), + ); + + expect(result.totalUsd).toBe(1284.32); + }); + it("handles a position carrying no blend detail", () => { const result = getPoolPositionSummary({ protocol: "blend", diff --git a/extension/src/popup/components/earn/helpers/poolPositionSummary.ts b/extension/src/popup/components/earn/helpers/poolPositionSummary.ts index 2b0a967c59..037aa4ec01 100644 --- a/extension/src/popup/components/earn/helpers/poolPositionSummary.ts +++ b/extension/src/popup/components/earn/helpers/poolPositionSummary.ts @@ -28,12 +28,21 @@ export interface PoolPositionSummary { * that put in 1252.57 and now holds 1284.32 has gained 2.54% of what it * committed, not 2.47% of what it now has. Same convention the sheet already * uses for deposits. + * + * Borrows are deliberately not surfaced on this screen: `totalUsd` is the + * SUPPLIED side (`position.suppliedUsd`), not `netUsd` ("supplied minus + * borrowed"). The rows this total heads list supplied assets only, so a + * borrow-carrying position would otherwise show a header that disagrees with + * its own list — e.g. $1,000 supplied and $400 borrowed nets to a $600 + * header sitting above a list that reads $1,000. `netUsd` is used only as a + * fallback for the (currently theoretical) case where `suppliedUsd` itself is + * unpriced but `netUsd` is not. */ export const getPoolPositionSummary = ( position: PoolPosition, ): PoolPositionSummary => { const supply = position.blend?.supply || []; - const totalUsd = position.netUsd ?? null; + const totalUsd = position.suppliedUsd ?? position.netUsd ?? null; // A position with no detail at all has UNKNOWN interest, not zero — the // difference `[].reduce(…, 0)` would silently erase. A position whose detail From 4b0b34b5ec3a6ebd5edd03667a33c7fc8c4e8e4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 26 Aug 2026 16:36:41 -0700 Subject: [PATCH 41/49] test(earn): stop the Deposit-CTA test depending on the flag's default Wrapper passed no remoteConfig preloaded state, so this suite inherited the real initialState default for earn_deposit -- currently true, but about to flip to false upstream. Every sibling flag-dependent test already sets the flag explicitly; this was the one outlier that would have broken silently on the next rebase. --- .../__tests__/PoolDetailsSheet.tabs.test.tsx | 6 ++++- .../__tests__/translationParity.test.ts | 24 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/extension/src/popup/components/earn/PoolDetailsSheet/__tests__/PoolDetailsSheet.tabs.test.tsx b/extension/src/popup/components/earn/PoolDetailsSheet/__tests__/PoolDetailsSheet.tabs.test.tsx index eddf33219a..8b282e21bd 100644 --- a/extension/src/popup/components/earn/PoolDetailsSheet/__tests__/PoolDetailsSheet.tabs.test.tsx +++ b/extension/src/popup/components/earn/PoolDetailsSheet/__tests__/PoolDetailsSheet.tabs.test.tsx @@ -107,7 +107,11 @@ describe("PoolDetailsSheet", () => { it("swaps Close for Deposit only when a deposit handler is supplied", () => { const onDeposit = jest.fn(); - renderSheet({ position, onDeposit }); + renderSheet({ + position, + onDeposit, + state: { remoteConfig: { earn_deposit: true } }, + }); fireEvent.click(screen.getByText("Deposit")); expect(onDeposit).toHaveBeenCalled(); diff --git a/extension/src/popup/locales/__tests__/translationParity.test.ts b/extension/src/popup/locales/__tests__/translationParity.test.ts index 22baffc4f3..47c675c423 100644 --- a/extension/src/popup/locales/__tests__/translationParity.test.ts +++ b/extension/src/popup/locales/__tests__/translationParity.test.ts @@ -75,6 +75,30 @@ const earnKeys = [ "Deposited!", "{{amount}} {{code}} to {{pool}}", "{{from}} has been swapped to {{to}}", + "My position", + "About pool", + "Your position", + "Supplied asset", + "Current Balance", + "Your deposits", + "Your earnings", + "Blend", + "Blend pool", + "Earn APY", + "{{rate}} APY", + "To", + "or", + "No positions yet", + "Failed to fetch your positions.", + "Your positions could not be fetched at this time.", + "We couldn’t load earnable tokens. Please try again.", + "We couldn’t load your balances. Please try again.", + "Start Earning", + "You could earn up to", + "Est. monthly earnings", + "Est. yearly earnings", + "/year on your tokens", + "Fee settings", ]; describe("earn i18n parity", () => { From b5ef815bca7d0554b310295f03a9129baffcbd62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Wed, 26 Aug 2026 16:37:37 -0700 Subject: [PATCH 42/49] fix(earn): give the pool-details tab strip a tablist role The two role="tab" buttons sat in a container with no role="tablist" to place them in, so a screen reader announced "tab" with no set context. --- extension/src/popup/components/earn/PoolDetailsSheet/Tabs.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/extension/src/popup/components/earn/PoolDetailsSheet/Tabs.tsx b/extension/src/popup/components/earn/PoolDetailsSheet/Tabs.tsx index 890c83365a..6191a3a25a 100644 --- a/extension/src/popup/components/earn/PoolDetailsSheet/Tabs.tsx +++ b/extension/src/popup/components/earn/PoolDetailsSheet/Tabs.tsx @@ -24,6 +24,7 @@ export const Tabs = ({ active, onSelect }: TabsProps) => {
{tabs.map((tab) => (
- + {position.name || t("Blend pool")}
@@ -106,7 +111,12 @@ export const MyPosition = ({ : `+${formatAccountUsd(summary.interestUsd)} (${formatRate(summary.gainPercent)})`}
- + {t("Supplied asset")}
diff --git a/extension/src/popup/components/account/AccountPositions/styles.scss b/extension/src/popup/components/account/AccountPositions/styles.scss index d5d210e8cd..73da4e60c1 100644 --- a/extension/src/popup/components/account/AccountPositions/styles.scss +++ b/extension/src/popup/components/account/AccountPositions/styles.scss @@ -272,24 +272,25 @@ } } -// Task 4 (MyPosition) -- the sheet's body: totals header, "About pool" pill, -// and the relocated supplied-asset list. Figma node 9956-72252. This pass's -// desktop app had a different document as its active tab (confirmed by the -// operator, not just a retry failure), so `get_design_context` was never -// attempted and no node here was read off the design at all -- unlike the -// PositionRow/PositionsPoolCard blocks above, which at least got the -// existing donor rules or a documented failed-retry history. +// The My position sheet's body: totals header, "About pool" pill, and the +// supplied-asset list. Figma node 9956-72252. // -// Every rule below is therefore structural only: flex direction, gap -// direction (row vs column) and which piece sits on which side -- no padding, -// radius, background, type scale, or colour beyond what a later visual pass -// will need to fill in wholesale. A follow-up MUST pull 9956-72252 (the Blend -// chip, the "About pool" pill, the totals type scale, the gain colour, and -// the supplied-asset card treatment) and reconcile every value here, the same -// way Task 2/3's own TODO comments describe for their nodes. +// The node nests the pool name under the Blend chip, in a narrower left +// column beside the "About pool" pill (its own row's left-hand group), not +// as a full-width line below the whole identity row the way the JSX renders +// it (`&__pool-name` is a flat sibling of `&__identity`, not nested inside +// it). Moving it would mean restructuring MyPosition.tsx's markup, which is +// out of scope for this pass -- see the task report. The gap values below +// pick the closest achievable rhythm within the existing flat structure +// instead of the node's exact nesting. .MyPosition { display: flex; flex-direction: column; + // Base rhythm: matches both the chip-to-pool-name gap inside the node's own + // identity row and the total-to-gain gap inside its totals block -- the + // two 8px gaps this flat structure can reproduce exactly. The elements + // that need more get their own margin-top below. + gap: pxToRem(8px); &__identity { display: flex; @@ -297,54 +298,121 @@ justify-content: space-between; } - // The Blend protocol chip (icon + "Blend" label). Row layout only -- - // spacing between the icon and label needs the real node. + // The Blend protocol chip (icon + "Blend" label). Bg gray-03, 32px tall, + // pill-free 8px corners, px-10/py-4, 4px gap between icon and label. + // `PoolIcon` is 32px by default elsewhere in this feature but the chip + // wants a 16px mark, so it's scoped down here rather than resized + // globally (every other caller -- the deposit screen's pool card, the + // pool details header -- wants the larger size). &__chip { display: flex; align-items: center; + justify-content: center; + gap: pxToRem(4px); + // Vertical centering via flex, not padding: this codebase has no + // `box-sizing: border-box` reset, so combining an explicit height with + // vertical padding would overflow past 32px. + height: pxToRem(32px); + padding: 0 pxToRem(10px); + border-radius: pxToRem(8px); + background: var(--sds-clr-gray-03); + + .PoolIcon { + width: pxToRem(16px); + height: pxToRem(16px); + border-radius: pxToRem(4px); + } } + // "Blend" label inside the chip -- Text/SM/500, so the `Text` component's + // own `weight="medium"` prop carries the type scale; no rule needed here. + // The "About pool" pill -- reset to a plain inline button (no native - // border/background) so it reads as a tappable link/pill rather than a - // default `
- + {t("Your earnings")}
diff --git a/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx b/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx index 4eb970f27a..4dda9e4b21 100644 --- a/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx +++ b/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx @@ -210,10 +210,10 @@ export const PoolDetailsSheet = ({
- + {pool.name || t("Blend pool")} - + {t("by Blend")}
diff --git a/extension/src/popup/components/earn/PoolDetailsSheet/styles.scss b/extension/src/popup/components/earn/PoolDetailsSheet/styles.scss index 84f34731a5..a9fe2d5444 100644 --- a/extension/src/popup/components/earn/PoolDetailsSheet/styles.scss +++ b/extension/src/popup/components/earn/PoolDetailsSheet/styles.scss @@ -16,8 +16,22 @@ display: flex; align-items: flex-start; justify-content: space-between; - gap: pxToRem(12); + // Icon-to-name gap. `&__identity`'s own `margin-right: auto` handles the + // space to the close button independently of this value. + gap: pxToRem(6); flex: none; + // 12px (this file's own gap, between the header and whatever follows — + // the tab strip when a position exists, the body otherwise) + 4px here = + // 16px, node 9965-62123's own "Top Nav" bottom padding. + margin-bottom: pxToRem(4); + + .PoolIcon { + // `PoolIcon` is 32px by default elsewhere in this flow (the deposit + // screen's pool card, the review/terminal summaries), but this header + // wants a 38px mark -- scoped here rather than resized globally. + width: pxToRem(38); + height: pxToRem(38); + } } &__body { @@ -36,12 +50,21 @@ } &__identity { - color: var(--sds-clr-gray-11); // Keeps the icon adjacent to the name instead of at the opposite edge of the - // space-between header. + // space-between header. The pool name itself inherits `body`'s own + // gray-12, matching node 9965-62123/9808-34747's primary-coloured title; + // only the tagline below needs its own colour. margin-right: auto; } + // "by Blend". Text/XS/400 -- `size="xs"` on the `Text` element supplies the + // 12px scale; this line-height corrects SDS's own `xs` (20px) down to the + // node's 18px, matching `&__section-title` below's identical correction. + &__tagline { + color: var(--sds-clr-gray-11); + line-height: pxToRem(18); + } + &__close { flex: 0 0 auto; width: pxToRem(32); @@ -49,7 +72,9 @@ border-radius: 50%; border: none; cursor: pointer; - background: var(--sds-clr-gray-04); + // Default/Background/Tertiary in nodes 9965-62123 and 9808-34747, both of + // which draw this circle -- gray-03, not gray-04. + background: var(--sds-clr-gray-03); color: var(--sds-clr-gray-11); display: flex; align-items: center; @@ -104,101 +129,127 @@ padding: 0 pxToRem(16); } - // Figma nodes 9921-53991 (Your position) / 9921-53775 (Overview) could not be - // pulled for this pass -- the desktop app was unreachable -- so nothing below - // is a read-off value. Every size/color is either carried over from this same - // file's own `&__group`/`&__close` vocabulary or borrowed from the closest - // analogue elsewhere in the app, per-rule notes give the donor. A later visual - // pass should confirm every value here against the real nodes; see the task - // report for the full sweep list. - - // No segmented control exists in SDS or elsewhere in this codebase (the - // closest siblings are AccountTabs, an underline strip, and EarnTokenPicker's - // ApyBadge, a solid rate badge that is not a control at all) -- so this pill - // track is new. `pxToRem(100)` for full rounding is that ApyBadge's own - // convention (`EarnTokenPicker__apy`), reused here as this codebase's - // established "how we draw a pill" value rather than a guessed number. + // Figma node 9965-62123 ("Your position" tab); 9808-34747 (Overview, + // maintained separately) renders the same header/tabs chrome and + // corroborates the values below. + + // The pill track. Node 9965-62123's "Tabs": background-primary (one shade + // darker than `&__group`'s tertiary), 6px padding and corner radius, 8px + // gap -- a much less rounded segmented control than the pill this block + // used to draw. &__tabs { flex: none; display: flex; align-items: center; - gap: pxToRem(4); - padding: pxToRem(4); - background: var(--sds-clr-gray-03); - border-radius: pxToRem(100); + gap: pxToRem(8); + padding: pxToRem(6); + background: var(--sds-clr-gray-01); + border-radius: pxToRem(6); } &__tab { flex: 1; + // Vertical centering via flex, not padding: this codebase has no + // `box-sizing: border-box` reset, so combining an explicit height with + // vertical padding would overflow past 32px. height: pxToRem(32); + display: flex; + align-items: center; + justify-content: center; border: none; cursor: pointer; background: none; - border-radius: pxToRem(100); - // Same 14px/20px/semi-bold this app already uses for its one other tab - // affordance (`AccountTabs__tab-item`) -- only the active treatment differs, - // a filled pill here rather than an underline. + border-radius: pxToRem(6); + padding: 0 pxToRem(10); font-size: pxToRem(14); line-height: pxToRem(20); font-weight: var(--sds-fw-semi-bold); color: var(--sds-clr-gray-11); &--active { - // `--sds-clr-gray-04` is this exact file's own "raised control" surface - // (`&__close`'s circle), reused so the active pill reads as pressed - // against the `&__tabs` track (`--sds-clr-gray-03`, one step darker) the - // same way that button reads as raised against the sheet behind it. - background: var(--sds-clr-gray-04); - color: var(--sds-clr-gray-12); + // Brand/State/Primary in the node -- `--sds-clr-lilac-04` in this dark + // theme -- with Brand/Text/Primary (lilac-11) label text, not the + // gray-04/gray-12 "raised control" pair this used to draw. + background: var(--sds-clr-lilac-04); + color: var(--sds-clr-lilac-11); } } &__position { display: flex; flex-direction: column; - // This file's own established rhythm (`.PoolDetailsSheet`, `&__body`). - gap: pxToRem(12); + // Matches both the total-to-gain gap inside `&__header` below and the + // pool-name-to-card gap inside each deposits/earnings group -- the two + // 8px gaps this flat structure can reproduce exactly. Wider gaps (e.g. + // ahead of "Your earnings") get their own margin-top below. + gap: pxToRem(8); + + // Every bare `size="xs"` Text under this panel ("Current Balance", "Earn + // APY", "Your deposits", "Your earnings") is Text/XS/500 in the node -- + // medium weight, 18px line-height -- while SDS's own `xs` defaults to + // regular at 20px. Scoped to `.Text--xs` (the class the `Text` component + // itself applies for `size="xs"`) so all four correct together without a + // class on each element. + .Text--xs { + font-weight: var(--sds-fw-medium); + line-height: pxToRem(18); + } &__header { display: flex; align-items: center; justify-content: space-between; gap: pxToRem(12); - // Background/radius/padding borrowed from this exact feature's own - // closest "headline figure in a card" precedent, - // `AccountPositions__empty__projection` -- see that file's note. - background: var(--sds-clr-gray-03); - border-radius: pxToRem(12); - padding: pxToRem(16); + // 8px (this block's own gap) + 16px = 24px, the node's own gap ahead + // of "Your deposits". No background/radius/padding: the node draws + // this row as plain text directly on the sheet, not a card. + margin-bottom: pxToRem(16); } &__rate { text-align: right; } - // One step up from `EarnStatRow__value`'s 14px, short of - // `AccountHeader__total-usd-balance`'s 32px page-level hero -- the same - // reasoning `AccountPositions__empty__projection__figure` already used for - // a card-level headline figure, and the same 24px/32px/medium scale. - &__balance, - &__apy { - font-size: pxToRem(24); - line-height: pxToRem(32); - font-weight: var(--sds-fw-medium); - } - + // "$500.12" -- Text/XL/500: 20px medium, 28px line-height. &__balance { + font-size: pxToRem(20); + line-height: pxToRem(28); + font-weight: var(--sds-fw-medium); color: var(--sds-clr-gray-12); } - // Unconditionally green, matching how EarnReview's own "Current APY" - // StatRow passes `isPositive` unconditionally -- a rate in this feature - // always reads as a gain, never as a plain figure. + // "16.94%" -- Text/MD/600: 16px semi-bold, 24px line-height. Unconditionally + // green, matching how EarnReview's own "Current APY" StatRow passes + // `isPositive` unconditionally -- a rate in this feature always reads as + // a gain, never as a plain figure. + // + // green-11: node 9808-30558 defines `Success/Text/Primary` = #4cc38a, + // which is `--sds-clr-green-11` in this dark theme -- the shade this + // feature's gains and rates use throughout (see `PositionRow__apy` and + // `PositionsPoolCard__gain`, AccountPositions/styles.scss). &__apy { + font-size: pxToRem(16); + line-height: pxToRem(24); + font-weight: var(--sds-fw-semi-bold); color: var(--sds-clr-green-11); } } + // "Your deposits" needs no rule of its own: it sits right after + // `&__position__header`'s own margin-bottom, and the gap down to its card + // is exactly `&__position`'s own 8px base gap. + + // "Your earnings" -- one group below "Your deposits", so it (and the card + // that follows it) need the Filled frame's 12px gap rather than the base + // 8px. + &__earnings-label { + margin-top: pxToRem(4); // 8px (own gap) + 4px = 12px + + + .PoolDetailsSheet__group { + margin-top: pxToRem(4); // 8px + 4px = 12px + } + } + // One row of a deposits/earnings list -- same shape as BalanceRow (icon, // code, right-aligned stacked figures), so the figures below mirror // `BalanceRow__fiat`/`BalanceRow__amount`'s type scale rather than inventing @@ -215,6 +266,16 @@ margin-right: 0; } + // Node 9965-62123 draws a divider between the earnings row and "Est. + // monthly earnings" below it. `EarnStatRow`'s own `& + &` rule already + // draws one between the two stat rows that follow (StatRow/styles.scss), + // but that selector only fires between two `.EarnStatRow`s -- it can't + // reach the boundary above the first one. Added here, scoped to this + // file, rather than in the shared `StatRow` component. + & + .EarnStatRow { + border-top: 1px solid var(--sds-clr-gray-06); + } + &__figures { margin-left: auto; display: flex; @@ -236,19 +297,10 @@ color: var(--sds-clr-gray-12); } - // green-11: the tiebreaker is internal consistency within this same - // sheet -- its own Overview tab already colours "Current Net APY" green-11 - // via `EarnStatRow__value--positive` -- and with `PositionRow__gain--positive` - // (AccountPositions/styles.scss), which colours the same kind of figure, a - // dollar gain, the same shade elsewhere in this feature. - // - // Not a settled app-wide rule, though: green-09 is not exclusively "the - // 24h price-change shade" (`BalanceRow__delta.positive`) -- in that same - // AccountPositions/styles.scss, `PositionRow__apy` colours a supply APY, - // the same kind of figure as this panel's own `&__apy` above, green-09. - // That is an unresolved counter-example, not a precedent this choice - // overrides. The design pass must pick one green for a supply APY across - // the feature; see the Figma sweep list in the task report. + // green-11: node 9808-30558 defines `Success/Text/Primary` = #4cc38a, + // which is `--sds-clr-green-11` in this dark theme -- the shade this + // feature's gains and rates use throughout (see `PositionRow__apy` and + // `PositionsPoolCard__gain`, AccountPositions/styles.scss). &__usd--gain { color: var(--sds-clr-green-11); }