diff --git a/@shared/api/helpers/__tests__/blend.test.ts b/@shared/api/helpers/__tests__/blend.test.ts new file mode 100644 index 0000000000..5e3f98c7c3 --- /dev/null +++ b/@shared/api/helpers/__tests__/blend.test.ts @@ -0,0 +1,387 @@ +import { + getBlendEarnOptions, + getBlendPools, + getBlendSuppliedTokens, +} from "../blend"; +import { sendMessageToBackground } from "../extensionMessaging"; +import { SERVICE_TYPES } from "@shared/constants/services"; +import { BLEND_FIXED_POOL_IDS } from "@shared/constants/blend"; +import { PUBLIC_SACS } from "@shared/constants/sac"; +import { NETWORKS } from "@shared/constants/stellar"; + +jest.mock("../extensionMessaging"); +jest.mock("@sentry/browser", () => ({ captureException: jest.fn() })); + +const mockedSend = sendMessageToBackground as jest.Mock; +const networkDetails = { network: "PUBLIC" } as never; + +const POOL_ID = BLEND_FIXED_POOL_IDS[NETWORKS.PUBLIC]!; +const USDC_SAC = PUBLIC_SACS.USDC!; +const XLM_SAC = PUBLIC_SACS.XLM; +const PUBLIC_KEY = "GAX2VVWVHU5YQY5J3NJBXKHI3FFKZN54BE6GRJCWSIKSBZTQWJJNJMPC"; + +beforeEach(() => { + mockedSend.mockReset(); +}); + +describe("getBlendEarnOptions", () => { + it("GETs earn-options with the network in the path", async () => { + // The query must live in `path` — authedFetch signs pathname + search, so a + // query appended anywhere downstream would break the JWT signature. + mockedSend.mockResolvedValue({ + status: 200, + body: { data: { options: [] } }, + }); + + await getBlendEarnOptions({ networkDetails }); + + expect(mockedSend).toHaveBeenCalledWith({ + type: SERVICE_TYPES.FETCH_BACKEND_V2, + activePublicKey: null, + method: "GET", + path: "/protocols/blend/earn-options?network=PUBLIC", + }); + }); + + it("maps snake_case wire fields to camelCase", async () => { + mockedSend.mockResolvedValue({ + status: 200, + body: { + data: { + options: [ + { + asset_id: USDC_SAC, + symbol: "USDC", + name: "USD Coin", + decimals: 7, + pools: [ + { + id: POOL_ID, + name: "Fixed Pool v2", + supply_apy: 0.1694, + emissions_supply_apr: null, + supplied_usd: 50050000, + }, + ], + }, + ], + }, + }, + }); + + expect(await getBlendEarnOptions({ networkDetails })).toEqual([ + { + assetId: USDC_SAC, + symbol: "USDC", + name: "USD Coin", + decimals: 7, + pools: [ + { + id: POOL_ID, + name: "Fixed Pool v2", + supplyApy: 0.1694, + emissionsSupplyApr: null, + suppliedUsd: 50050000, + }, + ], + }, + ]); + }); + + it("preserves the null/zero distinction on rates", async () => { + // null means "no fresh oracle price"; 0 means the rate really is zero. The + // UI renders these differently, so coalescing would be a data bug. + mockedSend.mockResolvedValue({ + status: 200, + body: { + data: { + options: [ + { + asset_id: XLM_SAC, + symbol: "XLM", + name: null, + decimals: null, + pools: [ + { + id: POOL_ID, + name: null, + supply_apy: 0, + emissions_supply_apr: null, + supplied_usd: null, + }, + ], + }, + ], + }, + }, + }); + + const [option] = await getBlendEarnOptions({ networkDetails }); + + expect(option.decimals).toBeNull(); + expect(option.pools[0].supplyApy).toBe(0); + expect(option.pools[0].emissionsSupplyApr).toBeNull(); + expect(option.pools[0].suppliedUsd).toBeNull(); + }); + + it("throws on a non-200", async () => { + mockedSend.mockResolvedValue({ status: 500, body: { error: "upstream" } }); + + await expect(getBlendEarnOptions({ networkDetails })).rejects.toThrow(); + }); + + it("throws on a 200 with no data payload", async () => { + mockedSend.mockResolvedValue({ status: 200, body: {} }); + + await expect(getBlendEarnOptions({ networkDetails })).rejects.toThrow(); + }); +}); + +describe("getBlendPools", () => { + it("GETs pools with the network in the path", async () => { + mockedSend.mockResolvedValue({ + status: 200, + body: { data: { pools: [] } }, + }); + + await getBlendPools({ networkDetails }); + + expect(mockedSend).toHaveBeenCalledWith({ + type: SERVICE_TYPES.FETCH_BACKEND_V2, + activePublicKey: null, + method: "GET", + path: "/protocols/blend/pools?network=PUBLIC", + }); + }); + + it("maps the pool and its reserves", async () => { + mockedSend.mockResolvedValue({ + status: 200, + body: { + data: { + pools: [ + { + id: POOL_ID, + name: "Fixed Pool v2", + status: "ACTIVE", + supplied_usd: 50050000, + borrowed_usd: 16150000, + interest_apy: 0.0424, + net_apy: 0.1694, + backstop_usd: 1530000, + reserves: [ + { + asset_id: USDC_SAC, + symbol: "USDC", + name: "USD Coin", + decimals: 7, + enabled: true, + utilization: 0.32, + supply_apy: 0.0424, + borrow_apy: 0.09, + emissions_supply_apr: null, + supplied_usd: 50050000, + borrowed_usd: 16150000, + price_usd: 1, + }, + ], + }, + ], + }, + }, + }); + + const [pool] = await getBlendPools({ networkDetails }); + + expect(pool.interestApy).toBe(0.0424); + expect(pool.netApy).toBe(0.1694); + expect(pool.backstopUsd).toBe(1530000); + expect(pool.status).toBe("ACTIVE"); + expect(pool.reserves[0]).toEqual({ + assetId: USDC_SAC, + symbol: "USDC", + name: "USD Coin", + decimals: 7, + enabled: true, + utilization: 0.32, + supplyApy: 0.0424, + borrowApy: 0.09, + emissionsSupplyApr: null, + suppliedUsd: 50050000, + borrowedUsd: 16150000, + priceUsd: 1, + }); + }); + + it.each([ + ["omitted", {}], + ["null", { backstop_usd: null }], + ])( + "maps a %s backstop to null rather than undefined or zero", + async (_label, backstop) => { + mockedSend.mockResolvedValue({ + status: 200, + body: { + data: { + pools: [ + { + id: POOL_ID, + name: "Fixed Pool v2", + status: "ACTIVE", + supplied_usd: 50050000, + borrowed_usd: 16150000, + interest_apy: 0.0424, + net_apy: 0.1694, + ...backstop, + reserves: [], + }, + ], + }, + }, + }); + + const [pool] = await getBlendPools({ networkDetails }); + + expect(pool.backstopUsd).toBeNull(); + }, + ); + + it("throws on a non-200", async () => { + mockedSend.mockResolvedValue({ status: 503, body: {} }); + + await expect(getBlendPools({ networkDetails })).rejects.toThrow(); + }); +}); + +describe("getBlendSuppliedTokens", () => { + const buildBody = (supply: unknown[]) => ({ + 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, borrow: [] }, + }, + ], + backstop: [], + }, + ], + }, + }); + + const call = () => + getBlendSuppliedTokens({ + publicKey: PUBLIC_KEY, + poolId: POOL_ID, + assetId: USDC_SAC, + networkDetails, + }); + + it("POSTs the address batch with the network in the path", async () => { + mockedSend.mockResolvedValue({ status: 200, body: { data: [] } }); + + 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("reads total_tokens, not supplied_tokens", async () => { + // Deposits use SupplyCollateral, so the balance lands in collateral_tokens. + // Reading supplied_tokens here would always report zero. + mockedSend.mockResolvedValue( + buildBody([ + { + asset_id: USDC_SAC, + supplied_tokens: "0", + collateral_tokens: "5000000000", + total_tokens: "5000000000", + }, + ]), + ); + + expect(await call()).toBe("5000000000"); + }); + + it("returns 0 when the account has no position", async () => { + mockedSend.mockResolvedValue({ status: 200, body: { data: [] } }); + + expect(await call()).toBe("0"); + }); + + it("returns 0 when the pool is present but the asset is not", async () => { + mockedSend.mockResolvedValue( + buildBody([ + { + asset_id: XLM_SAC, + supplied_tokens: "0", + collateral_tokens: "3700000000", + total_tokens: "3700000000", + }, + ]), + ); + + expect(await call()).toBe("0"); + }); + + it("returns 0 when the account holds positions only in another pool", async () => { + mockedSend.mockResolvedValue({ + status: 200, + body: { + data: [ + { + address: PUBLIC_KEY, + positions: [ + { + protocol: "BLEND", + id: "COTHERPOOL", + blend: { + supply: [{ asset_id: USDC_SAC, total_tokens: "999" }], + borrow: [], + }, + }, + ], + backstop: [], + }, + ], + }, + }); + + expect(await call()).toBe("0"); + }); + + it("returns 0 when the pool row carries no blend detail", async () => { + mockedSend.mockResolvedValue({ + status: 200, + body: { + data: [ + { address: PUBLIC_KEY, positions: [{ id: POOL_ID }], backstop: [] }, + ], + }, + }); + + expect(await call()).toBe("0"); + }); + + it("throws on a non-200 so the caller can fall back", async () => { + mockedSend.mockResolvedValue({ status: 500, body: {} }); + + await expect(call()).rejects.toThrow(); + }); +}); diff --git a/@shared/api/helpers/blend.ts b/@shared/api/helpers/blend.ts new file mode 100644 index 0000000000..0abf63283d --- /dev/null +++ b/@shared/api/helpers/blend.ts @@ -0,0 +1,182 @@ +import { captureException } from "@sentry/browser"; + +import { NetworkDetails } from "@shared/constants/stellar"; +import { fetchBackendV2 } from "./fetchBackendV2"; +import { + ApiAccountPositions, + ApiBlendCatalogPool, + ApiBlendCatalogReserve, + ApiBlendEarnAssetOption, + ApiBlendEarnOptionsCatalog, + ApiBlendEarnPool, + ApiBlendPoolsCatalog, + BlendCatalogPool, + BlendCatalogReserve, + BlendEarnAssetOption, + BlendEarnPool, +} from "../types/blend"; + +/** + * Clients for freighter-backend-v2's Blend endpoints. + * + * Every request puts its query string in `path`, never appends it downstream: + * `authedFetch` signs the JWT's `methodAndPath` over the server's full request + * target (pathname + search), so a query added anywhere else yields a 401. + * + * `networkDetails.network` is already exactly "PUBLIC" / "TESTNET" (the NETWORKS + * enum), which is what the handler validates against. Networks outside those two + * are rejected with a 400 — callers should gate on `isEarnSupportedNetwork` + * rather than relying on the error. + */ + +const mapEarnPool = (pool: ApiBlendEarnPool): BlendEarnPool => ({ + id: pool.id, + name: pool.name, + supplyApy: pool.supply_apy, + emissionsSupplyApr: pool.emissions_supply_apr, + suppliedUsd: pool.supplied_usd, +}); + +const mapEarnAssetOption = ( + option: ApiBlendEarnAssetOption, +): BlendEarnAssetOption => ({ + assetId: option.asset_id, + symbol: option.symbol, + name: option.name, + decimals: option.decimals, + pools: (option.pools || []).map(mapEarnPool), +}); + +const mapCatalogReserve = ( + reserve: ApiBlendCatalogReserve, +): BlendCatalogReserve => ({ + assetId: reserve.asset_id, + symbol: reserve.symbol, + name: reserve.name, + decimals: reserve.decimals, + enabled: reserve.enabled, + utilization: reserve.utilization, + supplyApy: reserve.supply_apy, + borrowApy: reserve.borrow_apy, + emissionsSupplyApr: reserve.emissions_supply_apr, + suppliedUsd: reserve.supplied_usd, + borrowedUsd: reserve.borrowed_usd, + priceUsd: reserve.price_usd, +}); + +const mapCatalogPool = (pool: ApiBlendCatalogPool): BlendCatalogPool => ({ + id: pool.id, + name: pool.name, + status: pool.status, + suppliedUsd: pool.supplied_usd, + borrowedUsd: pool.borrowed_usd, + interestApy: pool.interest_apy, + netApy: pool.net_apy, + // Normalised to null while the backend still omits the field, so callers have + // one "unavailable" case to render rather than two. + backstopUsd: pool.backstop_usd ?? null, + reserves: (pool.reserves || []).map(mapCatalogReserve), +}); + +/** + * 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 + * configured deployment this is the Fixed Pool only. + * + * Powers the Choose Token screen; `supply_apy + emissions_supply_apr` is the + * badge figure. + */ +export const getBlendEarnOptions = async ({ + networkDetails, +}: { + networkDetails: NetworkDetails; +}): Promise => { + const { status, body } = await fetchBackendV2({ + method: "GET", + path: `/protocols/blend/earn-options?network=${networkDetails.network}`, + }); + + // A 200 without a `data` payload is still a failure — returning undefined + // would violate the return contract, and the caller's try/catch only handles + // throws, not bad returns. + const parsed = body as { data?: ApiBlendEarnOptionsCatalog }; + if (status !== 200 || !parsed?.data) { + const _err = JSON.stringify(body); + captureException(`Failed to fetch Blend earn options - ${status}: ${_err}`); + throw new Error(_err); + } + + return (parsed.data.options || []).map(mapEarnAssetOption); +}; + +/** + * The full pool catalog — unfiltered by the earn allowlist. Used for the pool + * details sheet's Lending Interest / Current Net APY / Supplied / Borrowed / + * Backstop rows. + */ +export const getBlendPools = async ({ + networkDetails, +}: { + networkDetails: NetworkDetails; +}): Promise => { + const { status, body } = await fetchBackendV2({ + method: "GET", + path: `/protocols/blend/pools?network=${networkDetails.network}`, + }); + + const parsed = body as { data?: ApiBlendPoolsCatalog }; + if (status !== 200 || !parsed?.data) { + const _err = JSON.stringify(body); + captureException(`Failed to fetch Blend pools - ${status}: ${_err}`); + throw new Error(_err); + } + + return (parsed.data.pools || []).map(mapCatalogPool); +}; + +/** + * 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`. + * + * Reads `total_tokens` — the sum of the plain-supply and collateral buckets. + * Deposits use SupplyCollateral, so the balance lands in `collateral_tokens` and + * reading `supplied_tokens` would always report zero. + * + * Returns "0" for an account with no position, which is indistinguishable by + * design from an account unknown to the indexer. + * + * Callers should treat a rejection as non-fatal and render the "after" value + * alone — a stale before-value must never block a deposit. + */ +export const getBlendSuppliedTokens = async ({ + publicKey, + poolId, + assetId, + networkDetails, +}: { + publicKey: string; + poolId: string; + assetId: string; + networkDetails: NetworkDetails; +}): Promise => { + const { status, body } = await fetchBackendV2({ + method: "POST", + path: `/accounts/positions?network=${networkDetails.network}`, + body: JSON.stringify({ addresses: [publicKey] }), + }); + + // The endpoint is a batch: `data` is an array with one entry per requested + // address, so unwrap the single element we asked for. + const parsed = body as { data?: ApiAccountPositions[] }; + if (status !== 200 || !parsed?.data) { + const _err = JSON.stringify(body); + captureException(`Failed to fetch Blend positions - ${status}: ${_err}`); + throw new Error(_err); + } + + const supplyRow = parsed.data[0]?.positions + ?.find((position) => position.id === poolId) + ?.blend?.supply?.find((row) => row.asset_id === assetId); + + return supplyRow?.total_tokens || "0"; +}; diff --git a/@shared/api/internal.ts b/@shared/api/internal.ts index 45f48aa871..f2b63cc72c 100644 --- a/@shared/api/internal.ts +++ b/@shared/api/internal.ts @@ -2727,6 +2727,32 @@ export const dismissDiscoverWelcome = async (): Promise => { return !!hasSeenDiscoverWelcome; }; +export const getHasSeenEarnIntro = async (): Promise => { + const { hasSeenEarnIntro, error } = await sendMessageToBackground({ + activePublicKey: null, + type: SERVICE_TYPES.GET_EARN_INTRO_SEEN, + }); + + if (error) { + throw new Error(error); + } + + return !!hasSeenEarnIntro; +}; + +export const dismissEarnIntro = async (): Promise => { + const { hasSeenEarnIntro, error } = await sendMessageToBackground({ + activePublicKey: null, + type: SERVICE_TYPES.DISMISS_EARN_INTRO, + }); + + if (error) { + throw new Error(error); + } + + return !!hasSeenEarnIntro; +}; + export const getCachedSwapTopTokens = async ( network: string, ): Promise<{ tokens: TrendingAsset[]; updatedAt: number } | null> => { diff --git a/@shared/api/types/blend.ts b/@shared/api/types/blend.ts new file mode 100644 index 0000000000..1ff0bdd4a2 --- /dev/null +++ b/@shared/api/types/blend.ts @@ -0,0 +1,222 @@ +/** + * Types for freighter-backend-v2's Blend endpoints. + * + * The `Api*` types mirror the Go structs in freighter-backend-v2 + * `internal/types/{blend_catalog,positions}.go` exactly, including snake_case + * keys. The unprefixed types are the camelCase shapes the extension consumes. + * + * Number convention, inherited from wallet-backend and load-bearing throughout: + * a nullable USD/APY value is `null` when it is *unavailable* — the pool oracle + * has no fresh price (older than 24h counts as none, since the pool contract + * itself rejects it). A genuine zero is `0`. Never coalesce `null` to `0`; the + * UI must render it as unknown. + * + * On-chain token amounts are full-precision integer strings in the asset's + * smallest unit — scale by `decimals` for display, never parse to Number. + */ + +/* -------------------------------------------------------------------------- */ +/* GET /protocols/blend/earn-options */ +/* -------------------------------------------------------------------------- */ + +export interface ApiBlendEarnPool { + id: string; + name: string | null; + supply_apy: number | null; + emissions_supply_apr: number | null; + supplied_usd: number | null; +} + +export interface ApiBlendEarnAssetOption { + asset_id: string; + symbol: string | null; + name: string | null; + decimals: number | null; + /** Ordered by supplied USD descending (unpriced last). */ + pools: ApiBlendEarnPool[]; +} + +export interface ApiBlendEarnOptionsCatalog { + options: ApiBlendEarnAssetOption[]; +} + +export interface BlendEarnPool { + id: string; + name: string | null; + supplyApy: number | null; + emissionsSupplyApr: number | null; + suppliedUsd: number | null; +} + +export interface BlendEarnAssetOption { + /** The reserve's asset contract address — a SAC for every current reserve. */ + assetId: string; + symbol: string | null; + name: string | null; + decimals: number | null; + pools: BlendEarnPool[]; +} + +/* -------------------------------------------------------------------------- */ +/* GET /protocols/blend/pools */ +/* -------------------------------------------------------------------------- */ + +export interface ApiBlendCatalogReserve { + asset_id: string; + symbol: string | null; + name: string | null; + decimals: number | null; + enabled: boolean; + utilization: number | null; + supply_apy: number | null; + borrow_apy: number | null; + emissions_supply_apr: number | null; + supplied_usd: number | null; + borrowed_usd: number | null; + price_usd: number | null; +} + +export interface ApiBlendCatalogPool { + id: string; + name: string | null; + status: string | null; + supplied_usd: number | null; + borrowed_usd: number | null; + interest_apy: number | null; + net_apy: number | null; + /** + * Optional, unlike its siblings: the backend does not serve this field yet. + * wallet-backend's GraphQL has it (`BlendPool.backstopUsd`) but the v2 + * backend's pool mapper drops it, so it arrives absent rather than null. + * Absent and null both mean "unavailable" to the UI. + */ + backstop_usd?: number | null; + reserves: ApiBlendCatalogReserve[]; +} + +export interface ApiBlendPoolsCatalog { + pools: ApiBlendCatalogPool[]; +} + +export interface BlendCatalogReserve { + assetId: string; + symbol: string | null; + name: string | null; + decimals: number | null; + /** The reserve's own on/off flag, independent of pool status. */ + enabled: boolean; + utilization: number | null; + supplyApy: number | null; + borrowApy: number | null; + emissionsSupplyApr: number | null; + suppliedUsd: number | null; + borrowedUsd: number | null; + priceUsd: number | null; +} + +export interface BlendCatalogPool { + id: string; + name: string | null; + /** + * Upstream enum name: ADMIN_ACTIVE, ACTIVE, ADMIN_ON_ICE, ON_ICE, + * ADMIN_FROZEN, FROZEN, SETUP. The first four accept deposits. Null until the + * pool's config has been ingested. + */ + status: string | null; + suppliedUsd: number | null; + borrowedUsd: number | null; + /** Supplied-USD-weighted supply rate, interest only. */ + interestApy: number | null; + /** As `interestApy`, plus BLND emissions. Supply-side, not netted against borrow. */ + netApy: number | null; + /** + * USD value of this pool's own backstop deposit — its share of the Comet + * BLND:USDC LP, priced at the LP rate — not the backstop module's total, and + * not the account's own backstop position (see `ApiBlendBackstopRow`). + * + * Null while the field is unserved or unpriceable; `0` is a real zero, for a + * pool with no backstop deposits. + */ + backstopUsd: number | null; + reserves: BlendCatalogReserve[]; +} + +/* -------------------------------------------------------------------------- */ +/* POST /accounts/positions */ +/* -------------------------------------------------------------------------- */ + +export interface ApiBlendSupplyRow { + asset_id: string; + symbol: string | null; + name: string | null; + decimals: number | null; + /** Plain-supply portion, collateral portion, and their sum. All raw units. */ + supplied_tokens: string; + collateral_tokens: string; + total_tokens: string; + usd_value: number | null; + apy: number | null; + emissions_apr: number | null; + interest_earned: string; + interest_earned_usd: number | null; + claimable_blnd: string; + claimable_usd: number | null; + price_usd: number | null; +} + +export interface ApiBlendBorrowRow { + asset_id: string; + symbol: string | null; + name: string | null; + decimals: number | null; + borrowed_tokens: string; + usd_value: number | null; + apy: number | null; + emissions_apr: number | null; + price_usd: number | null; +} + +export interface ApiBlendQ4WRow { + amount: string; + lp_tokens: string; + usd_value: number | null; + /** Unix seconds. */ + expiration: number; +} + +export interface ApiBlendBackstopRow { + pool_id: string; + pool_name: string | null; + shares: string; + lp_tokens: string; + usd_value: number | null; + claimable_blnd: string; + claimable_usd: number | null; + q4w: ApiBlendQ4WRow[]; +} + +export interface ApiBlendPositionDetail { + supply: ApiBlendSupplyRow[]; + borrow: ApiBlendBorrowRow[]; +} + +export interface ApiPoolPosition { + protocol: string; + /** The pool's contract address. */ + id: string; + name: string | null; + net_usd: number | null; + supplied_usd: number | null; + borrowed_usd: number | null; + net_apy: number | null; + blend?: ApiBlendPositionDetail; +} + +export interface ApiAccountPositions { + address: string; + total_value_usd: number | null; + net_apy: number | null; + /** Always non-nil; empty for accounts with no positions or unknown upstream. */ + positions: ApiPoolPosition[]; + backstop: ApiBlendBackstopRow[]; +} diff --git a/@shared/api/types/message-request.ts b/@shared/api/types/message-request.ts index 5dc83dd06a..3ae6165ecd 100644 --- a/@shared/api/types/message-request.ts +++ b/@shared/api/types/message-request.ts @@ -449,6 +449,14 @@ export interface DismissDiscoverWelcomeMessage extends BaseMessage { type: SERVICE_TYPES.DISMISS_DISCOVER_WELCOME; } +export interface GetEarnIntroSeenMessage extends BaseMessage { + type: SERVICE_TYPES.GET_EARN_INTRO_SEEN; +} + +export interface DismissEarnIntroMessage extends BaseMessage { + type: SERVICE_TYPES.DISMISS_EARN_INTRO; +} + export interface GetBlockaidDebugOverrideMessage extends BaseMessage { type: SERVICE_TYPES.GET_BLOCKAID_DEBUG_OVERRIDE; } @@ -570,6 +578,8 @@ export type ServiceMessageRequest = | ClearRecentProtocolsMessage | GetDiscoverWelcomeSeenMessage | DismissDiscoverWelcomeMessage + | GetEarnIntroSeenMessage + | DismissEarnIntroMessage | GetBlockaidDebugOverrideMessage | AddCollectibleMessage | GetCollectiblesMessage diff --git a/@shared/api/types/types.ts b/@shared/api/types/types.ts index df88086b4a..41362c5348 100644 --- a/@shared/api/types/types.ts +++ b/@shared/api/types/types.ts @@ -142,6 +142,7 @@ export interface Response { overriddenBlockaidResponse: string | null; recentProtocols: RecentProtocolEntry[]; hasSeenDiscoverWelcome: boolean; + hasSeenEarnIntro: boolean; cachedSwapTopTokens: { tokens: TrendingAsset[]; updatedAt: number } | null; } diff --git a/@shared/constants/blend.ts b/@shared/constants/blend.ts new file mode 100644 index 0000000000..6a41011217 --- /dev/null +++ b/@shared/constants/blend.ts @@ -0,0 +1,25 @@ +import { NETWORKS, NetworkDetails } from "@shared/constants/stellar"; + +/** + * The Blend v2 "Fixed Pool" contract per network. These mirror the allowlist in + * freighter-backend-v2 `configs/earn-pools.json`, which is what filters the + * `/protocols/blend/earn-options` response. Keep the two in sync. + * + * Networks absent from this map do not support Earn — see `isEarnSupportedNetwork`. + */ +export const BLEND_FIXED_POOL_IDS: Partial> = { + [NETWORKS.PUBLIC]: "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD", + [NETWORKS.TESTNET]: + "CCEBVDYM32YNYCVNRXQKDFFPISJJCV557CDZEIRBEE4NCV4KHPQ44HGF", +}; + +export const getBlendPoolId = (networkDetails: NetworkDetails) => + BLEND_FIXED_POOL_IDS[networkDetails.network as NETWORKS]; + +/** + * Earn is only available where we have an allowlisted pool. The backend's Blend + * routes also reject any `?network=` outside PUBLIC/TESTNET, so gating here keeps + * the flow to a single code path with no custom-network fallback. + */ +export const isEarnSupportedNetwork = (networkDetails: NetworkDetails) => + Boolean(getBlendPoolId(networkDetails)); diff --git a/@shared/constants/sac.ts b/@shared/constants/sac.ts new file mode 100644 index 0000000000..1e6e3080a7 --- /dev/null +++ b/@shared/constants/sac.ts @@ -0,0 +1,34 @@ +import { NETWORKS } from "@shared/constants/stellar"; + +interface SacAddresses { + XLM: string; + USDC?: string; + EURC?: string; +} + +/** + * Well-known Stellar Asset Contract addresses, by network. + * + * A SAC address is a deterministic function of the classic asset and the network + * passphrase, so these are network facts rather than configuration — they can be + * re-derived with `new Asset(code, issuer).contractId(passphrase)`. They are + * spelled out here because several call sites need them without an issuer in + * hand (the Blend catalog addresses reserves by contract id, and the asset + * search needs the native contract before any asset is resolved), and because + * every test that fixtures a Soroban asset needs the real address: a placeholder + * contract id fails `getBalanceByKey`'s SAC derivation and silently makes a + * held asset look unheld. + */ +export const SACS: Record = { + [NETWORKS.PUBLIC]: { + XLM: "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA", + USDC: "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75", + EURC: "CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV", + }, + [NETWORKS.TESTNET]: { + XLM: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + }, +}; + +/** Shorthand for the mainnet addresses, which is what most callers want. */ +export const PUBLIC_SACS = SACS[NETWORKS.PUBLIC]; diff --git a/@shared/constants/services.ts b/@shared/constants/services.ts index d788684012..10011bcbec 100644 --- a/@shared/constants/services.ts +++ b/@shared/constants/services.ts @@ -68,6 +68,8 @@ export enum SERVICE_TYPES { CLEAR_RECENT_PROTOCOLS = "CLEAR_RECENT_PROTOCOLS", GET_DISCOVER_WELCOME_SEEN = "GET_DISCOVER_WELCOME_SEEN", DISMISS_DISCOVER_WELCOME = "DISMISS_DISCOVER_WELCOME", + GET_EARN_INTRO_SEEN = "GET_EARN_INTRO_SEEN", + DISMISS_EARN_INTRO = "DISMISS_EARN_INTRO", GET_CACHED_SWAP_TOP_TOKENS = "GET_CACHED_SWAP_TOP_TOKENS", CACHE_SWAP_TOP_TOKENS = "CACHE_SWAP_TOP_TOKENS", USER_ACTIVITY = "USER_ACTIVITY", diff --git a/@shared/helpers/soroban/__tests__/blend.test.ts b/@shared/helpers/soroban/__tests__/blend.test.ts new file mode 100644 index 0000000000..14d551b49f --- /dev/null +++ b/@shared/helpers/soroban/__tests__/blend.test.ts @@ -0,0 +1,146 @@ +import { Networks, scValToNative, xdr } from "stellar-sdk"; +import { + BlendRequestType, + buildBlendRequestScVal, + buildBlendSubmitOp, +} from "../blend"; +import { BLEND_FIXED_POOL_IDS } from "@shared/constants/blend"; +import { PUBLIC_SACS } from "@shared/constants/sac"; +import { NETWORKS } from "@shared/constants/stellar"; + +const USDC_SAC = PUBLIC_SACS.USDC!; +const XLM_SAC = PUBLIC_SACS.XLM; +const POOL_ID = BLEND_FIXED_POOL_IDS[NETWORKS.PUBLIC]!; +const PUBLIC_KEY = "GAX2VVWVHU5YQY5J3NJBXKHI3FFKZN54BE6GRJCWSIKSBZTQWJJNJMPC"; + +/** + * 500 USDC (7 decimals) as SupplyCollateral. Generated from this encoder and + * cross-checked byte-for-byte against `nativeToScVal` with an explicit type spec; + * the same shape is produced by `scRequestVec` in wallet-backend + * internal/integrationtests/infrastructure/blend_operations.go. + * + * If this value changes, the encoding changed — that is a protocol-level break, + * not a test to update casually. + */ +const GOLDEN_REQUEST_XDR = + "AAAAEQAAAAEAAAADAAAADwAAAAdhZGRyZXNzAAAAABIAAAABre/OWa7lKWj3YGHUlMJSW3Vln6QpamX0me8p5WR35JYAAAAPAAAABmFtb3VudAAAAAAACgAAAAAAAAAAAAAAASoF8gAAAAAPAAAADHJlcXVlc3RfdHlwZQAAAAMAAAAC"; + +describe("buildBlendRequestScVal", () => { + const buildUsdcSupplyCollateral = () => + buildBlendRequestScVal({ + assetId: USDC_SAC, + amount: "5000000000", + requestType: BlendRequestType.SupplyCollateral, + networkPassphrase: Networks.PUBLIC, + }); + + it("matches the golden XDR for a SupplyCollateral request", () => { + expect(buildUsdcSupplyCollateral().toXDR("base64")).toBe( + GOLDEN_REQUEST_XDR, + ); + }); + + it("orders the struct's symbol keys in ascending byte order", () => { + // Soroban rejects a UDT map whose keys are not sorted. This is the invariant + // that `nativeToScVal`'s localeCompare sort does not guarantee in general. + const keys = buildUsdcSupplyCollateral() + .map()! + .map((entry) => entry.key().sym().toString()); + + expect(keys).toEqual(["address", "amount", "request_type"]); + expect([...keys].sort()).toEqual(keys); + }); + + it("round-trips to the input values", () => { + expect(scValToNative(buildUsdcSupplyCollateral())).toEqual({ + address: USDC_SAC, + amount: BigInt("5000000000"), + request_type: BlendRequestType.SupplyCollateral, + }); + }); + + it("encodes amount as i128, not u32 or u64", () => { + const amount = buildUsdcSupplyCollateral() + .map()! + .find((entry) => entry.key().sym().toString() === "amount")! + .val(); + + expect(amount.switch().name).toBe("scvI128"); + }); + + it("preserves amounts beyond Number.MAX_SAFE_INTEGER", () => { + const huge = "170141183460469231731687303715884105727"; + const request = buildBlendRequestScVal({ + assetId: USDC_SAC, + amount: huge, + requestType: BlendRequestType.SupplyCollateral, + networkPassphrase: Networks.PUBLIC, + }); + + expect(scValToNative(request).amount).toBe(BigInt(huge)); + }); + + it("encodes each request type as its documented numeric value", () => { + // 0/1/2/3 are fixed by the Blend v2 contract; a silent renumbering here would + // turn a deposit into a withdrawal. + expect(BlendRequestType.Supply).toBe(0); + expect(BlendRequestType.Withdraw).toBe(1); + expect(BlendRequestType.SupplyCollateral).toBe(2); + expect(BlendRequestType.WithdrawCollateral).toBe(3); + }); +}); + +describe("buildBlendSubmitOp", () => { + const buildOp = () => + buildBlendSubmitOp({ + poolId: POOL_ID, + publicKey: PUBLIC_KEY, + requests: [ + buildBlendRequestScVal({ + assetId: XLM_SAC, + amount: "10000000", + requestType: BlendRequestType.SupplyCollateral, + networkPassphrase: Networks.PUBLIC, + }), + ], + networkPassphrase: Networks.PUBLIC, + }); + + const getInvokeArgs = (op: xdr.Operation) => + op.body().invokeHostFunctionOp().hostFunction().invokeContract(); + + it("invokes submit on the pool contract", () => { + const invocation = getInvokeArgs(buildOp()); + + expect(invocation.functionName().toString()).toBe("submit"); + expect( + scValToNative(xdr.ScVal.scvAddress(invocation.contractAddress())), + ).toBe(POOL_ID); + }); + + it("passes the user as from, spender and to", () => { + const args = getInvokeArgs(buildOp()).args(); + + // These being equal is what makes simulation emit source-account credentials, + // which is why no separate auth-entry signature is needed. + expect(args.slice(0, 3).map(scValToNative)).toEqual([ + PUBLIC_KEY, + PUBLIC_KEY, + PUBLIC_KEY, + ]); + }); + + it("passes requests as a vec", () => { + const args = getInvokeArgs(buildOp()).args(); + + expect(args).toHaveLength(4); + expect(args[3].switch().name).toBe("scvVec"); + expect(scValToNative(args[3])).toEqual([ + { + address: XLM_SAC, + amount: BigInt("10000000"), + request_type: BlendRequestType.SupplyCollateral, + }, + ]); + }); +}); diff --git a/@shared/helpers/soroban/blend.ts b/@shared/helpers/soroban/blend.ts new file mode 100644 index 0000000000..7f137c48b8 --- /dev/null +++ b/@shared/helpers/soroban/blend.ts @@ -0,0 +1,100 @@ +import { xdr } from "stellar-sdk"; +import { getSdk } from "@shared/helpers/stellar"; + +/** + * Blend v2 pool `Request.request_type`. The protocol docs name 0/2 `Deposit` and + * `Deposit Collateral`; blend-sdk-js names them `Supply`/`SupplyCollateral`. + * Same values either way. + * + * Only the deposit/withdraw half is modelled here — borrow (4), repay (5) and the + * auction fill types (6-9) are not reachable from Freighter. + */ +export enum BlendRequestType { + Supply = 0, + Withdraw = 1, + SupplyCollateral = 2, + WithdrawCollateral = 3, +} + +interface BuildBlendRequestScValParams { + /** The reserve's asset contract address (the SAC / SEP-41 token), not the pool. */ + assetId: string; + /** Base-10 string in the asset's smallest unit (i.e. already scaled by decimals). */ + amount: string; + requestType: BlendRequestType; + networkPassphrase: string; +} + +/** + * Encodes Blend v2's `Request { address, amount, request_type }` struct. + * + * Soroban encodes a UDT struct as an ScMap whose Symbol keys are in ascending + * BYTE order: address, amount, request_type. This is hand-built rather than going + * through `nativeToScVal`, which sorts with `String.localeCompare` — locale + * collation ignores `_`, so it is not byte order in general (it would invert + * `r_two` vs `reactivity`, for instance). For these three keys the two orders + * happen to agree, but relying on that is a trap for the next struct. + * + * Mirrors `scRequestVec` in wallet-backend + * internal/integrationtests/infrastructure/blend_operations.go. + */ +export const buildBlendRequestScVal = ({ + assetId, + amount, + requestType, + networkPassphrase, +}: BuildBlendRequestScValParams): xdr.ScVal => { + const Sdk = getSdk(networkPassphrase); + + return xdr.ScVal.scvMap([ + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol("address"), + val: new Sdk.Address(assetId).toScVal(), + }), + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol("amount"), + val: new Sdk.XdrLargeInt("i128", amount).toI128(), + }), + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol("request_type"), + val: xdr.ScVal.scvU32(requestType), + }), + ]); +}; + +interface BuildBlendSubmitOpParams { + poolId: string; + publicKey: string; + requests: xdr.ScVal[]; + networkPassphrase: string; +} + +/** + * Builds `pool.submit(from, spender, to, requests)`. + * + * All three addresses are the user: they own the position, pay for it, and receive + * it. Because `from`/`spender` equal the transaction source account, simulation + * returns the auth entry with source-account credentials, so the envelope + * signature covers it — no `authorizeEntry` round-trip is needed. + * + * We use `submit` rather than `submit_with_allowance`: the pool pulls the asset via + * `require_auth` on the SAC's `transfer` within this same transaction, so there is + * no separate approval step. + */ +export const buildBlendSubmitOp = ({ + poolId, + publicKey, + requests, + networkPassphrase, +}: BuildBlendSubmitOpParams) => { + const Sdk = getSdk(networkPassphrase); + const user = new Sdk.Address(publicKey).toScVal(); + + return new Sdk.Contract(poolId).call( + "submit", + user, + user, + user, + xdr.ScVal.scvVec(requests), + ); +}; diff --git a/extension/e2e-tests/README.md b/extension/e2e-tests/README.md index c88e60f92a..e75da30236 100644 --- a/extension/e2e-tests/README.md +++ b/extension/e2e-tests/README.md @@ -187,6 +187,17 @@ npx playwright show-trace test-results/[test-name]/trace.zip - Check that stub functions are properly defined in test setup - Ensure routes are registered before navigation +- Check the **scope**: `page.route` only intercepts requests made by the popup. + Anything the background service worker fetches — which is every + freighter-backend-v2 endpoint, since those go through `fetchBackendV2` -> + `callBackendV2` — needs `context.route` instead. A `page.route` on one of + those silently never fires and the request goes to the real backend. Stubs + taking a `BrowserContext` (`stubRpcHealth`, `stubAccountHistory`, + `stubBlendEarn`, ...) are the ones in this category; backend-v1 and Horizon + endpoints the popup fetches directly stay on `page.route`. +- When an assertion fails, read `test-results//error-context.md` — it + includes a snapshot of the rendered page, which usually names the state the + component fell into (a loading or error view) rather than leaving you to guess. ## Writing New Tests diff --git a/extension/e2e-tests/earnDeposit.test.ts b/extension/e2e-tests/earnDeposit.test.ts new file mode 100644 index 0000000000..9cf5d920e1 --- /dev/null +++ b/extension/e2e-tests/earnDeposit.test.ts @@ -0,0 +1,372 @@ +/** + * E2E spec: Earn deposit flow (Blend) + * + * Covers: + * 1. Entry point + first-run interstitial, then skipped on re-entry + * 2. Token picker: held vs supported sections, APY badges + * 3. "Not enough X" sheet — EURC (swap/transfer, no Buy: not Coinbase-listed) + * 4. "Not enough X" sheet — USDC (buy + swap + transfer) + * 5. Held-token happy path: Max bounces off the fee guard, then a smaller + * amount -> review -> confirm -> Deposited! + * 6. Pool details sheet, including its Backstop row + * 7. Earn tile hidden on a custom network with no allowlisted pool + * + * Stub URL shapes (all registered by stubBlendEarn), on the CONTEXT: + * - earn options: "** /protocols/blend/earn-options**" + * - pool catalog: "** /protocols/blend/pools**" + * - 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. + * + * The deposit's build and broadcast go through "** /simulate-tx**" and + * "** /submit-tx**" instead. Those stay PAGE-scoped: they are backend-v1 + * endpoints the popup fetches directly. Test 5 overrides the shared simulate + * stub with stubEarnSimulateTx, whose prepared XDR actually decodes — the + * shared one's placeholder does not, and signing rejects it. + * + * Asset ids in the stub are the REAL mainnet SACs, because getBalanceByKey + * resolves an earn option to a held balance by deriving that SAC. Placeholder + * contract ids would make every token look unheld. + * + * testid index: + * - nav-link-earn AccountHeader/index.tsx (Home action row) + * - earn-intro EarnIntro/index.tsx + * - earn-intro-start EarnIntro/index.tsx + * - earn-token-picker EarnTokenPicker/index.tsx + * - earn-token-picker-close EarnTokenPicker/index.tsx + * - earn-token-row- EarnTokenPicker/index.tsx + * - earn-apy- EarnTokenPicker/index.tsx + * - earn-not-enough-sheet NotEnoughTokenSheet.tsx + * - earn-not-enough-{buy,swap,transfer} + * - earn-amount EarnAmount/index.tsx + * - earn-amount-btn-continue EarnAmount/index.tsx + * - earn-pool-card EarnAmount/PoolCard.tsx + * - earn-pool-details-sheet PoolDetailsSheet/index.tsx + * - earn-pool-backstop PoolDetailsSheet/index.tsx + * - earn-review EarnReview/index.tsx + * - earn-review-confirm EarnReview/index.tsx + * - earn-submit EarnSubmit/index.tsx + * - earn-submit-done EarnSubmit/index.tsx + * + * Execution: `yarn test:e2e e2e-tests/earnDeposit.test.ts` from repo root. + */ + +import { test, expect } from "./test-fixtures"; +import { Page } from "@playwright/test"; +import { loginToTestAccount, switchToMainnet } from "./helpers/login"; +import { stubBlendEarn, stubEarnSimulateTx } from "./helpers/stubs"; + +/** Home -> Earn, through the first-run interstitial. */ +async function openEarnFlow(page: Page) { + await page.getByTestId("nav-link-earn").click(); + await expect(page.getByTestId("earn-intro")).toBeVisible(); + await page.getByTestId("earn-intro-start").click(); + await expect(page.getByTestId("earn-token-picker")).toBeVisible(); +} + +// --------------------------------------------------------------------------- +// 1. Entry point and the one-time interstitial +// --------------------------------------------------------------------------- +test("Earn tile opens the interstitial once, then goes straight to the picker", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + + await loginToTestAccount({ + page, + extensionId, + context, + stubOverrides: () => stubBlendEarn(context), + }); + await switchToMainnet(page); + + await openEarnFlow(page); + + // Leave and re-enter: the dismissal is persisted in the background store, so + // the interstitial must not reappear. Close via the header's X by its own + // testid rather than the first button in the subtree — the picker's rows are + // buttons too, so `.first()` is only incidentally the close control. The + // picker owns that X directly; it no longer goes through SubviewHeader, so + // there is no BackButton to reach for. + await page + .getByTestId("earn-token-picker") + .getByTestId("earn-token-picker-close") + .click(); + await expect(page.getByTestId("account-view")).toBeVisible({ + timeout: 30000, + }); + + await page.getByTestId("nav-link-earn").click(); + + await expect(page.getByTestId("earn-token-picker")).toBeVisible(); + await expect(page.getByTestId("earn-intro")).toBeHidden(); +}); + +// --------------------------------------------------------------------------- +// 2. Picker sections and APY badges +// --------------------------------------------------------------------------- +test("token picker splits held from supported and shows each rate", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + + await loginToTestAccount({ + page, + extensionId, + context, + stubOverrides: () => stubBlendEarn(context), + }); + await switchToMainnet(page); + await openEarnFlow(page); + + // The test account holds only XLM, so XLM is the sole held row and the other + // two reserves fall into "Other supported assets" — the heading only reads + // "Supported tokens" when nothing is held, where there is no "other" to be + // other than. Scoped to the picker: the Earn view keeps every visited step + // mounted, and the intro and the picker now share a subtitle, so an unscoped + // getByText would match both screens. + const picker = page.getByTestId("earn-token-picker"); + await expect(picker.getByText("In your wallet")).toBeVisible(); + await expect(picker.getByText("Other supported assets")).toBeVisible(); + + await expect(page.getByTestId("earn-token-row-XLM")).toBeVisible(); + await expect(page.getByTestId("earn-token-row-USDC")).toBeVisible(); + await expect(page.getByTestId("earn-token-row-EURC")).toBeVisible(); + + await expect(page.getByTestId("earn-apy-USDC")).toContainText("16.94%"); + await expect(page.getByTestId("earn-apy-EURC")).toContainText("10.59%"); + + await expect( + picker.getByText("APY may change based on protocol conditions.", { + exact: false, + }), + ).toBeVisible(); +}); + +// --------------------------------------------------------------------------- +// 3. "Not enough X" — EURC is not Coinbase-listed, so no Buy button +// --------------------------------------------------------------------------- +test("zero-balance EURC offers swap and transfer but not buy", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + + await loginToTestAccount({ + page, + extensionId, + context, + stubOverrides: () => stubBlendEarn(context), + }); + await switchToMainnet(page); + await openEarnFlow(page); + + await page.getByTestId("earn-token-row-EURC").click(); + + const sheet = page.getByTestId("earn-not-enough-sheet"); + await expect(sheet).toBeVisible(); + await expect(sheet).toContainText("Not enough EURC"); + await expect(page.getByTestId("earn-not-enough-swap")).toBeVisible(); + await expect(page.getByTestId("earn-not-enough-transfer")).toBeVisible(); + await expect(page.getByTestId("earn-not-enough-buy")).toBeHidden(); +}); + +// --------------------------------------------------------------------------- +// 4. "Not enough X" — USDC is onrampable and the account holds swappable XLM +// --------------------------------------------------------------------------- +test("zero-balance USDC offers buy, swap and transfer", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + + await loginToTestAccount({ + page, + extensionId, + context, + stubOverrides: () => stubBlendEarn(context), + }); + await switchToMainnet(page); + await openEarnFlow(page); + + await page.getByTestId("earn-token-row-USDC").click(); + + await expect(page.getByTestId("earn-not-enough-sheet")).toContainText( + "Not enough USDC", + ); + await expect(page.getByTestId("earn-not-enough-buy")).toBeVisible(); + await expect(page.getByTestId("earn-not-enough-swap")).toBeVisible(); + await expect(page.getByTestId("earn-not-enough-transfer")).toBeVisible(); +}); + +// --------------------------------------------------------------------------- +// 5. Happy path on a held token +// --------------------------------------------------------------------------- +test("depositing a held token reaches the success screen", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + + await loginToTestAccount({ + page, + extensionId, + context, + stubOverrides: async () => { + await stubBlendEarn(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); + }, + }); + await switchToMainnet(page); + await openEarnFlow(page); + + await page.getByTestId("earn-token-row-XLM").click(); + await expect(page.getByTestId("earn-amount")).toBeVisible(); + + // The CTA stays disabled until there is an amount to review. + await expect(page.getByTestId("earn-amount-btn-continue")).toBeDisabled(); + + // Max deliberately offers the whole spendable balance — nothing is held back + // for the Soroban resource fee, which is only knowable after simulation. So + // Max on XLM is expected to bounce off the post-simulation guard rather than + // open Review, and the CTA handler is where that check runs. + await page.getByTestId("SendAmountSetMax").click(); + await expect(page.getByTestId("earn-amount-btn-continue")).toBeEnabled(); + await page.getByTestId("earn-amount-btn-continue").click(); + + await expect( + page.getByText("Not enough XLM left for the network fee", { exact: false }), + ).toBeVisible(); + await expect(page.getByTestId("earn-review")).toBeHidden(); + + // Reduce to an amount that leaves room for the fee and the deposit proceeds. + await page + .getByTestId("earn-amount") + .getByTestId("send-amount-amount-input") + .fill("100"); + await expect(page.getByTestId("earn-amount-btn-continue")).toBeEnabled(); + await page.getByTestId("earn-amount-btn-continue").click(); + + const review = page.getByTestId("earn-review"); + await expect(review).toBeVisible(); + await expect(review).toContainText("You are depositing"); + await expect(page.getByTestId("earn-review-position")).toBeVisible(); + + await page.getByTestId("earn-review-confirm").click(); + + await expect(page.getByTestId("earn-submit")).toBeVisible(); + await expect(page.getByTestId("earn-submit-done")).toBeVisible({ + timeout: 30000, + }); + await expect(page.getByText("Deposited!")).toBeVisible(); +}); + +// --------------------------------------------------------------------------- +// 6. Pool details sheet +// --------------------------------------------------------------------------- +test("pool details sheet shows market stats including Backstop", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + + await loginToTestAccount({ + page, + extensionId, + context, + stubOverrides: () => stubBlendEarn(context), + }); + await switchToMainnet(page); + await openEarnFlow(page); + + await page.getByTestId("earn-token-row-XLM").click(); + await page.getByTestId("earn-pool-card").click(); + + const sheet = page.getByTestId("earn-pool-details-sheet"); + await expect(sheet).toBeVisible(); + await expect(page.getByTestId("earn-pool-interest-apy")).toContainText( + "4.24%", + ); + await expect(page.getByTestId("earn-pool-net-apy")).toContainText("16.94%"); + await expect(page.getByTestId("earn-pool-supplied")).toContainText("$50.05M"); + await expect(page.getByTestId("earn-pool-borrowed")).toContainText("$16.15M"); + + // Rendered from backstop_usd when the catalog supplies it; "--" otherwise, so + // the row never implies a pool has no insurance when the value is simply + // unavailable. + await expect(page.getByTestId("earn-pool-backstop")).toContainText("$1.53M"); + + await expect(page.getByTestId("earn-pool-docs-link")).toBeVisible(); +}); + +// --------------------------------------------------------------------------- +// 7. Network gating +// --------------------------------------------------------------------------- +test("Earn tile is hidden on a custom network with no allowlisted pool", async ({ + page, + extensionId, + context, +}) => { + test.slow(); + + // Both stock networks the selector offers — Testnet (where login lands) and + // Mainnet — have a BLEND_FIXED_POOL_IDS entry, so neither exercises the gate. + // A custom network is the only reachable one without an allowlisted pool. + await loginToTestAccount({ + page, + extensionId, + context, + stubOverrides: () => stubBlendEarn(context), + }); + + await expect(page.getByTestId("nav-link-earn")).toBeVisible(); + + await page.getByTestId("account-options-dropdown").click(); + await page.getByText("Settings").click(); + await page.getByText("Network").click(); + await page.getByText("Add custom network").click(); + await page.getByTestId("NetworkForm__networkName").fill("test standalone"); + await page + .getByTestId("NetworkForm__networkUrl") + .fill("https://horizon-testnet.stellar.org"); + await page + .getByTestId("NetworkForm__sorobanRpcUrl") + .fill("https://soroban-testnet.stellar.org/"); + await page + .getByTestId("NetworkForm__networkPassphrase") + .fill("Test SDF Network ; September 2015"); + await page.getByTestId("NetworkForm__add").click(); + await page.getByTestId("BackButton").click(); + await page.getByTestId("BackButton").click(); + + await expect(page.getByTestId("account-view")).toBeVisible({ + timeout: 30000, + }); + + // Adding a network does not select it, so switch to it explicitly — the tile + // is gated on the ACTIVE network, and without this the account is still on + // Testnet, which does have an allowlisted pool. + await page.getByTestId("network-selector-open").click(); + await page.getByText("test standalone").click(); + await expect(page.getByTestId("network-selector-open")).toContainText( + "test standalone", + { timeout: 30000 }, + ); + + // Send stays available everywhere, so it pins that the action row rendered at + // all — otherwise a blank row would satisfy the Earn assertion on its own. + await expect(page.getByTestId("nav-link-send")).toBeVisible(); + await expect(page.getByTestId("nav-link-earn")).toBeHidden(); +}); diff --git a/extension/e2e-tests/helpers/stubs.ts b/extension/e2e-tests/helpers/stubs.ts index 7997800ca9..9f653f1069 100644 --- a/extension/e2e-tests/helpers/stubs.ts +++ b/extension/e2e-tests/helpers/stubs.ts @@ -1,5 +1,8 @@ import { BrowserContext, Page } from "@playwright/test"; import { USDC_TOKEN_ADDRESS, TEST_TOKEN_ADDRESS } from "./test-token"; +import { BLEND_FIXED_POOL_IDS } from "@shared/constants/blend"; +import { PUBLIC_SACS } from "@shared/constants/sac"; +import { NETWORKS } from "@shared/constants/stellar"; export const createAssetObject = (assetCode: string | null, issuer: string) => { if (!assetCode || assetCode === "XLM") { @@ -3567,3 +3570,217 @@ export const stubVerifiedToken = async ( await route.fulfill({ json: verifiedAssetList }); }); }; + +/** + * 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 + * round-trips through `TransactionBuilder.fromXDR` under both network + * passphrases. + */ +const EARN_PREPARED_TX_XDR = + "AAAAAgAAAADLvQoIbFw9k0tgjZoOrLTuJJY9kHFYp/YAEAlt/xirbAASM1YAAAAASZYC0wAAAAEAAAAAAAAAAAAAAABqh3mXAAAAAAAAAAEAAAAAAAAAGAAAAAAAAAABEpzIzGM28f273MDzmDQ0w824X9nqhWl6N4LTGNh0pYAAAAAGc3VibWl0AAAAAAAEAAAAEgAAAAAAAAAAy70KCGxcPZNLYI2aDqy07iSWPZBxWKf2ABAJbf8Yq2wAAAASAAAAAAAAAADLvQoIbFw9k0tgjZoOrLTuJJY9kHFYp/YAEAlt/xirbAAAABIAAAAAAAAAAMu9CghsXD2TS2CNmg6stO4klj2QcVin9gAQCW3/GKtsAAAAEAAAAAEAAAABAAAAEQAAAAEAAAADAAAADwAAAAdhZGRyZXNzAAAAABIAAAABJbT82FmuwvpjSEOMSJs8PBDJi20hvk/TyzDLaJU++XcAAAAPAAAABmFtb3VudAAAAAAACgAAAAAAAAAAAAAAADuaygAAAAAPAAAADHJlcXVlc3RfdHlwZQAAAAMAAAACAAAAAAAAAAEAAAAAAAAAAAAAAAAAHoSAAAATiAAAC7gAAAAAAAhWWwAAAAA="; + +/** + * Earn-scoped override for the deposit's simulation. + * + * The shared `stubBackendSimulateTx` returns a placeholder `preparedTransaction` + * that is not a decodable envelope. Specs that never sign it are unaffected, but + * the Earn deposit hands that XDR to `signFreighterSorobanTransaction`, whose + * background handler does `TransactionBuilder.fromXDR(...).sign(...)` — so the + * placeholder throws and the flow lands on "Transaction failed. Try again." + * instead of the submit screen. + * + * This returns a real prepared Soroban envelope: `submit` on the mainnet Fixed + * pool, sourced by the test account, carrying sorobanData. Registered on the + * PAGE, because /simulate-tx is a backend-v1 endpoint the popup fetches directly + * — and registered after `stubAllExternalApis` so it wins the route match. + * + * `minResourceFee` is the pool's real ~0.0546 XLM rather than the shared stub's + * token 100 stroops, so the post-simulation fee guard is exercised at a + * realistic magnitude. + */ +export const stubEarnSimulateTx = async (page: Page) => { + await page.route("**/simulate-tx**", async (route) => { + await route.fulfill({ + json: { + preparedTransaction: EARN_PREPARED_TX_XDR, + simulationResponse: { + minResourceFee: "546395", + cost: { + cpuInsns: "2000000", + memBytes: "5000", + }, + latestLedger: "10000", + }, + }, + }); + }); +}; + +/** + * Stubs the three Blend endpoints the Earn flow reads. + * + * Asset ids are the real mainnet SACs so `getBalanceByKey` resolves them the + * way it does in production — XLM via its native-SAC special case, the rest via + * classic-SAC derivation. Using placeholder contract ids would make every token + * look unheld and silently collapse the "In your wallet" section. + * + * All three are backend-v2 endpoints, so the popup only sends a message and the + * actual fetch happens in the background service worker (#2879): `blend.ts` -> + * `fetchBackendV2` -> `callBackendV2`. They must therefore be intercepted with + * `context.route`; `page.route` only sees the popup and would let every request + * through to the real INDEXER_V2_URL, leaving the picker on its error state. + * + * `**\/accounts/positions**` does not collide with the page-level + * `**\/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. + */ +export const stubBlendEarn = async ( + context: BrowserContext, + { + positions = [], + }: { + positions?: unknown[]; + } = {}, +) => { + const { XLM: XLM_SAC, USDC: USDC_SAC, EURC: EURC_SAC } = PUBLIC_SACS; + // A reserve the pool carries but earn-options never offers, so it exists only + // to exercise the disabled-reserve path. Its real mainnet SAC, derived the + // same way as PUBLIC_SACS. + const AQUA_SAC = "CAUIKL3IYGMERDRUN6YSCLWVAKIFG5Q4YJHUKM4S4NJZQIA3BAS6OJPK"; + // The same constant the flow filters on, so a pool-id change cannot leave the + // stub serving an offer the picker silently discards. + const POOL_ID = BLEND_FIXED_POOL_IDS[NETWORKS.PUBLIC]!; + + const offer = (supplyApy: number) => ({ + id: POOL_ID, + // The live pool is named "Fixed", not "Fixed Pool v2". + name: "Fixed", + supply_apy: supplyApy, + // Null rather than 0 for USDC/EURC: on the live pool only XLM has a + // supply-side BLND stream, so this mirrors what the backend returns. + emissions_supply_apr: null, + supplied_usd: 50050000, + }); + + // 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. + await context.route(AMPLITUDE_EXPERIMENT_VARDATA_ROUTE, async (route) => { + await route.fulfill({ + json: { earn_deposit: { key: "on", value: "on" } }, + }); + }); + + await context.route("**/protocols/blend/earn-options**", async (route) => { + await route.fulfill({ + json: { + data: { + options: [ + { + asset_id: USDC_SAC, + symbol: "USDC", + name: "USD Coin", + decimals: 7, + pools: [offer(0.1694)], + }, + { + // Native XLM really does come back with a null symbol and name + // from the live catalog — verified against dev. Hardcoding "XLM" + // here hid a bug where the row rendered with no token code. + asset_id: XLM_SAC, + symbol: null, + name: null, + decimals: 7, + pools: [offer(0.0002)], + }, + { + asset_id: EURC_SAC, + symbol: "EURC", + name: "Euro Coin", + decimals: 7, + pools: [offer(0.1059)], + }, + ], + }, + }, + }); + }); + + await context.route("**/protocols/blend/pools**", async (route) => { + await route.fulfill({ + json: { + data: { + pools: [ + { + id: POOL_ID, + name: "Fixed", + status: "ACTIVE", + supplied_usd: 50050000, + borrowed_usd: 16150000, + interest_apy: 0.0424, + net_apy: 0.1694, + backstop_usd: 1530000, + // The pools catalog reports every reserve with its own `enabled` + // flag, disabled ones included — unlike earn-options above, which + // the backend derives with the disabled reserves already dropped. + // AQUA is here as the disabled case: the sheet's accepted-token + // cluster must leave it out, since Blend rejects a deposit into a + // disabled reserve. + reserves: [ + { + asset_id: XLM_SAC, + symbol: null, + name: null, + decimals: 7, + enabled: true, + utilization: 0.32, + supply_apy: 0.0002, + borrow_apy: 0.09, + emissions_supply_apr: 0.0001, + supplied_usd: 50050000, + borrowed_usd: 16150000, + price_usd: 0.15, + }, + { + asset_id: USDC_SAC, + symbol: "USDC", + name: "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", + decimals: 7, + enabled: true, + utilization: 0.71, + supply_apy: 0.1694, + borrow_apy: 0.22, + emissions_supply_apr: null, + supplied_usd: 12000000, + borrowed_usd: 8500000, + price_usd: 1, + }, + { + asset_id: AQUA_SAC, + symbol: "AQUA", + name: "AQUA:GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA", + decimals: 7, + enabled: false, + utilization: 0, + supply_apy: 0, + borrow_apy: 0, + emissions_supply_apr: null, + supplied_usd: 0, + borrowed_usd: 0, + price_usd: null, + }, + ], + }, + ], + }, + }, + }); + }); + + await context.route("**/accounts/positions**", async (route) => { + await route.fulfill({ json: { data: positions } }); + }); +}; diff --git a/extension/src/background/messageListener/__tests__/dismissEarnIntro.test.ts b/extension/src/background/messageListener/__tests__/dismissEarnIntro.test.ts new file mode 100644 index 0000000000..b6cd71f5a7 --- /dev/null +++ b/extension/src/background/messageListener/__tests__/dismissEarnIntro.test.ts @@ -0,0 +1,33 @@ +import { mockDataStorage } from "background/messageListener/helpers/test-helpers"; +import { HAS_SEEN_EARN_INTRO } from "constants/localStorageTypes"; + +import { dismissEarnIntro } from "../handlers/dismissEarnIntro"; +import { getEarnIntroSeen } from "../handlers/getEarnIntroSeen"; + +describe("dismissEarnIntro", () => { + beforeEach(async () => { + await mockDataStorage.remove(HAS_SEEN_EARN_INTRO); + }); + + it("persists the flag and reports it seen", async () => { + const result = await dismissEarnIntro({ localStore: mockDataStorage }); + + expect(result).toEqual({ hasSeenEarnIntro: true }); + expect(await mockDataStorage.getItem(HAS_SEEN_EARN_INTRO)).toBe(true); + }); + + it("is what makes the intro skip on the next flow entry", async () => { + await dismissEarnIntro({ localStore: mockDataStorage }); + + expect(await getEarnIntroSeen({ localStore: mockDataStorage })).toEqual({ + hasSeenEarnIntro: true, + }); + }); + + it("is idempotent", async () => { + await dismissEarnIntro({ localStore: mockDataStorage }); + const result = await dismissEarnIntro({ localStore: mockDataStorage }); + + expect(result).toEqual({ hasSeenEarnIntro: true }); + }); +}); diff --git a/extension/src/background/messageListener/__tests__/getEarnIntroSeen.test.ts b/extension/src/background/messageListener/__tests__/getEarnIntroSeen.test.ts new file mode 100644 index 0000000000..c6d1bb1e5b --- /dev/null +++ b/extension/src/background/messageListener/__tests__/getEarnIntroSeen.test.ts @@ -0,0 +1,22 @@ +import { mockDataStorage } from "background/messageListener/helpers/test-helpers"; +import { HAS_SEEN_EARN_INTRO } from "constants/localStorageTypes"; + +import { getEarnIntroSeen } from "../handlers/getEarnIntroSeen"; + +describe("getEarnIntroSeen", () => { + beforeEach(async () => { + await mockDataStorage.remove(HAS_SEEN_EARN_INTRO); + }); + + it("returns false when the flag has never been set", async () => { + const result = await getEarnIntroSeen({ localStore: mockDataStorage }); + expect(result).toEqual({ hasSeenEarnIntro: false }); + }); + + it("returns true once the flag has been persisted", async () => { + await mockDataStorage.setItem(HAS_SEEN_EARN_INTRO, true); + + const result = await getEarnIntroSeen({ localStore: mockDataStorage }); + expect(result).toEqual({ hasSeenEarnIntro: true }); + }); +}); diff --git a/extension/src/background/messageListener/handlers/dismissEarnIntro.ts b/extension/src/background/messageListener/handlers/dismissEarnIntro.ts new file mode 100644 index 0000000000..5feda327a4 --- /dev/null +++ b/extension/src/background/messageListener/handlers/dismissEarnIntro.ts @@ -0,0 +1,11 @@ +import { DataStorageAccess } from "background/helpers/dataStorageAccess"; +import { HAS_SEEN_EARN_INTRO } from "constants/localStorageTypes"; + +export const dismissEarnIntro = async ({ + localStore, +}: { + localStore: DataStorageAccess; +}): Promise<{ hasSeenEarnIntro: boolean }> => { + await localStore.setItem(HAS_SEEN_EARN_INTRO, true); + return { hasSeenEarnIntro: true }; +}; diff --git a/extension/src/background/messageListener/handlers/getEarnIntroSeen.ts b/extension/src/background/messageListener/handlers/getEarnIntroSeen.ts new file mode 100644 index 0000000000..816dc13289 --- /dev/null +++ b/extension/src/background/messageListener/handlers/getEarnIntroSeen.ts @@ -0,0 +1,11 @@ +import { DataStorageAccess } from "background/helpers/dataStorageAccess"; +import { HAS_SEEN_EARN_INTRO } from "constants/localStorageTypes"; + +export const getEarnIntroSeen = async ({ + localStore, +}: { + localStore: DataStorageAccess; +}): Promise<{ hasSeenEarnIntro: boolean }> => { + const seen = await localStore.getItem(HAS_SEEN_EARN_INTRO); + return { hasSeenEarnIntro: !!seen }; +}; diff --git a/extension/src/background/messageListener/popupMessageListener.ts b/extension/src/background/messageListener/popupMessageListener.ts index a3b42240e9..1cab052f3d 100644 --- a/extension/src/background/messageListener/popupMessageListener.ts +++ b/extension/src/background/messageListener/popupMessageListener.ts @@ -102,6 +102,8 @@ import { addRecentProtocol } from "./handlers/addRecentProtocol"; import { clearRecentProtocols } from "./handlers/clearRecentProtocols"; import { getDiscoverWelcomeSeen } from "./handlers/getDiscoverWelcomeSeen"; import { dismissDiscoverWelcome } from "./handlers/dismissDiscoverWelcome"; +import { getEarnIntroSeen } from "./handlers/getEarnIntroSeen"; +import { dismissEarnIntro } from "./handlers/dismissEarnIntro"; import { callBackendV2 } from "background/helpers/callBackendV2"; import { getCachedSwapTopTokens } from "./handlers/getCachedSwapTopTokens"; import { cacheSwapTopTokens } from "./handlers/cacheSwapTopTokens"; @@ -622,6 +624,12 @@ export const popupMessageListener = ( case SERVICE_TYPES.DISMISS_DISCOVER_WELCOME: { return dismissDiscoverWelcome({ localStore }); } + case SERVICE_TYPES.GET_EARN_INTRO_SEEN: { + return getEarnIntroSeen({ localStore }); + } + case SERVICE_TYPES.DISMISS_EARN_INTRO: { + return dismissEarnIntro({ localStore }); + } case SERVICE_TYPES.GET_CACHED_SWAP_TOP_TOKENS: { return getCachedSwapTopTokens({ request, localStore }); } diff --git a/extension/src/constants/localStorageTypes.ts b/extension/src/constants/localStorageTypes.ts index d69f3a7627..ad0eba3b40 100644 --- a/extension/src/constants/localStorageTypes.ts +++ b/extension/src/constants/localStorageTypes.ts @@ -34,4 +34,5 @@ export const IS_OPEN_SIDEBAR_BY_DEFAULT_ID = "isOpenSidebarByDefault"; export const METRICS_USER_ID = "metrics_user_id"; export const RECENT_PROTOCOLS = "recentProtocols"; export const HAS_SEEN_DISCOVER_WELCOME = "hasSeenDiscoverWelcome"; +export const HAS_SEEN_EARN_INTRO = "hasSeenEarnIntro"; export const AUTO_LOCK_TIMEOUT_MINUTES_ID = "autoLockTimeoutMinutes"; diff --git a/extension/src/helpers/__tests__/useGetBalances.test.tsx b/extension/src/helpers/__tests__/useGetBalances.test.tsx index 55d8e88dd9..29d191cbea 100644 --- a/extension/src/helpers/__tests__/useGetBalances.test.tsx +++ b/extension/src/helpers/__tests__/useGetBalances.test.tsx @@ -192,6 +192,7 @@ describe("useGetBalances (flag routing)", () => { isInitialized: true, use_token_prices_v2: true, use_balances_v2: useBalancesV2, + earn_deposit: false, maintenance_banner: { enabled: false, payload: undefined }, maintenance_screen: { enabled: false, payload: undefined }, }, diff --git a/extension/src/helpers/metrics.ts b/extension/src/helpers/metrics.ts index 24e01431cc..eee8f0fcfe 100644 --- a/extension/src/helpers/metrics.ts +++ b/extension/src/helpers/metrics.ts @@ -592,6 +592,7 @@ export type Flow = | "onboarding" | "send" | "swap" + | "earn" | "signing" | "assets" | "settings" diff --git a/extension/src/popup/App.tsx b/extension/src/popup/App.tsx index 02acb36279..e06af50db8 100755 --- a/extension/src/popup/App.tsx +++ b/extension/src/popup/App.tsx @@ -13,6 +13,7 @@ import { reducer as transactionSubmission } from "popup/ducks/transactionSubmiss import { reducer as tokenPaymentSimulation } from "popup/ducks/token-payment"; import { reducer as cache } from "popup/ducks/cache"; import { reducer as remoteConfig } from "popup/ducks/remoteConfig"; +import { reducer as earn } from "popup/ducks/earn"; import { ErrorTracking } from "popup/components/ErrorTracking"; import { AccountMismatch } from "popup/components/AccountMismatch"; import { ActivityTracker } from "popup/components/ActivityTracker"; @@ -31,6 +32,7 @@ const rootReducer = combineReducers({ tokenPaymentSimulation, cache, remoteConfig, + earn, }); export type AppState = ReturnType; export const store = configureStore({ diff --git a/extension/src/popup/Router.tsx b/extension/src/popup/Router.tsx index cbc23c9a72..fdbb52cb68 100644 --- a/extension/src/popup/Router.tsx +++ b/extension/src/popup/Router.tsx @@ -57,6 +57,7 @@ import { ManageAssets } from "popup/views/ManageAssets"; import { AddCollectibles } from "popup/views/AddCollectibles"; import { VerifyAccount } from "popup/views/VerifyAccount"; import { Swap } from "popup/views/Swap"; +import { Earn } from "popup/views/Earn"; import { ManageNetwork } from "popup/views/ManageNetwork"; import { LeaveFeedback } from "popup/views/LeaveFeedback"; import { AccountMigration } from "popup/views/AccountMigration"; @@ -275,6 +276,8 @@ export const Router = () => ( > }> }> + }> + }> } @@ -295,10 +298,7 @@ export const Router = () => ( path={ROUTES.advancedSettings} element={} > - } - > + }> } /> } /> diff --git a/extension/src/popup/__testHelpers__/index.tsx b/extension/src/popup/__testHelpers__/index.tsx index 3029df5be0..6c9711fb75 100644 --- a/extension/src/popup/__testHelpers__/index.tsx +++ b/extension/src/popup/__testHelpers__/index.tsx @@ -16,6 +16,7 @@ import { } from "popup/ducks/transactionSubmission"; import { reducer as tokenPaymentSimulation } from "popup/ducks/token-payment"; import { reducer as remoteConfig } from "popup/ducks/remoteConfig"; +import { reducer as earn } from "popup/ducks/earn"; import { WalletType } from "@shared/constants/hardwareWallet"; import { Account } from "@shared/api/types"; @@ -40,6 +41,7 @@ const rootReducer = combineReducers({ tokenPaymentSimulation, cache, remoteConfig, + earn, }); export const makeDummyStore = (state: any) => diff --git a/extension/src/popup/assets/blend-logo.svg b/extension/src/popup/assets/blend-logo.svg new file mode 100644 index 0000000000..df087dd35d --- /dev/null +++ b/extension/src/popup/assets/blend-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/extension/src/popup/assets/earn-glow.svg b/extension/src/popup/assets/earn-glow.svg new file mode 100644 index 0000000000..d555ed61b4 --- /dev/null +++ b/extension/src/popup/assets/earn-glow.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/extension/src/popup/assets/icon-earn.svg b/extension/src/popup/assets/icon-earn.svg new file mode 100644 index 0000000000..ac84724c40 --- /dev/null +++ b/extension/src/popup/assets/icon-earn.svg @@ -0,0 +1,3 @@ + + + diff --git a/extension/src/popup/components/BalanceRow/index.tsx b/extension/src/popup/components/BalanceRow/index.tsx index 447948cf1a..01c58fa5bc 100644 --- a/extension/src/popup/components/BalanceRow/index.tsx +++ b/extension/src/popup/components/BalanceRow/index.tsx @@ -33,6 +33,11 @@ export interface BalanceRowProps { /** Raw 24h % change number string (e.g. "1.23"); drives color + display. * Null → NO_FIAT_VALUE. */ percentChange?: string | null; + /** + * Replaces the fiat + 24h-delta pair on the right entirely. Used by the Earn + * token picker, whose rows carry an APY badge instead of a price. + */ + rightSlot?: React.ReactNode; onClick?: () => void; "data-testid"?: string; amountTestId?: string; @@ -57,6 +62,7 @@ export const BalanceRow = ({ amount, fiatAmount, percentChange, + rightSlot, onClick, "data-testid": dataTestId, amountTestId, @@ -112,19 +118,23 @@ export const BalanceRow = ({
- {hasFiat && ( -
- {fiatAmount} -
+ {rightSlot ?? ( + <> + {hasFiat && ( +
+ {fiatAmount} +
+ )} +
+ {hasDelta + ? `${formatAmount(roundUsdValue(percentChange as string))}%` + : NO_FIAT_VALUE} +
+ )} -
- {hasDelta - ? `${formatAmount(roundUsdValue(percentChange as string))}%` - : NO_FIAT_VALUE} -
); diff --git a/extension/src/popup/components/InternalTransaction/SubmitFail/index.tsx b/extension/src/popup/components/InternalTransaction/SubmitFail/index.tsx index 9f8173d859..37221f553a 100644 --- a/extension/src/popup/components/InternalTransaction/SubmitFail/index.tsx +++ b/extension/src/popup/components/InternalTransaction/SubmitFail/index.tsx @@ -31,7 +31,11 @@ interface ErrorDetails { status: string; } -export const SubmitFail = () => { +/** + * @param onDismiss overrides "Got it", which otherwise resets the submission and + * navigates home. An embedding flow needs to dismiss its own step instead. + */ +export const SubmitFail = ({ onDismiss }: { onDismiss?: () => void } = {}) => { const { error, transactionData } = useSelector(transactionSubmissionSelector); const isSwap = useIsSwap(); const { isCollectible, asset, destinationAsset } = transactionData; @@ -240,6 +244,10 @@ export const SubmitFail = () => { variant="tertiary" size="md" onClick={() => { + if (onDismiss) { + onDismiss(); + return; + } dispatch(resetSubmission()); navigateTo(ROUTES.account, navigate); }} diff --git a/extension/src/popup/components/InternalTransaction/SubmitTransaction/index.tsx b/extension/src/popup/components/InternalTransaction/SubmitTransaction/index.tsx index 0eaa15a638..3c39622c87 100644 --- a/extension/src/popup/components/InternalTransaction/SubmitTransaction/index.tsx +++ b/extension/src/popup/components/InternalTransaction/SubmitTransaction/index.tsx @@ -43,11 +43,24 @@ import "./styles.scss"; interface SendingTransactionProps { xdr: string; goBack: () => void; + /** + * Overrides what "Done" does on success. Defaults to navigating home and + * resetting the submission — correct for Send and Swap as routes, but wrong + * when this screen is embedded in another flow that owns its own exit. + */ + onDone?: () => void; + /** + * Overrides the in-flight "Close" button, which otherwise closes the popup + * outright. An embedding flow needs to stay open and dismiss its own step. + */ + onClose?: () => void; } export const SendingTransaction = ({ xdr, goBack, + onDone, + onClose, }: SendingTransactionProps) => { const { t } = useTranslation(); const dispatch: AppDispatch = useDispatch(); @@ -195,6 +208,10 @@ export const SendingTransaction = ({ variant="tertiary" onClick={(e) => { e.preventDefault(); + if (onClose) { + onClose(); + return; + } window.close(); }} > @@ -228,6 +245,10 @@ export const SendingTransaction = ({ variant="secondary" onClick={(e) => { e.preventDefault(); + if (onDone) { + onDone(); + return; + } navigateTo(ROUTES.account, navigate); setTimeout(() => { dispatch(resetSimulation()); @@ -363,21 +384,34 @@ export const SendingTransaction = ({ export const TransactionConfirm = ({ xdr, goBack, + onDone, + onClose, + onDismissError, }: { xdr: string; goBack: () => void; + onDone?: () => void; + onClose?: () => void; + onDismissError?: () => void; }) => { const submission = useSelector(transactionSubmissionSelector); const render = () => { switch (submission.submitStatus) { case ActionStatus.ERROR: - return ; + return ; case ActionStatus.IDLE: case ActionStatus.PENDING: case ActionStatus.SUCCESS: default: - return ; + return ( + + ); } }; diff --git a/extension/src/popup/components/SubviewHeader/index.tsx b/extension/src/popup/components/SubviewHeader/index.tsx index fd16cb9a7f..04e95f80b7 100644 --- a/extension/src/popup/components/SubviewHeader/index.tsx +++ b/extension/src/popup/components/SubviewHeader/index.tsx @@ -11,6 +11,8 @@ interface SubviewHeaderProps { subtitle?: React.ReactNode; hasBackButton?: boolean; rightButton?: React.ReactNode; + /** Sits in the left slot, after the back button if there is one. */ + leftButton?: React.ReactNode; } export const SubviewHeader = ({ @@ -20,10 +22,12 @@ export const SubviewHeader = ({ subtitle, hasBackButton = true, rightButton, + leftButton, }: SubviewHeaderProps) => ( + {isEarnDepositEnabled && + isEarnSupportedNetwork(networkDetails) ? ( + +
+
+ +
+ + {t("Earn")} + +
+
+ ) : null} {isBackgroundActive ? createPortal( diff --git a/extension/src/popup/components/account/AccountHeader/styles.scss b/extension/src/popup/components/account/AccountHeader/styles.scss index 656e194966..01a19ce7ce 100644 --- a/extension/src/popup/components/account/AccountHeader/styles.scss +++ b/extension/src/popup/components/account/AccountHeader/styles.scss @@ -279,7 +279,10 @@ &__actions { display: grid; - grid-template-columns: repeat(3, 1fr); + // Auto columns rather than a fixed count: the Earn tile only renders on + // networks with an allowlisted Blend pool, so the row is 3 or 4 wide. + grid-auto-flow: column; + grid-auto-columns: 1fr; margin-top: pxToRem(8px); gap: pxToRem(8px); // Figma's Balance frame closes with 24px, and the tab strip below adds no diff --git a/extension/src/popup/components/amount/AmountCard/__tests__/index.test.tsx b/extension/src/popup/components/amount/AmountCard/__tests__/index.test.tsx index 17093a93e4..9375596818 100644 --- a/extension/src/popup/components/amount/AmountCard/__tests__/index.test.tsx +++ b/extension/src/popup/components/amount/AmountCard/__tests__/index.test.tsx @@ -78,6 +78,43 @@ describe("AmountCard", () => { ).toBeInTheDocument(); }); + // The Earn deposit design carries the invalid state on the amount instead: its + // CTA already reads "Insufficient funds", and a message row would also grow + // the card past the design's fixed height. + it("reddens the amount instead of adding a row when asked to", () => { + render( + + + , + ); + + expect( + screen.queryByText( + "Insufficient balance. Maximum spendable: {{amount}} {{symbol}}", + ), + ).not.toBeInTheDocument(); + expect(screen.getByTestId("send-amount-amount-input").className).toContain( + "AmountCard__input-amount--invalid", + ); + }); + + it("leaves the amount unstyled when it is within balance", () => { + render( + + + , + ); + + expect( + screen.getByTestId("send-amount-amount-input").className, + ).not.toContain("AmountCard__input-amount--invalid"); + }); + it("shows the fiat line but no input-type toggle when read-only", () => { render( diff --git a/extension/src/popup/components/amount/AmountCard/index.tsx b/extension/src/popup/components/amount/AmountCard/index.tsx index 2f53f78ddd..9393eebd60 100644 --- a/extension/src/popup/components/amount/AmountCard/index.tsx +++ b/extension/src/popup/components/amount/AmountCard/index.tsx @@ -33,9 +33,24 @@ export interface AmountCardProps { hasUsdPrice?: boolean; fiatLineText: string; isAmountTooHigh: boolean; + /** + * How an over-limit amount is shown. "message" (the default) keeps the inline + * "Insufficient balance…" row. "amount" turns the amount itself red and leaves + * the card's height alone, which is what the Earn deposit design specifies — + * there the CTA already reads "Insufficient funds", so the row would only say + * it twice. + */ + invalidAmountStyle?: "message" | "amount"; /** Pre-formatted max-spendable amount shown in the insufficient-balance * error (e.g. "123.23"); the token code is taken from assetCode. */ maxSpendableText?: string; + /** + * The asset cannot be changed from this card, so the pill renders as a plain + * label — no chevron, not focusable. Used by the Earn swap sheet, where the + * receive token was chosen on the screen before and swapping it here would + * buy something the pool does not accept. + */ + isAssetLocked?: boolean; isReadOnly?: boolean; autoFocus?: boolean; /** Optional handle to the amount input so a parent can focus it (e.g. the @@ -69,7 +84,9 @@ export const AmountCard = ({ hasUsdPrice = true, fiatLineText, isAmountTooHigh, + invalidAmountStyle = "message", maxSpendableText = "", + isAssetLocked = false, isReadOnly = false, autoFocus = true, amountInputRef, @@ -114,7 +131,27 @@ export const AmountCard = ({ securityLevel === SecurityLevel.SUSPICIOUS; const isMalicious = securityLevel === SecurityLevel.MALICIOUS; - const fontClass = `AmountCard__input-amount AmountCard__${amountFontSizeClass}`; + // The icon-plus-code pair is identical whether the pill is a button or the + // locked label, and only the chevron differs. + const assetPill = ( + <> + + {assetCode} + + ); + + const isAmountShownInvalid = + isAmountTooHigh && invalidAmountStyle === "amount"; + const fontClass = `AmountCard__input-amount AmountCard__${amountFontSizeClass}${ + isAmountShownInvalid ? " AmountCard__input-amount--invalid" : "" + }`; return (
@@ -230,37 +267,36 @@ export const AmountCard = ({
--
))}
- + {isAssetLocked ? ( +
+ {assetPill} +
+ ) : ( + + )} {/* The fiat line is always shown (callers pass "$0.00"/"--" when there is @@ -278,6 +314,13 @@ export const AmountCard = ({ isRounded variant="tertiary" data-testid="amount-fiat-toggle" + // Do not take focus from the amount input. Pressing this fires a + // blur that a parent tracking focus reads as the user leaving the + // field — the swap CTA disables itself only while the input is + // focused, so it would flash enabled for a frame before the + // re-mounted input (crypto and fiat are separate inputs, each + // autoFocused) takes focus back. + onMouseDown={(e) => e.preventDefault()} onClick={(e) => { e.preventDefault(); onToggleInputType(); @@ -289,7 +332,7 @@ export const AmountCard = ({ - {isAmountTooHigh && ( + {isAmountTooHigh && invalidAmountStyle === "message" && (
diff --git a/extension/src/popup/components/amount/AmountCard/styles.scss b/extension/src/popup/components/amount/AmountCard/styles.scss index 413437f06d..75881887c2 100644 --- a/extension/src/popup/components/amount/AmountCard/styles.scss +++ b/extension/src/popup/components/amount/AmountCard/styles.scss @@ -115,7 +115,10 @@ color: var(--sds-clr-gray-12); min-height: pxToRem(36px); - &:hover { + // Only the interactive pill reacts to the pointer; the locked variant is a + // label. Written as an exclusion rather than an undo inside `--locked`, so + // the resting colour above is not duplicated and cannot drift from it. + &:hover:not(#{&}--locked) { background-color: var(--sds-clr-gray-06); } @@ -123,6 +126,14 @@ padding: pxToRem(4px) pxToRem(8px) pxToRem(4px) pxToRem(10px); } + // A locked pill is a label, not a control: no chevron in the markup, no + // pointer affordance suggesting one is missing, and the trailing padding the + // chevron used to provide. + &--locked { + cursor: default; + padding-right: pxToRem(12px); + } + .AccountAssets__asset--logo { width: pxToRem(20px) !important; height: pxToRem(20px) !important; @@ -203,6 +214,12 @@ } } + // Error/Text/Primary. Used instead of the message row when the caller asks for + // the amount itself to carry the invalid state. + &__input-amount--invalid { + color: var(--sds-clr-red-11); + } + &__invalid-state { display: flex; justify-content: center; diff --git a/extension/src/popup/components/amount/constants.ts b/extension/src/popup/components/amount/constants.ts new file mode 100644 index 0000000000..b4067a82a3 --- /dev/null +++ b/extension/src/popup/components/amount/constants.ts @@ -0,0 +1,12 @@ +/** + * The empty state of an amount field, shared by every screen that hosts + * `AmountCard`. + * + * They are strings, not numbers, because the amount lives in redux and formik as + * the user typed it. `"0"` is what an empty crypto input commits and what the + * screens compare against to decide whether an amount has been entered; + * `"0.00"` is its fiat counterpart, kept at cent precision so the fiat line + * never renders a bare "0". + */ +export const DEFAULT_AMOUNT = "0"; +export const DEFAULT_AMOUNT_USD = "0.00"; diff --git a/extension/src/popup/components/amount/helpers/__tests__/percentageAmount.test.ts b/extension/src/popup/components/amount/helpers/__tests__/percentageAmount.test.ts new file mode 100644 index 0000000000..6b1662e60b --- /dev/null +++ b/extension/src/popup/components/amount/helpers/__tests__/percentageAmount.test.ts @@ -0,0 +1,62 @@ +import { getPercentageAmount } from "../percentageAmount"; + +describe("getPercentageAmount", () => { + // The max spendable from the report: available less the XLM fee reserve. + const availableBalance = "9998.3942586"; + + it("treats the reported value as whole percents, not a multiplier", () => { + expect( + getPercentageAmount({ availableBalance, pct: 25, decimals: 7 }), + ).toBe("2499.5985646"); + // The regression: multiplying by the raw percent produced this. + expect( + getPercentageAmount({ availableBalance, pct: 25, decimals: 7 }), + ).not.toBe("249959.856465"); + }); + + it("covers the rest of the button set", () => { + expect( + getPercentageAmount({ availableBalance, pct: 50, decimals: 7 }), + ).toBe("4999.1971293"); + expect( + getPercentageAmount({ availableBalance, pct: 75, decimals: 7 }), + ).toBe("7498.7956939"); + }); + + it("Max commits exactly the maximum spendable", () => { + expect( + getPercentageAmount({ availableBalance, pct: 100, decimals: 7 }), + ).toBe("9998.3942586"); + }); + + it("never rounds up past the maximum spendable", () => { + // 75% of this is 0.7499999999...; ROUND_HALF_UP at 7dp would exceed it. + expect( + getPercentageAmount({ + availableBalance: "0.99999999", + pct: 75, + decimals: 7, + }), + ).toBe("0.7499999"); + }); + + it("respects a token's own precision", () => { + expect( + getPercentageAmount({ + availableBalance: "100.555", + pct: 25, + decimals: 2, + }), + ).toBe("25.13"); + }); + + it("stays at zero when there is nothing to deposit", () => { + expect( + getPercentageAmount({ + availableBalance: "0", + pct: 100, + decimals: 7, + }), + ).toBe("0"); + }); +}); diff --git a/extension/src/popup/components/swap/SwapAmount/helpers/swapAmountDisplay.ts b/extension/src/popup/components/amount/helpers/amountDisplay.ts similarity index 100% rename from extension/src/popup/components/swap/SwapAmount/helpers/swapAmountDisplay.ts rename to extension/src/popup/components/amount/helpers/amountDisplay.ts diff --git a/extension/src/popup/components/amount/helpers/percentageAmount.ts b/extension/src/popup/components/amount/helpers/percentageAmount.ts new file mode 100644 index 0000000000..ac4e316e2d --- /dev/null +++ b/extension/src/popup/components/amount/helpers/percentageAmount.ts @@ -0,0 +1,27 @@ +import BigNumber from "bignumber.js"; + +/** + * The amount a percentage button commits, shared by Send, Swap and the Earn + * deposit screen — all three host `PercentageButtons` over an available balance. + * + * `PercentageButtons` reports whole percents — 25/50/75, and 100 for Max — so + * the fraction has to be derived here; multiplying by the raw value asks for 25x + * the balance and every button trips the insufficient-balance check. + * + * Rounds DOWN at the asset's precision, so Max lands on the maximum spendable + * rather than a hair above it. + */ +export const getPercentageAmount = ({ + availableBalance, + pct, + decimals, +}: { + /** Spendable balance, already cleaned of group separators. */ + availableBalance: string; + pct: number; + decimals: number; +}) => + new BigNumber(availableBalance) + .multipliedBy(new BigNumber(pct).dividedBy(100)) + .decimalPlaces(decimals, BigNumber.ROUND_DOWN) + .toFixed(); diff --git a/extension/src/popup/components/earn/EarnAmount/NetworkFeeSheet.tsx b/extension/src/popup/components/earn/EarnAmount/NetworkFeeSheet.tsx new file mode 100644 index 0000000000..fab1492c2a --- /dev/null +++ b/extension/src/popup/components/earn/EarnAmount/NetworkFeeSheet.tsx @@ -0,0 +1,88 @@ +import React from "react"; +import { Button, Icon, Text } from "@stellar/design-system"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; + +import { EARN_FLOW_QUERY } from "popup/constants/earn"; +import { ROUTES } from "popup/constants/routes"; +import { navigateTo } from "popup/helpers/navigate"; +import { useGetOnrampToken } from "helpers/hooks/useGetOnrampToken"; + +interface NetworkFeeSheetProps { + onClose: () => void; + /** Onramp is mainnet-only; elsewhere only the transfer route is offered. */ + canBuyXlm: boolean; +} + +/** + * Blocks review when the account cannot cover the transaction fee. + * + * A Soroban invoke's fee is always paid in XLM, so this fires regardless of + * which asset is being deposited. Deliberately not the swap flow's + * XlmReserveSheet: that one's copy is about funding a new trustline's reserve, + * which a SupplyCollateral deposit never creates. + */ +export const NetworkFeeSheet = ({ + onClose, + canBuyXlm, +}: NetworkFeeSheetProps) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { fetchData: openOnramp } = useGetOnrampToken({ asset: "XLM" }); + + return ( +
+
+
+ +
+ +
+ + + {t("You need some XLM for the network fee")} + + +
+ + {t("Add XLM to your wallet to continue")} + +
+ +
+ {canBuyXlm && ( + + )} + +
+
+ ); +}; diff --git a/extension/src/popup/components/earn/EarnAmount/PoolCard.tsx b/extension/src/popup/components/earn/EarnAmount/PoolCard.tsx new file mode 100644 index 0000000000..3d6230f6c7 --- /dev/null +++ b/extension/src/popup/components/earn/EarnAmount/PoolCard.tsx @@ -0,0 +1,64 @@ +import React from "react"; +import { Icon, Text } from "@stellar/design-system"; +import { useTranslation } from "react-i18next"; + +import { formatRate } from "popup/components/earn/helpers/formatPoolStats"; +import { PoolIcon } from "popup/components/earn/PoolIcon"; + +interface PoolCardProps { + poolName: string | null; + /** The chosen asset's headline rate; null when there is no fresh price. */ + apy: number | null; + onOpenDetails: () => void; +} + +/** + * The destination pool, with its current rate on a ribbon above the card. + * Tapping it opens the pool details sheet. + */ +export const PoolCard = ({ poolName, apy, onOpenDetails }: PoolCardProps) => { + const { t } = useTranslation(); + + return ( +
+
+ + {/* The asterisk ties to the APY disclaimer shown on the token picker. */} + {t("Current APY: {{rate}}*", { rate: formatRate(apy) })} + +
+ + +
+ ); +}; 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 new file mode 100644 index 0000000000..6e7f4a4c5f --- /dev/null +++ b/extension/src/popup/components/earn/EarnAmount/__tests__/EarnAmount.position.test.tsx @@ -0,0 +1,197 @@ +import React from "react"; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import BigNumber from "bignumber.js"; + +import { TESTNET_NETWORK_DETAILS } from "@shared/constants/stellar"; +import { getBlendSuppliedTokens } from "@shared/api/helpers/blend"; +import { RequestState } from "constants/request"; +import { AppDataType } from "helpers/hooks/useGetAppData"; +import { EarnAmount } from "popup/components/earn/EarnAmount"; +import * as UseGetEarnAmountData from "popup/components/earn/EarnAmount/hooks/useGetEarnAmountData"; +import * as UseSimulateEarnDeposit from "popup/components/earn/EarnAmount/hooks/useSimulateEarnDeposit"; +import * as UseNetworkFees from "popup/helpers/useNetworkFees"; +import { initialState as earnInitialState } from "popup/ducks/earn"; +import { initialState as transactionSubmissionInitialState } from "popup/ducks/transactionSubmission"; +import { + TEST_PUBLIC_KEY, + TEST_USDC_CANONICAL, + Wrapper, +} from "popup/__testHelpers__"; + +jest.mock("@shared/api/helpers/blend", () => ({ + getBlendSuppliedTokens: jest.fn(), +})); + +jest.mock("helpers/metrics", () => ({ + ...jest.requireActual("helpers/metrics"), + emitMetric: jest.fn(), + emitScreenViewed: jest.fn(), +})); + +const POOL_ID = "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD"; +const USDC_SAC = "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75"; +const USDC_ISSUER = "GCK3D3V2XNLLKRFGFFFDEJXA4O2J4X36HET2FE446AV3M4U7DPHO3PEM"; + +// 100 USDC at 7 decimals — the raw shape `/accounts/positions` returns. +const RAW_POSITION = "1000000000"; + +const nativeBalance = { + token: { type: "native", code: "XLM" }, + total: new BigNumber("100"), + available: new BigNumber("100"), + blockaidData: {}, +}; + +const usdcBalance = { + token: { code: "USDC", issuer: { key: USDC_ISSUER } }, + total: new BigNumber("500"), + available: new BigNumber("500"), + blockaidData: {}, +}; + +const earnAmountData = { + type: AppDataType.RESOLVED, + publicKey: TEST_PUBLIC_KEY, + networkDetails: TESTNET_NETWORK_DETAILS, + balances: { balances: [nativeBalance, usdcBalance], icons: {} }, + tokenPrices: { [TEST_USDC_CANONICAL]: { currentPrice: "1" } }, +}; + +/** + * 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 + * production code cannot control. + */ +const makeDeferred = () => { + let resolve!: (value: string) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +}; + +const renderAmount = () => + render( + + + , + ); + +/** The sheet is always mounted; `open`/`closed` is what actually shows it. */ +const reviewSheet = () => + screen.getByTestId("earn-review").closest(".SlideupModal"); + +describe("EarnAmount position lookup", () => { + beforeEach(() => { + jest.spyOn(UseNetworkFees, "useNetworkFees").mockReturnValue({ + networkCongestion: "LOW", + recommendedFee: "0.00001", + } as any); + jest.spyOn(UseGetEarnAmountData, "useGetEarnAmountData").mockReturnValue({ + state: { + state: RequestState.SUCCESS, + data: earnAmountData, + error: null, + }, + fetchData: jest.fn().mockResolvedValue(earnAmountData), + } as any); + jest + .spyOn(UseSimulateEarnDeposit, "useSimulateEarnDeposit") + .mockReturnValue({ + state: { + state: RequestState.SUCCESS, + data: { transactionXdr: "AAAA", scanResult: null }, + error: null, + }, + // Resolves immediately, so the position lookup is always the slower leg. + simulate: jest.fn().mockResolvedValue({ + transactionXdr: "AAAA", + scanResult: null, + inclusionFee: "0.00001", + resourceFee: "0.001", + }), + } as any); + }); + afterEach(() => jest.restoreAllMocks()); + + it("holds the review sheet closed until the position resolves, then opens with the settled before-value", async () => { + const position = makeDeferred(); + (getBlendSuppliedTokens as jest.Mock).mockReturnValue(position.promise); + + renderAmount(); + + await act(async () => { + fireEvent.click(screen.getByTestId("earn-amount-btn-continue")); + }); + + // Simulation has already resolved here. Opening now would show "0 → 5" and + // then flip all three derived rows under the user. + expect(reviewSheet()).toHaveClass("closed"); + expect(screen.getByTestId("earn-review-position")).toHaveTextContent( + "0 → 5 USDC", + ); + + await act(async () => { + position.resolve(RAW_POSITION); + }); + + await waitFor(() => expect(reviewSheet()).toHaveClass("open")); + expect(screen.getByTestId("earn-review-position")).toHaveTextContent( + "100 → 105 USDC", + ); + }); + + it("still opens review when the position lookup fails, falling back to zero", async () => { + (getBlendSuppliedTokens as jest.Mock).mockRejectedValue( + new Error("positions 500"), + ); + + renderAmount(); + + await act(async () => { + fireEvent.click(screen.getByTestId("earn-amount-btn-continue")); + }); + + await waitFor(() => expect(reviewSheet()).toHaveClass("open")); + expect(screen.getByTestId("earn-review-position")).toHaveTextContent( + "0 → 5 USDC", + ); + // A failed before-value is not a failed deposit. + expect( + screen.queryByTestId("earn-amount-fail-banner"), + ).not.toBeInTheDocument(); + }); +}); diff --git a/extension/src/popup/components/earn/EarnAmount/helpers/__tests__/earnCtaState.test.ts b/extension/src/popup/components/earn/EarnAmount/helpers/__tests__/earnCtaState.test.ts new file mode 100644 index 0000000000..5fe2735f29 --- /dev/null +++ b/extension/src/popup/components/earn/EarnAmount/helpers/__tests__/earnCtaState.test.ts @@ -0,0 +1,190 @@ +import { + getEarnCtaState, + getXlmFeeShortfall, + isInsufficientBalanceFailure, + needsXlmForFee, +} from "../earnCtaState"; + +const inputs = (overrides = {}) => ({ + availableBalanceIsZero: false, + amountIsZero: false, + isAmountTooHigh: false, + ...overrides, +}); + +describe("getEarnCtaState", () => { + it("is enabled and offers review for a valid amount", () => { + expect(getEarnCtaState(inputs())).toEqual({ + disabled: false, + labelKey: "review", + }); + }); + + it("asks for an amount when none is entered", () => { + expect(getEarnCtaState(inputs({ amountIsZero: true }))).toEqual({ + disabled: true, + labelKey: "enter", + }); + }); + + it("reports insufficient funds when the amount exceeds the balance", () => { + expect(getEarnCtaState(inputs({ isAmountTooHigh: true }))).toEqual({ + disabled: true, + labelKey: "insufficient", + }); + }); + + it("reports insufficient funds ahead of asking for an amount", () => { + // With nothing spendable, prompting for an amount would invite an entry + // that can never be valid. + expect( + getEarnCtaState( + inputs({ availableBalanceIsZero: true, amountIsZero: true }), + ), + ).toEqual({ disabled: true, labelKey: "insufficient" }); + }); + + it("never enables the CTA while any blocker is present", () => { + const blockers = [ + { availableBalanceIsZero: true }, + { amountIsZero: true }, + { isAmountTooHigh: true }, + ]; + + blockers.forEach((blocker) => { + expect(getEarnCtaState(inputs(blocker)).disabled).toBe(true); + }); + }); +}); + +describe("needsXlmForFee", () => { + it("is true when spendable XLM is below the fee", () => { + expect(needsXlmForFee({ spendableXlm: "0.001", fee: "0.06" })).toBe(true); + }); + + it("is false when spendable XLM covers the fee", () => { + expect(needsXlmForFee({ spendableXlm: "1", fee: "0.06" })).toBe(false); + }); + + it("is false when spendable XLM exactly equals the fee", () => { + expect(needsXlmForFee({ spendableXlm: "0.06", fee: "0.06" })).toBe(false); + }); + + it("is true for an account with no spendable XLM at all", () => { + expect(needsXlmForFee({ spendableXlm: "0", fee: "0.0000100" })).toBe(true); + }); + + it("clears on the inclusion fee alone, well below a Blend resource fee", () => { + // The whole bar this gate can set before simulation. `fee` here is a real + // pubnet recommendedFee (the mode of max_fee bids, ~0.0119 XLM, not the + // 0.00001 base fee), and it is still a fifth of a Blend submit's resource + // fee — which is why getXlmFeeShortfall has to run for every asset + // afterwards rather than only for XLM deposits. + expect(needsXlmForFee({ spendableXlm: "0.02", fee: "0.0118720" })).toBe( + false, + ); + }); +}); + +describe("getXlmFeeShortfall", () => { + it("is zero when the deposit leaves more than the resource fee", () => { + expect( + getXlmFeeShortfall({ + spendableXlm: "100", + amount: "50", + resourceFee: "0.0546395", + }), + ).toBe("0"); + }); + + it("reports the shortfall when the whole spendable balance is deposited", () => { + // The full balance is depositable — nothing is held back — so the entire + // resource fee is missing. + expect( + getXlmFeeShortfall({ + spendableXlm: "100", + amount: "100", + resourceFee: "0.0546395", + }), + ).toBe("0.0546395"); + }); + + it("reports only the part of the fee that is not covered", () => { + expect( + getXlmFeeShortfall({ + spendableXlm: "100", + amount: "99.99", + resourceFee: "0.0546395", + }), + ).toBe("0.0446395"); + }); + + it("is zero when the remainder exactly covers the fee", () => { + expect( + getXlmFeeShortfall({ + spendableXlm: "100", + amount: "99.9453605", + resourceFee: "0.0546395", + }), + ).toBe("0"); + }); + + it("does not lose precision on a large balance", () => { + expect( + getXlmFeeShortfall({ + spendableXlm: "1691.6912345", + amount: "1691.6912345", + resourceFee: "0.0546395", + }), + ).toBe("0.0546395"); + }); + + it("measures a non-XLM deposit against the whole untouched balance", () => { + // amount "0" is how a non-XLM deposit is expressed: the XLM balance is not + // being spent, so the entire spendable balance has to absorb the fee. This + // spendable figure deliberately sits above a real recommendedFee, so the + // account clears needsXlmForFee and this is the only thing standing between + // it and a txINSUFFICIENT_BALANCE after signing. + expect( + getXlmFeeShortfall({ + spendableXlm: "0.02", + amount: "0", + resourceFee: "0.0546395", + }), + ).toBe("0.0346395"); + }); + + it("clears a non-XLM deposit whose XLM balance covers the fee", () => { + expect( + getXlmFeeShortfall({ + spendableXlm: "5", + amount: "0", + resourceFee: "0.0546395", + }), + ).toBe("0"); + }); +}); + +describe("isInsufficientBalanceFailure", () => { + it("matches the asset contract's BalanceError", () => { + expect( + isInsufficientBalanceFailure( + "host invocation failed: HostError: Error(Contract, #10)", + ), + ).toBe(true); + }); + + it("matches a classic insufficient-balance result code", () => { + expect(isInsufficientBalanceFailure("tx_insufficient_balance")).toBe(true); + expect(isInsufficientBalanceFailure("txINSUFFICIENT_BALANCE")).toBe(true); + }); + + it("leaves the pool's own rejections alone", () => { + // Supply cap, frozen pool, stale oracle — these must keep surfacing their + // own message rather than being retold as a fee problem. + expect( + isInsufficientBalanceFailure("HostError: Error(Contract, #1206)"), + ).toBe(false); + expect(isInsufficientBalanceFailure("pool is frozen")).toBe(false); + }); +}); diff --git a/extension/src/popup/components/earn/EarnAmount/helpers/earnCtaState.ts b/extension/src/popup/components/earn/EarnAmount/helpers/earnCtaState.ts new file mode 100644 index 0000000000..e015fdda4f --- /dev/null +++ b/extension/src/popup/components/earn/EarnAmount/helpers/earnCtaState.ts @@ -0,0 +1,108 @@ +import BigNumber from "bignumber.js"; + +/** + * Pure CTA state machine for the deposit amount screen. Precedence matters: + * each guard short-circuits, so the label reflects the most specific blocker. + */ +export type EarnCtaLabelKey = "enter" | "insufficient" | "review"; + +export interface EarnCtaInputs { + /** Spendable balance of the deposit asset, net of reserve and fee. */ + availableBalanceIsZero: boolean; + amountIsZero: boolean; + isAmountTooHigh: boolean; +} + +export const getEarnCtaState = ({ + availableBalanceIsZero, + amountIsZero, + isAmountTooHigh, +}: EarnCtaInputs): { disabled: boolean; labelKey: EarnCtaLabelKey } => { + // Nothing enterable is valid with zero spendable balance, so surface the + // blocker directly rather than inviting an amount that cannot work. + if (availableBalanceIsZero) { + return { disabled: true, labelKey: "insufficient" }; + } + if (amountIsZero) { + return { disabled: true, labelKey: "enter" }; + } + if (isAmountTooHigh) { + return { disabled: true, labelKey: "insufficient" }; + } + return { disabled: false, labelKey: "review" }; +}; + +/** + * Does the account lack the XLM to pay this transaction's fee? + * + * A Soroban invoke's fee is XLM-only and no trustline is involved, so this is + * simply "spendable XLM < fee". `spendableXlm` is expected to come from + * `getAvailableBalance`, which already nets out the base reserve. + * + * Order this AFTER the CTA's insufficient-funds check: when the deposit asset + * IS XLM, an unaffordable amount should read as insufficient funds on the + * button, and this sheet should only fire for an otherwise-affordable amount + * that leaves no fee headroom. + * + * `fee` is the inclusion fee, which is all that is known before simulation, so + * clearing this bar does not mean the account can pay the whole fee — see + * `getXlmFeeShortfall` for the post-simulation check that covers the rest. + */ +export const needsXlmForFee = ({ + spendableXlm, + fee, +}: { + spendableXlm: string; + fee: string; +}) => new BigNumber(spendableXlm).lt(new BigNumber(fee)); + +/** + * How much XLM a deposit is short of its own network fee, or "0" if it fits. + * + * A Blend `submit` is dominated by its resource fee — ~0.0546 XLM against the + * live pool, roughly 5,000x the inclusion fee — and that figure is only known + * once simulation returns. Rather than hold a guessed buffer back from the + * balance (which locks XLM the user may well want to deposit), the deposit + * screen offers the whole spendable balance and checks the *measured* fee here, + * after simulation and before the review sheet. + * + * `spendableXlm` is expected to come from `getAvailableBalance`, which already + * nets out the base reserve and the inclusion fee, so only the resource fee is + * left to cover. + * + * The fee is always paid in XLM, so this applies whichever asset is being + * deposited — only the remainder it comes out of differs. Pass `amount: "0"` + * for a non-XLM deposit: the XLM balance is untouched, so the whole spendable + * balance is what the fee has to fit inside. An account can be short either + * way, and `needsXlmForFee` cannot catch it: that gate runs before simulation, + * when only the inclusion fee is known. + */ +export const getXlmFeeShortfall = ({ + spendableXlm, + amount, + resourceFee, +}: { + spendableXlm: string; + /** + * Cleaned deposit amount — no group separators. "0" when the deposit asset is + * not XLM, since none of the XLM balance is being spent on the deposit. + */ + amount: string; + /** `minResourceFee` from simulation, in XLM. */ + resourceFee: string; +}) => { + const remaining = new BigNumber(spendableXlm).minus(new BigNumber(amount)); + const shortfall = new BigNumber(resourceFee).minus(remaining); + return BigNumber.max(shortfall, new BigNumber(0)).toFixed(); +}; + +/** + * Does a failed simulation read as "this account cannot cover the transfer"? + * + * Deliberately narrow: the Stellar Asset Contract's BalanceError (contract error + * #10) and the classic insufficient-balance result code are the only signals + * that mean the amount itself is the problem. Everything else — supply caps, a + * frozen pool, a stale oracle — must keep surfacing the pool's own message. + */ +export const isInsufficientBalanceFailure = (message: string) => + /Error\(Contract, #10\)|insufficient[ _]balance/i.test(message); diff --git a/extension/src/popup/components/earn/EarnAmount/hooks/useGetEarnAmountData.tsx b/extension/src/popup/components/earn/EarnAmount/hooks/useGetEarnAmountData.tsx new file mode 100644 index 0000000000..95f39be905 --- /dev/null +++ b/extension/src/popup/components/earn/EarnAmount/hooks/useGetEarnAmountData.tsx @@ -0,0 +1,96 @@ +import { useReducer } from "react"; + +import { NetworkDetails } from "@shared/constants/stellar"; +import { ApiTokenPrices } from "@shared/api/types"; +import { initialState, isError, reducer } from "helpers/request"; +import { isMainnet } from "helpers/stellar"; +import { + AppDataType, + NeedsReRoute, + useGetAppData, +} from "helpers/hooks/useGetAppData"; +import { AccountBalances, useGetBalances } from "helpers/hooks/useGetBalances"; +import { useGetTokenPrices } from "helpers/hooks/useGetTokenPrices"; + +export interface ResolvedEarnAmount { + type: AppDataType.RESOLVED; + publicKey: string; + networkDetails: NetworkDetails; + balances: AccountBalances; + tokenPrices: ApiTokenPrices; +} + +export type EarnAmountData = NeedsReRoute | ResolvedEarnAmount; + +/** + * Balances and prices for the deposit amount screen. + * + * Deliberately does NOT refetch the earn catalog — the chosen asset, its rate + * and the pool were captured when the token was picked and live in redux. + * Refetching would let the rate shift under a user mid-entry. + */ +export function useGetEarnAmountData() { + const [state, dispatch] = useReducer( + reducer, + initialState, + ); + const { fetchData: fetchAppData } = useGetAppData(); + const { fetchData: fetchBalances } = useGetBalances({ + showHidden: false, + includeIcons: true, + }); + const { fetchData: fetchTokenPrices } = useGetTokenPrices(); + + const fetchData = async ( + useCache = false, + ): Promise => { + dispatch({ type: "FETCH_DATA_START" }); + try { + const appData = await fetchAppData(useCache); + if (isError(appData)) { + throw new Error(appData.message); + } + + if (appData.type === AppDataType.REROUTE) { + dispatch({ type: "FETCH_DATA_SUCCESS", payload: appData }); + return appData; + } + + const publicKey = appData.account.publicKey; + const networkDetails = appData.settings.networkDetails; + + const balances = await fetchBalances( + publicKey, + isMainnet(networkDetails), + networkDetails, + useCache, + ); + if (isError(balances)) { + throw new Error(balances.message); + } + + const fetchedTokenPrices = await fetchTokenPrices({ + publicKey, + balances: balances.balances, + networkDetails, + useCache: true, + }); + + const payload = { + type: AppDataType.RESOLVED, + publicKey, + networkDetails, + balances, + tokenPrices: fetchedTokenPrices.tokenPrices, + } as EarnAmountData; + + dispatch({ type: "FETCH_DATA_SUCCESS", payload }); + return payload; + } catch (error) { + dispatch({ type: "FETCH_DATA_ERROR", payload: error }); + throw new Error(`Failed to fetch earn amount data - ${error}`); + } + }; + + return { state, fetchData }; +} diff --git a/extension/src/popup/components/earn/EarnAmount/hooks/useSimulateEarnDeposit.tsx b/extension/src/popup/components/earn/EarnAmount/hooks/useSimulateEarnDeposit.tsx new file mode 100644 index 0000000000..cbabfb818c --- /dev/null +++ b/extension/src/popup/components/earn/EarnAmount/hooks/useSimulateEarnDeposit.tsx @@ -0,0 +1,95 @@ +import { useReducer } from "react"; +import BigNumber from "bignumber.js"; +import { useDispatch } from "react-redux"; + +import { NetworkDetails } from "@shared/constants/stellar"; +import { initialState, reducer } from "helpers/request"; +import { SimulateTxData } from "types/transactions"; +import { + CLASSIC_ASSET_DECIMALS, + formatTokenAmount, +} from "popup/helpers/soroban"; +import { useScanTx } from "popup/helpers/blockaid"; +import { buildAndSimulateBlendDeposit } from "popup/helpers/blendDeposit"; +import { saveSimulation } from "popup/ducks/transactionSubmission"; + +const scanUrlstub = "internal"; + +interface SimulateEarnDepositParams { + publicKey: string; + /** The reserve's asset contract address. */ + assetId: string; + amount: string; + decimals: number; + networkDetails: NetworkDetails; + /** Inclusion fee in XLM. */ + transactionFee: string; + transactionTimeout: number; +} + +/** + * Builds, simulates and scans a Blend deposit. + * + * Returns the same `State` shape Send and Swap produce, + * so the shared FeesPane renders the inclusion/resource breakdown unchanged. + * The prepared XDR is also written to redux, which is where the submit step + * reads it from. + */ +export function useSimulateEarnDeposit() { + const [state, dispatch] = useReducer( + reducer, + initialState, + ); + const reduxDispatch = useDispatch(); + const { scanTx } = useScanTx(); + + const simulate = async (params: SimulateEarnDepositParams) => { + dispatch({ type: "FETCH_DATA_START" }); + try { + const { preparedTransaction, simulationResponse } = + await buildAndSimulateBlendDeposit(params); + + // minResourceFee comes back in stroops; the fee UI works in XLM. + const resourceFee = formatTokenAmount( + new BigNumber(simulationResponse.minResourceFee), + CLASSIC_ASSET_DECIMALS, + ); + + // Scanned on the PREPARED transaction — the thing the user actually + // signs — not the pre-assembly build. + const scanResult = await scanTx( + preparedTransaction, + // Blockaid's `url` is the originating dApp's URL. An in-wallet deposit + // has no originating dApp, so use the same stub Send and Swap pass. + scanUrlstub, + params.networkDetails, + ); + + reduxDispatch( + saveSimulation({ + preparedTransaction, + response: simulationResponse, + }), + ); + + const payload: SimulateTxData = { + transactionXdr: preparedTransaction, + scanResult, + inclusionFee: params.transactionFee, + resourceFee, + }; + + dispatch({ type: "FETCH_DATA_SUCCESS", payload }); + return payload; + } catch (error) { + // Surface the pool's own rejection (supply cap, frozen pool, stale + // oracle) — it is the only signal the user gets about why this deposit + // will not go through. + const message = error instanceof Error ? error.message : String(error); + dispatch({ type: "FETCH_DATA_ERROR", payload: message }); + throw new Error(message); + } + }; + + return { state, simulate }; +} diff --git a/extension/src/popup/components/earn/EarnAmount/index.tsx b/extension/src/popup/components/earn/EarnAmount/index.tsx new file mode 100644 index 0000000000..cd5e6dc768 --- /dev/null +++ b/extension/src/popup/components/earn/EarnAmount/index.tsx @@ -0,0 +1,490 @@ +import React, { useEffect, useRef, useState } from "react"; +import BigNumber from "bignumber.js"; +import { Button, Loader, Notification, Text } from "@stellar/design-system"; +import { useTranslation } from "react-i18next"; +import { useDispatch, useSelector } from "react-redux"; +import { Navigate } from "react-router-dom"; + +import { View } from "popup/basics/layout/View"; +import { SubviewHeader } from "popup/components/SubviewHeader"; +import { AmountCard } from "popup/components/amount/AmountCard"; +import { PercentageButtons } from "popup/components/amount/PercentageButtons"; +import { DEFAULT_AMOUNT } from "popup/components/amount/constants"; +import { + buildFiatLineText, + getAmountFontSizeClass, +} from "popup/components/amount/helpers/amountDisplay"; +import { SlideupModal } from "popup/components/SlideupModal"; +import { PoolDetailsSheet } from "popup/components/earn/PoolDetailsSheet"; +import { RequestState } from "constants/request"; +import { AppDataType } from "helpers/hooks/useGetAppData"; +import { newTabHref } from "helpers/urls"; +import { openTab } from "popup/helpers/navigate"; +import { getAssetFromCanonical, isMainnet } from "helpers/stellar"; +import { + cleanAmount, + formatAmount, + roundUsdValue, +} 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 { scrubStrKeys } from "helpers/stellarStrKey"; +import { + trackEarnPercentAmountSelected, + trackEarnSimulationFailed, + trackEarnXlmFeeInsufficientShown, +} from "popup/metrics/earn"; +import { + saveAmount, + transactionDataSelector, +} from "popup/ducks/transactionSubmission"; +import { EarnReview } from "popup/components/earn/EarnReview"; +import { + earnSelector, + saveCurrentPositionTokens, + setEarnSubmitFailed, +} from "popup/ducks/earn"; +import { getBlendSuppliedTokens } from "@shared/api/helpers/blend"; +import { formatTokenAmount } from "popup/helpers/soroban"; + +import { PoolCard } from "./PoolCard"; +import { NetworkFeeSheet } from "./NetworkFeeSheet"; +import { + getEarnCtaState, + getXlmFeeShortfall, + isInsufficientBalanceFailure, + needsXlmForFee, +} from "./helpers/earnCtaState"; +import { getPercentageAmount } from "popup/components/amount/helpers/percentageAmount"; +import { + ResolvedEarnAmount, + useGetEarnAmountData, +} from "./hooks/useGetEarnAmountData"; +import { useSimulateEarnDeposit } from "./hooks/useSimulateEarnDeposit"; + +import "./styles.scss"; + +interface EarnAmountProps { + goBack: () => void; + /** Confirmed on the review sheet — hand off to the submit step. */ + onConfirm: () => void; +} + +export const EarnAmount = ({ goBack, onConfirm }: EarnAmountProps) => { + const { t } = useTranslation(); + const dispatch = useDispatch(); + const { state, fetchData } = useGetEarnAmountData(); + const { asset, amount, destination, transactionTimeout } = useSelector( + transactionDataSelector, + ); + const { + pool, + selectedAssetApy, + selectedAssetId, + lastSubmitFailed, + currentPositionTokens, + } = useSelector(earnSelector); + const { state: simulationState, simulate } = useSimulateEarnDeposit(); + const { recommendedFee } = useNetworkFees(); + + const [isPoolSheetOpen, setIsPoolSheetOpen] = useState(false); + const [isFeeSheetOpen, setIsFeeSheetOpen] = useState(false); + const [isSimulating, setIsSimulating] = useState(false); + const [simulationError, setSimulationError] = useState(""); + const [isReviewOpen, setIsReviewOpen] = useState(false); + const hasEmittedReviewView = useRef(false); + + useEffect(() => { + fetchData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // A different asset means the previous asset's rejection no longer describes + // anything: the amount and simulation have been cleared with it. The screen + // stays mounted while the picker is up, so nothing tears this state down. + useEffect(() => { + setSimulationError(""); + }, [asset]); + + // The review sheet is the flow's `confirm` step — the last screen before a + // signature. It is a sheet rather than a step, so the Earn view's step effect + // never sees it. The ref clears on close so correcting the amount and + // reviewing again counts as a second view, but a re-render does not. + useEffect(() => { + if (!isReviewOpen) { + hasEmittedReviewView.current = false; + return; + } + if (hasEmittedReviewView.current) { + return; + } + hasEmittedReviewView.current = true; + emitScreenViewed("earn_review", { flow: "earn", step: "confirm" }); + }, [isReviewOpen]); + + if (state.data?.type === AppDataType.REROUTE) { + if (state.data.shouldOpenTab) { + openTab(newTabHref(state.data.routeTarget)); + window.close(); + } + return ; + } + + const isLoading = + state.state === RequestState.IDLE || state.state === RequestState.LOADING; + + if (isLoading || state.state === RequestState.ERROR) { + return ( + + + +
+ {isLoading ? ( + + ) : ( + + {t("We couldn’t load your balances. Please try again.")} + + )} +
+
+
+ ); + } + + const data = state.data as ResolvedEarnAmount; + const selected = asset ? getAssetFromCanonical(asset) : null; + const decimals = getAssetDecimals(asset, data.balances, true); + + const assetPrice = data.tokenPrices[asset]?.currentPrice; + const priceValueUsd = assetPrice + ? formatAmount( + roundUsdValue( + new BigNumber(assetPrice) + .multipliedBy(new BigNumber(cleanAmount(amount || "0"))) + .toString(), + ), + ) + : null; + + // Nets out the base reserve and the inclusion fee. A Blend submit's resource + // fee is far larger, but nothing is held back for it here: the whole balance + // stays depositable and handleContinue checks the measured fee once simulation + // reports it. + const availableBalance = asset + ? getAvailableBalance({ + assetCanonical: asset, + balances: data.balances.balances, + recommendedFee, + }) + : "0"; + + const isXlm = asset === "native"; + + const enteredAmount = new BigNumber(cleanAmount(amount || "0")); + const isAmountTooHigh = enteredAmount.gt(new BigNumber(availableBalance)); + const cta = getEarnCtaState({ + availableBalanceIsZero: new BigNumber(availableBalance).lte(0), + amountIsZero: enteredAmount.lte(0), + isAmountTooHigh, + }); + + const ctaLabel = { + enter: t("Enter an amount"), + insufficient: t("Insufficient funds"), + review: t("Review deposit"), + }[cta.labelKey]; + + const spendableXlm = getAvailableBalance({ + assetCanonical: "native", + balances: data.balances.balances, + recommendedFee, + }); + + const handleContinue = async () => { + // Clear a previous failure so the banner does not persist into a retry the + // user has already corrected. + dispatch(setEarnSubmitFailed(false)); + setSimulationError(""); + + // Checked after the CTA gate, so an unaffordable XLM deposit reads as + // "Insufficient funds" rather than as a missing-fee problem. + if (needsXlmForFee({ spendableXlm, fee: recommendedFee })) { + trackEarnXlmFeeInsufficientShown({ + assetCode: selected?.code || "", + reason: "no_xlm", + }); + setIsFeeSheetOpen(true); + return; + } + + setIsSimulating(true); + try { + // The existing position is the "before" half of Review's 0.00 -> N row, + // and it also feeds both earnings projections. Awaited with the simulation + // so the sheet opens with a settled before-value rather than flipping + // three rows under the user when the slower of the two lands. Still + // non-fatal: a failed lookup resolves to "0" instead of rejecting, so it + // can never block a deposit that is otherwise valid. + const positionPromise = getBlendSuppliedTokens({ + publicKey: data.publicKey, + poolId: destination, + assetId: selectedAssetId, + networkDetails: data.networkDetails, + }) + .then((raw) => formatTokenAmount(new BigNumber(raw), decimals)) + .catch(() => "0"); + + const [simulation, position] = await Promise.all([ + simulate({ + publicKey: data.publicKey, + assetId: selectedAssetId, + amount, + decimals, + networkDetails: data.networkDetails, + transactionFee: recommendedFee, + transactionTimeout, + }), + positionPromise, + ]); + + dispatch(saveCurrentPositionTokens(position)); + + // Simulation is the first place the resource fee is known, so a deposit + // that leaves nothing for it is caught here rather than by holding a + // guessed buffer back from the balance. The submission would otherwise + // fail with txINSUFFICIENT_BALANCE after the user had already signed. + // + // The fee is XLM-only, so the remainder it comes out of is whatever an XLM + // deposit leaves behind — or, for any other asset, the whole untouched + // spendable balance. The pre-simulation gate above is only inclusion-fee + // sized, which a Blend submit's resource fee dwarfs by ~5,000x, so a + // non-XLM deposit is just as capable of being short here. + const shortfall = getXlmFeeShortfall({ + spendableXlm, + amount: isXlm ? enteredAmount.toFixed() : "0", + resourceFee: simulation.resourceFee || "0", + }); + + if (new BigNumber(shortfall).gt(0)) { + trackEarnXlmFeeInsufficientShown({ + assetCode: selected?.code || "", + reason: "fee_not_covered", + }); + // Only an XLM deposit can trade amount for fee. For anything else the + // deposit is not competing with the fee at all, so the remedy is more + // XLM — the same sheet the pre-simulation gate opens. + if (isXlm) { + setSimulationError( + t( + "Not enough XLM left for the network fee. Reduce your deposit by at least {{amount}} XLM.", + { amount: formatAmount(shortfall) }, + ), + ); + } else { + setIsFeeSheetOpen(true); + } + return; + } + + setIsReviewOpen(true); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + // A simulation failure is the deposit's own pre-flight rejection — the + // same analytical unit as `payment.simulation_failed`, and never reaching + // the network means it has no result codes to key on, only this message. + trackEarnSimulationFailed({ + assetCode: selected?.code || "", + reasonCode: scrubStrKeys(message) || "unknown", + }); + // A balance rejection on an XLM deposit is the fee, not the amount: the + // CTA already gates anything above the spendable balance, so what is left + // is a deposit that cannot also pay for itself. Every other rejection is + // the pool's own and reads better in its own words. + setSimulationError( + isXlm && isInsufficientBalanceFailure(message) + ? t( + "Not enough XLM to cover the network fee. Try depositing a smaller amount.", + ) + : message, + ); + } finally { + setIsSimulating(false); + } + }; + + return ( + + + + {ctaLabel} + + } + > +
+ {(lastSubmitFailed || simulationError) && ( +
+ +
+ )} + + + dispatch(saveAmount(next === "" ? DEFAULT_AMOUNT : next)) + } + onAmountUsdChange={() => {}} + onToggleInputType={() => {}} + // The asset is fixed once chosen; changing it means going back to + // the picker, where the pool's rate for it is also re-read. + onSelectAsset={goBack} + /> + + {pool && ( + { + emitMetric(METRIC_NAMES.earnPoolDetailsOpened, { + pool_id: pool.id, + }); + setIsPoolSheetOpen(true); + }} + /> + )} + + { + trackEarnPercentAmountSelected({ + assetCode: selected?.code || "", + percent: pct, + }); + dispatch( + saveAmount( + getPercentageAmount({ + availableBalance, + pct, + decimals, + }), + ), + ); + }} + /> +
+
+ + + setIsReviewOpen(false)} + onConfirm={() => { + setIsReviewOpen(false); + onConfirm(); + }} + /> + + + + {pool ? ( + setIsPoolSheetOpen(false)} + /> + ) : ( +
+ )} + + + + setIsFeeSheetOpen(false)} + /> + + + ); +}; diff --git a/extension/src/popup/components/earn/EarnAmount/styles.scss b/extension/src/popup/components/earn/EarnAmount/styles.scss new file mode 100644 index 0000000000..3b4db6f2b3 --- /dev/null +++ b/extension/src/popup/components/earn/EarnAmount/styles.scss @@ -0,0 +1,165 @@ +@use "../../../styles/utils.scss" as *; + +.EarnAmount { + display: flex; + flex-direction: column; + gap: pxToRem(12); + + &__error { + margin-bottom: pxToRem(4); + } + + &__loader { + display: flex; + justify-content: center; + padding-top: pxToRem(48); + } +} + +.PoolCard { + display: flex; + flex-direction: column; + + // A tab above the card, per Figma 9448:29159: inset 16px each side so the + // card's own rounded corners sit outside it, 22px tall (2px padding on an + // 18px line), and flush with the card rather than tucked behind it. + // + // Colours are the design's Success/background/secondary under Colors/Green/9 + // (Figma 12607:42852) — a pale fill carrying mid-green text, which reads as a + // quiet annotation on the card rather than competing with the picker's APY + // pills. Those pills keep the saturated green-10 fill; they are a value the + // user is choosing between, this is a label on a choice already made. + &__ribbon { + align-self: center; + display: flex; + align-items: center; + justify-content: center; + background: var(--sds-clr-green-02); + color: var(--sds-clr-green-09); + border-radius: pxToRem(16) pxToRem(16) 0 0; + // Fixed at the design's 22px rather than padding a line box: SDS's xs size + // carries a 20px line-height where the design's Text/XS/500 is 18px, which + // left the tab 2px tall. + height: pxToRem(22); + padding: 0 pxToRem(12); + width: calc(100% - #{pxToRem(32)}); + text-align: center; + } + + &__body { + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + gap: pxToRem(16); + width: 100%; + padding: pxToRem(12) pxToRem(16); + border: none; + cursor: pointer; + text-align: left; + background: var(--sds-clr-gray-03); + // 16px, matching the ribbon's top corners (Figma 9448:29161). + border-radius: pxToRem(16); + } + + // 40px here, against the 32px this icon takes elsewhere in the flow + // (Figma I9448:29162;4603:1212). + .PoolIcon { + width: pxToRem(40); + height: pxToRem(40); + border-radius: pxToRem(4); + } + + // The row is space-between with three children now, so the identity claims the + // slack and keeps the icon beside it rather than at the opposite edge. + &__identity { + margin-right: auto; + } + + // Both lines are Text/SM/500: 14px on a 20px line, which keeps the two-line + // block at the design's 40px. SDS's own sm class sets a 22px line-height, so + // these are qualified by the parent to win on specificity rather than relying + // on stylesheet order. + &__identity &__name, + &__identity &__provider { + line-height: pxToRem(20); + } + + // Default/Text/Primary; this line inherited the secondary colour before, which + // is most of why the card read as muted against the design. + &__name { + color: var(--sds-clr-gray-12); + } + + // Default/Text/Secondary. + &__provider { + color: var(--sds-clr-gray-11); + } + + // 34px on Default/Background/Primary with a 14px glyph (Figma + // I9448:29162;4603:1304), where this was a 28px circle on gray-05. + &__chevron { + display: flex; + align-items: center; + justify-content: center; + flex: none; + width: pxToRem(34); + height: pxToRem(34); + border-radius: 50%; + background: var(--sds-clr-gray-01); + color: var(--sds-clr-gray-11); + + svg { + width: pxToRem(14); + height: pxToRem(14); + } + } +} + +.NetworkFeeSheet { + padding: pxToRem(24); + display: flex; + flex-direction: column; + gap: pxToRem(8); + + &__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + } + + &__badge { + width: pxToRem(32); + height: pxToRem(32); + border-radius: pxToRem(8); + background: var(--sds-clr-gray-04); + color: var(--sds-clr-gray-12); + display: flex; + align-items: center; + justify-content: center; + } + + &__close { + width: pxToRem(32); + height: pxToRem(32); + border-radius: 50%; + border: none; + cursor: pointer; + background: var(--sds-clr-gray-04); + color: var(--sds-clr-gray-11); + display: flex; + align-items: center; + justify-content: center; + } + + &__body { + color: var(--sds-clr-gray-11); + margin-bottom: pxToRem(12); + } + + &__actions { + display: flex; + flex-direction: column; + gap: pxToRem(8); + } +} diff --git a/extension/src/popup/components/earn/EarnIntro/hooks/useEarnIntroSeen.ts b/extension/src/popup/components/earn/EarnIntro/hooks/useEarnIntroSeen.ts new file mode 100644 index 0000000000..df5e2d149d --- /dev/null +++ b/extension/src/popup/components/earn/EarnIntro/hooks/useEarnIntroSeen.ts @@ -0,0 +1,55 @@ +import { useCallback, useEffect } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { captureException } from "@sentry/browser"; + +import { + getHasSeenEarnIntro, + dismissEarnIntro as dismissEarnIntroApi, +} from "@shared/api/internal"; +import { earnSelector, setEarnIntroSeen } from "popup/ducks/earn"; + +/** + * Reads and writes the persisted "has seen the Earn interstitial" flag. + * + * The flag lives in the background store rather than popup localStorage, + * following the Discover welcome precedent — the popup's storage is not a + * durable place for a once-ever decision. + * + * The resolved value goes into the `earn` duck rather than local state so the + * Earn view can distinguish "not yet known" (null) from "known false", and only + * show the interstitial once it is genuinely the latter. + */ +export const useEarnIntroSeen = () => { + const dispatch = useDispatch(); + const { hasSeenIntro } = useSelector(earnSelector); + + useEffect(() => { + if (hasSeenIntro !== null) { + return; + } + + const check = async () => { + try { + dispatch(setEarnIntroSeen(await getHasSeenEarnIntro())); + } catch (error) { + // Default to "seen" on a messaging failure: showing the interstitial to + // someone who already dismissed it is the worse of the two outcomes. + captureException(`Error checking Earn intro flag - ${error}`); + dispatch(setEarnIntroSeen(true)); + } + }; + + check(); + }, [dispatch, hasSeenIntro]); + + const dismissIntro = useCallback(async () => { + dispatch(setEarnIntroSeen(true)); + try { + await dismissEarnIntroApi(); + } catch (error) { + captureException(`Error dismissing Earn intro - ${error}`); + } + }, [dispatch]); + + return { hasSeenIntro, dismissIntro }; +}; diff --git a/extension/src/popup/components/earn/EarnIntro/index.tsx b/extension/src/popup/components/earn/EarnIntro/index.tsx new file mode 100644 index 0000000000..dad4e0e26a --- /dev/null +++ b/extension/src/popup/components/earn/EarnIntro/index.tsx @@ -0,0 +1,111 @@ +import React from "react"; +import { Button, Heading, Icon, Text } from "@stellar/design-system"; +import { useTranslation } from "react-i18next"; + +import BlendLogo from "popup/assets/blend-logo.svg"; +import EarnGlow from "popup/assets/earn-glow.svg"; + +import "./styles.scss"; + +interface EarnIntroProps { + onStart: () => void; + onClose: () => void; +} + +/** + * One-time interstitial shown the first time a user enters the Earn flow. + * + * A full view rather than a modal: it fills the popup, top to bottom, with the + * close affordance in the header and the primary action pinned to the footer. + * + * Ported from Figma `Freighter-Mobile` node 13701:332277 ("Blend intro (first + * time user)"). The mock is a 402x874 phone frame; the popup is 360x600, so + * every horizontal measure carries over unchanged (both use 24px gutters) while + * the header-to-content gap is the one measure tightened to fit the shorter + * viewport. + */ +export const EarnIntro = ({ onStart, onClose }: EarnIntroProps) => { + const { t } = useTranslation(); + + const features = [ + { + key: "yield", + title: t("Earn variable yield"), + body: t("Supply supported assets and earn based on current APY."), + }, + { + key: "control", + title: t("Stay in control"), + body: t("Manage and withdraw your supplied assets from your wallet."), + }, + ]; + + return ( +
+ {/* Sits outside __content, which scrolls and would clip it. */} + + +
+ +
+ +
+
+ + +
+ + {t("Earn with Blend")} + +
+ + {t("Supply assets to Blend and earn variable yield.")} + +
+
+
+ +
    + {features.map(({ key, title, body }) => ( +
  • + +
    + + {title} + +
    + + {body} + +
    +
    +
  • + ))} +
+
+ +
+ +
+
+ ); +}; diff --git a/extension/src/popup/components/earn/EarnIntro/styles.scss b/extension/src/popup/components/earn/EarnIntro/styles.scss new file mode 100644 index 0000000000..8a2d7d270c --- /dev/null +++ b/extension/src/popup/components/earn/EarnIntro/styles.scss @@ -0,0 +1,169 @@ +@use "../../../styles/utils.scss" as *; + +// Figma: Freighter-Mobile 13701:332277 "Blend intro (first time user)". +.EarnIntro { + position: relative; + display: flex; + flex-direction: column; + height: 100%; + padding: pxToRem(24); + // The glow bleeds past every edge, so the view clips horizontally. Vertically + // it scrolls instead of clipping: at the longest translations the column runs + // a little past 600px, and pushing the CTA below the fold degrades far better + // than truncating the last feature mid-sentence. + overflow-x: hidden; + overflow-y: auto; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } +} + +// The blurred green ellipse behind the Blend mark. In the mock its centre sits +// 40px above the logo's centre, which lands it here: 24 (top padding) + 24 +// (close icon) + 24 (header gap) + 24 (content offset) + 44 (half the logo) +// - 40. Kept as the exported asset rather than a CSS gradient so the falloff +// matches the design's 100px gaussian exactly. +.EarnIntro__glow { + position: absolute; + left: 50%; + top: pxToRem(100); + transform: translate(-50%, -50%); + width: pxToRem(664); + height: pxToRem(664); + max-width: none; + pointer-events: none; + z-index: 0; +} + +.EarnIntro__header { + position: relative; + z-index: 1; + flex: 0 0 auto; + display: flex; + padding-bottom: pxToRem(24); +} + +.EarnIntro__close { + width: pxToRem(24); + height: pxToRem(24); + padding: 0; + border: none; + cursor: pointer; + background: transparent; + color: var(--sds-clr-gray-12); + display: flex; + align-items: center; + justify-content: center; + + svg { + width: pxToRem(24); + height: pxToRem(24); + } +} + +// Grows to absorb the space between the copy and the footer button, matching +// the mock's top-anchored column with the CTA pinned to the bottom. Never +// shrinks: overflow is the view's to scroll, not this column's to clip. +.EarnIntro__content { + position: relative; + z-index: 1; + flex: 1 0 auto; + margin-top: pxToRem(24); + display: flex; + flex-direction: column; + gap: pxToRem(40); +} + +.EarnIntro__intro { + display: flex; + flex-direction: column; + align-items: center; + gap: pxToRem(32); +} + +.EarnIntro__logo { + width: pxToRem(88); + height: pxToRem(88); + display: block; +} + +// No gap between the two: the mock stacks them flush and lets the line heights +// do the spacing. +.EarnIntro__copy { + width: 100%; + text-align: center; +} + +// Figma's Text/SM/400 is 14/20; SDS's Text--sm is 14/22. The mock is the +// authority for this screen, whose vertical rhythm is built on the tighter +// leading — see also .EarnIntro__feature__body. +.EarnIntro__subtitle { + color: var(--sds-clr-gray-11); + + // Outruns SDS's own .Text--sm rule, which inheritance cannot. + .Text { + line-height: pxToRem(20); + } +} + +.EarnIntro__features { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: pxToRem(32); +} + +.EarnIntro__feature { + display: flex; + align-items: center; + gap: pxToRem(12); + + // SDS gives every `li` a "-" marker; the design has none. + &::before { + content: none !important; + } +} + +.EarnIntro__feature__icon { + flex: 0 0 auto; + width: pxToRem(40); + height: pxToRem(40); + border-radius: pxToRem(5); + background: var(--sds-clr-gray-03); + color: var(--sds-clr-gray-09); + display: flex; + align-items: center; + justify-content: center; + + svg { + width: pxToRem(18); + height: pxToRem(18); + } +} + +.EarnIntro__feature__copy { + flex: 1 1 0; + min-width: 0; + display: flex; + flex-direction: column; + gap: pxToRem(2); +} + +.EarnIntro__feature__body { + color: var(--sds-clr-gray-11); + + .Text { + line-height: pxToRem(20); + } +} + +.EarnIntro__footer { + position: relative; + z-index: 1; + flex: 0 0 auto; + padding-top: pxToRem(24); +} diff --git a/extension/src/popup/components/earn/EarnReview/__tests__/EarnReview.blockaid.test.tsx b/extension/src/popup/components/earn/EarnReview/__tests__/EarnReview.blockaid.test.tsx new file mode 100644 index 0000000000..7db4724958 --- /dev/null +++ b/extension/src/popup/components/earn/EarnReview/__tests__/EarnReview.blockaid.test.tsx @@ -0,0 +1,243 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { TransactionBuilder, Account } from "stellar-sdk"; + +import { MAINNET_NETWORK_DETAILS } from "@shared/constants/stellar"; +import { + BlendRequestType, + buildBlendRequestScVal, + buildBlendSubmitOp, +} from "@shared/helpers/soroban/blend"; +import { RequestState } from "constants/request"; +import { EarnReview } from "popup/components/earn/EarnReview"; +import { initialState as transactionSubmissionInitialState } from "popup/ducks/transactionSubmission"; +import { Wrapper } from "popup/__testHelpers__"; + +const TEST_PUBLIC_KEY = + "GBNZILSTVQZ4R7IKQDGHYGY2QXL5QOFJYQMXPKWRRM5PAV7Y4M67AQUA"; +const POOL_ID = "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD"; +const USDC_SAC = "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75"; + +// Mainnet throughout: shouldTreatTxAsUnableToScan gates on isBlockaidEnabled, +// which is mainnet-only, and it reads the network from redux rather than the +// prop — so the store and the envelope's passphrase both have to be PUBLIC for +// these paths to be reachable at all. +const { networkPassphrase } = MAINNET_NETWORK_DETAILS; + +// A real envelope: EarnReview parses the XDR for its details pane, so a +// placeholder string would leave the review half-rendered. +const buildDepositXdr = () => + new TransactionBuilder(new Account(TEST_PUBLIC_KEY, "1"), { + fee: "100", + networkPassphrase, + }) + .addOperation( + buildBlendSubmitOp({ + poolId: POOL_ID, + publicKey: TEST_PUBLIC_KEY, + requests: [ + buildBlendRequestScVal({ + assetId: USDC_SAC, + amount: "5000000", + requestType: BlendRequestType.SupplyCollateral, + networkPassphrase, + }), + ], + networkPassphrase, + }), + ) + .setTimeout(180) + .build() + .toXDR(); + +const renderReview = ({ + scanResult, + onConfirm = jest.fn(), + onCancel = jest.fn(), +}: { + scanResult?: unknown; + onConfirm?: () => void; + onCancel?: () => void; +}) => + render( + + + , + ); + +describe("EarnReview Blockaid transaction verdict", () => { + it("leaves the review untouched for a benign transaction", async () => { + const onConfirm = jest.fn(); + renderReview({ + scanResult: { validation: { result_type: "Benign" } }, + onConfirm, + }); + + expect( + screen.queryByTestId("earn-review-blockaid-warning"), + ).not.toBeInTheDocument(); + // No warning tone on either action: the clean row is the untinted one. + expect(screen.getByTestId("earn-review-confirm").parentElement).toHaveClass( + "EarnReview__action", + ); + expect( + screen.getByTestId("earn-review-confirm").parentElement?.className, + ).not.toMatch(/confirm-(malicious|caution)/); + + await userEvent.click(screen.getByTestId("earn-review-confirm")); + expect(onConfirm).toHaveBeenCalled(); + }); + + it("recolors the action row for a malicious transaction", async () => { + // The regression this closes: the scan verdict was computed and discarded, + // so a flagged deposit confirmed on a plain primary button. + const onConfirm = jest.fn(); + renderReview({ + scanResult: { validation: { result_type: "Malicious" } }, + onConfirm, + }); + + expect( + screen.getByTestId("earn-review-blockaid-warning"), + ).toHaveTextContent("This transaction was flagged as malicious"); + // The row keeps all three slots in the warned state — only the colors + // change, so Confirm stays a button rather than dropping to a text link. + expect(screen.getByTestId("earn-review-fees-btn")).toBeInTheDocument(); + expect(screen.getByTestId("earn-review-cancel")).toHaveClass( + "Button--destructive", + ); + expect(screen.getByTestId("earn-review-confirm").parentElement).toHaveClass( + "EarnReview__action--confirm-malicious", + ); + + await userEvent.click(screen.getByTestId("earn-review-confirm")); + expect(onConfirm).toHaveBeenCalled(); + }); + + it("places the banner between the deposit card and the position rows", () => { + // Placement is the point of the layout, not decoration: the warning has to + // sit against the deposit it describes rather than at the top of the view, + // matching the swap review. DOM order is what carries that. + renderReview({ scanResult: { validation: { result_type: "Malicious" } } }); + + const amount = screen.getByTestId("earn-review-amount"); + const banner = screen.getByTestId("earn-review-blockaid-warning"); + const position = screen.getByTestId("earn-review-position"); + + /* eslint-disable no-bitwise */ + expect( + amount.compareDocumentPosition(banner) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect( + banner.compareDocumentPosition(position) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + /* eslint-enable no-bitwise */ + }); + + it("recolors the action row in amber for a suspicious transaction", () => { + renderReview({ scanResult: { validation: { result_type: "Warning" } } }); + + expect( + screen.getByTestId("earn-review-blockaid-warning"), + ).toHaveTextContent("This transaction was flagged as suspicious"); + // Amber rather than red, tracking BlockaidBanner's own severity split. + expect(screen.getByTestId("earn-review-cancel").parentElement).toHaveClass( + "EarnReview__action--cancel-caution", + ); + expect(screen.getByTestId("earn-review-confirm").parentElement).toHaveClass( + "EarnReview__action--confirm-caution", + ); + }); + + it("shows the banner alone when the scan could not complete", async () => { + // Unable-to-scan is weaker than a verdict: the banner tells the user the + // scan came back empty, but with nothing actually flagged the action row + // stays neutral. Deliberately unlike ReviewTx, which tints on this state. + const onConfirm = jest.fn(); + renderReview({ scanResult: null, onConfirm }); + + expect( + screen.getByTestId("earn-review-blockaid-warning"), + ).toHaveTextContent("Proceed with caution"); + expect(screen.getByTestId("earn-review-cancel")).toHaveClass( + "Button--tertiary", + ); + expect(screen.getByTestId("earn-review-confirm")).toHaveClass( + "Button--secondary", + ); + expect( + screen.getByTestId("earn-review-confirm").parentElement?.className, + ).not.toMatch(/confirm-(malicious|caution)/); + expect( + screen.getByTestId("earn-review-cancel").parentElement?.className, + ).not.toMatch(/cancel-(malicious|caution)/); + + await userEvent.click(screen.getByTestId("earn-review-confirm")); + expect(onConfirm).toHaveBeenCalled(); + }); + + it("opens the reasons sheet from the banner and returns to the review", async () => { + renderReview({ + scanResult: { + validation: { + result_type: "Malicious", + features: [ + { + type: "Malicious", + feature_id: "known_malicious", + description: + "An identified malicious address is associated with the token.", + }, + ], + }, + }, + }); + + await userEvent.click(screen.getByTestId("earn-review-blockaid-warning")); + + const pane = await screen.findByTestId("earn-review-blockaid-pane"); + expect(pane).toHaveTextContent("Do not proceed"); + expect(pane).toHaveTextContent( + "An identified malicious address is associated with the token.", + ); + // The action row is reachable from the sheet too, not just the body. + expect(screen.getByTestId("earn-review-confirm")).toBeInTheDocument(); + + await userEvent.click(screen.getByTestId("blockaid-details-close")); + expect(await screen.findByTestId("earn-review")).toBeInTheDocument(); + }); +}); diff --git a/extension/src/popup/components/earn/EarnReview/__tests__/EarnReview.hardwareWallet.test.tsx b/extension/src/popup/components/earn/EarnReview/__tests__/EarnReview.hardwareWallet.test.tsx new file mode 100644 index 0000000000..e89810e8e3 --- /dev/null +++ b/extension/src/popup/components/earn/EarnReview/__tests__/EarnReview.hardwareWallet.test.tsx @@ -0,0 +1,266 @@ +import React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Keypair, TransactionBuilder, Account } from "stellar-sdk"; + +import { WalletType } from "@shared/constants/hardwareWallet"; +import { TESTNET_NETWORK_DETAILS } from "@shared/constants/stellar"; +import { + BlendRequestType, + buildBlendRequestScVal, + buildBlendSubmitOp, +} from "@shared/helpers/soroban/blend"; +import { RequestState } from "constants/request"; +import { EarnReview } from "popup/components/earn/EarnReview"; +import { + initialState as transactionSubmissionInitialState, + ShowOverlayStatus, +} from "popup/ducks/transactionSubmission"; +import { getTestStore, Wrapper } from "popup/__testHelpers__"; + +// A real keypair and a real envelope: signWithHardwareWallet rebuilds the +// transaction from the XDR and appends a DecoratedSignature, so a stand-in +// string would never round-trip. +const deviceKeypair = Keypair.fromRawEd25519Seed(Buffer.alloc(32, 7)); +const TEST_PUBLIC_KEY = deviceKeypair.publicKey(); + +const POOL_ID = "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD"; +const USDC_SAC = "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75"; + +const { networkPassphrase } = TESTNET_NETWORK_DETAILS; + +// The deposit the user is reviewing: pool.submit with one SupplyCollateral +// request, built through the same helpers the flow uses. +const buildDepositXdr = () => + new TransactionBuilder(new Account(TEST_PUBLIC_KEY, "1"), { + fee: "100", + networkPassphrase, + }) + .addOperation( + buildBlendSubmitOp({ + poolId: POOL_ID, + publicKey: TEST_PUBLIC_KEY, + requests: [ + buildBlendRequestScVal({ + assetId: USDC_SAC, + amount: "5000000", + requestType: BlendRequestType.SupplyCollateral, + networkPassphrase, + }), + ], + networkPassphrase, + }), + ) + .setTimeout(180) + .build() + .toXDR(); + +const mockGetWalletPublicKey = jest.fn(); +const mockHardwareSign = jest.fn(); + +jest.mock("popup/helpers/hardwareConnect", () => { + const actual = jest.requireActual("popup/helpers/hardwareConnect"); + return { + ...actual, + getWalletPublicKey: { + Ledger: (...args: unknown[]) => mockGetWalletPublicKey(...args), + }, + hardwareSign: { + Ledger: (...args: unknown[]) => mockHardwareSign(...args), + }, + }; +}); + +const renderReview = ({ + preparedXdr, + hardwareWalletType, + hwStatus = ShowOverlayStatus.IDLE, + onConfirm, + onCancel = jest.fn(), +}: { + preparedXdr: string; + hardwareWalletType: WalletType; + hwStatus?: ShowOverlayStatus; + onConfirm: () => void; + onCancel?: () => void; +}) => + render( + + + , + ); + +describe("EarnReview hardware wallet signing", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetWalletPublicKey.mockResolvedValue(TEST_PUBLIC_KEY); + mockHardwareSign.mockImplementation(({ tx }) => + Promise.resolve(deviceKeypair.sign(tx.signatureBase())), + ); + }); + + it("asks the device to sign rather than stepping to the terminal", async () => { + // The regression: Confirm went straight to the deposit terminal, which + // submits on mount — posting an unsigned envelope for tx_bad_auth, with the + // device never prompted. Confirm must hand the XDR to startHwSign instead. + const preparedXdr = buildDepositXdr(); + + renderReview({ + preparedXdr, + hardwareWalletType: WalletType.LEDGER, + onConfirm: jest.fn(), + }); + + await userEvent.click(screen.getByTestId("earn-review-confirm")); + + expect(await screen.findByTestId("HardwareSign__internal")).toBeDefined(); + expect( + getTestStore()!.getState().transactionSubmission.hardwareWalletData, + ).toEqual({ + status: ShowOverlayStatus.IN_PROGRESS, + transactionXDR: preparedXdr, + shouldSubmit: true, + }); + }); + + it("does not advance the flow when the device refuses", async () => { + // The other half of the regression: with no signature there is nothing + // submittable, so the flow must stay on the review rather than walk into a + // terminal that would post the unsigned envelope. + const preparedXdr = buildDepositXdr(); + const onConfirm = jest.fn(); + mockHardwareSign.mockRejectedValue( + new Error("Transaction approval denied"), + ); + + renderReview({ + preparedXdr, + hardwareWalletType: WalletType.LEDGER, + hwStatus: ShowOverlayStatus.IN_PROGRESS, + onConfirm, + }); + + await waitFor(() => + expect(screen.getByTestId("HardwareSign__connect-text")).toBeDefined(), + ); + + expect(onConfirm).not.toHaveBeenCalled(); + expect( + getTestStore()!.getState().transactionSubmission.transactionSimulation + .preparedTransaction, + ).toEqual(preparedXdr); + }); + + it("shows the device overlay in place of the review body", async () => { + renderReview({ + preparedXdr: buildDepositXdr(), + hardwareWalletType: WalletType.LEDGER, + hwStatus: ShowOverlayStatus.IN_PROGRESS, + onConfirm: jest.fn(), + }); + + expect(await screen.findByTestId("HardwareSign__internal")).toBeDefined(); + expect(screen.queryByTestId("earn-review")).toBeNull(); + }); + + it("writes the signed envelope to redux, then advances the flow", async () => { + // This is what makes the deposit terminal's "already signed" assumption + // true: HardwareSign replaces preparedTransaction with the signed envelope + // before onSubmit steps the flow forward. + const preparedXdr = buildDepositXdr(); + const onConfirm = jest.fn(); + + renderReview({ + preparedXdr, + hardwareWalletType: WalletType.LEDGER, + hwStatus: ShowOverlayStatus.IN_PROGRESS, + onConfirm, + }); + + await waitFor(() => expect(onConfirm).toHaveBeenCalledTimes(1)); + + const { preparedTransaction } = + getTestStore()!.getState().transactionSubmission.transactionSimulation; + expect(preparedTransaction).not.toEqual(preparedXdr); + + const signed = TransactionBuilder.fromXDR( + preparedTransaction!, + networkPassphrase, + ); + expect(signed.signatures).toHaveLength(1); + expect( + deviceKeypair.verify( + signed.signatureBase(), + signed.signatures[0].signature(), + ), + ).toBe(true); + }); + + it("confirms directly for a software wallet", async () => { + const onConfirm = jest.fn(); + + renderReview({ + preparedXdr: buildDepositXdr(), + hardwareWalletType: WalletType.NONE, + onConfirm, + }); + + await userEvent.click(screen.getByTestId("earn-review-confirm")); + + expect(onConfirm).toHaveBeenCalledTimes(1); + expect( + getTestStore()!.getState().transactionSubmission.hardwareWalletData + .status, + ).toEqual(ShowOverlayStatus.IDLE); + }); +}); diff --git a/extension/src/popup/components/earn/EarnReview/helpers/__tests__/projectEarnings.test.ts b/extension/src/popup/components/earn/EarnReview/helpers/__tests__/projectEarnings.test.ts new file mode 100644 index 0000000000..149d6e94c5 --- /dev/null +++ b/extension/src/popup/components/earn/EarnReview/helpers/__tests__/projectEarnings.test.ts @@ -0,0 +1,77 @@ +import { formatProjection, projectEarnings } from "../projectEarnings"; + +describe("projectEarnings", () => { + it("projects yearly and monthly from the deposit's USD value", () => { + // The design's worked example: $500 at 16.94% -> $84.70/yr, $7.06/mo. + expect(projectEarnings({ depositUsd: "500", apy: 0.1694 })).toEqual({ + yearly: "84.70", + monthly: "7.06", + }); + }); + + it("treats monthly as a twelfth of the annual figure", () => { + // Simple interest, not a compounded monthly rate: the APY moves with pool + // utilization, so compounding would imply precision the estimate lacks. + const { yearly, monthly } = projectEarnings({ + depositUsd: "1200", + apy: 0.12, + }); + + expect(yearly).toBe("144.00"); + expect(monthly).toBe("12.00"); + }); + + it("returns zero for a zero rate", () => { + // A real zero rate earns nothing — distinct from an unknown rate. + expect(projectEarnings({ depositUsd: "500", apy: 0 })).toEqual({ + yearly: "0.00", + monthly: "0.00", + }); + }); + + it("returns nulls when the rate is unavailable", () => { + expect(projectEarnings({ depositUsd: "500", apy: null })).toEqual({ + yearly: null, + monthly: null, + }); + }); + + it("returns nulls when the asset has no price", () => { + expect(projectEarnings({ depositUsd: null, apy: 0.1694 })).toEqual({ + yearly: null, + monthly: null, + }); + }); + + it("handles a zero deposit", () => { + expect(projectEarnings({ depositUsd: "0", apy: 0.1694 })).toEqual({ + yearly: "0.00", + monthly: "0.00", + }); + }); + + it("does not lose precision on a large deposit", () => { + expect(projectEarnings({ depositUsd: "1000000", apy: 0.0424 })).toEqual({ + yearly: "42400.00", + monthly: "3533.33", + }); + }); +}); + +describe("formatProjection", () => { + it("formats a known value as USD", () => { + expect(formatProjection("84.70")).toBe("$84.70"); + }); + + it("groups thousands", () => { + expect(formatProjection("42400.00")).toBe("$42,400.00"); + }); + + it("renders an unknown value as --", () => { + expect(formatProjection(null)).toBe("--"); + }); + + it("renders a real zero as $0.00", () => { + expect(formatProjection("0.00")).toBe("$0.00"); + }); +}); diff --git a/extension/src/popup/components/earn/EarnReview/helpers/projectEarnings.ts b/extension/src/popup/components/earn/EarnReview/helpers/projectEarnings.ts new file mode 100644 index 0000000000..e6e079bc7c --- /dev/null +++ b/extension/src/popup/components/earn/EarnReview/helpers/projectEarnings.ts @@ -0,0 +1,40 @@ +import BigNumber from "bignumber.js"; + +import { NO_FIAT_VALUE } from "popup/helpers/formatters"; + +/** + * Projected earnings on a deposit, in USD. + * + * Deliberately simple interest on the deposit's current USD value: the rate is + * a live figure that moves with pool utilization, so compounding it would imply + * a precision the estimate does not have. The screen labels these "(est.)" and + * the APY disclaimer carries the caveat. + * + * Returns null when either input is unavailable — a missing rate or an unpriced + * asset means "unknown", not "zero". Callers render null as "--". + */ +export const projectEarnings = ({ + depositUsd, + apy, +}: { + /** USD value of the deposit; null when the asset has no fresh price. */ + depositUsd: string | null; + /** Rate as a decimal fraction (0.1694 = 16.94%); null when unavailable. */ + apy: number | null; +}): { monthly: string | null; yearly: string | null } => { + if (depositUsd === null || apy === null) { + return { monthly: null, yearly: null }; + } + + const yearly = new BigNumber(depositUsd).multipliedBy(apy); + + return { + yearly: yearly.toFixed(2), + // A twelfth of the annual figure, not a compounded monthly rate — see above. + monthly: yearly.dividedBy(12).toFixed(2), + }; +}; + +/** Formats a projected figure for display, rendering an unknown value as "--". */ +export const formatProjection = (value: string | null) => + value === null ? NO_FIAT_VALUE : `$${new BigNumber(value).toFormat(2)}`; diff --git a/extension/src/popup/components/earn/EarnReview/index.tsx b/extension/src/popup/components/earn/EarnReview/index.tsx new file mode 100644 index 0000000000..97f787339d --- /dev/null +++ b/extension/src/popup/components/earn/EarnReview/index.tsx @@ -0,0 +1,497 @@ +import React, { useState } from "react"; +import BigNumber from "bignumber.js"; +import { Button, Icon, Text } from "@stellar/design-system"; +import { useTranslation } from "react-i18next"; +import { useDispatch, useSelector } from "react-redux"; +import { + Operation, + OperationRecord, + Transaction, + TransactionBuilder, +} from "stellar-sdk"; + +import { NetworkDetails } from "@shared/constants/stellar"; +import { BlendCatalogPool } from "@shared/api/types/blend"; +import { OPERATION_TYPES } from "constants/transaction"; +import { State } from "constants/request"; +import { SimulateTxData } from "types/transactions"; +import { SecurityLevel } from "popup/constants/blockaid"; +import { AuthEntries } from "popup/components/AuthEntry"; +import { BlockaidBanner } from "popup/components/BlockaidBanner"; +import { BlockAidScanExpanded } from "popup/components/WarningMessages"; +import { FeesPane } from "popup/components/InternalTransaction/FeesPane"; +import { HardwareSign } from "popup/components/hardwareConnect/HardwareSign"; +import { Summary } from "popup/views/SignTransaction/Preview/Summary"; +import { Details } from "popup/views/SignTransaction/Preview/Details"; +import { PoolIcon } from "popup/components/earn/PoolIcon"; +import { AssetIcon } from "popup/components/account/AccountAssets"; +import { + getTransactionSecurityLevel, + useBlockaidOverrideState, + useShouldTreatTxAsUnableToScan, +} from "popup/helpers/blockaid"; +import { getAuthEntryBoundAddress } from "popup/helpers/soroban"; +import { NO_FIAT_VALUE, formatAmount } from "popup/helpers/formatters"; +import { formatRate } from "popup/components/earn/helpers/formatPoolStats"; +import { StatRow } from "popup/components/earn/StatRow"; +import { hardwareWalletTypeSelector } from "popup/ducks/accountServices"; +import { + ShowOverlayStatus, + startHwSign, + transactionSubmissionSelector, +} from "popup/ducks/transactionSubmission"; + +import { formatProjection, projectEarnings } from "./helpers/projectEarnings"; + +import "./styles.scss"; + +interface EarnReviewProps { + pool: BlendCatalogPool | null; + assetCode: string; + assetIssuer?: string; + assetIcon?: string | null; + /** Human-readable deposit amount. */ + amount: string; + /** USD value of the deposit; null when the asset has no fresh price. */ + amountUsd: string | null; + apy: number | null; + /** Existing position in display units, "0" when there is none. */ + currentPosition: string; + /** USD value of the existing position; null when the asset has no price. */ + currentPositionUsd: string | null; + fee: string; + simulationState: State; + networkDetails: NetworkDetails; + onCancel: () => void; + onConfirm: () => void; +} + +/** + * The gate before signing: what is being deposited, where, what it becomes, and + * what it is projected to earn. + * + * Builds its own transaction-details and fees panes from the shared + * Summary/Details/AuthEntries/FeesPane pieces rather than adding an Earn mode + * to ReviewTx, which hardcodes its CTA copy and a fixed two-row summary. + */ +export const EarnReview = ({ + pool, + assetCode, + assetIssuer, + assetIcon, + amount, + amountUsd, + apy, + currentPosition, + currentPositionUsd, + fee, + simulationState, + networkDetails, + onCancel, + onConfirm, +}: EarnReviewProps) => { + const { t } = useTranslation(); + const dispatch = useDispatch(); + const [isOnDetailsPane, setIsOnDetailsPane] = useState(false); + const [isOnFeesPane, setIsOnFeesPane] = useState(false); + const [isOnBlockaidSheet, setIsOnBlockaidSheet] = useState(false); + + const hardwareWalletType = useSelector(hardwareWalletTypeSelector); + const isHardwareWallet = !!hardwareWalletType; + const { + hardwareWalletData: { status: hwStatus }, + } = useSelector(transactionSubmissionSelector); + + const preparedXdr = simulationState.data?.transactionXdr; + + /* + * The Blockaid verdict on the deposit the user is about to sign. Only the + * transaction scan applies here — Earn's reserves come from the backend's + * allowlist and there is no counterparty token to scan — so this reads + * getTransactionSecurityLevel directly instead of merging several verdicts + * the way the swap review does. + * + * shouldTreatTxAsUnableToScan carries the network gate, so off-mainnet (where + * the scan is a no-op and comes back null) never warns. + */ + const txScanResult = simulationState.data?.scanResult; + const shouldTreatTxAsUnableToScan = useShouldTreatTxAsUnableToScan(); + const blockaidOverrideState = useBlockaidOverrideState(); + const securityLevel = getTransactionSecurityLevel( + txScanResult, + shouldTreatTxAsUnableToScan(txScanResult), + blockaidOverrideState, + ); + const isMalicious = securityLevel === SecurityLevel.MALICIOUS; + const isSuspicious = securityLevel === SecurityLevel.SUSPICIOUS; + /* + * Two independent gates, because unable-to-scan is weaker than a verdict. + * + * The banner shows for all three states — the user should know the scan came + * back empty. The action row only recolors for an actual finding, so a scan + * Blockaid simply couldn't complete never tints the buttons. This is a + * deliberate split from ReviewTx, which folds unable-to-scan into one flag + * and lets it demote Confirm. + */ + const shouldShowTxWarning = + isMalicious || + isSuspicious || + securityLevel === SecurityLevel.UNABLE_TO_SCAN; + + /* + * Same shape as ReviewTx's onConfirmTx: a hardware wallet signs here, on the + * review, and only advances the flow once the device has answered. Confirm + * must not step to the deposit terminal first — that screen submits on mount, + * and with nothing signed it would post an unsigned envelope. + * + * Branching on the wallet alone, not on having an XDR: with no envelope to + * sign the overlay stalls on "Connect device", which is a visible dead end, + * where falling through would post an empty envelope instead. + */ + const onConfirmTx = () => { + if (isHardwareWallet) { + dispatch( + startHwSign({ + transactionXDR: preparedXdr || "", + shouldSubmit: true, + }), + ); + return; + } + onConfirm(); + }; + + const detailTx = React.useMemo(() => { + if (!preparedXdr) { + return null; + } + try { + const parsed = TransactionBuilder.fromXDR( + preparedXdr, + networkDetails.networkPassphrase, + ); + // The Earn flow never builds fee-bump envelopes, but guard so the cast + // below can't dereference a missing operations array. + return "operations" in parsed ? (parsed as Transaction) : null; + } catch (e) { + return null; + } + }, [preparedXdr, networkDetails.networkPassphrase]); + + const authEntries = + detailTx && + (detailTx.operations[0] as Operation.InvokeHostFunction).auth?.length + ? (detailTx.operations[0] as Operation.InvokeHostFunction).auth!.map( + (authEntry) => ({ + invocation: authEntry.rootInvocation(), + boundAddress: getAuthEntryBoundAddress(authEntry), + }), + ) + : []; + + const positionAfter = formatAmount( + new BigNumber(currentPosition).plus(amount || "0").toFixed(), + ); + + // Projections are shown as before -> after, so both sides are computed: + // "before" from the existing position, "after" from position plus deposit. + const currentEarnings = projectEarnings({ + depositUsd: currentPositionUsd, + apy, + }); + const { monthly, yearly } = projectEarnings({ + depositUsd: + currentPositionUsd !== null && amountUsd !== null + ? new BigNumber(currentPositionUsd).plus(amountUsd).toFixed() + : null, + apy, + }); + + if (isOnFeesPane) { + return ( +
+ setIsOnFeesPane(false)} + /> +
+ ); + } + + if (isOnDetailsPane && detailTx) { + return ( +
+
+ + {t("Transaction details")} + + +
+
+ + OPERATION_TYPES[op.type as keyof typeof OPERATION_TYPES] || + op.type, + )} + /> + {authEntries.length > 0 && } +
+
+
+ ); + } + + /* + * Replaces the review body while the device is signing, the way ReviewTx does + * inside Send's review modal. HardwareSign writes the signed envelope back + * over transactionSimulation.preparedTransaction and then calls onSubmit, so + * the deposit terminal it advances to reads the signed XDR from redux. + */ + if (hwStatus === ShowOverlayStatus.IN_PROGRESS && hardwareWalletType) { + return ( + + ); + } + + /* + * Shared by the review body and the Blockaid sheet, so acknowledging a + * warning is possible from either. The row keeps the same three slots in + * every state — fee settings, Cancel, Confirm — and a Blockaid verdict only + * recolors it. Confirm stays a real button rather than dropping to a text + * link, so the flagged layout matches the clean one. + * + * `warningTone` tracks BlockaidBanner's severity colors (red for malicious, + * amber for suspicious) so a tinted row always matches the banner above it. + * It is null for unable-to-scan, which shows the banner alone. + * + * Built here rather than reusing ReviewTx's ActionButtons, which hardcodes + * the Send/Swap CTA copy and takes memo props this flow has none of. + */ + const warningTone = isMalicious + ? "malicious" + : isSuspicious + ? "caution" + : null; + + const actions = ( +
+ + {/* Cancel is the recommended action once a warning is up, so it takes on + the filled weight in the severity color. The tone lives on the + wrapper, not on Button: SDS spreads incoming props after its own + className, so passing one through would wipe the base Button classes. + Its colors are custom properties, so overriding them on an ancestor + carries hover, focus and disabled along for free. */} +
+ +
+ {/* Confirm stays reachable but is demoted to an outline in the severity + color, which is what the old "Confirm anyway" text link conveyed. */} +
+ +
+
+ ); + + /* + * The "Do not proceed" detail sheet, listing Blockaid's reasons. Rendered + * after the hardware check above so that confirming from this sheet with a + * device connected swaps in HardwareSign instead of leaving the sheet up. + */ + if (isOnBlockaidSheet) { + return ( +
+ setIsOnBlockaidSheet(false)} + /> + {actions} +
+ ); + } + + return ( +
+
+ + {t("You are depositing")} + +
+ +
+
+ {formatAmount(amount)} {assetCode} +
+ + {amountUsd === null + ? NO_FIAT_VALUE + : `$${formatAmount(amountUsd)}`} + +
+
+ +
+ +
+ +
+ +
+ + {t("To")} + +
+ {pool?.name || t("Blend pool")} +
+
+
+
+ + {/* Sits between the deposit card and the position rows rather than at the + top of the view, so the warning reads against the deposit it is about + — the same placement the swap review uses. */} + {securityLevel && shouldShowTxWarning ? ( + setIsOnBlockaidSheet(true)} + dataTestId="earn-review-blockaid-warning" + /> + ) : null} + +
+ + + {formatAmount(currentPosition)} + + {" → "} + {positionAfter} {assetCode} + + } + /> + + + + {formatProjection(currentEarnings.monthly)} + + {" → "} + {formatProjection(monthly)} + + } + /> + + + {formatProjection(currentEarnings.yearly)} + + {" → "} + {formatProjection(yearly)} + + } + /> +
+ + {detailTx && ( + + )} + + {actions} +
+ ); +}; diff --git a/extension/src/popup/components/earn/EarnReview/styles.scss b/extension/src/popup/components/earn/EarnReview/styles.scss new file mode 100644 index 0000000000..f455445a4a --- /dev/null +++ b/extension/src/popup/components/earn/EarnReview/styles.scss @@ -0,0 +1,194 @@ +@use "../../../styles/utils.scss" as *; + +.EarnReview { + padding: pxToRem(20); + display: flex; + flex-direction: column; + gap: pxToRem(12); + + &__group { + background: var(--sds-clr-gray-03); + border-radius: pxToRem(12); + padding: pxToRem(16); + color: var(--sds-clr-gray-11); + + &--rows { + padding: 0 pxToRem(16); + } + } + + &__asset { + display: flex; + align-items: center; + gap: pxToRem(12); + padding-top: pxToRem(8); + + // AssetIcon carries a 1rem right margin for the account's list rows. Here + // the row's own gap does the spacing, and leaving the margin in place pushes + // the amount 16px further right than the pool row below it. + .AccountAssets__asset--logo { + margin-right: 0; + } + } + + &__amount { + color: var(--sds-clr-gray-12); + font-size: pxToRem(16); + font-weight: 500; + } + + &__chevrons { + color: var(--sds-clr-gray-09); + padding: pxToRem(6) 0 0 pxToRem(10); + } + + // The "before" half of a before -> after pair is de-emphasised so the + // resulting value reads as the answer. + &__before { + color: var(--sds-clr-gray-09); + font-weight: 400; + } + + &__details-btn { + display: flex; + align-items: center; + gap: pxToRem(8); + background: none; + border: none; + cursor: pointer; + padding: pxToRem(12) pxToRem(4); + color: var(--sds-clr-lilac-11); + } + + // The details pane carries the whole invocation — auth entries plus every + // contract parameter — so it routinely outgrows the popup. SlideupModal sizes + // itself to its content's scrollHeight, so without a cap here the sheet runs + // off the viewport with nothing to scroll. Capped like `.ReviewTx`, with the + // header pinned and only the body scrolling. + &--details { + max-height: 85vh; + } + + &__details-header { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: pxToRem(8); + color: var(--sds-clr-gray-12); + flex: none; + } + + &__details-body { + flex: 1; + // Without this a flex item refuses to shrink below its content height, and + // the overflow never engages. + min-height: 0; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: pxToRem(12); + } + + &__close { + width: pxToRem(32); + height: pxToRem(32); + border-radius: 50%; + border: none; + cursor: pointer; + background: var(--sds-clr-gray-04); + color: var(--sds-clr-gray-11); + display: flex; + align-items: center; + justify-content: center; + } + + &__actions { + display: flex; + align-items: center; + gap: pxToRem(8); + } + + // Wrapper around each action Button. SDS Button spreads incoming props after + // its own `className`, so a className passed to it replaces the base + // `Button Button--*` classes outright — hence the tone class lives here + // instead. + // + // The tones work by overriding the `--Button-color-*` custom properties the + // SDS variants are themselves built from, which keeps hover, focus and + // disabled states consistent without restating them. They must be declared + // on the `.Button` element rather than inherited from this wrapper: SDS sets + // those properties in `.Button--secondary` etc. on the button itself, and an + // element's own declaration beats a value inherited from an ancestor no + // matter how specific the ancestor's selector is. + &__action { + flex: 1 1 0; + min-width: 0; + } + + // Filled Cancel for a suspicious / unable-to-scan verdict. Malicious uses the + // SDS `destructive` variant directly and needs no override; SDS has no amber + // variant, so this mirrors `destructive`'s token set in amber. + &__action--cancel-caution .Button { + --Button-color-text-default: var(--sds-clr-white); + --Button-color-icon-default: var(--sds-clr-white); + --Button-color-background-default: var(--sds-clr-amber-09); + --Button-color-border-default: var(--sds-clr-amber-09); + --Button-color-text-hover: var(--sds-clr-white); + --Button-color-icon-hover: var(--sds-clr-white); + --Button-color-background-hover: var(--sds-clr-amber-10); + --Button-color-border-hover: var(--sds-clr-amber-10); + --Button-color-text-active: var(--sds-clr-white); + --Button-color-icon-active: var(--sds-clr-white); + --Button-color-background-active: var(--sds-clr-amber-10); + --Button-color-border-active: var(--sds-clr-amber-10); + --Button-box-shadow-color: var(--sds-clr-amber-06); + } + + // Outlined Confirm, in the severity color. Transparent fill against a + // severity border and label, so Cancel stays the visually recommended action. + &__action--confirm-malicious .Button { + --Button-color-text-default: var(--sds-clr-red-11); + --Button-color-icon-default: var(--sds-clr-red-11); + --Button-color-background-default: transparent; + --Button-color-border-default: var(--sds-clr-red-07); + --Button-color-text-hover: var(--sds-clr-red-11); + --Button-color-icon-hover: var(--sds-clr-red-11); + --Button-color-background-hover: var(--sds-clr-red-03); + --Button-color-border-hover: var(--sds-clr-red-08); + --Button-color-text-active: var(--sds-clr-red-11); + --Button-color-icon-active: var(--sds-clr-red-11); + --Button-color-background-active: var(--sds-clr-red-03); + --Button-color-border-active: var(--sds-clr-red-08); + --Button-box-shadow-color: var(--sds-clr-red-06); + } + + &__action--confirm-caution .Button { + --Button-color-text-default: var(--sds-clr-amber-11); + --Button-color-icon-default: var(--sds-clr-amber-11); + --Button-color-background-default: transparent; + --Button-color-border-default: var(--sds-clr-amber-07); + --Button-color-text-hover: var(--sds-clr-amber-11); + --Button-color-icon-hover: var(--sds-clr-amber-11); + --Button-color-background-hover: var(--sds-clr-amber-03); + --Button-color-border-hover: var(--sds-clr-amber-08); + --Button-color-text-active: var(--sds-clr-amber-11); + --Button-color-icon-active: var(--sds-clr-amber-11); + --Button-color-background-active: var(--sds-clr-amber-03); + --Button-color-border-active: var(--sds-clr-amber-08); + --Button-box-shadow-color: var(--sds-clr-amber-06); + } + + &__settings { + flex: 0 0 auto; + width: pxToRem(40); + height: pxToRem(40); + border-radius: 50%; + border: 1px solid var(--sds-clr-gray-06); + cursor: pointer; + background: none; + color: var(--sds-clr-gray-11); + display: flex; + align-items: center; + justify-content: center; + } +} diff --git a/extension/src/popup/components/earn/EarnSubmit/__tests__/EarnSubmit.test.tsx b/extension/src/popup/components/earn/EarnSubmit/__tests__/EarnSubmit.test.tsx new file mode 100644 index 0000000000..e7f2899429 --- /dev/null +++ b/extension/src/popup/components/earn/EarnSubmit/__tests__/EarnSubmit.test.tsx @@ -0,0 +1,346 @@ +import React from "react"; +import { + act, + render, + renderHook, + screen, + waitFor, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { WalletType } from "@shared/constants/hardwareWallet"; +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 { initialState as earnInitialState } from "popup/ducks/earn"; +import { + initialState as transactionSubmissionInitialState, + ShowOverlayStatus, +} from "popup/ducks/transactionSubmission"; +import { METRIC_NAMES } from "popup/constants/metricsNames"; +import { Wrapper } from "popup/__testHelpers__"; + +const TEST_PUBLIC_KEY = + "GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF"; +const POOL_ID = "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD"; + +// Two distinguishable envelopes, so the assertions can tell "what the flow was +// handed" apart from "what in-page signing produced". +const HW_SIGNED_XDR = "AAAA-signed-by-device"; +const SOFTWARE_SIGNED_XDR = "AAAA-signed-in-page"; + +const mockSignSoroban = jest.fn(); +const mockFetchBalances = jest.fn().mockResolvedValue({ balances: {} }); + +jest.mock("@shared/api/internal", () => ({ + ...jest.requireActual("@shared/api/internal"), + signFreighterSorobanTransaction: (...args: unknown[]) => + mockSignSoroban(...args), +})); + +jest.mock("helpers/hooks/useGetBalances", () => ({ + ...jest.requireActual("helpers/hooks/useGetBalances"), + useGetBalances: () => ({ fetchData: mockFetchBalances }), +})); + +jest.mock("helpers/metrics", () => ({ + ...jest.requireActual("helpers/metrics"), + emitMetric: jest.fn(), + emitScreenViewed: jest.fn(), +})); + +const mockFetch = jest.fn(); + +const { emitMetric } = + jest.requireMock("helpers/metrics"); + +const emittedMetricNames = () => + (emitMetric as jest.Mock).mock.calls.map(([name]) => name); + +/** + * A submission that has left but not landed, which is the only window in which + * Close is offered. Resolve the returned deferred to settle it. + */ +const holdSubmission = () => { + let settle: (value: unknown) => void = () => {}; + mockFetch.mockImplementation((url: string) => { + if (!String(url).includes("/submit-tx")) { + return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }); + } + return new Promise((resolve) => { + settle = resolve; + }); + }); + return { + succeed: () => + settle({ + ok: true, + json: () => Promise.resolve({ status: "PENDING", hash: "abc123" }), + }), + fail: () => + settle({ + ok: false, + json: () => Promise.resolve({ extras: { result_codes: {} } }), + }), + }; +}; + +/** + * The store the flow hands this screen: an amount and asset already chosen, the + * prepared envelope parked on `transactionSimulation`. Shared with the hook-level + * tests below, which render the hook against the same state the component sees. + */ +const makeState = ({ + xdr, + hardwareWalletType = WalletType.NONE, + hwStatus = ShowOverlayStatus.IDLE, + lastSubmitFailed = false, +}: { + xdr: string; + hardwareWalletType?: WalletType; + hwStatus?: ShowOverlayStatus; + lastSubmitFailed?: boolean; +}) => ({ + auth: { + allAccounts: [{ publicKey: TEST_PUBLIC_KEY, hardwareWalletType }], + publicKey: TEST_PUBLIC_KEY, + bipPath: "44'/148'/0'", + }, + settings: { + networkDetails: TESTNET_NETWORK_DETAILS, + isHashSigningEnabled: false, + }, + transactionSubmission: { + ...transactionSubmissionInitialState, + transactionData: { + ...transactionSubmissionInitialState.transactionData, + amount: "0.5", + asset: "USDC:GCK3D3V2XNLLKRFGFFFDEJXA4O2J4X36HET2FE446AV3M4U7DPHO3PEM", + }, + transactionSimulation: { response: null, preparedTransaction: xdr }, + hardwareWalletData: { + status: hwStatus, + transactionXDR: hwStatus === ShowOverlayStatus.IDLE ? "" : xdr, + shouldSubmit: true, + }, + }, + earn: { ...earnInitialState, pool: { id: POOL_ID }, lastSubmitFailed }, +}); + +const renderSubmit = ({ + xdr, + // Defaults to `xdr`; pass separately to model the render the flow actually + // performs, where the prop was captured before the device signed. + xdrProp = xdr, + ...stateOverrides +}: { + xdr: string; + xdrProp?: string; + hardwareWalletType?: WalletType; + hwStatus?: ShowOverlayStatus; + lastSubmitFailed?: boolean; +}) => + render( + + + , + ); + +const submittedXdrs = () => + mockFetch.mock.calls + .filter(([url]) => String(url).includes("/submit-tx")) + .map(([, options]) => JSON.parse(options.body).signed_xdr); + +describe("EarnSubmit", () => { + const originalFetch = global.fetch; + + beforeEach(() => { + jest.clearAllMocks(); + mockSignSoroban.mockResolvedValue({ + signedTransaction: SOFTWARE_SIGNED_XDR, + }); + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ status: "PENDING", hash: "abc123" }), + }); + global.fetch = mockFetch as unknown as typeof global.fetch; + }); + + afterAll(() => { + global.fetch = originalFetch; + }); + + it("signs in the popup and submits once for a software wallet", async () => { + renderSubmit({ xdr: "AAAA-unsigned" }); + + await waitFor(() => expect(submittedXdrs()).toEqual([SOFTWARE_SIGNED_XDR])); + expect(mockSignSoroban).toHaveBeenCalledTimes(1); + }); + + it("submits the device-signed envelope without signing again", async () => { + // The signed envelope arrives through transactionSimulation, written there + // by the review's HardwareSign overlay. Re-signing it in the popup is + // impossible for a hardware account, so the hook must not try — and it must + // read the envelope from redux, not from a prop that predates the signature. + renderSubmit({ + xdr: HW_SIGNED_XDR, + xdrProp: "AAAA-unsigned", + hardwareWalletType: WalletType.LEDGER, + }); + + await waitFor(() => expect(submittedXdrs()).toEqual([HW_SIGNED_XDR])); + expect(mockSignSoroban).not.toHaveBeenCalled(); + }); + + it("never renders a second device overlay", async () => { + // HardwareSign defers closeHwOverlay by 300ms while calling onSubmit + // immediately, so this screen mounts while the status is still IN_PROGRESS. + // An overlay here would auto-sign on mount and prompt the device twice. + renderSubmit({ + xdr: HW_SIGNED_XDR, + hardwareWalletType: WalletType.LEDGER, + hwStatus: ShowOverlayStatus.IN_PROGRESS, + }); + + await waitFor(() => expect(submittedXdrs()).toEqual([HW_SIGNED_XDR])); + expect(screen.queryByTestId("HardwareSign__internal")).toBeNull(); + expect(screen.getByTestId("earn-submit")).toBeDefined(); + }); + + it("still reports the outcome of a deposit the user stopped watching", async () => { + // Close navigates back to the account view; it does not close the popup, so + // this hook's continuation keeps running. The dismissal is a UX signal, and + // the completion that follows it is the truth — the deposit did land. + const submission = holdSubmission(); + const { unmount } = renderSubmit({ xdr: "AAAA-unsigned" }); + + const close = await screen.findByTestId("earn-submit-close"); + await userEvent.click(close); + // What the Earn view does with onExit: this screen goes away. + unmount(); + + expect(emittedMetricNames()).toContain(METRIC_NAMES.earnDepositDismissed); + expect(emittedMetricNames()).not.toContain( + METRIC_NAMES.earnDepositCompleted, + ); + + await act(async () => { + submission.succeed(); + }); + + await waitFor(() => + expect(emittedMetricNames()).toContain(METRIC_NAMES.earnDepositCompleted), + ); + expect( + emittedMetricNames().filter( + (name) => name === METRIC_NAMES.earnDepositCompleted, + ), + ).toHaveLength(1); + }); + + it("reports a failure that lands after the screen is gone", async () => { + // The gap this closes: the Earn view owned earn.deposit_failed, and it + // unmounts on close — so a post-close failure was silent while a post-close + // success was not, biasing the funnel toward success. + const submission = holdSubmission(); + const { unmount } = renderSubmit({ xdr: "AAAA-unsigned" }); + + await userEvent.click(await screen.findByTestId("earn-submit-close")); + unmount(); + + await act(async () => { + submission.fail(); + }); + + await waitFor(() => + expect(emittedMetricNames()).toContain(METRIC_NAMES.earnDepositFailed), + ); + expect(emittedMetricNames()).not.toContain( + METRIC_NAMES.earnDepositCompleted, + ); + }); + + it("does not resubmit an envelope the network already rejected", async () => { + // Second layer under the Earn view's step teardown: if anything remounts + // this screen while a failure still stands, it must not replay the request. + renderSubmit({ xdr: "AAAA-unsigned", lastSubmitFailed: true }); + + await waitFor(() => + expect(screen.getByTestId("earn-submit")).toBeDefined(), + ); + expect(submittedXdrs()).toEqual([]); + expect(mockSignSoroban).not.toHaveBeenCalled(); + }); +}); +/** + * The screen ands the hook's state together with redux `submitStatus`, and the + * Earn view unmounts this step outright on a failure — so a wrong state here is + * invisible through the DOM. These drive the hook directly to assert the signal + * itself, which is what the screen's guards would otherwise be covering for. + */ +describe("useSubmitEarnTxData", () => { + const originalFetch = global.fetch; + + beforeEach(() => { + jest.clearAllMocks(); + mockSignSoroban.mockResolvedValue({ + signedTransaction: SOFTWARE_SIGNED_XDR, + }); + global.fetch = mockFetch as unknown as typeof global.fetch; + }); + + afterAll(() => { + global.fetch = originalFetch; + }); + + const renderSubmitHook = (xdr: string) => + renderHook( + () => + useSubmitEarnTxData({ + isHardwareWallet: false, + networkDetails: TESTNET_NETWORK_DETAILS, + publicKey: TEST_PUBLIC_KEY, + xdr, + assetCode: "USDC", + poolId: POOL_ID, + apy: 0.05, + viaSwap: false, + }), + { + wrapper: ({ children }: { children: React.ReactNode }) => ( + + {children} + + ), + }, + ); + + it("reports an error state when the network rejects the submission", async () => { + mockFetch.mockResolvedValue({ + ok: false, + json: () => Promise.resolve({ extras: { result_codes: {} } }), + }); + const { result } = renderSubmitHook("AAAA-unsigned"); + + await act(async () => { + await result.current.fetchData(); + }); + + expect(result.current.state.state).toBe(RequestState.ERROR); + }); + + it("reports a success state when the submission lands", async () => { + mockFetch.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ status: "PENDING", hash: "abc123" }), + }); + const { result } = renderSubmitHook("AAAA-unsigned"); + + await act(async () => { + await result.current.fetchData(); + }); + + expect(result.current.state.state).toBe(RequestState.SUCCESS); + }); +}); diff --git a/extension/src/popup/components/earn/EarnSubmit/hooks/useSubmitEarnTxData.tsx b/extension/src/popup/components/earn/EarnSubmit/hooks/useSubmitEarnTxData.tsx new file mode 100644 index 0000000000..3a3de3e5d1 --- /dev/null +++ b/extension/src/popup/components/earn/EarnSubmit/hooks/useSubmitEarnTxData.tsx @@ -0,0 +1,178 @@ +import { useReducer } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { captureException } from "@sentry/browser"; + +import { ErrorMessage } from "@shared/api/types"; +import { NetworkDetails } from "@shared/constants/stellar"; +import { AppDispatch } from "popup/App"; +import { initialState, isError, reducer } from "helpers/request"; +import { isMainnet } from "helpers/stellar"; +import { + trackEarnDepositCompleted, + trackEarnDepositFailed, +} from "popup/metrics/earn"; +import { getFailureReasonCode } from "popup/components/earn/helpers/failureReasonCode"; +import { AccountBalances, useGetBalances } from "helpers/hooks/useGetBalances"; +import { + signFreighterSorobanTransaction, + submitFreighterSorobanTransaction, + transactionSubmissionSelector, +} from "popup/ducks/transactionSubmission"; + +interface SubmitEarnTxData { + status: "success"; +} + +/** + * Signs and submits the prepared Blend deposit. + * + * Deliberately not the shared `useSubmitTxData`: that one signs and submits via + * the *classic* thunks, and it registers the destination as a recent address — + * which for a deposit is the pool contract, not somewhere anyone sends funds. + * + * Hardware wallets skip the in-page signing step because they have already + * signed: EarnReview's Confirm dispatches `startHwSign`, and HardwareSign writes + * the signed envelope back over `transactionSimulation.preparedTransaction` + * before the flow steps here. Only the envelope needs signing — the deposit's + * auth carries source-account credentials, so there is no auth-entry round trip. + * + * That signed envelope is read from redux rather than from the `xdr` prop, the + * same way the shared `useSubmitTxData` does it: it is the value the hardware + * overlay actually wrote, and reading it here cannot go stale. + * + * Emits this step's outcome — completed or failed — rather than leaving failures + * to the Earn view's `submitStatus: ERROR` effect. Close is offered while the + * deposit is in flight, and it navigates back to the account view rather than + * closing the popup: the view unmounts but this continuation keeps running, so an + * outcome emitted from the view would be reported for successes and silently lost + * for failures. Emitted here, both survive. + * + * The Earn view's effect still owns failures that happen before the flow reaches + * this screen (a device-rejected signature at review) and still owns the UI + * response to a failure; it skips its own emit while this step is active. + */ +export function useSubmitEarnTxData({ + isHardwareWallet, + networkDetails, + publicKey, + xdr, + assetCode, + poolId, + apy, + viaSwap, +}: { + isHardwareWallet: boolean; + networkDetails: NetworkDetails; + publicKey: string; + xdr: string; + assetCode: string; + poolId: string; + apy: number | null; + viaSwap: boolean; +}) { + const reduxDispatch = useDispatch(); + const { transactionSimulation } = useSelector(transactionSubmissionSelector); + const [state, dispatch] = useReducer( + reducer, + initialState, + ); + const { fetchData: fetchBalances } = useGetBalances({ + showHidden: false, + includeIcons: false, + }); + + const trackDepositFailed = (error: ErrorMessage | undefined) => + trackEarnDepositFailed({ + assetCode, + poolId, + reasonCode: getFailureReasonCode(error), + }); + + const fetchData = async () => { + dispatch({ type: "FETCH_DATA_START" }); + try { + let signedXDR = transactionSimulation.preparedTransaction || xdr; + + if (!isHardwareWallet) { + const res = await reduxDispatch( + signFreighterSorobanTransaction({ + transactionXDR: xdr, + network: networkDetails.networkPassphrase, + }), + ); + + if ( + !signFreighterSorobanTransaction.fulfilled.match(res) || + !res.payload.signedTransaction + ) { + // Submitting `xdr` unsigned would fail on the network anyway, but as a + // *second* failure: the rejected sign thunk has already set + // submitStatus to ERROR and the flow has already stepped back to the + // amount screen, so the late submit rejection would report a second + // earn.deposit_failed for one attempt. + trackDepositFailed( + signFreighterSorobanTransaction.rejected.match(res) + ? res.payload + : undefined, + ); + dispatch({ type: "FETCH_DATA_ERROR", payload: res.payload }); + return res.payload; + } + + signedXDR = res.payload.signedTransaction; + } + + const submitResp = await reduxDispatch( + submitFreighterSorobanTransaction({ + publicKey, + signedXDR, + networkDetails, + }), + ); + + if (!submitFreighterSorobanTransaction.fulfilled.match(submitResp)) { + trackDepositFailed( + submitFreighterSorobanTransaction.rejected.match(submitResp) + ? submitResp.payload + : undefined, + ); + dispatch({ type: "FETCH_DATA_ERROR", payload: submitResp.payload }); + return submitResp.payload; + } + + trackEarnDepositCompleted({ + assetCode, + poolId, + apy, + viaSwap, + }); + + // The deposit moved funds out of the account, so refresh balances. A + // failure here does not affect the deposit itself — log and move on + // rather than reporting a successful submission as an error. + const balancesResult = await fetchBalances( + publicKey, + isMainnet(networkDetails), + networkDetails, + false, + ); + + if (isError(balancesResult)) { + captureException( + `Failed to fetch balances after earn deposit - ${JSON.stringify( + balancesResult.message, + )} ${networkDetails.network}`, + ); + } + + const payload: SubmitEarnTxData = { status: "success" }; + dispatch({ type: "FETCH_DATA_SUCCESS", payload }); + return payload; + } catch (error) { + dispatch({ type: "FETCH_DATA_ERROR", payload: error }); + return error; + } + }; + + return { state, fetchData }; +} diff --git a/extension/src/popup/components/earn/EarnSubmit/index.tsx b/extension/src/popup/components/earn/EarnSubmit/index.tsx new file mode 100644 index 0000000000..86dec47d32 --- /dev/null +++ b/extension/src/popup/components/earn/EarnSubmit/index.tsx @@ -0,0 +1,222 @@ +import React, { useEffect, useRef } from "react"; +import { Button, Icon, Loader, Text } from "@stellar/design-system"; +import { useTranslation } from "react-i18next"; +import { useSelector } from "react-redux"; + +import { ActionStatus } from "@shared/api/types"; +import { View } from "popup/basics/layout/View"; +import { AssetIcon } from "popup/components/account/AccountAssets"; +import { PoolIcon } from "popup/components/earn/PoolIcon"; +import { RequestState } from "constants/request"; +import { getAssetFromCanonical } from "helpers/stellar"; +import { isCustomNetwork } from "@shared/helpers/stellar"; +import { formatAmount } from "popup/helpers/formatters"; +import { getStellarExpertUrl } from "popup/helpers/account"; +import { openTab } from "popup/helpers/navigate"; +import { + hardwareWalletTypeSelector, + publicKeySelector, +} from "popup/ducks/accountServices"; +import { settingsNetworkDetailsSelector } from "popup/ducks/settings"; +import { transactionSubmissionSelector } from "popup/ducks/transactionSubmission"; +import { earnSelector } from "popup/ducks/earn"; +import { iconsSelector } from "popup/ducks/cache"; +import { emitScreenViewed } from "helpers/metrics"; +import { trackEarnDepositDismissed } from "popup/metrics/earn"; + +import { useSubmitEarnTxData } from "./hooks/useSubmitEarnTxData"; + +import "./styles.scss"; + +interface EarnSubmitProps { + /** Prepared, simulated deposit XDR. */ + xdr: string; + /** Dismisses the flow — used by both Close (in flight) and Done (settled). */ + onExit: () => void; +} + +/** + * The deposit's terminal screen: "Depositing" while in flight, "Deposited!" + * once it settles. One component, two states — the same shape SendingTransaction + * uses for Sending/Sent. + * + * Close is offered while in flight because a Soroban submission can outlast the + * user's patience. It abandons the *screen*, not the deposit: the envelope has + * already been submitted and nothing cancels it, so the submit hook's + * continuation — which outlives this component — still reports the outcome. What + * Close gives up is watching it, which is what `trackEarnDepositDismissed` + * records. + */ +export const EarnSubmit = ({ xdr, onExit }: EarnSubmitProps) => { + const { t } = useTranslation(); + const submission = useSelector(transactionSubmissionSelector); + const publicKey = useSelector(publicKeySelector); + const networkDetails = useSelector(settingsNetworkDetailsSelector); + const hardwareWalletType = useSelector(hardwareWalletTypeSelector); + const { pool, selectedAssetApy, didSwapInFlow, lastSubmitFailed } = + useSelector(earnSelector); + const cachedIcons = useSelector(iconsSelector); + + const { amount, asset } = submission.transactionData; + const srcAsset = getAssetFromCanonical(asset); + const transactionHash = submission.response?.hash; + + const { state: submissionState, fetchData } = useSubmitEarnTxData({ + publicKey, + networkDetails, + xdr, + isHardwareWallet: !!hardwareWalletType, + assetCode: srcAsset.code, + poolId: pool?.id || "", + apy: selectedAssetApy, + viaSwap: didSwapInFlow, + }); + + const isSuccess = + submissionState.state === RequestState.SUCCESS && + submission.submitStatus !== ActionStatus.ERROR; + const isLoading = !isSuccess; + + const hasEmittedSuccessView = useRef(false); + + useEffect(() => { + // A failed deposit drops this step and returns to the amount screen (the + // Earn view's submitStatus effect). Should anything remount this component + // while that failure still stands, submitting would replay the envelope the + // network just rejected — so refuse. A real retry runs through + // EarnAmount's handleContinue, which clears the flag first. + if (lastSubmitFailed) { + return; + } + fetchData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Two screens in the funnel, not one: this component is "Depositing" and then + // "Deposited!", and only it can see the transition. The Earn view's step + // effect therefore has no entry for DEPOSIT_CONFIRM. + useEffect(() => { + emitScreenViewed("earn_processing", { flow: "earn", step: "processing" }); + }, []); + + useEffect(() => { + if (!isSuccess || hasEmittedSuccessView.current) { + return; + } + hasEmittedSuccessView.current = true; + emitScreenViewed("earn_success", { flow: "earn", step: "success" }); + }, [isSuccess]); + + return ( + + + {isLoading && ( + <> +
+ {/* Same string Send and Swap use — reusing the key keeps the + copy consistent and avoids a second translation. */} + {t( + "You can close this screen, your transaction should be complete in less than a minute.", + )} +
+ + + )} + + {isSuccess && + !isCustomNetwork(networkDetails) && + transactionHash && ( + + )} + + {isSuccess && ( + + )} +
+ } + > +
+
+ {isLoading ? ( + <> + + {t("Depositing")} + + ) : ( + <> + + {t("Deposited!")} + + )} +
+ +
+
+ {/* The cached icon map rather than an empty one, which AssetIcon + reads as a lookup in flight. Warm by the time the flow reaches + here: the deposited asset is held, so the picker and amount + screens have both resolved it. */} + + + +
+ + {t("{{amount}} {{code}} to {{pool}}", { + amount: formatAmount(amount), + code: srcAsset.code, + pool: pool?.name || t("Blend pool"), + })} + +
+
+ +
+ ); +}; diff --git a/extension/src/popup/components/earn/EarnSubmit/styles.scss b/extension/src/popup/components/earn/EarnSubmit/styles.scss new file mode 100644 index 0000000000..d7a6fe1fdf --- /dev/null +++ b/extension/src/popup/components/earn/EarnSubmit/styles.scss @@ -0,0 +1,64 @@ +@use "../../../styles/utils.scss" as *; + +.EarnSubmit { + display: flex; + flex-direction: column; + align-items: center; + gap: pxToRem(24); + padding-top: pxToRem(72); + + &__title { + display: flex; + flex-direction: column; + align-items: center; + gap: pxToRem(12); + color: var(--sds-clr-gray-12); + font-size: pxToRem(18); + font-weight: 500; + + &-success { + color: var(--sds-clr-green-11); + width: pxToRem(28); + height: pxToRem(28); + } + } + + &__summary { + width: 100%; + background: var(--sds-clr-gray-03); + border-radius: pxToRem(12); + padding: pxToRem(20) pxToRem(16); + display: flex; + flex-direction: column; + align-items: center; + gap: pxToRem(12); + color: var(--sds-clr-gray-11); + text-align: center; + } + + &__icons { + display: flex; + align-items: center; + gap: pxToRem(10); + color: var(--sds-clr-gray-09); + + // Same as the review row: the gap owns the spacing, so AssetIcon's list-row + // margin would otherwise leave the chevron off-centre between the two icons. + .AccountAssets__asset--logo { + margin-right: 0; + } + } + + &__footer { + display: flex; + flex-direction: column; + gap: pxToRem(8); + + &-note { + color: var(--sds-clr-gray-10); + font-size: pxToRem(12); + text-align: center; + padding-bottom: pxToRem(4); + } + } +} diff --git a/extension/src/popup/components/earn/EarnSwap/__tests__/EarnSwap.dismiss.test.tsx b/extension/src/popup/components/earn/EarnSwap/__tests__/EarnSwap.dismiss.test.tsx new file mode 100644 index 0000000000..1cbc628fbc --- /dev/null +++ b/extension/src/popup/components/earn/EarnSwap/__tests__/EarnSwap.dismiss.test.tsx @@ -0,0 +1,111 @@ +import React from "react"; +import { act, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { ActionStatus } from "@shared/api/types"; +import { TESTNET_NETWORK_DETAILS } from "@shared/constants/stellar"; +import { EarnSwap } from "popup/components/earn/EarnSwap"; +import { + initialState as transactionSubmissionInitialState, + submitFreighterTransaction, +} from "popup/ducks/transactionSubmission"; +import { getTestStore, Wrapper } from "popup/__testHelpers__"; + +const USDC_ISSUER = "GCK3D3V2XNLLKRFGFFFDEJXA4O2J4X36HET2FE446AV3M4U7DPHO3PEM"; +const USDC = `USDC:${USDC_ISSUER}`; + +// The sheet's steps are irrelevant to what a dismissal means; stub them so the +// test is about the X and nothing else. +jest.mock("popup/components/InternalTransaction/SubmitTransaction", () => ({ + TransactionConfirm: () =>
, +})); + +jest.mock("popup/components/swap/SwapAmount", () => ({ + SwapAmount: () =>
, +})); + +jest.mock("popup/components/swap/SwapAsset", () => ({ + SwapAsset: () =>
, +})); + +jest.mock("helpers/metrics", () => ({ + ...jest.requireActual("helpers/metrics"), + emitMetric: jest.fn(), + emitScreenViewed: jest.fn(), +})); + +const renderSheet = ({ + onDone, + onCancel, +}: { + onDone: jest.Mock; + onCancel: jest.Mock; +}) => + render( + + + , + ); + +// The sheet clears the submission on mount to seed itself, so success has to +// land after that — which is also the real ordering: the swap settles while the +// sheet is open. +const settleSwap = () => + act(() => { + getTestStore()!.dispatch( + submitFreighterTransaction.fulfilled({} as never, "req-1", { + publicKey: "G123", + signedXDR: "AAAA-signed", + networkDetails: TESTNET_NETWORK_DETAILS, + }), + ); + }); + +describe("EarnSwap dismissal", () => { + it("reports a completion when the sheet is closed after the swap succeeded", async () => { + const onDone = jest.fn(); + const onCancel = jest.fn(); + renderSheet({ onDone, onCancel }); + settleSwap(); + + expect(getTestStore()!.getState().transactionSubmission.submitStatus).toBe( + ActionStatus.SUCCESS, + ); + + await userEvent.click(screen.getByTestId("earn-swap-close")); + + // The balance behind the sheet has changed. Treating this as a cancel is + // what left the picker offering the token at its pre-swap balance. + expect(onDone).toHaveBeenCalledWith({ fromCode: "XLM", toCode: "USDC" }); + expect(onCancel).not.toHaveBeenCalled(); + }); + + it("reports a cancellation when the sheet is closed before submitting", async () => { + const onDone = jest.fn(); + const onCancel = jest.fn(); + renderSheet({ onDone, onCancel }); + + await userEvent.click(screen.getByTestId("earn-swap-close")); + + expect(onCancel).toHaveBeenCalled(); + expect(onDone).not.toHaveBeenCalled(); + }); +}); diff --git a/extension/src/popup/components/earn/EarnSwap/index.tsx b/extension/src/popup/components/earn/EarnSwap/index.tsx new file mode 100644 index 0000000000..aff1540ced --- /dev/null +++ b/extension/src/popup/components/earn/EarnSwap/index.tsx @@ -0,0 +1,232 @@ +import React, { useEffect, useState } from "react"; +import { Icon } from "@stellar/design-system"; +import { useTranslation } from "react-i18next"; +import { useDispatch, useSelector } from "react-redux"; + +import { ActionStatus } from "@shared/api/types"; +import { AppDispatch } from "popup/App"; +import { + Sheet, + SheetContent, + SheetTitle, + ScreenReaderOnly, +} from "popup/basics/shadcn/Sheet"; +import { EARN_SWAP_STEPS } from "popup/constants/earn"; +import { + DEFAULT_AMOUNT, + DEFAULT_AMOUNT_USD, +} from "popup/components/amount/constants"; +import { InputType } from "helpers/transaction"; +import { emitMetric, emitScreenViewed } from "helpers/metrics"; +import { METRIC_NAMES } from "popup/constants/metricsNames"; +import { getAssetFromCanonical } from "helpers/stellar"; +import { TransactionConfirm } from "popup/components/InternalTransaction/SubmitTransaction"; +import { SwapAsset } from "popup/components/swap/SwapAsset"; +import { SwapAmount } from "popup/components/swap/SwapAmount"; +import { useSwapSubmitQuoteExpiry } from "popup/components/swap/hooks/useSwapSubmitQuoteExpiry"; +import { resetSimulation } from "popup/ducks/token-payment"; +import { + DestinationTokenDetails, + resetSubmission, + saveAmount, + saveAmountUsd, + saveAsset, + saveDestinationAsset, + saveDestinationTokenDetails, + saveIsToken, + transactionSubmissionSelector, +} from "popup/ducks/transactionSubmission"; + +import "./styles.scss"; + +interface EarnSwapProps { + /** Canonical of the token the user needs, pinned as the receive side. */ + destinationAsset: string; + destinationTokenDetails: DestinationTokenDetails | null; + /** Swap settled — return to the token picker with a toast. */ + onDone: (received: { fromCode: string; toCode: string }) => void; + /** Backed out without swapping. */ + onCancel: () => void; +} + +/** + * The swap branch of the Earn flow: a sibling of the Swap route rather than a + * modification of it. + * + * Owns its own sub-step state so the token picker underneath stays mounted, and + * so the sub-state resets for free when the branch unmounts. The receive side is + * pinned to the token the deposit needs — chosen on the picker, the whole point + * of entering here — so only the sell side has a picker. + * + * Presented as a bottom sheet over the still-visible token picker, per the + * design. Not a SlideupModal: those self-measure via scrollHeight, which breaks + * for the full-height Views this branch reuses from the Swap route. The radix + * sheet takes an explicit height instead, and `.EarnSwapSheet` re-bases those + * Views from 100dvh onto the sheet. + */ +export const EarnSwap = ({ + destinationAsset, + destinationTokenDetails, + onDone, + onCancel, +}: EarnSwapProps) => { + const { t } = useTranslation(); + const dispatch = useDispatch(); + const submission = useSelector(transactionSubmissionSelector); + const { transactionSimulation, transactionData } = submission; + + const [activeStep, setActiveStep] = useState(EARN_SWAP_STEPS.AMOUNT); + const [inputType, setInputType] = useState("crypto"); + + const { isQuoteExpiredAtSubmit } = useSwapSubmitQuoteExpiry({ + onRecover: () => setActiveStep(EARN_SWAP_STEPS.AMOUNT), + }); + + // Seed the swap: source defaults to XLM (the most common), destination is + // pinned to what the deposit needs. Runs once on entry — re-running would + // stomp a source the user has since chosen. + useEffect(() => { + dispatch(resetSubmission()); + dispatch(resetSimulation()); + dispatch(saveAsset("native")); + dispatch(saveIsToken(false)); + dispatch(saveAmount(DEFAULT_AMOUNT)); + dispatch(saveAmountUsd(DEFAULT_AMOUNT_USD)); + dispatch(saveDestinationAsset(destinationAsset)); + dispatch(saveDestinationTokenDetails(destinationTokenDetails)); + emitScreenViewed("earn_swap_amount", { flow: "earn" }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const finish = () => { + const fromCode = getAssetFromCanonical(transactionData.asset).code; + const toCode = getAssetFromCanonical(transactionData.destinationAsset).code; + dispatch(resetSubmission()); + dispatch(resetSimulation()); + onDone({ fromCode, toCode }); + }; + + // The sheet's X, its scrim and Escape are shared by every step, but the steps + // do not all mean the same thing by a dismissal. Once the swap has succeeded + // it is a completion, not a cancellation: the balance behind the sheet has + // changed, and routing it to `onCancel` drops the picker's refresh, the + // completion metric and the `via_swap` attribution on the floor, leaving the + // picker offering a token at the balance it had before the swap. + const dismiss = () => { + if (submission.submitStatus === ActionStatus.SUCCESS) { + finish(); + return; + } + onCancel(); + }; + + const renderStep = () => { + switch (activeStep) { + case EARN_SWAP_STEPS.SWAP_CONFIRM: { + // The recovery effect steps back to the amount screen on a quote-expiry + // failure; render nothing this frame so SubmitFail never flashes. + if (isQuoteExpiredAtSubmit) { + return null; + } + return ( + setActiveStep(EARN_SWAP_STEPS.AMOUNT)} + // Both exits stay inside Earn: Done returns to the picker with the + // new balance, Close backs out without abandoning the deposit. + onDone={finish} + onClose={onCancel} + onDismissError={onCancel} + /> + ); + } + + case EARN_SWAP_STEPS.SET_FROM_ASSET: { + return ( + } + goBack={() => setActiveStep(EARN_SWAP_STEPS.AMOUNT)} + onClickAsset={(canonical: string, isContract: boolean) => { + dispatch(saveAsset(canonical)); + dispatch(saveIsToken(isContract)); + dispatch(saveAmount("0")); + dispatch(saveAmountUsd("0.00")); + emitMetric(METRIC_NAMES.swapSourceSelected, { + asset_code: getAssetFromCanonical(canonical).code, + asset_issuer: getAssetFromCanonical(canonical).issuer, + source: "balances", + }); + setActiveStep(EARN_SWAP_STEPS.AMOUNT); + }} + /> + ); + } + + case EARN_SWAP_STEPS.AMOUNT: + default: { + return ( + setActiveStep(EARN_SWAP_STEPS.SET_FROM_ASSET)} + // Unreachable while the destination is pinned — the receive pill is + // a label, not a control — but the prop is required; point it at the + // source picker rather than leaving a silent no-op behind. + goToEditDst={() => setActiveStep(EARN_SWAP_STEPS.SET_FROM_ASSET)} + goToNext={() => setActiveStep(EARN_SWAP_STEPS.SWAP_CONFIRM)} + /> + ); + } + } + }; + + return ( + { + if (!isOpen) { + dismiss(); + } + }} + > + e.preventDefault()} + aria-describedby={undefined} + data-testid="earn-swap-sheet" + > + + {t("Swap")} + + {/* Sheet-level rather than per-step: the submitting/success step has no + header of its own. What the X *means* is not the same on every step + though — see `dismiss`. */} + +
{renderStep()}
+
+
+ ); +}; diff --git a/extension/src/popup/components/earn/EarnSwap/styles.scss b/extension/src/popup/components/earn/EarnSwap/styles.scss new file mode 100644 index 0000000000..3d7f8fcf5b --- /dev/null +++ b/extension/src/popup/components/earn/EarnSwap/styles.scss @@ -0,0 +1,93 @@ +@use "../../../styles/utils.scss" as *; + +// The swap branch is a bottom sheet over the still-visible token picker. The +// container rules are scoped by data-slot because our shadcn Sheet forwards +// `className` to the overlay as well, and the overlay must keep its dim scrim +// rather than take a solid background and a height. +[data-slot="sheet-content"].EarnSwapSheet { + background: var(--sds-clr-gray-01); + border-radius: 1.5rem 1.5rem 0 0; + box-shadow: 0 1rem 1.5rem rgba(0, 0, 0, 0.24); + // Deliberately not `overflow: hidden`. The slide-in animation transforms this + // element, which makes it the containing block for the review SlideupModal + // that SwapAmount nests inside it — clipping here would cut that sheet off. + // `__body` does the clipping instead, and the header inset carries the + // rounded top corners. + + // 90% of the 600px popup. One height for every sub-step: the screens reused + // from the Swap route pin their footer, so a shorter per-step sheet would push + // their content into a scroll. + height: 90dvh; + max-height: pxToRem(540); + + // Nothing inside the sheet is a full-height screen any more. + .View { + height: 100%; + min-height: 0; + } + + // The vertical rules belong to the fullscreen layout; inside the sheet they + // would draw down over the rounded corners. + .View__inset--vertical-border { + border-left: 0; + border-right: 0; + } + + // Follow whichever inset paints first rather than assuming a header: the + // submitting/success step renders only View.Content, and `--scroll-shadows` + // gives that inset an opaque background that would otherwise square off the + // sheet's rounded top corners. + .EarnSwapSheet__body > :first-child .View__inset:first-of-type { + border-radius: 1.5rem 1.5rem 0 0; + } + + // Leave room for the sheet's own X so a long title cannot run under it. + .View__header__box--right { + min-width: pxToRem(32); + } +} + +.EarnSwapSheet__close { + position: absolute; + // Centred on the 56px header row, whether or not the step draws one. + top: pxToRem(12); + right: 1rem; + z-index: 1; + width: pxToRem(32); + height: pxToRem(32); + border-radius: 50%; + border: none; + cursor: pointer; + background: var(--sds-clr-gray-04); + color: var(--sds-clr-gray-11); + display: flex; + align-items: center; + justify-content: center; +} + +.EarnSwapSheet__body { + // Mirrors `.Earn__step`: the reused screens render a bare header + content + // pair with no `.View` wrapper of their own. + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + // These screens have no `.View` ancestor inside the sheet, so the chrome + // variables `.View` normally declares have to be set here — without them the + // padding rules that reference them collapse to zero, which is what left the + // CTA flush against the sheet's bottom edge. + // + // Values are measured off the Figma sheet (node 9459:47811, 360x436): a 32px + // header row 24px below the top edge (56px total), a 12px gap from the header + // to the sell card, and 24px under the CTA. + --View-header-height: #{pxToRem(56)}; + --View-inset-padding-top: #{pxToRem(12)}; + --View-footer-padding-bottom: #{pxToRem(24)}; +} + +// The seam notch is 40px in this sheet (Figma node 9459:48064). The standalone +// Swap route keeps the shared 30px until design confirms the two should match. +.EarnSwapSheet__body .SwapAsset__direction-btn { + width: pxToRem(40); + height: pxToRem(40); +} diff --git a/extension/src/popup/components/earn/EarnTokenPicker/NotEnoughTokenSheet.tsx b/extension/src/popup/components/earn/EarnTokenPicker/NotEnoughTokenSheet.tsx new file mode 100644 index 0000000000..01a5acaeba --- /dev/null +++ b/extension/src/popup/components/earn/EarnTokenPicker/NotEnoughTokenSheet.tsx @@ -0,0 +1,221 @@ +import React from "react"; +import { Button, Icon, Text } from "@stellar/design-system"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; + +import { getCanonicalFromAsset } from "@shared/helpers/stellar"; +import { AssetIcon } from "popup/components/account/AccountAssets"; +import { ROUTES } from "popup/constants/routes"; +import { EARN_FLOW_QUERY, NotEnoughVariant } from "popup/constants/earn"; +import { navigateTo } from "popup/helpers/navigate"; +import { useGetOnrampToken } from "helpers/hooks/useGetOnrampToken"; +import { trackEarnFundingActionSelected } from "popup/metrics/earn"; + +import { EarnTokenOption } from "./hooks/useGetEarnTokensData"; + +interface NotEnoughTokenSheetProps { + option: EarnTokenOption; + variant: NotEnoughVariant; + onClose: () => void; + onSwap: () => void; +} + +/** + * Shown when a pool-supported token the account holds none of is tapped. + * + * Which actions appear depends on what is actually possible for this asset — + * see `getNotEnoughVariant`. Buy opens the Coinbase onramp in a tab rather than + * routing to Add Funds, which would abandon the Earn flow. + */ +export const NotEnoughTokenSheet = ({ + option, + variant, + onClose, + onSwap, +}: NotEnoughTokenSheetProps) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const { fetchData: openOnramp } = useGetOnrampToken({ asset: option.code }); + + const showBuy = + variant === NotEnoughVariant.BUY_OR_TRANSFER || + variant === NotEnoughVariant.BUY_SWAP_OR_TRANSFER; + const showSwap = + variant === NotEnoughVariant.SWAP_OR_TRANSFER || + variant === NotEnoughVariant.BUY_SWAP_OR_TRANSFER; + + // The copy names only the routes actually offered, so it never points at an + // action the sheet does not show. + const body = () => { + if (showBuy && showSwap) { + return t( + "You’ll need {{code}} to deposit into this pool. Buy or swap for {{code}} to continue.", + { code: option.code }, + ); + } + if (showBuy) { + return t( + "You’ll need {{code}} to deposit into this pool. Buy or transfer {{code}} to continue.", + { code: option.code }, + ); + } + if (showSwap) { + return t( + "You’ll need {{code}} to deposit into this pool. Swap or transfer {{code}} to continue.", + { code: option.code }, + ); + } + return t( + "You’ll need {{code}} to deposit into this pool. Transfer {{code}} to continue.", + { code: option.code }, + ); + }; + + // Two routes of equal weight get the side-by-side treatment in the design, + // with the transfer route demoted to a text button under an "or". A single + // route keeps the stacked full-width pair. + const showBothPrimaries = showBuy && showSwap; + const transferLabel = t("Transfer from another account"); + + // Each route reports itself before it leaves: Buy opens a tab, Transfer + // navigates away, and Swap replaces the sheet, so this is the last frame in + // which the choice is still attributable to the Earn funnel. + const handleBuy = () => { + trackEarnFundingActionSelected({ assetCode: option.code, action: "buy" }); + openOnramp(); + }; + const handleSwap = () => { + trackEarnFundingActionSelected({ assetCode: option.code, action: "swap" }); + onSwap(); + }; + const goToTransfer = () => { + trackEarnFundingActionSelected({ + assetCode: option.code, + action: "transfer", + }); + navigateTo(ROUTES.viewPublicKey, navigate, EARN_FLOW_QUERY); + }; + + return ( +
+
+ + +
+ +
+ + {t("Not enough {{code}}", { code: option.code })} + +
+ +
+ + {body()} + +
+ +
+ {showBothPrimaries ? ( + <> +
+ + +
+ +
+ {t("or")} +
+ + + + ) : ( + <> + {showBuy && ( + + )} + + {showSwap && ( + + )} + + {/* Outlined under a primary route; filled when transferring is the + only thing the account can do. */} + + + )} +
+
+ ); +}; diff --git a/extension/src/popup/components/earn/EarnTokenPicker/helpers/__tests__/getNotEnoughVariant.test.ts b/extension/src/popup/components/earn/EarnTokenPicker/helpers/__tests__/getNotEnoughVariant.test.ts new file mode 100644 index 0000000000..eeb4383383 --- /dev/null +++ b/extension/src/popup/components/earn/EarnTokenPicker/helpers/__tests__/getNotEnoughVariant.test.ts @@ -0,0 +1,145 @@ +import { AssetType } from "@shared/api/types/account-balance"; +import { NetworkDetails } from "@shared/constants/stellar"; +import { + MAINNET_NETWORK_DETAILS, + TESTNET_NETWORK_DETAILS, +} from "@shared/constants/stellar"; +import { PUBLIC_SACS } from "@shared/constants/sac"; +import { NotEnoughVariant } from "popup/constants/earn"; + +import { + getNotEnoughVariant, + hasSwappableBalance, + isOnrampableAsset, +} from "../getNotEnoughVariant"; + +const USDC_ISSUER = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; +const EURC_ISSUER = "GDHU6WRG4IEQXM5NZ4BMPKOXHW76MZM4Y2IEMFDVXBSDP6SJY4ITNPP2"; + +const classic = (code: string, issuer: string, total: string): AssetType => + ({ + token: { code, issuer: { key: issuer } }, + total, + }) as unknown as AssetType; + +const native = (total: string): AssetType => + ({ + token: { code: "XLM", type: "native" }, + total, + }) as unknown as AssetType; + +const soroban = (contractId: string, total: string): AssetType => + ({ + contractId, + total, + }) as unknown as AssetType; + +describe("isOnrampableAsset", () => { + it("allows curated assets on mainnet", () => { + expect(isOnrampableAsset("XLM", MAINNET_NETWORK_DETAILS)).toBe(true); + expect(isOnrampableAsset("USDC", MAINNET_NETWORK_DETAILS)).toBe(true); + }); + + it("rejects EURC — Coinbase does not list it", () => { + // This is exactly why the designs show no Buy button on the EURC sheet. + expect(isOnrampableAsset("EURC", MAINNET_NETWORK_DETAILS)).toBe(false); + }); + + it("rejects everything off mainnet", () => { + // Testnet assets are worthless; an onramp link there is a dead end. + expect(isOnrampableAsset("XLM", TESTNET_NETWORK_DETAILS)).toBe(false); + expect(isOnrampableAsset("USDC", TESTNET_NETWORK_DETAILS)).toBe(false); + }); +}); + +describe("hasSwappableBalance", () => { + const target = `EURC:${EURC_ISSUER}`; + + it("counts native XLM", () => { + // isClassicBalance keys off `issuer`, which native lacks — regression guard + // against excluding the most common swap source. + expect(hasSwappableBalance([native("100")], target)).toBe(true); + }); + + it("counts a held classic asset", () => { + expect( + hasSwappableBalance([classic("USDC", USDC_ISSUER, "500")], target), + ).toBe(true); + }); + + it("ignores zero balances", () => { + expect( + hasSwappableBalance( + [native("0"), classic("USDC", USDC_ISSUER, "0")], + target, + ), + ).toBe(false); + }); + + it("ignores the target asset itself", () => { + // A dust balance of the target must not read as "can swap into the target". + expect( + hasSwappableBalance([classic("EURC", EURC_ISSUER, "0.0001")], target), + ).toBe(false); + }); + + it("ignores Soroban-only balances", () => { + // Swap builds a classic pathPaymentStrictSend and rejects contract assets. + expect(hasSwappableBalance([soroban(PUBLIC_SACS.USDC!, "9")], target)).toBe( + false, + ); + }); + + it("is false for an empty account", () => { + expect(hasSwappableBalance([], target)).toBe(false); + }); +}); + +describe("getNotEnoughVariant", () => { + it.each([ + [true, true, NotEnoughVariant.BUY_SWAP_OR_TRANSFER], + [true, false, NotEnoughVariant.BUY_OR_TRANSFER], + [false, true, NotEnoughVariant.SWAP_OR_TRANSFER], + [false, false, NotEnoughVariant.TRANSFER_ONLY], + ])( + "onrampable=%s swappable=%s -> %s", + (isOnrampable, isSwappable, expected) => { + expect(getNotEnoughVariant({ isOnrampable, isSwappable })).toBe(expected); + }, + ); +}); + +describe("the three variants in the designs", () => { + const balances = [native("1691"), classic("USDC", USDC_ISSUER, "500")]; + const networkDetails: NetworkDetails = MAINNET_NETWORK_DETAILS; + + const variantFor = (code: string, canonical: string, held: AssetType[]) => + getNotEnoughVariant({ + isOnrampable: isOnrampableAsset(code, networkDetails), + isSwappable: hasSwappableBalance(held, canonical), + }); + + it("EURC with holdings -> swap or transfer", () => { + expect(variantFor("EURC", `EURC:${EURC_ISSUER}`, balances)).toBe( + NotEnoughVariant.SWAP_OR_TRANSFER, + ); + }); + + it("USDC on an empty account -> buy or transfer", () => { + expect(variantFor("USDC", `USDC:${USDC_ISSUER}`, [])).toBe( + NotEnoughVariant.BUY_OR_TRANSFER, + ); + }); + + it("USDC with other holdings -> buy, swap or transfer", () => { + expect(variantFor("USDC", `USDC:${USDC_ISSUER}`, [native("1691")])).toBe( + NotEnoughVariant.BUY_SWAP_OR_TRANSFER, + ); + }); + + it("EURC on an empty account -> transfer only", () => { + expect(variantFor("EURC", `EURC:${EURC_ISSUER}`, [])).toBe( + NotEnoughVariant.TRANSFER_ONLY, + ); + }); +}); diff --git a/extension/src/popup/components/earn/EarnTokenPicker/helpers/getNotEnoughVariant.ts b/extension/src/popup/components/earn/EarnTokenPicker/helpers/getNotEnoughVariant.ts new file mode 100644 index 0000000000..1c973110c7 --- /dev/null +++ b/extension/src/popup/components/earn/EarnTokenPicker/helpers/getNotEnoughVariant.ts @@ -0,0 +1,91 @@ +import BigNumber from "bignumber.js"; + +import { AssetType, ClassicAsset } from "@shared/api/types/account-balance"; +import { NetworkDetails } from "@shared/constants/stellar"; +import { getCanonicalFromAsset } from "@shared/helpers/stellar"; +import { isMainnet } from "helpers/stellar"; +import { EARN_ONRAMP_ASSETS, NotEnoughVariant } from "popup/constants/earn"; +import { + isClassicBalance, + isNativeBalance, + isSorobanBalance, +} from "popup/helpers/balance"; + +/** + * Can the Coinbase onramp sell this asset? + * + * `useGetOnrampToken` builds `pay.coinbase.com/buy/select-asset?…&defaultAsset=` + * from the bare code, so an asset Coinbase does not list produces a dead-end + * page rather than an error — hence the curated allowlist rather than offering + * Buy for everything. Testnet assets are worthless, so the onramp is mainnet-only. + */ +export const isOnrampableAsset = ( + code: string, + networkDetails: NetworkDetails, +) => EARN_ONRAMP_ASSETS.has(code) && isMainnet(networkDetails); + +/** + * Does the account hold anything it could swap into the target asset? + * + * Swap is classic-only — it builds a Horizon `pathPaymentStrictSend` and + * rejects contract-ID assets outright — so a Soroban-only balance is not a + * viable source. Native XLM counts: it is the most common source of all. + * + * `targetCanonical` is excluded so a dust balance of the target itself never + * makes the account look like it can swap into what it already has. + */ +export const hasSwappableBalance = ( + balances: AssetType[], + targetCanonical: string, +) => + balances.some((balance) => { + // Contract-only tokens are not swappable, and LP shares are not a token. + // Note isClassicBalance keys off `issuer`, which native XLM has not — so + // it has to be admitted explicitly or the most common source is excluded. + if (isSorobanBalance(balance)) { + return false; + } + const isNative = isNativeBalance(balance); + if (!isNative && !isClassicBalance(balance)) { + return false; + } + if (!new BigNumber(balance.total).gt(0)) { + return false; + } + + const canonical = isNative + ? "native" + : getCanonicalFromAsset( + (balance as ClassicAsset).token.code, + (balance as ClassicAsset).token.issuer.key, + ); + + return canonical !== targetCanonical; + }); + +/** + * Picks which button set the "Not enough X" sheet shows. + * + * `TRANSFER_ONLY` is absent from the designs but is genuinely reachable — an + * empty account on a non-onrampable asset has nothing to buy with and nothing + * to swap from. Falling through to a sheet with no actions would be worse than + * offering the one thing that always works. + */ +export const getNotEnoughVariant = ({ + isOnrampable, + isSwappable, +}: { + isOnrampable: boolean; + isSwappable: boolean; +}): NotEnoughVariant => { + if (isOnrampable && isSwappable) { + return NotEnoughVariant.BUY_SWAP_OR_TRANSFER; + } + if (isOnrampable) { + return NotEnoughVariant.BUY_OR_TRANSFER; + } + if (isSwappable) { + return NotEnoughVariant.SWAP_OR_TRANSFER; + } + return NotEnoughVariant.TRANSFER_ONLY; +}; diff --git a/extension/src/popup/components/earn/EarnTokenPicker/helpers/resolveSwapDestination.ts b/extension/src/popup/components/earn/EarnTokenPicker/helpers/resolveSwapDestination.ts new file mode 100644 index 0000000000..cde8b57c02 --- /dev/null +++ b/extension/src/popup/components/earn/EarnTokenPicker/helpers/resolveSwapDestination.ts @@ -0,0 +1,73 @@ +import { NetworkDetails } from "@shared/constants/stellar"; +import { getTokenDetails } from "@shared/api/internal"; +import { getCanonicalFromAsset } from "@shared/helpers/stellar"; +import { DestinationTokenDetails } from "popup/ducks/transactionSubmission"; +import { CLASSIC_ASSET_DECIMALS } from "popup/helpers/soroban"; + +import { EarnTokenOption } from "../hooks/useGetEarnTokensData"; + +/** + * Resolves the canonical (`CODE:ISSUER`) and destination descriptor for a token + * the user is about to swap into. + * + * A held token already carries its issuer, taken from the balance. A token the + * account holds none of does not — the earn catalog identifies assets only by + * contract address. For a SAC, the contract's own `name()` returns exactly the + * canonical form (`USDC:GA5ZSEJY…`, or `native` for XLM), which is what + * `isSacContract` already relies on to match a SAC to its classic asset. + * + * Returns null when the lookup fails, so callers can decline to start a swap + * rather than send the user somewhere with a half-filled destination. + */ +export const resolveSwapDestination = async ({ + option, + publicKey, + networkDetails, +}: { + option: EarnTokenOption; + publicKey: string; + networkDetails: NetworkDetails; +}): Promise<{ + canonical: string; + details: DestinationTokenDetails; +} | null> => { + let canonical = option.issuer + ? getCanonicalFromAsset(option.code, option.issuer) + : ""; + let issuer = option.issuer; + + if (!canonical) { + const tokenDetails = await getTokenDetails({ + contractId: option.assetId, + publicKey, + networkDetails, + }); + + if (!tokenDetails?.name) { + return null; + } + + canonical = tokenDetails.name; + // "native" has no issuer half; everything else is CODE:ISSUER. + issuer = canonical.includes(":") ? canonical.split(":")[1] : undefined; + } + + return { + canonical, + details: { + tokenCode: option.code, + // The account holds none of it, so if it is a classic asset there is no + // trustline yet — Swap bundles the changeTrust when this is true. + requiresTrustline: canonical !== "native", + // `??`, matching the picker: `||` would rewrite a genuine 0-decimal + // token to 7. + decimals: option.decimals ?? CLASSIC_ASSET_DECIMALS, + issuer, + // Carry the row's icon across: the swap screens read the destination's + // logo from here precisely because a non-held token is absent from the + // balances icon map, and without it the receive pill, the review row and + // the terminal summary all fall back to a placeholder. + iconUrl: option.iconUrl || undefined, + }, + }; +}; diff --git a/extension/src/popup/components/earn/EarnTokenPicker/hooks/useGetEarnTokensData.tsx b/extension/src/popup/components/earn/EarnTokenPicker/hooks/useGetEarnTokensData.tsx new file mode 100644 index 0000000000..bb339fb7f3 --- /dev/null +++ b/extension/src/popup/components/earn/EarnTokenPicker/hooks/useGetEarnTokensData.tsx @@ -0,0 +1,265 @@ +import { useReducer } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import BigNumber from "bignumber.js"; + +import { NetworkDetails } from "@shared/constants/stellar"; +import { getBlendEarnOptions, getBlendPools } from "@shared/api/helpers/blend"; +import { BlendCatalogPool } from "@shared/api/types/blend"; +import { getCombinedAssetListData } from "@shared/api/helpers/token-list"; +import { getBlendPoolId } from "@shared/constants/blend"; +import { getCanonicalFromAsset } from "@shared/helpers/stellar"; +import { + getCatalogAssetIdentity, + getCatalogIconKey, + getCatalogIssuer, + resolveEarnAssetIcons, +} from "popup/components/earn/helpers/earnAssetIcons"; +import { AppDispatch } from "popup/App"; +import { settingsSelector } from "popup/ducks/settings"; +import { + iconsSelector, + saveIconsForBalances, + tokensListsSelector, +} from "popup/ducks/cache"; +import { initialState, isError, reducer } from "helpers/request"; +import { isMainnet } from "helpers/stellar"; +import { CLASSIC_ASSET_DECIMALS } from "popup/helpers/soroban"; +import { + AppDataType, + NeedsReRoute, + useGetAppData, +} from "helpers/hooks/useGetAppData"; +import { AccountBalances, useGetBalances } from "helpers/hooks/useGetBalances"; +import { getBalanceByKey } from "popup/helpers/balance"; + +/** One row in the picker. */ +export interface EarnTokenOption { + /** The reserve's asset contract address (a SAC for every current reserve). */ + assetId: string; + code: string; + issuer?: string; + iconUrl?: string | null; + decimals: number; + /** Raw total in display units, "0" when the account holds none. */ + total: string; + /** + * Headline rate as a decimal fraction (0.1694 = 16.94%), or null when the + * pool oracle has no fresh price and the rate is genuinely unknown. + */ + apy: number | null; + poolId: string; +} + +export interface ResolvedEarnTokens { + type: AppDataType.RESOLVED; + publicKey: string; + networkDetails: NetworkDetails; + balances: AccountBalances; + /** Pool-supported assets the account holds — the "In your wallet" section. */ + held: EarnTokenOption[]; + /** + * Pool-supported assets at zero — the second section, headed "Other supported + * assets" when something is held and "Supported tokens" when nothing is. + */ + supported: EarnTokenOption[]; + pool: BlendCatalogPool | null; +} + +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, + initialState, + ); + const { fetchData: fetchAppData } = useGetAppData(); + const { fetchData: fetchBalances } = useGetBalances({ + showHidden: false, + includeIcons: true, + }); + const reduxDispatch = useDispatch(); + const { assetsLists } = useSelector(settingsSelector); + const cachedIcons = useSelector(iconsSelector); + const cachedTokenLists = useSelector(tokensListsSelector); + + const fetchData = async (useCache = false): Promise => { + dispatch({ type: "FETCH_DATA_START" }); + try { + const appData = await fetchAppData(useCache); + if (isError(appData)) { + throw new Error(appData.message); + } + + if (appData.type === AppDataType.REROUTE) { + dispatch({ type: "FETCH_DATA_SUCCESS", payload: appData }); + return appData; + } + + const publicKey = appData.account.publicKey; + const networkDetails = appData.settings.networkDetails; + + const balances = await fetchBalances( + publicKey, + isMainnet(networkDetails), + networkDetails, + useCache, + ); + if (isError(balances)) { + throw new Error(balances.message); + } + + const [earnOptions, pools] = await Promise.all([ + getBlendEarnOptions({ networkDetails }), + getBlendPools({ networkDetails }), + ]); + + // The backend's allowlist should already have narrowed this to the Fixed + // pool, but pin it to our own constant too: a drifted allowlist would + // otherwise silently offer deposits into a pool the flow never vetted. + const poolId = getBlendPoolId(networkDetails); + const pool = pools.find((p) => p.id === poolId) || null; + + const held: EarnTokenOption[] = []; + const supported: EarnTokenOption[] = []; + + earnOptions.forEach((option) => { + const offer = option.pools.find((p) => p.id === poolId); + if (!offer) { + return; + } + + const balance = getBalanceByKey( + option.assetId, + balances.balances, + networkDetails, + ); + const total = balance?.total ? new BigNumber(balance.total) : null; + const balanceIssuer = + balance && "token" in balance && "issuer" in balance.token + ? balance.token.issuer.key + : undefined; + + // A zero-balance reserve has no balance to take an issuer from; the + // catalog's `name` is the only other source. Without it the row can + // build no icon key at all. + const issuer = balanceIssuer || getCatalogIssuer(option.name); + + // `symbol` is null for native XLM on the live catalog (verified against + // dev), so it cannot be the only source of the display code — taking it + // alone renders that row with no token code at all. Fall back to the + // held balance's code, then to a truncated contract id, which is the + // same fallback the backend documents for unnamed pools. + // + // `name` is deliberately NOT a candidate: for classic assets the + // catalog returns the canonical there ("USDC:GA5ZSEJY…"), not a + // friendly name. + const balanceCode = + balance && "token" in balance ? balance.token.code : ""; + // Covers the two codes the catalog can imply rather than state: the code + // half of a classic asset's canonical, and "XLM" for native, which is + // reported with a null symbol *and* a null name and so is recognisable + // only by its SAC. Without it an account holding no XLM had no code to + // fall back on and the row rendered a truncated contract address. + const catalogCode = getCatalogAssetIdentity({ + symbol: option.symbol, + name: option.name, + assetId: option.assetId, + networkDetails, + }).code; + const code = + option.symbol || + balanceCode || + catalogCode || + `${option.assetId.slice(0, 4)}…`; + + // Icons are keyed by canonical. `balances.icons` only holds entries for + // assets the account actually has, so a zero-balance reserve resolves + // nothing here and gets its icon fetched below. Native is the exception: + // keyed "native" whether or not it is held, and its logo is bundled. + const canonical = getCatalogIconKey({ + code, + issuer, + assetId: option.assetId, + networkDetails, + }); + + const row: EarnTokenOption = { + assetId: option.assetId, + code, + issuer, + iconUrl: balances.icons?.[canonical], + decimals: option.decimals ?? CLASSIC_ASSET_DECIMALS, + total: total ? total.toFixed() : "0", + apy: headlineApy(offer.supplyApy, offer.emissionsSupplyApr), + poolId: offer.id, + }; + + if (total && total.gt(0)) { + held.push(row); + } else { + supported.push(row); + } + }); + + // `balances.icons` covers only what the account holds, so every + // zero-balance reserve would otherwise render AssetIcon's placeholder even + // on Mainnet, where the icon is perfectly resolvable. Resolve those + // through the exact path balances use — cached icons, then the verified + // token lists, then the issuer's TOML — by handing getAssetIcons a + // balance-shaped record per row. Bounded work: a pool has a handful of + // reserves, and results land in the icon cache for the next open. + const rowsMissingIcons = [...held, ...supported].filter( + (row) => !row.iconUrl && row.issuer, + ); + if (rowsMissingIcons.length) { + const assetsListsData = cachedTokenLists.length + ? cachedTokenLists + : await getCombinedAssetListData({ networkDetails, assetsLists }); + + const fetchedIcons = await resolveEarnAssetIcons({ + assets: rowsMissingIcons, + networkDetails, + cachedIcons, + assetsListsData, + }); + + rowsMissingIcons.forEach((row) => { + const canonicalKey = getCanonicalFromAsset(row.code, row.issuer!); + row.iconUrl = fetchedIcons[canonicalKey] || undefined; + }); + reduxDispatch(saveIconsForBalances({ icons: fetchedIcons })); + } + + const payload = { + type: AppDataType.RESOLVED, + publicKey, + networkDetails, + balances, + held, + supported, + pool, + } as EarnTokens; + + dispatch({ type: "FETCH_DATA_SUCCESS", payload }); + return payload; + } catch (error) { + dispatch({ type: "FETCH_DATA_ERROR", payload: error }); + throw new Error(`Failed to fetch earn tokens - ${error}`); + } + }; + + return { state, fetchData }; +} diff --git a/extension/src/popup/components/earn/EarnTokenPicker/index.tsx b/extension/src/popup/components/earn/EarnTokenPicker/index.tsx new file mode 100644 index 0000000000..75a2e62036 --- /dev/null +++ b/extension/src/popup/components/earn/EarnTokenPicker/index.tsx @@ -0,0 +1,305 @@ +import React, { useEffect, useState } from "react"; +import { Icon, Loader, Text } from "@stellar/design-system"; +import { useTranslation } from "react-i18next"; +import { Navigate } from "react-router-dom"; + +import BlendLogo from "popup/assets/blend-logo.svg"; +import EarnGlow from "popup/assets/earn-glow.svg"; +import { BalanceRow } from "popup/components/BalanceRow"; +import { SlideupModal } from "popup/components/SlideupModal"; +import { RequestState } from "constants/request"; +import { AppDataType } from "helpers/hooks/useGetAppData"; +import { newTabHref } from "helpers/urls"; +import { openTab } from "popup/helpers/navigate"; +import { NO_FIAT_VALUE, formatAmount } from "popup/helpers/formatters"; +import { trackEarnBalanceInsufficientShown } from "popup/metrics/earn"; + +import { NotEnoughTokenSheet } from "./NotEnoughTokenSheet"; +import { + getNotEnoughVariant, + hasSwappableBalance, + isOnrampableAsset, +} from "./helpers/getNotEnoughVariant"; +import { + EarnTokenOption, + ResolvedEarnTokens, + useGetEarnTokensData, +} from "./hooks/useGetEarnTokensData"; + +import "./styles.scss"; + +interface EarnTokenPickerProps { + onClose: () => void; + /** A token the account holds — proceeds to the amount screen. */ + onSelect: (option: EarnTokenOption, resolved: ResolvedEarnTokens) => void; + /** The user chose to swap into a token they hold none of. */ + onSwapRequested: ( + option: EarnTokenOption, + resolved: ResolvedEarnTokens, + ) => void; + /** Bumped by the caller after a swap so the list re-fetches its balances. */ + refreshKey?: number; +} + +/** Solid green pill showing the pool's headline rate for an asset. */ +const ApyBadge = ({ apy, code }: { apy: number | null; code: string }) => { + const { t } = useTranslation(); + + return ( +
+ {/* A null rate means no fresh oracle price — genuinely unknown, and + distinct from a rate that really is zero. */} + {apy === null + ? NO_FIAT_VALUE + : t("{{rate}}% APY", { + rate: formatAmount((apy * 100).toFixed(2)), + })} +
+ ); +}; + +/** + * The screen's chrome: close affordance, protocol badge and title. Shared by + * the loading and error states so neither shifts the header when the list + * resolves. + */ +const PickerShell = ({ + onClose, + children, + contentFooter, + "data-testid": dataTestId, +}: { + onClose: () => void; + children: React.ReactNode; + contentFooter?: React.ReactNode; + "data-testid": string; +}) => { + const { t } = useTranslation(); + + return ( +
+ + +
+ +
+ +
+
+ {/* Not translated: the protocol's own name. */} +
+ + + Blend + +
+ +
+ + {t("Choose an asset")} + +
+ + {t("Supply assets to Blend and earn variable yield.")} + +
+
+
+ + {children} +
+ + {contentFooter} +
+ ); +}; + +export const EarnTokenPicker = ({ + onClose, + onSelect, + onSwapRequested, + refreshKey = 0, +}: EarnTokenPickerProps) => { + const { t } = useTranslation(); + const { state, fetchData } = useGetEarnTokensData(); + const [notEnoughToken, setNotEnoughToken] = useState( + null, + ); + + useEffect(() => { + fetchData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [refreshKey]); + + // Onboarding guard, matching SwapAsset: a half-onboarded account gets sent + // back to where it left off rather than shown an empty picker. + if (state.data?.type === AppDataType.REROUTE) { + if (state.data.shouldOpenTab) { + openTab(newTabHref(state.data.routeTarget)); + window.close(); + } + return ; + } + + const isLoading = + state.state === RequestState.IDLE || state.state === RequestState.LOADING; + + if (isLoading) { + return ( + +
+ +
+
+ ); + } + + if (state.state === RequestState.ERROR) { + return ( + + + {t("We couldn’t load earnable tokens. Please try again.")} + + + ); + } + + const data = state.data as ResolvedEarnTokens; + + // A zero-balance token is excluded from `hasSwappableBalance` by its own + // canonical, but it holds none of it anyway, so the assetId is a safe stand-in + // for a canonical we would otherwise have to resolve from the SAC. + const resolveNotEnoughVariant = (option: EarnTokenOption) => + getNotEnoughVariant({ + isOnrampable: isOnrampableAsset(option.code, data.networkDetails), + isSwappable: hasSwappableBalance(data.balances.balances, option.assetId), + }); + + const openNotEnoughSheet = (option: EarnTokenOption) => { + // The same resolver the sheet renders from, so the reported variant can + // never disagree with the buttons the user was actually shown. + trackEarnBalanceInsufficientShown({ + assetCode: option.code, + variant: resolveNotEnoughVariant(option), + }); + setNotEnoughToken(option); + }; + + const renderRow = (option: EarnTokenOption, isHeld: boolean) => ( + } + onClick={() => + isHeld ? onSelect(option, data) : openNotEnoughSheet(option) + } + data-testid={`earn-token-row-${option.code}`} + /> + ); + + const renderSection = ( + title: string, + options: EarnTokenOption[], + isHeld: boolean, + ) => ( +
+
+ + {title} + +
+
+ {options.map((option) => renderRow(option, isHeld))} +
+
+ ); + + const notEnoughVariant = notEnoughToken + ? resolveNotEnoughVariant(notEnoughToken) + : null; + + const hasHeld = data.held.length > 0; + + return ( + <> + + + {t("APY may change based on protocol conditions.")} + +
+ } + > +
+ {hasHeld ? ( + renderSection(t("In your wallet"), data.held, true) + ) : ( +
+ + {t("No supported assets in your wallet")} + +
+ + {t("Add a supported asset to start earning.")} + +
+
+ )} + {data.supported.length > 0 && + renderSection( + // Nothing is held, so there is no "other" to be other than. + hasHeld ? t("Other supported assets") : t("Supported tokens"), + data.supported, + false, + )} +
+ + + { + if (!isOpen) { + setNotEnoughToken(null); + } + }} + hasBackdrop + > + {notEnoughToken && notEnoughVariant ? ( + setNotEnoughToken(null)} + onSwap={() => { + const option = notEnoughToken; + setNotEnoughToken(null); + onSwapRequested(option, data); + }} + /> + ) : ( +
+ )} + + + ); +}; diff --git a/extension/src/popup/components/earn/EarnTokenPicker/styles.scss b/extension/src/popup/components/earn/EarnTokenPicker/styles.scss new file mode 100644 index 0000000000..c672c84664 --- /dev/null +++ b/extension/src/popup/components/earn/EarnTokenPicker/styles.scss @@ -0,0 +1,323 @@ +@use "../../../styles/utils.scss" as *; + +// Figma: Freighter-Mobile 13717:332943 "Choose an asset". Structured like +// EarnIntro rather than View/SubviewHeader — the design's header is a bare +// close icon with the title in the body, which View's centred app header +// cannot express. +.EarnTokenPicker { + position: relative; + display: flex; + flex-direction: column; + height: 100%; + padding: pxToRem(24); + // Both axes, deliberately. The glow is absolutely positioned and its box runs + // past the bottom edge; with only `overflow-x: hidden`, `overflow-y` computes + // to `auto` and that bleed becomes 217px of phantom scroll under the pinned + // disclaimer. Nothing here scrolls but `&__content`. + overflow: hidden; + + // The same ellipse EarnIntro uses, at the design's 0.7 alpha and anchored to + // the bottom of the screen instead of the top: its centre sits 115px above + // the frame's safe bottom in the mock. + &__glow { + position: absolute; + left: 50%; + bottom: pxToRem(115); + transform: translate(-50%, 50%); + width: pxToRem(664); + height: pxToRem(664); + max-width: none; + opacity: 0.7; + pointer-events: none; + z-index: 0; + } + + &__header { + position: relative; + z-index: 1; + flex: 0 0 auto; + display: flex; + padding-bottom: pxToRem(24); + } + + &__close { + width: pxToRem(24); + height: pxToRem(24); + padding: 0; + border: none; + cursor: pointer; + background: transparent; + color: var(--sds-clr-gray-12); + display: flex; + align-items: center; + justify-content: center; + + svg { + width: pxToRem(24); + height: pxToRem(24); + } + } + + // Scrolls on its own so the disclaimer stays pinned, matching the mock where + // the footnote sits outside the list's frame. + &__content { + position: relative; + z-index: 1; + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: pxToRem(40); + + // View.Content hid its scrollbar and faded its scroll edge; this screen no + // longer goes through it, so both come along by hand. A mask rather than a + // background gradient, which would wipe the glow sitting behind the fade. + scrollbar-width: none; + -webkit-mask-image: linear-gradient( + to bottom, + #000 calc(100% - #{pxToRem(16)}), + transparent + ); + mask-image: linear-gradient( + to bottom, + #000 calc(100% - #{pxToRem(16)}), + transparent + ); + + &::-webkit-scrollbar { + display: none; + } + } + + &__intro { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: pxToRem(8); + } + + &__protocol { + display: flex; + align-items: center; + gap: pxToRem(4); + height: pxToRem(32); + padding: pxToRem(4) pxToRem(10); + border-radius: pxToRem(8); + background: var(--sds-clr-gray-03); + color: var(--sds-clr-gray-12); + } + + &__protocol-icon { + width: pxToRem(20); + height: pxToRem(20); + border-radius: pxToRem(4); + display: block; + flex: none; + } + + &__copy { + display: flex; + flex-direction: column; + gap: pxToRem(4); + } + + &__subtitle { + color: var(--sds-clr-gray-11); + + // Figma's Text/SM/400 is 14/20; SDS's Text--sm is 14/22. Outruns SDS's own + // rule, which inheritance cannot. + .Text { + line-height: pxToRem(20); + } + } + + // Stands in for the held section when the account holds none of the pool's + // reserves. Figma 13701:332629. + &__empty { + display: flex; + flex-direction: column; + gap: pxToRem(4); + color: var(--sds-clr-gray-12); + } + + &__empty-body { + color: var(--sds-clr-gray-11); + + .Text { + line-height: pxToRem(20); + } + } + + &__sections { + display: flex; + flex-direction: column; + gap: pxToRem(24); + } + + &__section { + display: flex; + flex-direction: column; + gap: pxToRem(12); + } + + &__section-title { + color: var(--sds-clr-gray-12); + } + + &__rows { + display: flex; + flex-direction: column; + gap: pxToRem(12); + } + + // The shared row supplies the structure the mock draws — icon, code over + // balance, right slot — so only the card treatment and type scale differ. + .BalanceRow { + padding: pxToRem(12) pxToRem(16); + border-radius: pxToRem(16); + background: var(--sds-clr-gray-03); + } + + .BalanceRow__code { + font-size: pxToRem(16); + line-height: pxToRem(24); + } + + .BalanceRow__amount { + font-size: pxToRem(14); + line-height: pxToRem(20); + } + + // 40px in the mock, against the shared component's 32. Its 1rem margin-right + // is already the mock's 16px icon-to-text gap, so no gap is added here. + .AccountAssets__asset--logo { + width: pxToRem(40); + height: pxToRem(40); + min-width: pxToRem(40); + + // The mock's 1px ring, drawn over the artwork rather than around it so the + // icon keeps its 40px box. + &::after { + content: ""; + position: absolute; + inset: 0; + border-radius: 50%; + border: 1px solid var(--sds-clr-gray-06); + pointer-events: none; + } + } + + &__apy { + background: var(--sds-clr-green-10); + color: var(--sds-clr-green-04); + border-radius: pxToRem(100); + padding: pxToRem(6) pxToRem(12); + font-size: pxToRem(14); + line-height: pxToRem(20); + font-weight: var(--sds-fw-medium); + white-space: nowrap; + } + + &__loader { + display: flex; + justify-content: center; + padding-top: pxToRem(48); + } + + &__disclaimer { + position: relative; + z-index: 1; + flex: 0 0 auto; + padding-top: pxToRem(24); + text-align: center; + color: var(--sds-clr-gray-11); + + .Text { + line-height: pxToRem(20); + } + } +} + +.NotEnoughTokenSheet { + padding: pxToRem(24); + display: flex; + flex-direction: column; + gap: pxToRem(8); + + &__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + } + + &__close { + width: pxToRem(32); + height: pxToRem(32); + border-radius: 50%; + border: none; + cursor: pointer; + background: var(--sds-clr-gray-04); + color: var(--sds-clr-gray-11); + display: flex; + align-items: center; + justify-content: center; + } + + &__title { + margin-top: pxToRem(8); + } + + &__body { + color: var(--sds-clr-gray-11); + margin-bottom: pxToRem(12); + } + + &__actions { + display: flex; + flex-direction: column; + gap: pxToRem(8); + } + + // Buy and Swap share the row evenly (152px each of 312 with an 8px gap in the + // design), so neither is sized by its label length. + &__actions-row { + display: flex; + gap: pxToRem(8); + + > * { + flex: 1; + min-width: 0; + } + } + + // A hairline either side of the word, matching the design's 20px divider row. + &__or { + display: flex; + align-items: center; + gap: pxToRem(8); + height: pxToRem(20); + color: var(--sds-clr-gray-11); + font-size: pxToRem(14); + + &::before, + &::after { + content: ""; + flex: 1; + height: 1px; + background: var(--sds-clr-gray-06); + } + } + + // The transfer route under two equal options is a text button, not a pill. + &__text-action { + height: pxToRem(36); + padding: 0; + border: none; + background: none; + cursor: pointer; + color: var(--sds-clr-gray-11); + font-size: pxToRem(14); + font-weight: var(--font-weight-medium); + } +} diff --git a/extension/src/popup/components/earn/PoolDetailsSheet/__tests__/PoolDetailsSheet.test.tsx b/extension/src/popup/components/earn/PoolDetailsSheet/__tests__/PoolDetailsSheet.test.tsx new file mode 100644 index 0000000000..403a19f994 --- /dev/null +++ b/extension/src/popup/components/earn/PoolDetailsSheet/__tests__/PoolDetailsSheet.test.tsx @@ -0,0 +1,57 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; + +import { TESTNET_NETWORK_DETAILS } from "@shared/constants/stellar"; +import { BlendCatalogPool } from "@shared/api/types/blend"; +import { PoolDetailsSheet } from "popup/components/earn/PoolDetailsSheet"; +import { BLEND_LENDING_DOCS_URL } from "popup/constants/externalLinks"; +import { openTab } from "popup/helpers/navigate"; +import { Wrapper } from "popup/__testHelpers__"; + +jest.mock("popup/helpers/navigate", () => ({ + ...jest.requireActual("popup/helpers/navigate"), + openTab: jest.fn(), +})); + +const pool: BlendCatalogPool = { + id: "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD", + name: "Fixed Pool v2", + status: "ACTIVE", + suppliedUsd: 50050000, + borrowedUsd: 16150000, + interestApy: 0.0424, + netApy: 0.1694, + backstopUsd: 1530000, + reserves: [], +}; + +const renderSheet = (onClose = jest.fn()) => + render( + + + , + ); + +describe("PoolDetailsSheet", () => { + afterEach(() => jest.clearAllMocks()); + + it("renders the pool-agnostic description for any pool", () => { + renderSheet(); + expect( + screen.getByText( + "Deposit supported assets into this Blend pool to earn yield. APY may change over time. Withdraw anytime.", + ), + ).toBeInTheDocument(); + }); + + it("opens Blend's lending docs in a new tab", () => { + renderSheet(); + fireEvent.click(screen.getByTestId("earn-pool-docs-link")); + expect(openTab).toHaveBeenCalledWith(BLEND_LENDING_DOCS_URL); + }); +}); diff --git a/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx b/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx new file mode 100644 index 0000000000..f088847999 --- /dev/null +++ b/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx @@ -0,0 +1,149 @@ +import React from "react"; +import { Button, Icon, Text } from "@stellar/design-system"; +import { useTranslation } from "react-i18next"; + +import { BlendCatalogPool } from "@shared/api/types/blend"; +import { PoolIcon } from "popup/components/earn/PoolIcon"; +import { BLEND_LENDING_DOCS_URL } from "popup/constants/externalLinks"; +import { openTab } from "popup/helpers/navigate"; +import { StatRow } from "popup/components/earn/StatRow"; +import { + formatCompactUsd, + formatRate, +} from "popup/components/earn/helpers/formatPoolStats"; + +import "./styles.scss"; + +interface PoolDetailsSheetProps { + pool: BlendCatalogPool; + onClose: () => void; +} + +/** + * Pool description and market stats, opened from the pool card on the amount + * screen. + * + * The Backstop row renders whatever the catalog reports, and "--" until a + * backend serves `backstop_usd` — the v2 backend drops the field its own + * upstream provides. Never a hardcoded figure, which would misrepresent the + * pool's actual insurance. + * + * The description is one string for every pool rather than a per-pool lookup: + * it describes what supplying to a Blend pool does, and makes no claim about a + * specific deployment. "View pool details" carries the pool-specific detail out + * to Blend's docs instead. + */ +export const PoolDetailsSheet = ({ pool, onClose }: PoolDetailsSheetProps) => { + const { t } = useTranslation(); + + return ( +
+
+ +
+ + {pool.name || t("Blend pool")} + + + {t("by Blend")} + +
+ +
+ +
+
+ + {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 new file mode 100644 index 0000000000..8396c121c2 --- /dev/null +++ b/extension/src/popup/components/earn/PoolDetailsSheet/styles.scss @@ -0,0 +1,106 @@ +@use "../../../styles/utils.scss" as *; + +.PoolDetailsSheet { + padding: pxToRem(24); + display: flex; + flex-direction: column; + gap: pxToRem(12); + + // The stat rows grow as the pool gains fields, and SlideupModal sizes itself + // to its content's scrollHeight — so without a cap here the sheet runs to the + // top of the popup with nothing to scroll. Capped like `.EarnReview--details`, + // with the header and the Close button pinned and only the body scrolling. + max-height: 85vh; + + &__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: pxToRem(12); + flex: none; + } + + &__body { + flex: 1; + // Without this a flex item refuses to shrink below its content height, and + // the overflow never engages. + min-height: 0; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: pxToRem(12); + } + + > .Button { + flex: none; + } + + &__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. + margin-right: auto; + } + + &__close { + flex: 0 0 auto; + width: pxToRem(32); + height: pxToRem(32); + border-radius: 50%; + border: none; + cursor: pointer; + background: var(--sds-clr-gray-04); + color: var(--sds-clr-gray-11); + display: flex; + align-items: center; + justify-content: center; + } + + &__description { + display: flex; + flex-direction: column; + // Figma 13722:341971 stacks the label, the copy and the link on one 8px + // gap. + gap: pxToRem(8); + color: var(--sds-clr-gray-11); + } + + // "Description" and "Pool Performance". Text/SM/500 in secondary, per the + // design — SDS's own `sm` line-height is 22px, so the 20px is set here. + &__section-title { + color: var(--sds-clr-gray-11); + line-height: pxToRem(20); + } + + // "View pool details", opening Blend's lending docs in a new tab. A button + // rather than an anchor: the popup navigates by `openTab`, and an + // would replace the popup's own document. + &__docs-link { + display: flex; + align-items: center; + align-self: flex-start; + gap: pxToRem(4); + padding: 0; + // The design hangs the link's breathing room underneath it, not above: + // the description block's 8px gap is all that separates it from the copy. + padding-bottom: pxToRem(8); + border: 0; + background: none; + color: var(--sds-clr-lilac-11); + font-size: pxToRem(14); + font-weight: var(--sds-fw-medium); + line-height: pxToRem(20); + cursor: pointer; + + svg { + width: pxToRem(16); + height: pxToRem(16); + } + } + + &__group { + background: var(--sds-clr-gray-03); + border-radius: pxToRem(12); + padding: 0 pxToRem(16); + } +} diff --git a/extension/src/popup/components/earn/PoolIcon/index.tsx b/extension/src/popup/components/earn/PoolIcon/index.tsx new file mode 100644 index 0000000000..d2af23bcb6 --- /dev/null +++ b/extension/src/popup/components/earn/PoolIcon/index.tsx @@ -0,0 +1,41 @@ +import React from "react"; + +import BlendLogo from "popup/assets/blend-logo.svg"; + +import "./styles.scss"; + +/** + * Protocols the Earn flow can show a pool for. Blend is the only one today, but + * keying the icon by protocol keeps the call sites from hardcoding its logo. + */ +export type EarnProtocol = "blend"; + +const PROTOCOL_LOGOS: Record = { + blend: BlendLogo, +}; + +interface PoolIconProps { + protocol?: EarnProtocol; +} + +/** + * The pool's protocol mark, shown wherever the flow depicts a pool: the deposit + * screen's pool card, the pool details sheet, and the review and terminal + * summaries. + * + * Bundled rather than fetched: the Blend catalog carries no icon field, and the + * only protocol icon URL the app has comes from the Discover `/protocols` + * response, which this flow never loads. Swap the map for that URL if a pool + * ever arrives carrying one. + * + * `alt` is empty by design — every site pairs this with the pool's name and + * "by ", so announcing the logo would only repeat them. + */ +export const PoolIcon = ({ protocol = "blend" }: PoolIconProps) => ( + +); diff --git a/extension/src/popup/components/earn/PoolIcon/styles.scss b/extension/src/popup/components/earn/PoolIcon/styles.scss new file mode 100644 index 0000000000..57f14c904d --- /dev/null +++ b/extension/src/popup/components/earn/PoolIcon/styles.scss @@ -0,0 +1,10 @@ +@use "../../../styles/utils.scss" as *; + +// The protocol marks carry their own colour, so no plate behind them — matching +// the design, where the pool icon sits directly on the card. +.PoolIcon { + width: pxToRem(32); + height: pxToRem(32); + flex: none; + display: block; +} diff --git a/extension/src/popup/components/earn/StatRow/index.tsx b/extension/src/popup/components/earn/StatRow/index.tsx new file mode 100644 index 0000000000..32d5b8d639 --- /dev/null +++ b/extension/src/popup/components/earn/StatRow/index.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import { Text } from "@stellar/design-system"; + +import "./styles.scss"; + +interface StatRowProps { + label: React.ReactNode; + /** A node rather than a string: several rows render a before -> after pair. */ + value: React.ReactNode; + /** Renders the value in green — a projected gain rather than a plain figure. */ + isPositive?: boolean; + testId?: string; +} + +/** + * A label/value line in one of Earn's stat cards, divided from the line above it. + * + * Shared by the review screen and the pool details sheet, which the design draws + * identically; they had the same markup and the same rules under two BEM + * prefixes before this. + */ +export const StatRow = ({ + label, + value, + isPositive = false, + testId, +}: StatRowProps) => ( +
+ + {label} + +
+ {value} +
+
+); diff --git a/extension/src/popup/components/earn/StatRow/styles.scss b/extension/src/popup/components/earn/StatRow/styles.scss new file mode 100644 index 0000000000..3fae36ec5a --- /dev/null +++ b/extension/src/popup/components/earn/StatRow/styles.scss @@ -0,0 +1,24 @@ +@use "../../../styles/utils.scss" as *; + +.EarnStatRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: pxToRem(12); + padding: pxToRem(14) 0; + color: var(--sds-clr-gray-11); + + & + & { + border-top: 1px solid var(--sds-clr-gray-06); + } + + &__value { + color: var(--sds-clr-gray-12); + font-weight: 500; + text-align: right; + + &--positive { + color: var(--sds-clr-green-11); + } + } +} diff --git a/extension/src/popup/components/earn/helpers/__tests__/earnAssetIcons.test.ts b/extension/src/popup/components/earn/helpers/__tests__/earnAssetIcons.test.ts new file mode 100644 index 0000000000..12f227af3a --- /dev/null +++ b/extension/src/popup/components/earn/helpers/__tests__/earnAssetIcons.test.ts @@ -0,0 +1,125 @@ +import { Asset, Networks } from "stellar-sdk"; + +import { NetworkDetails } from "@shared/constants/stellar"; +import { PUBLIC_SACS } from "@shared/constants/sac"; +import { + getCatalogAssetIdentity, + getCatalogIconKey, + getCatalogIssuer, +} from "../earnAssetIcons"; + +const USDC_ISSUER = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; +const networkDetails = { + networkPassphrase: Networks.PUBLIC, +} as NetworkDetails; + +describe("getCatalogIssuer", () => { + it("reads the issuer out of the catalog's canonical", () => { + expect(getCatalogIssuer(`USDC:${USDC_ISSUER}`)).toBe(USDC_ISSUER); + }); + + it("returns nothing for the null name native XLM reports", () => { + expect(getCatalogIssuer(null)).toBeUndefined(); + expect(getCatalogIssuer(undefined)).toBeUndefined(); + }); + + it("rejects a name that is not a canonical", () => { + // A friendly name, a bare code, and a canonical whose issuer half is junk. + expect(getCatalogIssuer("Stellar Lumens")).toBeUndefined(); + expect(getCatalogIssuer("USDC")).toBeUndefined(); + expect(getCatalogIssuer("USDC:not-a-key")).toBeUndefined(); + expect(getCatalogIssuer("A:B:C")).toBeUndefined(); + }); +}); + +describe("getCatalogIconKey", () => { + it("keys a classic asset by canonical", () => { + expect( + getCatalogIconKey({ + code: "USDC", + issuer: USDC_ISSUER, + assetId: PUBLIC_SACS.USDC!, + networkDetails, + }), + ).toBe(`USDC:${USDC_ISSUER}`); + }); + + it("recognises native XLM by its SAC when the catalog names it nothing", () => { + expect( + getCatalogIconKey({ + code: "", + assetId: Asset.native().contractId(Networks.PUBLIC), + networkDetails, + }), + ).toBe("native"); + }); + + it("has no key for an asset with neither issuer nor the native SAC", () => { + expect( + getCatalogIconKey({ + code: "", + assetId: PUBLIC_SACS.USDC!, + networkDetails, + }), + ).toBe(""); + }); +}); + +describe("getCatalogAssetIdentity", () => { + const USDC_SAC = PUBLIC_SACS.USDC!; + + // The reported bug: the live catalog gives native XLM a null symbol and a null + // name, so an account holding no XLM had nothing to name the row with and it + // rendered a truncated contract address instead. + it("names native XLM from its SAC when the catalog reports nothing", () => { + expect( + getCatalogAssetIdentity({ + symbol: null, + name: null, + assetId: Asset.native().contractId(Networks.PUBLIC), + networkDetails, + }), + ).toEqual({ + code: "XLM", + issuer: undefined, + canonical: "native", + isNative: true, + }); + }); + + it("prefers the reported symbol", () => { + const identity = getCatalogAssetIdentity({ + symbol: "USDC", + name: `USDC:${USDC_ISSUER}`, + assetId: USDC_SAC, + networkDetails, + }); + expect(identity.code).toBe("USDC"); + expect(identity.issuer).toBe(USDC_ISSUER); + expect(identity.canonical).toBe(`USDC:${USDC_ISSUER}`); + expect(identity.isNative).toBe(false); + }); + + it("falls back to the canonical's code half when the symbol is missing", () => { + const identity = getCatalogAssetIdentity({ + symbol: null, + name: `USDC:${USDC_ISSUER}`, + assetId: USDC_SAC, + networkDetails, + }); + expect(identity.code).toBe("USDC"); + expect(identity.canonical).toBe(`USDC:${USDC_ISSUER}`); + }); + + it("leaves an unnameable asset without a code or key", () => { + const identity = getCatalogAssetIdentity({ + symbol: null, + name: null, + assetId: USDC_SAC, + networkDetails, + }); + expect(identity.code).toBe(""); + expect(identity.canonical).toBe(""); + expect(identity.isNative).toBe(false); + }); +}); diff --git a/extension/src/popup/components/earn/helpers/__tests__/failureReasonCode.test.ts b/extension/src/popup/components/earn/helpers/__tests__/failureReasonCode.test.ts new file mode 100644 index 0000000000..d5388cfda5 --- /dev/null +++ b/extension/src/popup/components/earn/helpers/__tests__/failureReasonCode.test.ts @@ -0,0 +1,66 @@ +import { ErrorMessage } from "@shared/api/types"; +import { getFailureReasonCode } from "../failureReasonCode"; + +const asError = (error: unknown) => error as ErrorMessage | undefined; + +describe("getFailureReasonCode", () => { + it("prefers the operation result code", () => { + expect( + getFailureReasonCode( + asError({ + errorMessage: "Transaction failed", + response: { + extras: { + result_codes: { + transaction: "tx_failed", + operations: ["op_underfunded"], + }, + }, + }, + }), + ), + ).toBe("op_underfunded"); + }); + + it("falls back to the transaction result code", () => { + expect( + getFailureReasonCode( + asError({ + errorMessage: "Transaction failed", + response: { + extras: { result_codes: { transaction: "tx_insufficient_fee" } }, + }, + }), + ), + ).toBe("tx_insufficient_fee"); + }); + + it("scrubs addresses out of a message with no result codes", () => { + expect( + getFailureReasonCode( + asError({ + errorMessage: + "Account GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF declined", + }), + ), + ).toBe("Account G*** declined"); + }); + + it("reads a string reason out of a response body", () => { + // The soroban submit thunk rejects with the parsed body in `errorMessage`, + // so this field is an object at runtime however it is typed. + expect( + getFailureReasonCode(asError({ errorMessage: { error: "host_error" } })), + ).toBe("host_error"); + }); + + it("returns unknown rather than throwing on an unrecognised body", () => { + // The regression: scrubStrKeys called `.replace` on an object and threw, + // which inside the submit hook's try/catch meant the failure went + // unreported entirely. + expect( + getFailureReasonCode(asError({ errorMessage: { extras: { foo: 1 } } })), + ).toBe("unknown"); + expect(getFailureReasonCode(undefined)).toBe("unknown"); + }); +}); diff --git a/extension/src/popup/components/earn/helpers/__tests__/formatPoolStats.test.ts b/extension/src/popup/components/earn/helpers/__tests__/formatPoolStats.test.ts new file mode 100644 index 0000000000..8382627032 --- /dev/null +++ b/extension/src/popup/components/earn/helpers/__tests__/formatPoolStats.test.ts @@ -0,0 +1,51 @@ +import { formatCompactUsd, formatRate } from "../formatPoolStats"; + +describe("formatCompactUsd", () => { + it("renders millions compactly", () => { + expect(formatCompactUsd(50050000)).toBe("$50.05M"); + }); + + it("renders billions compactly", () => { + expect(formatCompactUsd(1530000000)).toBe("$1.53B"); + // The Backstop figure in the design. + expect(formatCompactUsd(1530000)).toBe("$1.53M"); + }); + + it("renders thousands compactly", () => { + expect(formatCompactUsd(16150)).toBe("$16.15K"); + }); + + it("renders small amounts in full", () => { + expect(formatCompactUsd(942.5)).toBe("$942.50"); + }); + + it("distinguishes an unavailable value from zero", () => { + // null means the pool oracle has no fresh price; 0 means the pool really + // holds nothing. Collapsing them would misreport an empty pool. + expect(formatCompactUsd(null)).toBe("--"); + expect(formatCompactUsd(0)).toBe("$0.00"); + }); + + it("groups thousands in the non-compact range", () => { + expect(formatCompactUsd(999.99)).toBe("$999.99"); + }); +}); + +describe("formatRate", () => { + it("converts a decimal fraction to a percentage", () => { + expect(formatRate(0.1694)).toBe("16.94%"); + }); + + it("distinguishes an unavailable rate from zero", () => { + expect(formatRate(null)).toBe("--"); + expect(formatRate(0)).toBe("0.00%"); + }); + + it("rounds to two places", () => { + expect(formatRate(0.042419)).toBe("4.24%"); + }); + + it("handles a rate above 100%", () => { + expect(formatRate(1.5)).toBe("150.00%"); + }); +}); diff --git a/extension/src/popup/components/earn/helpers/earnAssetIcons.ts b/extension/src/popup/components/earn/helpers/earnAssetIcons.ts new file mode 100644 index 0000000000..8c699f89c4 --- /dev/null +++ b/extension/src/popup/components/earn/helpers/earnAssetIcons.ts @@ -0,0 +1,137 @@ +import { Asset, StrKey } from "stellar-sdk"; + +import { getAssetIcons } from "@shared/api/internal"; +import { Balances } from "@shared/api/types/backend-api"; +import { AssetListResponse } from "@shared/constants/soroban/asset-list"; +import { NetworkDetails } from "@shared/constants/stellar"; +import { getCanonicalFromAsset } from "@shared/helpers/stellar"; + +/** + * The Blend catalog identifies every asset by contract address and reports a + * classic asset's canonical ("USDC:GA5ZSEJY…") in `name` — the only place an + * issuer is available without resolving the SAC. Guarded on shape so an + * unexpected `name` cannot produce a bogus canonical. + */ +export const getCatalogIssuer = (name?: string | null) => { + const parts = name ? name.split(":") : []; + return parts.length === 2 && StrKey.isValidEd25519PublicKey(parts[1]) + ? parts[1] + : undefined; +}; + +/** + * The key an asset's icon is cached under. Icons are keyed by canonical, so an + * asset with no resolvable issuer has no key and cannot carry an icon. + * + * Native XLM is reported with a null symbol *and* a null name, so its contract + * address is the only way to recognise it; its icon lives under "native". + */ +export const getCatalogIconKey = ({ + code, + issuer, + assetId, + networkDetails, +}: { + code: string; + issuer?: string; + assetId: string; + networkDetails: NetworkDetails; +}) => { + if (issuer && code) { + return getCanonicalFromAsset(code, issuer); + } + if (assetId === Asset.native().contractId(networkDetails.networkPassphrase)) { + return "native"; + } + return ""; +}; + +/** + * A catalog entry's display code, issuer and icon key. + * + * The code falls back through the reported symbol, the canonical's code half, + * and finally "XLM" for the native SAC — which the catalog reports with a null + * symbol and a null name, leaving its contract address as the only clue. + */ +export const getCatalogAssetIdentity = ({ + symbol, + name, + assetId, + networkDetails, +}: { + symbol?: string | null; + name?: string | null; + assetId: string; + networkDetails: NetworkDetails; +}) => { + const issuer = getCatalogIssuer(name); + const isNative = + assetId === Asset.native().contractId(networkDetails.networkPassphrase); + const code = + symbol || (issuer ? name!.split(":")[0] : "") || (isNative ? "XLM" : ""); + + return { + code, + issuer, + canonical: getCatalogIconKey({ code, issuer, assetId, networkDetails }), + isNative, + }; +}; + +export interface EarnIconAsset { + code: string; + issuer?: string; + assetId: string; +} + +/** + * Icons for catalog assets the account does not hold. + * + * `balances.icons` only covers held assets, so pool reserves and zero-balance + * earn options resolve nothing there. This runs them through the same path + * balances use — the icon cache, then the verified token lists, then the + * issuer's TOML — by handing getAssetIcons a balance-shaped record per asset. + * Bounded work: a pool has a handful of reserves. + * + * Returns a canonical-keyed map, including the `null`s getAssetIcons records for + * assets it checked and could not resolve. + */ +export const resolveEarnAssetIcons = async ({ + assets, + networkDetails, + cachedIcons, + assetsListsData, +}: { + assets: EarnIconAsset[]; + networkDetails: NetworkDetails; + cachedIcons: Record; + assetsListsData: AssetListResponse[]; +}) => { + const lookupBalances = assets.reduce((acc, asset) => { + const key = getCatalogIconKey({ ...asset, networkDetails }); + if (!key || key === "native") { + // Native's icon is bundled, and a keyless asset has nothing to look up. + return acc; + } + return { + ...acc, + [key]: { + token: { code: asset.code, issuer: { key: asset.issuer! } }, + contractId: asset.assetId, + }, + }; + }, {}); + + if (!Object.keys(lookupBalances).length) { + return {} as Record; + } + + // getAssetIcons only reads each entry's `token` and `contractId`; BalanceMap's + // required `native` key and its amount fields would be noise here. + return getAssetIcons({ + balances: lookupBalances as unknown as Balances, + networkDetails, + assetsListsData, + cachedIcons, + }); +}; diff --git a/extension/src/popup/components/earn/helpers/failureReasonCode.ts b/extension/src/popup/components/earn/helpers/failureReasonCode.ts new file mode 100644 index 0000000000..308d2d2194 --- /dev/null +++ b/extension/src/popup/components/earn/helpers/failureReasonCode.ts @@ -0,0 +1,43 @@ +import get from "lodash/get"; + +import { ErrorMessage } from "@shared/api/types"; +import { scrubStrKeys } from "helpers/stellarStrKey"; +import { getResultCodes } from "popup/helpers/parseTransaction"; + +/** + * `errorMessage` is typed as a string, but the soroban submit thunk rejects with + * the parsed response body in that field (`rejectWithValue({ errorMessage: + * response })`), so at runtime it can be an object — and `scrubStrKeys` would + * throw on one. Only a string reason is usable as a metric dimension anyway: + * stringifying a whole body would put unbounded cardinality on the event. + */ +const getErrorText = (error: ErrorMessage | undefined) => { + const { errorMessage } = error || {}; + if (typeof errorMessage === "string") { + return errorMessage; + } + const nested = get(errorMessage, "error") || get(errorMessage, "message"); + return typeof nested === "string" ? nested : undefined; +}; + +/** + * The `reason_code` for a failed deposit: the operation or transaction result + * code when the attempt reached the network, otherwise the error message — a + * rejected signature never gets result codes, and "unknown" for every one of + * those would collapse the failure modes we most want to tell apart. Scrubbed, + * because an error message can quote an address. + * + * Shared, because `earn.deposit_failed` has two emitters: the submit hook owns + * the failures of its own thunks (its closure outlives the screen, so it still + * reports after the user closes it), and the Earn view owns everything that + * fails before the flow reaches that screen. + */ +export const getFailureReasonCode = (error: ErrorMessage | undefined) => { + const resultCodes = getResultCodes(error); + return ( + resultCodes.operations?.[0] || + resultCodes.transaction || + scrubStrKeys(getErrorText(error)) || + "unknown" + ); +}; diff --git a/extension/src/popup/components/earn/helpers/formatPoolStats.ts b/extension/src/popup/components/earn/helpers/formatPoolStats.ts new file mode 100644 index 0000000000..9d605b965a --- /dev/null +++ b/extension/src/popup/components/earn/helpers/formatPoolStats.ts @@ -0,0 +1,43 @@ +import BigNumber from "bignumber.js"; + +import { NO_FIAT_VALUE } from "popup/helpers/formatters"; + +/** + * Compact USD for pool-scale figures — "$50.05M" rather than "$50,050,000". + * + * Returns "--" for null, which means the pool oracle has no fresh price. That + * is distinct from a real zero, which formats as "$0.00". + */ +export const formatCompactUsd = (value: number | null): string => { + if (value === null) { + return NO_FIAT_VALUE; + } + + const amount = new BigNumber(value); + const abs = amount.abs(); + + const units: [BigNumber, string][] = [ + [new BigNumber(1e9), "B"], + [new BigNumber(1e6), "M"], + [new BigNumber(1e3), "K"], + ]; + + const unit = units.find(([threshold]) => abs.gte(threshold)); + if (!unit) { + return `$${amount.toFormat(2)}`; + } + + const [divisor, suffix] = unit; + return `$${amount.dividedBy(divisor).toFormat(2)}${suffix}`; +}; + +/** + * A rate as a percentage — 0.1694 becomes "16.94%". + * + * Null means no fresh oracle price and renders "--"; a genuine zero renders + * "0.00%". Never conflate the two. + */ +export const formatRate = (rate: number | null): string => + rate === null + ? NO_FIAT_VALUE + : `${new BigNumber(rate).times(100).toFormat(2)}%`; diff --git a/extension/src/popup/components/send/SendAmount/index.tsx b/extension/src/popup/components/send/SendAmount/index.tsx index 9fcb17d477..d6bda51340 100644 --- a/extension/src/popup/components/send/SendAmount/index.tsx +++ b/extension/src/popup/components/send/SendAmount/index.tsx @@ -68,6 +68,11 @@ import { SlideupModal } from "popup/components/SlideupModal"; import { MemoEditingContext } from "popup/constants/send-payment"; import { AmountCard } from "popup/components/amount/AmountCard"; import { PercentageButtons } from "popup/components/amount/PercentageButtons"; +import { getPercentageAmount } from "popup/components/amount/helpers/percentageAmount"; +import { + DEFAULT_AMOUNT, + DEFAULT_AMOUNT_USD, +} from "popup/components/amount/constants"; import { checkIsMuxedSupported, getMemoDisabledState, @@ -535,8 +540,8 @@ export const SendAmount = ({ const goBackAction = () => { dispatch(saveAsset("native")); dispatch(saveIsToken(false)); - dispatch(saveAmount("0")); - dispatch(saveAmountUsd("0.00")); + dispatch(saveAmount(DEFAULT_AMOUNT)); + dispatch(saveAmountUsd(DEFAULT_AMOUNT_USD)); // Clear any manually-saved fee so the next send session always starts from // the simulated base fee rather than a stale override. dispatch(saveTransactionFee("")); @@ -575,10 +580,11 @@ export const SendAmount = ({ // Always a fraction of the crypto available balance, so the committed // amount is identical in crypto and fiat display. In fiat mode the fiat // field mirrors it (rounded to cents) for display only. - const pctAmount = new BigNumber(cleanAmount(availableBalance)) - .multipliedBy(new BigNumber(pct).dividedBy(100)) - .decimalPlaces(assetDecimals) - .toString(); + const pctAmount = getPercentageAmount({ + availableBalance: cleanAmount(availableBalance), + pct, + decimals: assetDecimals, + }); formik.setFieldValue("amount", pctAmount); dispatch(saveAmount(pctAmount)); if (inputType === "fiat" && assetPrice) { diff --git a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.layout.test.tsx b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.layout.test.tsx index 8f0b31363a..57b3cb22f4 100644 --- a/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.layout.test.tsx +++ b/extension/src/popup/components/swap/SwapAmount/__tests__/SwapAmount.layout.test.tsx @@ -77,4 +77,57 @@ describe("SwapAmount layout", () => { expect(chevron.compareDocumentPosition(receive) & following).toBeTruthy(); expect(receive.compareDocumentPosition(pct) & following).toBeTruthy(); }); + + it("offers a receive-asset picker by default", () => { + render( + + + , + ); + + const receive = screen.getByTestId("swap-receive-card"); + expect( + receive.querySelector("[data-testid='send-amount-edit-dest-asset']"), + ).not.toBeNull(); + }); + + // The Earn swap pins the receive token, chosen on the picker before it. The + // pill must be a label, not a control: a dropdown that silently does nothing + // reads as broken, and one that works would buy a token the pool rejects. + it("renders the receive asset as a label when the destination is locked", () => { + render( + + + , + ); + + const receive = screen.getByTestId("swap-receive-card"); + expect( + receive.querySelector("[data-testid='send-amount-edit-dest-asset']"), + ).toBeNull(); + expect( + receive.querySelector("[data-testid='send-amount-dest-asset-locked']"), + ).not.toBeNull(); + // The sell side keeps its picker. + expect( + screen + .getByTestId("swap-sell-card") + .querySelector("[data-testid='send-amount-edit-dest-asset']"), + ).not.toBeNull(); + }); }); diff --git a/extension/src/popup/components/swap/SwapAmount/helpers/__tests__/swapAmountHelpers.test.ts b/extension/src/popup/components/swap/SwapAmount/helpers/__tests__/swapAmountHelpers.test.ts index 0387ff17ca..ee4a218b76 100644 --- a/extension/src/popup/components/swap/SwapAmount/helpers/__tests__/swapAmountHelpers.test.ts +++ b/extension/src/popup/components/swap/SwapAmount/helpers/__tests__/swapAmountHelpers.test.ts @@ -3,7 +3,7 @@ import { validateSwapAmount } from "../swapAmountValidation"; import { getAmountFontSizeClass, buildFiatLineText, -} from "../swapAmountDisplay"; +} from "popup/components/amount/helpers/amountDisplay"; describe("validateSwapAmount", () => { it("returns null for a valid amount", () => { diff --git a/extension/src/popup/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index bda326ef96..1973d112b2 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -57,7 +57,7 @@ import { validateSwapAmount } from "./helpers/swapAmountValidation"; import { getAmountFontSizeClass, buildFiatLineText, -} from "./helpers/swapAmountDisplay"; +} from "popup/components/amount/helpers/amountDisplay"; import { getAvailableBalanceFontSizePx } from "popup/components/amount/fontScale"; import { useSwapQuoteExpiry } from "./hooks/useSwapQuoteExpiry"; import { useSwapDestinationScan } from "./hooks/useSwapDestinationScan"; @@ -66,6 +66,11 @@ import { settingsNetworkDetailsSelector } from "popup/ducks/settings"; import { SlideupModal } from "popup/components/SlideupModal"; import { AmountCard } from "popup/components/amount/AmountCard"; import { PercentageButtons } from "popup/components/amount/PercentageButtons"; +import { + DEFAULT_AMOUNT, + DEFAULT_AMOUNT_USD, +} from "popup/components/amount/constants"; +import { getPercentageAmount } from "popup/components/amount/helpers/percentageAmount"; import { shouldShowXlmReservePreflight } from "popup/helpers/xlmReserve"; import { horizonGetBestReceivePath } from "popup/helpers/horizonGetBestPath"; import { XlmReserveSheet } from "popup/components/swap/XlmReserveSheet"; @@ -75,8 +80,6 @@ import { EditSlippage } from "./EditSlippage"; import "./styles.scss"; // Canonical "zero" values for the swap amount and its USD equivalent. -const DEFAULT_AMOUNT = "0"; -const DEFAULT_AMOUNT_USD = "0.00"; interface SwapAmountProps { inputType: InputType; @@ -85,6 +88,20 @@ interface SwapAmountProps { goToNext: () => void; goToEditSrc: () => void; goToEditDst: () => void; + /** + * Pins the receive side: the token was chosen on the screen before (the Earn + * deposit picker), and changing it here would buy something the pool does not + * accept. The pill renders as a label rather than a dead dropdown, and the + * direction toggle becomes decorative — flipping would move the pinned token + * to the sell side. + */ + isDestinationLocked?: boolean; + /** + * Laid out for a bottom sheet rather than a screen (the Earn swap branch): + * the host sheet owns the dismiss X, so the header's left slot carries the + * settings control that would otherwise sit above the CTA. + */ + isSheetLayout?: boolean; } export const SwapAmount = ({ @@ -93,6 +110,8 @@ export const SwapAmount = ({ goBack, goToNext, goToEditSrc, + isDestinationLocked = false, + isSheetLayout = false, goToEditDst, }: SwapAmountProps) => { const { t } = useTranslation(); @@ -490,21 +509,43 @@ export const SwapAmount = ({ <> {t("Swap")}} - hasBackButton + hasBackButton={!isSheetLayout} customBackAction={goBack} + leftButton={ + isSheetLayout ? ( + + ) : undefined + } /> -
-
- - {t("Fee")}: - - {/* The network fee is always denominated in XLM, regardless of - whether the amount is being entered in crypto or fiat. */} - {`${fee} XLM`} -
+
+ {/* In a sheet the fee readout and the settings button move out: + the fee is shown on the review sheet and edited from the header, + which leaves slippage as the only footer control. */} + {!isSheetLayout && ( +
+ + {t("Fee")}: + + {/* The network fee is always denominated in XLM, regardless of + whether the amount is being entered in crypto or fiat. */} + {`${fee} XLM`} +
+ )}
- + {!isSheetLayout && ( + + )}
+ {/* The seam and its notch are part of the card pair's shape, so + they render either way; with the destination pinned there is + simply nothing to press. */}
- + {isDestinationLocked ? ( + + ) : ( + + )}
void; goBack: () => void; + /** + * Defaults to an X, which is what dismissing the picker means on the Swap + * route. A flow that reaches the picker from a screen the user should land + * back on — the Earn swap sheet — passes an arrow instead. + */ + backIcon?: React.ReactNode; } export const SwapAsset = ({ @@ -51,6 +57,7 @@ export const SwapAsset = ({ hiddenAssets, onClickAsset, goBack, + backIcon = , }: SwapAssetProps) => { const { t } = useTranslation(); const networkDetails = useSelector(settingsNetworkDetailsSelector); @@ -272,7 +279,7 @@ export const SwapAsset = ({ title={{title}} hasBackButton customBackAction={goBack} - customBackIcon={} + customBackIcon={backIcon} /> diff --git a/extension/src/popup/components/swap/hooks/useSwapSubmitQuoteExpiry.ts b/extension/src/popup/components/swap/hooks/useSwapSubmitQuoteExpiry.ts new file mode 100644 index 0000000000..d9b2b7a15a --- /dev/null +++ b/extension/src/popup/components/swap/hooks/useSwapSubmitQuoteExpiry.ts @@ -0,0 +1,63 @@ +import { useEffect } from "react"; +import { useDispatch, useSelector } from "react-redux"; + +import { ActionStatus } from "@shared/api/types"; +import { AppDispatch } from "popup/App"; +import { emitMetric } from "helpers/metrics"; +import { METRIC_NAMES } from "popup/constants/metricsNames"; +import { getAssetFromCanonical } from "helpers/stellar"; +import { getQuoteExpiredOperationCodes } from "popup/helpers/quoteExpiry"; +import { + resetSubmitStatus, + transactionSubmissionSelector, +} from "popup/ducks/transactionSubmission"; + +/** + * Recovers from a quote that expired between review and submit + * (op_under_dest_min / op_too_few_offers) by returning to the amount screen + * with a fresh quote, instead of dead-ending in SubmitFail. + * + * Extracted from the Swap route so the Earn flow's embedded swap gets the same + * recovery — a swap that dead-ends there would strand the user mid-deposit. Both + * callers host the swap steps rather than being SwapAmount itself, which is why + * this sits beside the swap components rather than inside one of them. + * + * Returns `isQuoteExpiredAtSubmit` so the caller can render nothing on the + * frame the recovery runs, keeping SubmitFail from flashing before the step + * changes. + */ +export const useSwapSubmitQuoteExpiry = ({ + onRecover, +}: { + onRecover: () => void; +}) => { + const dispatch = useDispatch(); + const submission = useSelector(transactionSubmissionSelector); + const { transactionData } = submission; + + const isQuoteExpiredAtSubmit = + submission.submitStatus === ActionStatus.ERROR && + submission.isSwapQuoteExpired; + + useEffect(() => { + if (!isQuoteExpiredAtSubmit) { + return; + } + // Amounts intentionally dropped (parity with swap.completed/failed, which + // carry no amounts). Assets are bare codes (getAssetFromCanonical) so + // from_asset_code/to_asset_code match mobile rather than being canonical ids. + emitMetric(METRIC_NAMES.swapQuoteExpired, { + from_asset_code: getAssetFromCanonical(transactionData.asset).code, + to_asset_code: getAssetFromCanonical(transactionData.destinationAsset) + .code, + result_code: getQuoteExpiredOperationCodes(submission.error).join(", "), + }); + // Clear only the ERROR status (keep the transaction data + the + // isSwapQuoteExpired flag, which drives the amount-screen notification). + dispatch(resetSubmitStatus()); + onRecover(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isQuoteExpiredAtSubmit]); + + return { isQuoteExpiredAtSubmit }; +}; diff --git a/extension/src/popup/constants/__tests__/metricsNames.test.ts b/extension/src/popup/constants/__tests__/metricsNames.test.ts index 65f067493e..b7c7bc6add 100644 --- a/extension/src/popup/constants/__tests__/metricsNames.test.ts +++ b/extension/src/popup/constants/__tests__/metricsNames.test.ts @@ -30,7 +30,9 @@ describe("METRIC_NAMES domain-event catalog", () => { it("names swap events (routed/path-payment outcomes settle here too)", () => { expect(METRIC_NAMES.swapPickerOpened).toBe("swap.picker_opened"); expect(METRIC_NAMES.swapSourceSelected).toBe("swap.source_selected"); - expect(METRIC_NAMES.swapDestinationSelected).toBe("swap.destination_selected"); + expect(METRIC_NAMES.swapDestinationSelected).toBe( + "swap.destination_selected", + ); expect(METRIC_NAMES.swapDirectionToggled).toBe("swap.direction_toggled"); expect(METRIC_NAMES.swapTrustlineAdded).toBe("swap.trustline_added"); expect(METRIC_NAMES.swapXlmReserveInsufficientShown).toBe( @@ -41,6 +43,28 @@ describe("METRIC_NAMES domain-event catalog", () => { expect(METRIC_NAMES.swapFailed).toBe("swap.failed"); }); + it("names the earn deposit funnel", () => { + expect(METRIC_NAMES.earnTokenSelected).toBe("earn.token_selected"); + expect(METRIC_NAMES.earnBalanceInsufficientShown).toBe( + "earn.balance_insufficient_shown", + ); + expect(METRIC_NAMES.earnFundingActionSelected).toBe( + "earn.funding_action_selected", + ); + expect(METRIC_NAMES.earnSwapCompleted).toBe("earn.swap_completed"); + expect(METRIC_NAMES.earnPoolDetailsOpened).toBe("earn.pool_details_opened"); + expect(METRIC_NAMES.earnMaxAmountSelected).toBe("earn.max_amount_selected"); + expect(METRIC_NAMES.earnXlmFeeInsufficientShown).toBe( + "earn.xlm_fee_insufficient_shown", + ); + expect(METRIC_NAMES.earnSimulationFailed).toBe("earn.simulation_failed"); + expect(METRIC_NAMES.earnDepositCompleted).toBe("earn.deposit_completed"); + expect(METRIC_NAMES.earnDepositFailed).toBe("earn.deposit_failed"); + expect(METRIC_NAMES.earnDepositDismissed).toBe( + "earn.deposit_processing_dismissed", + ); + }); + it("names collectible-send and transaction-submission events", () => { expect(METRIC_NAMES.collectibleSendCompleted).toBe( "collectible_send.completed", @@ -106,8 +130,12 @@ describe("METRIC_NAMES domain-event catalog", () => { expect(METRIC_NAMES.signingAuthEntryFailed).toBe( "signing.auth_entry_failed", ); - expect(METRIC_NAMES.signingMessageApproved).toBe("signing.message_approved"); - expect(METRIC_NAMES.signingMessageRejected).toBe("signing.message_rejected"); + expect(METRIC_NAMES.signingMessageApproved).toBe( + "signing.message_approved", + ); + expect(METRIC_NAMES.signingMessageRejected).toBe( + "signing.message_rejected", + ); expect(METRIC_NAMES.signingMessageFailed).toBe("signing.message_failed"); }); diff --git a/extension/src/popup/constants/earn.ts b/extension/src/popup/constants/earn.ts new file mode 100644 index 0000000000..027422f031 --- /dev/null +++ b/extension/src/popup/constants/earn.ts @@ -0,0 +1,77 @@ +/** + * Marks a shared screen as having been reached from Earn, via + * `?flow=`. Lets the wallet-address screen carry the titled + * chrome the Earn design asks for without changing its other five callers. + * + * The key is a constant so the screens that append it and the screens that read + * it cannot drift apart — a renamed key would otherwise silently stop matching. + */ +export const FLOW_QUERY_KEY = "flow"; + +export const EARN_FLOW_PARAM = "earn"; + +export const EARN_FLOW_QUERY = `?${FLOW_QUERY_KEY}=${EARN_FLOW_PARAM}`; + +/** Was this screen reached from the Earn flow? Takes a `location.search`. */ +export const isEarnFlowSearch = (search: string) => + new URLSearchParams(search).get(FLOW_QUERY_KEY) === EARN_FLOW_PARAM; + +/** + * Steps in the Earn deposit flow. + * + * Modelled on Send (`constants/send-payment.ts`) rather than Swap: every visited + * step stays mounted and inactive ones are hidden, because the token picker owns + * a fetched list and scroll position, the amount screen owns formik state, and + * the swap branch has to return to CHOOSE_TOKEN without a remount flash. + * + * Screens that are transient overlays are NOT steps — the pool details sheet, + * the "not enough X" sheet, the network-fee sheet, the review sheet and the + * whole swap branch are sheets owned by their host step, so that step's state + * survives them. This mirrors how SendAmount hosts EditSettings and + * ReviewTransaction. + */ +export enum STEPS { + /** One-time interstitial; skipped once the user has seen it. */ + INTRO = "earn-intro", + CHOOSE_TOKEN = "earn-choose-token", + AMOUNT = "earn-amount", + /** Both "Depositing" and "Deposited!" — loading vs success of one screen. */ + DEPOSIT_CONFIRM = "earn-deposit-confirm", +} + +/** + * Sub-steps of the swap branch, owned by EarnSwap rather than the Earn view. + * + * Keeping these out of STEPS means CHOOSE_TOKEN stays the active step with the + * swap sheet over it, the mount-all loop stays cheap, and the sub-state resets + * for free on unmount. + */ +export enum EARN_SWAP_STEPS { + AMOUNT = "earn-swap-amount", + SET_FROM_ASSET = "earn-swap-from-asset", + SWAP_CONFIRM = "earn-swap-confirm", +} + +/** + * Which button set the "Not enough X" sheet shows. Driven by whether the asset + * can be bought via the onramp and whether the account holds anything swappable. + * `TRANSFER_ONLY` is not in the designs but is reachable — a funded account with + * only the target asset's siblings unavailable, on a non-onrampable asset. + */ +export enum NotEnoughVariant { + SWAP_OR_TRANSFER = "swap-or-transfer", + BUY_OR_TRANSFER = "buy-or-transfer", + BUY_SWAP_OR_TRANSFER = "buy-swap-or-transfer", + TRANSFER_ONLY = "transfer-only", +} + +/** + * Assets the Coinbase onramp can sell, by code. + * + * `useGetOnrampToken` builds `pay.coinbase.com/buy/select-asset?...&defaultAsset=` + * from the code, so an unlisted asset produces a dead-end page rather than an + * error. Freighter only ever passed "XLM" before Earn. EURC is deliberately + * absent — it is not Coinbase-listed, which is why the designs show no Buy + * button on the EURC sheet. + */ +export const EARN_ONRAMP_ASSETS = new Set(["XLM", "USDC"]); diff --git a/extension/src/popup/constants/externalLinks.ts b/extension/src/popup/constants/externalLinks.ts index e5b8812e61..06145ca9e7 100644 --- a/extension/src/popup/constants/externalLinks.ts +++ b/extension/src/popup/constants/externalLinks.ts @@ -5,3 +5,9 @@ export const STELLAR_DOCS_CREATE_ACCOUNT_URL = // "How much XLM do I need in my wallet?" help article. export const XLM_RESERVE_HELP_URL = "https://help.freighter.app/article/xjlva9dxov-how-much-xlm-do-i-need-in-my-wallet"; + +// "View pool details" on the Earn pool sheet. Blend's own lending guide rather +// than a per-pool page: the docs have no per-deployment route, and the sheet +// links out to explain what supplying to a pool means, not to describe one pool. +export const BLEND_LENDING_DOCS_URL = + "https://docs.blend.capital/users/lending-borrowing/lending"; diff --git a/extension/src/popup/constants/metricsNames.ts b/extension/src/popup/constants/metricsNames.ts index 0758f417a5..fd30e17802 100644 --- a/extension/src/popup/constants/metricsNames.ts +++ b/extension/src/popup/constants/metricsNames.ts @@ -38,6 +38,38 @@ export const METRIC_NAMES = { swapCompleted: "swap.completed", swapFailed: "swap.failed", + // -- Earn ---------------------------------------------------------------- + // In funnel order. The stage-to-stage drop-off is read from `screen.viewed` + // (earn_intro → earn_select_token → earn_amount → earn_review → + // earn_processing → earn_success); the events below carry what a screen name + // cannot — which asset and pool, which remedy an unfunded user picked, and why + // an attempt ended. Emitted through popup/metrics/earn.ts, never inline. + earnTokenSelected: "earn.token_selected", + // A supported token the account holds none of was tapped; `variant` is the set + // of remedies the sheet actually offered. + earnBalanceInsufficientShown: "earn.balance_insufficient_shown", + // Which remedy was chosen on that sheet: buy | swap | transfer. + earnFundingActionSelected: "earn.funding_action_selected", + // 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", + earnPoolDetailsOpened: "earn.pool_details_opened", + // 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. + earnMaxAmountSelected: "earn.max_amount_selected", + // The deposit cannot cover its own network fee; `reason` separates an account + // with no spendable XLM from an amount that leaves less than the resource fee. + earnXlmFeeInsufficientShown: "earn.xlm_fee_insufficient_shown", + earnSimulationFailed: "earn.simulation_failed", + earnDepositCompleted: "earn.deposit_completed", + earnDepositFailed: "earn.deposit_failed", + // The in-flight screen was closed before the submission settled. The deposit + // itself is not abandoned — it was already submitted — so a completed/failed + // event for the same attempt normally still follows. Only closing the popup + // outright loses the outcome. + earnDepositDismissed: "earn.deposit_processing_dismissed", + // -- Collectibles -------------------------------------------------------- collectibleSendCompleted: "collectible_send.completed", collectibleSendFailed: "collectible_send.failed", @@ -81,7 +113,8 @@ export const METRIC_NAMES = { "onboarding.recovery_phrase_confirm_failed", // Not emitted on extension: the create-account recovery-phrase screens have // no Back affordance to instrument. Mobile emits it; kept for a shared catalog. - onboardingRecoveryPhraseBackClicked: "onboarding.recovery_phrase_back_clicked", + onboardingRecoveryPhraseBackClicked: + "onboarding.recovery_phrase_back_clicked", onboardingCompleted: "onboarding.completed", // -- Account recovery / management -------------------------------------- diff --git a/extension/src/popup/constants/routes.ts b/extension/src/popup/constants/routes.ts index 8431c95a71..eb135bd040 100644 --- a/extension/src/popup/constants/routes.ts +++ b/extension/src/popup/constants/routes.ts @@ -11,6 +11,7 @@ export enum ROUTES { accountHistory = "/account-history", sendPayment = "/account/sendPayment", swap = "/swap", + earn = "/earn", addAccount = "/add-account", addToken = "/add-token", signTransaction = "/sign-transaction", diff --git a/extension/src/popup/ducks/__tests__/remoteConfig.test.ts b/extension/src/popup/ducks/__tests__/remoteConfig.test.ts index 9c61685bdf..e81ed77818 100644 --- a/extension/src/popup/ducks/__tests__/remoteConfig.test.ts +++ b/extension/src/popup/ducks/__tests__/remoteConfig.test.ts @@ -7,6 +7,7 @@ import { isRemoteConfigInitializedSelector, tokenPricesV2Selector, balancesV2Selector, + earnDepositSelector, reducer, } from "../remoteConfig"; import { @@ -424,4 +425,29 @@ describe("remoteConfig selectors", () => { await store.dispatch(fetchFeatureFlags()); expect(balancesV2Selector(store.getState())).toBe(false); }); + + it("earnDepositSelector defaults to false", () => { + const store = makeStore(); + expect(earnDepositSelector(store.getState())).toBe(false); + }); + + it("earnDepositSelector stays false when Amplitude omits the flag", async () => { + (getExperimentClient as jest.Mock).mockReturnValue(makeClient()); + + const store = makeStore(); + await store.dispatch(fetchFeatureFlags()); + expect(earnDepositSelector(store.getState())).toBe(false); + }); + + it("earnDepositSelector turns true when the variant is on", async () => { + (getExperimentClient as jest.Mock).mockReturnValue( + makeClient({ + earn_deposit: { value: "on" }, + }), + ); + + const store = makeStore(); + await store.dispatch(fetchFeatureFlags()); + expect(earnDepositSelector(store.getState())).toBe(true); + }); }); diff --git a/extension/src/popup/ducks/earn.ts b/extension/src/popup/ducks/earn.ts new file mode 100644 index 0000000000..f39f636f23 --- /dev/null +++ b/extension/src/popup/ducks/earn.ts @@ -0,0 +1,123 @@ +import { createSlice } from "@reduxjs/toolkit"; + +import { BlendCatalogPool } from "@shared/api/types/blend"; + +/** + * Earn-domain state that has no home in `transactionSubmission`. + * + * The deposit's transaction-shaped state (asset, amount, destination, fee, + * simulation, submit status) deliberately lives in `transactionSubmission` + * instead: every terminal component the flow reuses — TransactionConfirm, + * SendingTransaction, SubmitFail, ReviewTx — reads that slice directly, and the + * swap branch *is* the Swap components, so it has to use it regardless. + * + * What lands here is what must survive `resetSubmission()` or has no equivalent + * field there. + */ +export interface EarnState { + /** Catalog entry for the allowlisted pool; null until the fetch resolves. */ + pool: BlendCatalogPool | null; + /** + * The chosen asset's contract address (its SAC). Captured at pick time rather + * than re-derived from the canonical later: the pool addresses reserves by + * contract id, and the deposit's Request carries that address, not the code. + */ + selectedAssetId: string; + /** + * The chosen asset's headline rate (supply APY + emissions APR) as a decimal + * fraction, or null when the oracle has no fresh price. Carried from the token + * picker so the amount ribbon and review row don't re-derive it. + */ + selectedAssetApy: number | null; + /** + * The account's existing balance in the pool for the chosen asset, in raw + * token units — the "before" side of Review's `0.00 -> 500.00`. Defaults to + * "0", which is also what a fetch failure falls back to. + */ + currentPositionTokens: string; + /** null until the background flag has been read; avoids flashing the intro. */ + hasSeenIntro: boolean | null; + /** + * Drives the "Transaction failed. Try again." banner on the amount screen. + * + * Lives here rather than in component state because it is set while + * DEPOSIT_CONFIRM is active and read after the flow has stepped back to + * AMOUNT — component state would be torn down in between. + */ + lastSubmitFailed: boolean; + /** + * True once the swap branch has produced a balance during this flow — the + * `via_swap` dimension on `earn.deposit_completed`. + * + * Lives here because the swap happens in the picker and the deposit metric is + * emitted from the submit hook, two screens and one `resetSubmission()` apart. + */ + didSwapInFlow: boolean; +} + +export const initialState: EarnState = { + pool: null, + selectedAssetId: "", + selectedAssetApy: null, + currentPositionTokens: "0", + hasSeenIntro: null, + lastSubmitFailed: false, + didSwapInFlow: false, +}; + +const earnSlice = createSlice({ + name: "earn", + initialState, + reducers: { + saveEarnPool: (state, action: { payload: BlendCatalogPool | null }) => { + state.pool = action.payload; + }, + saveSelectedAssetApy: (state, action: { payload: number | null }) => { + state.selectedAssetApy = action.payload; + }, + saveSelectedAssetId: (state, action: { payload: string }) => { + state.selectedAssetId = action.payload; + }, + saveCurrentPositionTokens: (state, action: { payload: string }) => { + state.currentPositionTokens = action.payload; + }, + setEarnIntroSeen: (state, action: { payload: boolean }) => { + state.hasSeenIntro = action.payload; + }, + setEarnSubmitFailed: (state, action: { payload: boolean }) => { + state.lastSubmitFailed = action.payload; + }, + setDidSwapInFlow: (state, action: { payload: boolean }) => { + state.didSwapInFlow = action.payload; + }, + /** + * Resets everything except `hasSeenIntro` — that flag is persisted in the + * background store and re-reading it on every flow entry would reintroduce + * the interstitial flash it exists to prevent. + */ + resetEarn: (state) => ({ + ...initialState, + hasSeenIntro: state.hasSeenIntro, + }), + }, +}); + +export const { + saveEarnPool, + saveSelectedAssetApy, + saveSelectedAssetId, + saveCurrentPositionTokens, + setEarnIntroSeen, + setEarnSubmitFailed, + setDidSwapInFlow, + resetEarn, +} = earnSlice.actions; + +export const { reducer } = earnSlice; + +export const earnSelector = (state: { earn: EarnState }) => state.earn; + +export const earnPoolSelector = (state: { earn: EarnState }) => state.earn.pool; + +export const earnSubmitFailedSelector = (state: { earn: EarnState }) => + state.earn.lastSubmitFailed; diff --git a/extension/src/popup/ducks/remoteConfig.ts b/extension/src/popup/ducks/remoteConfig.ts index cbef9cded7..3ab614f2fc 100644 --- a/extension/src/popup/ducks/remoteConfig.ts +++ b/extension/src/popup/ducks/remoteConfig.ts @@ -26,7 +26,11 @@ const ON_VARIANT_VALUES = ["on", "true", "enabled", "yes"]; * Boolean flags — variant value is checked against ON_VARIANT_VALUES. * Add flag names here as new boolean flags are introduced. */ -const BOOLEAN_FLAGS = ["use_token_prices_v2", "use_balances_v2"] as const; +const BOOLEAN_FLAGS = [ + "use_token_prices_v2", + "use_balances_v2", + "earn_deposit", +] as const; /** * Version flags — variant value is parsed from underscore format (1_2_3 → 1.2.3). @@ -88,6 +92,8 @@ const initialState: RemoteConfigState = { // Defaults to v2; Amplitude can flip it off to roll back to the v1 // account-balances endpoint without a release. use_balances_v2: true, + // Defaults to off; Amplitude turns it on to expose the Earn entry point. + earn_deposit: false, maintenance_banner: { enabled: false, payload: undefined }, maintenance_screen: { enabled: false, payload: undefined }, }; @@ -241,6 +247,15 @@ export const balancesV2Selector = createSelector( (rc) => rc.use_balances_v2, ); +/** + * Returns whether the Earn deposit flow should be exposed. Defaults to false + * so the entry point stays hidden until Amplitude turns the flag on. + */ +export const earnDepositSelector = createSelector( + remoteConfigSelector, + (rc) => rc.earn_deposit, +); + /** * Returns true once the Experiment flags have been fetched (or failed). */ diff --git a/extension/src/popup/helpers/blendDeposit.ts b/extension/src/popup/helpers/blendDeposit.ts new file mode 100644 index 0000000000..4a3a960b2b --- /dev/null +++ b/extension/src/popup/helpers/blendDeposit.ts @@ -0,0 +1,111 @@ +import { rpc as SorobanRpc } from "stellar-sdk"; + +import { NetworkDetails } from "@shared/constants/stellar"; +import { getSdk } from "@shared/helpers/stellar"; +import { stellarSdkServer } from "@shared/api/helpers/stellarSdkServer"; +import { simulateTransaction } from "@shared/api/internal"; +import { getBlendPoolId } from "@shared/constants/blend"; +import { + BlendRequestType, + buildBlendRequestScVal, + buildBlendSubmitOp, +} from "@shared/helpers/soroban/blend"; +import { xlmToStroop } from "helpers/stellar"; +import { parseTokenAmount } from "popup/helpers/soroban"; +import i18n from "popup/helpers/localizationConfig"; + +interface BuildAndSimulateBlendDepositParams { + publicKey: string; + /** The reserve's asset contract address (a SAC for every current reserve). */ + assetId: string; + /** Human-readable amount, e.g. "500.00". */ + amount: string; + decimals: number; + networkDetails: NetworkDetails; + /** Inclusion fee in XLM; the resource fee is added by assembleTransaction. */ + transactionFee: string; + transactionTimeout: number; +} + +/** + * Builds and simulates a Blend deposit — `pool.submit` with one + * SupplyCollateral request. + * + * Returns the prepared (assembled) XDR ready to sign, plus the raw simulation + * so callers can read `minResourceFee` for the fee breakdown. + * + * The account is loaded from Horizon rather than soroban-rpc: the popup cannot + * reach a mainnet RPC directly, which is why simulation goes through the v1 + * backend's `/simulate-tx` proxy. Same shape as `buildAndSimulateSoroswapTx`. + */ +export const buildAndSimulateBlendDeposit = async ({ + publicKey, + assetId, + amount, + decimals, + networkDetails, + transactionFee, + transactionTimeout, +}: BuildAndSimulateBlendDepositParams): Promise<{ + preparedTransaction: string; + simulationResponse: SorobanRpc.Api.SimulateTransactionSuccessResponse; +}> => { + const poolId = getBlendPoolId(networkDetails); + if (!poolId) { + throw new Error(i18n.t("Earn is not supported on this network")); + } + + const Sdk = getSdk(networkDetails.networkPassphrase); + const server = stellarSdkServer( + networkDetails.networkUrl, + networkDetails.networkPassphrase, + ); + const account = await server.loadAccount(publicKey); + + const request = buildBlendRequestScVal({ + assetId, + // Blend takes the amount in the asset's smallest unit. toFixed(0) because + // an i128 cannot carry a fraction, and exponential notation would not parse. + amount: parseTokenAmount(amount, decimals).toFixed(0), + // request_type 2 (SupplyCollateral), not 0 (Supply): the position lands in + // the pool's `collateral_tokens` rather than `supply_tokens`, so it counts + // as collateral and the user's borrowing power stays open. + requestType: BlendRequestType.SupplyCollateral, + networkPassphrase: networkDetails.networkPassphrase, + }); + + const builtTx = new Sdk.TransactionBuilder(account, { + // Inclusion fee only — assembleTransaction adds the resource fee on top + // once simulation reports it. + fee: xlmToStroop(transactionFee).toFixed(), + networkPassphrase: networkDetails.networkPassphrase, + }) + .addOperation( + buildBlendSubmitOp({ + poolId, + publicKey, + requests: [request], + networkPassphrase: networkDetails.networkPassphrase, + }), + ) + // A finite timeout, unlike the token-transfer helpers' TimeoutInfinite: a + // deposit priced against a live APY should expire rather than sit signable. + .setTimeout(transactionTimeout) + .build(); + + const { ok, response } = await simulateTransaction({ + xdr: builtTx.toXDR(), + networkDetails, + }); + + if (!ok) { + throw new Error( + typeof response === "string" ? response : JSON.stringify(response), + ); + } + + return { + preparedTransaction: response.preparedTransaction, + simulationResponse: response.simulationResponse, + }; +}; diff --git a/extension/src/popup/helpers/searchAsset.ts b/extension/src/popup/helpers/searchAsset.ts index 33e33fd8b6..97fa28728e 100644 --- a/extension/src/popup/helpers/searchAsset.ts +++ b/extension/src/popup/helpers/searchAsset.ts @@ -5,6 +5,7 @@ import { AssetListResponse, AssetListReponseItem, } from "@shared/constants/soroban/asset-list"; +import { SACS } from "@shared/constants/sac"; import { getApiStellarExpertUrl } from "popup/helpers/account"; import { getCombinedAssetListData } from "@shared/api/helpers/token-list"; @@ -43,13 +44,13 @@ export const getNativeContractDetails = (networkDetails: NetworkDetails) => { case NETWORKS.PUBLIC: return { ...NATIVE_CONTRACT_DEFAULTS, - contract: "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA", + contract: SACS[NETWORKS.PUBLIC].XLM, issuer: "GDMTVHLWJTHSUDMZVVMXXH6VJHA2ZV3HNG5LYNAZ6RTWB7GISM6PGTUV", }; case NETWORKS.TESTNET: return { ...NATIVE_CONTRACT_DEFAULTS, - contract: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", + contract: SACS[NETWORKS.TESTNET].XLM, issuer: "", }; default: diff --git a/extension/src/popup/locales/__tests__/translationParity.test.ts b/extension/src/popup/locales/__tests__/translationParity.test.ts index 30900f0341..22baffc4f3 100644 --- a/extension/src/popup/locales/__tests__/translationParity.test.ts +++ b/extension/src/popup/locales/__tests__/translationParity.test.ts @@ -28,6 +28,91 @@ const swapKeys = [ "Copy my wallet address", ]; +const earnKeys = [ + "Choose an asset", + "In your wallet", + "Other supported assets", + "Supported tokens", + "No supported assets in your wallet", + "Add a supported asset to start earning.", + "APY may change based on protocol conditions.", + "{{rate}}% APY", + "Earn with Blend", + "Supply assets to Blend and earn variable yield.", + "Earn variable yield", + "Supply supported assets and earn based on current APY.", + "Stay in control", + "Manage and withdraw your supplied assets from your wallet.", + "Not enough {{code}}", + "Swap for {{code}}", + "Buy {{code}}", + "Deposit", + "You deposit", + "{{amount}} {{code}} available", + "Current APY: {{rate}}*", + "by Blend", + "Review deposit", + "Insufficient funds", + "You need some XLM for the network fee", + "Not enough XLM left for the network fee. Reduce your deposit by at least {{amount}} XLM.", + "Not enough XLM to cover the network fee. Try depositing a smaller amount.", + "Add XLM to your wallet to continue", + "Transaction failed. Try again.", + "Pool Performance", + "Deposit supported assets into this Blend pool to earn yield. APY may change over time. Withdraw anytime.", + "View pool details", + "Interest", + "Net APY", + "Supplied", + "Borrowed", + "Backstop", + "You are depositing", + "Position", + "Current APY", + "Monthly earnings (est.)", + "Yearly earnings (est.)", + "Depositing", + "Deposited!", + "{{amount}} {{code}} to {{pool}}", + "{{from}} has been swapped to {{to}}", +]; + +describe("earn i18n parity", () => { + it("defines every earn key in en and pt", () => { + earnKeys.forEach((k) => { + expect(en).toHaveProperty([k]); + expect(pt).toHaveProperty([k]); + }); + }); +}); + +// An empty value is never correct. i18next returns "" for a key whose value is +// empty, so the UI renders BLANK — whereas a missing key falls back to the key +// itself and at least reads. The i18next scanner adds newly-seen keys with empty +// values on every build, so without this check a feature ships with unlabelled +// buttons and nothing fails. +const findEmptyValues = (bundle: Record) => + Object.entries(bundle) + .filter(([, value]) => value === "") + .map(([key]) => key); + +describe("i18n empty values", () => { + it.each([ + ["en", en], + ["pt", pt], + ])("has no empty translation values in %s", (_name, bundle) => { + expect(findEmptyValues(bundle as Record)).toEqual([]); + }); + + it("detects an empty value", () => { + // Guards the guard, as above: a check that never fires would let the + // scanner's blank entries through unnoticed. + expect(findEmptyValues({ Filled: "Preenchido", Blank: "" })).toEqual([ + "Blank", + ]); + }); +}); + describe("swap i18n parity", () => { it("defines every swap key in en and pt", () => { swapKeys.forEach((k) => { diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index 4471809953..da9270b213 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -1,6 +1,10 @@ { + "{{amount}} {{code}} available": "{{amount}} {{code}} available", + "{{amount}} {{code}} to {{pool}}": "{{amount}} {{code}} to {{pool}}", "{{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", "* All Stellar accounts must maintain a minimum balance of lumens.": "* All Stellar accounts must maintain a minimum balance of lumens.", "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.", @@ -18,6 +22,7 @@ "Account Migration": "Account Migration", "Account minimum balance is too low": "Account minimum balance is too low", "Add": "Add", + "Add a supported asset to start earning.": "Add a supported asset to start earning.", "Add a wallet from a hardware wallet": "Add a wallet from a hardware wallet", "Add a wallet using a secret key": "Add a wallet using a secret key", "Add an asset": "Add an asset", @@ -41,6 +46,7 @@ "Add trustline icon": "Add trustline icon", "Add wallet": "Add wallet", "Add XLM": "Add XLM", + "Add XLM to your wallet to continue": "Add XLM to your wallet to continue", "Adding this token is not possible at the moment.": "Adding this token is not possible at the moment.", "Additional details": "Additional details", "Address": "Address", @@ -63,6 +69,7 @@ "An unknown error has occurred.": "An unknown error has occurred.", "An unknown error occurred when loading your account": "An unknown error occurred when loading your account", "Anyone who has access to this phrase has access to your account and to the funds in it, so save it in a safe and secure place.": "Anyone who has access to this phrase has access to your account and to the funds in it, so save it in a safe and secure place.", + "APY may change based on protocol conditions.": "APY may change based on protocol conditions.", "Are you sure you want to delete this list?": "Are you sure you want to delete this list?", "Are you sure you want to remove this network?": "Are you sure you want to remove this network?", "As long as you have your old and new mnemonics phrase, you’ll still be able to control accounts related to your current backup phrase which were not merged.": "As long as you have your old and new mnemonics phrase, you’ll still be able to control accounts related to your current backup phrase which were not merged.", @@ -87,23 +94,29 @@ "Auto-lock timer": "Auto-lock timer", "available": "available", "Back": "Back", + "Backstop": "Backstop", "Balance": "Balance", "Balance ID": "Balance ID", "Before we start with migration, please read": "Before we start with migration, please read", + "Blend pool": "Blend pool", "Blockaid": "Blockaid", "Blockaid Response Override": "Blockaid Response Override", "Blockaid unfunded destination": "This is a new account and needs 1 XLM in order to get started. Any transaction to send non-XLM to an unfunded account will fail.", "Blockaid unfunded destination native": "This is a new account and needs at least 1 XLM to be created. Sending less than 1 XLM to create it will fail.", + "Borrowed": "Borrowed", "Bump To": "Bump To", + "Buy {{code}}": "Buy {{code}}", "Buy Amount": "Buy Amount", "Buy with Coinbase": "Buy with Coinbase", "Buy XLM with Coinbase": "Buy XLM with Coinbase", "Buying": "Buying", "by": "by", + "by Blend": "by Blend", "Calculating...": "Calculating...", "Cancel": "Cancel", "Change asset": "Change asset", "Check the destination account memo requirements and include it in the transaction.": "Check the destination account memo requirements and include it in the transaction.", + "Choose an asset": "Choose an asset", "Choose asset": "Choose asset", "Choose Recipient": "Choose Recipient", "Clear Flags": "Clear Flags", @@ -166,6 +179,8 @@ "Create Contract": "Create Contract", "Create New Address": "Create New Address", "Create new wallet": "Create new wallet", + "Current APY": "Current APY", + "Current APY: {{rate}}*": "Current APY: {{rate}}*", "Current Network": "Current Network", "Custom": "Custom", "dApps": "dApps", @@ -174,6 +189,10 @@ "Debug menu is only available in development mode.": "Debug menu is only available in development mode.", "Default": "Default", "Deleted": "Deleted", + "Deposit": "Deposit", + "Deposit supported assets into this Blend pool to earn yield. APY may change over time. Withdraw anytime.": "Deposit supported assets into this Blend pool to earn yield. APY may change over time. Withdraw anytime.", + "Deposited!": "Deposited!", + "Depositing": "Depositing", "Description": "Description", "Destination": "Destination", "Destination account does not accept this asset": "Destination account does not accept this asset", @@ -193,6 +212,10 @@ "Domain": "Domain", "Don’t share this phrase with anyone": "Don’t share this phrase with anyone", "Done": "Done", + "Earn": "Earn", + "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", "Enable Blind Signing on Ledger": "Enable Blind Signing on Ledger", "Enabled": "Enabled", "Enabling these may impact the security of your wallets and result in loss of funds": "Enabling these may impact the security of your wallets and result in loss of funds.", @@ -240,6 +263,7 @@ "Fee": "Fee", "Fee breakdown": "Fee breakdown", "Fee is required": "Fee is required", + "Fee settings": "Fee settings", "Fee unavailable": "Fee unavailable", "Feedback": "Feedback", "Feedback?": "Feedback?", @@ -318,13 +342,16 @@ "Important, Please Read": "Important, Please Read", "Imported": "Imported", "In this process, Freighter will create a new backup phrase for you and migrate your lumens, trustlines, and assets to the new account.": "In this process, Freighter will create a new backup phrase for you and migrate your lumens, trustlines, and assets to the new account.", + "In your wallet": "In your wallet", "Inclusion Fee": "Inclusion Fee", "Inflation Destination": "Inflation Destination", "Insufficient balance": "Insufficient balance", "Insufficient Balance": "Insufficient Balance", "Insufficient balance. Maximum spendable: {{amount}} {{symbol}}": "Insufficient balance. Maximum spendable: {{amount}} {{symbol}}", "Insufficient Fee": "Insufficient Fee", + "Insufficient funds": "Insufficient funds", "INSUFFICIENT FUNDS FOR FEE": "INSUFFICIENT FUNDS FOR FEE", + "Interest": "Interest", "Invalid address": "Invalid address", "Invalid Authorization Entry": "Invalid Authorization Entry", "invalid destination address": "invalid destination address", @@ -362,6 +389,7 @@ "Make sure you have your current 12 words backup phrase before continuing.": "Make sure you have your current 12 words backup phrase before continuing.", "Make sure your Ledger wallet is connected to your computer and the Stellar app is open on the Ledger wallet.": "Make sure your Ledger wallet is connected to your computer and the Stellar app is open on the Ledger wallet.", "Malicious": "Malicious", + "Manage and withdraw your supplied assets from your wallet.": "Manage and withdraw your supplied assets from your wallet.", "Manage assets": "Manage assets", "Manage List": "Manage List", "Master Weight": "Master Weight", @@ -391,6 +419,7 @@ "Min Price": "Min Price", "Minimum XLM needed": "Minimum XLM needed", "Minted": "Minted", + "Monthly earnings (est.)": "Monthly earnings (est.)", "More options": "More options", "Multiple assets": "Multiple assets", "Multiple assets have a similar code, please check the domain before adding.": "Multiple assets have a similar code, please check the domain before adding.", @@ -399,6 +428,7 @@ "Muxed address not supported": "Muxed address not supported", "My Accounts": "My Accounts", "Name": "Name", + "Net APY": "Net APY", "Network": "Network", "Network fees": "Network fees", "Network icon": "Network icon", @@ -417,10 +447,14 @@ "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 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}}", "No transactions to show": "No transactions to show", "None": "None", + "Not enough {{code}}": "Not enough {{code}}", "Not enough XLM for network fees": "Not enough XLM for network fees", + "Not enough XLM left for the network fee. Reduce your deposit by at least {{amount}} XLM.": "Not enough XLM left for the network fee. Reduce your deposit by at least {{amount}} XLM.", + "Not enough XLM to cover the network fee. Try depositing a smaller amount.": "Not enough XLM to cover the network fee. Try depositing a smaller amount.", "Not funded": "Not funded", "Not migrated": "Not migrated", "Note that you will need to reload this tab to load any account changes that happen outside this session.": "Note that you will need to reload this tab to load any account changes that happen outside this session.", @@ -438,7 +472,9 @@ "Operation": "Operation", "Operations": "Operations", "Optional": "Optional", + "or": "or", "Order is incorrect, try again": "Order is incorrect, try again", + "Other supported assets": "Other supported assets", "Overridden response": "Overridden response", "Override Blockaid security responses for testing different security states (DEV only)": "Override Blockaid security responses for testing different security states (DEV only)", "Overview": "Overview", @@ -462,7 +498,9 @@ "Please try again with a different value.": "Please try again with a different value.", "Please try again.": "Please try again.", "Please try using the suggested fee and try again.": "Please try using the suggested fee and try again.", + "Pool Performance": "Pool Performance", "Popular tokens": "Popular tokens", + "Position": "Position", "powered by": "powered by", "Powered by ": "Powered by ", "Pre Auth Transaction": "Pre Auth Transaction", @@ -475,6 +513,8 @@ "Rate": "Rate", "Read before importing your key": "Read before importing your key", "Ready to migrate": "Ready to migrate", + "Receive": "Receive", + "Receive funds": "Receive funds", "Receive funds from another wallet": "Receive funds from another wallet", "Received": "Received", "Recent": "Recent", @@ -499,6 +539,7 @@ "Retry": "Retry", "Review accounts to migrate": "Review accounts to migrate", "Review authorization on device": "Review authorization on device", + "Review deposit": "Review deposit", "Review message on device": "Review message on device", "Review Send": "Review Send", "Review swap": "Review swap", @@ -570,6 +611,7 @@ "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.", "Starting Balance": "Starting Balance", "Status": "Status", + "Stay in control": "Stay in control", "Stellar": "Stellar", "Stellar Development Foundation will never ask for your phrase": "Stellar Development Foundation will never ask for your phrase", "Stellar Logo": "Stellar Logo", @@ -584,12 +626,17 @@ "Success": "Success", "Success!": "Success!", "Suggestions": "Suggestions", + "Supplied": "Supplied", + "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", "Suspicious": "Suspicious", "Suspicious Request": "Suspicious Request", "Swap": "Swap", "Swap destination token logo": "Swap destination token logo", "Swap direction": "Swap direction", "Swap failed": "Swap failed", + "Swap for {{code}}": "Swap for {{code}}", "Swap for 0.5 XLM": "Swap for 0.5 XLM", "Swap from": "Swap from", "Swap Settings": "Swap Settings", @@ -627,7 +674,6 @@ "These assets are not on any of your lists. Proceed with caution before adding.": "These assets are not on any of your lists. Proceed with caution before adding.", "These services are operated by independent third parties, not by Freighter or SDF. Inclusion here is not an endorsement. DeFi carries risk, including loss of funds. Use at your own risk.": "These services are operated by independent third parties, not by Freighter or SDF. Inclusion here is not an endorsement. DeFi carries risk, including loss of funds. Use at your own risk.", "These words are your wallet’s keys—store them securely to keep your funds safe.": "These words are your wallet’s keys—store them securely to keep your funds safe.", - "This address supports Stellar network.": "This address supports Stellar network.", "This address was flagged as malicious": "This address was flagged as malicious", "This address was flagged as suspicious": "This address was flagged as suspicious", "This authorization is for {{address}}.": "This authorization is for {{address}}.", @@ -660,6 +706,7 @@ "Timeout": "Timeout", "Timeout (seconds)": "Timeout (seconds)", "to": "to", + "To": "To", "To access your wallet, click Freighter from your browser Extensions browser menu.": "To access your wallet, click Freighter from your browser Extensions browser menu.", "To create a new account you need to send at least 1 XLM to it.": "To create a new account you need to send at least 1 XLM to it.", "To hold {{code}} in your wallet, Stellar requires a trustline. 0.5 XLM will be reserved from your balance. You can get it back by removing the trustline after your {{code}} balance is zero.": "To hold {{code}} in your wallet, Stellar requires a trustline. 0.5 XLM will be reserved from your balance. You can get it back by removing the trustline after your {{code}} balance is zero.", @@ -684,6 +731,7 @@ "Transaction Details": "Transaction Details", "Transaction failed": "Transaction failed", "Transaction Failed": "Transaction Failed", + "Transaction failed. Try again.": "Transaction failed. Try again.", "Transaction Fee": "Transaction Fee", "Transaction Rejected": "Transaction Rejected.", "Transaction Request": "Transaction Request", @@ -735,6 +783,7 @@ "View on": "View on", "View on stellar.expert": "View on stellar.expert", "View options": "View options", + "View pool details": "View pool details", "View transaction": "View transaction", "Wallet": "Wallet", "Wallets": "Wallets", @@ -743,6 +792,8 @@ "was swapped to": "was swapped to", "wasm": "wasm", "Wasm Hash": "Wasm Hash", + "We couldn’t load earnable tokens. Please try again.": "We couldn’t load earnable tokens. Please try again.", + "We couldn’t load your balances. Please try again.": "We couldn’t load your balances. Please try again.", "We were unable to scan this site for security issues": "We were unable to scan this site for security issues", "We were unable to scan this token for security threats": "We were unable to scan this token for security threats", "WEBSITE CONNECTION IS NOT SECURE": "WEBSITE CONNECTION IS NOT SECURE", @@ -755,6 +806,8 @@ "XDR": "XDR", "XLM": "XLM", "XLM balance": "XLM balance", + "Yearly earnings (est.)": "Yearly earnings (est.)", + "You are depositing": "You are depositing", "You are in fullscreen mode": "You are in fullscreen mode", "You are overwriting an existing account.": "You are overwriting an existing account.", "You are sending": "You are sending", @@ -764,10 +817,12 @@ "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 deposit": "You deposit", "You have no assets added.": "You have no assets added.", "You have no collectibles added.": "You have no collectibles added.", "You may enable connection to domains that do not use an SSL certificate in Settings > Security > Advanced settings.": "You may enable connection to domains that do not use an SSL certificate in Settings > Security > Advanced settings.", "You may not be able to transact with Soroban smart contracts or see your Soroban tokens at this time.": "You may not be able to transact with Soroban smart contracts or see your Soroban tokens at this time.", + "You need some XLM for the network fee": "You need some XLM for the network fee", "You need XLM to create a trustline": "You need XLM to create a trustline", "You previously did not complete onboarding.": "You previously did not complete onboarding.", "You receive": "You receive", @@ -775,6 +830,10 @@ "You will have to re-add it if you want to use it again.": "You will have to re-add it if you want to use it again.", "You will permanently lose access to the account currently stored in Freighter.": "You will permanently lose access to the account currently stored in Freighter.", "You will permanently lose access to the account you started to create in Freighter.": "You will permanently lose access to the account you started to create in Freighter.", + "You’ll need {{code}} to deposit into this pool. Buy or swap for {{code}} to continue.": "You’ll need {{code}} to deposit into this pool. Buy or swap for {{code}} to continue.", + "You’ll need {{code}} to deposit into this pool. Buy or transfer {{code}} to continue.": "You’ll need {{code}} to deposit into this pool. Buy or transfer {{code}} to continue.", + "You’ll need {{code}} to deposit into this pool. Swap or transfer {{code}} to continue.": "You’ll need {{code}} to deposit into this pool. Swap or transfer {{code}} to continue.", + "You’ll need {{code}} to deposit into this pool. Transfer {{code}} to continue.": "You’ll need {{code}} to deposit into this pool. Transfer {{code}} to continue.", "You’ll still be able to import your current backup phrase into Freighter and control current accounts as long as they were not merged into the new accounts.": "You’ll still be able to import your current backup phrase into Freighter and control current accounts as long as they were not merged into the new accounts.", "You’re all set!": "You’re all set!", "You’re good to go!": "You’re good to go!", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index 9f39e27158..c0bd411b51 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -1,6 +1,10 @@ { + "{{amount}} {{code}} available": "{{amount}} {{code}} disponível", + "{{amount}} {{code}} to {{pool}}": "{{amount}} {{code}} para {{pool}}", "{{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", "* All Stellar accounts must maintain a minimum balance of lumens.": "* Todas as contas Stellar devem manter um saldo mínimo de lumens.", "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.", @@ -18,6 +22,7 @@ "Account Migration": "Migração de Conta", "Account minimum balance is too low": "O saldo mínimo da conta está muito baixo", "Add": "Adicionar", + "Add a supported asset to start earning.": "Adicione um ativo compatível para começar a render.", "Add a wallet from a hardware wallet": "Adicionar uma carteira de uma carteira de hardware", "Add a wallet using a secret key": "Adicionar uma carteira usando uma chave secreta", "Add an asset": "Adicionar um ativo", @@ -41,6 +46,7 @@ "Add trustline icon": "Ícone adicionar trustline", "Add wallet": "Adicionar carteira", "Add XLM": "Adicionar XLM", + "Add XLM to your wallet to continue": "Adicione XLM à sua carteira para continuar", "Adding this token is not possible at the moment.": "Adicionar este token não é possível no momento.", "Additional details": "Detalhes adicionais", "Address": "Endereço", @@ -63,6 +69,7 @@ "An unknown error has occurred.": "Ocorreu um erro desconhecido.", "An unknown error occurred when loading your account": "Ocorreu um erro desconhecido ao carregar sua conta", "Anyone who has access to this phrase has access to your account and to the funds in it, so save it in a safe and secure place.": "Qualquer pessoa que tenha acesso a esta frase terá acesso à sua conta e aos fundos nela, então guarde-a em um local seguro.", + "APY may change based on protocol conditions.": "A APY pode mudar conforme as condições do protocolo.", "Are you sure you want to delete this list?": "Tem certeza de que deseja excluir esta lista?", "Are you sure you want to remove this network?": "Tem certeza de que deseja remover esta rede?", "As long as you have your old and new mnemonics phrase, you’ll still be able to control accounts related to your current backup phrase which were not merged.": "Enquanto você tiver sua frase mnemônica antiga e nova, ainda poderá controlar contas relacionadas à sua frase de backup atual que não foram mescladas.", @@ -87,23 +94,29 @@ "Auto-lock timer": "Temporizador de bloqueio automático", "available": "disponível", "Back": "Voltar", + "Backstop": "Backstop", "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 pool": "Pool da Blend", "Blockaid": "Blockaid", "Blockaid Response Override": "Substituição de Resposta do Blockaid", "Blockaid unfunded destination": "Esta é uma nova conta e precisa de 1 XLM para começar. Qualquer transação para enviar não-XLM para uma conta não financiada falhará.", "Blockaid unfunded destination native": "Esta é uma nova conta e precisa de pelo menos 1 XLM para ser criada. Enviar menos de 1 XLM para criá-la falhará.", + "Borrowed": "Emprestado", "Bump To": "Bump Para", + "Buy {{code}}": "Comprar {{code}}", "Buy Amount": "Quantia de Compra", "Buy with Coinbase": "Comprar com Coinbase", "Buy XLM with Coinbase": "Comprar XLM com Coinbase", "Buying": "Comprando", "by": "por", + "by Blend": "pela Blend", "Calculating...": "Calculando...", "Cancel": "Cancelar", "Change asset": "Alterar ativo", "Check the destination account memo requirements and include it in the transaction.": "Verifique os requisitos de memo da conta de destino e inclua-o na transação.", + "Choose an asset": "Escolha um ativo", "Choose asset": "Escolher ativo", "Choose Recipient": "Escolher Destinatário", "Clear Flags": "Limpar Flags", @@ -166,6 +179,8 @@ "Create Contract": "Criar Contrato", "Create New Address": "Criar Novo Endereço", "Create new wallet": "Criar nova carteira", + "Current APY": "APY atual", + "Current APY: {{rate}}*": "APY atual: {{rate}}*", "Current Network": "Rede Atual", "Custom": "Personalizado", "dApps": "dApps", @@ -174,6 +189,10 @@ "Debug menu is only available in development mode.": "O menu de depuração está disponível apenas no modo de desenvolvimento.", "Default": "Padrão", "Deleted": "Excluído", + "Deposit": "Depositar", + "Deposit supported assets into this Blend pool to earn yield. APY may change over time. Withdraw anytime.": "Deposite ativos compatíveis neste pool da Blend para gerar rendimento. A APY pode mudar ao longo do tempo. Saque quando quiser.", + "Deposited!": "Depositado!", + "Depositing": "Depositando", "Description": "Descrição", "Destination": "Destino", "Destination account does not accept this asset": "A conta de destino não aceita este ativo", @@ -193,6 +212,10 @@ "Domain": "Domínio", "Don’t share this phrase with anyone": "Não compartilhe esta frase com ninguém", "Done": "Concluído", + "Earn": "Render", + "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", "Enable Blind Signing on Ledger": "Habilitar Blind Signing no Ledger", "Enabled": "Habilitado", "Enabling these may impact the security of your wallets and result in loss of funds": "Habilitar essas configurações pode impactar a segurança de suas carteiras e resultar em perda de fundos.", @@ -240,6 +263,7 @@ "Fee": "Taxa", "Fee breakdown": "Detalhes da taxa", "Fee is required": "A taxa é obrigatória", + "Fee settings": "Configurações de taxa", "Fee unavailable": "Taxa indisponível", "Feedback": "Feedback", "Feedback?": "Feedback?", @@ -318,13 +342,16 @@ "Important, Please Read": "Importante, Por Favor Leia", "Imported": "Importado", "In this process, Freighter will create a new backup phrase for you and migrate your lumens, trustlines, and assets to the new account.": "Neste processo, o Freighter criará uma nova frase de backup para você e migrará seus lumens, trustlines e ativos para a nova conta.", + "In your wallet": "Na sua carteira", "Inclusion Fee": "Taxa de Inclusão", "Inflation Destination": "Destino de Inflação", "Insufficient balance": "Saldo insuficiente", "Insufficient Balance": "Saldo Insuficiente", "Insufficient balance. Maximum spendable: {{amount}} {{symbol}}": "Saldo insuficiente. Máximo disponível: {{amount}} {{symbol}}", "Insufficient Fee": "Taxa Insuficiente", + "Insufficient funds": "Fundos insuficientes", "INSUFFICIENT FUNDS FOR FEE": "FUNDOS INSUFICIENTES PARA TAXA", + "Interest": "Juros", "Invalid address": "Endereço inválido", "Invalid Authorization Entry": "Entrada de autorização inválida", "invalid destination address": "endereço de destino inválido", @@ -362,6 +389,7 @@ "Make sure you have your current 12 words backup phrase before continuing.": "Certifique-se de ter sua frase de backup atual de 12 palavras antes de continuar.", "Make sure your Ledger wallet is connected to your computer and the Stellar app is open on the Ledger wallet.": "Certifique-se de que sua carteira Ledger está conectada ao seu computador e o aplicativo Stellar está aberto na carteira Ledger.", "Malicious": "Malicioso", + "Manage and withdraw your supplied assets from your wallet.": "Gerencie e retire os ativos fornecidos a partir da sua carteira.", "Manage assets": "Gerenciar ativos", "Manage List": "Gerenciar Lista", "Master Weight": "Peso Mestre", @@ -391,6 +419,7 @@ "Min Price": "Preço Mínimo", "Minimum XLM needed": "XLM mínimo necessário", "Minted": "Cunhado", + "Monthly earnings (est.)": "Ganhos mensais (est.)", "More options": "Mais opções", "Multiple assets": "Múltiplos ativos", "Multiple assets have a similar code, please check the domain before adding.": "Vários ativos têm um código similar, verifique o domínio antes de adicionar.", @@ -399,6 +428,7 @@ "Muxed address not supported": "Endereço muxed não suportado", "My Accounts": "Minhas Contas", "Name": "Nome", + "Net APY": "APY líquida", "Network": "Rede", "Network fees": "Taxas de rede", "Network icon": "Ícone de rede", @@ -417,10 +447,14 @@ "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 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}}", "No transactions to show": "Nenhuma transação para mostrar", "None": "Nenhum", + "Not enough {{code}}": "{{code}} insuficiente", "Not enough XLM for network fees": "XLM insuficiente para taxas de rede", + "Not enough XLM left for the network fee. Reduce your deposit by at least {{amount}} XLM.": "Não há XLM suficiente para a taxa de rede. Reduza seu depósito em pelo menos {{amount}} XLM.", + "Not enough XLM to cover the network fee. Try depositing a smaller amount.": "Não há XLM suficiente para cobrir a taxa de rede. Tente depositar um valor menor.", "Not funded": "Não financiado", "Not migrated": "Não migrado", "Note that you will need to reload this tab to load any account changes that happen outside this session.": "Observe que você precisará recarregar esta aba para carregar quaisquer alterações de conta que aconteçam fora desta sessão.", @@ -438,7 +472,9 @@ "Operation": "Operação", "Operations": "Operações", "Optional": "Opcional", + "or": "ou", "Order is incorrect, try again": "A ordem está incorreta, tente novamente", + "Other supported assets": "Outros ativos compatíveis", "Overridden response": "Resposta substituída", "Override Blockaid security responses for testing different security states (DEV only)": "Substituir respostas de segurança do Blockaid para testar diferentes estados de segurança (apenas DEV)", "Overview": "Visão geral", @@ -462,7 +498,9 @@ "Please try again with a different value.": "Por favor, tente novamente com um valor diferente.", "Please try again.": "Por favor, tente novamente.", "Please try using the suggested fee and try again.": "Por favor, tente usar a taxa sugerida e tente novamente.", + "Pool Performance": "Desempenho do pool", "Popular tokens": "Tokens populares", + "Position": "Posição", "powered by": "desenvolvido por", "Powered by ": "Desenvolvido por ", "Pre Auth Transaction": "Transação Pré-Autorizada", @@ -475,6 +513,8 @@ "Rate": "Taxa", "Read before importing your key": "Leia antes de importar sua chave", "Ready to migrate": "Pronto para migrar", + "Receive": "Receber", + "Receive funds": "Receber fundos", "Receive funds from another wallet": "Receber fundos de outra carteira", "Received": "Recebido", "Recent": "Recentes", @@ -499,6 +539,7 @@ "Retry": "Tentar novamente", "Review accounts to migrate": "Revisar contas para migrar", "Review authorization on device": "Revise a autorização no dispositivo", + "Review deposit": "Revisar depósito", "Review message on device": "Revise a mensagem no dispositivo", "Review Send": "Revisar Envio", "Review swap": "Revisar troca", @@ -570,6 +611,7 @@ "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.", "Starting Balance": "Saldo Inicial", "Status": "Status", + "Stay in control": "Mantenha o controle", "Stellar": "Stellar", "Stellar Development Foundation will never ask for your phrase": "A Stellar Development Foundation nunca pedirá sua frase", "Stellar Logo": "Logo Stellar", @@ -584,12 +626,17 @@ "Success": "Sucesso", "Success!": "Sucesso!", "Suggestions": "Sugestões", + "Supplied": "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", "Suspicious": "Suspeito", "Suspicious Request": "Solicitação Suspeita", "Swap": "Trocar", "Swap destination token logo": "Logotipo do token de destino da troca", "Swap direction": "Direção da troca", "Swap failed": "Troca falhou", + "Swap for {{code}}": "Trocar por {{code}}", "Swap for 0.5 XLM": "Trocar por 0,5 XLM", "Swap from": "Trocar de", "Swap Settings": "Configurações de Troca", @@ -627,7 +674,6 @@ "These assets are not on any of your lists. Proceed with caution before adding.": "Esses ativos não estão em nenhuma de suas listas. Prossiga com cautela antes de adicionar.", "These services are operated by independent third parties, not by Freighter or SDF. Inclusion here is not an endorsement. DeFi carries risk, including loss of funds. Use at your own risk.": "Estes serviços são operados por terceiros independentes, não pela Freighter ou SDF. A inclusão aqui não constitui um endosso. DeFi envolve riscos, incluindo perda de fundos. Use por sua conta e risco.", "These words are your wallet’s keys—store them securely to keep your funds safe.": "Essas palavras são as chaves da sua carteira—guarde-as com segurança para manter seus fundos seguros.", - "This address supports Stellar network.": "Este endereço é compatível com a rede Stellar.", "This address was flagged as malicious": "Este endereço foi sinalizado como malicioso", "This address was flagged as suspicious": "Este endereço foi sinalizado como suspeito", "This authorization is for {{address}}.": "Esta autorização é para {{address}}.", @@ -660,6 +706,7 @@ "Timeout": "Tempo esgotado", "Timeout (seconds)": "Timeout (segundos)", "to": "para", + "To": "Para", "To access your wallet, click Freighter from your browser Extensions browser menu.": "Para acessar sua carteira, clique em Freighter no menu de Extensões do seu navegador.", "To create a new account you need to send at least 1 XLM to it.": "Para criar uma nova conta, você precisa enviar pelo menos 1 XLM para ela.", "To hold {{code}} in your wallet, Stellar requires a trustline. 0.5 XLM will be reserved from your balance. You can get it back by removing the trustline after your {{code}} balance is zero.": "Para manter {{code}} na sua carteira, a Stellar exige uma linha de confiança. 0,5 XLM serão reservados do seu saldo. Você pode recuperá-los removendo a linha de confiança após o saldo de {{code}} ficar zerado.", @@ -684,6 +731,7 @@ "Transaction Details": "Detalhes da Transação", "Transaction failed": "Transação falhou", "Transaction Failed": "Transação Falhou", + "Transaction failed. Try again.": "A transação falhou. Tente novamente.", "Transaction Fee": "Taxa de Transação", "Transaction Rejected": "Transação Rejeitada.", "Transaction Request": "Solicitação de Transação", @@ -735,6 +783,7 @@ "View on": "Ver em", "View on stellar.expert": "Ver em stellar.expert", "View options": "Ver opções", + "View pool details": "Ver detalhes do pool", "View transaction": "Ver transação", "Wallet": "Carteira", "Wallets": "Carteiras", @@ -743,6 +792,8 @@ "was swapped to": "foi trocado para", "wasm": "wasm", "Wasm Hash": "Hash Wasm", + "We couldn’t load earnable tokens. Please try again.": "Não foi possível carregar os tokens disponíveis. Tente novamente.", + "We couldn’t load your balances. Please try again.": "Não foi possível carregar seus saldos. Tente novamente.", "We were unable to scan this site for security issues": "Não foi possível verificar este site quanto a problemas de segurança", "We were unable to scan this token for security threats": "Não foi possível verificar este token quanto a ameaças de segurança", "WEBSITE CONNECTION IS NOT SECURE": "CONEXÃO DO WEBSITE NÃO É SEGURA", @@ -755,6 +806,8 @@ "XDR": "XDR", "XLM": "XLM", "XLM balance": "Saldo XLM", + "Yearly earnings (est.)": "Ganhos anuais (est.)", + "You are depositing": "Você está depositando", "You are in fullscreen mode": "Você está no modo fullscreen", "You are overwriting an existing account.": "Você está sobrescrevendo uma conta existente.", "You are sending": "Você está enviando", @@ -764,10 +817,12 @@ "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 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.", "You may enable connection to domains that do not use an SSL certificate in Settings > Security > Advanced settings.": "Você pode habilitar a conexão com domínios que não usam um certificado SSL em Configurações > Segurança > Configurações avançadas.", "You may not be able to transact with Soroban smart contracts or see your Soroban tokens at this time.": "Você talvez não consiga realizar transações com contratos inteligentes Soroban ou ver seus tokens Soroban neste momento.", + "You need some XLM for the network fee": "Você precisa de XLM para a taxa de rede", "You need XLM to create a trustline": "Você precisa de XLM para criar uma trustline", "You previously did not complete onboarding.": "Você anteriormente não completou o onboarding.", "You receive": "Você recebe", @@ -775,6 +830,10 @@ "You will have to re-add it if you want to use it again.": "Você terá que adicioná-lo novamente se quiser usá-lo novamente.", "You will permanently lose access to the account currently stored in Freighter.": "Você perderá permanentemente o acesso à conta atualmente armazenada no Freighter.", "You will permanently lose access to the account you started to create in Freighter.": "Você perderá permanentemente o acesso à conta que começou a criar no Freighter.", + "You’ll need {{code}} to deposit into this pool. Buy or swap for {{code}} to continue.": "Você precisará de {{code}} para depositar neste pool. Compre ou troque por {{code}} para continuar.", + "You’ll need {{code}} to deposit into this pool. Buy or transfer {{code}} to continue.": "Você precisará de {{code}} para depositar neste pool. Compre ou transfira {{code}} para continuar.", + "You’ll need {{code}} to deposit into this pool. Swap or transfer {{code}} to continue.": "Você precisará de {{code}} para depositar neste pool. Troque ou transfira {{code}} para continuar.", + "You’ll need {{code}} to deposit into this pool. Transfer {{code}} to continue.": "Você precisará de {{code}} para depositar neste pool. Transfira {{code}} para continuar.", "You’ll still be able to import your current backup phrase into Freighter and control current accounts as long as they were not merged into the new accounts.": "Você ainda poderá importar sua frase de backup atual para o Freighter e controlar contas atuais, desde que não tenham sido mescladas nas novas contas.", "You’re all set!": "Tudo pronto!", "You’re good to go!": "Você está pronto para começar!", diff --git a/extension/src/popup/metrics/earn.test.ts b/extension/src/popup/metrics/earn.test.ts new file mode 100644 index 0000000000..99144c7493 --- /dev/null +++ b/extension/src/popup/metrics/earn.test.ts @@ -0,0 +1,209 @@ +import { emitMetric } from "helpers/metrics"; +import { BLEND_FIXED_POOL_IDS } from "@shared/constants/blend"; +import { NETWORKS } from "@shared/constants/stellar"; +import { NotEnoughVariant } from "popup/constants/earn"; +import { METRIC_NAMES } from "popup/constants/metricsNames"; + +import { + trackEarnBalanceInsufficientShown, + trackEarnDepositDismissed, + trackEarnDepositCompleted, + trackEarnDepositFailed, + trackEarnFundingActionSelected, + trackEarnPercentAmountSelected, + trackEarnSimulationFailed, + trackEarnSwapCompleted, + trackEarnTokenSelected, + trackEarnXlmFeeInsufficientShown, +} from "./earn"; + +jest.mock("helpers/metrics", () => ({ + emitMetric: jest.fn(), +})); + +const mockEmitMetric = emitMetric as jest.MockedFunction; + +const POOL_ID = BLEND_FIXED_POOL_IDS[NETWORKS.PUBLIC]!; + +describe("Earn funnel metrics", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("reports the token selection with its pool and rate", () => { + trackEarnTokenSelected({ + assetCode: "USDC", + poolId: POOL_ID, + apy: 0.1694, + }); + + expect(mockEmitMetric).toHaveBeenCalledWith( + METRIC_NAMES.earnTokenSelected, + { + asset_code: "USDC", + pool_id: POOL_ID, + apy: 0.1694, + }, + ); + }); + + it("keeps a null rate null rather than coercing it to zero", () => { + trackEarnTokenSelected({ assetCode: "EURC", poolId: POOL_ID, apy: null }); + + expect(mockEmitMetric).toHaveBeenCalledWith( + METRIC_NAMES.earnTokenSelected, + { + asset_code: "EURC", + pool_id: POOL_ID, + apy: null, + }, + ); + }); + + it("reports the insufficient-balance sheet with the variant shown", () => { + trackEarnBalanceInsufficientShown({ + assetCode: "EURC", + variant: NotEnoughVariant.SWAP_OR_TRANSFER, + }); + + expect(mockEmitMetric).toHaveBeenCalledWith( + METRIC_NAMES.earnBalanceInsufficientShown, + { asset_code: "EURC", variant: "swap-or-transfer" }, + ); + }); + + it.each(["buy", "swap", "transfer"] as const)( + "reports the %s remedy", + (action) => { + trackEarnFundingActionSelected({ assetCode: "USDC", action }); + + expect(mockEmitMetric).toHaveBeenCalledWith( + METRIC_NAMES.earnFundingActionSelected, + { asset_code: "USDC", action }, + ); + }, + ); + + it("reports an in-earn swap separately from swap.completed", () => { + trackEarnSwapCompleted({ fromAssetCode: "XLM", toAssetCode: "USDC" }); + + expect(mockEmitMetric).toHaveBeenCalledWith( + METRIC_NAMES.earnSwapCompleted, + { + from_asset_code: "XLM", + to_asset_code: "USDC", + }, + ); + }); + + 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. + trackEarnXlmFeeInsufficientShown({ assetCode: "USDC", reason: "no_xlm" }); + expect(mockEmitMetric).toHaveBeenCalledWith( + METRIC_NAMES.earnXlmFeeInsufficientShown, + { asset_code: "USDC", reason: "no_xlm" }, + ); + + trackEarnXlmFeeInsufficientShown({ + assetCode: "XLM", + reason: "fee_not_covered", + }); + expect(mockEmitMetric).toHaveBeenCalledWith( + METRIC_NAMES.earnXlmFeeInsufficientShown, + { asset_code: "XLM", reason: "fee_not_covered" }, + ); + }); + + it("reports a simulation failure with its reason", () => { + trackEarnSimulationFailed({ + assetCode: "XLM", + reasonCode: "simulation failed", + }); + + expect(mockEmitMetric).toHaveBeenCalledWith( + METRIC_NAMES.earnSimulationFailed, + { asset_code: "XLM", reason_code: "simulation failed" }, + ); + }); + + it("reports Max as percent 100", () => { + trackEarnPercentAmountSelected({ assetCode: "XLM", percent: 100 }); + + expect(mockEmitMetric).toHaveBeenCalledWith( + METRIC_NAMES.earnMaxAmountSelected, + { asset_code: "XLM", percent: 100 }, + ); + }); + + it.each([25, 50, 75])( + "does not report a %i%% shortcut as a set-max", + (percent) => { + trackEarnPercentAmountSelected({ assetCode: "XLM", percent }); + + expect(mockEmitMetric).not.toHaveBeenCalled(); + }, + ); + + it("reports an in-flight deposit the user stopped watching", () => { + trackEarnDepositDismissed({ assetCode: "USDC", poolId: POOL_ID }); + + expect(mockEmitMetric).toHaveBeenCalledWith( + METRIC_NAMES.earnDepositDismissed, + { asset_code: "USDC", pool_id: POOL_ID }, + ); + }); + + it("reports a completed deposit with its swap attribution", () => { + trackEarnDepositCompleted({ + assetCode: "USDC", + poolId: POOL_ID, + apy: 0.1694, + viaSwap: true, + }); + + expect(mockEmitMetric).toHaveBeenCalledWith( + METRIC_NAMES.earnDepositCompleted, + { + asset_code: "USDC", + pool_id: POOL_ID, + apy: 0.1694, + via_swap: true, + }, + ); + }); + + it("carries no amount or fiat value on the deposit events", () => { + trackEarnDepositCompleted({ + assetCode: "USDC", + poolId: POOL_ID, + apy: 0.1694, + viaSwap: false, + }); + + const [, body] = mockEmitMetric.mock.calls[0]; + expect(Object.keys(body || {})).toEqual([ + "asset_code", + "pool_id", + "apy", + "via_swap", + ]); + }); + + it("reports a failed deposit with a result code", () => { + trackEarnDepositFailed({ + assetCode: "USDC", + poolId: POOL_ID, + reasonCode: "op_underfunded", + }); + + expect(mockEmitMetric).toHaveBeenCalledWith( + METRIC_NAMES.earnDepositFailed, + { + asset_code: "USDC", + pool_id: POOL_ID, + reason_code: "op_underfunded", + }, + ); + }); +}); diff --git a/extension/src/popup/metrics/earn.ts b/extension/src/popup/metrics/earn.ts new file mode 100644 index 0000000000..7cfd6722a4 --- /dev/null +++ b/extension/src/popup/metrics/earn.ts @@ -0,0 +1,221 @@ +import { emitMetric } from "helpers/metrics"; +import { NotEnoughVariant } from "popup/constants/earn"; +import { METRIC_NAMES } from "popup/constants/metricsNames"; + +/** + * Emitters for the Earn deposit funnel. + * + * Kept in one module — the way `send.ts` holds the Send flow's trackers — so the + * property names each event carries are declared once and unit-testable, rather + * than spelled out at eight call sites across the flow. Every emitter here takes + * camelCase arguments and is the only place their snake_case wire names appear. + * + * None of these carry an amount or a fiat value: the sibling outcome events + * (`payment.completed`, `swap.completed`) carry asset codes only, and deposit + * size is measurable on-chain from the pool. + * + * `network`, `surface` and the account fields are stamped by buildCommonContext. + */ + +/** Which remedy the user chose on the "Not enough X" sheet. */ +export type EarnFundingAction = "buy" | "swap" | "transfer"; + +export const trackEarnTokenSelected = ({ + assetCode, + poolId, + apy, +}: { + assetCode: string; + poolId: string; + apy: number | null; +}) => { + emitMetric(METRIC_NAMES.earnTokenSelected, { + asset_code: assetCode, + pool_id: poolId, + apy, + }); +}; + +/** + * A pool-supported token the account holds none of was tapped. `variant` is the + * set of remedies actually offered, so a drop-off can be read against what the + * sheet made possible. + */ +export const trackEarnBalanceInsufficientShown = ({ + assetCode, + variant, +}: { + assetCode: string; + variant: NotEnoughVariant; +}) => { + emitMetric(METRIC_NAMES.earnBalanceInsufficientShown, { + asset_code: assetCode, + variant, + }); +}; + +/** + * Buy also emits `onramp.coinbase_opened` from useGetOnrampToken. That event is + * not Earn-scoped, so the funnel needs its own step to compare the three + * remedies against each other. + */ +export const trackEarnFundingActionSelected = ({ + assetCode, + action, +}: { + assetCode: string; + action: EarnFundingAction; +}) => { + emitMetric(METRIC_NAMES.earnFundingActionSelected, { + asset_code: assetCode, + action, + }); +}; + +/** + * The swap branch settled and returned to the picker. Distinct from + * `swap.completed`, which the reused Swap components emit for every swap and + * cannot attribute to Earn. + */ +export const trackEarnSwapCompleted = ({ + fromAssetCode, + toAssetCode, +}: { + fromAssetCode: string; + toAssetCode: string; +}) => { + emitMetric(METRIC_NAMES.earnSwapCompleted, { + from_asset_code: fromAssetCode, + to_asset_code: toAssetCode, + }); +}; + +/** + * 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 + * buy/swap sheet; `fee_not_covered` is an amount that leaves less XLM than the + * simulated resource fee, which gets an inline message asking for a smaller + * deposit. Deliberately one event — both are the same drop-off — but the remedy + * differs, so the funnel needs to tell them apart. + */ +export const trackEarnXlmFeeInsufficientShown = ({ + assetCode, + reason, +}: { + assetCode: string; + reason: "no_xlm" | "fee_not_covered"; +}) => { + emitMetric(METRIC_NAMES.earnXlmFeeInsufficientShown, { + asset_code: assetCode, + reason, + }); +}; + +export const trackEarnSimulationFailed = ({ + assetCode, + reasonCode, +}: { + assetCode: string; + reasonCode: string; +}) => { + emitMetric(METRIC_NAMES.earnSimulationFailed, { + asset_code: assetCode, + reason_code: reasonCode, + }); +}; + +/** + * The Max tap on the amount screen. Called for every percentage shortcut, but + * only 100 emits: a 25/50/75 tap is not a set-max, and an event named + * `max_amount_selected` that also fires for partials makes anything keyed on + * the name alone (dashboards, funnels, alerting) count partials as Max usage. + * + * Send and Swap gate identically (RFC #2883, D5) and so does mobile — the + * max-amount action fires on the max tap only, on send, swap and earn alike. + * If Earn ever needs partial-shortcut usage, add a separately named event + * rather than widening this one. + */ +export const trackEarnPercentAmountSelected = ({ + assetCode, + percent, +}: { + assetCode: string; + percent: number; +}) => { + if (percent !== 100) { + return; + } + + emitMetric(METRIC_NAMES.earnMaxAmountSelected, { + asset_code: assetCode, + percent, + }); +}; + +/** + * Close pressed while the deposit was still in flight — the user stopped + * watching, not the deposit. By the time that button renders the envelope is + * already being signed and submitted, and nothing cancels it; the submit hook's + * continuation outlives the screen, so `deposit_completed` or `deposit_failed` + * for the same attempt normally follows this event. + * + * A UX signal, then, not an outcome: how often the wait outlasts the user's + * patience. The outcome is genuinely missing only when the popup itself is + * closed, which kills the page before either event can be emitted. + */ +export const trackEarnDepositDismissed = ({ + assetCode, + poolId, +}: { + assetCode: string; + poolId: string; +}) => { + emitMetric(METRIC_NAMES.earnDepositDismissed, { + asset_code: assetCode, + pool_id: poolId, + }); +}; + +export const trackEarnDepositCompleted = ({ + assetCode, + poolId, + apy, + viaSwap, +}: { + assetCode: string; + poolId: string; + apy: number | null; + /** The deposited balance was created by the swap branch in this same flow. */ + viaSwap: boolean; +}) => { + emitMetric(METRIC_NAMES.earnDepositCompleted, { + asset_code: assetCode, + pool_id: poolId, + apy, + via_swap: viaSwap, + }); +}; + +/** + * Two emitters, split by where the failure happened: the submit hook owns its + * own sign/submit rejections — its closure outlives the in-flight screen, so the + * failure is still reported after the user closes it — and the Earn view owns + * everything that fails earlier, a device-rejected signature at review being the + * one that matters. Neither can see the other's failures, so they cannot double + * count. + */ +export const trackEarnDepositFailed = ({ + assetCode, + poolId, + reasonCode, +}: { + assetCode: string; + poolId: string; + reasonCode: string; +}) => { + emitMetric(METRIC_NAMES.earnDepositFailed, { + asset_code: assetCode, + pool_id: poolId, + reason_code: reasonCode, + }); +}; diff --git a/extension/src/popup/metrics/views.test.ts b/extension/src/popup/metrics/views.test.ts index 191b85aea2..0eb5c372d5 100644 --- a/extension/src/popup/metrics/views.test.ts +++ b/extension/src/popup/metrics/views.test.ts @@ -33,6 +33,7 @@ import { ROUTES } from "popup/constants/routes"; // Importing the module registers the navigate handler via registerHandler. import "popup/metrics/views"; +import { ROUTES_WITHOUT_SCREEN_VIEW } from "popup/metrics/views"; type NavHandler = (state: unknown, action: unknown) => void; @@ -142,9 +143,11 @@ describe("views navigate handler → screen.viewed", () => { const screenRoutes = Object.values(ROUTES).filter( (r) => r !== ROUTES.manageAssetsListsModifyAssetList && - // The send route is an intentional non-emit container (D8); its - // per-step screens are emitted by the Send flow's step effect. - r !== ROUTES.sendPayment, + // Container routes (send, earn) intentionally emit nothing (D8) — their + // per-step screens come from the flow's own step effect. Read the real + // set rather than restating it, so adding a container can't silently + // drift this test out of sync. + !ROUTES_WITHOUT_SCREEN_VIEW.has(r), ); const names: string[] = []; screenRoutes.forEach((pathname) => { diff --git a/extension/src/popup/metrics/views.ts b/extension/src/popup/metrics/views.ts index 099a39fe0c..fec66ef129 100644 --- a/extension/src/popup/metrics/views.ts +++ b/extension/src/popup/metrics/views.ts @@ -166,8 +166,16 @@ const SCREEN_BY_ROUTE: Partial> = { * its per-step screens (send_payment_to / send_payment_amount / …) are emitted * by the Send flow's step effect, so tracking the bare container here would only * double-count. Mobile has no send_payment container either (RFC #2883, D8). + * + * Earn is a container for the same reason — earn_intro / earn_select_token / + * earn_amount come from the Earn flow's own step effect, and earn_review / + * earn_processing / earn_success from the screens that own them (the review + * sheet and EarnSubmit). */ -const ROUTES_WITHOUT_SCREEN_VIEW = new Set([ROUTES.sendPayment]); +export const ROUTES_WITHOUT_SCREEN_VIEW = new Set([ + ROUTES.sendPayment, + ROUTES.earn, +]); /** Builds the screen.viewed props object, dropping any undefined flow/step. */ const screenProps = ( diff --git a/extension/src/popup/views/AddToken/__tests__/AddToken.test.tsx b/extension/src/popup/views/AddToken/__tests__/AddToken.test.tsx index 12eee82460..0e86df9843 100644 --- a/extension/src/popup/views/AddToken/__tests__/AddToken.test.tsx +++ b/extension/src/popup/views/AddToken/__tests__/AddToken.test.tsx @@ -317,6 +317,7 @@ const renderWithBalancesV2 = (useBalancesV2: boolean) => isInitialized: true, use_token_prices_v2: true, use_balances_v2: useBalancesV2, + earn_deposit: false, maintenance_banner: { enabled: false, payload: undefined }, maintenance_screen: { enabled: false, payload: undefined }, }, diff --git a/extension/src/popup/views/Earn/__tests__/Earn.submitFailure.test.tsx b/extension/src/popup/views/Earn/__tests__/Earn.submitFailure.test.tsx new file mode 100644 index 0000000000..7dd9811b2b --- /dev/null +++ b/extension/src/popup/views/Earn/__tests__/Earn.submitFailure.test.tsx @@ -0,0 +1,266 @@ +import React from "react"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { ActionStatus } from "@shared/api/types"; +import { TESTNET_NETWORK_DETAILS } from "@shared/constants/stellar"; +import { Earn } from "popup/views/Earn"; +import { initialState as earnInitialState } from "popup/ducks/earn"; +import { + initialState as transactionSubmissionInitialState, + submitFreighterSorobanTransaction, +} from "popup/ducks/transactionSubmission"; +import { getTestStore, Wrapper } from "popup/__testHelpers__"; + +const TEST_PUBLIC_KEY = + "GBTYAFHGNZSTE4VBWZYAGB3SRGJEPTI5I4Y22KZ4JTVAN56LESB6JZOF"; +const USDC_ISSUER = "GCK3D3V2XNLLKRFGFFFDEJXA4O2J4X36HET2FE446AV3M4U7DPHO3PEM"; +const USDC_SAC = "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75"; +const POOL_ID = "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD"; + +// Counts mounts, which is the whole point: the deposit terminal submits from a +// mount effect, so "how many times was it mounted" is "how many times did it +// post to the network". +const mockDepositMounts = jest.fn(); + +// jest.mock factories are hoisted and may only reach `mock`-prefixed bindings, +// so the picker's payload lives here rather than inline in the factory. +const mockPickerSelection = { + option: { + code: "USDC", + issuer: USDC_ISSUER, + assetId: USDC_SAC, + poolId: POOL_ID, + apy: 0.05, + }, + resolved: { + pool: { id: POOL_ID }, + publicKey: TEST_PUBLIC_KEY, + networkDetails: TESTNET_NETWORK_DETAILS, + }, +}; + +jest.mock("popup/components/earn/EarnIntro/hooks/useEarnIntroSeen", () => ({ + useEarnIntroSeen: () => ({ hasSeenIntro: true, dismissIntro: jest.fn() }), +})); + +jest.mock("popup/components/earn/EarnIntro", () => ({ + EarnIntro: () =>
, +})); + +jest.mock("popup/components/earn/EarnSwap", () => ({ + EarnSwap: () =>
, +})); + +jest.mock("popup/components/earn/EarnTokenPicker", () => ({ + EarnTokenPicker: ({ onSelect }: { onSelect: Function }) => ( + + ), +})); + +jest.mock("popup/components/earn/EarnAmount", () => ({ + EarnAmount: ({ onConfirm }: { onConfirm: () => void }) => ( + + ), +})); + +jest.mock("popup/components/earn/EarnSubmit", () => ({ + EarnSubmit: () => { + // Required inside the factory: hoisting puts this above the react import. + const { useEffect } = require("react"); + useEffect(() => { + mockDepositMounts(); + }, []); + return
; + }, +})); + +jest.mock("helpers/metrics", () => ({ + ...jest.requireActual("helpers/metrics"), + emitMetric: jest.fn(), + emitScreenViewed: jest.fn(), +})); + +jest.mock("popup/metrics/earn", () => ({ + ...jest.requireActual("popup/metrics/earn"), + trackEarnDepositFailed: jest.fn(), + trackEarnTokenSelected: jest.fn(), + trackEarnSwapCompleted: jest.fn(), +})); + +const { trackEarnDepositFailed } = + jest.requireMock("popup/metrics/earn"); + +const renderEarn = () => + render( + + + , + ); + +/** Drive the flow to the deposit terminal the way a user does. */ +const reachDepositTerminal = async () => { + await userEvent.click(screen.getByTestId("stub-pick-token")); + await userEvent.click(screen.getByTestId("stub-confirm-amount")); + await waitFor(() => expect(mockDepositMounts).toHaveBeenCalledTimes(1)); +}; + +describe("Earn deposit failure", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("does not remount the deposit terminal after a failed submission", async () => { + // The regression: every visited step stayed mounted, and the terminal was + // only *blanked* while submitStatus was ERROR. Clearing that status put it + // straight back, remounting it — and its mount effect resubmitted the same + // rejected envelope, forever. + renderEarn(); + await reachDepositTerminal(); + + act(() => { + getTestStore()!.dispatch( + submitFreighterSorobanTransaction.rejected( + null, + "req-1", + { + publicKey: TEST_PUBLIC_KEY, + signedXDR: "AAAA-prepared", + networkDetails: TESTNET_NETWORK_DETAILS, + }, + { errorMessage: "tx_bad_auth" }, + ), + ); + }); + + // Back on the amount screen, and the terminal is gone rather than hidden. + await waitFor(() => + expect(screen.queryByTestId("stub-deposit-terminal")).toBeNull(), + ); + expect(mockDepositMounts).toHaveBeenCalledTimes(1); + // Not this view's event to emit: the deposit step's own failures come from + // useSubmitEarnTxData, whose continuation survives the user closing the + // screen. (The terminal is stubbed here, so nothing emits at all.) + expect(trackEarnDepositFailed).not.toHaveBeenCalled(); + + // Give any loop a chance to run: the old code cycled on every state change. + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(mockDepositMounts).toHaveBeenCalledTimes(1); + expect(trackEarnDepositFailed).not.toHaveBeenCalled(); + }); + + it("emits the failure for a rejection that never reached the terminal", async () => { + // A device-rejected signature at the review sheet: the flow is still on the + // amount step, so no submit hook exists to own the event and this view emits + // it. The split by step is what keeps the two from double counting. + renderEarn(); + await userEvent.click(screen.getByTestId("stub-pick-token")); + + act(() => { + getTestStore()!.dispatch( + submitFreighterSorobanTransaction.rejected( + null, + "req-1", + { + publicKey: TEST_PUBLIC_KEY, + signedXDR: "AAAA-prepared", + networkDetails: TESTNET_NETWORK_DETAILS, + }, + { errorMessage: "User declined access" }, + ), + ); + }); + + await waitFor(() => + expect(trackEarnDepositFailed).toHaveBeenCalledTimes(1), + ); + expect(trackEarnDepositFailed).toHaveBeenCalledWith( + expect.objectContaining({ reasonCode: "User declined access" }), + ); + }); + + it("ignores an error status left behind by a previous flow", async () => { + // Closing the in-flight screen navigates away and resets the submission, + // but does not cancel the submit — a late rejection writes ERROR back into + // a store the flow has already left. Re-entering Earn must not read that as + // its own failure: it would emit against an empty asset and drop the fresh + // flow onto the amount screen with nothing selected. + render( + + + , + ); + + expect(await screen.findByTestId("stub-pick-token")).toBeDefined(); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(trackEarnDepositFailed).not.toHaveBeenCalled(); + // Still on the picker, not bounced to the amount screen. + expect(screen.queryByTestId("stub-confirm-amount")).toBeNull(); + }); + + it("mounts the terminal again when the user retries", async () => { + // The teardown must not strand the flow: confirming again has to bring a + // fresh terminal back, which is what makes a retry possible. + renderEarn(); + await reachDepositTerminal(); + + act(() => { + getTestStore()!.dispatch( + submitFreighterSorobanTransaction.rejected( + null, + "req-1", + { + publicKey: TEST_PUBLIC_KEY, + signedXDR: "AAAA-prepared", + networkDetails: TESTNET_NETWORK_DETAILS, + }, + { errorMessage: "tx_bad_auth" }, + ), + ); + }); + + await waitFor(() => + expect(screen.queryByTestId("stub-deposit-terminal")).toBeNull(), + ); + + await userEvent.click(screen.getByTestId("stub-confirm-amount")); + + expect(await screen.findByTestId("stub-deposit-terminal")).toBeDefined(); + expect(mockDepositMounts).toHaveBeenCalledTimes(2); + }); +}); diff --git a/extension/src/popup/views/Earn/index.tsx b/extension/src/popup/views/Earn/index.tsx new file mode 100644 index 0000000000..692698c201 --- /dev/null +++ b/extension/src/popup/views/Earn/index.tsx @@ -0,0 +1,364 @@ +import React, { useEffect, useRef, useState } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { useNavigate } from "react-router-dom"; + +import { getCanonicalFromAsset } from "@shared/helpers/stellar"; +import { getAssetFromCanonical } from "helpers/stellar"; +import { ROUTES } from "popup/constants/routes"; +import { STEPS } from "popup/constants/earn"; +import { navigateTo } from "popup/helpers/navigate"; +import { emitScreenViewed, ScreenViewedProps } from "helpers/metrics"; +import { ActionStatus } from "@shared/api/types"; +import { + resetSubmission, + resetSubmitStatus, + saveAsset, + saveDestination, + saveIsToken, + transactionSubmissionSelector, +} from "popup/ducks/transactionSubmission"; +import { + earnSelector, + resetEarn, + saveCurrentPositionTokens, + saveEarnPool, + saveSelectedAssetApy, + saveSelectedAssetId, + setDidSwapInFlow, + setEarnSubmitFailed, +} from "popup/ducks/earn"; +import { + trackEarnDepositFailed, + trackEarnSwapCompleted, + trackEarnTokenSelected, +} from "popup/metrics/earn"; +import { getFailureReasonCode } from "popup/components/earn/helpers/failureReasonCode"; +import { EarnIntro } from "popup/components/earn/EarnIntro"; +import { useEarnIntroSeen } from "popup/components/earn/EarnIntro/hooks/useEarnIntroSeen"; +import { EarnTokenPicker } from "popup/components/earn/EarnTokenPicker"; +import { EarnAmount } from "popup/components/earn/EarnAmount"; +import { EarnSubmit } from "popup/components/earn/EarnSubmit"; +import { EarnSwap } from "popup/components/earn/EarnSwap"; +import { resolveSwapDestination } from "popup/components/earn/EarnTokenPicker/helpers/resolveSwapDestination"; +import { + DestinationTokenDetails, + saveDestinationAsset, + saveDestinationTokenDetails, +} from "popup/ducks/transactionSubmission"; +import { Notification } from "@stellar/design-system"; +import { toast } from "sonner"; +import { useTranslation } from "react-i18next"; + +import "./styles.scss"; + +type EnterAnim = "from-bottom" | "from-right" | "from-left"; + +/** + * Screen-view metric per step. Names are kept in sync with freighter-mobile so + * the Earn funnel joins cross-platform (RFC #2883); SWAP is absent because the + * swap branch emits its own screens. + * + * DEPOSIT_CONFIRM is absent too: it is two screens in the funnel — `processing` + * while in flight, then `success` — and only EarnSubmit can see that + * transition, so it owns both of its screen views. The `confirm` step is the + * review sheet, emitted by EarnAmount. + */ +const EARN_SCREEN_BY_STEP: Partial< + Record +> = { + [STEPS.INTRO]: { screen_name: "earn_intro", flow: "earn" }, + [STEPS.CHOOSE_TOKEN]: { screen_name: "earn_select_token", flow: "earn" }, + [STEPS.AMOUNT]: { screen_name: "earn_amount", flow: "earn" }, +}; + +export const Earn = () => { + const dispatch = useDispatch(); + const navigate = useNavigate(); + const { t } = useTranslation(); + const { hasSeenIntro, dismissIntro } = useEarnIntroSeen(); + const submission = useSelector(transactionSubmissionSelector); + const { pool, selectedAssetId } = useSelector(earnSelector); + + // 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 [visitedSteps, setVisitedSteps] = useState>( + () => ({ [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 + // nudge to re-fetch balances once a swap lands. + const [pickerRefreshKey, setPickerRefreshKey] = useState(0); + const [swapTarget, setSwapTarget] = useState<{ + canonical: string; + details: DestinationTokenDetails; + } | null>(null); + + const hasResolvedIntro = useRef(false); + const lastEmittedStep = useRef(null); + const isFirstSubmitStatusRun = useRef(true); + + const goToStep = (next: STEPS, anim: EnterAnim | "dismiss") => { + setEnterAnim(anim === "dismiss" ? "from-bottom" : anim); + setVisitedSteps((currentSteps) => ({ ...currentSteps, [next]: true })); + setActiveStep(next); + }; + + const closeEarnFlow = () => { + dispatch(resetEarn()); + dispatch(resetSubmission()); + navigateTo(ROUTES.account, navigate); + }; + + useEffect(() => { + dispatch(resetSubmission()); + }, [dispatch]); + + useEffect(() => { + if (hasResolvedIntro.current || hasSeenIntro === null) { + return; + } + hasResolvedIntro.current = true; + + if (!hasSeenIntro) { + goToStep(STEPS.INTRO, "from-bottom"); + } + }, [hasSeenIntro]); + + // A failed submission returns to the amount screen with a banner rather than + // showing a terminal failure state — the design has no SubmitFail for Earn, + // and the amount is the only place the user can act on the failure. + // + // Every way a deposit can fail lands in `submitStatus: ERROR` (see + // transactionSubmission's extraReducers), so this effect owns the UI response + // to all of them. It does *not* own all of the metric: the submit step's own + // failures are emitted by useSubmitEarnTxData, whose continuation outlives the + // in-flight screen the user is invited to close. Emitting here as well would + // double count, so the emit is skipped while that step is active. What is left + // for this effect is everything that fails earlier — a device-rejected + // signature at the review sheet, which never reaches DEPOSIT_CONFIRM. + useEffect(() => { + if (isFirstSubmitStatusRun.current) { + // Whatever the status was at mount cannot belong to this flow: a fresh + // flow has not submitted anything yet. It is the leftover of a deposit + // abandoned in a previous visit, whose thunk settled after + // `closeEarnFlow` had already reset the submission — acting on it would + // report a failure against an empty asset and drop this flow onto the + // amount screen with nothing selected. The mount effect above clears it. + isFirstSubmitStatusRun.current = false; + return; + } + if (submission.submitStatus !== ActionStatus.ERROR) { + return; + } + if (activeStep !== STEPS.DEPOSIT_CONFIRM) { + trackEarnDepositFailed({ + assetCode: getAssetFromCanonical(submission.transactionData.asset).code, + poolId: pool?.id || "", + reasonCode: getFailureReasonCode(submission.error), + }); + } + dispatch(setEarnSubmitFailed(true)); + // Keeps transactionData and the simulation so the amount screen comes back + // populated and the user can retry without re-entering anything. + dispatch(resetSubmitStatus()); + goToStep(STEPS.AMOUNT, "dismiss"); + // Unvisit the terminal rather than leaning on renderStep's blanking. Every + // visited step stays mounted, so a step that is merely blanked while + // submitStatus is ERROR returns the instant resetSubmitStatus clears the + // flag — remounting EarnSubmit, whose mount effect submits the same envelope + // again, and again. A real retry re-adds the step through goToStep. + setVisitedSteps((currentSteps) => ({ + ...currentSteps, + [STEPS.DEPOSIT_CONFIRM]: false, + })); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [submission.submitStatus]); + + // useIsSwap is path-based and also honours ?swap=true. Inside /earn the swap + // branch would otherwise be misread as a payment — SubmitFail would title + // "Transaction failed" and emit payment.failed instead of swap.failed. Setting + // the flag here means neither useIsSwap nor its other caller has to change. + useEffect(() => { + const isSwapOpen = swapTarget !== null; + const search = isSwapOpen ? "?swap=true" : ""; + if (window.location.hash.includes("?swap=true") === isSwapOpen) { + return; + } + navigate({ pathname: ROUTES.earn, search }, { replace: true }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [swapTarget]); + + // Emit a screen-view metric only once per step transition. + useEffect(() => { + if (activeStep === lastEmittedStep.current) { + return; + } + lastEmittedStep.current = activeStep; + + const screen = EARN_SCREEN_BY_STEP[activeStep]; + if (screen) { + const { screen_name, ...props } = screen; + emitScreenViewed(screen_name, props); + } + }, [activeStep]); + + const renderStep = (step: STEPS) => { + switch (step) { + case STEPS.INTRO: + return ( + { + dismissIntro(); + goToStep(STEPS.CHOOSE_TOKEN, "from-right"); + }} + onClose={closeEarnFlow} + /> + ); + case STEPS.CHOOSE_TOKEN: + return ( + { + trackEarnTokenSelected({ + assetCode: option.code, + poolId: option.poolId, + apy: option.apy, + }); + // Picking a different asset invalidates everything the last + // one configured — the amount, its simulation and prepared XDR, + // the fetched position, a previous failure banner. Only a real + // change clears it, so backing out of the amount screen and + // re-picking the same asset returns to the amount already + // entered. The amount screen clears its own simulation error off + // the same asset change; it stays mounted behind the picker, so + // nothing here can reach its component state. + if (option.assetId !== selectedAssetId) { + dispatch(resetSubmission()); + dispatch(saveCurrentPositionTokens("0")); + dispatch(setEarnSubmitFailed(false)); + } + dispatch(saveEarnPool(resolved.pool)); + dispatch(saveSelectedAssetApy(option.apy)); + dispatch(saveSelectedAssetId(option.assetId)); + dispatch( + saveAsset(getCanonicalFromAsset(option.code, option.issuer)), + ); + // The pool contract is the transaction's destination; + // isContractId() on it is what routes the flow down the Soroban + // simulation path rather than the classic one. + dispatch(saveDestination(option.poolId)); + dispatch(saveIsToken(true)); + goToStep(STEPS.AMOUNT, "from-right"); + }} + onSwapRequested={async (option, resolved) => { + // A zero-balance token carries no issuer, so the canonical has to + // be read off the SAC before the swap can target it. Decline to + // start rather than open a swap with a half-filled destination. + const target = await resolveSwapDestination({ + option, + publicKey: resolved.publicKey, + networkDetails: resolved.networkDetails, + }); + if (!target) { + return; + } + // No step change: the swap opens as a sheet over the picker, which + // stays the active step underneath it. + setSwapTarget(target); + }} + /> + ); + case STEPS.AMOUNT: + return ( + goToStep(STEPS.CHOOSE_TOKEN, "from-left")} + onConfirm={() => goToStep(STEPS.DEPOSIT_CONFIRM, "from-right")} + /> + ); + case STEPS.DEPOSIT_CONFIRM: + // Blanked for the frame on which a submission error is being handled, + // so the terminal never flashes before the flow steps back to AMOUNT. + if (submission.submitStatus === ActionStatus.ERROR) { + return null; + } + return ( + + ); + default: + return null; + } + }; + + return ( +
+ {(Object.values(STEPS) as STEPS[]).map((step) => { + if (!visitedSteps[step]) { + return null; + } + + const isActive = activeStep === step; + + return ( +
+ {renderStep(step)} +
+ ); + })} + + {/* A sheet rather than a step: the design keeps the picker visible behind + the swap, and the sheet portals to the body, so it cannot be hidden by + a `display: none` step wrapper. */} + {swapTarget && ( + { + setSwapTarget(null); + dispatch(saveDestinationAsset("")); + dispatch(saveDestinationTokenDetails(null)); + }} + onDone={({ fromCode, toCode }) => { + trackEarnSwapCompleted({ + fromAssetCode: fromCode, + toAssetCode: toCode, + }); + // Read back at deposit time as `via_swap`, which is how we tell a + // deposit the swap branch made possible from one that needed nothing. + dispatch(setDidSwapInFlow(true)); + setSwapTarget(null); + dispatch(saveDestinationAsset("")); + dispatch(saveDestinationTokenDetails(null)); + toast.custom( + () => ( + + ), + { id: "earn-swap-success" }, + ); + // The picker stayed mounted underneath, so it needs an explicit + // nudge to pick up the balance the swap just created. + setPickerRefreshKey((key) => key + 1); + }} + /> + )} +
+ ); +}; diff --git a/extension/src/popup/views/Earn/styles.scss b/extension/src/popup/views/Earn/styles.scss new file mode 100644 index 0000000000..8b5d2bfccd --- /dev/null +++ b/extension/src/popup/views/Earn/styles.scss @@ -0,0 +1,75 @@ +// Step transition animations for the Earn flow. Mirrors Send/styles.scss: +// modally-presented steps (X close) rise from below, pushed steps (back arrow) +// slide in from the right, and going back slides from the left. Subtle offset + +// opacity rather than a full slide, which reads better in a popup this small. + +.Earn { + position: relative; + height: 100%; +} + +.Earn__step { + height: 100%; + display: flex; + flex-direction: column; +} + +.Earn__step--hidden { + display: none; +} + +.Earn__step--from-bottom { + animation: earnStepFromBottom 0.22s ease-out; +} + +.Earn__step--from-right { + animation: earnStepFromRight 0.22s ease-out; +} + +.Earn__step--from-left { + animation: earnStepFromLeft 0.22s ease-out; +} + +@keyframes earnStepFromBottom { + from { + opacity: 0; + transform: translateY(14px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes earnStepFromRight { + from { + opacity: 0; + transform: translateX(14px); + } + + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes earnStepFromLeft { + from { + opacity: 0; + transform: translateX(-14px); + } + + to { + opacity: 1; + transform: translateX(0); + } +} + +@media (prefers-reduced-motion: reduce) { + .Earn__step--from-bottom, + .Earn__step--from-right, + .Earn__step--from-left { + animation: none; + } +} diff --git a/extension/src/popup/views/Swap/index.tsx b/extension/src/popup/views/Swap/index.tsx index 0271388c45..2f2b77f758 100644 --- a/extension/src/popup/views/Swap/index.tsx +++ b/extension/src/popup/views/Swap/index.tsx @@ -1,7 +1,6 @@ import React, { useEffect, useRef, useState } from "react"; import { useDispatch, useSelector } from "react-redux"; import { useNavigate, useLocation } from "react-router-dom"; -import { ActionStatus } from "@shared/api/types"; import { STEPS } from "popup/constants/swap"; import { emitMetric, @@ -11,13 +10,12 @@ import { import { InputType } from "helpers/transaction"; import { TransactionConfirm } from "popup/components/InternalTransaction/SubmitTransaction"; import { METRIC_NAMES } from "popup/constants/metricsNames"; -import { getQuoteExpiredOperationCodes } from "popup/helpers/quoteExpiry"; import { SwapAsset } from "popup/components/swap/SwapAsset"; import { SwapAmount } from "popup/components/swap/SwapAmount"; +import { useSwapSubmitQuoteExpiry } from "popup/components/swap/hooks/useSwapSubmitQuoteExpiry"; import { AppDispatch } from "popup/App"; import { resetSubmission, - resetSubmitStatus, saveAmount, saveAmountUsd, saveAsset, @@ -77,30 +75,9 @@ export const Swap = () => { const { transactionSimulation, transactionData } = submission; const networkDetails = useSelector(settingsNetworkDetailsSelector); - // Quote expired at submit (op_under_dest_min / op_too_few_offers): recover to - // the review screen with a fresh quote instead of dead-ending in SubmitFail. - const isQuoteExpiredAtSubmit = - submission.submitStatus === ActionStatus.ERROR && - submission.isSwapQuoteExpired; - useEffect(() => { - if (!isQuoteExpiredAtSubmit) { - return; - } - // Amounts intentionally dropped (parity with swap.completed/failed, which - // carry no amounts). Assets are bare codes (getAssetFromCanonical) so - // from_asset_code/to_asset_code match mobile rather than being canonical ids. - emitMetric(METRIC_NAMES.swapQuoteExpired, { - from_asset_code: getAssetFromCanonical(transactionData.asset).code, - to_asset_code: getAssetFromCanonical(transactionData.destinationAsset) - .code, - result_code: getQuoteExpiredOperationCodes(submission.error).join(", "), - }); - // Clear only the ERROR status (keep the transaction data + the - // isSwapQuoteExpired flag, which drives the amount-screen notification). - dispatch(resetSubmitStatus()); - setActiveStep(STEPS.AMOUNT); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isQuoteExpiredAtSubmit]); + const { isQuoteExpiredAtSubmit } = useSwapSubmitQuoteExpiry({ + onRecover: () => setActiveStep(STEPS.AMOUNT), + }); const [inputType, setInputType] = useState("crypto"); // Children fetch in their own mount effects, and React runs child effects diff --git a/extension/src/popup/views/ViewPublicKey/index.tsx b/extension/src/popup/views/ViewPublicKey/index.tsx index 98bc2ffd6f..9a35181d24 100644 --- a/extension/src/popup/views/ViewPublicKey/index.tsx +++ b/extension/src/popup/views/ViewPublicKey/index.tsx @@ -3,7 +3,7 @@ import { useSelector } from "react-redux"; import { QRCodeSVG } from "qrcode.react"; import { Icon, Button, Notification } from "@stellar/design-system"; import { useTranslation } from "react-i18next"; -import { Navigate, useLocation } from "react-router-dom"; +import { Navigate, useLocation, useNavigate } from "react-router-dom"; import { toast } from "sonner"; import { emitMetric } from "helpers/metrics"; @@ -11,6 +11,7 @@ import { truncatedPublicKey } from "helpers/stellar"; import { METRIC_NAMES } from "popup/constants/metricsNames"; import { openTab } from "popup/helpers/navigate"; import { View } from "popup/basics/layout/View"; +import { isEarnFlowSearch } from "popup/constants/earn"; import { accountNameSelector } from "popup/ducks/accountServices"; import { AppDataType, useGetAppData } from "helpers/hooks/useGetAppData"; import { RequestState } from "constants/request"; @@ -26,6 +27,10 @@ import "./styles.scss"; export const ViewPublicKey = () => { const { t } = useTranslation(); const location = useLocation(); + const navigate = useNavigate(); + // The Earn flow marks itself so this shared screen can carry its chrome + // without changing how every other caller looks. + const isEarnFlow = isEarnFlowSearch(location.search); const accountName = useSelector(accountNameSelector); const { state, fetchData } = useGetAppData(); // Holds the currently-shown copy-toast's id (see copyAddress below for why @@ -118,7 +123,31 @@ export const ViewPublicKey = () => { return ( - } /> + {/* Only the Earn flow gets the titled, sheet-style chrome the design + specifies for it; the five other entry points keep the plain left X + they have always had. */} + {t("Receive funds")} + ) : undefined + } + hasBackButton={!isEarnFlow} + customBackIcon={} + rightContent={ + isEarnFlow ? ( + + ) : undefined + } + />
@@ -156,9 +185,6 @@ export const ViewPublicKey = () => {
-
- {t("This address supports Stellar network.")} -
{/* account.public_key_copied carries no source and never the raw key. Emitted from copyAddress only once the clipboard write succeeds, so failed copies aren't counted. */} diff --git a/extension/src/popup/views/ViewPublicKey/styles.scss b/extension/src/popup/views/ViewPublicKey/styles.scss index a9bc5b18c7..9d17b46210 100644 --- a/extension/src/popup/views/ViewPublicKey/styles.scss +++ b/extension/src/popup/views/ViewPublicKey/styles.scss @@ -86,13 +86,28 @@ flex-direction: column; gap: pxToRem(12px); width: 100%; + } - &__caption { - color: var(--sds-clr-gray-11); - font-size: pxToRem(14px); - font-weight: var(--font-weight-regular); - line-height: pxToRem(20px); - text-align: center; - } + // Text/MD/500 per the design (Figma 9457:46306): 16px/24px, weight 500. The + // shared app header hard-codes its title at 14px, which is 2px short. + &__title { + font-size: pxToRem(16); + font-weight: var(--font-weight-medium); + line-height: pxToRem(24); + color: var(--sds-clr-gray-12); + } + + // Matches the circular close on the Earn sheets this screen is reached from. + &__close { + width: pxToRem(32); + height: pxToRem(32); + border-radius: 50%; + border: none; + cursor: pointer; + background: var(--sds-clr-gray-04); + color: var(--sds-clr-gray-11); + display: flex; + align-items: center; + justify-content: center; } }