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..47117e5e3c 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"; /** @@ -29,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, @@ -78,6 +98,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 @@ -106,6 +191,8 @@ export const getBlendEarnOptions = async ({ throw new Error(_err); } + debugLogBlendResponse("earn-options", parsed.data); + return (parsed.data.options || []).map(mapEarnAssetOption); }; @@ -131,10 +218,70 @@ export const getBlendPools = async ({ throw new Error(_err); } + debugLogBlendResponse("pools", parsed.data); + return (parsed.data.pools || []).map(mapCatalogPool); }; /** + * 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); + } + + debugLogBlendResponse("positions", parsed.data); + + 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[]; +} diff --git a/extension/e2e-tests/earnDeposit.test.ts b/extension/e2e-tests/earnDeposit.test.ts index 9cf5d920e1..d4da651ff5 100644 --- a/extension/e2e-tests/earnDeposit.test.ts +++ b/extension/e2e-tests/earnDeposit.test.ts @@ -17,8 +17,13 @@ * - positions: "** /accounts/positions**" * These three are backend-v2 endpoints, which the background service worker * fetches rather than the popup, so page.route never sees them — hence - * stubBlendEarn(context), and hence passing it through loginToTestAccount's - * stubOverrides so it is registered before the popup navigates. + * context.route for those three inside stubBlendEarn. The fourth route it + * registers, the `earn_deposit` Amplitude vardata stub, is PAGE-scoped in + * effect too: stubBlendEarn takes `page` and reloads once every route is in + * place, because loginToTestAccount's stubOverrides runs after page.goto has + * already navigated — by then the popup's one-shot flag fetch has typically + * already fired and failed against the real (unreachable) endpoint, so + * registering the route alone is too late without the reload. * * The deposit's build and broadcast go through "** /simulate-tx**" and * "** /submit-tx**" instead. Those stay PAGE-scoped: they are backend-v1 @@ -80,7 +85,7 @@ test("Earn tile opens the interstitial once, then goes straight to the picker", page, extensionId, context, - stubOverrides: () => stubBlendEarn(context), + stubOverrides: () => stubBlendEarn(page, context), }); await switchToMainnet(page); @@ -120,7 +125,7 @@ test("token picker splits held from supported and shows each rate", async ({ page, extensionId, context, - stubOverrides: () => stubBlendEarn(context), + stubOverrides: () => stubBlendEarn(page, context), }); await switchToMainnet(page); await openEarnFlow(page); @@ -163,7 +168,7 @@ test("zero-balance EURC offers swap and transfer but not buy", async ({ page, extensionId, context, - stubOverrides: () => stubBlendEarn(context), + stubOverrides: () => stubBlendEarn(page, context), }); await switchToMainnet(page); await openEarnFlow(page); @@ -192,7 +197,7 @@ test("zero-balance USDC offers buy, swap and transfer", async ({ page, extensionId, context, - stubOverrides: () => stubBlendEarn(context), + stubOverrides: () => stubBlendEarn(page, context), }); await switchToMainnet(page); await openEarnFlow(page); @@ -222,7 +227,7 @@ test("depositing a held token reaches the success screen", async ({ extensionId, context, stubOverrides: async () => { - await stubBlendEarn(context); + await stubBlendEarn(page, context); // Only this test signs and broadcasts, so it is the only one that needs a // decodable prepared XDR out of /simulate-tx. await stubEarnSimulateTx(page); @@ -286,7 +291,7 @@ test("pool details sheet shows market stats including Backstop", async ({ page, extensionId, context, - stubOverrides: () => stubBlendEarn(context), + stubOverrides: () => stubBlendEarn(page, context), }); await switchToMainnet(page); await openEarnFlow(page); @@ -328,7 +333,7 @@ test("Earn tile is hidden on a custom network with no allowlisted pool", async ( page, extensionId, context, - stubOverrides: () => stubBlendEarn(context), + stubOverrides: () => stubBlendEarn(page, context), }); await expect(page.getByTestId("nav-link-earn")).toBeVisible(); diff --git a/extension/e2e-tests/earnPositions.test.ts b/extension/e2e-tests/earnPositions.test.ts new file mode 100644 index 0000000000..2357d2c02c --- /dev/null +++ b/extension/e2e-tests/earnPositions.test.ts @@ -0,0 +1,196 @@ +/** + * 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 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 + * 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 + * 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 + * 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 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 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-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(page, 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 pool card opens My position +// --------------------------------------------------------------------------- +test("a pool card opens My position", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + + await loginToTestAccount({ + page, + extensionId, + context, + stubOverrides: () => + stubBlendEarn(page, context, { positions: positionsFixture() }), + }); + + await page.getByTestId("account-tab-positions").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 loginToTestAccount({ + page, + extensionId, + context, + stubOverrides: () => + stubBlendEarn(page, 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(); +}); + +// --------------------------------------------------------------------------- +// 4. Deposit from a supplied asset lands on the amount screen +// --------------------------------------------------------------------------- +test("Deposit from a supplied asset lands on the amount screen prefilled", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + + await loginToTestAccount({ + page, + extensionId, + context, + stubOverrides: () => + stubBlendEarn(page, context, { positions: positionsFixture() }), + }); + + 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 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", + ); +}); diff --git a/extension/e2e-tests/helpers/stubs.ts b/extension/e2e-tests/helpers/stubs.ts index 9f653f1069..0481029361 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 @@ -3635,8 +3685,22 @@ export const stubEarnSimulateTx = async (page: Page) => { * `**\/accounts/**` in `stubHorizonAccounts`: that one only ever sees Horizon's * `loadAccount` from the popup, and this one only ever sees the service worker's * positions POST. + * + * Callers reach this through `loginToTestAccount`'s `stubOverrides`, which runs + * AFTER `page.goto` has already navigated to the popup — so `useRemoteConfig`'s + * one-shot `fetchFeatureFlags` (dispatched from `App.tsx` on mount) has, in + * practice, already fired and rejected (no real Amplitude deployment in test) + * before the `earn_deposit` vardata route below is even registered, leaving the + * flag at its default. A route registered after the fact never intercepts a + * request that was already sent. This mirrors exactly the timing + * `stubExperimentVariants` documents above for the maintenance-screen flags, + * and takes the same fix: reload once every route is in place so the popup + * remounts and refetches with the stub live. `loginToTestAccount` hasn't + * touched the DOM yet at this point (still the pre-"I already have a wallet" + * screen) so the reload is a no-op for wallet state. */ export const stubBlendEarn = async ( + page: Page, context: BrowserContext, { positions = [], @@ -3666,8 +3730,10 @@ export const stubBlendEarn = async ( // The Earn tile is gated on the `earn_deposit` Amplitude flag, which defaults // to off. Serve it as "on" from the Experiment vardata endpoint so the entry - // point renders. Context-scoped like the rest of the Earn stubs so it is in - // place before the popup's first flag fetch. + // point renders. Context-scoped like the rest of the Earn stubs, and — per + // this function's own doc comment above — followed by a reload so it is + // actually in place for the popup's flag fetch rather than just registered + // after the fact. await context.route(AMPLITUDE_EXPERIMENT_VARDATA_ROUTE, async (route) => { await route.fulfill({ json: { earn_deposit: { key: "on", value: "on" } }, @@ -3783,4 +3849,92 @@ export const stubBlendEarn = async ( await context.route("**/accounts/positions**", async (route) => { await route.fulfill({ json: { data: positions } }); }); + + // Every route above is context-scoped and therefore survives the reload. + // Force a fresh popup mount so `useRemoteConfig` re-dispatches + // `fetchFeatureFlags` and this time hits the `earn_deposit` stub instead of + // the real (unreachable) Amplitude endpoint it raced on the first load. + await page.reload({ waitUntil: "domcontentloaded" }); +}; + +/** + * 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: [], + }, + ]; }; 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..9cec3357e0 --- /dev/null +++ b/extension/src/helpers/hooks/__tests__/useGetPositions.test.tsx @@ -0,0 +1,190 @@ +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} + +); + +// 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", () => { + 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"); + }); + + 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("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. + 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 new file mode 100644 index 0000000000..20d602d00b --- /dev/null +++ b/extension/src/helpers/hooks/useGetPositions.tsx @@ -0,0 +1,119 @@ +import { useCallback, useMemo, useReducer } from "react"; +import { useDispatch, useStore } 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, AppState } from "popup/App"; +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 + * 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 store = useStore(); + + const fetchData = useCallback( + 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. + if (!isEarnSupportedNetwork(networkDetails)) { + const empty = EMPTY_POSITIONS(publicKey); + dispatch({ type: "FETCH_DATA_SUCCESS", payload: empty }); + 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 (shouldUseCache && 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; + } + }, + // `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]); +} + +export { useGetPositions }; +export type UseGetPositionsReturn = { + state: State; + fetchData: (params: FetchPositionsParams) => Promise; +}; 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/BalanceRow/index.tsx b/extension/src/popup/components/BalanceRow/index.tsx index 01c58fa5bc..a30a397b0e 100644 --- a/extension/src/popup/components/BalanceRow/index.tsx +++ b/extension/src/popup/components/BalanceRow/index.tsx @@ -27,6 +27,11 @@ export interface BalanceRowProps { retryAssetIconFetch?: (arg: { key: string; code: string }) => void; /** Formatted token balance, e.g. "123.45". */ amount: string; + /** + * Replaces the token-amount line under the code. Used by the Positions tab, + * whose rows carry a coloured APY there instead of a balance. + */ + amountSlot?: React.ReactNode; /** Formatted fiat balance incl. symbol, e.g. "$12.34". When null the fiat * cell is omitted entirely (matches the account-home no-price row). */ fiatAmount?: string | null; @@ -60,6 +65,7 @@ export const BalanceRow = ({ isLPShare = false, retryAssetIconFetch, amount, + amountSlot, fiatAmount, percentChange, rightSlot, @@ -113,7 +119,7 @@ export const BalanceRow = ({
{displayName}
- {amount} + {amountSlot ?? amount}
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/EmptyState.tsx b/extension/src/popup/components/account/AccountPositions/EmptyState.tsx new file mode 100644 index 0000000000..d6d35552b4 --- /dev/null +++ b/extension/src/popup/components/account/AccountPositions/EmptyState.tsx @@ -0,0 +1,107 @@ +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"; + +interface EmptyStateProps { + /** Annual projection in USD, or null when there is nothing to total. */ + 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. */ + onStartEarning: () => void; +} + +/** + * Shown once the positions request lands with nothing. + * + * The card promises a dollar figure when the account actually holds something + * depositable, and the ceiling rate when it does not — a new account has no + * balance to project from, so "$0.00/year" would read as a rejection rather + * than an invitation. With neither figure known, the card is omitted entirely + * rather than rendering blank. + * + * The CTA's navigation lives here, in one place, rather than behind a prop — + * so the `source` query param the deposit funnel attributes back to this CTA + * only needed one call site to change. + */ +export const EmptyState = ({ + projectedUsd, + bestApy, + onStartEarning, +}: 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 ( +
+
+ {t("No positions yet")} +
+
+ {t( + "Put your crypto to work and earn rewards while keeping track of your positions.", + )} +
+ + {hasProjection && ( +
+
+ {t("You could earn up to")} +
+
+ + {projectedUsd !== null + ? `$${formatAmount(projectedUsd)}` + : formatRate(bestApy)} + + {t("/year on your tokens")} +
+
+ )} + + {canDeposit && ( +
+ +
+ )} +
+ ); +}; 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..c59d9b5ecd --- /dev/null +++ b/extension/src/popup/components/account/AccountPositions/MyPosition.tsx @@ -0,0 +1,136 @@ +import React from "react"; +import { Icon, Text } from "@stellar/design-system"; +import { useTranslation } from "react-i18next"; + +import { AssetIcons } from "@shared/api/types"; +import { 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; + 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; +} + +/** + * One pool position in full: its totals, and the assets supplied to it. + * + * Rendered in place by the Positions tab as a bottom sheet, 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, 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). + */ +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 ( + + } + customBackAction={onClose} + data-testid="my-position-close" + /> + +
+
+
+ + + {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/PoolCard.tsx b/extension/src/popup/components/account/AccountPositions/PoolCard.tsx new file mode 100644 index 0000000000..cd9aa437d7 --- /dev/null +++ b/extension/src/popup/components/account/AccountPositions/PoolCard.tsx @@ -0,0 +1,70 @@ +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/PositionRow.tsx b/extension/src/popup/components/account/AccountPositions/PositionRow.tsx new file mode 100644 index 0000000000..4136e0b612 --- /dev/null +++ b/extension/src/popup/components/account/AccountPositions/PositionRow.tsx @@ -0,0 +1,60 @@ +import React from "react"; +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 { + formatAccountUsd, + formatRate, +} from "popup/components/earn/helpers/formatPoolStats"; +import { NO_FIAT_VALUE } from "popup/helpers/formatters"; + +interface PositionRowProps { + row: PositionTokenRow; + assetIcons: AssetIcons; + onClick: () => void; +} + +/** + * 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. 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(); + + return ( + + {row.apy === null + ? NO_FIAT_VALUE + : t("{{rate}} APY", { rate: formatRate(row.apy) })} + + } + rightSlot={ +
+ {formatAccountUsd(row.suppliedUsd)} +
+ } + onClick={onClick} + data-testid={`position-row-${row.code}`} + /> + ); +}; 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..a946efe581 --- /dev/null +++ b/extension/src/popup/components/account/AccountPositions/__tests__/AccountPositions.sheet.test.tsx @@ -0,0 +1,272 @@ +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 { AccountPositions } from "popup/components/account/AccountPositions"; +import { TEST_PUBLIC_KEY, Wrapper } from "popup/__testHelpers__"; + +const POOL_ID = "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD"; +const POOL_ID_2 = "CBKJ2R5UM6VXAYXHQPUW3ZO5RRP5FS3XPBOZFQ2Q4WFXA3Y3XZATV3XM"; +const USDC_SAC = "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75"; +const XLM_SAC = "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA"; + +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 onePosition = withSupply([supply()]); +const twoAssetPositions = withSupply([ + supply({ + assetId: XLM_SAC, + symbol: null, + name: null, + usdValue: 120.04, + }), + supply(), +]); + +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 pool2 = { ...pool, id: POOL_ID_2, name: "Variable Pool" }; + +const poolAPosition = { + protocol: "blend", + id: POOL_ID, + name: "Fixed Pool v2", + netUsd: 620.16, + suppliedUsd: 620.16, + borrowedUsd: 0, + netApy: 0.16, + blend: { supply: [supply()], borrow: [] }, +}; +const poolBPosition = { + protocol: "blend", + id: POOL_ID_2, + name: "Variable Pool", + netUsd: 500.12, + suppliedUsd: 500.12, + borrowedUsd: 0, + netApy: 0.1, + blend: { supply: [supply()], borrow: [] }, +}; + +// Two independent pool positions -- for the stale-modal regression below, +// which needs a second pool to switch to after closing the first. +const twoPoolPositions = { + address: TEST_PUBLIC_KEY, + totalValueUsd: 1120.28, + netApy: 0.16, + positions: [poolAPosition, poolBPosition], + backstop: [], +} as never; + +// The 30-second refresh tick's view of the world once POOL_ID has dropped out +// -- e.g. closed, drained, or otherwise no longer carrying a position -- +// while POOL_ID_2 is unaffected. Used to simulate that tick landing while My +// position is open for POOL_ID. +const withoutFirstPool = { + address: TEST_PUBLIC_KEY, + totalValueUsd: poolBPosition.netUsd, + netApy: poolBPosition.netApy, + positions: [poolBPosition], + backstop: [], +} as never; + +const renderTab = ( + props: Partial>, +) => + render( + + {}} + pools={[]} + onDeposit={() => {}} + {...props} + /> + , + ); + +/** + * Covers the tab's route into My position through a tapped pool card. + * + * PoolDetailsSheet -- its own tabs, its Deposit button, the metrics that path + * fires -- is nested one level down inside My position, and is covered by + * PoolDetailsSheet's own tests plus MyPosition.test.tsx. What this file owns: + * My position opens for the tapped pool, whether or not the catalog carries a + * matching entry for it. + */ +describe("AccountPositions pool sheet", () => { + 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(`pool-card-${POOL_ID}`)); + + expect(await screen.findByTestId("my-position-sheet")).toBeInTheDocument(); + }); + + 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(`pool-card-${POOL_ID}`)); + + expect(await screen.findByTestId("my-position-sheet")).toBeInTheDocument(); + }); + + it("About pool opens the sheet with no tabs", async () => { + renderTab({ positions: twoAssetPositions, pools: [pool] }); + + fireEvent.click(screen.getByTestId(`pool-card-${POOL_ID}`)); + fireEvent.click(await screen.findByTestId("my-position-about-pool")); + + expect( + await screen.findByTestId("earn-pool-details-sheet"), + ).toBeInTheDocument(); + expect( + screen.queryByTestId("earn-pool-details-tabs"), + ).not.toBeInTheDocument(); + }); + + it("a supplied asset opens the sheet on Your position", async () => { + renderTab({ positions: twoAssetPositions, pools: [pool] }); + + fireEvent.click(screen.getByTestId(`pool-card-${POOL_ID}`)); + fireEvent.click(await screen.findByTestId("position-row-USDC")); + + expect( + await screen.findByTestId("earn-position-panel"), + ).toBeInTheDocument(); + expect(screen.getByTestId("earn-pool-details-tabs")).toBeInTheDocument(); + // Pins the panel to the tapped asset, not the pool's other row: USDC's + // 500.12 here, never XLM's 120.04. + expect(screen.getByTestId("earn-position-balance")).toHaveTextContent( + "$500.12", + ); + }); + + it("does not carry a stale pool-details view into the next pool's My position", async () => { + // Open pool A's My position, open About pool inside it, then close My + // position without closing the nested sheet first. If closing My + // position didn't also clear that nested state, it would reappear the + // moment ANY pool card is tapped next -- including pool B's, over a + // position it was never about. + renderTab({ positions: twoPoolPositions, pools: [pool, pool2] }); + + fireEvent.click(screen.getByTestId(`pool-card-${POOL_ID}`)); + fireEvent.click(await screen.findByTestId("my-position-about-pool")); + expect( + await screen.findByTestId("earn-pool-details-sheet"), + ).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId("my-position-close")); + expect(screen.queryByTestId("my-position-sheet")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByTestId(`pool-card-${POOL_ID_2}`)); + expect(await screen.findByTestId("my-position-sheet")).toBeInTheDocument(); + expect( + screen.queryByTestId("earn-pool-details-sheet"), + ).not.toBeInTheDocument(); + }); + + it("does not reopen My position on its own when a refresh tick brings the pool back", async () => { + // Radix's Sheet is controlled by `open={Boolean(selectedPosition)}`. A + // 30-second refresh tick that drops the selected pool closes the sheet + // declaratively -- Radix's own onOpenChange never fires for that, so + // nothing clears `selectedPoolId` unless the component does it itself. If + // it didn't, the pool reappearing on a later tick would flip + // `selectedPosition` truthy again and the sheet would reopen with no tap + // from the account. + const { rerender } = renderTab({ + positions: twoPoolPositions, + pools: [pool, pool2], + }); + + fireEvent.click(screen.getByTestId(`pool-card-${POOL_ID}`)); + expect(await screen.findByTestId("my-position-sheet")).toBeInTheDocument(); + + const rerenderTab = ( + props: Partial>, + ) => + rerender( + + {}} + pools={[pool, pool2]} + onDeposit={() => {}} + {...props} + /> + , + ); + + // The tick that drops POOL_ID. + rerenderTab({ positions: withoutFirstPool }); + expect(screen.queryByTestId("my-position-sheet")).not.toBeInTheDocument(); + + // The tick that brings it back. + rerenderTab({ positions: twoPoolPositions }); + expect(screen.queryByTestId("my-position-sheet")).not.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 new file mode 100644 index 0000000000..d92bff34b0 --- /dev/null +++ b/extension/src/popup/components/account/AccountPositions/__tests__/AccountPositions.test.tsx @@ -0,0 +1,213 @@ +import React from "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__"; + +const POOL_ID = "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD"; +const USDC_SAC = "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75"; +const XLM_SAC = "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA"; + +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 noPositions = { + address: TEST_PUBLIC_KEY, + totalValueUsd: null, + netApy: null, + positions: [], + backstop: [], +} as never; + +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 defaultState = { + settings: { + networkDetails: MAINNET_NETWORK_DETAILS, + networksList: [MAINNET_NETWORK_DETAILS], + }, +}; + +const renderTab = ( + props: Partial> & { + state?: Record; + }, +) => { + const { state, ...componentProps } = props; + return render( + + {}} + pools={[]} + onDeposit={() => {}} + {...componentProps} + /> + , + ); +}; + +describe("AccountPositions", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + 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: noPositions }); + + expect(screen.getByText("No positions yet")).toBeInTheDocument(); + }); + + it("promises a dollar figure when the account holds depositable tokens", () => { + renderTab({ + positions: noPositions, + projectedUsd: "89.32", + bestApy: 0.1694, + }); + + expect( + screen.getByTestId("account-positions-projection"), + ).toHaveTextContent("$89.32"); + }); + + it("promises the ceiling rate when it holds none", () => { + renderTab({ positions: noPositions, projectedUsd: null, bestApy: 0.1694 }); + + expect( + screen.getByTestId("account-positions-projection"), + ).toHaveTextContent("16.94%"); + }); + + it("omits the card entirely when neither figure is known", () => { + renderTab({ positions: noPositions, projectedUsd: null, bestApy: null }); + + expect( + screen.queryByTestId("account-positions-projection"), + ).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. + // + // PoolCard 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.getAllByTestId(/^pool-card-[A-Z0-9]+$/)).toHaveLength(1); + expect(screen.queryByTestId("position-row-USDC")).not.toBeInTheDocument(); + }); + + it("opens My position when a pool card is tapped", async () => { + renderTab({ positions: twoAssetPositions, pools: [pool] }); + + fireEvent.click(screen.getByTestId(`pool-card-${POOL_ID}`)); + + expect(await screen.findByTestId("my-position-sheet")).toBeInTheDocument(); + }); + + it("closes My position from its X without leaving the tab", async () => { + renderTab({ positions: twoAssetPositions, pools: [pool] }); + + fireEvent.click(screen.getByTestId(`pool-card-${POOL_ID}`)); + fireEvent.click(await screen.findByTestId("my-position-close")); + + await waitFor(() => + expect(screen.queryByTestId("my-position-sheet")).not.toBeInTheDocument(), + ); + expect(screen.getByTestId(`pool-card-${POOL_ID}`)).toBeInTheDocument(); + }); +}); 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..1de49ac873 --- /dev/null +++ b/extension/src/popup/components/account/AccountPositions/__tests__/MyPosition.test.tsx @@ -0,0 +1,121 @@ +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__/PoolCard.test.tsx b/extension/src/popup/components/account/AccountPositions/__tests__/PoolCard.test.tsx new file mode 100644 index 0000000000..47837bfe56 --- /dev/null +++ b/extension/src/popup/components/account/AccountPositions/__tests__/PoolCard.test.tsx @@ -0,0 +1,108 @@ +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", + ); + // 31.75 / (1284.32 - 31.75) works out to 0.025347..., which formatRate's + // half-up rounding renders as "2.53%" -- verified independently with + // BigNumber, and against getPoolPositionSummary's actual output rather + // than hand-rounded expectations. + 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", + ); + }); + + 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/__tests__/PositionRow.test.tsx b/extension/src/popup/components/account/AccountPositions/__tests__/PositionRow.test.tsx new file mode 100644 index 0000000000..ed8a2927fc --- /dev/null +++ b/extension/src/popup/components/account/AccountPositions/__tests__/PositionRow.test.tsx @@ -0,0 +1,74 @@ +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 now lives inside MyPosition, which drops the interest-gain + * column -- that figure 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", () => { + renderRow({ row: row({ suppliedUsd: null }) }); + + expect(screen.getByTestId("position-value-USDC")).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/components/account/AccountPositions/index.tsx b/extension/src/popup/components/account/AccountPositions/index.tsx new file mode 100644 index 0000000000..c74d4b8529 --- /dev/null +++ b/extension/src/popup/components/account/AccountPositions/index.tsx @@ -0,0 +1,272 @@ +import React, { useEffect, useState } from "react"; +import { Loader, Notification } from "@stellar/design-system"; +import { useTranslation } from "react-i18next"; + +import { + AccountPositions as AccountPositionsData, + BlendCatalogPool, +} from "@shared/api/types/blend"; +import { AssetIcons } from "@shared/api/types"; +import { NetworkDetails } from "@shared/constants/stellar"; + +import { PositionTokenRow } from "popup/components/earn/helpers/positionRows"; +import { + Sheet, + SheetContent, + SheetTitle, + ScreenReaderOnly, +} 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"; + +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; + assetIcons: AssetIcons; + networkDetails: NetworkDetails; + /** 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. */ + bestApy: number | null; + /** Empty state's CTA was pressed, before it navigates to Earn. */ + onStartEarning: () => void; + /** 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; +} + +/** + * 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. + * + * Lists one card per pool 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. + */ +export const AccountPositions = ({ + positions, + isLoading, + hasError, + assetIcons, + networkDetails, + projectedUsd, + bestApy, + onStartEarning, + pools, + onDeposit, +}: AccountPositionsProps) => { + const { t } = useTranslation(); + // 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); + // What the pool-details modal nested inside My position is showing, if + // anything. Cleared whenever My position itself closes (see + // `closeMyPosition`) -- otherwise this survives to the next pool card tap + // and reopens over a position it was never about. + const [sheetMode, setSheetMode] = useState< + { kind: "about" } | { kind: "asset"; row: PositionTokenRow } | null + >(null); + + const closeMyPosition = () => { + setSelectedPoolId(null); + setSheetMode(null); + }; + + // Resolved from the tapped card's id rather than held directly, so the sheet + // always renders the catalog's live figures instead of a snapshot taken at + // click time. Computed unconditionally, ahead of the early returns, so the + // effect below can watch it on every render. + const poolPositions = positions?.positions ?? []; + const selectedPosition = + poolPositions.find((p) => p.id === selectedPoolId) ?? null; + + // A 30-second refresh tick can drop the selected pool from `positions` + // (closed, drained, etc.) without any user interaction. Radix's Sheet is + // controlled by `open={Boolean(selectedPosition)}`, so the sheet closes on + // its own the moment that happens -- but `selectedPoolId` and `sheetMode` + // stay set. If the pool reappears on a later tick, `selectedPosition` + // becomes truthy again and the sheet reopens by itself, nested modal + // restored, with no tap from the account. Clear both the moment the + // selection stops resolving to a live position. + useEffect(() => { + if (selectedPoolId !== null && selectedPosition === null) { + setSelectedPoolId(null); + setSheetMode(null); + } + }, [selectedPoolId, selectedPosition]); + + if (isLoading) { + return ( +
+
+ +
+
+ ); + } + + if (hasError) { + return ( +
+
+ + {t("Your positions could not be fetched at this time.")} + +
+
+ ); + } + + if (!poolPositions.length) { + return ( +
+ +
+ ); + } + + const selectedPool = pools.find((p) => p.id === selectedPoolId) ?? null; + + return ( +
+
+ {poolPositions.map((position) => ( + { + trackPoolSelected({ + poolId: position.id, + protocol: position.protocol, + }); + setSelectedPoolId(position.id); + }} + /> + ))} +
+ + !open && closeMyPosition()} + > + e.preventDefault()} + aria-describedby={undefined} + side="bottom" + className="AccountPositions__my-position__wrapper" + > + + {t("My position")} + + {selectedPosition && ( + { + 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)} + hasBackdrop + > + {sheetMode && selectedPool && selectedPosition ? ( + 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) + : undefined + } + /> + ) : ( +
+ )} + + + +
+ ); +}; 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..73da4e60c1 --- /dev/null +++ b/extension/src/popup/components/account/AccountPositions/styles.scss @@ -0,0 +1,418 @@ +@use "../../../styles/utils.scss" as *; + +// Figma node 9808-30015 (the empty state, "Positions/emtpy"), pulled once the +// design file was reachable again. Values below are read off that node; see +// the per-rule notes for anything the node left unspecified. +.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 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; + justify-content: center; + min-height: pxToRem(108px); + color: var(--sds-clr-gray-09); + } + + // No node covers a fetch-failure state -- Figma has nothing between "loaded" + // and "empty" -- so this stays unstyled. `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. + + &__empty { + // Node 9808-30015: the title/subtitle pair sits centered with a 6px gap; + // 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(6px); + padding-top: 0; + + &__title { + color: var(--sds-clr-gray-12); + font-size: pxToRem(14px); + font-weight: var(--sds-fw-medium); + line-height: pxToRem(20px); + } + + &__subtitle { + color: var(--sds-clr-gray-11); + font-size: pxToRem(12px); + font-weight: var(--sds-fw-regular); + line-height: pxToRem(18px); + } + + // The "you could earn up to" card. Node 9808-30015's "Row". + &__projection { + display: flex; + flex-direction: column; + align-items: center; + align-self: stretch; + gap: pxToRem(4px); + // 6px of the column's own gap above plus 6px here lands the card 12px + // under the subtitle, the node's own gap between the text block and + // this card. + margin-top: pxToRem(6px); + padding: pxToRem(12px) pxToRem(16px); + border-radius: pxToRem(16px); + background: var(--sds-clr-gray-03); + + &__label { + color: var(--sds-clr-gray-12); + font-size: pxToRem(12px); + font-weight: var(--sds-fw-medium); + line-height: pxToRem(18px); + } + + // Wraps the coloured figure plus the trailing "/year on your tokens" + // text -- the node keeps both at the same small size as the label + // above; there is no "big number" treatment here. + &__value { + color: var(--sds-clr-gray-11); + font-size: pxToRem(12px); + font-weight: var(--sds-fw-medium); + line-height: pxToRem(18px); + } + + // green-11: node 9808-30558 defines `Success/Text/Primary` = #4cc38a, + // which is `--sds-clr-green-11` in this dark theme -- the shade this + // whole feature's gains and rates now use (see `PositionRow__apy` and + // `PositionsPoolCard__gain` below, and `MyPosition__gain`). + &__figure { + color: var(--sds-clr-green-11); + font-size: pxToRem(12px); + font-weight: var(--sds-fw-medium); + line-height: pxToRem(18px); + } + } + + // 6px of the column's own gap plus 6px here lands the button 12px under + // whatever sits above it (the projection card when shown, the subtitle + // when not) -- the node's own gap value once more. + &__cta { + margin-top: pxToRem(6px); + } + + // Same weight bump every other empty-state CTA in this codebase gives its + // button (AccountCollectibles, NotFundedMessage). + .Button { + font-weight: var(--sds-fw-semi-bold); + } + } + + // One `PositionsPoolCard` per pool. Node 9808-30558 shows only a single + // pool, so the inter-card gap isn't directly observable there; 8px matches + // the card's own internal vertical padding (see `.PositionsPoolCard` + // below) rather than being invented from nothing. Worth confirming once a + // multi-pool state exists in Figma. + &__list { + display: flex; + flex-direction: column; + gap: pxToRem(8px); + } + + // 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 wraps BalanceRow inside the My position sheet and colours its +// rate figure green. Figma node 9956-72252's supplied-asset rows (e.g. "1.87% +// APY") give the treatment: Text/XS/400 (12px regular, 18px line-height) in +// `Success/Text/Primary`. +.PositionRow { + // Sits inside BalanceRow's own `&__amount` div (the `amountSlot` prop), so + // font-size/line-height already come from `.BalanceRow__amount` by + // inheritance -- but that rule's font-weight is medium, and the node wants + // this line regular, so weight needs its own override too. + // + // 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 `PositionsPoolCard__gain` and + // `MyPosition__gain` below). + &__apy { + color: var(--sds-clr-green-11); + font-weight: var(--sds-fw-regular); + } + + // 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 -- see that file's `&__gain` for its replacement. + + // Primary (now only) right-side line, matching `.BalanceRow__fiat`. Not a + // coloured figure -- a supplied total, not a gain -- so it stays neutral. + // Matches node 9956-72252's "$784.20"/"$500.12" figures (14px medium, + // primary). + &__value { + font-size: pxToRem(14px); + line-height: pxToRem(20px); + font-weight: var(--sds-fw-medium); + color: var(--sds-clr-gray-12); + } +} + +// The Positions tab's one-row-per-pool card, Figma node 9808-30558's "Row". +// +// 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. +.PositionsPoolCard { + display: flex; + align-items: center; + gap: pxToRem(12px); + width: 100%; + padding: pxToRem(8px) pxToRem(12px); + border: none; + border-radius: pxToRem(16px); + background: var(--sds-clr-gray-03); + text-align: left; + cursor: pointer; + font: inherit; + color: inherit; + + // `PoolIcon` is already 32px square by default (`PoolIcon/styles.scss`), + // matching the node's 32px "dApp" icon exactly -- no override needed here. + + &__body { + display: flex; + flex: 1; + min-width: 0; + flex-direction: column; + gap: pxToRem(2px); + } + + // "Fixed Pool v2 · Blend". Text/XS/400 (12px regular, 18px line-height); + // the pool name inherits this row's own `color: inherit` (primary), and + // only the " · Blend" suffix needs its own secondary colour. + &__identity { + font-size: pxToRem(12px); + font-weight: var(--sds-fw-regular); + line-height: pxToRem(18px); + } + + &__protocol { + color: var(--sds-clr-gray-11); + } + + &__figures { + display: flex; + align-items: center; + gap: pxToRem(4px); + } + + // "$1,284.32". Text/SM/500 (14px medium, 20px line-height), primary. + &__value { + font-size: pxToRem(14px); + font-weight: var(--sds-fw-medium); + line-height: pxToRem(20px); + color: var(--sds-clr-gray-12); + } + + // "+2.54%". Same Text/SM/500 scale as `&__value`, at 80% opacity over + // green-11 -- the node applies `opacity-80` on top of the colour itself, + // not a lighter green token. + // + // 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 + // `MyPosition__gain`). + &__gain { + font-size: pxToRem(14px); + font-weight: var(--sds-fw-medium); + line-height: pxToRem(20px); + color: var(--sds-clr-green-11); + opacity: 0.8; + } + + // "10.23% APY". Text/XS/500 (12px medium, 18px line-height), secondary. + &__apy { + font-size: pxToRem(12px); + font-weight: var(--sds-fw-medium); + line-height: pxToRem(18px); + color: var(--sds-clr-gray-11); + } +} + +// The My position sheet's body: totals header, "About pool" pill, and the +// supplied-asset list. Figma node 9956-72252. +// +// 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; + align-items: center; + justify-content: space-between; + } + + // 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), then styled as its own outlined pill: bg + // background-primary, 1px border-primary, full rounding, px-10/py-6, + // 4px gap to the chevron, Text/SM/600. + &__about { + display: flex; + align-items: center; + justify-content: center; + gap: pxToRem(4px); + height: pxToRem(32px); + padding: 0 pxToRem(10px); + border: 1px solid var(--sds-clr-gray-06); + border-radius: pxToRem(100px); + background: var(--sds-clr-gray-01); + font-size: pxToRem(14px); + font-weight: var(--sds-fw-semi-bold); + line-height: pxToRem(20px); + cursor: pointer; + color: inherit; + + svg { + width: pxToRem(14px); + height: pxToRem(14px); + } + } + + // Pool name above the total -- Text/SM/500, secondary. `Text`'s own + // `size="sm"` prop supplies the 14px scale; `weight="medium"` and this + // colour are the two things it doesn't default to. + &__pool-name { + color: var(--sds-clr-gray-11); + } + + // "$1,284.32" -- Display/XS/400: 24px regular (not medium), -4% + // letter-spacing, matching `AccountHeader__total-usd-balance`'s own + // tracking convention at this smaller size. + &__total { + margin-top: pxToRem(4px); // 8px (own gap) + 4px = 12px under the pool name + font-size: pxToRem(24px); + font-weight: var(--sds-fw-regular); + line-height: pxToRem(32px); + letter-spacing: pxToRem(-0.96px); + color: var(--sds-clr-gray-12); + } + + // "+31.75 (2.54%)" -- Text/SM/500, green. + // + // 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`). + &__gain { + font-size: pxToRem(14px); + font-weight: var(--sds-fw-medium); + line-height: pxToRem(20px); + color: var(--sds-clr-green-11); + } + + // "Supplied asset" section label -- Text/SM/500, primary. `size="sm"` on + // the `Text` element supplies the 14px scale but not this 20px + // line-height (SDS's own `sm` is 22px) or the medium weight. + &__section-title { + margin-top: pxToRem(16px); // 8px (own gap) + 16px = 24px, the node's own + // gap ahead of the "Supplied asset" section. + line-height: pxToRem(20px); + } + + // One `PositionRow` per supplied asset. The node draws each as its own + // bg-tertiary, rounded-12 card (px-12/py-8) rather than a plain BalanceRow + // list; scoped onto `.BalanceRow` here (PositionRow's root, via + // `BalanceRow/index.tsx`) rather than PositionRow's own markup, so no + // component changes are needed to get the card look. The node's 40px + // asset icon and 12px icon-to-text gap are `AssetIcon`'s own, shared, + // fixed-32px sizing (`AccountAssets/styles.scss`) -- not resized here for + // the same reason `PoolIcon` isn't above: every other `AssetIcon` caller + // (the Tokens tab, EarnReview, the pool details sheet) wants the smaller + // size. See the task report. + &__assets { + display: flex; + flex-direction: column; + gap: pxToRem(12px); + + .BalanceRow { + padding: pxToRem(8px) pxToRem(12px); + border-radius: pxToRem(12px); + background: var(--sds-clr-gray-03); + } + } +} diff --git a/extension/src/popup/components/account/AccountTabs/index.tsx b/extension/src/popup/components/account/AccountTabs/index.tsx index 2bd66ce021..077caf7757 100644 --- a/extension/src/popup/components/account/AccountTabs/index.tsx +++ b/extension/src/popup/components/account/AccountTabs/index.tsx @@ -1,7 +1,8 @@ /** * @fileoverview AccountTabs renders the Home screen's tab row. Adding tokens and * collectibles is handled by FloatingAddButton; hidden collectibles are reached - * from the Add Collectible screen. + * from the Add Collectible screen. Positions has no add flow of its own -- a + * position is opened by depositing through Earn, not through this row. */ import React from "react"; @@ -12,6 +13,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"; @@ -19,7 +21,8 @@ import "./styles.scss"; /** * Tab navigation for the account view. Hides the collectibles tab on custom - * networks, where collectibles are unsupported. + * networks, where collectibles are unsupported, and the positions tab on any + * network where Earn is unsupported (every network but PUBLIC and TESTNET). */ export const TabButtons = () => { const { t } = useTranslation(); @@ -28,6 +31,7 @@ export const TabButtons = () => { const tabLabels: Record = { tokens: t("Tokens"), + positions: t("Positions"), collectibles: t("Collectibles"), }; @@ -38,6 +42,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 (
; }) => 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, @@ -68,9 +71,44 @@ describe("FloatingAddButton", () => { }); }); + describe("Positions tab", () => { + // Regression case: before the Positions tab existed, `!isTokensTab` meant + // exactly "Collectibles". Inserting a third `TabsList` member without an + // explicit guard here made it mean "Positions or Collectibles", and for a + // 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. 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(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, @@ -84,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, @@ -97,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, @@ -109,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, @@ -122,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 78b11a3b5e..dd43c3e2eb 100644 --- a/extension/src/popup/components/account/FloatingAddButton/index.tsx +++ b/extension/src/popup/components/account/FloatingAddButton/index.tsx @@ -1,9 +1,14 @@ 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 { 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"; import { TabsList } from "popup/views/Account/contexts/activeTabContext"; import { useActiveTab } from "popup/components/account/AccountTabs/hooks/useActiveTab"; @@ -32,6 +37,31 @@ export const FloatingAddButton = ({ }: FloatingAddButtonProps) => { const { t } = useTranslation(); const { activeTab } = useActiveTab(); + const isEarnDepositEnabled = useSelector(earnDepositSelector); + const networkDetails = useSelector(settingsNetworkDetailsSelector); + + // 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) { + if (!isEarnDepositEnabled || !isEarnSupportedNetwork(networkDetails)) { + return null; + } + return ( + + + {t("Deposit")} + + ); + } const isTokensTab = activeTab === TabsList.TOKENS; 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..f574bf49f1 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. +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..305997b355 100644 --- a/extension/src/popup/components/earn/EarnAmount/index.tsx +++ b/extension/src/popup/components/earn/EarnAmount/index.tsx @@ -28,11 +28,13 @@ 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 { + POOL_DETAILS_SOURCE, trackEarnPercentAmountSelected, + trackEarnPoolDetailsOpened, + trackEarnPoolDetailsTabSelected, trackEarnSimulationFailed, trackEarnXlmFeeInsufficientShown, } from "popup/metrics/earn"; @@ -46,6 +48,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 +91,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 +159,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); @@ -398,8 +408,9 @@ 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: POOL_DETAILS_SOURCE.EARN_AMOUNT, }); setIsPoolSheetOpen(true); }} @@ -468,7 +479,20 @@ export const EarnAmount = ({ goBack, onConfirm }: EarnAmountProps) => { {pool ? ( setIsPoolSheetOpen(false)} + onTabChange={(tab) => + trackEarnPoolDetailsTabSelected({ + poolId: pool.id, + tab, + source: POOL_DETAILS_SOURCE.EARN_AMOUNT, + }) + } /> ) : (
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/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/PoolDetailsSheet/Tabs.tsx b/extension/src/popup/components/earn/PoolDetailsSheet/Tabs.tsx new file mode 100644 index 0000000000..6191a3a25a --- /dev/null +++ b/extension/src/popup/components/earn/PoolDetailsSheet/Tabs.tsx @@ -0,0 +1,46 @@ +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..6d791872a1 --- /dev/null +++ b/extension/src/popup/components/earn/PoolDetailsSheet/YourPosition.tsx @@ -0,0 +1,157 @@ +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 { + formatAccountUsd, + formatRate, +} from "popup/components/earn/helpers/formatPoolStats"; +import { formatProjection } from "popup/components/earn/EarnReview/helpers/projectEarnings"; +import { NO_FIAT_VALUE, 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 + ? NO_FIAT_VALUE + : `${isGain ? "+" : ""}${formatAccountUsd(row.usd)}`} +
+
+ {`${formatAmount(row.tokens)} ${row.code}`} +
+
+
+); + +/** + * The account's stake in this pool, scoped to one supplied asset. + * + * 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, + focusedAssetId, + assetIcons, +}: YourPositionProps) => { + const { t } = useTranslation(); + const networkDetails = useSelector(settingsNetworkDetailsSelector); + const summary = getPositionSummary({ + position, + focusedAssetId, + networkDetails, + }); + + return ( +
+
+
+ + {t("Current Balance")} + +
+ {formatAccountUsd(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 position's balance + and rate — not one pair per earnings row. */} + + +
+
+ ); +}; 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..874307a753 --- /dev/null +++ b/extension/src/popup/components/earn/PoolDetailsSheet/__tests__/PoolDetailsSheet.tabs.test.tsx @@ -0,0 +1,179 @@ +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__"; + +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 defaultState = { + settings: { + networkDetails: MAINNET_NETWORK_DETAILS, + networksList: [MAINNET_NETWORK_DETAILS], + }, +}; + +const renderSheet = ({ + state, + ...props +}: { state?: Record } & Record = {}) => + 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("renders untabbed when focusedAssetId names an asset the position doesn't hold", () => { + // 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, + state: { remoteConfig: { earn_deposit: true } }, + }); + + fireEvent.click(screen.getByText("Deposit")); + 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() }); + + 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); + }); + + it("hides the tabs and shows Overview alone when overviewOnly is set", () => { + // "About pool" asks about the pool. The account may well have a position, + // but that is not what was asked. + renderSheet({ position, overviewOnly: true, onDeposit: jest.fn() }); + + expect( + screen.queryByTestId("earn-pool-details-tabs"), + ).not.toBeInTheDocument(); + expect(screen.getByTestId("earn-pool-interest-apy")).toBeInTheDocument(); + expect(screen.queryByTestId("earn-position-panel")).not.toBeInTheDocument(); + }); + + it("keeps the CTA as Close in overviewOnly, even with a deposit handler", () => { + const onDeposit = jest.fn(); + renderSheet({ position, overviewOnly: true, onDeposit }); + + expect(screen.getByText("Close")).toBeInTheDocument(); + expect(screen.queryByText("Deposit")).not.toBeInTheDocument(); + }); +}); diff --git a/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx b/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx index f088847999..4dda9e4b21 100644 --- a/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx +++ b/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx @@ -1,27 +1,57 @@ import React from "react"; import { Button, Icon, Text } from "@stellar/design-system"; +import { useSelector } from "react-redux"; 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 { 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"; import { formatCompactUsd, 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; + /** Hide the tab strip and show Overview alone, even when a position exists. + * "About pool" asks about the pool, not about the user's stake in it. */ + overviewOnly?: boolean; } /** - * 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,18 +63,157 @@ 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` 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 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 the deposit flow opens this sheet mid-deposit with a position + * already on file but no Deposit action of its own to offer. + * + * `overviewOnly` is a third configuration layered on top of both: the + * account may well hold a position, but "About pool" asked about the pool, + * not the stake in it. It gates `hasPosition` itself, so the tab strip hides + * and the Overview pane renders through the same path a genuinely + * position-less account takes — no separate branch — and the CTA stays + * Close even when `onDeposit` is supplied. + */ +export const PoolDetailsSheet = ({ + pool, + onClose, + position, + focusedAssetId, + onDeposit, + defaultTab, + onTabChange, + assetIcons = {}, + 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", + ); + 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 (
- + {pool.name || t("Blend pool")} - + {t("by Blend")}
@@ -59,80 +228,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..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; @@ -103,4 +128,188 @@ border-radius: pxToRem(12); padding: 0 pxToRem(16); } + + // 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(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(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 { + // 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; + // 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); + // 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; + } + + // "$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); + } + + // "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 + // 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; + } + + // 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; + 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: 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); + } + + &__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/components/earn/helpers/__tests__/earnProjection.test.ts b/extension/src/popup/components/earn/helpers/__tests__/earnProjection.test.ts new file mode 100644 index 0000000000..b330559e96 --- /dev/null +++ b/extension/src/popup/components/earn/helpers/__tests__/earnProjection.test.ts @@ -0,0 +1,139 @@ +import BigNumber from "bignumber.js"; + +import { MAINNET_NETWORK_DETAILS } from "@shared/constants/stellar"; +import { PUBLIC_SACS } from "@shared/constants/sac"; +import { getBestEarnApy, projectAnnualEarnings } from "../earnProjection"; + +const networkDetails = MAINNET_NETWORK_DETAILS; +const USDC_SAC = PUBLIC_SACS.USDC!; +const XLM_SAC = PUBLIC_SACS.XLM; +const USDC_ISSUER = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; + +const option = ( + assetId: string, + apys: (number | null)[], + symbol: string | null, +) => ({ + assetId, + symbol, + name: symbol ? `${symbol}:${USDC_ISSUER}` : null, + decimals: 7, + pools: apys.map((supplyApy, i) => ({ + id: `POOL${i}`, + name: `Pool ${i}`, + supplyApy, + emissionsSupplyApr: 0, + suppliedUsd: 1, + })), +}); + +const usdcBalance = { + token: { code: "USDC", issuer: { key: USDC_ISSUER } }, + contractId: USDC_SAC, + total: new BigNumber("500"), + available: new BigNumber("500"), +}; +const xlmBalance = { + token: { type: "native", code: "XLM" }, + total: new BigNumber("38"), + available: new BigNumber("38"), +}; + +const prices = { + [`USDC:${USDC_ISSUER}`]: { + currentPrice: "1.00", + percentagePriceChange24h: "0", + }, + native: { currentPrice: "1.00", percentagePriceChange24h: "0" }, +} as never; + +describe("getBestEarnApy", () => { + it("returns the highest rate on offer anywhere", () => { + expect( + getBestEarnApy([option(USDC_SAC, [0.12, 0.1694], "USDC")]), + ).toBeCloseTo(0.1694); + }); + + 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", () => { + // 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", () => { + it("sums each held token's value at that token's best rate", () => { + const result = projectAnnualEarnings({ + options: [ + option(USDC_SAC, [0.1694], "USDC"), + option(XLM_SAC, [0.121], null), + ], + balances: [usdcBalance, xlmBalance] as never, + tokenPrices: prices, + networkDetails, + }); + + // 500 * 0.1694 = 84.70, 38 * 0.121 = 4.598 -> 89.30 + expect(result.usd).toBe("89.30"); + }); + + it("ignores tokens no pool accepts", () => { + const result = projectAnnualEarnings({ + options: [option(USDC_SAC, [0.1694], "USDC")], + balances: [usdcBalance, xlmBalance] as never, + tokenPrices: prices, + networkDetails, + }); + + expect(result.usd).toBe("84.70"); + }); + + it("falls back to the best rate alone when nothing supported is held", () => { + // A new account holds no depositable token, so there is no dollar figure to + // promise — the card shows the ceiling rate instead. + const result = projectAnnualEarnings({ + options: [option(USDC_SAC, [0.1694], "USDC")], + balances: [], + tokenPrices: prices, + networkDetails, + }); + + expect(result.usd).toBeNull(); + expect(result.bestApy).toBeCloseTo(0.1694); + }); + + it("skips a held token with no price rather than valuing it at zero", () => { + const result = projectAnnualEarnings({ + options: [option(USDC_SAC, [0.1694], "USDC")], + balances: [usdcBalance] as never, + tokenPrices: {}, + networkDetails, + }); + + expect(result.usd).toBeNull(); + }); + + it("returns nothing to show when the catalog never arrived", () => { + const result = projectAnnualEarnings({ + options: null, + balances: [usdcBalance] as never, + tokenPrices: prices, + networkDetails, + }); + + expect(result).toEqual({ usd: null, bestApy: null }); + }); +}); 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..be4633725a 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,39 @@ -import { formatCompactUsd, formatRate } from "../formatPoolStats"; +import { + formatAccountUsd, + formatCompactUsd, + formatRate, +} from "../formatPoolStats"; + +describe("formatAccountUsd", () => { + it("renders a four-figure balance in full, never compact", () => { + // 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"); + }); + + 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", () => { it("renders millions compactly", () => { 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..9d17b5434c --- /dev/null +++ b/extension/src/popup/components/earn/helpers/__tests__/poolPositionSummary.test.ts @@ -0,0 +1,154 @@ +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", () => { + // 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()], { suppliedUsd: null, 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 })], { + suppliedUsd: 50, + netUsd: 50, + }), + ); + + expect(result.gainPercent).toBeNull(); + }); + + 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("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", + 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__/positionRows.test.ts b/extension/src/popup/components/earn/helpers/__tests__/positionRows.test.ts new file mode 100644 index 0000000000..29b969d201 --- /dev/null +++ b/extension/src/popup/components/earn/helpers/__tests__/positionRows.test.ts @@ -0,0 +1,148 @@ +import { MAINNET_NETWORK_DETAILS } from "@shared/constants/stellar"; +import { PUBLIC_SACS } from "@shared/constants/sac"; +import { toPositionTokenRows } from "../positionRows"; + +const networkDetails = MAINNET_NETWORK_DETAILS; +const USDC_SAC = PUBLIC_SACS.USDC!; +const XLM_SAC = PUBLIC_SACS.XLM; +const POOL_ID = "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD"; + +const supplyRow = (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 accountPositions = (supply: unknown[]) => + ({ + address: "G…", + totalValueUsd: 500.12, + netApy: 0.1694, + positions: [ + { + protocol: "blend", + id: POOL_ID, + name: "Fixed Pool v2", + netUsd: 500.12, + suppliedUsd: 500.12, + borrowedUsd: 0, + netApy: 0.1694, + blend: { supply, borrow: [] }, + }, + ], + backstop: [], + }) as never; + +describe("toPositionTokenRows", () => { + it("returns nothing for a null payload", () => { + expect(toPositionTokenRows({ positions: null, networkDetails })).toEqual( + [], + ); + }); + + it("scales raw token amounts by the row's decimals", () => { + // Raw integer strings in the asset's smallest unit — parsing to Number + // would lose precision on large balances. + const [row] = toPositionTokenRows({ + positions: accountPositions([supplyRow()]), + networkDetails, + }); + + expect(row.suppliedTokens).toBe("500.1223"); + expect(row.decimals).toBe(7); + }); + + it("carries the pool and protocol so the row can open the right sheet", () => { + const [row] = toPositionTokenRows({ + positions: accountPositions([supplyRow()]), + networkDetails, + }); + + expect(row.poolId).toBe(POOL_ID); + expect(row.poolName).toBe("Fixed Pool v2"); + expect(row.protocol).toBe("blend"); + }); + + it("adds emissions to the headline rate", () => { + const [row] = toPositionTokenRows({ + positions: accountPositions([ + supplyRow({ apy: 0.1, emissionsApr: 0.05 }), + ]), + networkDetails, + }); + + expect(row.apy).toBeCloseTo(0.15); + }); + + it("keeps an unavailable rate null rather than treating it as zero", () => { + const [row] = toPositionTokenRows({ + positions: accountPositions([ + supplyRow({ apy: null, emissionsApr: 0.05 }), + ]), + networkDetails, + }); + + expect(row.apy).toBeNull(); + }); + + it("resolves native XLM, which the payload reports with no symbol and no name", () => { + // Its SAC is the only clue — reading `symbol` alone would render a + // truncated contract address as the token code. + const [row] = toPositionTokenRows({ + positions: accountPositions([ + supplyRow({ assetId: XLM_SAC, symbol: null, name: null }), + ]), + networkDetails, + }); + + expect(row.code).toBe("XLM"); + }); + + it("emits one row per supplied asset in a pool", () => { + const rows = toPositionTokenRows({ + positions: accountPositions([ + supplyRow(), + supplyRow({ assetId: XLM_SAC, symbol: null, name: null }), + ]), + networkDetails, + }); + + expect(rows).toHaveLength(2); + expect(rows.every((r) => r.poolId === POOL_ID)).toBe(true); + }); + + it("skips a pool row carrying no blend detail", () => { + const positions = { + address: "G…", + totalValueUsd: null, + netApy: null, + positions: [ + { + protocol: "blend", + id: POOL_ID, + name: null, + netUsd: null, + suppliedUsd: null, + borrowedUsd: null, + netApy: null, + }, + ], + backstop: [], + } as never; + + expect(toPositionTokenRows({ positions, networkDetails })).toEqual([]); + }); +}); 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..85b562c5e7 --- /dev/null +++ b/extension/src/popup/components/earn/helpers/__tests__/positionSummary.test.ts @@ -0,0 +1,231 @@ +import { MAINNET_NETWORK_DETAILS } from "@shared/constants/stellar"; +import { PUBLIC_SACS } from "@shared/constants/sac"; +import { getPositionSummary, hasResolvableSupply } 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", () => { + const summary = ( + focusedAssetId: string, + supply: unknown[] = [usdcSupply, xlmSupply], + ) => + getPositionSummary({ + position: position(supply), + focusedAssetId, + networkDetails, + }); + + 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("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); + + 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, + }); + + expect(result.deposits[0].code).toBe("USDC"); + }); + + it("resolves to an empty scope when the focused asset matches nothing, instead of the first row", () => { + // 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", () => { + 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("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", () => { + 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/earnProjection.ts b/extension/src/popup/components/earn/helpers/earnProjection.ts new file mode 100644 index 0000000000..17a3f77903 --- /dev/null +++ b/extension/src/popup/components/earn/helpers/earnProjection.ts @@ -0,0 +1,103 @@ +import BigNumber from "bignumber.js"; + +import { ApiTokenPrices } from "@shared/api/types"; +import { AssetType } from "@shared/api/types/account-balance"; +import { BlendEarnAssetOption } from "@shared/api/types/blend"; +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 (`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) => { + const rate = headlineApy(pool.supplyApy, pool.emissionsSupplyApr); + if (rate === null) { + return best; + } + return best === null || rate > best ? rate : best; + }, null); + +/** The highest headline rate on offer across every option and pool. */ +export const getBestEarnApy = ( + options: BlendEarnAssetOption[], +): number | null => + options.reduce((best, option) => { + const rate = bestApyForOption(option); + if (rate === null) { + return best; + } + return best === null || rate > best ? rate : best; + }, null); + +/** + * What the account could earn in a year by depositing what it already holds. + * + * Each held, priced, pool-supported token is valued at ITS OWN best rate and the + * results summed — a single blended rate over a mixed basket would credit a + * low-APY token the wrong number. Tokens no pool accepts, and tokens with no + * fresh price, are skipped rather than counted at zero. + * + * `usd` is null when there is nothing to total: no catalog, or nothing + * depositable held. The empty state then shows `bestApy` alone — a ceiling rate + * rather than a dollar promise it cannot make. + */ +export const projectAnnualEarnings = ({ + options, + balances, + tokenPrices, + networkDetails, +}: { + options: BlendEarnAssetOption[] | null; + balances: AssetType[]; + tokenPrices: ApiTokenPrices | null | undefined; + networkDetails: NetworkDetails; +}): { usd: string | null; bestApy: number | null } => { + if (!options?.length) { + return { usd: null, bestApy: null }; + } + + const bestApy = getBestEarnApy(options); + let total = new BigNumber(0); + let counted = 0; + + options.forEach((option) => { + const apy = bestApyForOption(option); + if (apy === null) { + return; + } + + const balance = getBalanceByKey(option.assetId, balances, networkDetails); + if (!balance || new BigNumber(balance.total).lte(0)) { + return; + } + + // Prices are keyed by canonical ("CODE:ISSUER", or "native" for XLM), which + // is exactly what getCatalogAssetIdentity resolves. + const { canonical } = getCatalogAssetIdentity({ + symbol: option.symbol, + name: option.name, + assetId: option.assetId, + networkDetails, + }); + const price = canonical + ? tokenPrices?.[canonical]?.currentPrice + : undefined; + if (!price) { + return; + } + + counted += 1; + total = total.plus( + new BigNumber(balance.total).multipliedBy(price).multipliedBy(apy), + ); + }); + + return { usd: counted ? total.toFixed(2) : null, bestApy }; +}; diff --git a/extension/src/popup/components/earn/helpers/formatPoolStats.ts b/extension/src/popup/components/earn/helpers/formatPoolStats.ts index 9d605b965a..728b1a40f5 100644 --- a/extension/src/popup/components/earn/helpers/formatPoolStats.ts +++ b/extension/src/popup/components/earn/helpers/formatPoolStats.ts @@ -1,6 +1,29 @@ import BigNumber from "bignumber.js"; -import { NO_FIAT_VALUE } 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 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( + new BigNumber(value).decimalPlaces(2, BigNumber.ROUND_DOWN).toFixed(2), + )}`; /** * Compact USD for pool-scale figures — "$50.05M" rather than "$50,050,000". @@ -41,3 +64,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. + */ +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/poolPositionSummary.ts b/extension/src/popup/components/earn/helpers/poolPositionSummary.ts new file mode 100644 index 0000000000..037aa4ec01 --- /dev/null +++ b/extension/src/popup/components/earn/helpers/poolPositionSummary.ts @@ -0,0 +1,82 @@ +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. + * + * 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.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 + // 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/positionRows.ts b/extension/src/popup/components/earn/helpers/positionRows.ts new file mode 100644 index 0000000000..f20a74f991 --- /dev/null +++ b/extension/src/popup/components/earn/helpers/positionRows.ts @@ -0,0 +1,94 @@ +import BigNumber from "bignumber.js"; + +import { AccountPositions, BlendSupplyRow } from "@shared/api/types/blend"; +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 { + poolId: string; + poolName: string | null; + protocol: string; + assetId: string; + code: string; + issuer?: string; + decimals: number; + /** Display units, already scaled by `decimals`. */ + suppliedTokens: string; + suppliedUsd: number | null; + /** Headline rate as a decimal fraction; null when genuinely unknown. */ + apy: number | null; + interestEarnedUsd: number | null; +} + +const toRow = ({ + row, + poolId, + poolName, + protocol, + networkDetails, +}: { + row: BlendSupplyRow; + poolId: string; + poolName: string | null; + protocol: string; + networkDetails: NetworkDetails; +}): PositionTokenRow => { + // The payload reports native XLM with a null symbol AND a null name, leaving + // its SAC as the only clue. Deriving the identity rather than reading `symbol` + // is what keeps an XLM position from rendering a contract address. + const { code, issuer } = getCatalogAssetIdentity({ + symbol: row.symbol, + name: row.name, + assetId: row.assetId, + networkDetails, + }); + const decimals = row.decimals ?? CLASSIC_ASSET_DECIMALS; + + return { + poolId, + poolName, + protocol, + assetId: row.assetId, + code: code || `${row.assetId.slice(0, 4)}…`, + issuer, + decimals, + suppliedTokens: new BigNumber(row.totalTokens) + .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, + }; +}; + +/** + * Flattens the pool-shaped payload into the token-shaped rows the v1 design + * draws — one row per supplied asset, each carrying the pool it belongs to. + * + * This is the ONLY place that flattening happens. The multi-pool design + * (Figma 9848-110100) lists pools rather than tokens; switching to it is a + * change here plus a row component, with no data-layer change at all. + */ +export const toPositionTokenRows = ({ + positions, + networkDetails, +}: { + positions: AccountPositions | null; + networkDetails: NetworkDetails; +}): PositionTokenRow[] => + (positions?.positions || []).flatMap((position) => + (position.blend?.supply || []).map((row) => + toRow({ + row, + poolId: position.id, + poolName: position.name, + protocol: position.protocol, + networkDetails, + }), + ), + ); 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..a1f68557c8 --- /dev/null +++ b/extension/src/popup/components/earn/helpers/positionSummary.ts @@ -0,0 +1,202 @@ +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"; +import { headlineApy } from "./formatPoolStats"; + +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. */ + 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 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: isOutOfRange + ? "0" + : scale(totalTokens.minus(interestEarned).toFixed(0), decimals), + usd: + isOutOfRange || row.usdValue === null || row.interestEarnedUsd === null + ? null + : new BigNumber(row.usdValue).minus(row.interestEarnedUsd).toNumber(), + }; +}; + +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, + }; +}; + +/** + * 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, +}: { + position: PoolPosition; + focusedAssetId?: string; +}): BlendSupplyRow[] => { + const supply = position.blend?.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`. `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, +}: { + position: PoolPosition; + focusedAssetId?: string; +}): boolean => resolveRows({ position, focusedAssetId }).length > 0; + +/** + * The sheet's "Your position" figures for one supplied asset. + * + * Pool-level totals are a different surface with a different helper — see + * `getPoolPositionSummary`. This one never aggregates. + */ +export const getPositionSummary = ({ + position, + focusedAssetId, + networkDetails, +}: { + position: PoolPosition; + focusedAssetId?: string; + networkDetails: NetworkDetails; +}): PositionSummary => { + const rows = resolveRows({ position, focusedAssetId }); + + const currentBalanceUsd = rows[0]?.usdValue ?? null; + // headlineApy: the null-is-not-zero exception -- see formatPoolStats.ts. + 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)); + + 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, + }; +}; diff --git a/extension/src/popup/constants/__tests__/metricsNames.test.ts b/extension/src/popup/constants/__tests__/metricsNames.test.ts index b7c7bc6add..99430c8630 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,14 @@ 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", + ); + }); + 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 027422f031..5c6bfcc933 100644 --- a/extension/src/popup/constants/earn.ts +++ b/extension/src/popup/constants/earn.ts @@ -16,6 +16,58 @@ 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"; + +/** + * 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", + /** The Positions tab's floating Deposit pill (FloatingAddButton). */ + POSITIONS_PILL: "positions_pill", +} 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.POSITIONS_PILL + ? 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..27268a0496 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,17 @@ export const METRIC_NAMES = { // outright loses the outcome. earnDepositDismissed: "earn.deposit_processing_dismissed", + // -- Positions ----------------------------------------------------------- + // 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", + // -- Collectibles -------------------------------------------------------- collectibleSendCompleted: "collectible_send.completed", collectibleSendFailed: "collectible_send.failed", 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; 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/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", () => { diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index da9270b213..ba01242135 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -4,14 +4,17 @@ "{{appName}} disconnected": "{{appName}} disconnected", "{{domain}} is not currently connected to Freighter": "{{domain}} is not currently connected to Freighter", "{{from}} has been swapped to {{to}}": "{{from}} has been swapped to {{to}}", + "{{rate}} APY": "{{rate}} APY", "{{rate}}% APY": "{{rate}}% APY", "* All Stellar accounts must maintain a minimum balance of lumens.": "* All Stellar accounts must maintain a minimum balance of lumens.", + "/year on your tokens": "/year on your tokens", "0.5 XLM required": "0.5 XLM required", "A destination account requires the use of the memo field which is not present in the transaction you’re about to sign.": "A destination account requires the use of the memo field which is not present in the transaction you’re about to sign.", "A new signing request arrived while you were reviewing another. Please review it carefully before approving.": "A new signing request arrived while you were reviewing another. Please review it carefully before approving.", "A token was flagged as malicious": "A token was flagged as malicious", "A token was flagged as suspicious": "A token was flagged as suspicious", "About": "About", + "About pool": "About pool", "About unverified tokens": "About unverified tokens", "About verified tokens": "About verified tokens", "Account": "Account", @@ -98,6 +101,7 @@ "Balance": "Balance", "Balance ID": "Balance ID", "Before we start with migration, please read": "Before we start with migration, please read", + "Blend": "Blend", "Blend pool": "Blend pool", "Blockaid": "Blockaid", "Blockaid Response Override": "Blockaid Response Override", @@ -181,6 +185,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", @@ -213,6 +218,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", @@ -238,6 +244,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", @@ -248,6 +256,7 @@ "Failed to fetch assets.": "Failed to fetch assets.", "Failed to fetch your account balances.": "Failed to fetch your account balances.", "Failed to fetch your account data.": "Failed to fetch your account data.", + "Failed to fetch your positions.": "Failed to fetch your positions.", "Failed to fetch your transaction details": "Failed to fetch your transaction details", "Failed to fetch your wallets.": "Failed to fetch your wallets.", "Failed to load assets.": "Failed to load assets.", @@ -427,6 +436,7 @@ "must be below": "must be below", "Muxed address not supported": "Muxed address not supported", "My Accounts": "My Accounts", + "My position": "My position", "Name": "Name", "Net APY": "Net APY", "Network": "Network", @@ -446,6 +456,7 @@ "No device detected.": "No device detected.", "No hidden collectibles": "No hidden collectibles", "No one from Stellar Development Foundation will ever ask for your recovery phrase": "No one from Stellar Development Foundation will ever ask for your recovery phrase", + "No positions yet": "No positions yet", "No quote available": "No quote available", "No supported assets in your wallet": "No supported assets in your wallet", "No tokens match {{term}}": "No tokens match {{term}}", @@ -501,6 +512,7 @@ "Pool Performance": "Pool Performance", "Popular tokens": "Popular tokens", "Position": "Position", + "Positions": "Positions", "powered by": "powered by", "Powered by ": "Powered by ", "Pre Auth Transaction": "Pre Auth Transaction", @@ -509,6 +521,7 @@ "Price": "Price", "Privacy Policy": "Privacy Policy", "Proceed with caution": "Proceed with caution", + "Put your crypto to work and earn rewards while keeping track of your positions.": "Put your crypto to work and earn rewards while keeping track of your positions.", "Quote has expired, please try again to get a new quote": "Quote has expired, please try again to get a new quote", "Rate": "Rate", "Read before importing your key": "Read before importing your key", @@ -609,6 +622,7 @@ "Source token not found": "Source token not found", "Sponsored ID": "Sponsored ID", "SSL certificates provide an encrypted network connection and also provide proof of ownership of the domain.": "SSL certificates provide an encrypted network connection and also provide proof of ownership of the domain.", + "Start Earning": "Start Earning", "Starting Balance": "Starting Balance", "Status": "Status", "Stay in control": "Stay in control", @@ -627,6 +641,7 @@ "Success!": "Success!", "Suggestions": "Suggestions", "Supplied": "Supplied", + "Supplied asset": "Supplied asset", "Supply assets to Blend and earn variable yield.": "Supply assets to Blend and earn variable yield.", "Supply supported assets and earn based on current APY.": "Supply supported assets and earn based on current APY.", "Supported tokens": "Supported tokens", @@ -817,6 +832,7 @@ "You can choose to merge your current account into the new accounts after the migration, which will effectively destroy your current account.": "You can choose to merge your current account into the new accounts after the migration, which will effectively destroy your current account.", "You can close this screen, your transaction should be complete in less than a minute.": "You can close this screen, your transaction should be complete in less than a minute.", "You cannot send to yourself": "You cannot send to yourself", + "You could earn up to": "You could earn up to", "You deposit": "You deposit", "You have no assets added.": "You have no assets added.", "You have no collectibles added.": "You have no collectibles added.", @@ -843,7 +859,11 @@ "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", "Your recovery phrase gives you access to your account and is the only way to access it in a new browser.": "Your recovery phrase gives you access to your account and is the only way to access it in a new browser.", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index c0bd411b51..7b5114ddbf 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -4,14 +4,17 @@ "{{appName}} disconnected": "{{appName}} desconectado", "{{domain}} is not currently connected to Freighter": "{{domain}} não está atualmente conectado ao Freighter", "{{from}} has been swapped to {{to}}": "{{from}} foi trocado por {{to}}", + "{{rate}} APY": "{{rate}} APY", "{{rate}}% APY": "{{rate}}% APY", "* All Stellar accounts must maintain a minimum balance of lumens.": "* Todas as contas Stellar devem manter um saldo mínimo de lumens.", + "/year on your tokens": "/ano com seus tokens", "0.5 XLM required": "0,5 XLM necessários", "A destination account requires the use of the memo field which is not present in the transaction you’re about to sign.": "Uma conta de destino requer o uso do campo memo que não está presente na transação que você está prestes a assinar.", "A new signing request arrived while you were reviewing another. Please review it carefully before approving.": "Uma nova solicitação de assinatura chegou enquanto você revisava outra. Revise-a com atenção antes de aprovar.", "A token was flagged as malicious": "Um token foi sinalizado como malicioso", "A token was flagged as suspicious": "Um token foi sinalizado como suspeito", "About": "Sobre", + "About pool": "Sobre o pool", "About unverified tokens": "Sobre tokens não verificados", "About verified tokens": "Sobre tokens verificados", "Account": "Conta", @@ -98,6 +101,7 @@ "Balance": "Saldo", "Balance ID": "ID do Saldo", "Before we start with migration, please read": "Antes de começarmos com a migração, por favor leia", + "Blend": "Blend", "Blend pool": "Pool da Blend", "Blockaid": "Blockaid", "Blockaid Response Override": "Substituição de Resposta do Blockaid", @@ -181,6 +185,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", @@ -213,6 +218,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", @@ -238,6 +244,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", @@ -248,6 +256,7 @@ "Failed to fetch assets.": "Falha ao buscar ativos.", "Failed to fetch your account balances.": "Falha ao buscar os saldos da sua conta.", "Failed to fetch your account data.": "Falha ao buscar os dados da sua conta.", + "Failed to fetch your positions.": "Falha ao buscar suas posições.", "Failed to fetch your transaction details": "Falha ao buscar os detalhes da sua transação", "Failed to fetch your wallets.": "Falha ao buscar suas carteiras.", "Failed to load assets.": "Falha ao carregar ativos.", @@ -427,6 +436,7 @@ "must be below": "deve estar abaixo de", "Muxed address not supported": "Endereço muxed não suportado", "My Accounts": "Minhas Contas", + "My position": "Minha posição", "Name": "Nome", "Net APY": "APY líquida", "Network": "Rede", @@ -446,6 +456,7 @@ "No device detected.": "Nenhum dispositivo detectado.", "No hidden collectibles": "Nenhum colecionável oculto", "No one from Stellar Development Foundation will ever ask for your recovery phrase": "Ninguém da Stellar Development Foundation jamais pedirá sua frase de recuperação", + "No positions yet": "Ainda não há posições", "No quote available": "Nenhuma cotação disponível", "No supported assets in your wallet": "Nenhum ativo compatível na sua carteira", "No tokens match {{term}}": "Nenhum token corresponde a {{term}}", @@ -501,6 +512,7 @@ "Pool Performance": "Desempenho do pool", "Popular tokens": "Tokens populares", "Position": "Posição", + "Positions": "Posições", "powered by": "desenvolvido por", "Powered by ": "Desenvolvido por ", "Pre Auth Transaction": "Transação Pré-Autorizada", @@ -509,6 +521,7 @@ "Price": "Preço", "Privacy Policy": "Política de Privacidade", "Proceed with caution": "Prossiga com cautela", + "Put your crypto to work and earn rewards while keeping track of your positions.": "Coloque suas criptomoedas para trabalhar e obtenha recompensas enquanto acompanha suas posições.", "Quote has expired, please try again to get a new quote": "A cotação expirou, tente novamente para obter uma nova cotação", "Rate": "Taxa", "Read before importing your key": "Leia antes de importar sua chave", @@ -609,6 +622,7 @@ "Source token not found": "Token de origem não encontrado", "Sponsored ID": "ID Patrocinado", "SSL certificates provide an encrypted network connection and also provide proof of ownership of the domain.": "Os certificados SSL fornecem uma conexão de rede criptografada e também fornecem prova de propriedade do domínio.", + "Start Earning": "Começar a ganhar", "Starting Balance": "Saldo Inicial", "Status": "Status", "Stay in control": "Mantenha o controle", @@ -627,6 +641,7 @@ "Success!": "Sucesso!", "Suggestions": "Sugestões", "Supplied": "Fornecido", + "Supplied asset": "Token fornecido", "Supply assets to Blend and earn variable yield.": "Forneça ativos à Blend e obtenha rendimento variável.", "Supply supported assets and earn based on current APY.": "Forneça tokens compatíveis e renda com base na APY atual.", "Supported tokens": "Tokens compatíveis", @@ -817,6 +832,7 @@ "You can choose to merge your current account into the new accounts after the migration, which will effectively destroy your current account.": "Você pode escolher mesclar sua conta atual nas novas contas após a migração, o que efetivamente destruirá sua conta atual.", "You can close this screen, your transaction should be complete in less than a minute.": "Você pode fechar esta tela, sua transação deve estar completa em menos de um minuto.", "You cannot send to yourself": "Você não pode enviar para si mesmo", + "You could earn up to": "Você pode ganhar até", "You deposit": "Você deposita", "You have no assets added.": "Você não tem ativos adicionados.", "You have no collectibles added.": "Você não tem colecionáveis adicionados.", @@ -843,7 +859,11 @@ "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", "Your recovery phrase gives you access to your account and is the only way to access it in a new browser.": "Sua frase de recuperação lhe dá acesso à sua conta e é a única maneira de acessá-la em um novo navegador.", diff --git a/extension/src/popup/metrics/earn.test.ts b/extension/src/popup/metrics/earn.test.ts index 99144c7493..5b901a01d1 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,37 @@ 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("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. @@ -160,6 +193,7 @@ describe("Earn funnel metrics", () => { poolId: POOL_ID, apy: 0.1694, viaSwap: true, + source: "position_row", }); expect(mockEmitMetric).toHaveBeenCalledWith( @@ -169,6 +203,7 @@ describe("Earn funnel metrics", () => { pool_id: POOL_ID, apy: 0.1694, via_swap: true, + source: "position_row", }, ); }); @@ -179,6 +214,7 @@ describe("Earn funnel metrics", () => { poolId: POOL_ID, apy: 0.1694, viaSwap: false, + source: "home", }); const [, body] = mockEmitMetric.mock.calls[0]; @@ -187,6 +223,7 @@ describe("Earn funnel metrics", () => { "pool_id", "apy", "via_swap", + "source", ]); }); @@ -195,6 +232,7 @@ describe("Earn funnel metrics", () => { assetCode: "USDC", poolId: POOL_ID, reasonCode: "op_underfunded", + source: "positions_empty", }); expect(mockEmitMetric).toHaveBeenCalledWith( @@ -203,6 +241,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..0ce1b1024a 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. @@ -20,6 +21,21 @@ import { METRIC_NAMES } from "popup/constants/metricsNames"; /** 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, @@ -90,6 +106,35 @@ export const trackEarnSwapCompleted = ({ }); }; +export const trackEarnPoolDetailsOpened = ({ + poolId, + source, +}: { + poolId: string; + source: PoolDetailsSource; +}) => { + emitMetric(METRIC_NAMES.earnPoolDetailsOpened, { + pool_id: poolId, + source, + }); +}; + +export const trackEarnPoolDetailsTabSelected = ({ + poolId, + tab, + source, +}: { + poolId: string; + tab: PoolDetailsTab; + source: PoolDetailsSource; +}) => { + 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 +226,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 +257,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..3493375a2e --- /dev/null +++ b/extension/src/popup/metrics/positions.test.ts @@ -0,0 +1,45 @@ +import { emitMetric } from "helpers/metrics"; +import { METRIC_NAMES } from "popup/constants/metricsNames"; +import { + trackPoolSelected, + 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 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", + 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..d211d9eec2 --- /dev/null +++ b/extension/src/popup/metrics/positions.ts @@ -0,0 +1,50 @@ +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: 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, + 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/contexts/activeTabContext.tsx b/extension/src/popup/views/Account/contexts/activeTabContext.tsx index 01fb442cde..92181023f8 100644 --- a/extension/src/popup/views/Account/contexts/activeTabContext.tsx +++ b/extension/src/popup/views/Account/contexts/activeTabContext.tsx @@ -2,6 +2,7 @@ import React, { createContext, useState } from "react"; export enum TabsList { TOKENS = "tokens", + POSITIONS = "positions", COLLECTIBLES = "collectibles", } 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 a185a9a023..635bca39ac 100644 --- a/extension/src/popup/views/Account/hooks/useGetAccountData.tsx +++ b/extension/src/popup/views/Account/hooks/useGetAccountData.tsx @@ -5,8 +5,16 @@ 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, getBlendPools } from "@shared/api/helpers/blend"; +import { + AccountPositions, + BlendCatalogPool, + BlendEarnAssetOption, +} from "@shared/api/types/blend"; +import { isEarnSupportedNetwork } from "@shared/constants/blend"; import { AppDataType, NeedsReRoute, @@ -40,6 +48,35 @@ 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; + /** + * 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; @@ -60,6 +97,7 @@ function useGetAccountData(options: { const { fetchData: fetchCollectibles } = useGetCollectibles({ useCache: true, }); + const { fetchData: fetchPositions } = useGetPositions({ useCache: true }); const fetchData = async ({ useAppDataCache = true, @@ -106,6 +144,34 @@ 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. + // `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, + }); + // 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. 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( publicKey, @@ -129,6 +195,11 @@ function useGetAccountData(options: { isScanAppended: false, collectibles: { collections: [] }, hasLoadedCollectibles: false, + positions: null, + hasLoadedPositions: false, + hasPositionsError: false, + earnOptions: null, + pools: [], } as ResolvedAccountData; if (isMainnetNetwork) { @@ -156,6 +227,50 @@ 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 } }); + + // 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. + 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. 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 + // projection. Skipped entirely for an account that already has positions. + if ( + !payload.hasPositionsError && + !payload.positions?.positions.length + ) { + earnOptionsRequest = getBlendEarnOptions({ networkDetails }); + earnOptionsRequest.catch(() => {}); + } + } + 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 { @@ -167,17 +282,32 @@ 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, 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}`); } } + 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; @@ -265,10 +395,33 @@ function useGetAccountData(options: { false, ); + 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; + captureException(`Error refreshing positions on Account - ${error}`); + } + const payload = { ...state.data, balances: balancesResult, isScanAppended: true, + positions: refreshedPositions, + // 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) { @@ -276,7 +429,7 @@ function useGetAccountData(options: { } }, 30000); return () => clearInterval(interval); - }, [_isMainnet, state.data, fetchBalances]); + }, [_isMainnet, state.data, fetchBalances, fetchPositions]); return { state, diff --git a/extension/src/popup/views/Account/index.tsx b/extension/src/popup/views/Account/index.tsx index 99b468d7c4..7ccd7a0111 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"; @@ -25,6 +36,7 @@ import { AccountCollectibles, hasVisibleCollections, } from "popup/components/account/AccountCollectibles"; +import { AccountPositions } from "popup/components/account/AccountPositions"; import { AccountHeader } from "popup/components/account/AccountHeader"; import { FloatingAddButton } from "popup/components/account/FloatingAddButton"; import { useHiddenCollectibles } from "popup/components/account/hooks/useHiddenCollectibles"; @@ -32,13 +44,25 @@ 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, + 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"; @@ -62,6 +86,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); @@ -71,7 +97,20 @@ export const Account = () => { // account with a blank identicon (and a copy button holding ""). const reduxPublicKey = useSelector(publicKeySelector); const networkDetails = useSelector(settingsNetworkDetailsSelector); - const { activeTab } = useContext(AccountTabsContext); + const { activeTab, setActiveTab } = useContext(AccountTabsContext); + + // The Positions button is hidden where Earn is unsupported. Switching networks + // while that tab is active would otherwise leave the pane on screen with no + // button to switch away from it. + useEffect(() => { + if ( + activeTab === TabsList.POSITIONS && + !isEarnSupportedNetwork(networkDetails) + ) { + setActiveTab(TabsList.TOKENS); + } + }, [activeTab, networkDetails, setActiveTab]); + const [isDiscoverOpen, setIsDiscoverOpen] = useState(false); const isFullscreenModeEnabled = isFullscreenMode(); @@ -255,6 +294,38 @@ export const Account = () => { isTokensEmptyStateShown && !hasVisibleCollections(collections, isCollectibleHidden); + // Feeds the Positions tab's empty-state card. `earnOptions` is only ever + // fetched once positions land empty (useGetAccountData), so this is a no-op + // the rest of the time. + const projection = projectAnnualEarnings({ + options: resolvedData?.earnOptions ?? null, + balances, + tokenPrices, + 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}&${EARN_SOURCE_KEY}=${EARN_SOURCE.POSITION_ROW}`, + ); + }; + return ( <> { /> ) ), +
+ +
,
{ 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); + 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 // 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 +114,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 +131,17 @@ export const Earn = () => { }; 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. + if (isPrefilled) { + return; + } dispatch(resetSubmission()); - }, [dispatch]); + }, [dispatch, isPrefilled]); useEffect(() => { if (hasResolvedIntro.current || hasSeenIntro === null) { @@ -158,6 +185,7 @@ export const Earn = () => { assetCode: getAssetFromCanonical(submission.transactionData.asset).code, poolId: pool?.id || "", reasonCode: getFailureReasonCode(submission.error), + source, }); } dispatch(setEarnSubmitFailed(true)); diff --git a/extension/src/popup/views/__tests__/Account.test.tsx b/extension/src/popup/views/__tests__/Account.test.tsx index 9258558bdf..6c4416289e 100644 --- a/extension/src/popup/views/__tests__/Account.test.tsx +++ b/extension/src/popup/views/__tests__/Account.test.tsx @@ -11,6 +11,7 @@ import { TESTNET_NETWORK_DETAILS, DEFAULT_NETWORKS, MAINNET_NETWORK_DETAILS, + FUTURENET_NETWORK_DETAILS, } from "@shared/constants/stellar"; import { Balances } from "@shared/api/types/backend-api"; import * as ApiInternal from "@shared/api/internal"; @@ -46,7 +47,11 @@ import { DEFAULT_AUTO_LOCK_TIMEOUT_MINUTES } from "@shared/constants/autoLock"; import { AppDataType } from "helpers/hooks/useGetAppData"; import * as AccountDataHooks from "../../views/Account/hooks/useGetAccountData"; import { RequestState } from "helpers/hooks/fetchHookInterface"; -import { ActiveTabProvider } from "../Account/contexts/activeTabContext"; +import { + ActiveTabProvider, + AccountTabsContext, + TabsList, +} from "../Account/contexts/activeTabContext"; const mockHistoryOperations = { operations: [ @@ -1427,6 +1432,68 @@ describe("Account view", () => { // 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. + 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 @@ -1680,6 +1747,74 @@ describe("Account view", () => { }); }); + // Spec D14: switching to a network where Earn is unsupported while the + // Positions tab is active must not strand the user there -- the very + // button that tab needs to leave from is what just disappeared. + // `isEarnSupportedNetwork` is true only for PUBLIC and TESTNET, so + // Futurenet is what triggers this. Supplies `AccountTabsContext` directly, + // rather than `ActiveTabProvider` (which always starts on Tokens), so the + // guard's precondition -- already on Positions -- holds from the first + // render; the effect runs identically whether that became true on mount or + // from a later network switch, so this exercises the same branch either way. + it("resets from Positions to Tokens on an earn-unsupported network", async () => { + const accountDataSpy = jest + .spyOn(AccountDataHooks, "useGetAccountData") + .mockReturnValue({ + state: { + state: RequestState.SUCCESS, + error: null, + data: { + type: AppDataType.RESOLVED, + publicKey: TEST_PUBLIC_KEY, + applicationState: ApplicationState.MNEMONIC_PHRASE_CONFIRMED, + networkDetails: FUTURENET_NETWORK_DETAILS, + allowList: ApiInternal.DEFAULT_ALLOW_LIST, + isScanAppended: true, + collectibles: { collections: [] }, + balances: { balances: [], isFunded: true, subentryCount: 0 }, + tokenPrices: {}, + }, + }, + fetchData: jest.fn(), + refreshAppData: jest.fn(), + } as any); + + const setActiveTab = jest.fn(); + + render( + + + + + , + ); + + await waitFor(() => { + expect(setActiveTab).toHaveBeenCalledWith(TabsList.TOKENS); + }); + + // Restored before the next test: a failure after this point would + // otherwise leave useGetAccountData mocked for every later test in this + // file, exactly as it briefly did before this line was added. + accountDataSpy.mockRestore(); + }); + it("handles abandoned onboarding in password created step", async () => { jest.spyOn(ApiInternal, "loadAccount").mockImplementation(() => Promise.resolve({