From 2b08984f26b9f0f6922b7c1dbfc44ab1e2a5a0ae Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Mon, 17 Aug 2026 11:16:18 -0600 Subject: [PATCH 01/43] feat(earn): add Blend deposit transaction encoding primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First piece of the Earn feature: the pure encoding layer for depositing into a Blend v2 pool. No UI yet. Adds `buildBlendRequestScVal` and `buildBlendSubmitOp` for constructing `pool.submit(from, spender, to, requests)`, plus the per-network Fixed Pool allowlist mirroring freighter-backend-v2's `configs/earn-pools.json`. Deposits use request_type 2 (SupplyCollateral), following Blend's own integration guidance. Note this lands the position in `collateral_tokens` rather than `supplied_tokens`, so consumers should read `total_tokens`. The Request struct's ScMap is hand-built rather than going through `nativeToScVal`, which sorts map keys with `String.localeCompare`. Locale collation ignores `_`, so it is not byte order in general — it would invert `r_two` vs `reactivity`. The three keys here happen to agree, but relying on that is a trap for the next struct. Because from/spender/to all equal the transaction source account, simulation emits source-account credentials and the envelope signature covers the auth — no `authorizeEntry` round-trip is needed, which is what lets hardware wallets use the ordinary signing path. Also adds BLEND_DEPOSIT_XLM_FEE_BUFFER: a Blend submit's resource fee measures ~546,395 stroops (0.0546 XLM) against the live pool, roughly 5,000x the inclusion fee that `getAvailableBalance` accounts for. Without the buffer, a Max deposit of XLM simulates into insufficient balance. Verified against the live mainnet and testnet Fixed pools; the golden XDR fixture is cross-checked byte-for-byte against `nativeToScVal` with an explicit type spec and matches `scRequestVec` in wallet-backend's integration test infrastructure. --- @shared/constants/blend.ts | 38 +++++ .../helpers/soroban/__tests__/blend.test.ts | 143 ++++++++++++++++++ @shared/helpers/soroban/blend.ts | 100 ++++++++++++ 3 files changed, 281 insertions(+) create mode 100644 @shared/constants/blend.ts create mode 100644 @shared/helpers/soroban/__tests__/blend.test.ts create mode 100644 @shared/helpers/soroban/blend.ts diff --git a/@shared/constants/blend.ts b/@shared/constants/blend.ts new file mode 100644 index 0000000000..ad5ae6a0b7 --- /dev/null +++ b/@shared/constants/blend.ts @@ -0,0 +1,38 @@ +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)); + +/** + * XLM held back from a Max deposit to cover the transaction's resource fee. + * + * `getAvailableBalance` only subtracts the *inclusion* fee (~0.00001 XLM), but a + * Blend `submit` is dominated by its resource fee — measured at 546,395 stroops + * (~0.0546 XLM) against the live pool, roughly 5,000x the inclusion fee. Without + * this buffer a Max deposit of XLM simulates into an insufficient-balance error. + * + * The buffer is deliberately generous; Review re-checks against the real + * `minResourceFee` once simulation returns and clamps if it still does not fit. + */ +export const BLEND_DEPOSIT_XLM_FEE_BUFFER = "0.5"; diff --git a/@shared/helpers/soroban/__tests__/blend.test.ts b/@shared/helpers/soroban/__tests__/blend.test.ts new file mode 100644 index 0000000000..1c761d7344 --- /dev/null +++ b/@shared/helpers/soroban/__tests__/blend.test.ts @@ -0,0 +1,143 @@ +import { Networks, scValToNative, xdr } from "stellar-sdk"; +import { + BlendRequestType, + buildBlendRequestScVal, + buildBlendSubmitOp, +} from "../blend"; + +const USDC_SAC = "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75"; +const XLM_SAC = "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA"; +const POOL_ID = "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD"; +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), + ); +}; From a4c82740e057d51ee0ff55f3f59945a6f0fc441a Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Mon, 17 Aug 2026 11:58:21 -0600 Subject: [PATCH 02/43] feat(earn): add Blend API client, flow scaffolding, and Home entry point Wires up everything below the Earn UI: the backend client, the route and step machine, persistent first-run state, and the Home tile that opens it. Clicking Earn now shows the interstitial on first run and lands on an empty token picker. --- @shared/api/helpers/__tests__/blend.test.ts | 349 ++++++++++++++++++ @shared/api/helpers/blend.ts | 178 +++++++++ @shared/api/internal.ts | 26 ++ @shared/api/types/blend.ts | 206 +++++++++++ @shared/api/types/message-request.ts | 10 + @shared/api/types/types.ts | 1 + @shared/constants/services.ts | 2 + .../__tests__/dismissEarnIntro.test.ts | 33 ++ .../__tests__/getEarnIntroSeen.test.ts | 22 ++ .../handlers/dismissEarnIntro.ts | 11 + .../handlers/getEarnIntroSeen.ts | 11 + .../messageListener/popupMessageListener.ts | 8 + extension/src/constants/localStorageTypes.ts | 1 + extension/src/helpers/metrics.ts | 1 + extension/src/popup/App.tsx | 2 + extension/src/popup/Router.tsx | 8 +- .../account/AccountHeader/index.tsx | 13 + .../account/AccountHeader/styles.scss | 5 +- .../earn/EarnIntro/hooks/useEarnIntroSeen.ts | 55 +++ .../popup/components/earn/EarnIntro/index.tsx | 65 ++++ .../components/earn/EarnIntro/styles.scss | 53 +++ extension/src/popup/constants/earn.ts | 59 +++ extension/src/popup/constants/routes.ts | 1 + extension/src/popup/ducks/earn.ts | 99 +++++ .../src/popup/locales/en/translation.json | 4 + .../src/popup/locales/pt/translation.json | 4 + extension/src/popup/metrics/views.test.ts | 9 +- extension/src/popup/metrics/views.ts | 8 +- extension/src/popup/views/Earn/index.tsx | 136 +++++++ extension/src/popup/views/Earn/styles.scss | 75 ++++ 30 files changed, 1446 insertions(+), 9 deletions(-) create mode 100644 @shared/api/helpers/__tests__/blend.test.ts create mode 100644 @shared/api/helpers/blend.ts create mode 100644 @shared/api/types/blend.ts create mode 100644 extension/src/background/messageListener/__tests__/dismissEarnIntro.test.ts create mode 100644 extension/src/background/messageListener/__tests__/getEarnIntroSeen.test.ts create mode 100644 extension/src/background/messageListener/handlers/dismissEarnIntro.ts create mode 100644 extension/src/background/messageListener/handlers/getEarnIntroSeen.ts create mode 100644 extension/src/popup/components/earn/EarnIntro/hooks/useEarnIntroSeen.ts create mode 100644 extension/src/popup/components/earn/EarnIntro/index.tsx create mode 100644 extension/src/popup/components/earn/EarnIntro/styles.scss create mode 100644 extension/src/popup/constants/earn.ts create mode 100644 extension/src/popup/ducks/earn.ts create mode 100644 extension/src/popup/views/Earn/index.tsx create mode 100644 extension/src/popup/views/Earn/styles.scss diff --git a/@shared/api/helpers/__tests__/blend.test.ts b/@shared/api/helpers/__tests__/blend.test.ts new file mode 100644 index 0000000000..c739d6a998 --- /dev/null +++ b/@shared/api/helpers/__tests__/blend.test.ts @@ -0,0 +1,349 @@ +import { + getBlendEarnOptions, + getBlendPools, + getBlendSuppliedTokens, +} from "../blend"; +import { sendMessageToBackground } from "../extensionMessaging"; +import { SERVICE_TYPES } from "@shared/constants/services"; + +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 = "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD"; +const USDC_SAC = "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75"; +const XLM_SAC = "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA"; +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, + 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.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("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..e759d7d555 --- /dev/null +++ b/@shared/api/helpers/blend.ts @@ -0,0 +1,178 @@ +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, + 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 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 7eacf758d8..048337d093 100644 --- a/@shared/api/internal.ts +++ b/@shared/api/internal.ts @@ -2609,6 +2609,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..959b33f78e --- /dev/null +++ b/@shared/api/types/blend.ts @@ -0,0 +1,206 @@ +/** + * 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; + 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; + 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/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/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/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/components/account/AccountHeader/index.tsx b/extension/src/popup/components/account/AccountHeader/index.tsx index 66e8c311e4..5f0c803b1a 100644 --- a/extension/src/popup/components/account/AccountHeader/index.tsx +++ b/extension/src/popup/components/account/AccountHeader/index.tsx @@ -27,6 +27,7 @@ import { signOut } from "popup/ducks/accountServices"; import { AccountHeaderModal } from "popup/components/account/AccountHeaderModal"; import { NetworkIcon } from "popup/components/manageNetwork/NetworkIcon"; import { NetworkDetails } from "@shared/constants/stellar"; +import { isEarnSupportedNetwork } from "@shared/constants/blend"; import { AccountTabs } from "popup/components/account/AccountTabs"; import { MaintenanceBanner } from "popup/components/MaintenanceBanner"; import { getNetworkDisplayName } from "./getNetworkDisplayName"; @@ -415,6 +416,18 @@ export const AccountHeader = ({ + {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/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..e6995582a6 --- /dev/null +++ b/extension/src/popup/components/earn/EarnIntro/index.tsx @@ -0,0 +1,65 @@ +import React from "react"; +import { Button, Icon, Text } from "@stellar/design-system"; +import { useTranslation } from "react-i18next"; + +import "./styles.scss"; + +interface EarnIntroProps { + onStart: () => void; + onClose: () => void; +} + +/** + * One-time interstitial shown the first time a user enters the Earn flow. + * + * A full step rather than a modal: it fills the popup and the artwork needs the + * whole viewport, which a self-measuring SlideupModal cannot give it. + * + * The artwork slot is a placeholder pending the final asset from design. + */ +export const EarnIntro = ({ onStart, onClose }: EarnIntroProps) => { + const { t } = useTranslation(); + + return ( +
+
+ +
+ +
+
+ + {t("Make your tokens earn for you")} + +
+ +
+ + {t( + "Deposit supported tokens into DeFi pools and start earning rewards.", + )} + +
+ + +
+
+ ); +}; 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..4bb4b4300a --- /dev/null +++ b/extension/src/popup/components/earn/EarnIntro/styles.scss @@ -0,0 +1,53 @@ +@use "../../../styles/utils.scss" as *; + +.EarnIntro { + display: flex; + flex-direction: column; + height: 100%; + + // Artwork fills the space above the copy. Placeholder until design ships the + // final asset; the flex-grow keeps the copy block anchored to the bottom + // regardless of the artwork's eventual intrinsic size. + &__art { + position: relative; + flex: 1 1 auto; + min-height: pxToRem(160); + background: var(--sds-clr-gray-04); + border-bottom-left-radius: pxToRem(16); + border-bottom-right-radius: pxToRem(16); + } + + &__close { + position: absolute; + top: pxToRem(16); + right: pxToRem(16); + width: pxToRem(32); + height: pxToRem(32); + border-radius: 50%; + border: none; + cursor: pointer; + background: var(--sds-clr-gray-08); + color: var(--sds-clr-gray-12); + display: flex; + align-items: center; + justify-content: center; + } + + &__content { + flex: 0 0 auto; + padding: pxToRem(24); + display: flex; + flex-direction: column; + gap: pxToRem(12); + } + + &__title { + text-align: center; + } + + &__body { + text-align: center; + color: var(--sds-clr-gray-11); + margin-bottom: pxToRem(12); + } +} diff --git a/extension/src/popup/constants/earn.ts b/extension/src/popup/constants/earn.ts new file mode 100644 index 0000000000..b131979111 --- /dev/null +++ b/extension/src/popup/constants/earn.ts @@ -0,0 +1,59 @@ +/** + * 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 and the review sheet are all + * `SlideupModal`s 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", + /** Hosts the whole swap sub-flow; see EARN_SWAP_STEPS. */ + SWAP = "earn-swap", + /** 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 mounted underneath, 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/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/earn.ts b/extension/src/popup/ducks/earn.ts new file mode 100644 index 0000000000..56472c64fa --- /dev/null +++ b/extension/src/popup/ducks/earn.ts @@ -0,0 +1,99 @@ +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 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; +} + +export const initialState: EarnState = { + pool: null, + selectedAssetApy: null, + currentPositionTokens: "0", + hasSeenIntro: null, + lastSubmitFailed: 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; + }, + 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; + }, + /** + * 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, + saveCurrentPositionTokens, + setEarnIntroSeen, + setEarnSubmitFailed, + 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/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index 4471809953..6eca6e1347 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -174,6 +174,7 @@ "Debug menu is only available in development mode.": "Debug menu is only available in development mode.", "Default": "Default", "Deleted": "Deleted", + "Deposit supported tokens into DeFi pools and start earning rewards.": "", "Description": "Description", "Destination": "Destination", "Destination account does not accept this asset": "Destination account does not accept this asset", @@ -193,6 +194,7 @@ "Domain": "Domain", "Don’t share this phrase with anyone": "Don’t share this phrase with anyone", "Done": "Done", + "Earn": "", "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.", @@ -361,6 +363,7 @@ "Make sure you have your 12 word backup phrase": "Make sure you have your 12 word backup phrase", "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.", + "Make your tokens earn for you": "", "Malicious": "Malicious", "Manage assets": "Manage assets", "Manage List": "Manage List", @@ -568,6 +571,7 @@ "Source token not found": "Source token not found", "Sponsored ID": "Sponsored ID", "SSL certificates provide an encrypted network connection and also provide proof of ownership of the domain.": "SSL certificates provide an encrypted network connection and also provide proof of ownership of the domain.", + "Start earning": "", "Starting Balance": "Starting Balance", "Status": "Status", "Stellar": "Stellar", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index 9f39e27158..6ed1f5c804 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -174,6 +174,7 @@ "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 supported tokens into DeFi pools and start earning rewards.": "", "Description": "Descrição", "Destination": "Destino", "Destination account does not accept this asset": "A conta de destino não aceita este ativo", @@ -193,6 +194,7 @@ "Domain": "Domínio", "Don’t share this phrase with anyone": "Não compartilhe esta frase com ninguém", "Done": "Concluído", + "Earn": "", "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.", @@ -361,6 +363,7 @@ "Make sure you have your 12 word backup phrase": "Certifique-se de ter sua backup phrase de 12 palavras", "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.", + "Make your tokens earn for you": "", "Malicious": "Malicioso", "Manage assets": "Gerenciar ativos", "Manage List": "Gerenciar Lista", @@ -568,6 +571,7 @@ "Source token not found": "Token de origem não encontrado", "Sponsored ID": "ID Patrocinado", "SSL certificates provide an encrypted network connection and also provide proof of ownership of the domain.": "Os certificados SSL fornecem uma conexão de rede criptografada e também fornecem prova de propriedade do domínio.", + "Start earning": "", "Starting Balance": "Saldo Inicial", "Status": "Status", "Stellar": "Stellar", 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..a65b328e2e 100644 --- a/extension/src/popup/metrics/views.ts +++ b/extension/src/popup/metrics/views.ts @@ -166,8 +166,14 @@ 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 / earn_confirm come from the Earn flow's own step effect. */ -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/Earn/index.tsx b/extension/src/popup/views/Earn/index.tsx new file mode 100644 index 0000000000..1ff11ab17c --- /dev/null +++ b/extension/src/popup/views/Earn/index.tsx @@ -0,0 +1,136 @@ +import React, { useEffect, useRef, useState } from "react"; +import { useDispatch } from "react-redux"; +import { useNavigate } from "react-router-dom"; + +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 { resetSubmission } from "popup/ducks/transactionSubmission"; +import { resetEarn } from "popup/ducks/earn"; +import { EarnIntro } from "popup/components/earn/EarnIntro"; +import { useEarnIntroSeen } from "popup/components/earn/EarnIntro/hooks/useEarnIntroSeen"; + +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. + */ +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" }, + [STEPS.DEPOSIT_CONFIRM]: { + screen_name: "earn_confirm", + flow: "earn", + step: "confirm", + }, +}; + +export const Earn = () => { + const dispatch = useDispatch(); + const navigate = useNavigate(); + const { hasSeenIntro, dismissIntro } = useEarnIntroSeen(); + + // 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"); + + const hasResolvedIntro = useRef(false); + const lastEmittedStep = useRef(null); + + 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]); + + // 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} + /> + ); + default: + case STEPS.CHOOSE_TOKEN: + return null; + } + }; + + return ( +
+ {(Object.values(STEPS) as STEPS[]).map((step) => { + if (!visitedSteps[step]) { + return null; + } + + const isActive = activeStep === step; + + return ( +
+ {renderStep(step)} +
+ ); + })} +
+ ); +}; 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; + } +} From 1385ebdad08354cf918ad079410c63feb244047a Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Mon, 17 Aug 2026 12:25:29 -0600 Subject: [PATCH 03/43] feat(earn): add the Choose Token screen The first Earn screen with real data behind it: pool-supported assets split into what the account holds and what it does not, each with the pool's headline rate, and a sheet offering the ways to acquire a token the account has none of. --- .../src/popup/components/BalanceRow/index.tsx | 34 ++- .../EarnTokenPicker/NotEnoughTokenSheet.tsx | 146 ++++++++++++ .../__tests__/getNotEnoughVariant.test.ts | 152 ++++++++++++ .../helpers/getNotEnoughVariant.ts | 91 ++++++++ .../hooks/useGetEarnTokensData.tsx | 182 +++++++++++++++ .../components/earn/EarnTokenPicker/index.tsx | 217 ++++++++++++++++++ .../earn/EarnTokenPicker/styles.scss | 79 +++++++ .../src/popup/locales/en/translation.json | 12 + .../src/popup/locales/pt/translation.json | 12 + extension/src/popup/views/Earn/index.tsx | 47 +++- 10 files changed, 957 insertions(+), 15 deletions(-) create mode 100644 extension/src/popup/components/earn/EarnTokenPicker/NotEnoughTokenSheet.tsx create mode 100644 extension/src/popup/components/earn/EarnTokenPicker/helpers/__tests__/getNotEnoughVariant.test.ts create mode 100644 extension/src/popup/components/earn/EarnTokenPicker/helpers/getNotEnoughVariant.ts create mode 100644 extension/src/popup/components/earn/EarnTokenPicker/hooks/useGetEarnTokensData.tsx create mode 100644 extension/src/popup/components/earn/EarnTokenPicker/index.tsx create mode 100644 extension/src/popup/components/earn/EarnTokenPicker/styles.scss 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/earn/EarnTokenPicker/NotEnoughTokenSheet.tsx b/extension/src/popup/components/earn/EarnTokenPicker/NotEnoughTokenSheet.tsx new file mode 100644 index 0000000000..a0f486f4f8 --- /dev/null +++ b/extension/src/popup/components/earn/EarnTokenPicker/NotEnoughTokenSheet.tsx @@ -0,0 +1,146 @@ +import React from "react"; +import { Button, Icon, Text } from "@stellar/design-system"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; + +import { AssetIcon } from "popup/components/account/AccountAssets"; +import { ROUTES } from "popup/constants/routes"; +import { NotEnoughVariant } from "popup/constants/earn"; +import { navigateTo } from "popup/helpers/navigate"; +import { useGetOnrampToken } from "helpers/hooks/useGetOnrampToken"; + +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 }, + ); + }; + + return ( +
+
+ + +
+ +
+ + {t("Not enough {{code}}", { code: option.code })} + +
+ +
+ + {body()} + +
+ +
+ {showBuy && ( + + )} + + {showSwap && ( + + )} + + +
+
+ ); +}; 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..142fd3b951 --- /dev/null +++ b/extension/src/popup/components/earn/EarnTokenPicker/helpers/__tests__/getNotEnoughVariant.test.ts @@ -0,0 +1,152 @@ +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 { 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( + "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75", + "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/hooks/useGetEarnTokensData.tsx b/extension/src/popup/components/earn/EarnTokenPicker/hooks/useGetEarnTokensData.tsx new file mode 100644 index 0000000000..dddaf2ee95 --- /dev/null +++ b/extension/src/popup/components/earn/EarnTokenPicker/hooks/useGetEarnTokensData.tsx @@ -0,0 +1,182 @@ +import { useReducer } from "react"; +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 { getBlendPoolId } from "@shared/constants/blend"; +import { getCanonicalFromAsset } from "@shared/helpers/stellar"; +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 account" section. */ + held: EarnTokenOption[]; + /** Pool-supported assets at zero — the "Supported tokens" section. */ + 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 is an estimate" 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 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 issuer = + balance && "token" in balance && "issuer" in balance.token + ? balance.token.issuer.key + : undefined; + + // Icons are keyed by canonical, so only held assets resolve one — a + // zero-balance supported token has no issuer to build a key from and + // falls back to AssetIcon's placeholder. XLM is the exception: it is + // keyed "native" whether or not it is held. + const code = option.symbol || ""; + let canonical = ""; + if (issuer) { + canonical = getCanonicalFromAsset(code, issuer); + } else if (code === "XLM") { + canonical = "native"; + } + + 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); + } + }); + + 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..184c6758cb --- /dev/null +++ b/extension/src/popup/components/earn/EarnTokenPicker/index.tsx @@ -0,0 +1,217 @@ +import React, { useEffect, useState } from "react"; +import { Loader, Text } from "@stellar/design-system"; +import { useTranslation } from "react-i18next"; +import { Navigate } from "react-router-dom"; + +import { View } from "popup/basics/layout/View"; +import { SubviewHeader } from "popup/components/SubviewHeader"; +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 { formatAmount } from "popup/helpers/formatters"; + +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; +} + +/** Green pill showing the pool's headline rate for an asset. */ +const ApyBadge = ({ apy, code }: { apy: number | null; code: string }) => ( +
+ {/* A null rate means no fresh oracle price — genuinely unknown, and + distinct from a rate that really is zero. */} + {apy === null ? "--" : `${formatAmount((apy * 100).toFixed(2))}%*`} +
+); + +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; + + const renderRow = (option: EarnTokenOption, isHeld: boolean) => ( + } + onClick={() => + isHeld ? onSelect(option, data) : setNotEnoughToken(option) + } + data-testid={`earn-token-row-${option.code}`} + /> + ); + + // 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 notEnoughVariant = notEnoughToken + ? getNotEnoughVariant({ + isOnrampable: isOnrampableAsset( + notEnoughToken.code, + data.networkDetails, + ), + isSwappable: hasSwappableBalance( + data.balances.balances, + notEnoughToken.assetId, + ), + }) + : null; + + return ( + + + + + {t( + "*APY is an estimate based on current protocol conditions and changes with market activity. It is not guaranteed by Freighter, SDF, or the protocol.", + )} + + + } + > +
+ {data.held.length > 0 && ( + <> +
+ + {t("In your account")} + +
+ {data.held.map((option) => renderRow(option, true))} + + )} + + {data.supported.length > 0 && ( + <> +
+ + {t("Supported tokens")} + +
+ {data.supported.map((option) => renderRow(option, 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..a661e5ceb2 --- /dev/null +++ b/extension/src/popup/components/earn/EarnTokenPicker/styles.scss @@ -0,0 +1,79 @@ +@use "../../../styles/utils.scss" as *; + +.EarnTokenPicker { + display: flex; + flex-direction: column; + gap: pxToRem(4); + + &__section-title { + color: var(--sds-clr-gray-12); + margin-top: pxToRem(12); + margin-bottom: pxToRem(4); + + &:first-child { + margin-top: 0; + } + } + + &__apy { + background: var(--sds-clr-green-04); + color: var(--sds-clr-green-11); + border-radius: pxToRem(16); + padding: pxToRem(4) pxToRem(10); + font-size: pxToRem(14); + font-weight: 500; + white-space: nowrap; + } + + &__loader { + display: flex; + justify-content: center; + padding-top: pxToRem(48); + } + + &__disclaimer { + color: var(--sds-clr-gray-10); + padding-top: pxToRem(12); + } +} + +.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); + } +} diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index 6eca6e1347..5d4c9ae0fc 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -2,6 +2,7 @@ "{{appName}} disconnected": "{{appName}} disconnected", "{{domain}} is not currently connected to Freighter": "{{domain}} is not currently connected to Freighter", "* All Stellar accounts must maintain a minimum balance of lumens.": "* All Stellar accounts must maintain a minimum balance of lumens.", + "*APY is an estimate based on current protocol conditions and changes with market activity. It is not guaranteed by Freighter, SDF, or the protocol.": "", "0.5 XLM required": "0.5 XLM required", "A destination account requires the use of the memo field which is not present in the transaction you’re about to sign.": "A destination account requires the use of the memo field which is not present in the transaction you’re about to sign.", "A new signing request arrived while you were reviewing another. Please review it carefully before approving.": "A new signing request arrived while you were reviewing another. Please review it carefully before approving.", @@ -95,6 +96,7 @@ "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.", "Bump To": "Bump To", + "Buy {{code}}": "", "Buy Amount": "Buy Amount", "Buy with Coinbase": "Buy with Coinbase", "Buy XLM with Coinbase": "Buy XLM with Coinbase", @@ -106,6 +108,7 @@ "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 asset": "Choose asset", "Choose Recipient": "Choose Recipient", + "Choose Token to earn": "", "Clear Flags": "Clear Flags", "Clear Override": "Clear Override", "Clear recents": "Clear recents", @@ -320,6 +323,7 @@ "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 account": "", "Inclusion Fee": "Inclusion Fee", "Inflation Destination": "Inflation Destination", "Insufficient balance": "Insufficient balance", @@ -423,6 +427,7 @@ "No tokens match {{term}}": "No tokens match {{term}}", "No transactions to show": "No transactions to show", "None": "None", + "Not enough {{code}}": "", "Not enough XLM for network fees": "Not enough XLM for network fees", "Not funded": "Not funded", "Not migrated": "Not migrated", @@ -588,12 +593,14 @@ "Success": "Success", "Success!": "Success!", "Suggestions": "Suggestions", + "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 0.5 XLM": "Swap for 0.5 XLM", "Swap from": "Swap from", "Swap Settings": "Swap Settings", @@ -747,6 +754,7 @@ "was swapped to": "was swapped to", "wasm": "wasm", "Wasm Hash": "Wasm Hash", + "We couldn’t load earnable tokens. 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", @@ -779,6 +787,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 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 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 6ed1f5c804..9998e00c2e 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -2,6 +2,7 @@ "{{appName}} disconnected": "{{appName}} desconectado", "{{domain}} is not currently connected to Freighter": "{{domain}} não está atualmente conectado ao Freighter", "* All Stellar accounts must maintain a minimum balance of lumens.": "* Todas as contas Stellar devem manter um saldo mínimo de lumens.", + "*APY is an estimate based on current protocol conditions and changes with market activity. It is not guaranteed by Freighter, SDF, or the protocol.": "", "0.5 XLM required": "0,5 XLM necessários", "A destination account requires the use of the memo field which is not present in the transaction you’re about to sign.": "Uma conta de destino requer o uso do campo memo que não está presente na transação que você está prestes a assinar.", "A new signing request arrived while you were reviewing another. Please review it carefully before approving.": "Uma nova solicitação de assinatura chegou enquanto você revisava outra. Revise-a com atenção antes de aprovar.", @@ -95,6 +96,7 @@ "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á.", "Bump To": "Bump Para", + "Buy {{code}}": "", "Buy Amount": "Quantia de Compra", "Buy with Coinbase": "Comprar com Coinbase", "Buy XLM with Coinbase": "Comprar XLM com Coinbase", @@ -106,6 +108,7 @@ "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 asset": "Escolher ativo", "Choose Recipient": "Escolher Destinatário", + "Choose Token to earn": "", "Clear Flags": "Limpar Flags", "Clear Override": "Limpar Substituição", "Clear recents": "Limpar recentes", @@ -320,6 +323,7 @@ "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 account": "", "Inclusion Fee": "Taxa de Inclusão", "Inflation Destination": "Destino de Inflação", "Insufficient balance": "Saldo insuficiente", @@ -423,6 +427,7 @@ "No tokens match {{term}}": "Nenhum token corresponde a {{term}}", "No transactions to show": "Nenhuma transação para mostrar", "None": "Nenhum", + "Not enough {{code}}": "", "Not enough XLM for network fees": "XLM insuficiente para taxas de rede", "Not funded": "Não financiado", "Not migrated": "Não migrado", @@ -588,12 +593,14 @@ "Success": "Sucesso", "Success!": "Sucesso!", "Suggestions": "Sugestões", + "Supported tokens": "", "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}}": "", "Swap for 0.5 XLM": "Trocar por 0,5 XLM", "Swap from": "Trocar de", "Swap Settings": "Configurações de Troca", @@ -747,6 +754,7 @@ "was swapped to": "foi trocado para", "wasm": "wasm", "Wasm Hash": "Hash Wasm", + "We couldn’t load earnable tokens. Please try again.": "", "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", @@ -779,6 +787,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.": "", + "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. 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.": "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/views/Earn/index.tsx b/extension/src/popup/views/Earn/index.tsx index 1ff11ab17c..d6d4cc9ec6 100644 --- a/extension/src/popup/views/Earn/index.tsx +++ b/extension/src/popup/views/Earn/index.tsx @@ -2,14 +2,25 @@ import React, { useEffect, useRef, useState } from "react"; import { useDispatch } from "react-redux"; import { useNavigate } from "react-router-dom"; +import { getCanonicalFromAsset } from "@shared/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 { resetSubmission } from "popup/ducks/transactionSubmission"; -import { resetEarn } from "popup/ducks/earn"; +import { + resetSubmission, + saveAsset, + saveDestination, + saveIsToken, +} from "popup/ducks/transactionSubmission"; +import { + resetEarn, + saveEarnPool, + saveSelectedAssetApy, +} from "popup/ducks/earn"; import { EarnIntro } from "popup/components/earn/EarnIntro"; import { useEarnIntroSeen } from "popup/components/earn/EarnIntro/hooks/useEarnIntroSeen"; +import { EarnTokenPicker } from "popup/components/earn/EarnTokenPicker"; import "./styles.scss"; @@ -47,6 +58,9 @@ export const Earn = () => { () => ({ [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] = useState(0); const hasResolvedIntro = useRef(false); const lastEmittedStep = useRef(null); @@ -104,8 +118,35 @@ export const Earn = () => { onClose={closeEarnFlow} /> ); - default: case STEPS.CHOOSE_TOKEN: + return ( + { + dispatch(saveEarnPool(resolved.pool)); + dispatch(saveSelectedAssetApy(option.apy)); + 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"); + }} + // Interim: hand off to the existing Swap route. The design layers + // Swap as a sheet over this picker and returns here with a toast, + // which needs the Swap components remounted inside STEPS.SWAP — + // that lands with the swap-branch work. + onSwapRequested={() => navigateTo(ROUTES.swap, navigate)} + /> + ); + // AMOUNT, SWAP and DEPOSIT_CONFIRM land in later steps. Returning null + // rather than falling through to a default keeps an unbuilt step from + // silently rendering another step's screen. + default: return null; } }; From 37deb92e2ffbd0e632618f6738f3a9d7a92f4210 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Mon, 17 Aug 2026 13:41:57 -0600 Subject: [PATCH 04/43] feat(earn): add the deposit amount screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Amount entry, pool card with current rate, percentage buttons, and the pool details sheet. The CTA builds and simulates the real Blend deposit via blendDeposit.ts. Max holds back an XLM buffer: getAvailableBalance nets out only the inclusion fee, but a Blend submit is dominated by its resource fee (~0.0546 XLM), so the raw balance would fail simulation. The fee check runs after the CTA gate so an unaffordable XLM deposit reads as insufficient funds rather than a missing-fee problem. Moves getAmountFontSizeClass/buildFiatLineText out of swap/ into amount/helpers — they are generic and Earn needs them too. The CTA stops at a successful simulation: stepping into DEPOSIT_CONFIRM would hit SendingTransaction, which submits on mount, and the review sheet does not exist yet. --- .../helpers/amountDisplay.ts} | 0 .../earn/EarnAmount/NetworkFeeSheet.tsx | 85 +++++ .../components/earn/EarnAmount/PoolCard.tsx | 50 +++ .../helpers/__tests__/earnCtaState.test.ts | 117 +++++++ .../earn/EarnAmount/helpers/earnCtaState.ts | 85 +++++ .../EarnAmount/hooks/useGetEarnAmountData.tsx | 96 +++++ .../components/earn/EarnAmount/index.tsx | 331 ++++++++++++++++++ .../components/earn/EarnAmount/styles.scss | 112 ++++++ .../helpers/__tests__/formatPoolStats.test.ts | 49 +++ .../helpers/formatPoolStats.ts | 39 +++ .../earn/PoolDetailsSheet/index.tsx | 144 ++++++++ .../earn/PoolDetailsSheet/poolDescriptions.ts | 26 ++ .../earn/PoolDetailsSheet/styles.scss | 78 +++++ .../__tests__/swapAmountHelpers.test.ts | 2 +- .../components/swap/SwapAmount/index.tsx | 2 +- extension/src/popup/helpers/blendDeposit.ts | 108 ++++++ .../src/popup/locales/en/translation.json | 19 + .../src/popup/locales/pt/translation.json | 19 + extension/src/popup/views/Earn/index.tsx | 17 +- 19 files changed, 1374 insertions(+), 5 deletions(-) rename extension/src/popup/components/{swap/SwapAmount/helpers/swapAmountDisplay.ts => amount/helpers/amountDisplay.ts} (100%) create mode 100644 extension/src/popup/components/earn/EarnAmount/NetworkFeeSheet.tsx create mode 100644 extension/src/popup/components/earn/EarnAmount/PoolCard.tsx create mode 100644 extension/src/popup/components/earn/EarnAmount/helpers/__tests__/earnCtaState.test.ts create mode 100644 extension/src/popup/components/earn/EarnAmount/helpers/earnCtaState.ts create mode 100644 extension/src/popup/components/earn/EarnAmount/hooks/useGetEarnAmountData.tsx create mode 100644 extension/src/popup/components/earn/EarnAmount/index.tsx create mode 100644 extension/src/popup/components/earn/EarnAmount/styles.scss create mode 100644 extension/src/popup/components/earn/PoolDetailsSheet/helpers/__tests__/formatPoolStats.test.ts create mode 100644 extension/src/popup/components/earn/PoolDetailsSheet/helpers/formatPoolStats.ts create mode 100644 extension/src/popup/components/earn/PoolDetailsSheet/index.tsx create mode 100644 extension/src/popup/components/earn/PoolDetailsSheet/poolDescriptions.ts create mode 100644 extension/src/popup/components/earn/PoolDetailsSheet/styles.scss create mode 100644 extension/src/popup/helpers/blendDeposit.ts 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/earn/EarnAmount/NetworkFeeSheet.tsx b/extension/src/popup/components/earn/EarnAmount/NetworkFeeSheet.tsx new file mode 100644 index 0000000000..6e701baf65 --- /dev/null +++ b/extension/src/popup/components/earn/EarnAmount/NetworkFeeSheet.tsx @@ -0,0 +1,85 @@ +import React from "react"; +import { Button, Icon, Text } from "@stellar/design-system"; +import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; + +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 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..3b72d42735 --- /dev/null +++ b/extension/src/popup/components/earn/EarnAmount/PoolCard.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { Icon, Text } from "@stellar/design-system"; +import { useTranslation } from "react-i18next"; + +import { formatRate } from "popup/components/earn/PoolDetailsSheet/helpers/formatPoolStats"; + +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/helpers/__tests__/earnCtaState.test.ts b/extension/src/popup/components/earn/EarnAmount/helpers/__tests__/earnCtaState.test.ts new file mode 100644 index 0000000000..075ed25386 --- /dev/null +++ b/extension/src/popup/components/earn/EarnAmount/helpers/__tests__/earnCtaState.test.ts @@ -0,0 +1,117 @@ +import BigNumber from "bignumber.js"; + +import { BLEND_DEPOSIT_XLM_FEE_BUFFER } from "@shared/constants/blend"; + +import { + getEarnCtaState, + getMaxDepositAmount, + 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); + }); +}); + +describe("getMaxDepositAmount", () => { + it("returns the full available balance for a non-XLM asset", () => { + // The fee comes out of a different balance, so nothing is held back. + expect(getMaxDepositAmount({ availableBalance: "500", isXlm: false })).toBe( + "500", + ); + }); + + it("holds back the resource-fee buffer for XLM", () => { + // getAvailableBalance only nets out the inclusion fee (~0.00001 XLM); the + // resource fee is ~0.0546 XLM and would otherwise blow the simulation. + expect(getMaxDepositAmount({ availableBalance: "100", isXlm: true })).toBe( + new BigNumber(100).minus(BLEND_DEPOSIT_XLM_FEE_BUFFER).toFixed(), + ); + }); + + it("floors at zero rather than going negative", () => { + expect(getMaxDepositAmount({ availableBalance: "0.1", isXlm: true })).toBe( + "0", + ); + }); + + it("returns zero when the buffer exactly consumes the balance", () => { + expect( + getMaxDepositAmount({ + availableBalance: BLEND_DEPOSIT_XLM_FEE_BUFFER, + isXlm: true, + }), + ).toBe("0"); + }); + + it("does not lose precision on a large balance", () => { + expect( + getMaxDepositAmount({ availableBalance: "1691.6912345", isXlm: true }), + ).toBe("1691.1912345"); + }); +}); 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..533c61c488 --- /dev/null +++ b/extension/src/popup/components/earn/EarnAmount/helpers/earnCtaState.ts @@ -0,0 +1,85 @@ +import BigNumber from "bignumber.js"; + +import { BLEND_DEPOSIT_XLM_FEE_BUFFER } from "@shared/constants/blend"; + +/** + * 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. + */ +export const needsXlmForFee = ({ + spendableXlm, + fee, +}: { + spendableXlm: string; + fee: string; +}) => new BigNumber(spendableXlm).lt(new BigNumber(fee)); + +/** + * Spendable amount to offer for a Max deposit. + * + * `getAvailableBalance` subtracts the base reserve and the *inclusion* fee, but + * a Blend `submit` is dominated by its resource fee — measured at ~0.0546 XLM + * against the live pool, roughly 5,000x the inclusion fee. Depositing the raw + * available balance of XLM therefore simulates into an insufficient-balance + * error. Hold back a buffer; Review re-checks against the real `minResourceFee` + * once simulation returns. + * + * Only XLM needs this — for any other asset the fee is paid from a separate + * balance, and the shortfall surfaces through the network-fee sheet instead. + */ +export const getMaxDepositAmount = ({ + availableBalance, + isXlm, +}: { + availableBalance: string; + isXlm: boolean; +}) => { + const available = new BigNumber(availableBalance); + if (!isXlm) { + return available.toFixed(); + } + return BigNumber.max( + available.minus(new BigNumber(BLEND_DEPOSIT_XLM_FEE_BUFFER)), + new BigNumber(0), + ).toFixed(); +}; 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/index.tsx b/extension/src/popup/components/earn/EarnAmount/index.tsx new file mode 100644 index 0000000000..1309902523 --- /dev/null +++ b/extension/src/popup/components/earn/EarnAmount/index.tsx @@ -0,0 +1,331 @@ +import React, { useEffect, 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 { + 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 { + saveAmount, + saveSimulation, + transactionDataSelector, +} from "popup/ducks/transactionSubmission"; +import { buildAndSimulateBlendDeposit } from "popup/helpers/blendDeposit"; +import { earnSelector, setEarnSubmitFailed } from "popup/ducks/earn"; + +import { PoolCard } from "./PoolCard"; +import { NetworkFeeSheet } from "./NetworkFeeSheet"; +import { + getEarnCtaState, + getMaxDepositAmount, + needsXlmForFee, +} from "./helpers/earnCtaState"; +import { + ResolvedEarnAmount, + useGetEarnAmountData, +} from "./hooks/useGetEarnAmountData"; + +import "./styles.scss"; + +const DEFAULT_AMOUNT = "0"; + +interface EarnAmountProps { + goBack: () => void; + /** + * Fired once the deposit has been built and simulated successfully. The + * prepared XDR is in redux (`transactionSimulation`) by the time this runs. + */ + onSimulated: () => void; +} + +export const EarnAmount = ({ goBack, onSimulated }: EarnAmountProps) => { + const { t } = useTranslation(); + const dispatch = useDispatch(); + const { state, fetchData } = useGetEarnAmountData(); + const { asset, amount, destination, transactionTimeout } = useSelector( + transactionDataSelector, + ); + const { pool, selectedAssetApy, lastSubmitFailed } = + useSelector(earnSelector); + const { recommendedFee } = useNetworkFees(); + + const [isPoolSheetOpen, setIsPoolSheetOpen] = useState(false); + const [isFeeSheetOpen, setIsFeeSheetOpen] = useState(false); + const [isSimulating, setIsSimulating] = useState(false); + const [simulationError, setSimulationError] = useState(""); + + useEffect(() => { + fetchData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + 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. The resource fee is far + // larger for a Blend submit and is handled by getMaxDepositAmount's buffer. + const availableBalance = asset + ? getAvailableBalance({ + assetCanonical: asset, + balances: data.balances.balances, + recommendedFee, + }) + : "0"; + + const isXlm = asset === "native"; + const maxDepositable = getMaxDepositAmount({ + availableBalance, + isXlm, + }); + + const enteredAmount = new BigNumber(cleanAmount(amount || "0")); + const isAmountTooHigh = enteredAmount.gt(new BigNumber(maxDepositable)); + const cta = getEarnCtaState({ + availableBalanceIsZero: new BigNumber(maxDepositable).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 })) { + setIsFeeSheetOpen(true); + return; + } + + setIsSimulating(true); + try { + const { preparedTransaction, simulationResponse } = + await buildAndSimulateBlendDeposit({ + publicKey: data.publicKey, + assetId: destination, + amount, + decimals, + networkDetails: data.networkDetails, + transactionFee: recommendedFee, + transactionTimeout, + }); + + dispatch( + saveSimulation({ + preparedTransaction, + response: simulationResponse, + }), + ); + onSimulated(); + } catch (error) { + // Surface the pool's own rejection (supply cap, frozen pool, stale + // oracle) rather than a generic failure — it is the only signal the user + // gets about why this deposit will not go through. + setSimulationError( + error instanceof Error ? error.message : String(error), + ); + } 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 && ( + setIsPoolSheetOpen(true)} + /> + )} + + + dispatch( + saveAmount( + new BigNumber(maxDepositable) + .multipliedBy(pct) + .decimalPlaces(decimals, BigNumber.ROUND_DOWN) + .toFixed(), + ), + ) + } + /> +
+
+ + + {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..8d28929f1e --- /dev/null +++ b/extension/src/popup/components/earn/EarnAmount/styles.scss @@ -0,0 +1,112 @@ +@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; + + // The ribbon sits behind the card's top edge, so the card overlaps it. + &__ribbon { + align-self: center; + background: var(--sds-clr-green-08); + color: var(--sds-clr-green-01); + border-radius: pxToRem(8) pxToRem(8) 0 0; + padding: pxToRem(4) pxToRem(12) pxToRem(10); + margin-bottom: pxToRem(-6); + width: calc(100% - #{pxToRem(40)}); + text-align: center; + } + + &__body { + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + gap: pxToRem(12); + width: 100%; + padding: pxToRem(14) pxToRem(16); + border: none; + cursor: pointer; + text-align: left; + background: var(--sds-clr-gray-03); + border-radius: pxToRem(12); + } + + &__identity { + color: var(--sds-clr-gray-11); + } + + &__chevron { + display: flex; + align-items: center; + justify-content: center; + width: pxToRem(28); + height: pxToRem(28); + border-radius: 50%; + background: var(--sds-clr-gray-05); + color: var(--sds-clr-gray-11); + } +} + +.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/PoolDetailsSheet/helpers/__tests__/formatPoolStats.test.ts b/extension/src/popup/components/earn/PoolDetailsSheet/helpers/__tests__/formatPoolStats.test.ts new file mode 100644 index 0000000000..2dbeb383e7 --- /dev/null +++ b/extension/src/popup/components/earn/PoolDetailsSheet/helpers/__tests__/formatPoolStats.test.ts @@ -0,0 +1,49 @@ +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"); + }); + + 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/PoolDetailsSheet/helpers/formatPoolStats.ts b/extension/src/popup/components/earn/PoolDetailsSheet/helpers/formatPoolStats.ts new file mode 100644 index 0000000000..5fdc8dab28 --- /dev/null +++ b/extension/src/popup/components/earn/PoolDetailsSheet/helpers/formatPoolStats.ts @@ -0,0 +1,39 @@ +import BigNumber from "bignumber.js"; + +/** + * 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 "--"; + } + + 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 ? "--" : `${new BigNumber(rate).times(100).toFormat(2)}%`; 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..be2e84f9b4 --- /dev/null +++ b/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx @@ -0,0 +1,144 @@ +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 { AssetIcon } from "popup/components/account/AccountAssets"; + +import { getPoolDescription } from "./poolDescriptions"; +import { formatCompactUsd, formatRate } from "./helpers/formatPoolStats"; + +import "./styles.scss"; + +interface PoolDetailsSheetProps { + pool: BlendCatalogPool; + onClose: () => void; +} + +const StatRow = ({ + label, + value, + isPositive = false, + testId, +}: { + label: string; + value: string; + isPositive?: boolean; + testId: string; +}) => ( +
+ + {label} + +
+ {value} +
+
+); + +/** + * Pool description and market stats, opened from the pool card on the amount + * screen. + * + * No Backstop row: the backend's pool catalog does not expose `backstop_usd`, + * and a stale hardcoded figure would misrepresent the pool's actual insurance. + */ +export const PoolDetailsSheet = ({ pool, onClose }: PoolDetailsSheetProps) => { + const { t } = useTranslation(); + const description = getPoolDescription(pool.id); + + return ( +
+
+
+ + {pool.name || t("Blend pool")} + + + {t("by Blend")} + +
+ +
+ + {description && ( +
+ + {t("Description")} + + + {description} + +
+ )} + + + {t("Pool Details")} + + +
+ + +
+ +
+
+ + {t("Accepted tokens")} + +
+ {pool.reserves.map((reserve) => ( + + ))} +
+
+ + +
+ + +
+ ); +}; diff --git a/extension/src/popup/components/earn/PoolDetailsSheet/poolDescriptions.ts b/extension/src/popup/components/earn/PoolDetailsSheet/poolDescriptions.ts new file mode 100644 index 0000000000..0a8daca800 --- /dev/null +++ b/extension/src/popup/components/earn/PoolDetailsSheet/poolDescriptions.ts @@ -0,0 +1,26 @@ +import i18n from "popup/helpers/localizationConfig"; + +/** + * Prose descriptions for the pools we surface, keyed by pool contract address. + * + * Hardcoded because the backend's pool catalog carries no description field. + * Keyed by contract ID rather than shown generically because the copy makes + * claims that are only true of a specific deployment: the mainnet Fixed pool's + * admin account is burned (master weight 0, no signers, all thresholds 0), so + * "no admin" is a fact about that pool, not about Blend pools in general. + * + * A pool with no entry renders no description rather than a wrong one. + */ +const POOL_DESCRIPTION_KEYS: Record = { + // Fixed Pool v2 — mainnet + CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD: + "Permissionless lending pool with no admin. Collateral factors, interest curves, and supported assets are locked at deployment.", + // Fixed Pool v2 — testnet + CCEBVDYM32YNYCVNRXQKDFFPISJJCV557CDZEIRBEE4NCV4KHPQ44HGF: + "Permissionless lending pool with no admin. Collateral factors, interest curves, and supported assets are locked at deployment.", +}; + +export const getPoolDescription = (poolId: string): string | null => { + const description = POOL_DESCRIPTION_KEYS[poolId]; + return description ? i18n.t(description) : null; +}; 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..8b35e07eda --- /dev/null +++ b/extension/src/popup/components/earn/PoolDetailsSheet/styles.scss @@ -0,0 +1,78 @@ +@use "../../../styles/utils.scss" as *; + +.PoolDetailsSheet { + padding: pxToRem(24); + display: flex; + flex-direction: column; + gap: pxToRem(12); + + &__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: pxToRem(12); + } + + &__identity { + color: var(--sds-clr-gray-11); + } + + &__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; + gap: pxToRem(4); + color: var(--sds-clr-gray-11); + } + + &__group { + background: var(--sds-clr-gray-03); + border-radius: pxToRem(12); + padding: 0 pxToRem(16); + } + + &__row { + 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); + } + } + + &__row-value { + color: var(--sds-clr-gray-12); + font-weight: 500; + + &--positive { + color: var(--sds-clr-green-11); + } + } + + // Overlapping stack, matching the design's accepted-token cluster. + &__tokens { + display: flex; + align-items: center; + + > * + * { + margin-left: pxToRem(-8); + } + } +} 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..303889fdad 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"; diff --git a/extension/src/popup/helpers/blendDeposit.ts b/extension/src/popup/helpers/blendDeposit.ts new file mode 100644 index 0000000000..8b8065145b --- /dev/null +++ b/extension/src/popup/helpers/blendDeposit.ts @@ -0,0 +1,108 @@ +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), + 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/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index 5d4c9ae0fc..5e7f85bbe5 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -1,4 +1,5 @@ { + "{{amount}} {{code}} available": "", "{{appName}} disconnected": "{{appName}} disconnected", "{{domain}} is not currently connected to Freighter": "{{domain}} is not currently connected to Freighter", "* All Stellar accounts must maintain a minimum balance of lumens.": "* All Stellar accounts must maintain a minimum balance of lumens.", @@ -11,6 +12,7 @@ "About": "About", "About unverified tokens": "About unverified tokens", "About verified tokens": "About verified tokens", + "Accepted tokens": "", "Account": "Account", "Account details": "Account details", "Account ID": "Account ID", @@ -42,6 +44,7 @@ "Add trustline icon": "Add trustline icon", "Add wallet": "Add wallet", "Add XLM": "Add XLM", + "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", @@ -91,10 +94,12 @@ "Balance": "Balance", "Balance ID": "Balance ID", "Before we start with migration, please read": "Before we start with migration, please read", + "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": "", "Bump To": "Bump To", "Buy {{code}}": "", "Buy Amount": "Buy Amount", @@ -102,6 +107,7 @@ "Buy XLM with Coinbase": "Buy XLM with Coinbase", "Buying": "Buying", "by": "by", + "by Blend": "", "Calculating...": "Calculating...", "Cancel": "Cancel", "Change asset": "Change asset", @@ -169,6 +175,8 @@ "Create Contract": "Create Contract", "Create New Address": "Create New Address", "Create new wallet": "Create new wallet", + "Current APY: {{rate}}*": "", + "Current Net APY": "", "Current Network": "Current Network", "Custom": "Custom", "dApps": "dApps", @@ -177,6 +185,7 @@ "Debug menu is only available in development mode.": "Debug menu is only available in development mode.", "Default": "Default", "Deleted": "Deleted", + "Deposit": "", "Deposit supported tokens into DeFi pools and start earning rewards.": "", "Description": "Description", "Destination": "Destination", @@ -198,6 +207,7 @@ "Don’t share this phrase with anyone": "Don’t share this phrase with anyone", "Done": "Done", "Earn": "", + "Earn is not supported on this network": "", "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.", @@ -330,6 +340,7 @@ "Insufficient Balance": "Insufficient Balance", "Insufficient balance. Maximum spendable: {{amount}} {{symbol}}": "Insufficient balance. Maximum spendable: {{amount}} {{symbol}}", "Insufficient Fee": "Insufficient Fee", + "Insufficient funds": "", "INSUFFICIENT FUNDS FOR FEE": "INSUFFICIENT FUNDS FOR FEE", "Invalid address": "Invalid address", "Invalid Authorization Entry": "Invalid Authorization Entry", @@ -352,6 +363,7 @@ "Leave Feedback": "Leave Feedback", "Leave feedback about Blockaid warnings and messages": "Leave feedback about Blockaid warnings and messages", "Ledger will not display the transaction details in the device display prior to signing so make sure you only interact with applications you know and trust.": "Ledger will not display the transaction details in the device display prior to signing so make sure you only interact with applications you know and trust.", + "Lending Interest": "", "Let’s go": "Let’s go", "Limit": "Limit", "Links": "Links", @@ -470,6 +482,7 @@ "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 Details": "", "Popular tokens": "Popular tokens", "powered by": "powered by", "Powered by ": "Powered by ", @@ -507,6 +520,7 @@ "Retry": "Retry", "Review accounts to migrate": "Review accounts to migrate", "Review authorization on device": "Review authorization on device", + "Review deposit": "", "Review message on device": "Review message on device", "Review Send": "Review Send", "Review swap": "Review swap", @@ -593,6 +607,7 @@ "Success": "Success", "Success!": "Success!", "Suggestions": "Suggestions", + "Supplied": "", "Supported tokens": "", "Suspicious": "Suspicious", "Suspicious Request": "Suspicious Request", @@ -695,6 +710,7 @@ "Transaction Details": "Transaction Details", "Transaction failed": "Transaction failed", "Transaction Failed": "Transaction Failed", + "Transaction failed. Try again.": "", "Transaction Fee": "Transaction Fee", "Transaction Rejected": "Transaction Rejected.", "Transaction Request": "Transaction Request", @@ -755,6 +771,7 @@ "wasm": "wasm", "Wasm Hash": "Wasm Hash", "We couldn’t load earnable tokens. 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", @@ -776,10 +793,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 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 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", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index 9998e00c2e..75c8a8241e 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -1,4 +1,5 @@ { + "{{amount}} {{code}} available": "", "{{appName}} disconnected": "{{appName}} desconectado", "{{domain}} is not currently connected to Freighter": "{{domain}} não está atualmente conectado ao Freighter", "* All Stellar accounts must maintain a minimum balance of lumens.": "* Todas as contas Stellar devem manter um saldo mínimo de lumens.", @@ -11,6 +12,7 @@ "About": "Sobre", "About unverified tokens": "Sobre tokens não verificados", "About verified tokens": "Sobre tokens verificados", + "Accepted tokens": "", "Account": "Conta", "Account details": "Detalhes da conta", "Account ID": "ID da Conta", @@ -42,6 +44,7 @@ "Add trustline icon": "Ícone adicionar trustline", "Add wallet": "Adicionar carteira", "Add XLM": "Adicionar XLM", + "Add XLM to your wallet to continue": "", "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", @@ -91,10 +94,12 @@ "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": "", "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": "", "Bump To": "Bump Para", "Buy {{code}}": "", "Buy Amount": "Quantia de Compra", @@ -102,6 +107,7 @@ "Buy XLM with Coinbase": "Comprar XLM com Coinbase", "Buying": "Comprando", "by": "por", + "by Blend": "", "Calculating...": "Calculando...", "Cancel": "Cancelar", "Change asset": "Alterar ativo", @@ -169,6 +175,8 @@ "Create Contract": "Criar Contrato", "Create New Address": "Criar Novo Endereço", "Create new wallet": "Criar nova carteira", + "Current APY: {{rate}}*": "", + "Current Net APY": "", "Current Network": "Rede Atual", "Custom": "Personalizado", "dApps": "dApps", @@ -177,6 +185,7 @@ "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": "", "Deposit supported tokens into DeFi pools and start earning rewards.": "", "Description": "Descrição", "Destination": "Destino", @@ -198,6 +207,7 @@ "Don’t share this phrase with anyone": "Não compartilhe esta frase com ninguém", "Done": "Concluído", "Earn": "", + "Earn is not supported on this network": "", "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.", @@ -330,6 +340,7 @@ "Insufficient Balance": "Saldo Insuficiente", "Insufficient balance. Maximum spendable: {{amount}} {{symbol}}": "Saldo insuficiente. Máximo disponível: {{amount}} {{symbol}}", "Insufficient Fee": "Taxa Insuficiente", + "Insufficient funds": "", "INSUFFICIENT FUNDS FOR FEE": "FUNDOS INSUFICIENTES PARA TAXA", "Invalid address": "Endereço inválido", "Invalid Authorization Entry": "Entrada de autorização inválida", @@ -352,6 +363,7 @@ "Leave Feedback": "Deixar Feedback", "Leave feedback about Blockaid warnings and messages": "Deixar feedback sobre avisos e mensagens do Blockaid", "Ledger will not display the transaction details in the device display prior to signing so make sure you only interact with applications you know and trust.": "O Ledger não exibirá os detalhes da transação na tela do dispositivo antes de assinar, então certifique-se de interagir apenas com aplicativos que você conhece e confia.", + "Lending Interest": "", "Let’s go": "Vamos lá", "Limit": "Limite", "Links": "Links", @@ -470,6 +482,7 @@ "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 Details": "", "Popular tokens": "Tokens populares", "powered by": "desenvolvido por", "Powered by ": "Desenvolvido por ", @@ -507,6 +520,7 @@ "Retry": "Tentar novamente", "Review accounts to migrate": "Revisar contas para migrar", "Review authorization on device": "Revise a autorização no dispositivo", + "Review deposit": "", "Review message on device": "Revise a mensagem no dispositivo", "Review Send": "Revisar Envio", "Review swap": "Revisar troca", @@ -593,6 +607,7 @@ "Success": "Sucesso", "Success!": "Sucesso!", "Suggestions": "Sugestões", + "Supplied": "", "Supported tokens": "", "Suspicious": "Suspeito", "Suspicious Request": "Solicitação Suspeita", @@ -695,6 +710,7 @@ "Transaction Details": "Detalhes da Transação", "Transaction failed": "Transação falhou", "Transaction Failed": "Transação Falhou", + "Transaction failed. Try again.": "", "Transaction Fee": "Taxa de Transação", "Transaction Rejected": "Transação Rejeitada.", "Transaction Request": "Solicitação de Transação", @@ -755,6 +771,7 @@ "wasm": "wasm", "Wasm Hash": "Hash Wasm", "We couldn’t load earnable tokens. Please try again.": "", + "We couldn’t load your balances. Please try again.": "", "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", @@ -776,10 +793,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": "", "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 network fee": "", "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", diff --git a/extension/src/popup/views/Earn/index.tsx b/extension/src/popup/views/Earn/index.tsx index d6d4cc9ec6..fb6b217899 100644 --- a/extension/src/popup/views/Earn/index.tsx +++ b/extension/src/popup/views/Earn/index.tsx @@ -21,6 +21,7 @@ import { 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 "./styles.scss"; @@ -143,9 +144,19 @@ export const Earn = () => { onSwapRequested={() => navigateTo(ROUTES.swap, navigate)} /> ); - // AMOUNT, SWAP and DEPOSIT_CONFIRM land in later steps. Returning null - // rather than falling through to a default keeps an unbuilt step from - // silently rendering another step's screen. + case STEPS.AMOUNT: + return ( + goToStep(STEPS.CHOOSE_TOKEN, "from-left")} + // The review sheet is the next thing to land; until it exists the + // simulated deposit stops here rather than stepping into a screen + // that would sign without a review. + onSimulated={() => {}} + /> + ); + // SWAP and DEPOSIT_CONFIRM land in later steps. Returning null rather + // than falling through to a default keeps an unbuilt step from silently + // rendering another step's screen. default: return null; } From 90d01996886c21e1a0c492fff4480dc8ac523fc5 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Mon, 17 Aug 2026 14:01:54 -0600 Subject: [PATCH 05/43] feat(earn): add the review sheet and deposit terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the deposit path — the amount CTA simulates, opens a review sheet, and Confirm signs and submits. useSimulateEarnDeposit returns the same State shape Send and Swap produce, so the shared FeesPane works unchanged; the Blockaid scan runs on the prepared transaction, not the pre-assembly build. projectEarnings uses simple interest, reproducing the design's worked example exactly ($500 at 16.94% -> $84.70/yr). Uses dedicated submit/terminal components rather than useSubmitTxData and SendingTransaction: the former registers the destination as a recent address (here, a pool contract) and signs via the classic thunks; the latter hardcodes a Send-shaped summary. A failed submission returns to the amount screen with a banner, with the terminal blanked for that frame so it never flashes. --- .../hooks/useSimulateEarnDeposit.tsx | 93 +++++ .../components/earn/EarnAmount/index.tsx | 111 ++++-- .../helpers/__tests__/projectEarnings.test.ts | 77 ++++ .../EarnReview/helpers/projectEarnings.ts | 38 ++ .../components/earn/EarnReview/index.tsx | 342 ++++++++++++++++++ .../components/earn/EarnReview/styles.scss | 130 +++++++ .../EarnSubmit/hooks/useSubmitEarnTxData.tsx | 118 ++++++ .../components/earn/EarnSubmit/index.tsx | 189 ++++++++++ .../components/earn/EarnSubmit/styles.scss | 69 ++++ extension/src/popup/constants/metricsNames.ts | 9 +- extension/src/popup/ducks/earn.ts | 11 + .../src/popup/locales/en/translation.json | 11 + .../src/popup/locales/pt/translation.json | 11 + extension/src/popup/views/Earn/index.tsx | 49 ++- 14 files changed, 1219 insertions(+), 39 deletions(-) create mode 100644 extension/src/popup/components/earn/EarnAmount/hooks/useSimulateEarnDeposit.tsx create mode 100644 extension/src/popup/components/earn/EarnReview/helpers/__tests__/projectEarnings.test.ts create mode 100644 extension/src/popup/components/earn/EarnReview/helpers/projectEarnings.ts create mode 100644 extension/src/popup/components/earn/EarnReview/index.tsx create mode 100644 extension/src/popup/components/earn/EarnReview/styles.scss create mode 100644 extension/src/popup/components/earn/EarnSubmit/hooks/useSubmitEarnTxData.tsx create mode 100644 extension/src/popup/components/earn/EarnSubmit/index.tsx create mode 100644 extension/src/popup/components/earn/EarnSubmit/styles.scss 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..b8b200ae89 --- /dev/null +++ b/extension/src/popup/components/earn/EarnAmount/hooks/useSimulateEarnDeposit.tsx @@ -0,0 +1,93 @@ +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"; + +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, + // Internal flows have no originating dApp; the pool contract is the + // subject, so attribute the scan to it. + params.assetId, + 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 index 1309902523..c350702510 100644 --- a/extension/src/popup/components/earn/EarnAmount/index.tsx +++ b/extension/src/popup/components/earn/EarnAmount/index.tsx @@ -29,11 +29,16 @@ import { getAssetDecimals, getAvailableBalance } from "popup/helpers/soroban"; import { useNetworkFees } from "popup/helpers/useNetworkFees"; import { saveAmount, - saveSimulation, transactionDataSelector, } from "popup/ducks/transactionSubmission"; -import { buildAndSimulateBlendDeposit } from "popup/helpers/blendDeposit"; -import { earnSelector, setEarnSubmitFailed } from "popup/ducks/earn"; +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"; @@ -46,6 +51,7 @@ import { ResolvedEarnAmount, useGetEarnAmountData, } from "./hooks/useGetEarnAmountData"; +import { useSimulateEarnDeposit } from "./hooks/useSimulateEarnDeposit"; import "./styles.scss"; @@ -53,28 +59,32 @@ const DEFAULT_AMOUNT = "0"; interface EarnAmountProps { goBack: () => void; - /** - * Fired once the deposit has been built and simulated successfully. The - * prepared XDR is in redux (`transactionSimulation`) by the time this runs. - */ - onSimulated: () => void; + /** Confirmed on the review sheet — hand off to the submit step. */ + onConfirm: () => void; } -export const EarnAmount = ({ goBack, onSimulated }: EarnAmountProps) => { +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, lastSubmitFailed } = - useSelector(earnSelector); + 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); useEffect(() => { fetchData(); @@ -177,28 +187,36 @@ export const EarnAmount = ({ goBack, onSimulated }: EarnAmountProps) => { setIsSimulating(true); try { - const { preparedTransaction, simulationResponse } = - await buildAndSimulateBlendDeposit({ - publicKey: data.publicKey, - assetId: destination, - amount, - decimals, - networkDetails: data.networkDetails, - transactionFee: recommendedFee, - transactionTimeout, - }); + // The existing position is the "before" half of Review's 0.00 -> N row. + // Fetched alongside the simulation and deliberately non-fatal: a stale + // before-value must never block a deposit that is otherwise valid. + getBlendSuppliedTokens({ + publicKey: data.publicKey, + poolId: destination, + assetId: selectedAssetId, + networkDetails: data.networkDetails, + }) + .then((raw) => + dispatch( + saveCurrentPositionTokens( + formatTokenAmount(new BigNumber(raw), decimals), + ), + ), + ) + .catch(() => dispatch(saveCurrentPositionTokens("0"))); - dispatch( - saveSimulation({ - preparedTransaction, - response: simulationResponse, - }), - ); - onSimulated(); + await simulate({ + publicKey: data.publicKey, + assetId: selectedAssetId, + amount, + decimals, + networkDetails: data.networkDetails, + transactionFee: recommendedFee, + transactionTimeout, + }); + + setIsReviewOpen(true); } catch (error) { - // Surface the pool's own rejection (supply cap, frozen pool, stale - // oracle) rather than a generic failure — it is the only signal the user - // gets about why this deposit will not go through. setSimulationError( error instanceof Error ? error.message : String(error), ); @@ -301,6 +319,37 @@ export const EarnAmount = ({ goBack, onSimulated }: EarnAmountProps) => {
+ + setIsReviewOpen(false)} + onConfirm={() => { + setIsReviewOpen(false); + onConfirm(); + }} + /> + + { + 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..e05ffd700f --- /dev/null +++ b/extension/src/popup/components/earn/EarnReview/helpers/projectEarnings.ts @@ -0,0 +1,38 @@ +import BigNumber from "bignumber.js"; + +/** + * 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 ? "--" : `$${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..29a67a15b2 --- /dev/null +++ b/extension/src/popup/components/earn/EarnReview/index.tsx @@ -0,0 +1,342 @@ +import React, { useState } from "react"; +import BigNumber from "bignumber.js"; +import { Button, Icon, Text } from "@stellar/design-system"; +import { useTranslation } from "react-i18next"; +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 { AuthEntries } from "popup/components/AuthEntry"; +import { FeesPane } from "popup/components/InternalTransaction/FeesPane"; +import { Summary } from "popup/views/SignTransaction/Preview/Summary"; +import { Details } from "popup/views/SignTransaction/Preview/Details"; +import { AssetIcon } from "popup/components/account/AccountAssets"; +import { getAuthEntryBoundAddress } from "popup/helpers/soroban"; +import { formatAmount } from "popup/helpers/formatters"; +import { formatRate } from "popup/components/earn/PoolDetailsSheet/helpers/formatPoolStats"; + +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; +} + +const Row = ({ + label, + value, + isPositive = false, + testId, +}: { + label: string; + value: React.ReactNode; + isPositive?: boolean; + testId?: string; +}) => ( +
+ + {label} + +
+ {value} +
+
+); + +/** + * 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 [isOnDetailsPane, setIsOnDetailsPane] = useState(false); + const [isOnFeesPane, setIsOnFeesPane] = useState(false); + + const preparedXdr = simulationState.data?.transactionXdr; + + 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 && } +
+
+ ); + } + + return ( +
+
+ + {t("You are depositing")} + +
+ +
+
+ {formatAmount(amount)} {assetCode} +
+ + {amountUsd === null ? "--" : `$${formatAmount(amountUsd)}`} + +
+
+ +
+ +
+ +
+
+ +
+
+ + {t("To")} + +
+ {pool?.name || t("Blend pool")} +
+
+
+
+ +
+ + + {formatAmount(currentPosition)} + + {" → "} + {positionAfter} {assetCode} + + } + /> + + + + {formatProjection(currentEarnings.monthly)} + + {" → "} + {formatProjection(monthly)} + + } + /> + + + {formatProjection(currentEarnings.yearly)} + + {" → "} + {formatProjection(yearly)} + + } + /> +
+ + {detailTx && ( + + )} + +
+ + + +
+
+ ); +}; 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..ff81a00b90 --- /dev/null +++ b/extension/src/popup/components/earn/EarnReview/styles.scss @@ -0,0 +1,130 @@ +@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); + } + + &__amount { + color: var(--sds-clr-gray-12); + font-size: pxToRem(16); + font-weight: 500; + } + + &__pool-icon { + width: pxToRem(32); + height: pxToRem(32); + border-radius: 50%; + background: var(--sds-clr-green-04); + color: var(--sds-clr-green-11); + display: flex; + align-items: center; + justify-content: center; + } + + &__chevrons { + color: var(--sds-clr-gray-09); + padding: pxToRem(6) 0 0 pxToRem(10); + } + + &__row { + 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); + } + } + + &__row-value { + color: var(--sds-clr-gray-12); + font-weight: 500; + text-align: right; + + &--positive { + color: var(--sds-clr-green-11); + } + } + + // 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); + } + + &__details-header { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: pxToRem(8); + color: var(--sds-clr-gray-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); + } + + &__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/hooks/useSubmitEarnTxData.tsx b/extension/src/popup/components/earn/EarnSubmit/hooks/useSubmitEarnTxData.tsx new file mode 100644 index 0000000000..a020090a89 --- /dev/null +++ b/extension/src/popup/components/earn/EarnSubmit/hooks/useSubmitEarnTxData.tsx @@ -0,0 +1,118 @@ +import { useReducer } from "react"; +import { useDispatch } from "react-redux"; +import { captureException } from "@sentry/browser"; + +import { NetworkDetails } from "@shared/constants/stellar"; +import { AppDispatch } from "popup/App"; +import { initialState, isError, reducer } from "helpers/request"; +import { emitMetric } from "helpers/metrics"; +import { isMainnet } from "helpers/stellar"; +import { METRIC_NAMES } from "popup/constants/metricsNames"; +import { AccountBalances, useGetBalances } from "helpers/hooks/useGetBalances"; +import { + signFreighterSorobanTransaction, + submitFreighterSorobanTransaction, +} 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; the prepared XDR is already + * signed by the time this runs (the deposit's auth carries source-account + * credentials, so the envelope signature covers it — no auth-entry round trip). + */ +export function useSubmitEarnTxData({ + isHardwareWallet, + networkDetails, + publicKey, + xdr, + assetCode, +}: { + isHardwareWallet: boolean; + networkDetails: NetworkDetails; + publicKey: string; + xdr: string; + assetCode: string; +}) { + const reduxDispatch = useDispatch(); + const [state, dispatch] = useReducer( + reducer, + initialState, + ); + const { fetchData: fetchBalances } = useGetBalances({ + showHidden: false, + includeIcons: false, + }); + + const fetchData = async () => { + dispatch({ type: "FETCH_DATA_START" }); + try { + let signedXDR = xdr; + + if (!isHardwareWallet) { + const res = await reduxDispatch( + signFreighterSorobanTransaction({ + transactionXDR: xdr, + network: networkDetails.networkPassphrase, + }), + ); + + if ( + signFreighterSorobanTransaction.fulfilled.match(res) && + res.payload.signedTransaction + ) { + signedXDR = res.payload.signedTransaction; + } + } + + const submitResp = await reduxDispatch( + submitFreighterSorobanTransaction({ + publicKey, + signedXDR, + networkDetails, + }), + ); + + if (submitFreighterSorobanTransaction.fulfilled.match(submitResp)) { + emitMetric(METRIC_NAMES.earnDepositCompleted, { + asset_code: assetCode, + }); + + // 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..597096645f --- /dev/null +++ b/extension/src/popup/components/earn/EarnSubmit/index.tsx @@ -0,0 +1,189 @@ +import React, { useEffect } 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 { HardwareSign } from "popup/components/hardwareConnect/HardwareSign"; +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 { + ShowOverlayStatus, + transactionSubmissionSelector, +} from "popup/ducks/transactionSubmission"; +import { earnSelector } from "popup/ducks/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 + * popup. The result is deliberately not tracked afterwards: the outcome shows up + * in the refreshed balances and in history, and following it would mean owning + * an in-flight submission outside the popup, which nothing does today. + */ +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 } = useSelector(earnSelector); + + 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, + }); + + useEffect(() => { + fetchData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if ( + submission.hardwareWalletData?.status === ShowOverlayStatus.IN_PROGRESS && + hardwareWalletType + ) { + return ( + + ); + } + + const isSuccess = + submissionState.state === RequestState.SUCCESS && + submission.submitStatus !== ActionStatus.ERROR; + const isLoading = !isSuccess; + + return ( + + + {isLoading && ( + <> +
+ {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!")} + + )} +
+ +
+
+ + +
+ +
+
+ + {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..2a3083dad3 --- /dev/null +++ b/extension/src/popup/components/earn/EarnSubmit/styles.scss @@ -0,0 +1,69 @@ +@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); + } + + &__pool-icon { + width: pxToRem(32); + height: pxToRem(32); + border-radius: 50%; + background: var(--sds-clr-green-04); + color: var(--sds-clr-green-11); + display: flex; + align-items: center; + justify-content: center; + } + + &__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/constants/metricsNames.ts b/extension/src/popup/constants/metricsNames.ts index 0758f417a5..bdc5125439 100644 --- a/extension/src/popup/constants/metricsNames.ts +++ b/extension/src/popup/constants/metricsNames.ts @@ -38,6 +38,12 @@ export const METRIC_NAMES = { swapCompleted: "swap.completed", swapFailed: "swap.failed", + // -- Earn ---------------------------------------------------------------- + earnDepositCompleted: "earn.deposit_completed", + earnDepositFailed: "earn.deposit_failed", + earnPoolDetailsOpened: "earn.pool_details_opened", + earnXlmFeeInsufficientShown: "earn.xlm_fee_insufficient_shown", + // -- Collectibles -------------------------------------------------------- collectibleSendCompleted: "collectible_send.completed", collectibleSendFailed: "collectible_send.failed", @@ -81,7 +87,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/ducks/earn.ts b/extension/src/popup/ducks/earn.ts index 56472c64fa..e22a85d351 100644 --- a/extension/src/popup/ducks/earn.ts +++ b/extension/src/popup/ducks/earn.ts @@ -17,6 +17,12 @@ import { BlendCatalogPool } from "@shared/api/types/blend"; 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 @@ -43,6 +49,7 @@ export interface EarnState { export const initialState: EarnState = { pool: null, + selectedAssetId: "", selectedAssetApy: null, currentPositionTokens: "0", hasSeenIntro: null, @@ -59,6 +66,9 @@ const earnSlice = createSlice({ 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; }, @@ -83,6 +93,7 @@ const earnSlice = createSlice({ export const { saveEarnPool, saveSelectedAssetApy, + saveSelectedAssetId, saveCurrentPositionTokens, setEarnIntroSeen, setEarnSubmitFailed, diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index 5e7f85bbe5..02b60baefe 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -1,5 +1,6 @@ { "{{amount}} {{code}} available": "", + "{{amount}} {{code}} to {{pool}}": "", "{{appName}} disconnected": "{{appName}} disconnected", "{{domain}} is not currently connected to Freighter": "{{domain}} is not currently connected to Freighter", "* All Stellar accounts must maintain a minimum balance of lumens.": "* All Stellar accounts must maintain a minimum balance of lumens.", @@ -175,6 +176,7 @@ "Create Contract": "Create Contract", "Create New Address": "Create New Address", "Create new wallet": "Create new wallet", + "Current APY": "", "Current APY: {{rate}}*": "", "Current Net APY": "", "Current Network": "Current Network", @@ -187,6 +189,8 @@ "Deleted": "Deleted", "Deposit": "", "Deposit supported tokens into DeFi pools and start earning rewards.": "", + "Deposited!": "", + "Depositing": "", "Description": "Description", "Destination": "Destination", "Destination account does not accept this asset": "Destination account does not accept this asset", @@ -255,6 +259,7 @@ "Fee": "Fee", "Fee breakdown": "Fee breakdown", "Fee is required": "Fee is required", + "Fee settings": "", "Fee unavailable": "Fee unavailable", "Feedback": "Feedback", "Feedback?": "Feedback?", @@ -410,6 +415,7 @@ "Min Price": "Min Price", "Minimum XLM needed": "Minimum XLM needed", "Minted": "Minted", + "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.", @@ -484,6 +490,7 @@ "Please try using the suggested fee and try again.": "Please try using the suggested fee and try again.", "Pool Details": "", "Popular tokens": "Popular tokens", + "Position": "", "powered by": "powered by", "Powered by ": "Powered by ", "Pre Auth Transaction": "Pre Auth Transaction", @@ -686,6 +693,7 @@ "Timeout": "Timeout", "Timeout (seconds)": "Timeout (seconds)", "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.", @@ -784,6 +792,8 @@ "XDR": "XDR", "XLM": "XLM", "XLM balance": "XLM balance", + "Yearly earnings (est.)": "", + "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", @@ -791,6 +801,7 @@ "You can access this later in the app, but we strongly recommend saving this in a safe place.": "You can access this later in the app, but we strongly recommend saving this in a safe place.", "You can also paste the phrase in the first box.": "You can also paste the phrase in the first box.", "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 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": "", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index 75c8a8241e..dc246a7e9e 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -1,5 +1,6 @@ { "{{amount}} {{code}} available": "", + "{{amount}} {{code}} to {{pool}}": "", "{{appName}} disconnected": "{{appName}} desconectado", "{{domain}} is not currently connected to Freighter": "{{domain}} não está atualmente conectado ao Freighter", "* All Stellar accounts must maintain a minimum balance of lumens.": "* Todas as contas Stellar devem manter um saldo mínimo de lumens.", @@ -175,6 +176,7 @@ "Create Contract": "Criar Contrato", "Create New Address": "Criar Novo Endereço", "Create new wallet": "Criar nova carteira", + "Current APY": "", "Current APY: {{rate}}*": "", "Current Net APY": "", "Current Network": "Rede Atual", @@ -187,6 +189,8 @@ "Deleted": "Excluído", "Deposit": "", "Deposit supported tokens into DeFi pools and start earning rewards.": "", + "Deposited!": "", + "Depositing": "", "Description": "Descrição", "Destination": "Destino", "Destination account does not accept this asset": "A conta de destino não aceita este ativo", @@ -255,6 +259,7 @@ "Fee": "Taxa", "Fee breakdown": "Detalhes da taxa", "Fee is required": "A taxa é obrigatória", + "Fee settings": "", "Fee unavailable": "Taxa indisponível", "Feedback": "Feedback", "Feedback?": "Feedback?", @@ -410,6 +415,7 @@ "Min Price": "Preço Mínimo", "Minimum XLM needed": "XLM mínimo necessário", "Minted": "Cunhado", + "Monthly earnings (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.", @@ -484,6 +490,7 @@ "Please try using the suggested fee and try again.": "Por favor, tente usar a taxa sugerida e tente novamente.", "Pool Details": "", "Popular tokens": "Tokens populares", + "Position": "", "powered by": "desenvolvido por", "Powered by ": "Desenvolvido por ", "Pre Auth Transaction": "Transação Pré-Autorizada", @@ -686,6 +693,7 @@ "Timeout": "Tempo esgotado", "Timeout (seconds)": "Timeout (segundos)", "to": "para", + "To": "", "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.", @@ -784,6 +792,8 @@ "XDR": "XDR", "XLM": "XLM", "XLM balance": "Saldo XLM", + "Yearly earnings (est.)": "", + "You are depositing": "", "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", @@ -791,6 +801,7 @@ "You can access this later in the app, but we strongly recommend saving this in a safe place.": "Você pode acessar isso mais tarde no aplicativo, mas recomendamos fortemente salvar isso em um local seguro.", "You can also paste the phrase in the first box.": "Você também pode colar a frase na primeira caixa.", "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": "", "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": "", diff --git a/extension/src/popup/views/Earn/index.tsx b/extension/src/popup/views/Earn/index.tsx index fb6b217899..1ad1f07a1a 100644 --- a/extension/src/popup/views/Earn/index.tsx +++ b/extension/src/popup/views/Earn/index.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef, useState } from "react"; -import { useDispatch } from "react-redux"; +import { useDispatch, useSelector } from "react-redux"; import { useNavigate } from "react-router-dom"; import { getCanonicalFromAsset } from "@shared/helpers/stellar"; @@ -7,21 +7,29 @@ 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 { resetEarn, saveEarnPool, saveSelectedAssetApy, + saveSelectedAssetId, + setEarnSubmitFailed, } from "popup/ducks/earn"; +import { emitMetric } from "helpers/metrics"; +import { METRIC_NAMES } from "popup/constants/metricsNames"; 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 "./styles.scss"; @@ -49,6 +57,7 @@ export const Earn = () => { const dispatch = useDispatch(); const navigate = useNavigate(); const { hasSeenIntro, dismissIntro } = useEarnIntroSeen(); + const submission = useSelector(transactionSubmissionSelector); // Start on CHOOSE_TOKEN and only fall back to the interstitial once the // persisted flag has actually resolved to false. Defaulting to INTRO instead @@ -93,6 +102,22 @@ export const Earn = () => { } }, [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. + useEffect(() => { + if (submission.submitStatus !== ActionStatus.ERROR) { + return; + } + emitMetric(METRIC_NAMES.earnDepositFailed); + 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"); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [submission.submitStatus]); + // Emit a screen-view metric only once per step transition. useEffect(() => { if (activeStep === lastEmittedStep.current) { @@ -127,6 +152,7 @@ export const Earn = () => { onSelect={(option, resolved) => { dispatch(saveEarnPool(resolved.pool)); dispatch(saveSelectedAssetApy(option.apy)); + dispatch(saveSelectedAssetId(option.assetId)); dispatch( saveAsset(getCanonicalFromAsset(option.code, option.issuer)), ); @@ -148,14 +174,23 @@ export const Earn = () => { return ( goToStep(STEPS.CHOOSE_TOKEN, "from-left")} - // The review sheet is the next thing to land; until it exists the - // simulated deposit stops here rather than stepping into a screen - // that would sign without a review. - onSimulated={() => {}} + 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 ( + ); - // SWAP and DEPOSIT_CONFIRM land in later steps. Returning null rather - // than falling through to a default keeps an unbuilt step from silently + // SWAP lands with the swap-branch work. Returning null rather than + // falling through to a default keeps an unbuilt step from silently // rendering another step's screen. default: return null; From 3fa196f8994d1415c3d584a02f418684805e8e91 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Mon, 17 Aug 2026 14:22:15 -0600 Subject: [PATCH 06/43] feat(earn): add the swap-within-earn branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tapping a token the account holds none of now opens a swap inside the Earn flow, pinned to that token, returning to the picker with a toast and the new balance. Replaces the interim hand-off to the Swap route. EarnSwap is a sibling of the Swap route, owning its own sub-steps so the picker stays mounted underneath. Shared additions (all optional, existing behaviour preserved): onDone/onClose/onDismissError on TransactionConfirm, onDismiss on SubmitFail, isDestinationLocked on SwapAmount, and the quote-expiry recovery extracted into useSwapSubmitQuoteExpiry so a stale quote does not dead-end a user mid-deposit. resolveSwapDestination reads the canonical off the SAC's name() for zero-balance tokens, which the earn catalog identifies only by contract address — verified against the live contracts. Sets ?swap=true while the branch is active so useIsSwap classifies it correctly without needing to change. --- .../InternalTransaction/SubmitFail/index.tsx | 10 +- .../SubmitTransaction/index.tsx | 38 ++++- .../popup/components/earn/EarnSwap/index.tsx | 149 ++++++++++++++++++ .../helpers/resolveSwapDestination.ts | 66 ++++++++ .../hooks/useSwapSubmitQuoteExpiry.ts | 61 +++++++ .../components/swap/SwapAmount/index.tsx | 13 ++ .../src/popup/locales/en/translation.json | 1 + .../src/popup/locales/pt/translation.json | 1 + extension/src/popup/views/Earn/index.tsx | 91 +++++++++-- extension/src/popup/views/Swap/index.tsx | 31 +--- 10 files changed, 422 insertions(+), 39 deletions(-) create mode 100644 extension/src/popup/components/earn/EarnSwap/index.tsx create mode 100644 extension/src/popup/components/earn/EarnTokenPicker/helpers/resolveSwapDestination.ts create mode 100644 extension/src/popup/components/swap/SwapAmount/hooks/useSwapSubmitQuoteExpiry.ts 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/earn/EarnSwap/index.tsx b/extension/src/popup/components/earn/EarnSwap/index.tsx new file mode 100644 index 0000000000..ba31e8beba --- /dev/null +++ b/extension/src/popup/components/earn/EarnSwap/index.tsx @@ -0,0 +1,149 @@ +import React, { useEffect, useState } from "react"; +import { useDispatch, useSelector } from "react-redux"; + +import { AppDispatch } from "popup/App"; +import { EARN_SWAP_STEPS } from "popup/constants/earn"; +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/SwapAmount/hooks/useSwapSubmitQuoteExpiry"; +import { resetSimulation } from "popup/ducks/token-payment"; +import { + DestinationTokenDetails, + resetSubmission, + saveAmount, + saveAmountUsd, + saveAsset, + saveDestinationAsset, + saveDestinationTokenDetails, + saveIsToken, + transactionSubmissionSelector, +} from "popup/ducks/transactionSubmission"; + +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 — the whole point of entering here. + * + * Not rendered inside a SlideupModal: those self-measure via scrollHeight, which + * breaks for full-height screens using View.Content's footer. + */ +export const EarnSwap = ({ + destinationAsset, + destinationTokenDetails, + onDone, + onCancel, +}: EarnSwapProps) => { + 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("0")); + dispatch(saveAmountUsd("0.00")); + 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 }); + }; + + 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 ( + 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 locked, but the prop is + // required; point it at the source picker rather than leaving a + // no-op that would silently swallow a future unlock. + goToEditDst={() => setActiveStep(EARN_SWAP_STEPS.SET_FROM_ASSET)} + goToNext={() => setActiveStep(EARN_SWAP_STEPS.SWAP_CONFIRM)} + /> + ); + } + } +}; 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..e2578d748e --- /dev/null +++ b/extension/src/popup/components/earn/EarnTokenPicker/helpers/resolveSwapDestination.ts @@ -0,0 +1,66 @@ +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", + decimals: option.decimals || CLASSIC_ASSET_DECIMALS, + issuer, + }, + }; +}; diff --git a/extension/src/popup/components/swap/SwapAmount/hooks/useSwapSubmitQuoteExpiry.ts b/extension/src/popup/components/swap/SwapAmount/hooks/useSwapSubmitQuoteExpiry.ts new file mode 100644 index 0000000000..6a3ce200a9 --- /dev/null +++ b/extension/src/popup/components/swap/SwapAmount/hooks/useSwapSubmitQuoteExpiry.ts @@ -0,0 +1,61 @@ +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. + * + * 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/components/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index 303889fdad..1a419587dc 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -85,6 +85,12 @@ interface SwapAmountProps { goToNext: () => void; goToEditSrc: () => void; goToEditDst: () => void; + /** + * Pins the receive side. Used when the swap is embedded in a flow that + * already chose what the user needs (the Earn deposit picker), where letting + * them change it would silently swap for something the pool does not accept. + */ + isDestinationLocked?: boolean; } export const SwapAmount = ({ @@ -93,6 +99,7 @@ export const SwapAmount = ({ goBack, goToNext, goToEditSrc, + isDestinationLocked = false, goToEditDst, }: SwapAmountProps) => { const { t } = useTranslation(); @@ -671,9 +678,12 @@ export const SwapAmount = ({ }} /> + {/* Flipping the direction would move the pinned token to the + sell side, which the embedding flow did not ask for. */} - - OPERATION_TYPES[op.type as keyof typeof OPERATION_TYPES] || - op.type, - )} - /> - {authEntries.length > 0 && } -
+
+ + OPERATION_TYPES[op.type as keyof typeof OPERATION_TYPES] || + op.type, + )} + /> + {authEntries.length > 0 && } +
+
); } @@ -232,9 +241,7 @@ export const EarnReview = ({
-
- -
+
{t("To")} diff --git a/extension/src/popup/components/earn/EarnReview/styles.scss b/extension/src/popup/components/earn/EarnReview/styles.scss index ff81a00b90..2b55089160 100644 --- a/extension/src/popup/components/earn/EarnReview/styles.scss +++ b/extension/src/popup/components/earn/EarnReview/styles.scss @@ -30,17 +30,6 @@ font-weight: 500; } - &__pool-icon { - width: pxToRem(32); - height: pxToRem(32); - border-radius: 50%; - background: var(--sds-clr-green-04); - color: var(--sds-clr-green-11); - display: flex; - align-items: center; - justify-content: center; - } - &__chevrons { color: var(--sds-clr-gray-09); padding: pxToRem(6) 0 0 pxToRem(10); @@ -87,12 +76,33 @@ 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 { diff --git a/extension/src/popup/components/earn/EarnSubmit/index.tsx b/extension/src/popup/components/earn/EarnSubmit/index.tsx index cb276c28ea..10c245a913 100644 --- a/extension/src/popup/components/earn/EarnSubmit/index.tsx +++ b/extension/src/popup/components/earn/EarnSubmit/index.tsx @@ -6,6 +6,7 @@ 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 { HardwareSign } from "popup/components/hardwareConnect/HardwareSign"; import { RequestState } from "constants/request"; import { getAssetFromCanonical } from "helpers/stellar"; @@ -172,9 +173,7 @@ export const EarnSubmit = ({ xdr, onExit }: EarnSubmitProps) => { issuerKey={srcAsset.issuer} /> -
- -
+
{t("{{amount}} {{code}} to {{pool}}", { diff --git a/extension/src/popup/components/earn/EarnSubmit/styles.scss b/extension/src/popup/components/earn/EarnSubmit/styles.scss index 2a3083dad3..ec2bf2e532 100644 --- a/extension/src/popup/components/earn/EarnSubmit/styles.scss +++ b/extension/src/popup/components/earn/EarnSubmit/styles.scss @@ -43,17 +43,6 @@ color: var(--sds-clr-gray-09); } - &__pool-icon { - width: pxToRem(32); - height: pxToRem(32); - border-radius: 50%; - background: var(--sds-clr-green-04); - color: var(--sds-clr-green-11); - display: flex; - align-items: center; - justify-content: center; - } - &__footer { display: flex; flex-direction: column; diff --git a/extension/src/popup/components/earn/EarnSwap/index.tsx b/extension/src/popup/components/earn/EarnSwap/index.tsx index ba31e8beba..0e4e54c1b7 100644 --- a/extension/src/popup/components/earn/EarnSwap/index.tsx +++ b/extension/src/popup/components/earn/EarnSwap/index.tsx @@ -1,7 +1,15 @@ import React, { useEffect, useState } from "react"; +import { Icon } from "@stellar/design-system"; +import { useTranslation } from "react-i18next"; import { useDispatch, useSelector } from "react-redux"; import { AppDispatch } from "popup/App"; +import { + Sheet, + SheetContent, + SheetTitle, + ScreenReaderOnly, +} from "popup/basics/shadcn/Sheet"; import { EARN_SWAP_STEPS } from "popup/constants/earn"; import { InputType } from "helpers/transaction"; import { emitMetric, emitScreenViewed } from "helpers/metrics"; @@ -24,6 +32,8 @@ import { transactionSubmissionSelector, } from "popup/ducks/transactionSubmission"; +import "./styles.scss"; + interface EarnSwapProps { /** Canonical of the token the user needs, pinned as the receive side. */ destinationAsset: string; @@ -40,10 +50,15 @@ interface EarnSwapProps { * * 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 — the whole point of entering here. + * seeded with the token the deposit needs — the reason for entering here — but + * stays editable, so both amount-card dropdowns open their own picker with only + * the opposite side's token filtered out. * - * Not rendered inside a SlideupModal: those self-measure via scrollHeight, which - * breaks for full-height screens using View.Content's footer. + * 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, @@ -51,6 +66,7 @@ export const EarnSwap = ({ onDone, onCancel, }: EarnSwapProps) => { + const { t } = useTranslation(); const dispatch = useDispatch(); const submission = useSelector(transactionSubmissionSelector); const { transactionSimulation, transactionData } = submission; @@ -86,64 +102,145 @@ export const EarnSwap = ({ onDone({ fromCode, toCode }); }; - 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; + 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} + /> + ); } - 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 ( - 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.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")); + // Can't swap a token for itself: if it matches the current + // destination, reset the destination to "(+) Select". + if (canonical === transactionData.destinationAsset) { + dispatch(saveDestinationAsset("")); + dispatch(saveDestinationTokenDetails(null)); + } + 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 locked, but the prop is - // required; point it at the source picker rather than leaving a - // no-op that would silently swallow a future unlock. - goToEditDst={() => setActiveStep(EARN_SWAP_STEPS.SET_FROM_ASSET)} - goToNext={() => setActiveStep(EARN_SWAP_STEPS.SWAP_CONFIRM)} - /> - ); + case EARN_SWAP_STEPS.SET_TO_ASSET: { + return ( + } + goBack={() => setActiveStep(EARN_SWAP_STEPS.AMOUNT)} + onClickAsset={(canonical, isContract, details) => { + dispatch(saveDestinationAsset(canonical)); + dispatch(saveIsToken(isContract)); + dispatch(saveDestinationTokenDetails(details ?? null)); + // Mirror of the source handler above. + if (canonical === transactionData.asset) { + dispatch(saveAsset("")); + dispatch(saveAmount("0")); + dispatch(saveAmountUsd("0.00")); + } + emitMetric(METRIC_NAMES.swapDestinationSelected, { + asset_code: details?.tokenCode, + asset_issuer: details?.issuer, + requires_trustline: details?.requiresTrustline, + source: details?.source, + }); + setActiveStep(EARN_SWAP_STEPS.AMOUNT); + }} + /> + ); + } + + case EARN_SWAP_STEPS.AMOUNT: + default: { + return ( + setActiveStep(EARN_SWAP_STEPS.SET_FROM_ASSET)} + goToEditDst={() => setActiveStep(EARN_SWAP_STEPS.SET_TO_ASSET)} + goToNext={() => setActiveStep(EARN_SWAP_STEPS.SWAP_CONFIRM)} + /> + ); + } } - } + }; + + return ( + { + if (!isOpen) { + onCancel(); + } + }} + > + 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, and every step's X means the same thing. */} + +
{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 index a0f486f4f8..2ac6df9573 100644 --- a/extension/src/popup/components/earn/EarnTokenPicker/NotEnoughTokenSheet.tsx +++ b/extension/src/popup/components/earn/EarnTokenPicker/NotEnoughTokenSheet.tsx @@ -69,6 +69,13 @@ export const NotEnoughTokenSheet = ({ ); }; + // 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"); + const goToTransfer = () => navigateTo(ROUTES.viewPublicKey, navigate); + return (
@@ -102,44 +109,86 @@ export const NotEnoughTokenSheet = ({
- {showBuy && ( - - )} + {showBothPrimaries ? ( + <> +
+ + +
- {showSwap && ( - - )} +
+ {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/resolveSwapDestination.ts b/extension/src/popup/components/earn/EarnTokenPicker/helpers/resolveSwapDestination.ts index e2578d748e..34bd6fa6d2 100644 --- a/extension/src/popup/components/earn/EarnTokenPicker/helpers/resolveSwapDestination.ts +++ b/extension/src/popup/components/earn/EarnTokenPicker/helpers/resolveSwapDestination.ts @@ -61,6 +61,11 @@ export const resolveSwapDestination = async ({ requiresTrustline: canonical !== "native", 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 index dddaf2ee95..6f45f88945 100644 --- a/extension/src/popup/components/earn/EarnTokenPicker/hooks/useGetEarnTokensData.tsx +++ b/extension/src/popup/components/earn/EarnTokenPicker/hooks/useGetEarnTokensData.tsx @@ -1,11 +1,23 @@ import { useReducer } from "react"; +import { useDispatch, useSelector } from "react-redux"; import BigNumber from "bignumber.js"; +import { StrKey } from "stellar-sdk"; import { NetworkDetails } from "@shared/constants/stellar"; import { getBlendEarnOptions, getBlendPools } from "@shared/api/helpers/blend"; import { BlendCatalogPool } from "@shared/api/types/blend"; +import { Balances } from "@shared/api/types/backend-api"; +import { getAssetIcons } from "@shared/api/internal"; +import { getCombinedAssetListData } from "@shared/api/helpers/token-list"; import { getBlendPoolId } from "@shared/constants/blend"; import { getCanonicalFromAsset } from "@shared/helpers/stellar"; +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"; @@ -72,6 +84,10 @@ export function useGetEarnTokensData() { 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" }); @@ -125,16 +141,48 @@ export function useGetEarnTokensData() { networkDetails, ); const total = balance?.total ? new BigNumber(balance.total) : null; - const issuer = + const balanceIssuer = balance && "token" in balance && "issuer" in balance.token ? balance.token.issuer.key : undefined; - // Icons are keyed by canonical, so only held assets resolve one — a - // zero-balance supported token has no issuer to build a key from and - // falls back to AssetIcon's placeholder. XLM is the exception: it is - // keyed "native" whether or not it is held. - const code = option.symbol || ""; + // A zero-balance reserve has no balance to take an issuer from, but the + // catalog puts the canonical in `name` for classic assets + // ("USDC:GA5ZSEJY…"). That is the only issuer available without + // resolving the SAC, and without it the row can build no icon key at + // all. Shape-guarded so an unexpected `name` cannot yield a bogus + // canonical. + const nameParts = option.name ? option.name.split(":") : []; + const issuerFromName = + nameParts.length === 2 && StrKey.isValidEd25519PublicKey(nameParts[1]) + ? nameParts[1] + : undefined; + const issuer = balanceIssuer || issuerFromName; + + // `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 : ""; + // The canonical's code half is a usable display code when the catalog + // reports no symbol and the account holds none of the asset. + const nameCode = nameParts.length === 2 ? nameParts[0] : ""; + const code = + option.symbol || + balanceCode || + nameCode || + `${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. XLM is the exception: it + // is keyed "native" whether or not it is held. let canonical = ""; if (issuer) { canonical = getCanonicalFromAsset(code, issuer); @@ -160,6 +208,49 @@ export function useGetEarnTokensData() { } }); + // `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 }); + + // getAssetIcons only reads each entry's `token` and `contractId`, so a + // partial record is enough; BalanceMap's required `native` key and the + // amount fields would be noise here. + const iconLookupBalances = rowsMissingIcons.reduce( + (acc, row) => ({ + ...acc, + [getCanonicalFromAsset(row.code, row.issuer!)]: { + token: { code: row.code, issuer: { key: row.issuer! } }, + contractId: row.assetId, + }, + }), + {}, + ) as unknown as Balances; + + const fetchedIcons = await getAssetIcons({ + balances: iconLookupBalances, + networkDetails, + assetsListsData, + cachedIcons, + }); + + 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, diff --git a/extension/src/popup/components/earn/EarnTokenPicker/index.tsx b/extension/src/popup/components/earn/EarnTokenPicker/index.tsx index 184c6758cb..f278a16a07 100644 --- a/extension/src/popup/components/earn/EarnTokenPicker/index.tsx +++ b/extension/src/popup/components/earn/EarnTokenPicker/index.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from "react"; -import { Loader, Text } from "@stellar/design-system"; +import { Icon, Loader, Text } from "@stellar/design-system"; import { useTranslation } from "react-i18next"; import { Navigate } from "react-router-dom"; @@ -84,7 +84,8 @@ export const EarnTokenPicker = ({ } />
@@ -100,8 +101,8 @@ export const EarnTokenPicker = ({ } /> @@ -149,8 +150,8 @@ export const EarnTokenPicker = ({ } /> * { + 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/index.tsx b/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx index be2e84f9b4..58f19d0a81 100644 --- a/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx +++ b/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx @@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next"; import { BlendCatalogPool } from "@shared/api/types/blend"; import { AssetIcon } from "popup/components/account/AccountAssets"; +import { PoolIcon } from "popup/components/earn/PoolIcon"; import { getPoolDescription } from "./poolDescriptions"; import { formatCompactUsd, formatRate } from "./helpers/formatPoolStats"; @@ -55,6 +56,7 @@ export const PoolDetailsSheet = ({ pool, onClose }: PoolDetailsSheetProps) => { return (
+
{pool.name || t("Blend pool")} diff --git a/extension/src/popup/components/earn/PoolDetailsSheet/styles.scss b/extension/src/popup/components/earn/PoolDetailsSheet/styles.scss index 8b35e07eda..e55e6205ee 100644 --- a/extension/src/popup/components/earn/PoolDetailsSheet/styles.scss +++ b/extension/src/popup/components/earn/PoolDetailsSheet/styles.scss @@ -15,6 +15,9 @@ &__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 { 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/swap/SwapAmount/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index 1a419587dc..95f0058fb6 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -86,11 +86,11 @@ interface SwapAmountProps { goToEditSrc: () => void; goToEditDst: () => void; /** - * Pins the receive side. Used when the swap is embedded in a flow that - * already chose what the user needs (the Earn deposit picker), where letting - * them change it would silently swap for something the pool does not accept. + * 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. */ - isDestinationLocked?: boolean; + isSheetLayout?: boolean; } export const SwapAmount = ({ @@ -99,7 +99,7 @@ export const SwapAmount = ({ goBack, goToNext, goToEditSrc, - isDestinationLocked = false, + isSheetLayout = false, goToEditDst, }: SwapAmountProps) => { const { t } = useTranslation(); @@ -497,21 +497,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 && ( + + )}
- {/* Flipping the direction would move the pinned token to the - sell side, which the embedding flow did not ask for. */} + {/* The seam and the notch sitting on it are part of the card + pair's shape, so they always render. */} ); })} + + {/* 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 }) => { + 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); + }} + /> + )}
); }; From ee6cb045d640de9e5445d54178497501c0e9f672 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Tue, 18 Aug 2026 13:46:33 -0600 Subject: [PATCH 09/43] fix(earn): resolve asset icons across the flow and align the review rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every screen in the Earn flow that draws an asset icon was handing AssetIcon an empty `assetIcons` map, which it reads as "Method 1 lookup still in flight" and answers with a loader — before it consults the `icon` prop at all. So those icons never resolved; they sat as blank circles. XLM hid it, because its logo is bundled and short-circuits the lookup, and until the swap branch worked XLM was the only token reachable as a deposit. --- .../components/earn/EarnAmount/index.tsx | 9 +- .../components/earn/EarnReview/styles.scss | 7 + .../components/earn/EarnSubmit/index.tsx | 8 +- .../components/earn/EarnSubmit/styles.scss | 6 + .../EarnTokenPicker/NotEnoughTokenSheet.tsx | 9 +- .../hooks/useGetEarnTokensData.tsx | 63 +++----- .../hooks/usePoolReserveIcons.tsx | 126 ++++++++++++++++ .../earn/PoolDetailsSheet/index.tsx | 34 ++++- .../helpers/__tests__/earnAssetIcons.test.ts | 61 ++++++++ .../components/earn/helpers/earnAssetIcons.ts | 137 ++++++++++++++++++ 10 files changed, 411 insertions(+), 49 deletions(-) create mode 100644 extension/src/popup/components/earn/PoolDetailsSheet/hooks/usePoolReserveIcons.tsx create mode 100644 extension/src/popup/components/earn/helpers/__tests__/earnAssetIcons.test.ts create mode 100644 extension/src/popup/components/earn/helpers/earnAssetIcons.ts diff --git a/extension/src/popup/components/earn/EarnAmount/index.tsx b/extension/src/popup/components/earn/EarnAmount/index.tsx index 596d0eb33c..f6a630cb7e 100644 --- a/extension/src/popup/components/earn/EarnAmount/index.tsx +++ b/extension/src/popup/components/earn/EarnAmount/index.tsx @@ -279,7 +279,11 @@ export const EarnAmount = ({ goBack, onConfirm }: EarnAmountProps) => { amountUsd="" amountFontSizeClass={getAmountFontSizeClass(amount)} assetCode={selected?.code || ""} - assetIcons={{}} + // The account's icon map, not an empty one: AssetIcon reads empty as + // "lookup still in flight" and holds a loader, which only looked + // right while XLM — whose logo is bundled and skips the lookup — was + // the sole depositable token. + assetIcons={data.balances.icons || {}} assetIssuerKey={selected?.issuer} fiatLineText={buildFiatLineText({ hasAsset: Boolean(asset), @@ -336,6 +340,9 @@ export const EarnAmount = ({ goBack, onConfirm }: EarnAmountProps) => { pool={pool} assetCode={selected?.code || ""} assetIssuer={selected?.issuer} + // Without this the sheet builds an empty icon map, which AssetIcon + // reads as a lookup in flight and answers with a spinner. + assetIcon={data.balances.icons?.[asset]} amount={amount} amountUsd={priceValueUsd} apy={selectedAssetApy} diff --git a/extension/src/popup/components/earn/EarnReview/styles.scss b/extension/src/popup/components/earn/EarnReview/styles.scss index 2b55089160..cb0b01e81e 100644 --- a/extension/src/popup/components/earn/EarnReview/styles.scss +++ b/extension/src/popup/components/earn/EarnReview/styles.scss @@ -22,6 +22,13 @@ 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 { diff --git a/extension/src/popup/components/earn/EarnSubmit/index.tsx b/extension/src/popup/components/earn/EarnSubmit/index.tsx index 10c245a913..559711f816 100644 --- a/extension/src/popup/components/earn/EarnSubmit/index.tsx +++ b/extension/src/popup/components/earn/EarnSubmit/index.tsx @@ -24,6 +24,7 @@ import { transactionSubmissionSelector, } from "popup/ducks/transactionSubmission"; import { earnSelector } from "popup/ducks/earn"; +import { iconsSelector } from "popup/ducks/cache"; import { useSubmitEarnTxData } from "./hooks/useSubmitEarnTxData"; @@ -53,6 +54,7 @@ export const EarnSubmit = ({ xdr, onExit }: EarnSubmitProps) => { const networkDetails = useSelector(settingsNetworkDetailsSelector); const hardwareWalletType = useSelector(hardwareWalletTypeSelector); const { pool } = useSelector(earnSelector); + const cachedIcons = useSelector(iconsSelector); const { amount, asset } = submission.transactionData; const srcAsset = getAssetFromCanonical(asset); @@ -167,8 +169,12 @@ export const EarnSubmit = ({ xdr, onExit }: EarnSubmitProps) => {
+ {/* 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. */} diff --git a/extension/src/popup/components/earn/EarnSubmit/styles.scss b/extension/src/popup/components/earn/EarnSubmit/styles.scss index ec2bf2e532..d7a6fe1fdf 100644 --- a/extension/src/popup/components/earn/EarnSubmit/styles.scss +++ b/extension/src/popup/components/earn/EarnSubmit/styles.scss @@ -41,6 +41,12 @@ 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 { diff --git a/extension/src/popup/components/earn/EarnTokenPicker/NotEnoughTokenSheet.tsx b/extension/src/popup/components/earn/EarnTokenPicker/NotEnoughTokenSheet.tsx index 2ac6df9573..fce4da829a 100644 --- a/extension/src/popup/components/earn/EarnTokenPicker/NotEnoughTokenSheet.tsx +++ b/extension/src/popup/components/earn/EarnTokenPicker/NotEnoughTokenSheet.tsx @@ -3,6 +3,7 @@ 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 { NotEnoughVariant } from "popup/constants/earn"; @@ -80,7 +81,13 @@ export const NotEnoughTokenSheet = ({
({ - ...acc, - [getCanonicalFromAsset(row.code, row.issuer!)]: { - token: { code: row.code, issuer: { key: row.issuer! } }, - contractId: row.assetId, - }, - }), - {}, - ) as unknown as Balances; - - const fetchedIcons = await getAssetIcons({ - balances: iconLookupBalances, + const fetchedIcons = await resolveEarnAssetIcons({ + assets: rowsMissingIcons, networkDetails, - assetsListsData, cachedIcons, + assetsListsData, }); rowsMissingIcons.forEach((row) => { diff --git a/extension/src/popup/components/earn/PoolDetailsSheet/hooks/usePoolReserveIcons.tsx b/extension/src/popup/components/earn/PoolDetailsSheet/hooks/usePoolReserveIcons.tsx new file mode 100644 index 0000000000..7cb26a3edb --- /dev/null +++ b/extension/src/popup/components/earn/PoolDetailsSheet/hooks/usePoolReserveIcons.tsx @@ -0,0 +1,126 @@ +import { useEffect, useState } from "react"; +import { useDispatch, useSelector } from "react-redux"; + +import { getCombinedAssetListData } from "@shared/api/helpers/token-list"; +import { BlendCatalogPool } from "@shared/api/types/blend"; +import { AppDispatch } from "popup/App"; +import { + getCatalogAssetIdentity, + resolveEarnAssetIcons, +} from "popup/components/earn/helpers/earnAssetIcons"; +import { + iconsSelector, + saveIconsForBalances, + tokensListsSelector, +} from "popup/ducks/cache"; +import { + settingsNetworkDetailsSelector, + settingsSelector, +} from "popup/ducks/settings"; + +/** + * Canonical-keyed icons for a pool's accepted tokens, in the shape AssetIcon + * looks assets up by. + * + * The catalog carries no icons, and the balances icon map only covers reserves + * the account happens to hold, so the rest have to be resolved. Usually a cache + * read: the token picker resolves the same assets on the way in and writes them + * to the icon cache. Runs on open rather than with the deposit screen, so an + * unopened sheet costs nothing. + * + * The map stays empty only while resolution is in flight — AssetIcon reads an + * empty map as "still fetching" and holds a loader — so a failed lookup records + * an explicit null instead, letting it fall through to its missing-image state. + */ +export const usePoolReserveIcons = (pool: BlendCatalogPool | null) => { + const reduxDispatch = useDispatch(); + const networkDetails = useSelector(settingsNetworkDetailsSelector); + const { assetsLists } = useSelector(settingsSelector); + const cachedIcons = useSelector(iconsSelector); + const cachedTokenLists = useSelector(tokensListsSelector); + const [icons, setIcons] = useState>({}); + + useEffect(() => { + let isActive = true; + + const resolve = async () => { + const reserves = pool?.reserves || []; + const assets = reserves + .map((reserve) => ({ + assetId: reserve.assetId, + ...getCatalogAssetIdentity({ + symbol: reserve.symbol, + name: reserve.name, + assetId: reserve.assetId, + networkDetails, + }), + })) + // Native's logo ships with the extension, and an asset with no icon key + // has nothing to look up. + .filter((asset) => asset.canonical && !asset.isNative); + + if (!assets.length) { + return; + } + + const seeded = assets.reduce( + (acc, asset) => + asset.canonical in cachedIcons + ? { ...acc, [asset.canonical]: cachedIcons[asset.canonical] } + : acc, + {} as Record, + ); + const missing = assets.filter((asset) => !(asset.canonical in seeded)); + + if (isActive && Object.keys(seeded).length) { + setIcons(seeded); + } + if (!missing.length) { + return; + } + + let resolved: Record = {}; + try { + const assetsListsData = cachedTokenLists.length + ? cachedTokenLists + : await getCombinedAssetListData({ networkDetails, assetsLists }); + resolved = await resolveEarnAssetIcons({ + assets: missing.map(({ code, issuer, assetId }) => ({ + code, + issuer, + assetId, + })), + networkDetails, + cachedIcons, + assetsListsData, + }); + if (Object.keys(resolved).length) { + reduxDispatch(saveIconsForBalances({ icons: resolved })); + } + } catch (e) { + // An unresolved icon is a placeholder, not a broken sheet. + } + + if (!isActive) { + return; + } + setIcons((current) => + missing.reduce( + (acc, asset) => ({ + ...acc, + [asset.canonical]: resolved[asset.canonical] ?? null, + }), + { ...current, ...seeded }, + ), + ); + }; + + resolve(); + return () => { + isActive = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pool?.id]); + + return icons; +}; diff --git a/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx b/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx index 58f19d0a81..7307b16b3e 100644 --- a/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx +++ b/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx @@ -1,10 +1,15 @@ import React from "react"; import { Button, Icon, Text } from "@stellar/design-system"; import { useTranslation } from "react-i18next"; +import { useSelector } from "react-redux"; import { BlendCatalogPool } from "@shared/api/types/blend"; import { AssetIcon } from "popup/components/account/AccountAssets"; +import { settingsNetworkDetailsSelector } from "popup/ducks/settings"; import { PoolIcon } from "popup/components/earn/PoolIcon"; +import { getCatalogAssetIdentity } from "popup/components/earn/helpers/earnAssetIcons"; + +import { usePoolReserveIcons } from "./hooks/usePoolReserveIcons"; import { getPoolDescription } from "./poolDescriptions"; import { formatCompactUsd, formatRate } from "./helpers/formatPoolStats"; @@ -52,6 +57,8 @@ const StatRow = ({ export const PoolDetailsSheet = ({ pool, onClose }: PoolDetailsSheetProps) => { const { t } = useTranslation(); const description = getPoolDescription(pool.id); + const networkDetails = useSelector(settingsNetworkDetailsSelector); + const reserveIcons = usePoolReserveIcons(pool); return (
@@ -111,13 +118,26 @@ export const PoolDetailsSheet = ({ pool, onClose }: PoolDetailsSheetProps) => { {t("Accepted tokens")}
- {pool.reserves.map((reserve) => ( - - ))} + {pool.reserves.map((reserve) => { + // The catalog reports native XLM with no symbol and no name, so + // the identity has to be derived rather than read straight off the + // reserve — AssetIcon needs a code to recognise XLM and an issuer + // to look anything else up. + const { code, issuer } = getCatalogAssetIdentity({ + symbol: reserve.symbol, + name: reserve.name, + assetId: reserve.assetId, + networkDetails, + }); + return ( + + ); + })}
{ + 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: "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75", + 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: "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75", + networkDetails, + }), + ).toBe(""); + }); +}); 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, + }); +}; From 13a492a30c302ed371ed0e91170d1a618aaeeb9c Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Tue, 18 Aug 2026 15:44:09 -0600 Subject: [PATCH 10/43] fix(earn): align the deposit screens with Figma and pin the receive side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the deposit screen, its pool card and the wallet-address screen to spec, measured against the frames rather than eyeballed. The APY ribbon is Colors/Green/10 on Green/4 with 16px top corners, where green-08 on green-01 at 8px read as muted; the card is 16px with the pool name on Text/Primary instead of inheriting Secondary; the pool details sheet gains its Backstop row and 16px accepted-token icons; and an over-limit amount turns red rather than adding a message row the design does not have — the CTA already reads "Insufficient funds". Three behavioural fixes alongside it. The swap's receive side is pinned again, chosen on the picker before it, so the pill is a label rather than a dropdown that silently does nothing. The fiat toggle no longer takes focus from the amount input, which was flashing the disabled CTA white on every press. And an account holding no XLM no longer sees it named after its contract address: the catalog reports native with a null symbol *and* a null name, so the code now comes from recognising its SAC. Shared components keep their existing behaviour by default — AmountCard's invalidAmountStyle and isAssetLocked, SwapAmount's isDestinationLocked — and the wallet-address screen's titled chrome is gated on ?flow=earn so its five other callers are untouched. The Backstop row reads "--" until a backend serves backstop_usd: the v2 backend drops the field its own upstream already provides. --- @shared/api/helpers/__tests__/blend.test.ts | 35 ++++++ @shared/api/helpers/blend.ts | 6 +- @shared/api/types/blend.ts | 16 +++ extension/e2e-tests/earnDeposit.test.ts | 12 +- extension/e2e-tests/helpers/stubs.ts | 1 + .../AmountCard/__tests__/index.test.tsx | 37 ++++++ .../components/amount/AmountCard/index.tsx | 113 +++++++++++++----- .../components/amount/AmountCard/styles.scss | 19 ++- .../earn/EarnAmount/NetworkFeeSheet.tsx | 5 +- .../components/earn/EarnAmount/PoolCard.tsx | 16 ++- .../components/earn/EarnAmount/index.tsx | 3 + .../components/earn/EarnAmount/styles.scss | 74 ++++++++++-- .../popup/components/earn/EarnSwap/index.tsx | 50 ++------ .../EarnTokenPicker/NotEnoughTokenSheet.tsx | 5 +- .../hooks/useGetEarnTokensData.tsx | 38 +++--- .../helpers/__tests__/formatPoolStats.test.ts | 2 + .../earn/PoolDetailsSheet/index.tsx | 11 +- .../earn/PoolDetailsSheet/styles.scss | 16 ++- .../helpers/__tests__/earnAssetIcons.test.ts | 65 +++++++++- .../__tests__/SwapAmount.layout.test.tsx | 53 ++++++++ .../components/swap/SwapAmount/index.tsx | 82 ++++++++----- .../components/swap/SwapAmount/styles.scss | 7 ++ extension/src/popup/constants/earn.ts | 10 +- .../__tests__/translationParity.test.ts | 1 + .../src/popup/locales/en/translation.json | 4 +- .../src/popup/locales/pt/translation.json | 4 +- .../src/popup/views/ViewPublicKey/index.tsx | 37 +++++- .../src/popup/views/ViewPublicKey/styles.scss | 29 +++-- 28 files changed, 584 insertions(+), 167 deletions(-) diff --git a/@shared/api/helpers/__tests__/blend.test.ts b/@shared/api/helpers/__tests__/blend.test.ts index c739d6a998..c05f94eb69 100644 --- a/@shared/api/helpers/__tests__/blend.test.ts +++ b/@shared/api/helpers/__tests__/blend.test.ts @@ -165,6 +165,7 @@ describe("getBlendPools", () => { borrowed_usd: 16150000, interest_apy: 0.0424, net_apy: 0.1694, + backstop_usd: 1530000, reserves: [ { asset_id: USDC_SAC, @@ -191,6 +192,7 @@ describe("getBlendPools", () => { 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, @@ -208,6 +210,39 @@ describe("getBlendPools", () => { }); }); + 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: {} }); diff --git a/@shared/api/helpers/blend.ts b/@shared/api/helpers/blend.ts index e759d7d555..0abf63283d 100644 --- a/@shared/api/helpers/blend.ts +++ b/@shared/api/helpers/blend.ts @@ -72,6 +72,9 @@ const mapCatalogPool = (pool: ApiBlendCatalogPool): BlendCatalogPool => ({ 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), }); @@ -108,7 +111,8 @@ export const getBlendEarnOptions = async ({ /** * The full pool catalog — unfiltered by the earn allowlist. Used for the pool - * details sheet's Lending Interest / Current Net APY / Supplied / Borrowed rows. + * details sheet's Lending Interest / Current Net APY / Supplied / Borrowed / + * Backstop rows. */ export const getBlendPools = async ({ networkDetails, diff --git a/@shared/api/types/blend.ts b/@shared/api/types/blend.ts index 959b33f78e..1ff0bdd4a2 100644 --- a/@shared/api/types/blend.ts +++ b/@shared/api/types/blend.ts @@ -84,6 +84,13 @@ export interface ApiBlendCatalogPool { 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[]; } @@ -122,6 +129,15 @@ export interface BlendCatalogPool { 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[]; } diff --git a/extension/e2e-tests/earnDeposit.test.ts b/extension/e2e-tests/earnDeposit.test.ts index 1608a182ff..0b44c41b7f 100644 --- a/extension/e2e-tests/earnDeposit.test.ts +++ b/extension/e2e-tests/earnDeposit.test.ts @@ -7,7 +7,7 @@ * 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: amount -> Max -> review -> confirm -> Deposited! - * 6. Pool details sheet, and that it carries no Backstop row + * 6. Pool details sheet, including its Backstop row * 7. Earn tile hidden on an unsupported network * * Stub URL shapes (all registered by stubBlendEarn): @@ -34,6 +34,7 @@ * - 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 @@ -208,7 +209,7 @@ test("depositing a held token reaches the success screen", async ({ // --------------------------------------------------------------------------- // 6. Pool details sheet // --------------------------------------------------------------------------- -test("pool details sheet shows market stats and omits Backstop", async ({ +test("pool details sheet shows market stats including Backstop", async ({ page, extensionId, context, @@ -232,9 +233,10 @@ test("pool details sheet shows market stats and omits Backstop", async ({ await expect(page.getByTestId("earn-pool-supplied")).toContainText("$50.05M"); await expect(page.getByTestId("earn-pool-borrowed")).toContainText("$16.15M"); - // Deliberately absent: the catalog exposes no backstop_usd, and a hardcoded - // figure would misrepresent the pool's insurance. - await expect(sheet).not.toContainText("Backstop"); + // 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"); }); // --------------------------------------------------------------------------- diff --git a/extension/e2e-tests/helpers/stubs.ts b/extension/e2e-tests/helpers/stubs.ts index 394b0e68ed..4b331fe893 100644 --- a/extension/e2e-tests/helpers/stubs.ts +++ b/extension/e2e-tests/helpers/stubs.ts @@ -3482,6 +3482,7 @@ export const stubBlendEarn = async ( borrowed_usd: 16150000, interest_apy: 0.0424, net_apy: 0.1694, + backstop_usd: 1530000, reserves: [ { asset_id: XLM_SAC, 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..5258869618 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,11 @@ export const AmountCard = ({ securityLevel === SecurityLevel.SUSPICIOUS; const isMalicious = securityLevel === SecurityLevel.MALICIOUS; - const fontClass = `AmountCard__input-amount AmountCard__${amountFontSizeClass}`; + const isAmountShownInvalid = + isAmountTooHigh && invalidAmountStyle === "amount"; + const fontClass = `AmountCard__input-amount AmountCard__${amountFontSizeClass}${ + isAmountShownInvalid ? " AmountCard__input-amount--invalid" : "" + }`; return (
@@ -230,37 +251,56 @@ export const AmountCard = ({
--
))}
- + {isAssetLocked ? ( +
+ + {assetCode} +
+ ) : ( + + )}
{/* The fiat line is always shown (callers pass "$0.00"/"--" when there is @@ -278,6 +318,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 +336,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/earn/EarnAmount/NetworkFeeSheet.tsx b/extension/src/popup/components/earn/EarnAmount/NetworkFeeSheet.tsx index 6e701baf65..e51991b2c4 100644 --- a/extension/src/popup/components/earn/EarnAmount/NetworkFeeSheet.tsx +++ b/extension/src/popup/components/earn/EarnAmount/NetworkFeeSheet.tsx @@ -3,6 +3,7 @@ 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"; @@ -74,7 +75,9 @@ export const NetworkFeeSheet = ({ variant="tertiary" isFullWidth isRounded - onClick={() => navigateTo(ROUTES.viewPublicKey, navigate)} + onClick={() => + navigateTo(ROUTES.viewPublicKey, navigate, EARN_FLOW_QUERY) + } data-testid="earn-network-fee-transfer" > {t("Transfer from another account")} diff --git a/extension/src/popup/components/earn/EarnAmount/PoolCard.tsx b/extension/src/popup/components/earn/EarnAmount/PoolCard.tsx index 333f1f5eb9..4c082214b2 100644 --- a/extension/src/popup/components/earn/EarnAmount/PoolCard.tsx +++ b/extension/src/popup/components/earn/EarnAmount/PoolCard.tsx @@ -36,10 +36,22 @@ export const PoolCard = ({ poolName, apy, onOpenDetails }: PoolCardProps) => { >
- + {/* Both lines are Text/SM/500 in the design; only their colour + differs — primary for the pool, secondary for the provider. */} + {poolName || t("Blend pool")} - + {t("by Blend")}
diff --git a/extension/src/popup/components/earn/EarnAmount/index.tsx b/extension/src/popup/components/earn/EarnAmount/index.tsx index f6a630cb7e..c3ee0aa02b 100644 --- a/extension/src/popup/components/earn/EarnAmount/index.tsx +++ b/extension/src/popup/components/earn/EarnAmount/index.tsx @@ -294,6 +294,9 @@ export const EarnAmount = ({ goBack, onConfirm }: EarnAmountProps) => { code: selected?.code || "", })} isAmountTooHigh={isAmountTooHigh} + // The design turns the amount red rather than adding a message row: + // the CTA below already reads "Insufficient funds". + invalidAmountStyle="amount" maxSpendableText={formatAmount(maxDepositable)} cryptoDecimals={decimals} onAmountChange={({ amount: next }) => diff --git a/extension/src/popup/components/earn/EarnAmount/styles.scss b/extension/src/popup/components/earn/EarnAmount/styles.scss index 7f11aefbf6..5ef4fdb7de 100644 --- a/extension/src/popup/components/earn/EarnAmount/styles.scss +++ b/extension/src/popup/components/earn/EarnAmount/styles.scss @@ -20,15 +20,27 @@ display: flex; flex-direction: column; - // The ribbon sits behind the card's top edge, so the card overlaps it. + // 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 Colors/Green/10 on Colors/Green/4 — a brighter + // fill with dark green text, where this previously used green-08 on green-01 + // and read as muted. &__ribbon { align-self: center; - background: var(--sds-clr-green-08); - color: var(--sds-clr-green-01); - border-radius: pxToRem(8) pxToRem(8) 0 0; - padding: pxToRem(4) pxToRem(12) pxToRem(10); - margin-bottom: pxToRem(-6); - width: calc(100% - #{pxToRem(40)}); + display: flex; + align-items: center; + justify-content: center; + background: var(--sds-clr-green-10); + color: var(--sds-clr-green-04); + 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; } @@ -37,32 +49,68 @@ display: flex; align-items: center; justify-content: space-between; - gap: pxToRem(12); + gap: pxToRem(16); width: 100%; - padding: pxToRem(14) pxToRem(16); + padding: pxToRem(12) pxToRem(16); border: none; cursor: pointer; text-align: left; background: var(--sds-clr-gray-03); - border-radius: pxToRem(12); + // 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; - width: pxToRem(28); - height: pxToRem(28); + flex: none; + width: pxToRem(34); + height: pxToRem(34); border-radius: 50%; - background: var(--sds-clr-gray-05); + background: var(--sds-clr-gray-01); color: var(--sds-clr-gray-11); + + svg { + width: pxToRem(14); + height: pxToRem(14); + } } } diff --git a/extension/src/popup/components/earn/EarnSwap/index.tsx b/extension/src/popup/components/earn/EarnSwap/index.tsx index 0e4e54c1b7..edc5493d6f 100644 --- a/extension/src/popup/components/earn/EarnSwap/index.tsx +++ b/extension/src/popup/components/earn/EarnSwap/index.tsx @@ -50,9 +50,8 @@ interface EarnSwapProps { * * 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 - * seeded with the token the deposit needs — the reason for entering here — but - * stays editable, so both amount-card dropdowns open their own picker with only - * the opposite side's token filtered out. + * 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 @@ -127,9 +126,7 @@ export const EarnSwap = ({ return ( } @@ -139,12 +136,6 @@ export const EarnSwap = ({ dispatch(saveIsToken(isContract)); dispatch(saveAmount("0")); dispatch(saveAmountUsd("0.00")); - // Can't swap a token for itself: if it matches the current - // destination, reset the destination to "(+) Select". - if (canonical === transactionData.destinationAsset) { - dispatch(saveDestinationAsset("")); - dispatch(saveDestinationTokenDetails(null)); - } emitMetric(METRIC_NAMES.swapSourceSelected, { asset_code: getAssetFromCanonical(canonical).code, asset_issuer: getAssetFromCanonical(canonical).issuer, @@ -156,47 +147,22 @@ export const EarnSwap = ({ ); } - case EARN_SWAP_STEPS.SET_TO_ASSET: { - return ( - } - goBack={() => setActiveStep(EARN_SWAP_STEPS.AMOUNT)} - onClickAsset={(canonical, isContract, details) => { - dispatch(saveDestinationAsset(canonical)); - dispatch(saveIsToken(isContract)); - dispatch(saveDestinationTokenDetails(details ?? null)); - // Mirror of the source handler above. - if (canonical === transactionData.asset) { - dispatch(saveAsset("")); - dispatch(saveAmount("0")); - dispatch(saveAmountUsd("0.00")); - } - emitMetric(METRIC_NAMES.swapDestinationSelected, { - asset_code: details?.tokenCode, - asset_issuer: details?.issuer, - requires_trustline: details?.requiresTrustline, - source: details?.source, - }); - setActiveStep(EARN_SWAP_STEPS.AMOUNT); - }} - /> - ); - } - case EARN_SWAP_STEPS.AMOUNT: default: { return ( setActiveStep(EARN_SWAP_STEPS.SET_FROM_ASSET)} - goToEditDst={() => setActiveStep(EARN_SWAP_STEPS.SET_TO_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)} /> ); diff --git a/extension/src/popup/components/earn/EarnTokenPicker/NotEnoughTokenSheet.tsx b/extension/src/popup/components/earn/EarnTokenPicker/NotEnoughTokenSheet.tsx index fce4da829a..025d71b788 100644 --- a/extension/src/popup/components/earn/EarnTokenPicker/NotEnoughTokenSheet.tsx +++ b/extension/src/popup/components/earn/EarnTokenPicker/NotEnoughTokenSheet.tsx @@ -6,7 +6,7 @@ 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 { NotEnoughVariant } from "popup/constants/earn"; +import { EARN_FLOW_QUERY, NotEnoughVariant } from "popup/constants/earn"; import { navigateTo } from "popup/helpers/navigate"; import { useGetOnrampToken } from "helpers/hooks/useGetOnrampToken"; @@ -75,7 +75,8 @@ export const NotEnoughTokenSheet = ({ // route keeps the stacked full-width pair. const showBothPrimaries = showBuy && showSwap; const transferLabel = t("Transfer from another account"); - const goToTransfer = () => navigateTo(ROUTES.viewPublicKey, navigate); + const goToTransfer = () => + navigateTo(ROUTES.viewPublicKey, navigate, EARN_FLOW_QUERY); return (
diff --git a/extension/src/popup/components/earn/EarnTokenPicker/hooks/useGetEarnTokensData.tsx b/extension/src/popup/components/earn/EarnTokenPicker/hooks/useGetEarnTokensData.tsx index 8de7c2593f..9446103739 100644 --- a/extension/src/popup/components/earn/EarnTokenPicker/hooks/useGetEarnTokensData.tsx +++ b/extension/src/popup/components/earn/EarnTokenPicker/hooks/useGetEarnTokensData.tsx @@ -9,6 +9,7 @@ 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, @@ -164,30 +165,33 @@ export function useGetEarnTokensData() { // friendly name. const balanceCode = balance && "token" in balance ? balance.token.code : ""; - // The canonical's code half is a usable display code when the catalog - // reports no symbol and the account holds none of the asset. - const nameCode = getCatalogIssuer(option.name) - ? option.name!.split(":")[0] - : ""; + // 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 || - nameCode || + 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. XLM is the exception: it - // is keyed "native" whether or not it is held. - const canonical = - !issuer && code === "XLM" - ? "native" - : getCatalogIconKey({ - code, - issuer, - assetId: option.assetId, - networkDetails, - }); + // 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, diff --git a/extension/src/popup/components/earn/PoolDetailsSheet/helpers/__tests__/formatPoolStats.test.ts b/extension/src/popup/components/earn/PoolDetailsSheet/helpers/__tests__/formatPoolStats.test.ts index 2dbeb383e7..8382627032 100644 --- a/extension/src/popup/components/earn/PoolDetailsSheet/helpers/__tests__/formatPoolStats.test.ts +++ b/extension/src/popup/components/earn/PoolDetailsSheet/helpers/__tests__/formatPoolStats.test.ts @@ -7,6 +7,8 @@ describe("formatCompactUsd", () => { 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", () => { diff --git a/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx b/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx index 7307b16b3e..66437dee85 100644 --- a/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx +++ b/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx @@ -51,8 +51,10 @@ const StatRow = ({ * Pool description and market stats, opened from the pool card on the amount * screen. * - * No Backstop row: the backend's pool catalog does not expose `backstop_usd`, - * and a stale hardcoded figure would misrepresent the pool's actual insurance. + * 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. */ export const PoolDetailsSheet = ({ pool, onClose }: PoolDetailsSheetProps) => { const { t } = useTranslation(); @@ -150,6 +152,11 @@ export const PoolDetailsSheet = ({ pool, onClose }: PoolDetailsSheetProps) => { value={formatCompactUsd(pool.borrowedUsd)} testId="earn-pool-borrowed" /> +
- {/* The seam and the notch sitting on it are part of the card - pair's shape, so they always render. */} + {/* 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 ? ( + + ) : ( + + )}
`. Lets the wallet-address screen carry the titled + * chrome the Earn design asks for without changing its other five callers. + */ +export const EARN_FLOW_PARAM = "earn"; + +export const EARN_FLOW_QUERY = `?flow=${EARN_FLOW_PARAM}`; + /** * Steps in the Earn deposit flow. * @@ -31,7 +40,6 @@ export enum STEPS { export enum EARN_SWAP_STEPS { AMOUNT = "earn-swap-amount", SET_FROM_ASSET = "earn-swap-from-asset", - SET_TO_ASSET = "earn-swap-to-asset", SWAP_CONFIRM = "earn-swap-confirm", } diff --git a/extension/src/popup/locales/__tests__/translationParity.test.ts b/extension/src/popup/locales/__tests__/translationParity.test.ts index 48574fc294..ad4ce5f82e 100644 --- a/extension/src/popup/locales/__tests__/translationParity.test.ts +++ b/extension/src/popup/locales/__tests__/translationParity.test.ts @@ -54,6 +54,7 @@ const earnKeys = [ "Accepted tokens", "Supplied", "Borrowed", + "Backstop", "You are depositing", "Position", "Current APY", diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index 9b250783c0..1aec2a0770 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -93,6 +93,7 @@ "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", @@ -505,6 +506,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", @@ -662,7 +665,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}}.", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index 85af92fab1..469287818a 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -93,6 +93,7 @@ "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", @@ -505,6 +506,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", @@ -662,7 +665,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}}.", diff --git a/extension/src/popup/views/ViewPublicKey/index.tsx b/extension/src/popup/views/ViewPublicKey/index.tsx index 98bc2ffd6f..fa64c7c905 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 { EARN_FLOW_PARAM } 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,11 @@ 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 = + new URLSearchParams(location.search).get("flow") === EARN_FLOW_PARAM; const accountName = useSelector(accountNameSelector); const { state, fetchData } = useGetAppData(); // Holds the currently-shown copy-toast's id (see copyAddress below for why @@ -118,7 +124,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 +186,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; } } From e507aa92b61a443c3a8294e9d99865517f2e3c87 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Thu, 20 Aug 2026 09:30:05 -0600 Subject: [PATCH 11/43] feat(earn): instrument the deposit funnel and fix its mislabeled steps --- .../components/earn/EarnAmount/index.tsx | 48 ++++- .../EarnSubmit/hooks/useSubmitEarnTxData.tsx | 34 +++- .../components/earn/EarnSubmit/index.tsx | 48 ++++- .../EarnTokenPicker/NotEnoughTokenSheet.tsx | 28 ++- .../components/earn/EarnTokenPicker/index.tsx | 36 ++-- .../constants/__tests__/metricsNames.test.ts | 32 +++- extension/src/popup/constants/metricsNames.ts | 25 ++- extension/src/popup/ducks/earn.ts | 13 ++ extension/src/popup/metrics/earn.test.ts | 178 ++++++++++++++++++ extension/src/popup/metrics/earn.ts | 172 +++++++++++++++++ extension/src/popup/metrics/views.ts | 4 +- extension/src/popup/views/Earn/index.tsx | 66 ++++++- 12 files changed, 627 insertions(+), 57 deletions(-) create mode 100644 extension/src/popup/metrics/earn.test.ts create mode 100644 extension/src/popup/metrics/earn.ts diff --git a/extension/src/popup/components/earn/EarnAmount/index.tsx b/extension/src/popup/components/earn/EarnAmount/index.tsx index c3ee0aa02b..ee16b3558c 100644 --- a/extension/src/popup/components/earn/EarnAmount/index.tsx +++ b/extension/src/popup/components/earn/EarnAmount/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +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"; @@ -27,8 +27,13 @@ import { } from "popup/helpers/formatters"; import { getAssetDecimals, getAvailableBalance } from "popup/helpers/soroban"; import { useNetworkFees } from "popup/helpers/useNetworkFees"; -import { emitMetric } from "helpers/metrics"; +import { emitMetric, emitScreenViewed } from "helpers/metrics"; import { METRIC_NAMES } from "popup/constants/metricsNames"; +import { scrubStrKeys } from "helpers/stellarStrKey"; +import { + trackEarnPercentAmountSelected, + trackEarnSimulationFailed, +} from "popup/metrics/earn"; import { saveAmount, transactionDataSelector, @@ -88,12 +93,29 @@ export const EarnAmount = ({ goBack, onConfirm }: EarnAmountProps) => { 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 }, []); + // 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)); @@ -223,9 +245,15 @@ export const EarnAmount = ({ goBack, onConfirm }: EarnAmountProps) => { setIsReviewOpen(true); } catch (error) { - setSimulationError( - error instanceof Error ? error.message : String(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", + }); + setSimulationError(message); } finally { setIsSimulating(false); } @@ -323,13 +351,17 @@ export const EarnAmount = ({ goBack, onConfirm }: EarnAmountProps) => { )} + onSelect={(pct) => { + trackEarnPercentAmountSelected({ + assetCode: selected?.code || "", + percent: pct, + }); dispatch( saveAmount( getPercentageDepositAmount({ maxDepositable, pct, decimals }), ), - ) - } + ); + }} />
diff --git a/extension/src/popup/components/earn/EarnSubmit/hooks/useSubmitEarnTxData.tsx b/extension/src/popup/components/earn/EarnSubmit/hooks/useSubmitEarnTxData.tsx index a020090a89..202bb7d130 100644 --- a/extension/src/popup/components/earn/EarnSubmit/hooks/useSubmitEarnTxData.tsx +++ b/extension/src/popup/components/earn/EarnSubmit/hooks/useSubmitEarnTxData.tsx @@ -5,9 +5,8 @@ import { captureException } from "@sentry/browser"; import { NetworkDetails } from "@shared/constants/stellar"; import { AppDispatch } from "popup/App"; import { initialState, isError, reducer } from "helpers/request"; -import { emitMetric } from "helpers/metrics"; import { isMainnet } from "helpers/stellar"; -import { METRIC_NAMES } from "popup/constants/metricsNames"; +import { trackEarnDepositCompleted } from "popup/metrics/earn"; import { AccountBalances, useGetBalances } from "helpers/hooks/useGetBalances"; import { signFreighterSorobanTransaction, @@ -28,6 +27,10 @@ interface SubmitEarnTxData { * Hardware wallets skip the in-page signing step; the prepared XDR is already * signed by the time this runs (the deposit's auth carries source-account * credentials, so the envelope signature covers it — no auth-entry round trip). + * + * Emits the deposit's success event only. Failures are emitted once, by the Earn + * view's `submitStatus: ERROR` effect — every path out of here that can fail + * rejects one of the transactionSubmission thunks, and those set that status. */ export function useSubmitEarnTxData({ isHardwareWallet, @@ -35,12 +38,18 @@ export function useSubmitEarnTxData({ 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 [state, dispatch] = useReducer( @@ -66,11 +75,19 @@ export function useSubmitEarnTxData({ ); if ( - signFreighterSorobanTransaction.fulfilled.match(res) && - res.payload.signedTransaction + !signFreighterSorobanTransaction.fulfilled.match(res) || + !res.payload.signedTransaction ) { - signedXDR = 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, the flow has already stepped back to the + // amount screen, and the late submit rejection would trip that effect + // a second time — a duplicate earn.deposit_failed for one attempt. + dispatch({ type: "FETCH_DATA_ERROR", payload: res.payload }); + return res.payload; } + + signedXDR = res.payload.signedTransaction; } const submitResp = await reduxDispatch( @@ -82,8 +99,11 @@ export function useSubmitEarnTxData({ ); if (submitFreighterSorobanTransaction.fulfilled.match(submitResp)) { - emitMetric(METRIC_NAMES.earnDepositCompleted, { - asset_code: assetCode, + trackEarnDepositCompleted({ + assetCode, + poolId, + apy, + viaSwap, }); // The deposit moved funds out of the account, so refresh balances. A diff --git a/extension/src/popup/components/earn/EarnSubmit/index.tsx b/extension/src/popup/components/earn/EarnSubmit/index.tsx index 559711f816..5e321fccac 100644 --- a/extension/src/popup/components/earn/EarnSubmit/index.tsx +++ b/extension/src/popup/components/earn/EarnSubmit/index.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from "react"; +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"; @@ -25,6 +25,8 @@ import { } from "popup/ducks/transactionSubmission"; import { earnSelector } from "popup/ducks/earn"; import { iconsSelector } from "popup/ducks/cache"; +import { emitScreenViewed } from "helpers/metrics"; +import { trackEarnDepositAbandoned } from "popup/metrics/earn"; import { useSubmitEarnTxData } from "./hooks/useSubmitEarnTxData"; @@ -53,7 +55,7 @@ export const EarnSubmit = ({ xdr, onExit }: EarnSubmitProps) => { const publicKey = useSelector(publicKeySelector); const networkDetails = useSelector(settingsNetworkDetailsSelector); const hardwareWalletType = useSelector(hardwareWalletTypeSelector); - const { pool } = useSelector(earnSelector); + const { pool, selectedAssetApy, didSwapInFlow } = useSelector(earnSelector); const cachedIcons = useSelector(iconsSelector); const { amount, asset } = submission.transactionData; @@ -66,13 +68,40 @@ export const EarnSubmit = ({ xdr, onExit }: EarnSubmitProps) => { xdr, isHardwareWallet: !!hardwareWalletType, assetCode: srcAsset.code, + poolId: pool?.id || "", + apy: selectedAssetApy, + viaSwap: didSwapInFlow, }); + // Derived above the hardware-overlay branch so the effects below stay in the + // same order on every render. + const isSuccess = + submissionState.state === RequestState.SUCCESS && + submission.submitStatus !== ActionStatus.ERROR; + const isLoading = !isSuccess; + + const hasEmittedSuccessView = useRef(false); + useEffect(() => { 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]); + if ( submission.hardwareWalletData?.status === ShowOverlayStatus.IN_PROGRESS && hardwareWalletType @@ -86,11 +115,6 @@ export const EarnSubmit = ({ xdr, onExit }: EarnSubmitProps) => { ); } - const isSuccess = - submissionState.state === RequestState.SUCCESS && - submission.submitStatus !== ActionStatus.ERROR; - const isLoading = !isSuccess; - return ( { isFullWidth isRounded variant="tertiary" - onClick={onExit} + onClick={() => { + // The only record that this deposit has an outcome nobody + // will report: the flow stops following it here. + trackEarnDepositAbandoned({ + assetCode: srcAsset.code, + poolId: pool?.id || "", + }); + onExit(); + }} data-testid="earn-submit-close" > {t("Close")} diff --git a/extension/src/popup/components/earn/EarnTokenPicker/NotEnoughTokenSheet.tsx b/extension/src/popup/components/earn/EarnTokenPicker/NotEnoughTokenSheet.tsx index 025d71b788..01a5acaeba 100644 --- a/extension/src/popup/components/earn/EarnTokenPicker/NotEnoughTokenSheet.tsx +++ b/extension/src/popup/components/earn/EarnTokenPicker/NotEnoughTokenSheet.tsx @@ -9,6 +9,7 @@ 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"; @@ -75,8 +76,25 @@ export const NotEnoughTokenSheet = ({ // route keeps the stacked full-width pair. const showBothPrimaries = showBuy && showSwap; const transferLabel = t("Transfer from another account"); - const goToTransfer = () => + + // 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 (
@@ -125,7 +143,7 @@ export const NotEnoughTokenSheet = ({ variant="secondary" isFullWidth isRounded - onClick={() => openOnramp()} + onClick={handleBuy} data-testid="earn-not-enough-buy" > {t("Buy {{code}}", { code: option.code })} @@ -135,7 +153,7 @@ export const NotEnoughTokenSheet = ({ variant="secondary" isFullWidth isRounded - onClick={onSwap} + onClick={handleSwap} data-testid="earn-not-enough-swap" > {t("Swap for {{code}}", { code: option.code })} @@ -163,7 +181,7 @@ export const NotEnoughTokenSheet = ({ variant="secondary" isFullWidth isRounded - onClick={() => openOnramp()} + onClick={handleBuy} data-testid="earn-not-enough-buy" > {t("Buy with Coinbase")} @@ -176,7 +194,7 @@ export const NotEnoughTokenSheet = ({ variant="secondary" isFullWidth isRounded - onClick={onSwap} + onClick={handleSwap} data-testid="earn-not-enough-swap" > {t("Swap for {{code}}", { code: option.code })} diff --git a/extension/src/popup/components/earn/EarnTokenPicker/index.tsx b/extension/src/popup/components/earn/EarnTokenPicker/index.tsx index f278a16a07..b0801449ab 100644 --- a/extension/src/popup/components/earn/EarnTokenPicker/index.tsx +++ b/extension/src/popup/components/earn/EarnTokenPicker/index.tsx @@ -12,6 +12,7 @@ import { AppDataType } from "helpers/hooks/useGetAppData"; import { newTabHref } from "helpers/urls"; import { openTab } from "popup/helpers/navigate"; import { formatAmount } from "popup/helpers/formatters"; +import { trackEarnBalanceInsufficientShown } from "popup/metrics/earn"; import { NotEnoughTokenSheet } from "./NotEnoughTokenSheet"; import { @@ -115,6 +116,25 @@ export const EarnTokenPicker = ({ 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) : setNotEnoughToken(option) + isHeld ? onSelect(option, data) : openNotEnoughSheet(option) } data-testid={`earn-token-row-${option.code}`} /> ); - // 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 notEnoughVariant = notEnoughToken - ? getNotEnoughVariant({ - isOnrampable: isOnrampableAsset( - notEnoughToken.code, - data.networkDetails, - ), - isSwappable: hasSwappableBalance( - data.balances.balances, - notEnoughToken.assetId, - ), - }) + ? resolveNotEnoughVariant(notEnoughToken) : null; return ( diff --git a/extension/src/popup/constants/__tests__/metricsNames.test.ts b/extension/src/popup/constants/__tests__/metricsNames.test.ts index 65f067493e..7f9d1cb93f 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,26 @@ 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.earnDepositAbandoned).toBe("earn.deposit_abandoned"); + }); + it("names collectible-send and transaction-submission events", () => { expect(METRIC_NAMES.collectibleSendCompleted).toBe( "collectible_send.completed", @@ -106,8 +128,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/metricsNames.ts b/extension/src/popup/constants/metricsNames.ts index bdc5125439..97ac7e76e8 100644 --- a/extension/src/popup/constants/metricsNames.ts +++ b/extension/src/popup/constants/metricsNames.ts @@ -39,10 +39,31 @@ export const METRIC_NAMES = { swapFailed: "swap.failed", // -- Earn ---------------------------------------------------------------- - earnDepositCompleted: "earn.deposit_completed", - earnDepositFailed: "earn.deposit_failed", + // 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", + // A percentage shortcut on the amount screen; `percent: 100` is Max. + earnMaxAmountSelected: "earn.max_amount_selected", earnXlmFeeInsufficientShown: "earn.xlm_fee_insufficient_shown", + earnSimulationFailed: "earn.simulation_failed", + earnDepositCompleted: "earn.deposit_completed", + earnDepositFailed: "earn.deposit_failed", + // Closed while the submission was still in flight. The flow stops following + // the result there, so this marks an outcome that no completed/failed event + // will ever report. + earnDepositAbandoned: "earn.deposit_abandoned", // -- Collectibles -------------------------------------------------------- collectibleSendCompleted: "collectible_send.completed", diff --git a/extension/src/popup/ducks/earn.ts b/extension/src/popup/ducks/earn.ts index e22a85d351..f39f636f23 100644 --- a/extension/src/popup/ducks/earn.ts +++ b/extension/src/popup/ducks/earn.ts @@ -45,6 +45,14 @@ export interface EarnState { * 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 = { @@ -54,6 +62,7 @@ export const initialState: EarnState = { currentPositionTokens: "0", hasSeenIntro: null, lastSubmitFailed: false, + didSwapInFlow: false, }; const earnSlice = createSlice({ @@ -78,6 +87,9 @@ const earnSlice = createSlice({ 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 @@ -97,6 +109,7 @@ export const { saveCurrentPositionTokens, setEarnIntroSeen, setEarnSubmitFailed, + setDidSwapInFlow, resetEarn, } = earnSlice.actions; diff --git a/extension/src/popup/metrics/earn.test.ts b/extension/src/popup/metrics/earn.test.ts new file mode 100644 index 0000000000..5b792f1ef2 --- /dev/null +++ b/extension/src/popup/metrics/earn.test.ts @@ -0,0 +1,178 @@ +import { emitMetric } from "helpers/metrics"; +import { NotEnoughVariant } from "popup/constants/earn"; +import { METRIC_NAMES } from "popup/constants/metricsNames"; + +import { + trackEarnBalanceInsufficientShown, + trackEarnDepositAbandoned, + trackEarnDepositCompleted, + trackEarnDepositFailed, + trackEarnFundingActionSelected, + trackEarnPercentAmountSelected, + trackEarnSimulationFailed, + trackEarnSwapCompleted, + trackEarnTokenSelected, +} from "./earn"; + +jest.mock("helpers/metrics", () => ({ + emitMetric: jest.fn(), +})); + +const mockEmitMetric = emitMetric as jest.MockedFunction; + +const POOL_ID = "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD"; + +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("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("reports a deposit left in flight", () => { + trackEarnDepositAbandoned({ assetCode: "USDC", poolId: POOL_ID }); + + expect(mockEmitMetric).toHaveBeenCalledWith( + METRIC_NAMES.earnDepositAbandoned, + { 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..5f88c8adfc --- /dev/null +++ b/extension/src/popup/metrics/earn.ts @@ -0,0 +1,172 @@ +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, + }); +}; + +export const trackEarnSimulationFailed = ({ + assetCode, + reasonCode, +}: { + assetCode: string; + reasonCode: string; +}) => { + emitMetric(METRIC_NAMES.earnSimulationFailed, { + asset_code: assetCode, + reason_code: reasonCode, + }); +}; + +/** A percentage shortcut on the amount screen; `percent: 100` is Max. */ +export const trackEarnPercentAmountSelected = ({ + assetCode, + percent, +}: { + assetCode: string; + percent: number; +}) => { + emitMetric(METRIC_NAMES.earnMaxAmountSelected, { + asset_code: assetCode, + percent, + }); +}; + +/** + * Close pressed while the deposit was still in flight. The flow deliberately + * does not follow the submission after that, so this event is the only record + * that an outcome exists which no `completed`/`failed` event will report. + */ +export const trackEarnDepositAbandoned = ({ + assetCode, + poolId, +}: { + assetCode: string; + poolId: string; +}) => { + emitMetric(METRIC_NAMES.earnDepositAbandoned, { + 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, + }); +}; + +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.ts b/extension/src/popup/metrics/views.ts index a65b328e2e..fec66ef129 100644 --- a/extension/src/popup/metrics/views.ts +++ b/extension/src/popup/metrics/views.ts @@ -168,7 +168,9 @@ const SCREEN_BY_ROUTE: Partial> = { * 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 / earn_confirm come from the Earn flow's own step effect. + * 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). */ export const ROUTES_WITHOUT_SCREEN_VIEW = new Set([ ROUTES.sendPayment, diff --git a/extension/src/popup/views/Earn/index.tsx b/extension/src/popup/views/Earn/index.tsx index 4a6776dcce..2b3bdd39fb 100644 --- a/extension/src/popup/views/Earn/index.tsx +++ b/extension/src/popup/views/Earn/index.tsx @@ -3,11 +3,12 @@ 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 { ActionStatus, ErrorMessage } from "@shared/api/types"; import { resetSubmission, resetSubmitStatus, @@ -17,14 +18,21 @@ import { transactionSubmissionSelector, } from "popup/ducks/transactionSubmission"; import { + earnSelector, resetEarn, saveEarnPool, saveSelectedAssetApy, saveSelectedAssetId, + setDidSwapInFlow, setEarnSubmitFailed, } from "popup/ducks/earn"; -import { emitMetric } from "helpers/metrics"; -import { METRIC_NAMES } from "popup/constants/metricsNames"; +import { scrubStrKeys } from "helpers/stellarStrKey"; +import { getResultCodes } from "popup/helpers/parseTransaction"; +import { + trackEarnDepositFailed, + trackEarnSwapCompleted, + trackEarnTokenSelected, +} from "popup/metrics/earn"; import { EarnIntro } from "popup/components/earn/EarnIntro"; import { useEarnIntroSeen } from "popup/components/earn/EarnIntro/hooks/useEarnIntroSeen"; import { EarnTokenPicker } from "popup/components/earn/EarnTokenPicker"; @@ -49,6 +57,11 @@ 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 @@ -56,11 +69,23 @@ const EARN_SCREEN_BY_STEP: Partial< [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" }, - [STEPS.DEPOSIT_CONFIRM]: { - screen_name: "earn_confirm", - flow: "earn", - step: "confirm", - }, +}; + +/** + * 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. + */ +const getFailureReasonCode = (error: ErrorMessage | undefined) => { + const resultCodes = getResultCodes(error); + return ( + resultCodes.operations?.[0] || + resultCodes.transaction || + scrubStrKeys(error?.errorMessage) || + "unknown" + ); }; export const Earn = () => { @@ -69,6 +94,7 @@ export const Earn = () => { const { t } = useTranslation(); const { hasSeenIntro, dismissIntro } = useEarnIntroSeen(); const submission = useSelector(transactionSubmissionSelector); + const { pool } = 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 @@ -120,11 +146,21 @@ export const Earn = () => { // 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. + // + // This is the flow's single source of `earn.deposit_failed`. Every way a + // deposit can fail — a rejected signature, a rejected submit — lands in + // `submitStatus: ERROR` (see transactionSubmission's extraReducers), so the + // submit hook must not emit its own failure event or the two would double + // count. useEffect(() => { if (submission.submitStatus !== ActionStatus.ERROR) { return; } - emitMetric(METRIC_NAMES.earnDepositFailed); + 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. @@ -179,6 +215,11 @@ export const Earn = () => { refreshKey={pickerRefreshKey} onClose={closeEarnFlow} onSelect={(option, resolved) => { + trackEarnTokenSelected({ + assetCode: option.code, + poolId: option.poolId, + apy: option.apy, + }); dispatch(saveEarnPool(resolved.pool)); dispatch(saveSelectedAssetApy(option.apy)); dispatch(saveSelectedAssetId(option.assetId)); @@ -269,6 +310,13 @@ export const Earn = () => { 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)); From 2d7f19e3cf2408f7bfd4a34a4ce36616c3ada9ac Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Thu, 20 Aug 2026 10:19:43 -0600 Subject: [PATCH 12/43] fix(earn): stop holding XLM back from a deposit and explain the fee shortfall --- @shared/constants/blend.ts | 13 --- .../helpers/__tests__/earnCtaState.test.ts | 91 +++++++++++++------ .../earn/EarnAmount/helpers/earnCtaState.ts | 60 +++++++----- .../components/earn/EarnAmount/index.tsx | 71 ++++++++++++--- extension/src/popup/constants/metricsNames.ts | 2 + .../__tests__/translationParity.test.ts | 2 + .../src/popup/locales/en/translation.json | 2 + .../src/popup/locales/pt/translation.json | 2 + extension/src/popup/metrics/earn.test.ts | 20 ++++ extension/src/popup/metrics/earn.ts | 21 +++++ 10 files changed, 205 insertions(+), 79 deletions(-) diff --git a/@shared/constants/blend.ts b/@shared/constants/blend.ts index ad5ae6a0b7..6a41011217 100644 --- a/@shared/constants/blend.ts +++ b/@shared/constants/blend.ts @@ -23,16 +23,3 @@ export const getBlendPoolId = (networkDetails: NetworkDetails) => */ export const isEarnSupportedNetwork = (networkDetails: NetworkDetails) => Boolean(getBlendPoolId(networkDetails)); - -/** - * XLM held back from a Max deposit to cover the transaction's resource fee. - * - * `getAvailableBalance` only subtracts the *inclusion* fee (~0.00001 XLM), but a - * Blend `submit` is dominated by its resource fee — measured at 546,395 stroops - * (~0.0546 XLM) against the live pool, roughly 5,000x the inclusion fee. Without - * this buffer a Max deposit of XLM simulates into an insufficient-balance error. - * - * The buffer is deliberately generous; Review re-checks against the real - * `minResourceFee` once simulation returns and clamps if it still does not fit. - */ -export const BLEND_DEPOSIT_XLM_FEE_BUFFER = "0.5"; 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 index 075ed25386..c26018200f 100644 --- a/extension/src/popup/components/earn/EarnAmount/helpers/__tests__/earnCtaState.test.ts +++ b/extension/src/popup/components/earn/EarnAmount/helpers/__tests__/earnCtaState.test.ts @@ -1,10 +1,7 @@ -import BigNumber from "bignumber.js"; - -import { BLEND_DEPOSIT_XLM_FEE_BUFFER } from "@shared/constants/blend"; - import { getEarnCtaState, - getMaxDepositAmount, + getXlmFeeShortfall, + isInsufficientBalanceFailure, needsXlmForFee, } from "../earnCtaState"; @@ -78,40 +75,80 @@ describe("needsXlmForFee", () => { }); }); -describe("getMaxDepositAmount", () => { - it("returns the full available balance for a non-XLM asset", () => { - // The fee comes out of a different balance, so nothing is held back. - expect(getMaxDepositAmount({ availableBalance: "500", isXlm: false })).toBe( - "500", - ); +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("holds back the resource-fee buffer for XLM", () => { - // getAvailableBalance only nets out the inclusion fee (~0.00001 XLM); the - // resource fee is ~0.0546 XLM and would otherwise blow the simulation. - expect(getMaxDepositAmount({ availableBalance: "100", isXlm: true })).toBe( - new BigNumber(100).minus(BLEND_DEPOSIT_XLM_FEE_BUFFER).toFixed(), - ); + 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("floors at zero rather than going negative", () => { - expect(getMaxDepositAmount({ availableBalance: "0.1", isXlm: true })).toBe( - "0", - ); + 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("returns zero when the buffer exactly consumes the balance", () => { + it("is zero when the remainder exactly covers the fee", () => { expect( - getMaxDepositAmount({ - availableBalance: BLEND_DEPOSIT_XLM_FEE_BUFFER, - isXlm: true, + getXlmFeeShortfall({ + spendableXlm: "100", + amount: "99.9453605", + resourceFee: "0.0546395", }), ).toBe("0"); }); it("does not lose precision on a large balance", () => { expect( - getMaxDepositAmount({ availableBalance: "1691.6912345", isXlm: true }), - ).toBe("1691.1912345"); + getXlmFeeShortfall({ + spendableXlm: "1691.6912345", + amount: "1691.6912345", + resourceFee: "0.0546395", + }), + ).toBe("0.0546395"); + }); +}); + +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 index 533c61c488..fb6c5d229f 100644 --- a/extension/src/popup/components/earn/EarnAmount/helpers/earnCtaState.ts +++ b/extension/src/popup/components/earn/EarnAmount/helpers/earnCtaState.ts @@ -1,7 +1,5 @@ import BigNumber from "bignumber.js"; -import { BLEND_DEPOSIT_XLM_FEE_BUFFER } from "@shared/constants/blend"; - /** * Pure CTA state machine for the deposit amount screen. Precedence matters: * each guard short-circuits, so the label reflects the most specific blocker. @@ -55,31 +53,45 @@ export const needsXlmForFee = ({ }) => new BigNumber(spendableXlm).lt(new BigNumber(fee)); /** - * Spendable amount to offer for a Max deposit. + * 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. * - * `getAvailableBalance` subtracts the base reserve and the *inclusion* fee, but - * a Blend `submit` is dominated by its resource fee — measured at ~0.0546 XLM - * against the live pool, roughly 5,000x the inclusion fee. Depositing the raw - * available balance of XLM therefore simulates into an insufficient-balance - * error. Hold back a buffer; Review re-checks against the real `minResourceFee` - * once simulation returns. + * `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. * - * Only XLM needs this — for any other asset the fee is paid from a separate - * balance, and the shortfall surfaces through the network-fee sheet instead. + * Only XLM deposits can be short this way: for any other asset the fee comes + * out of an untouched XLM balance. */ -export const getMaxDepositAmount = ({ - availableBalance, - isXlm, +export const getXlmFeeShortfall = ({ + spendableXlm, + amount, + resourceFee, }: { - availableBalance: string; - isXlm: boolean; + spendableXlm: string; + /** Cleaned deposit amount — no group separators. */ + amount: string; + /** `minResourceFee` from simulation, in XLM. */ + resourceFee: string; }) => { - const available = new BigNumber(availableBalance); - if (!isXlm) { - return available.toFixed(); - } - return BigNumber.max( - available.minus(new BigNumber(BLEND_DEPOSIT_XLM_FEE_BUFFER)), - new BigNumber(0), - ).toFixed(); + 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/index.tsx b/extension/src/popup/components/earn/EarnAmount/index.tsx index ee16b3558c..a4f54542ea 100644 --- a/extension/src/popup/components/earn/EarnAmount/index.tsx +++ b/extension/src/popup/components/earn/EarnAmount/index.tsx @@ -33,6 +33,7 @@ import { scrubStrKeys } from "helpers/stellarStrKey"; import { trackEarnPercentAmountSelected, trackEarnSimulationFailed, + trackEarnXlmFeeInsufficientShown, } from "popup/metrics/earn"; import { saveAmount, @@ -51,7 +52,8 @@ import { PoolCard } from "./PoolCard"; import { NetworkFeeSheet } from "./NetworkFeeSheet"; import { getEarnCtaState, - getMaxDepositAmount, + getXlmFeeShortfall, + isInsufficientBalanceFailure, needsXlmForFee, } from "./helpers/earnCtaState"; import { getPercentageDepositAmount } from "./helpers/depositAmount"; @@ -161,8 +163,10 @@ export const EarnAmount = ({ goBack, onConfirm }: EarnAmountProps) => { ) : null; - // Nets out the base reserve and the inclusion fee. The resource fee is far - // larger for a Blend submit and is handled by getMaxDepositAmount's buffer. + // 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, @@ -172,15 +176,11 @@ export const EarnAmount = ({ goBack, onConfirm }: EarnAmountProps) => { : "0"; const isXlm = asset === "native"; - const maxDepositable = getMaxDepositAmount({ - availableBalance, - isXlm, - }); const enteredAmount = new BigNumber(cleanAmount(amount || "0")); - const isAmountTooHigh = enteredAmount.gt(new BigNumber(maxDepositable)); + const isAmountTooHigh = enteredAmount.gt(new BigNumber(availableBalance)); const cta = getEarnCtaState({ - availableBalanceIsZero: new BigNumber(maxDepositable).lte(0), + availableBalanceIsZero: new BigNumber(availableBalance).lte(0), amountIsZero: enteredAmount.lte(0), isAmountTooHigh, }); @@ -206,8 +206,9 @@ export const EarnAmount = ({ goBack, onConfirm }: EarnAmountProps) => { // 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 })) { - emitMetric(METRIC_NAMES.earnXlmFeeInsufficientShown, { - asset_code: selected?.code, + trackEarnXlmFeeInsufficientShown({ + assetCode: selected?.code || "", + reason: "no_xlm", }); setIsFeeSheetOpen(true); return; @@ -233,7 +234,7 @@ export const EarnAmount = ({ goBack, onConfirm }: EarnAmountProps) => { ) .catch(() => dispatch(saveCurrentPositionTokens("0"))); - await simulate({ + const simulation = await simulate({ publicKey: data.publicKey, assetId: selectedAssetId, amount, @@ -243,6 +244,32 @@ export const EarnAmount = ({ goBack, onConfirm }: EarnAmountProps) => { transactionTimeout, }); + // Simulation is the first place the resource fee is known, so an XLM + // 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. + const shortfall = isXlm + ? getXlmFeeShortfall({ + spendableXlm, + amount: enteredAmount.toFixed(), + resourceFee: simulation.resourceFee || "0", + }) + : "0"; + + if (new BigNumber(shortfall).gt(0)) { + trackEarnXlmFeeInsufficientShown({ + assetCode: selected?.code || "", + reason: "fee_not_covered", + }); + setSimulationError( + t( + "Not enough XLM left for the network fee. Reduce your deposit by at least {{amount}} XLM.", + { amount: formatAmount(shortfall) }, + ), + ); + return; + } + setIsReviewOpen(true); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -253,7 +280,17 @@ export const EarnAmount = ({ goBack, onConfirm }: EarnAmountProps) => { assetCode: selected?.code || "", reasonCode: scrubStrKeys(message) || "unknown", }); - setSimulationError(message); + // 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); } @@ -325,7 +362,7 @@ export const EarnAmount = ({ goBack, onConfirm }: EarnAmountProps) => { // The design turns the amount red rather than adding a message row: // the CTA below already reads "Insufficient funds". invalidAmountStyle="amount" - maxSpendableText={formatAmount(maxDepositable)} + maxSpendableText={formatAmount(availableBalance)} cryptoDecimals={decimals} onAmountChange={({ amount: next }) => dispatch(saveAmount(next === "" ? DEFAULT_AMOUNT : next)) @@ -358,7 +395,11 @@ export const EarnAmount = ({ goBack, onConfirm }: EarnAmountProps) => { }); dispatch( saveAmount( - getPercentageDepositAmount({ maxDepositable, pct, decimals }), + getPercentageDepositAmount({ + maxDepositable: availableBalance, + pct, + decimals, + }), ), ); }} diff --git a/extension/src/popup/constants/metricsNames.ts b/extension/src/popup/constants/metricsNames.ts index 97ac7e76e8..4f28f8ea4b 100644 --- a/extension/src/popup/constants/metricsNames.ts +++ b/extension/src/popup/constants/metricsNames.ts @@ -56,6 +56,8 @@ export const METRIC_NAMES = { earnPoolDetailsOpened: "earn.pool_details_opened", // A percentage shortcut on the amount screen; `percent: 100` is Max. 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", diff --git a/extension/src/popup/locales/__tests__/translationParity.test.ts b/extension/src/popup/locales/__tests__/translationParity.test.ts index ad4ce5f82e..4a14a7eac2 100644 --- a/extension/src/popup/locales/__tests__/translationParity.test.ts +++ b/extension/src/popup/locales/__tests__/translationParity.test.ts @@ -46,6 +46,8 @@ const earnKeys = [ "Review deposit", "Insufficient funds", "You need some XLM for 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 Details", diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index 1aec2a0770..0bde800ce7 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -449,6 +449,8 @@ "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.", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index 469287818a..82f6acbc33 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -449,6 +449,8 @@ "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.", diff --git a/extension/src/popup/metrics/earn.test.ts b/extension/src/popup/metrics/earn.test.ts index 5b792f1ef2..4612862b20 100644 --- a/extension/src/popup/metrics/earn.test.ts +++ b/extension/src/popup/metrics/earn.test.ts @@ -12,6 +12,7 @@ import { trackEarnSimulationFailed, trackEarnSwapCompleted, trackEarnTokenSelected, + trackEarnXlmFeeInsufficientShown, } from "./earn"; jest.mock("helpers/metrics", () => ({ @@ -93,6 +94,25 @@ describe("Earn funnel metrics", () => { ); }); + 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", diff --git a/extension/src/popup/metrics/earn.ts b/extension/src/popup/metrics/earn.ts index 5f88c8adfc..4d30596058 100644 --- a/extension/src/popup/metrics/earn.ts +++ b/extension/src/popup/metrics/earn.ts @@ -90,6 +90,27 @@ export const trackEarnSwapCompleted = ({ }); }; +/** + * 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, From c7d7e832ddf155c47e27be9f133d5f3b650534c9 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Thu, 20 Aug 2026 10:51:06 -0600 Subject: [PATCH 13/43] refactor: hoist duplicated earn constants and helpers into shared files --- @shared/api/helpers/__tests__/blend.test.ts | 9 +- @shared/constants/sac.ts | 34 ++++++++ .../helpers/soroban/__tests__/blend.test.ts | 9 +- extension/e2e-tests/helpers/stubs.ts | 11 ++- .../components/amount/AmountCard/index.tsx | 40 ++++----- .../src/popup/components/amount/constants.ts | 12 +++ .../__tests__/percentageAmount.test.ts} | 28 +++--- .../helpers/percentageAmount.ts} | 12 +-- .../components/earn/EarnAmount/PoolCard.tsx | 2 +- .../components/earn/EarnAmount/index.tsx | 9 +- .../components/earn/EarnReview/index.tsx | 37 ++------ .../components/earn/EarnReview/styles.scss | 23 ----- .../popup/components/earn/EarnSwap/index.tsx | 10 ++- .../__tests__/getNotEnoughVariant.test.ts | 15 +--- .../helpers/resolveSwapDestination.ts | 4 +- .../earn/PoolDetailsSheet/index.tsx | 86 +++++++------------ .../earn/PoolDetailsSheet/styles.scss | 22 ----- .../popup/components/earn/StatRow/index.tsx | 41 +++++++++ .../popup/components/earn/StatRow/styles.scss | 24 ++++++ .../helpers/__tests__/earnAssetIcons.test.ts | 7 +- .../helpers/__tests__/formatPoolStats.test.ts | 0 .../helpers/formatPoolStats.ts | 0 .../components/send/SendAmount/index.tsx | 18 ++-- .../components/swap/SwapAmount/index.tsx | 18 ++-- .../hooks/useSwapSubmitQuoteExpiry.ts | 4 +- extension/src/popup/constants/earn.ts | 11 ++- extension/src/popup/helpers/searchAsset.ts | 5 +- extension/src/popup/metrics/earn.test.ts | 4 +- extension/src/popup/views/Swap/index.tsx | 2 +- .../src/popup/views/ViewPublicKey/index.tsx | 5 +- 30 files changed, 274 insertions(+), 228 deletions(-) create mode 100644 @shared/constants/sac.ts create mode 100644 extension/src/popup/components/amount/constants.ts rename extension/src/popup/components/{earn/EarnAmount/helpers/__tests__/depositAmount.test.ts => amount/helpers/__tests__/percentageAmount.test.ts} (59%) rename extension/src/popup/components/{earn/EarnAmount/helpers/depositAmount.ts => amount/helpers/percentageAmount.ts} (62%) create mode 100644 extension/src/popup/components/earn/StatRow/index.tsx create mode 100644 extension/src/popup/components/earn/StatRow/styles.scss rename extension/src/popup/components/earn/{PoolDetailsSheet => }/helpers/__tests__/formatPoolStats.test.ts (100%) rename extension/src/popup/components/earn/{PoolDetailsSheet => }/helpers/formatPoolStats.ts (100%) rename extension/src/popup/components/swap/{SwapAmount => }/hooks/useSwapSubmitQuoteExpiry.ts (93%) diff --git a/@shared/api/helpers/__tests__/blend.test.ts b/@shared/api/helpers/__tests__/blend.test.ts index c05f94eb69..5e3f98c7c3 100644 --- a/@shared/api/helpers/__tests__/blend.test.ts +++ b/@shared/api/helpers/__tests__/blend.test.ts @@ -5,6 +5,9 @@ import { } 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() })); @@ -12,9 +15,9 @@ jest.mock("@sentry/browser", () => ({ captureException: jest.fn() })); const mockedSend = sendMessageToBackground as jest.Mock; const networkDetails = { network: "PUBLIC" } as never; -const POOL_ID = "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD"; -const USDC_SAC = "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75"; -const XLM_SAC = "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA"; +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(() => { 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/helpers/soroban/__tests__/blend.test.ts b/@shared/helpers/soroban/__tests__/blend.test.ts index 1c761d7344..14d551b49f 100644 --- a/@shared/helpers/soroban/__tests__/blend.test.ts +++ b/@shared/helpers/soroban/__tests__/blend.test.ts @@ -4,10 +4,13 @@ import { 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 = "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75"; -const XLM_SAC = "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA"; -const POOL_ID = "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD"; +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"; /** diff --git a/extension/e2e-tests/helpers/stubs.ts b/extension/e2e-tests/helpers/stubs.ts index 4b331fe893..da3b06ad92 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") { @@ -3418,10 +3421,10 @@ export const stubBlendEarn = async ( positions?: unknown[]; } = {}, ) => { - const XLM_SAC = "CAS3J7GYLGXMF6TDJBBYYSE3HQ6BBSMLNUQ34T6TZMYMW2EVH34XOWMA"; - const USDC_SAC = "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75"; - const EURC_SAC = "CDTKPWPLOURQA2SGTKTUQOWRCBZEORB4BWBOMJ3D3ZTQQSGE5F6JBQLV"; - const POOL_ID = "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD"; + const { XLM: XLM_SAC, USDC: USDC_SAC, EURC: EURC_SAC } = PUBLIC_SACS; + // 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, diff --git a/extension/src/popup/components/amount/AmountCard/index.tsx b/extension/src/popup/components/amount/AmountCard/index.tsx index 5258869618..9393eebd60 100644 --- a/extension/src/popup/components/amount/AmountCard/index.tsx +++ b/extension/src/popup/components/amount/AmountCard/index.tsx @@ -131,6 +131,22 @@ export const AmountCard = ({ securityLevel === SecurityLevel.SUSPICIOUS; const isMalicious = securityLevel === SecurityLevel.MALICIOUS; + // 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}${ @@ -256,17 +272,7 @@ export const AmountCard = ({ className="AmountCard__asset-selector-inline AmountCard__asset-selector-inline--locked" data-testid="send-amount-dest-asset-locked" > - - {assetCode} + {assetPill}
) : (
- } /> - - } /> - after pair is de-emphasised so the // resulting value reads as the answer. &__before { diff --git a/extension/src/popup/components/earn/EarnSwap/index.tsx b/extension/src/popup/components/earn/EarnSwap/index.tsx index edc5493d6f..3642f60707 100644 --- a/extension/src/popup/components/earn/EarnSwap/index.tsx +++ b/extension/src/popup/components/earn/EarnSwap/index.tsx @@ -11,6 +11,10 @@ import { 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"; @@ -18,7 +22,7 @@ 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/SwapAmount/hooks/useSwapSubmitQuoteExpiry"; +import { useSwapSubmitQuoteExpiry } from "popup/components/swap/hooks/useSwapSubmitQuoteExpiry"; import { resetSimulation } from "popup/ducks/token-payment"; import { DestinationTokenDetails, @@ -85,8 +89,8 @@ export const EarnSwap = ({ dispatch(resetSimulation()); dispatch(saveAsset("native")); dispatch(saveIsToken(false)); - dispatch(saveAmount("0")); - dispatch(saveAmountUsd("0.00")); + dispatch(saveAmount(DEFAULT_AMOUNT)); + dispatch(saveAmountUsd(DEFAULT_AMOUNT_USD)); dispatch(saveDestinationAsset(destinationAsset)); dispatch(saveDestinationTokenDetails(destinationTokenDetails)); emitScreenViewed("earn_swap_amount", { flow: "earn" }); 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 index 142fd3b951..eeb4383383 100644 --- a/extension/src/popup/components/earn/EarnTokenPicker/helpers/__tests__/getNotEnoughVariant.test.ts +++ b/extension/src/popup/components/earn/EarnTokenPicker/helpers/__tests__/getNotEnoughVariant.test.ts @@ -4,6 +4,7 @@ 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 { @@ -84,17 +85,9 @@ describe("hasSwappableBalance", () => { it("ignores Soroban-only balances", () => { // Swap builds a classic pathPaymentStrictSend and rejects contract assets. - expect( - hasSwappableBalance( - [ - soroban( - "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75", - "9", - ), - ], - target, - ), - ).toBe(false); + expect(hasSwappableBalance([soroban(PUBLIC_SACS.USDC!, "9")], target)).toBe( + false, + ); }); it("is false for an empty account", () => { diff --git a/extension/src/popup/components/earn/EarnTokenPicker/helpers/resolveSwapDestination.ts b/extension/src/popup/components/earn/EarnTokenPicker/helpers/resolveSwapDestination.ts index 34bd6fa6d2..cde8b57c02 100644 --- a/extension/src/popup/components/earn/EarnTokenPicker/helpers/resolveSwapDestination.ts +++ b/extension/src/popup/components/earn/EarnTokenPicker/helpers/resolveSwapDestination.ts @@ -59,7 +59,9 @@ export const resolveSwapDestination = async ({ // 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", - decimals: option.decimals || CLASSIC_ASSET_DECIMALS, + // `??`, 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 diff --git a/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx b/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx index 66437dee85..3dcf82634b 100644 --- a/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx +++ b/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx @@ -8,11 +8,15 @@ import { AssetIcon } from "popup/components/account/AccountAssets"; import { settingsNetworkDetailsSelector } from "popup/ducks/settings"; import { PoolIcon } from "popup/components/earn/PoolIcon"; import { getCatalogAssetIdentity } from "popup/components/earn/helpers/earnAssetIcons"; +import { StatRow } from "popup/components/earn/StatRow"; import { usePoolReserveIcons } from "./hooks/usePoolReserveIcons"; import { getPoolDescription } from "./poolDescriptions"; -import { formatCompactUsd, formatRate } from "./helpers/formatPoolStats"; +import { + formatCompactUsd, + formatRate, +} from "popup/components/earn/helpers/formatPoolStats"; import "./styles.scss"; @@ -21,32 +25,6 @@ interface PoolDetailsSheetProps { onClose: () => void; } -const StatRow = ({ - label, - value, - isPositive = false, - testId, -}: { - label: string; - value: string; - isPositive?: boolean; - testId: string; -}) => ( -
- - {label} - -
- {value} -
-
-); - /** * Pool description and market stats, opened from the pool card on the amount * screen. @@ -115,33 +93,33 @@ export const PoolDetailsSheet = ({ pool, onClose }: PoolDetailsSheetProps) => {
-
- - {t("Accepted tokens")} - -
- {pool.reserves.map((reserve) => { - // The catalog reports native XLM with no symbol and no name, so - // the identity has to be derived rather than read straight off the - // reserve — AssetIcon needs a code to recognise XLM and an issuer - // to look anything else up. - const { code, issuer } = getCatalogAssetIdentity({ - symbol: reserve.symbol, - name: reserve.name, - assetId: reserve.assetId, - networkDetails, - }); - return ( - - ); - })} -
-
+ + {pool.reserves.map((reserve) => { + // The catalog reports native XLM with no symbol and no name, so + // the identity has to be derived rather than read straight off + // the reserve — AssetIcon needs a code to recognise XLM and an + // issuer to look anything else up. + const { code, issuer } = getCatalogAssetIdentity({ + symbol: reserve.symbol, + name: reserve.name, + assetId: reserve.assetId, + networkDetails, + }); + return ( + + ); + })} +
+ } + /> 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 index a95dd9b03e..12f227af3a 100644 --- a/extension/src/popup/components/earn/helpers/__tests__/earnAssetIcons.test.ts +++ b/extension/src/popup/components/earn/helpers/__tests__/earnAssetIcons.test.ts @@ -1,6 +1,7 @@ import { Asset, Networks } from "stellar-sdk"; import { NetworkDetails } from "@shared/constants/stellar"; +import { PUBLIC_SACS } from "@shared/constants/sac"; import { getCatalogAssetIdentity, getCatalogIconKey, @@ -37,7 +38,7 @@ describe("getCatalogIconKey", () => { getCatalogIconKey({ code: "USDC", issuer: USDC_ISSUER, - assetId: "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75", + assetId: PUBLIC_SACS.USDC!, networkDetails, }), ).toBe(`USDC:${USDC_ISSUER}`); @@ -57,7 +58,7 @@ describe("getCatalogIconKey", () => { expect( getCatalogIconKey({ code: "", - assetId: "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75", + assetId: PUBLIC_SACS.USDC!, networkDetails, }), ).toBe(""); @@ -65,7 +66,7 @@ describe("getCatalogIconKey", () => { }); describe("getCatalogAssetIdentity", () => { - const USDC_SAC = "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75"; + 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 diff --git a/extension/src/popup/components/earn/PoolDetailsSheet/helpers/__tests__/formatPoolStats.test.ts b/extension/src/popup/components/earn/helpers/__tests__/formatPoolStats.test.ts similarity index 100% rename from extension/src/popup/components/earn/PoolDetailsSheet/helpers/__tests__/formatPoolStats.test.ts rename to extension/src/popup/components/earn/helpers/__tests__/formatPoolStats.test.ts diff --git a/extension/src/popup/components/earn/PoolDetailsSheet/helpers/formatPoolStats.ts b/extension/src/popup/components/earn/helpers/formatPoolStats.ts similarity index 100% rename from extension/src/popup/components/earn/PoolDetailsSheet/helpers/formatPoolStats.ts rename to extension/src/popup/components/earn/helpers/formatPoolStats.ts 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/index.tsx b/extension/src/popup/components/swap/SwapAmount/index.tsx index 8b8f2ee96a..1973d112b2 100644 --- a/extension/src/popup/components/swap/SwapAmount/index.tsx +++ b/extension/src/popup/components/swap/SwapAmount/index.tsx @@ -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; @@ -833,12 +836,11 @@ export const SwapAmount = ({ // 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/hooks/useSwapSubmitQuoteExpiry.ts b/extension/src/popup/components/swap/hooks/useSwapSubmitQuoteExpiry.ts similarity index 93% rename from extension/src/popup/components/swap/SwapAmount/hooks/useSwapSubmitQuoteExpiry.ts rename to extension/src/popup/components/swap/hooks/useSwapSubmitQuoteExpiry.ts index 6a3ce200a9..d9b2b7a15a 100644 --- a/extension/src/popup/components/swap/SwapAmount/hooks/useSwapSubmitQuoteExpiry.ts +++ b/extension/src/popup/components/swap/hooks/useSwapSubmitQuoteExpiry.ts @@ -18,7 +18,9 @@ import { * 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. + * 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 diff --git a/extension/src/popup/constants/earn.ts b/extension/src/popup/constants/earn.ts index fb6d0a01a6..027422f031 100644 --- a/extension/src/popup/constants/earn.ts +++ b/extension/src/popup/constants/earn.ts @@ -2,10 +2,19 @@ * 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=${EARN_FLOW_PARAM}`; +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. 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/metrics/earn.test.ts b/extension/src/popup/metrics/earn.test.ts index 4612862b20..b8c4e959d3 100644 --- a/extension/src/popup/metrics/earn.test.ts +++ b/extension/src/popup/metrics/earn.test.ts @@ -1,4 +1,6 @@ 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"; @@ -21,7 +23,7 @@ jest.mock("helpers/metrics", () => ({ const mockEmitMetric = emitMetric as jest.MockedFunction; -const POOL_ID = "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD"; +const POOL_ID = BLEND_FIXED_POOL_IDS[NETWORKS.PUBLIC]!; describe("Earn funnel metrics", () => { beforeEach(() => { diff --git a/extension/src/popup/views/Swap/index.tsx b/extension/src/popup/views/Swap/index.tsx index d6e424e6f2..2f2b77f758 100644 --- a/extension/src/popup/views/Swap/index.tsx +++ b/extension/src/popup/views/Swap/index.tsx @@ -12,7 +12,7 @@ import { TransactionConfirm } from "popup/components/InternalTransaction/SubmitT import { METRIC_NAMES } from "popup/constants/metricsNames"; import { SwapAsset } from "popup/components/swap/SwapAsset"; import { SwapAmount } from "popup/components/swap/SwapAmount"; -import { useSwapSubmitQuoteExpiry } from "popup/components/swap/SwapAmount/hooks/useSwapSubmitQuoteExpiry"; +import { useSwapSubmitQuoteExpiry } from "popup/components/swap/hooks/useSwapSubmitQuoteExpiry"; import { AppDispatch } from "popup/App"; import { resetSubmission, diff --git a/extension/src/popup/views/ViewPublicKey/index.tsx b/extension/src/popup/views/ViewPublicKey/index.tsx index fa64c7c905..9a35181d24 100644 --- a/extension/src/popup/views/ViewPublicKey/index.tsx +++ b/extension/src/popup/views/ViewPublicKey/index.tsx @@ -11,7 +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 { EARN_FLOW_PARAM } from "popup/constants/earn"; +import { isEarnFlowSearch } from "popup/constants/earn"; import { accountNameSelector } from "popup/ducks/accountServices"; import { AppDataType, useGetAppData } from "helpers/hooks/useGetAppData"; import { RequestState } from "constants/request"; @@ -30,8 +30,7 @@ export const ViewPublicKey = () => { 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 = - new URLSearchParams(location.search).get("flow") === EARN_FLOW_PARAM; + 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 From c9ceefb2dfdd45491eafb7445e46bbb0714352d2 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Thu, 20 Aug 2026 11:36:48 -0600 Subject: [PATCH 14/43] fix(earn): extract the pool description for translation --- .../earn/PoolDetailsSheet/poolDescriptions.ts | 31 +++++++++++++------ .../__tests__/translationParity.test.ts | 4 +++ .../src/popup/locales/en/translation.json | 1 + .../src/popup/locales/pt/translation.json | 1 + 4 files changed, 28 insertions(+), 9 deletions(-) diff --git a/extension/src/popup/components/earn/PoolDetailsSheet/poolDescriptions.ts b/extension/src/popup/components/earn/PoolDetailsSheet/poolDescriptions.ts index 0a8daca800..6c6ba57580 100644 --- a/extension/src/popup/components/earn/PoolDetailsSheet/poolDescriptions.ts +++ b/extension/src/popup/components/earn/PoolDetailsSheet/poolDescriptions.ts @@ -1,3 +1,5 @@ +import { BLEND_FIXED_POOL_IDS } from "@shared/constants/blend"; +import { NETWORKS } from "@shared/constants/stellar"; import i18n from "popup/helpers/localizationConfig"; /** @@ -9,18 +11,29 @@ import i18n from "popup/helpers/localizationConfig"; * admin account is burned (master weight 0, no signers, all thresholds 0), so * "no admin" is a fact about that pool, not about Blend pools in general. * + * Each entry is a thunk holding a literal `i18n.t` call, for two reasons. The + * i18next scanner extracts keys by reading the literal argument of a `t()` call + * (see `func.list` in webpack.extension.js), so passing a variable — a string + * looked up out of this map — extracts nothing: the key never lands in + * translation.json, i18next falls back to returning the key itself, and the copy + * renders in English in every language while looking correct in en. And the + * thunk keeps resolution lazy, where a bare `i18n.t` at module scope would run + * before the language bundle has loaded. + * * A pool with no entry renders no description rather than a wrong one. */ -const POOL_DESCRIPTION_KEYS: Record = { +const POOL_DESCRIPTIONS: Record string> = { // Fixed Pool v2 — mainnet - CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD: - "Permissionless lending pool with no admin. Collateral factors, interest curves, and supported assets are locked at deployment.", + [BLEND_FIXED_POOL_IDS[NETWORKS.PUBLIC]!]: () => + i18n.t( + "Permissionless lending pool with no admin. Collateral factors, interest curves, and supported assets are locked at deployment.", + ), // Fixed Pool v2 — testnet - CCEBVDYM32YNYCVNRXQKDFFPISJJCV557CDZEIRBEE4NCV4KHPQ44HGF: - "Permissionless lending pool with no admin. Collateral factors, interest curves, and supported assets are locked at deployment.", + [BLEND_FIXED_POOL_IDS[NETWORKS.TESTNET]!]: () => + i18n.t( + "Permissionless lending pool with no admin. Collateral factors, interest curves, and supported assets are locked at deployment.", + ), }; -export const getPoolDescription = (poolId: string): string | null => { - const description = POOL_DESCRIPTION_KEYS[poolId]; - return description ? i18n.t(description) : null; -}; +export const getPoolDescription = (poolId: string): string | null => + POOL_DESCRIPTIONS[poolId]?.() ?? null; diff --git a/extension/src/popup/locales/__tests__/translationParity.test.ts b/extension/src/popup/locales/__tests__/translationParity.test.ts index 4a14a7eac2..8ffded581c 100644 --- a/extension/src/popup/locales/__tests__/translationParity.test.ts +++ b/extension/src/popup/locales/__tests__/translationParity.test.ts @@ -51,6 +51,10 @@ const earnKeys = [ "Add XLM to your wallet to continue", "Transaction failed. Try again.", "Pool Details", + // Reached through a lookup table rather than a literal `t()` argument, so the + // scanner cannot see it — the one earn string that has to be listed here to + // stay extracted. See PoolDetailsSheet/poolDescriptions.ts. + "Permissionless lending pool with no admin. Collateral factors, interest curves, and supported assets are locked at deployment.", "Lending Interest", "Current Net APY", "Accepted tokens", diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index 0bde800ce7..51962f71d2 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -478,6 +478,7 @@ "Password": "Password", "Path": "Path", "Payment token logo": "Payment token logo", + "Permissionless lending pool with no admin. Collateral factors, interest curves, and supported assets are locked at deployment.": "Permissionless lending pool with no admin. Collateral factors, interest curves, and supported assets are locked at deployment.", "Pin Freighter to the toolbar": "Pin Freighter to the toolbar", "Please check if the network information is correct and try again.": "Please check if the network information is correct and try again.", "Please check the asset code or address.": "Please check the asset code or address.", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index 82f6acbc33..02792e0aa6 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -478,6 +478,7 @@ "Password": "Senha", "Path": "Caminho", "Payment token logo": "Logotipo do token de pagamento", + "Permissionless lending pool with no admin. Collateral factors, interest curves, and supported assets are locked at deployment.": "Pool de empréstimo sem permissão e sem administrador. Fatores de garantia, curvas de juros e ativos compatíveis são fixados na implantação.", "Pin Freighter to the toolbar": "Fixar Freighter na toolbar", "Please check if the network information is correct and try again.": "Por favor, verifique se as informações da rede estão corretas e tente novamente.", "Please check the asset code or address.": "Por favor, verifique o código do ativo ou endereço.", From 7d9e9e6a68a6d90f504b5864d295d872d321e309 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Thu, 20 Aug 2026 14:45:35 -0600 Subject: [PATCH 15/43] fix(earn): reset the deposit when the selected asset changes --- .../components/earn/EarnAmount/index.tsx | 7 + .../earn/PoolDetailsSheet/index.tsx | 143 +++++++++--------- .../earn/PoolDetailsSheet/styles.scss | 22 +++ extension/src/popup/views/Earn/index.tsx | 16 +- 4 files changed, 118 insertions(+), 70 deletions(-) diff --git a/extension/src/popup/components/earn/EarnAmount/index.tsx b/extension/src/popup/components/earn/EarnAmount/index.tsx index c6f6780ae9..3b3da06227 100644 --- a/extension/src/popup/components/earn/EarnAmount/index.tsx +++ b/extension/src/popup/components/earn/EarnAmount/index.tsx @@ -101,6 +101,13 @@ export const EarnAmount = ({ goBack, onConfirm }: EarnAmountProps) => { // 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 diff --git a/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx b/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx index 3dcf82634b..06a7f05b89 100644 --- a/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx +++ b/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx @@ -63,78 +63,83 @@ export const PoolDetailsSheet = ({ pool, onClose }: PoolDetailsSheetProps) => {
- {description && ( -
- - {t("Description")} - - - {description} - -
- )} +
+ {description && ( +
+ + {t("Description")} + + + {description} + +
+ )} - - {t("Pool Details")} - + + {t("Pool Details")} + -
- - -
+
+ + +
-
- - {pool.reserves.map((reserve) => { - // The catalog reports native XLM with no symbol and no name, so - // the identity has to be derived rather than read straight off - // the reserve — AssetIcon needs a code to recognise XLM and an - // issuer to look anything else up. - const { code, issuer } = getCatalogAssetIdentity({ - symbol: reserve.symbol, - name: reserve.name, - assetId: reserve.assetId, - networkDetails, - }); - return ( - - ); - })} -
- } - /> - - - +
+ + {pool.reserves.map((reserve) => { + // The catalog reports native XLM with no symbol and no name, + // so the identity has to be derived rather than read straight + // off the reserve — AssetIcon needs a code to recognise XLM + // and an issuer to look anything else up. + const { code, issuer } = getCatalogAssetIdentity({ + symbol: reserve.symbol, + name: reserve.name, + assetId: reserve.assetId, + networkDetails, + }); + return ( + + ); + })} +
+ } + /> + + + +
+ ), +})); + +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); + expect(trackEarnDepositFailed).toHaveBeenCalledTimes(1); + + // 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).toHaveBeenCalledTimes(1); + }); + + 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 index 391433cd3f..82cb69c10a 100644 --- a/extension/src/popup/views/Earn/index.tsx +++ b/extension/src/popup/views/Earn/index.tsx @@ -167,6 +167,15 @@ export const Earn = () => { // 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]); From 0e00205b68d536612ae801aa8b197968ea4c5bd9 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Fri, 21 Aug 2026 10:28:59 -0600 Subject: [PATCH 18/43] fix(earn): check the resource fee on non-XLM deposits too --- .../earn/EarnAmount/NetworkFeeSheet.tsx | 2 +- .../helpers/__tests__/earnCtaState.test.ts | 36 ++++++++++++++++ .../earn/EarnAmount/helpers/earnCtaState.ts | 17 ++++++-- .../components/earn/EarnAmount/index.tsx | 43 ++++++++++++------- .../__tests__/translationParity.test.ts | 2 +- .../src/popup/locales/en/translation.json | 2 +- .../src/popup/locales/pt/translation.json | 2 +- 7 files changed, 81 insertions(+), 23 deletions(-) diff --git a/extension/src/popup/components/earn/EarnAmount/NetworkFeeSheet.tsx b/extension/src/popup/components/earn/EarnAmount/NetworkFeeSheet.tsx index e51991b2c4..fab1492c2a 100644 --- a/extension/src/popup/components/earn/EarnAmount/NetworkFeeSheet.tsx +++ b/extension/src/popup/components/earn/EarnAmount/NetworkFeeSheet.tsx @@ -48,7 +48,7 @@ export const NetworkFeeSheet = ({
- {t("You need some XLM for network fee")} + {t("You need some XLM for the network fee")}
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 index c26018200f..5fe2735f29 100644 --- a/extension/src/popup/components/earn/EarnAmount/helpers/__tests__/earnCtaState.test.ts +++ b/extension/src/popup/components/earn/EarnAmount/helpers/__tests__/earnCtaState.test.ts @@ -73,6 +73,17 @@ describe("needsXlmForFee", () => { 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", () => { @@ -127,6 +138,31 @@ describe("getXlmFeeShortfall", () => { }), ).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", () => { diff --git a/extension/src/popup/components/earn/EarnAmount/helpers/earnCtaState.ts b/extension/src/popup/components/earn/EarnAmount/helpers/earnCtaState.ts index fb6c5d229f..e015fdda4f 100644 --- a/extension/src/popup/components/earn/EarnAmount/helpers/earnCtaState.ts +++ b/extension/src/popup/components/earn/EarnAmount/helpers/earnCtaState.ts @@ -43,6 +43,10 @@ export const getEarnCtaState = ({ * 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, @@ -66,8 +70,12 @@ export const needsXlmForFee = ({ * nets out the base reserve and the inclusion fee, so only the resource fee is * left to cover. * - * Only XLM deposits can be short this way: for any other asset the fee comes - * out of an untouched XLM balance. + * 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, @@ -75,7 +83,10 @@ export const getXlmFeeShortfall = ({ resourceFee, }: { spendableXlm: string; - /** Cleaned deposit amount — no group separators. */ + /** + * 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; diff --git a/extension/src/popup/components/earn/EarnAmount/index.tsx b/extension/src/popup/components/earn/EarnAmount/index.tsx index 3b3da06227..070bc0c94a 100644 --- a/extension/src/popup/components/earn/EarnAmount/index.tsx +++ b/extension/src/popup/components/earn/EarnAmount/index.tsx @@ -250,29 +250,40 @@ export const EarnAmount = ({ goBack, onConfirm }: EarnAmountProps) => { transactionTimeout, }); - // Simulation is the first place the resource fee is known, so an XLM - // deposit that leaves nothing for it is caught here rather than by holding - // a guessed buffer back from the balance. The submission would otherwise + // 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. - const shortfall = isXlm - ? getXlmFeeShortfall({ - spendableXlm, - amount: enteredAmount.toFixed(), - resourceFee: simulation.resourceFee || "0", - }) - : "0"; + // + // 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", }); - setSimulationError( - t( - "Not enough XLM left for the network fee. Reduce your deposit by at least {{amount}} XLM.", - { amount: formatAmount(shortfall) }, - ), - ); + // 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; } diff --git a/extension/src/popup/locales/__tests__/translationParity.test.ts b/extension/src/popup/locales/__tests__/translationParity.test.ts index 8ffded581c..14001b30fb 100644 --- a/extension/src/popup/locales/__tests__/translationParity.test.ts +++ b/extension/src/popup/locales/__tests__/translationParity.test.ts @@ -45,7 +45,7 @@ const earnKeys = [ "by Blend", "Review deposit", "Insufficient funds", - "You need some XLM for network fee", + "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", diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index 51962f71d2..6b6d1cb0ce 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -815,7 +815,7 @@ "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 network fee": "You need some XLM for network fee", + "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", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index 02792e0aa6..981826b61f 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -815,7 +815,7 @@ "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 network fee": "Você precisa de XLM para a taxa de rede", + "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", From 58bc03e768e14a6d9957100e455057a94b737204 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Fri, 21 Aug 2026 10:52:49 -0600 Subject: [PATCH 19/43] fix(earn): await the position lookup with the deposit simulation The existing-position fetch was fired without being awaited, so review could open on the simulation alone and then flip Position, Monthly and Yearly under the user when the slower lookup landed. Await both, with the position leg resolving to "0" on failure so it still cannot block a deposit. --- .../__tests__/EarnAmount.position.test.tsx | 197 ++++++++++++++++++ .../components/earn/EarnAmount/index.tsx | 46 ++-- 2 files changed, 221 insertions(+), 22 deletions(-) create mode 100644 extension/src/popup/components/earn/EarnAmount/__tests__/EarnAmount.position.test.tsx 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/index.tsx b/extension/src/popup/components/earn/EarnAmount/index.tsx index 070bc0c94a..cd5e6dc768 100644 --- a/extension/src/popup/components/earn/EarnAmount/index.tsx +++ b/extension/src/popup/components/earn/EarnAmount/index.tsx @@ -222,33 +222,35 @@ export const EarnAmount = ({ goBack, onConfirm }: EarnAmountProps) => { setIsSimulating(true); try { - // The existing position is the "before" half of Review's 0.00 -> N row. - // Fetched alongside the simulation and deliberately non-fatal: a stale - // before-value must never block a deposit that is otherwise valid. - getBlendSuppliedTokens({ + // 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) => - dispatch( - saveCurrentPositionTokens( - formatTokenAmount(new BigNumber(raw), decimals), - ), - ), - ) - .catch(() => dispatch(saveCurrentPositionTokens("0"))); - - const simulation = await simulate({ - publicKey: data.publicKey, - assetId: selectedAssetId, - amount, - decimals, - networkDetails: data.networkDetails, - transactionFee: recommendedFee, - transactionTimeout, - }); + .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 From c8415a4a74f417457aaf0f617cbe0240480f004a Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Fri, 21 Aug 2026 12:47:20 -0600 Subject: [PATCH 20/43] fix(earn): report the real outcome of a deposit left in flight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closing the "Depositing" screen navigates back to the account view rather than closing the popup, so the submit hook's continuation kept running and emitted earn.deposit_completed after earn.deposit_abandoned. The deposit was never abandoned — only the screen was. Worse than the double count was the asymmetry: a post-close success still emitted from the hook's closure, while a post-close failure emitted nothing, because earn.deposit_failed lived in the Earn view's effect and that view had unmounted. - useSubmitEarnTxData now emits both outcomes, from the closure that outlives the screen. The Earn view keeps the failure UI work but skips its emit while DEPOSIT_CONFIRM is active, so it still owns the failures that never reach that step (a device-rejected signature at review) without double counting. - Rename earn.deposit_abandoned to earn.deposit_processing_dismissed: a UX signal, not an outcome. The outcome is lost only when the popup itself closes. - Ignore a submitStatus left behind by a previous flow. A late rejection wrote ERROR back after closeEarnFlow had reset the submission, and re-entering Earn read it as its own failure — emitting against an empty asset and dropping the fresh flow onto the amount screen. - Extract getFailureReasonCode, now shared by both emitters, and stop it throwing: the soroban submit thunk rejects with the parsed response body in errorMessage, which scrubStrKeys called .replace on. Inside the hook's try/catch that swallowed the failure entirely. --- .../EarnSubmit/__tests__/EarnSubmit.test.tsx | 91 ++++++++++++++++++- .../EarnSubmit/hooks/useSubmitEarnTxData.tsx | 46 ++++++++-- .../components/earn/EarnSubmit/index.tsx | 18 ++-- .../__tests__/failureReasonCode.test.ts | 66 ++++++++++++++ .../earn/helpers/failureReasonCode.ts | 43 +++++++++ .../constants/__tests__/metricsNames.test.ts | 4 +- extension/src/popup/constants/metricsNames.ts | 9 +- extension/src/popup/metrics/earn.test.ts | 8 +- extension/src/popup/metrics/earn.ts | 24 ++++- .../__tests__/Earn.submitFailure.test.tsx | 69 +++++++++++++- extension/src/popup/views/Earn/index.tsx | 58 ++++++------ 11 files changed, 374 insertions(+), 62 deletions(-) create mode 100644 extension/src/popup/components/earn/helpers/__tests__/failureReasonCode.test.ts create mode 100644 extension/src/popup/components/earn/helpers/failureReasonCode.ts diff --git a/extension/src/popup/components/earn/EarnSubmit/__tests__/EarnSubmit.test.tsx b/extension/src/popup/components/earn/EarnSubmit/__tests__/EarnSubmit.test.tsx index 214d500244..1bfaab2f45 100644 --- a/extension/src/popup/components/earn/EarnSubmit/__tests__/EarnSubmit.test.tsx +++ b/extension/src/popup/components/earn/EarnSubmit/__tests__/EarnSubmit.test.tsx @@ -1,5 +1,6 @@ import React from "react"; -import { render, screen, waitFor } from "@testing-library/react"; +import { act, render, 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"; @@ -9,6 +10,7 @@ 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 = @@ -42,6 +44,40 @@ jest.mock("helpers/metrics", () => ({ 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: {} } }), + }), + }; +}; + const renderSubmit = ({ xdr, // Defaults to `xdr`; pass separately to model the render the flow actually @@ -153,6 +189,59 @@ describe("EarnSubmit", () => { 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. diff --git a/extension/src/popup/components/earn/EarnSubmit/hooks/useSubmitEarnTxData.tsx b/extension/src/popup/components/earn/EarnSubmit/hooks/useSubmitEarnTxData.tsx index 1a1e2f1194..14c2a2fe1d 100644 --- a/extension/src/popup/components/earn/EarnSubmit/hooks/useSubmitEarnTxData.tsx +++ b/extension/src/popup/components/earn/EarnSubmit/hooks/useSubmitEarnTxData.tsx @@ -2,11 +2,16 @@ 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 } from "popup/metrics/earn"; +import { + trackEarnDepositCompleted, + trackEarnDepositFailed, +} from "popup/metrics/earn"; +import { getFailureReasonCode } from "popup/components/earn/helpers/failureReasonCode"; import { AccountBalances, useGetBalances } from "helpers/hooks/useGetBalances"; import { signFreighterSorobanTransaction, @@ -35,9 +40,16 @@ interface SubmitEarnTxData { * same way the shared `useSubmitTxData` does it: it is the value the hardware * overlay actually wrote, and reading it here cannot go stale. * - * Emits the deposit's success event only. Failures are emitted once, by the Earn - * view's `submitStatus: ERROR` effect — every path out of here that can fail - * rejects one of the transactionSubmission thunks, and those set that status. + * 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, @@ -69,6 +81,13 @@ export function useSubmitEarnTxData({ includeIcons: false, }); + const trackDepositFailed = (error: ErrorMessage | undefined) => + trackEarnDepositFailed({ + assetCode, + poolId, + reasonCode: getFailureReasonCode(error), + }); + const fetchData = async () => { dispatch({ type: "FETCH_DATA_START" }); try { @@ -88,9 +107,14 @@ export function useSubmitEarnTxData({ ) { // Submitting `xdr` unsigned would fail on the network anyway, but as a // *second* failure: the rejected sign thunk has already set - // submitStatus to ERROR, the flow has already stepped back to the - // amount screen, and the late submit rejection would trip that effect - // a second time — a duplicate earn.deposit_failed for one attempt. + // 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; } @@ -106,7 +130,13 @@ export function useSubmitEarnTxData({ }), ); - if (submitFreighterSorobanTransaction.fulfilled.match(submitResp)) { + if (!submitFreighterSorobanTransaction.fulfilled.match(submitResp)) { + trackDepositFailed( + submitFreighterSorobanTransaction.rejected.match(submitResp) + ? submitResp.payload + : undefined, + ); + } else { trackEarnDepositCompleted({ assetCode, poolId, diff --git a/extension/src/popup/components/earn/EarnSubmit/index.tsx b/extension/src/popup/components/earn/EarnSubmit/index.tsx index 449d372623..86dec47d32 100644 --- a/extension/src/popup/components/earn/EarnSubmit/index.tsx +++ b/extension/src/popup/components/earn/EarnSubmit/index.tsx @@ -22,7 +22,7 @@ import { transactionSubmissionSelector } from "popup/ducks/transactionSubmission import { earnSelector } from "popup/ducks/earn"; import { iconsSelector } from "popup/ducks/cache"; import { emitScreenViewed } from "helpers/metrics"; -import { trackEarnDepositAbandoned } from "popup/metrics/earn"; +import { trackEarnDepositDismissed } from "popup/metrics/earn"; import { useSubmitEarnTxData } from "./hooks/useSubmitEarnTxData"; @@ -41,9 +41,11 @@ interface EarnSubmitProps { * uses for Sending/Sent. * * Close is offered while in flight because a Soroban submission can outlast the - * popup. The result is deliberately not tracked afterwards: the outcome shows up - * in the refreshed balances and in history, and following it would mean owning - * an in-flight submission outside the popup, which nothing does today. + * 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(); @@ -125,9 +127,11 @@ export const EarnSubmit = ({ xdr, onExit }: EarnSubmitProps) => { isRounded variant="tertiary" onClick={() => { - // The only record that this deposit has an outcome nobody - // will report: the flow stops following it here. - trackEarnDepositAbandoned({ + // A UX signal, not an outcome — the deposit is in flight and + // useSubmitEarnTxData still emits its completed/failed event + // from a closure this unmount does not touch. Only closing + // the popup outright loses the outcome. + trackEarnDepositDismissed({ assetCode: srcAsset.code, poolId: pool?.id || "", }); 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/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/constants/__tests__/metricsNames.test.ts b/extension/src/popup/constants/__tests__/metricsNames.test.ts index 7f9d1cb93f..b7c7bc6add 100644 --- a/extension/src/popup/constants/__tests__/metricsNames.test.ts +++ b/extension/src/popup/constants/__tests__/metricsNames.test.ts @@ -60,7 +60,9 @@ describe("METRIC_NAMES domain-event catalog", () => { 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.earnDepositAbandoned).toBe("earn.deposit_abandoned"); + expect(METRIC_NAMES.earnDepositDismissed).toBe( + "earn.deposit_processing_dismissed", + ); }); it("names collectible-send and transaction-submission events", () => { diff --git a/extension/src/popup/constants/metricsNames.ts b/extension/src/popup/constants/metricsNames.ts index 4f28f8ea4b..c2cb6f9e3e 100644 --- a/extension/src/popup/constants/metricsNames.ts +++ b/extension/src/popup/constants/metricsNames.ts @@ -62,10 +62,11 @@ export const METRIC_NAMES = { earnSimulationFailed: "earn.simulation_failed", earnDepositCompleted: "earn.deposit_completed", earnDepositFailed: "earn.deposit_failed", - // Closed while the submission was still in flight. The flow stops following - // the result there, so this marks an outcome that no completed/failed event - // will ever report. - earnDepositAbandoned: "earn.deposit_abandoned", + // 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", diff --git a/extension/src/popup/metrics/earn.test.ts b/extension/src/popup/metrics/earn.test.ts index b8c4e959d3..a2a952e01f 100644 --- a/extension/src/popup/metrics/earn.test.ts +++ b/extension/src/popup/metrics/earn.test.ts @@ -6,7 +6,7 @@ import { METRIC_NAMES } from "popup/constants/metricsNames"; import { trackEarnBalanceInsufficientShown, - trackEarnDepositAbandoned, + trackEarnDepositDismissed, trackEarnDepositCompleted, trackEarnDepositFailed, trackEarnFundingActionSelected, @@ -136,11 +136,11 @@ describe("Earn funnel metrics", () => { ); }); - it("reports a deposit left in flight", () => { - trackEarnDepositAbandoned({ assetCode: "USDC", poolId: POOL_ID }); + it("reports an in-flight deposit the user stopped watching", () => { + trackEarnDepositDismissed({ assetCode: "USDC", poolId: POOL_ID }); expect(mockEmitMetric).toHaveBeenCalledWith( - METRIC_NAMES.earnDepositAbandoned, + METRIC_NAMES.earnDepositDismissed, { asset_code: "USDC", pool_id: POOL_ID }, ); }); diff --git a/extension/src/popup/metrics/earn.ts b/extension/src/popup/metrics/earn.ts index 4d30596058..9c0d766030 100644 --- a/extension/src/popup/metrics/earn.ts +++ b/extension/src/popup/metrics/earn.ts @@ -139,18 +139,24 @@ export const trackEarnPercentAmountSelected = ({ }; /** - * Close pressed while the deposit was still in flight. The flow deliberately - * does not follow the submission after that, so this event is the only record - * that an outcome exists which no `completed`/`failed` event will report. + * 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 trackEarnDepositAbandoned = ({ +export const trackEarnDepositDismissed = ({ assetCode, poolId, }: { assetCode: string; poolId: string; }) => { - emitMetric(METRIC_NAMES.earnDepositAbandoned, { + emitMetric(METRIC_NAMES.earnDepositDismissed, { asset_code: assetCode, pool_id: poolId, }); @@ -176,6 +182,14 @@ export const trackEarnDepositCompleted = ({ }); }; +/** + * 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, diff --git a/extension/src/popup/views/Earn/__tests__/Earn.submitFailure.test.tsx b/extension/src/popup/views/Earn/__tests__/Earn.submitFailure.test.tsx index 08245304dc..7dd9811b2b 100644 --- a/extension/src/popup/views/Earn/__tests__/Earn.submitFailure.test.tsx +++ b/extension/src/popup/views/Earn/__tests__/Earn.submitFailure.test.tsx @@ -2,6 +2,7 @@ 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"; @@ -160,12 +161,76 @@ describe("Earn deposit failure", () => { expect(screen.queryByTestId("stub-deposit-terminal")).toBeNull(), ); expect(mockDepositMounts).toHaveBeenCalledTimes(1); - expect(trackEarnDepositFailed).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).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 () => { diff --git a/extension/src/popup/views/Earn/index.tsx b/extension/src/popup/views/Earn/index.tsx index 82cb69c10a..692698c201 100644 --- a/extension/src/popup/views/Earn/index.tsx +++ b/extension/src/popup/views/Earn/index.tsx @@ -8,7 +8,7 @@ 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, ErrorMessage } from "@shared/api/types"; +import { ActionStatus } from "@shared/api/types"; import { resetSubmission, resetSubmitStatus, @@ -27,13 +27,12 @@ import { setDidSwapInFlow, setEarnSubmitFailed, } from "popup/ducks/earn"; -import { scrubStrKeys } from "helpers/stellarStrKey"; -import { getResultCodes } from "popup/helpers/parseTransaction"; 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"; @@ -72,23 +71,6 @@ const EARN_SCREEN_BY_STEP: Partial< [STEPS.AMOUNT]: { screen_name: "earn_amount", flow: "earn" }, }; -/** - * 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. - */ -const getFailureReasonCode = (error: ErrorMessage | undefined) => { - const resultCodes = getResultCodes(error); - return ( - resultCodes.operations?.[0] || - resultCodes.transaction || - scrubStrKeys(error?.errorMessage) || - "unknown" - ); -}; - export const Earn = () => { const dispatch = useDispatch(); const navigate = useNavigate(); @@ -116,6 +98,7 @@ export const Earn = () => { 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); @@ -148,20 +131,35 @@ export const Earn = () => { // 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. // - // This is the flow's single source of `earn.deposit_failed`. Every way a - // deposit can fail — a rejected signature, a rejected submit — lands in - // `submitStatus: ERROR` (see transactionSubmission's extraReducers), so the - // submit hook must not emit its own failure event or the two would double - // count. + // 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; } - trackEarnDepositFailed({ - assetCode: getAssetFromCanonical(submission.transactionData.asset).code, - poolId: pool?.id || "", - reasonCode: getFailureReasonCode(submission.error), - }); + 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. From c2bbe6178647553a50ae9fc1ed8dcc01a541e9ff Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Fri, 21 Aug 2026 13:24:36 -0600 Subject: [PATCH 21/43] fix(earn): stop labelling disabled reserves as accepted tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pool details sheet listed every reserve the catalog reports under "Accepted tokens", including reserves whose `enabled` flag is false. Blend's `require_action_allowed` panics with `ReserveDisabled` (#1223) on Supply, SupplyCollateral, and Borrow into such a reserve — only Withdraw and Repay stay open — so the row could claim a token was depositable when it was not. The backend already applies the same filter one endpoint over: `deriveEarnOptions` skips disabled reserves, so the token picker never offers them. The pools catalog deliberately reports every reserve with its flag and leaves the decision to the client, which left the sheet as the only place able to contradict the picker on the same screen. Filter through a shared `getAcceptedReserves`, used by both the icon row and the icon-resolution hook so the two cannot drift and no icon is fetched for a token that is never drawn. An all-disabled pool renders "--", the same unavailable marker the USD and rate rows use. No currently served pool is affected — all three mainnet Fixed Pool reserves are enabled — but a pool admin can flip the flag at any time. Covered by a unit test on the helper and by the e2e pool-details test, whose Blend stub now carries a disabled reserve alongside the enabled ones. --- extension/e2e-tests/earnDeposit.test.ts | 9 +++ extension/e2e-tests/helpers/stubs.ts | 38 +++++++++++ .../hooks/usePoolReserveIcons.tsx | 6 +- .../earn/PoolDetailsSheet/index.tsx | 57 ++++++++++------- .../helpers/__tests__/poolReserves.test.ts | 64 +++++++++++++++++++ .../components/earn/helpers/poolReserves.ts | 19 ++++++ 6 files changed, 169 insertions(+), 24 deletions(-) create mode 100644 extension/src/popup/components/earn/helpers/__tests__/poolReserves.test.ts create mode 100644 extension/src/popup/components/earn/helpers/poolReserves.ts diff --git a/extension/e2e-tests/earnDeposit.test.ts b/extension/e2e-tests/earnDeposit.test.ts index 5c9e93b8f2..9e84fee11d 100644 --- a/extension/e2e-tests/earnDeposit.test.ts +++ b/extension/e2e-tests/earnDeposit.test.ts @@ -298,6 +298,15 @@ test("pool details sheet shows market stats including Backstop", async ({ // 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"); + + // Only the pool's enabled reserves count as accepted tokens. The stubbed + // catalog carries three — XLM, USDC, and a disabled AQUA — and Blend rejects + // a deposit into a disabled reserve, so the cluster must show two icons. + await expect( + page + .getByTestId("earn-pool-accepted-tokens") + .locator(".PoolDetailsSheet__tokens > *"), + ).toHaveCount(2); }); // --------------------------------------------------------------------------- diff --git a/extension/e2e-tests/helpers/stubs.ts b/extension/e2e-tests/helpers/stubs.ts index 02513e7f76..5c63d9ac2c 100644 --- a/extension/e2e-tests/helpers/stubs.ts +++ b/extension/e2e-tests/helpers/stubs.ts @@ -3479,6 +3479,10 @@ export const stubBlendEarn = async ( } = {}, ) => { 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]!; @@ -3543,6 +3547,12 @@ export const stubBlendEarn = async ( 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, @@ -3558,6 +3568,34 @@ export const stubBlendEarn = async ( 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, + }, ], }, ], diff --git a/extension/src/popup/components/earn/PoolDetailsSheet/hooks/usePoolReserveIcons.tsx b/extension/src/popup/components/earn/PoolDetailsSheet/hooks/usePoolReserveIcons.tsx index 7cb26a3edb..9c45de6d4b 100644 --- a/extension/src/popup/components/earn/PoolDetailsSheet/hooks/usePoolReserveIcons.tsx +++ b/extension/src/popup/components/earn/PoolDetailsSheet/hooks/usePoolReserveIcons.tsx @@ -8,6 +8,7 @@ import { getCatalogAssetIdentity, resolveEarnAssetIcons, } from "popup/components/earn/helpers/earnAssetIcons"; +import { getAcceptedReserves } from "popup/components/earn/helpers/poolReserves"; import { iconsSelector, saveIconsForBalances, @@ -44,8 +45,9 @@ export const usePoolReserveIcons = (pool: BlendCatalogPool | null) => { let isActive = true; const resolve = async () => { - const reserves = pool?.reserves || []; - const assets = reserves + // Exactly what the sheet renders — a disabled reserve is never drawn, so + // resolving its icon would be a TOML round-trip for nothing. + const assets = getAcceptedReserves(pool) .map((reserve) => ({ assetId: reserve.assetId, ...getCatalogAssetIdentity({ diff --git a/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx b/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx index 06a7f05b89..874701d420 100644 --- a/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx +++ b/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx @@ -8,6 +8,7 @@ import { AssetIcon } from "popup/components/account/AccountAssets"; import { settingsNetworkDetailsSelector } from "popup/ducks/settings"; import { PoolIcon } from "popup/components/earn/PoolIcon"; import { getCatalogAssetIdentity } from "popup/components/earn/helpers/earnAssetIcons"; +import { getAcceptedReserves } from "popup/components/earn/helpers/poolReserves"; import { StatRow } from "popup/components/earn/StatRow"; import { usePoolReserveIcons } from "./hooks/usePoolReserveIcons"; @@ -33,12 +34,17 @@ interface PoolDetailsSheetProps { * 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. + * + * "Accepted tokens" shows only the pool's enabled reserves. The catalog reports + * disabled ones too, but Blend rejects a deposit into them, and the token + * picker behind this sheet has already dropped them — see `getAcceptedReserves`. */ export const PoolDetailsSheet = ({ pool, onClose }: PoolDetailsSheetProps) => { const { t } = useTranslation(); const description = getPoolDescription(pool.id); const networkDetails = useSelector(settingsNetworkDetailsSelector); const reserveIcons = usePoolReserveIcons(pool); + const acceptedReserves = getAcceptedReserves(pool); return (
@@ -99,29 +105,36 @@ export const PoolDetailsSheet = ({ pool, onClose }: PoolDetailsSheetProps) => {
- {pool.reserves.map((reserve) => { - // The catalog reports native XLM with no symbol and no name, - // so the identity has to be derived rather than read straight - // off the reserve — AssetIcon needs a code to recognise XLM - // and an issuer to look anything else up. - const { code, issuer } = getCatalogAssetIdentity({ - symbol: reserve.symbol, - name: reserve.name, - assetId: reserve.assetId, - networkDetails, - }); - return ( - - ); - })} -
+ // "--" rather than an empty cluster when nothing is depositable, + // the same unavailable marker the USD and rate rows below use. + !acceptedReserves.length ? ( + "--" + ) : ( +
+ {acceptedReserves.map((reserve) => { + // The catalog reports native XLM with no symbol and no + // name, so the identity has to be derived rather than read + // straight off the reserve — AssetIcon needs a code to + // recognise XLM and an issuer to look anything else up. + const { code, issuer } = getCatalogAssetIdentity({ + symbol: reserve.symbol, + name: reserve.name, + assetId: reserve.assetId, + networkDetails, + }); + return ( + + ); + })} +
+ ) } /> ({ + assetId, + symbol: assetId, + name: null, + decimals: 7, + enabled, + utilization: null, + supplyApy: null, + borrowApy: null, + emissionsSupplyApr: null, + suppliedUsd: null, + borrowedUsd: null, + priceUsd: null, +}); + +const pool = (reserves: BlendCatalogReserve[]): BlendCatalogPool => ({ + id: "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD", + name: "Fixed", + status: "ACTIVE", + suppliedUsd: null, + borrowedUsd: null, + interestApy: null, + netApy: null, + backstopUsd: null, + reserves, +}); + +describe("getAcceptedReserves", () => { + it("drops the reserves Blend would reject a deposit into", () => { + const result = getAcceptedReserves( + pool([ + reserve("XLM", true), + reserve("EURC", false), + reserve("USDC", true), + ]), + ); + + expect(result.map((r) => r.assetId)).toEqual(["XLM", "USDC"]); + }); + + it("keeps the catalog's order for an all enabled pool", () => { + const reserves = [reserve("XLM", true), reserve("USDC", true)]; + + expect(getAcceptedReserves(pool(reserves))).toEqual(reserves); + }); + + it("returns nothing when every reserve is disabled", () => { + expect( + getAcceptedReserves( + pool([reserve("XLM", false), reserve("USDC", false)]), + ), + ).toEqual([]); + }); + + // The sheet's pool is null until the catalog resolves, and the icon hook runs + // against the same value. + it("returns nothing for a pool that has not loaded", () => { + expect(getAcceptedReserves(null)).toEqual([]); + }); +}); diff --git a/extension/src/popup/components/earn/helpers/poolReserves.ts b/extension/src/popup/components/earn/helpers/poolReserves.ts new file mode 100644 index 0000000000..20c90736c8 --- /dev/null +++ b/extension/src/popup/components/earn/helpers/poolReserves.ts @@ -0,0 +1,19 @@ +import { BlendCatalogPool, BlendCatalogReserve } from "@shared/api/types/blend"; + +/** + * A pool's reserves that can actually take a deposit. + * + * Blend's `require_action_allowed` panics with `ReserveDisabled` on Supply, + * SupplyCollateral, and Borrow into a reserve whose config has `enabled: false` + * — only Withdraw and Repay stay open — so a disabled reserve is not an + * accepted token however the catalog reports it. + * + * The pools catalog reports every reserve with its flag and leaves the decision + * here, while the backend's earn-options derivation drops disabled reserves + * before the token picker ever sees them. Filtering with the same predicate is + * what keeps the two lists from contradicting each other on the same screen. + */ +export const getAcceptedReserves = ( + pool: BlendCatalogPool | null, +): BlendCatalogReserve[] => + (pool?.reserves || []).filter((reserve) => reserve.enabled); From 6ab6418ab1b1784da345c1817ebad5d3fc024081 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Mon, 24 Aug 2026 09:14:42 -0600 Subject: [PATCH 22/43] feat(earn): rebuild the intro and token picker to the new designs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports two Earn screens from Freighter-Mobile: "Blend intro (first time user)" (13701:332277) and "Choose an asset" (13717:332943), plus its empty state (13701:332629). The mocks are 402x874 phone frames against a 360x600 popup, so horizontal measures carry over unchanged — both use 24px gutters — while the vertical slack the phone frame has to spare is what gives. --- extension/src/popup/assets/earn-glow.svg | 12 + .../popup/components/earn/EarnIntro/index.tsx | 82 +++++-- .../components/earn/EarnIntro/styles.scss | 188 ++++++++++++--- .../components/earn/EarnTokenPicker/index.tsx | 211 +++++++++++----- .../earn/EarnTokenPicker/styles.scss | 226 +++++++++++++++++- .../__tests__/translationParity.test.ts | 18 +- .../src/popup/locales/en/translation.json | 19 +- .../src/popup/locales/pt/translation.json | 19 +- 8 files changed, 626 insertions(+), 149 deletions(-) create mode 100644 extension/src/popup/assets/earn-glow.svg 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/components/earn/EarnIntro/index.tsx b/extension/src/popup/components/earn/EarnIntro/index.tsx index e6995582a6..dad4e0e26a 100644 --- a/extension/src/popup/components/earn/EarnIntro/index.tsx +++ b/extension/src/popup/components/earn/EarnIntro/index.tsx @@ -1,7 +1,10 @@ import React from "react"; -import { Button, Icon, Text } from "@stellar/design-system"; +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 { @@ -12,17 +15,37 @@ interface EarnIntroProps { /** * One-time interstitial shown the first time a user enters the Earn flow. * - * A full step rather than a modal: it fills the popup and the artwork needs the - * whole viewport, which a self-measuring SlideupModal cannot give it. + * 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. * - * The artwork slot is a placeholder pending the final asset from design. + * 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("Make your tokens earn for you")} - -
+
+ -
- - {t( - "Deposit supported tokens into DeFi pools and start earning rewards.", - )} - +
+ + {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 index 4bb4b4300a..8a2d7d270c 100644 --- a/extension/src/popup/components/earn/EarnIntro/styles.scss +++ b/extension/src/popup/components/earn/EarnIntro/styles.scss @@ -1,53 +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; - // Artwork fills the space above the copy. Placeholder until design ships the - // final asset; the flex-grow keeps the copy block anchored to the bottom - // regardless of the artwork's eventual intrinsic size. - &__art { - position: relative; - flex: 1 1 auto; - min-height: pxToRem(160); - background: var(--sds-clr-gray-04); - border-bottom-left-radius: pxToRem(16); - border-bottom-right-radius: pxToRem(16); + &::-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; +} - &__close { - position: absolute; - top: pxToRem(16); - right: pxToRem(16); - width: pxToRem(32); - height: pxToRem(32); - border-radius: 50%; - border: none; - cursor: pointer; - background: var(--sds-clr-gray-08); - color: var(--sds-clr-gray-12); - display: flex; - align-items: center; - justify-content: center; +// 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); } +} - &__content { - flex: 0 0 auto; - padding: pxToRem(24); - display: flex; - flex-direction: column; - gap: pxToRem(12); +.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; } +} - &__title { - text-align: center; +.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); - &__body { - text-align: center; - color: var(--sds-clr-gray-11); - margin-bottom: pxToRem(12); + .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/EarnTokenPicker/index.tsx b/extension/src/popup/components/earn/EarnTokenPicker/index.tsx index b0801449ab..2049414e12 100644 --- a/extension/src/popup/components/earn/EarnTokenPicker/index.tsx +++ b/extension/src/popup/components/earn/EarnTokenPicker/index.tsx @@ -3,8 +3,8 @@ import { Icon, Loader, Text } from "@stellar/design-system"; import { useTranslation } from "react-i18next"; import { Navigate } from "react-router-dom"; -import { View } from "popup/basics/layout/View"; -import { SubviewHeader } from "popup/components/SubviewHeader"; +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"; @@ -41,14 +41,90 @@ interface EarnTokenPickerProps { refreshKey?: number; } -/** Green pill showing the pool's headline rate for an asset. */ -const ApyBadge = ({ apy, code }: { apy: number | null; code: string }) => ( -
- {/* A null rate means no fresh oracle price — genuinely unknown, and - distinct from a rate that really is zero. */} - {apy === null ? "--" : `${formatAmount((apy * 100).toFixed(2))}%*`} -
-); +/** 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 + ? "--" + : 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, @@ -82,35 +158,21 @@ export const EarnTokenPicker = ({ if (isLoading) { return ( - - } - /> - -
- -
-
-
+ +
+ +
+
); } if (state.state === RequestState.ERROR) { return ( - - } - /> - - - {t("We couldn’t load earnable tokens. Please try again.")} - - - + + + {t("We couldn’t load earnable tokens. Please try again.")} + + ); } @@ -150,52 +212,69 @@ export const EarnTokenPicker = ({ /> ); + 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 is an estimate based on current protocol conditions and changes with market activity. It is not guaranteed by Freighter, SDF, or the protocol.", - )} + + {t("APY may change based on protocol conditions.")}
} > -
- {data.held.length > 0 && ( - <> -
- - {t("In your account")} - -
- {data.held.map((option) => renderRow(option, true))} - - )} - - {data.supported.length > 0 && ( - <> -
- - {t("Supported tokens")} +
+ {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.map((option) => renderRow(option, false))} - +
)} + {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, + )}
- + )} - + ); }; diff --git a/extension/src/popup/components/earn/EarnTokenPicker/styles.scss b/extension/src/popup/components/earn/EarnTokenPicker/styles.scss index 0db3ce2a2a..c672c84664 100644 --- a/extension/src/popup/components/earn/EarnTokenPicker/styles.scss +++ b/extension/src/popup/components/earn/EarnTokenPicker/styles.scss @@ -1,27 +1,221 @@ @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; - gap: pxToRem(4); + 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); - margin-top: pxToRem(12); - margin-bottom: pxToRem(4); + } + + &__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); + } - &:first-child { - margin-top: 0; + .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-04); - color: var(--sds-clr-green-11); - border-radius: pxToRem(16); - padding: pxToRem(4) pxToRem(10); + 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); - font-weight: 500; + line-height: pxToRem(20); + font-weight: var(--sds-fw-medium); white-space: nowrap; } @@ -32,8 +226,16 @@ } &__disclaimer { - color: var(--sds-clr-gray-10); - padding-top: pxToRem(12); + 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); + } } } diff --git a/extension/src/popup/locales/__tests__/translationParity.test.ts b/extension/src/popup/locales/__tests__/translationParity.test.ts index 14001b30fb..979697a595 100644 --- a/extension/src/popup/locales/__tests__/translationParity.test.ts +++ b/extension/src/popup/locales/__tests__/translationParity.test.ts @@ -29,12 +29,20 @@ const swapKeys = [ ]; const earnKeys = [ - "Choose Token to earn", - "In your account", + "Choose an asset", + "In your wallet", + "Other supported assets", "Supported tokens", - "Make your tokens earn for you", - "Deposit supported tokens into DeFi pools and start earning rewards.", - "Start earning", + "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}}", diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index 6b6d1cb0ce..18aadf4ffb 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -4,8 +4,8 @@ "{{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.", - "*APY is an estimate based on current protocol conditions and changes with market activity. It is not guaranteed by Freighter, SDF, or the protocol.": "*APY is an estimate based on current protocol conditions and changes with market activity. It is not guaranteed by Freighter, SDF, or the protocol.", "0.5 XLM required": "0.5 XLM required", "A destination account requires the use of the memo field which is not present in the transaction you’re about to sign.": "A destination account requires the use of the memo field which is not present in the transaction you’re about to sign.", "A new signing request arrived while you were reviewing another. Please review it carefully before approving.": "A new signing request arrived while you were reviewing another. Please review it carefully before approving.", @@ -23,6 +23,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", @@ -69,6 +70,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.", @@ -115,9 +117,9 @@ "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", - "Choose Token to earn": "Choose Token to earn", "Clear Flags": "Clear Flags", "Clear Override": "Clear Override", "Clear recents": "Clear recents", @@ -190,7 +192,6 @@ "Default": "Default", "Deleted": "Deleted", "Deposit": "Deposit", - "Deposit supported tokens into DeFi pools and start earning rewards.": "Deposit supported tokens into DeFi pools and start earning rewards.", "Deposited!": "Deposited!", "Depositing": "Depositing", "Description": "Description", @@ -214,6 +215,8 @@ "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.", @@ -340,7 +343,7 @@ "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 account": "In your account", + "In your wallet": "In your wallet", "Inclusion Fee": "Inclusion Fee", "Inflation Destination": "Inflation Destination", "Insufficient balance": "Insufficient balance", @@ -386,8 +389,8 @@ "Make sure you have your 12 word backup phrase": "Make sure you have your 12 word backup phrase", "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.", - "Make your tokens earn for you": "Make your tokens earn for you", "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", @@ -444,6 +447,7 @@ "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", @@ -470,6 +474,7 @@ "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", @@ -605,9 +610,9 @@ "Source token not found": "Source token not found", "Sponsored ID": "Sponsored ID", "SSL certificates provide an encrypted network connection and also provide proof of ownership of the domain.": "SSL certificates provide an encrypted network connection and also provide proof of ownership of the domain.", - "Start earning": "Start earning", "Starting Balance": "Starting Balance", "Status": "Status", + "Stay in control": "Stay in control", "Stellar": "Stellar", "Stellar Development Foundation will never ask for your phrase": "Stellar Development Foundation will never ask for your phrase", "Stellar Logo": "Stellar Logo", @@ -623,6 +628,8 @@ "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", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index 981826b61f..cc5f4b9849 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -4,8 +4,8 @@ "{{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.", - "*APY is an estimate based on current protocol conditions and changes with market activity. It is not guaranteed by Freighter, SDF, or the protocol.": "*A APY é uma estimativa baseada nas condições atuais do protocolo e muda conforme a atividade do mercado. Não é garantida pela Freighter, pela SDF nem pelo protocolo.", "0.5 XLM required": "0,5 XLM necessários", "A destination account requires the use of the memo field which is not present in the transaction you’re about to sign.": "Uma conta de destino requer o uso do campo memo que não está presente na transação que você está prestes a assinar.", "A new signing request arrived while you were reviewing another. Please review it carefully before approving.": "Uma nova solicitação de assinatura chegou enquanto você revisava outra. Revise-a com atenção antes de aprovar.", @@ -23,6 +23,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", @@ -69,6 +70,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.", @@ -115,9 +117,9 @@ "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", - "Choose Token to earn": "Escolha o token para render", "Clear Flags": "Limpar Flags", "Clear Override": "Limpar Substituição", "Clear recents": "Limpar recentes", @@ -190,7 +192,6 @@ "Default": "Padrão", "Deleted": "Excluído", "Deposit": "Depositar", - "Deposit supported tokens into DeFi pools and start earning rewards.": "Deposite tokens compatíveis em pools DeFi e comece a ganhar recompensas.", "Deposited!": "Depositado!", "Depositing": "Depositando", "Description": "Descrição", @@ -214,6 +215,8 @@ "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.", @@ -340,7 +343,7 @@ "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 account": "Na sua conta", + "In your wallet": "Na sua carteira", "Inclusion Fee": "Taxa de Inclusão", "Inflation Destination": "Destino de Inflação", "Insufficient balance": "Saldo insuficiente", @@ -386,8 +389,8 @@ "Make sure you have your 12 word backup phrase": "Certifique-se de ter sua backup phrase de 12 palavras", "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.", - "Make your tokens earn for you": "Faça seus tokens renderem para você", "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", @@ -444,6 +447,7 @@ "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", @@ -470,6 +474,7 @@ "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", @@ -605,9 +610,9 @@ "Source token not found": "Token de origem não encontrado", "Sponsored ID": "ID Patrocinado", "SSL certificates provide an encrypted network connection and also provide proof of ownership of the domain.": "Os certificados SSL fornecem uma conexão de rede criptografada e também fornecem prova de propriedade do domínio.", - "Start earning": "Começar a render", "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", @@ -623,6 +628,8 @@ "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", From 312239334f0bad32c79700fd4ac1b9881bec2445 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Mon, 24 Aug 2026 09:34:09 -0600 Subject: [PATCH 23/43] fix(earn): treat closing the swap sheet after success as a completion --- .../__tests__/EarnSwap.dismiss.test.tsx | 111 ++++++++++++++++++ .../popup/components/earn/EarnSwap/index.tsx | 22 +++- 2 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 extension/src/popup/components/earn/EarnSwap/__tests__/EarnSwap.dismiss.test.tsx 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 index 3642f60707..aff1540ced 100644 --- a/extension/src/popup/components/earn/EarnSwap/index.tsx +++ b/extension/src/popup/components/earn/EarnSwap/index.tsx @@ -3,6 +3,7 @@ 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, @@ -105,6 +106,20 @@ export const EarnSwap = ({ 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: { @@ -179,7 +194,7 @@ export const EarnSwap = ({ open onOpenChange={(isOpen) => { if (!isOpen) { - onCancel(); + dismiss(); } }} > @@ -199,11 +214,12 @@ export const EarnSwap = ({ {t("Swap")} {/* Sheet-level rather than per-step: the submitting/success step has no - header of its own, and every step's X means the same thing. */} + header of its own. What the X *means* is not the same on every step + though — see `dismiss`. */} + {/* Cancel is the recommended action once a warning is up, so it takes + over the Confirm slot's weight — destructive for a malicious + verdict, secondary otherwise, exactly as ReviewTx does. */} + + {!shouldShowTxWarning && ( + + )} +
+ {shouldShowTxWarning && ( + + )} + + ); + + /* + * 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 (
+ {securityLevel && shouldShowTxWarning ? ( + setIsOnBlockaidSheet(true)} + dataTestId="earn-review-blockaid-warning" + /> + ) : null}
{t("You are depositing")} @@ -343,37 +472,7 @@ export const EarnReview = ({ )} -
- - - -
+ {actions}
); }; diff --git a/extension/src/popup/components/earn/EarnReview/styles.scss b/extension/src/popup/components/earn/EarnReview/styles.scss index 2863a0c11c..e11401e636 100644 --- a/extension/src/popup/components/earn/EarnReview/styles.scss +++ b/extension/src/popup/components/earn/EarnReview/styles.scss @@ -108,6 +108,30 @@ gap: pxToRem(8); } + // The "Confirm anyway" acknowledgement shown under the action row once + // Blockaid has flagged the deposit. Matches `.ReviewTx__TextAction` so the + // demoted confirm reads the same here as it does on the Send/Swap review. + &__text-action { + width: 100%; + border: none; + background: none; + font-family: var(--sds-ff-base); + font-weight: 600; + font-size: pxToRem(14); + line-height: pxToRem(20); + padding: pxToRem(8) 0; + cursor: pointer; + text-align: center; + + &--default { + color: var(--sds-clr-gray-09); + } + + &--error { + color: var(--sds-clr-red-11); + } + } + &__settings { flex: 0 0 auto; width: pxToRem(40); From 95619ff73b2561e53a52115d6df2c22ed0ce1900 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Tue, 25 Aug 2026 15:45:06 -0600 Subject: [PATCH 31/43] fix(earn): match the review's Blockaid layout to the other review screens --- .../__tests__/EarnReview.blockaid.test.tsx | 90 +++++++++--- .../components/earn/EarnReview/index.tsx | 135 ++++++++++-------- .../components/earn/EarnReview/styles.scss | 86 ++++++++--- 3 files changed, 209 insertions(+), 102 deletions(-) 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 index ab0774defc..7db4724958 100644 --- a/extension/src/popup/components/earn/EarnReview/__tests__/EarnReview.blockaid.test.tsx +++ b/extension/src/popup/components/earn/EarnReview/__tests__/EarnReview.blockaid.test.tsx @@ -109,15 +109,19 @@ describe("EarnReview Blockaid transaction verdict", () => { 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.queryByTestId("earn-review-confirm-anyway"), - ).not.toBeInTheDocument(); + screen.getByTestId("earn-review-confirm").parentElement?.className, + ).not.toMatch(/confirm-(malicious|caution)/); await userEvent.click(screen.getByTestId("earn-review-confirm")); expect(onConfirm).toHaveBeenCalled(); }); - it("warns and demotes Confirm for a malicious transaction", async () => { + 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(); @@ -129,39 +133,81 @@ describe("EarnReview Blockaid transaction verdict", () => { expect( screen.getByTestId("earn-review-blockaid-warning"), ).toHaveTextContent("This transaction was flagged as malicious"); - expect(screen.queryByTestId("earn-review-confirm")).not.toBeInTheDocument(); - // The warned row keeps the fee gear — the acknowledgement is appended - // below it rather than replacing the row. + // 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-anyway")); + await userEvent.click(screen.getByTestId("earn-review-confirm")); expect(onConfirm).toHaveBeenCalled(); }); - it("warns and demotes Confirm for a suspicious transaction", () => { + 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"); - expect(screen.queryByTestId("earn-review-confirm")).not.toBeInTheDocument(); - expect( - screen.getByTestId("earn-review-confirm-anyway"), - ).toBeInTheDocument(); + // 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("treats an absent scan on mainnet as unable-to-scan and gates on it", () => { - // Mirrors ReviewTx: a deposit Blockaid could not scan still requires the - // explicit acknowledgement rather than silently confirming. - renderReview({ scanResult: null }); + 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.queryByTestId("earn-review-confirm")).not.toBeInTheDocument(); + expect(screen.getByTestId("earn-review-cancel")).toHaveClass( + "Button--tertiary", + ); + expect(screen.getByTestId("earn-review-confirm")).toHaveClass( + "Button--secondary", + ); expect( - screen.getByTestId("earn-review-confirm-anyway"), - ).toBeInTheDocument(); + 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 () => { @@ -188,10 +234,8 @@ describe("EarnReview Blockaid transaction verdict", () => { expect(pane).toHaveTextContent( "An identified malicious address is associated with the token.", ); - // The acknowledgement is reachable from the sheet too, not just the body. - expect( - screen.getByTestId("earn-review-confirm-anyway"), - ).toBeInTheDocument(); + // 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/index.tsx b/extension/src/popup/components/earn/EarnReview/index.tsx index 1f7590c165..836902fcf8 100644 --- a/extension/src/popup/components/earn/EarnReview/index.tsx +++ b/extension/src/popup/components/earn/EarnReview/index.tsx @@ -123,11 +123,19 @@ export const EarnReview = ({ blockaidOverrideState, ); const isMalicious = securityLevel === SecurityLevel.MALICIOUS; - // Matches ReviewTx's gate: a flagged transaction AND one that couldn't be - // scanned both demote Confirm to an explicit "Confirm anyway". + 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 || - securityLevel === SecurityLevel.SUSPICIOUS || + isSuspicious || securityLevel === SecurityLevel.UNABLE_TO_SCAN; /* @@ -277,38 +285,49 @@ export const EarnReview = ({ /* * Shared by the review body and the Blockaid sheet, so acknowledging a - * warning is possible from either. Two states, mirroring ReviewTx's - * ActionButtons: a clean transaction keeps Cancel + Confirm, and a flagged - * one promotes Cancel into the Confirm slot and demotes confirmation to a - * "Confirm anyway" text button below the row. + * 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 - over the Confirm slot's weight — destructive for a malicious - verdict, secondary otherwise, exactly as ReviewTx does. */} +
+ + {/* 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. */} +
- {!shouldShowTxWarning && ( - - )}
- {shouldShowTxWarning && ( - - )} - + {t("Confirm")} + +
+
); /* @@ -363,14 +376,6 @@ export const EarnReview = ({ return (
- {securityLevel && shouldShowTxWarning ? ( - setIsOnBlockaidSheet(true)} - dataTestId="earn-review-blockaid-warning" - /> - ) : null}
{t("You are depositing")} @@ -412,6 +417,18 @@ export const EarnReview = ({
+ {/* 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} +
Date: Wed, 26 Aug 2026 08:35:18 -0600 Subject: [PATCH 32/43] feat(earn): gate the home Earn tile behind the earn_deposit flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `earn_deposit` as an Amplitude boolean flag, defaulting to off, and require it alongside the existing network check before the Earn action renders in the home action row. The default keeps the tile hidden when the flag fetch fails or no deployment key is configured. The /earn route itself stays registered — only the entry point is gated. - remoteConfig: new flag, default, and earnDepositSelector - AccountHeader: flag && isEarnSupportedNetwork gate on the tile - stubBlendEarn: serve earn_deposit "on" from the Experiment vardata route so the Earn e2e specs still reach the flow - backfill earn_deposit in hand-built RemoteConfigState test fixtures --- extension/e2e-tests/helpers/stubs.ts | 10 +++++++ .../helpers/__tests__/useGetBalances.test.tsx | 1 + .../__tests__/maintenanceMode.test.tsx | 1 + .../account/AccountHeader/index.tsx | 5 +++- .../ducks/__tests__/remoteConfig.test.ts | 26 +++++++++++++++++++ extension/src/popup/ducks/remoteConfig.ts | 17 +++++++++++- .../AddToken/__tests__/AddToken.test.tsx | 1 + 7 files changed, 59 insertions(+), 2 deletions(-) diff --git a/extension/e2e-tests/helpers/stubs.ts b/extension/e2e-tests/helpers/stubs.ts index d1373a8b16..9f653f1069 100644 --- a/extension/e2e-tests/helpers/stubs.ts +++ b/extension/e2e-tests/helpers/stubs.ts @@ -3664,6 +3664,16 @@ export const stubBlendEarn = async ( 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: { 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/popup/components/__tests__/maintenanceMode.test.tsx b/extension/src/popup/components/__tests__/maintenanceMode.test.tsx index 396c5b1268..30eeae8a38 100644 --- a/extension/src/popup/components/__tests__/maintenanceMode.test.tsx +++ b/extension/src/popup/components/__tests__/maintenanceMode.test.tsx @@ -55,6 +55,7 @@ function makeStore(overrides?: { isInitialized: true, use_token_prices_v2: true, use_balances_v2: true, + earn_deposit: false, maintenance_screen: overrides?.maintenance_screen ?? { enabled: false, payload: undefined, diff --git a/extension/src/popup/components/account/AccountHeader/index.tsx b/extension/src/popup/components/account/AccountHeader/index.tsx index 5f0c803b1a..c88189cdcb 100644 --- a/extension/src/popup/components/account/AccountHeader/index.tsx +++ b/extension/src/popup/components/account/AccountHeader/index.tsx @@ -24,6 +24,7 @@ import { settingsNetworksListSelector, } from "popup/ducks/settings"; import { signOut } from "popup/ducks/accountServices"; +import { earnDepositSelector } from "popup/ducks/remoteConfig"; import { AccountHeaderModal } from "popup/components/account/AccountHeaderModal"; import { NetworkIcon } from "popup/components/manageNetwork/NetworkIcon"; import { NetworkDetails } from "@shared/constants/stellar"; @@ -66,6 +67,7 @@ export const AccountHeader = ({ }; const networkDetails = useSelector(settingsNetworkDetailsSelector); const networksList = useSelector(settingsNetworksListSelector); + const isEarnDepositEnabled = useSelector(earnDepositSelector); const [isNetworkSelectorOpen, setIsNetworkSelectorOpen] = useState(false); const [isAccountOptionsOpen, setIsAccountOptionsOpen] = useState(false); const navigate = useNavigate(); @@ -416,7 +418,8 @@ export const AccountHeader = ({
- {isEarnSupportedNetwork(networkDetails) ? ( + {isEarnDepositEnabled && + isEarnSupportedNetwork(networkDetails) ? (
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/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/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 }, }, From a22cfd87e846a54c9734b1d8472514ca554a0fd5 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Wed, 26 Aug 2026 08:41:58 -0600 Subject: [PATCH 33/43] refactor(earn): use the NO_FIAT_VALUE constant instead of "--" literals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The codebase already exports NO_FIAT_VALUE from popup/helpers/formatters for a USD figure that cannot be determined, with docs distinguishing it from "$0.00" (unknown vs. known zero). The earn flow was hardcoding the same "--" glyph in five places while documenting that same distinction in its own comments. Swap the literals for the shared constant in formatCompactUsd, formatRate, formatProjection, the review screen's USD line, the token picker's APY badge, and the pool sheet's empty accepted-tokens state. No behavior change — the rendered output is identical, and the tests still assert the literal "--" so a change to the constant's value would be caught. --- .../components/earn/EarnReview/helpers/projectEarnings.ts | 4 +++- extension/src/popup/components/earn/EarnReview/index.tsx | 6 ++++-- .../src/popup/components/earn/EarnTokenPicker/index.tsx | 4 ++-- .../src/popup/components/earn/PoolDetailsSheet/index.tsx | 3 ++- .../src/popup/components/earn/helpers/formatPoolStats.ts | 8 ++++++-- 5 files changed, 17 insertions(+), 8 deletions(-) diff --git a/extension/src/popup/components/earn/EarnReview/helpers/projectEarnings.ts b/extension/src/popup/components/earn/EarnReview/helpers/projectEarnings.ts index e05ffd700f..e6e079bc7c 100644 --- a/extension/src/popup/components/earn/EarnReview/helpers/projectEarnings.ts +++ b/extension/src/popup/components/earn/EarnReview/helpers/projectEarnings.ts @@ -1,5 +1,7 @@ import BigNumber from "bignumber.js"; +import { NO_FIAT_VALUE } from "popup/helpers/formatters"; + /** * Projected earnings on a deposit, in USD. * @@ -35,4 +37,4 @@ export const projectEarnings = ({ /** Formats a projected figure for display, rendering an unknown value as "--". */ export const formatProjection = (value: string | null) => - value === null ? "--" : `$${new BigNumber(value).toFormat(2)}`; + 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 index 836902fcf8..97f787339d 100644 --- a/extension/src/popup/components/earn/EarnReview/index.tsx +++ b/extension/src/popup/components/earn/EarnReview/index.tsx @@ -31,7 +31,7 @@ import { useShouldTreatTxAsUnableToScan, } from "popup/helpers/blockaid"; import { getAuthEntryBoundAddress } from "popup/helpers/soroban"; -import { formatAmount } from "popup/helpers/formatters"; +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"; @@ -395,7 +395,9 @@ export const EarnReview = ({ {formatAmount(amount)} {assetCode}
- {amountUsd === null ? "--" : `$${formatAmount(amountUsd)}`} + {amountUsd === null + ? NO_FIAT_VALUE + : `$${formatAmount(amountUsd)}`}
diff --git a/extension/src/popup/components/earn/EarnTokenPicker/index.tsx b/extension/src/popup/components/earn/EarnTokenPicker/index.tsx index 2049414e12..75a2e62036 100644 --- a/extension/src/popup/components/earn/EarnTokenPicker/index.tsx +++ b/extension/src/popup/components/earn/EarnTokenPicker/index.tsx @@ -11,7 +11,7 @@ import { RequestState } from "constants/request"; import { AppDataType } from "helpers/hooks/useGetAppData"; import { newTabHref } from "helpers/urls"; import { openTab } from "popup/helpers/navigate"; -import { formatAmount } from "popup/helpers/formatters"; +import { NO_FIAT_VALUE, formatAmount } from "popup/helpers/formatters"; import { trackEarnBalanceInsufficientShown } from "popup/metrics/earn"; import { NotEnoughTokenSheet } from "./NotEnoughTokenSheet"; @@ -50,7 +50,7 @@ const ApyBadge = ({ apy, code }: { apy: number | null; code: string }) => { {/* 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)), })} diff --git a/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx b/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx index 874701d420..3852845c83 100644 --- a/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx +++ b/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx @@ -5,6 +5,7 @@ import { useSelector } from "react-redux"; import { BlendCatalogPool } from "@shared/api/types/blend"; import { AssetIcon } from "popup/components/account/AccountAssets"; +import { NO_FIAT_VALUE } from "popup/helpers/formatters"; import { settingsNetworkDetailsSelector } from "popup/ducks/settings"; import { PoolIcon } from "popup/components/earn/PoolIcon"; import { getCatalogAssetIdentity } from "popup/components/earn/helpers/earnAssetIcons"; @@ -110,7 +111,7 @@ export const PoolDetailsSheet = ({ pool, onClose }: PoolDetailsSheetProps) => { // "--" rather than an empty cluster when nothing is depositable, // the same unavailable marker the USD and rate rows below use. !acceptedReserves.length ? ( - "--" + NO_FIAT_VALUE ) : (
{acceptedReserves.map((reserve) => { diff --git a/extension/src/popup/components/earn/helpers/formatPoolStats.ts b/extension/src/popup/components/earn/helpers/formatPoolStats.ts index 5fdc8dab28..9d605b965a 100644 --- a/extension/src/popup/components/earn/helpers/formatPoolStats.ts +++ b/extension/src/popup/components/earn/helpers/formatPoolStats.ts @@ -1,5 +1,7 @@ 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". * @@ -8,7 +10,7 @@ import BigNumber from "bignumber.js"; */ export const formatCompactUsd = (value: number | null): string => { if (value === null) { - return "--"; + return NO_FIAT_VALUE; } const amount = new BigNumber(value); @@ -36,4 +38,6 @@ export const formatCompactUsd = (value: number | null): string => { * "0.00%". Never conflate the two. */ export const formatRate = (rate: number | null): string => - rate === null ? "--" : `${new BigNumber(rate).times(100).toFormat(2)}%`; + rate === null + ? NO_FIAT_VALUE + : `${new BigNumber(rate).times(100).toFormat(2)}%`; From 874a3b645861a16f8aeb6f078ae4d4e972321318 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Wed, 26 Aug 2026 08:58:18 -0600 Subject: [PATCH 34/43] fix(earn): match the pool sheet description to the mocks, add the docs link --- .../__tests__/PoolDetailsSheet.test.tsx | 57 +++++++++++++++++++ .../earn/PoolDetailsSheet/index.tsx | 38 +++++++++---- .../earn/PoolDetailsSheet/poolDescriptions.ts | 39 ------------- .../earn/PoolDetailsSheet/styles.scss | 24 ++++++++ .../src/popup/constants/externalLinks.ts | 6 ++ .../__tests__/translationParity.test.ts | 6 +- .../src/popup/locales/en/translation.json | 3 +- .../src/popup/locales/pt/translation.json | 3 +- 8 files changed, 119 insertions(+), 57 deletions(-) create mode 100644 extension/src/popup/components/earn/PoolDetailsSheet/__tests__/PoolDetailsSheet.test.tsx delete mode 100644 extension/src/popup/components/earn/PoolDetailsSheet/poolDescriptions.ts 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 index 3852845c83..af4ae28c52 100644 --- a/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx +++ b/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx @@ -8,13 +8,14 @@ import { AssetIcon } from "popup/components/account/AccountAssets"; import { NO_FIAT_VALUE } from "popup/helpers/formatters"; import { settingsNetworkDetailsSelector } from "popup/ducks/settings"; import { PoolIcon } from "popup/components/earn/PoolIcon"; +import { BLEND_LENDING_DOCS_URL } from "popup/constants/externalLinks"; +import { openTab } from "popup/helpers/navigate"; import { getCatalogAssetIdentity } from "popup/components/earn/helpers/earnAssetIcons"; import { getAcceptedReserves } from "popup/components/earn/helpers/poolReserves"; import { StatRow } from "popup/components/earn/StatRow"; import { usePoolReserveIcons } from "./hooks/usePoolReserveIcons"; -import { getPoolDescription } from "./poolDescriptions"; import { formatCompactUsd, formatRate, @@ -36,13 +37,17 @@ interface PoolDetailsSheetProps { * 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. + * * "Accepted tokens" shows only the pool's enabled reserves. The catalog reports * disabled ones too, but Blend rejects a deposit into them, and the token * picker behind this sheet has already dropped them — see `getAcceptedReserves`. */ export const PoolDetailsSheet = ({ pool, onClose }: PoolDetailsSheetProps) => { const { t } = useTranslation(); - const description = getPoolDescription(pool.id); const networkDetails = useSelector(settingsNetworkDetailsSelector); const reserveIcons = usePoolReserveIcons(pool); const acceptedReserves = getAcceptedReserves(pool); @@ -74,16 +79,25 @@ export const PoolDetailsSheet = ({ pool, onClose }: PoolDetailsSheetProps) => { className="PoolDetailsSheet__body" data-testid="earn-pool-details-body" > - {description && ( -
- - {t("Description")} - - - {description} - -
- )} +
+ + {t("Description")} + + + {t( + "Deposit supported assets into this Blend pool to earn yield. APY may change over time. Withdraw anytime.", + )} + + +
{t("Pool Details")} diff --git a/extension/src/popup/components/earn/PoolDetailsSheet/poolDescriptions.ts b/extension/src/popup/components/earn/PoolDetailsSheet/poolDescriptions.ts deleted file mode 100644 index 6c6ba57580..0000000000 --- a/extension/src/popup/components/earn/PoolDetailsSheet/poolDescriptions.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { BLEND_FIXED_POOL_IDS } from "@shared/constants/blend"; -import { NETWORKS } from "@shared/constants/stellar"; -import i18n from "popup/helpers/localizationConfig"; - -/** - * Prose descriptions for the pools we surface, keyed by pool contract address. - * - * Hardcoded because the backend's pool catalog carries no description field. - * Keyed by contract ID rather than shown generically because the copy makes - * claims that are only true of a specific deployment: the mainnet Fixed pool's - * admin account is burned (master weight 0, no signers, all thresholds 0), so - * "no admin" is a fact about that pool, not about Blend pools in general. - * - * Each entry is a thunk holding a literal `i18n.t` call, for two reasons. The - * i18next scanner extracts keys by reading the literal argument of a `t()` call - * (see `func.list` in webpack.extension.js), so passing a variable — a string - * looked up out of this map — extracts nothing: the key never lands in - * translation.json, i18next falls back to returning the key itself, and the copy - * renders in English in every language while looking correct in en. And the - * thunk keeps resolution lazy, where a bare `i18n.t` at module scope would run - * before the language bundle has loaded. - * - * A pool with no entry renders no description rather than a wrong one. - */ -const POOL_DESCRIPTIONS: Record string> = { - // Fixed Pool v2 — mainnet - [BLEND_FIXED_POOL_IDS[NETWORKS.PUBLIC]!]: () => - i18n.t( - "Permissionless lending pool with no admin. Collateral factors, interest curves, and supported assets are locked at deployment.", - ), - // Fixed Pool v2 — testnet - [BLEND_FIXED_POOL_IDS[NETWORKS.TESTNET]!]: () => - i18n.t( - "Permissionless lending pool with no admin. Collateral factors, interest curves, and supported assets are locked at deployment.", - ), -}; - -export const getPoolDescription = (poolId: string): string | null => - POOL_DESCRIPTIONS[poolId]?.() ?? null; diff --git a/extension/src/popup/components/earn/PoolDetailsSheet/styles.scss b/extension/src/popup/components/earn/PoolDetailsSheet/styles.scss index 9480efd0bc..c2a3ed590b 100644 --- a/extension/src/popup/components/earn/PoolDetailsSheet/styles.scss +++ b/extension/src/popup/components/earn/PoolDetailsSheet/styles.scss @@ -63,6 +63,30 @@ color: var(--sds-clr-gray-11); } + // "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); + margin-top: pxToRem(4); + padding: 0; + 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); 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/locales/__tests__/translationParity.test.ts b/extension/src/popup/locales/__tests__/translationParity.test.ts index 979697a595..40a73ea146 100644 --- a/extension/src/popup/locales/__tests__/translationParity.test.ts +++ b/extension/src/popup/locales/__tests__/translationParity.test.ts @@ -59,10 +59,8 @@ const earnKeys = [ "Add XLM to your wallet to continue", "Transaction failed. Try again.", "Pool Details", - // Reached through a lookup table rather than a literal `t()` argument, so the - // scanner cannot see it — the one earn string that has to be listed here to - // stay extracted. See PoolDetailsSheet/poolDescriptions.ts. - "Permissionless lending pool with no admin. Collateral factors, interest curves, and supported assets are locked at deployment.", + "Deposit supported assets into this Blend pool to earn yield. APY may change over time. Withdraw anytime.", + "View pool details", "Lending Interest", "Current Net APY", "Accepted tokens", diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index 18aadf4ffb..6dd6ea40e4 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -192,6 +192,7 @@ "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", @@ -483,7 +484,6 @@ "Password": "Password", "Path": "Path", "Payment token logo": "Payment token logo", - "Permissionless lending pool with no admin. Collateral factors, interest curves, and supported assets are locked at deployment.": "Permissionless lending pool with no admin. Collateral factors, interest curves, and supported assets are locked at deployment.", "Pin Freighter to the toolbar": "Pin Freighter to the toolbar", "Please check if the network information is correct and try again.": "Please check if the network information is correct and try again.", "Please check the asset code or address.": "Please check the asset code or address.", @@ -784,6 +784,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", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index cc5f4b9849..06a2e4a5b1 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -192,6 +192,7 @@ "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", @@ -483,7 +484,6 @@ "Password": "Senha", "Path": "Caminho", "Payment token logo": "Logotipo do token de pagamento", - "Permissionless lending pool with no admin. Collateral factors, interest curves, and supported assets are locked at deployment.": "Pool de empréstimo sem permissão e sem administrador. Fatores de garantia, curvas de juros e ativos compatíveis são fixados na implantação.", "Pin Freighter to the toolbar": "Fixar Freighter na toolbar", "Please check if the network information is correct and try again.": "Por favor, verifique se as informações da rede estão corretas e tente novamente.", "Please check the asset code or address.": "Por favor, verifique o código do ativo ou endereço.", @@ -784,6 +784,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", From e06f14f40690a5f79c8f81b922cb9ea09c9821b1 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Wed, 26 Aug 2026 09:19:51 -0600 Subject: [PATCH 35/43] fix(earn): align the pool details sheet with the mocks --- extension/e2e-tests/earnDeposit.test.ts | 9 +- .../hooks/usePoolReserveIcons.tsx | 128 ------------------ .../earn/PoolDetailsSheet/index.tsx | 77 +++-------- .../earn/PoolDetailsSheet/styles.scss | 37 ++--- .../helpers/__tests__/poolReserves.test.ts | 64 --------- .../components/earn/helpers/poolReserves.ts | 19 --- extension/src/popup/ducks/remoteConfig.ts | 2 +- .../__tests__/translationParity.test.ts | 7 +- .../src/popup/locales/en/translation.json | 7 +- .../src/popup/locales/pt/translation.json | 7 +- 10 files changed, 45 insertions(+), 312 deletions(-) delete mode 100644 extension/src/popup/components/earn/PoolDetailsSheet/hooks/usePoolReserveIcons.tsx delete mode 100644 extension/src/popup/components/earn/helpers/__tests__/poolReserves.test.ts delete mode 100644 extension/src/popup/components/earn/helpers/poolReserves.ts diff --git a/extension/e2e-tests/earnDeposit.test.ts b/extension/e2e-tests/earnDeposit.test.ts index 97b67c1c7d..9cf5d920e1 100644 --- a/extension/e2e-tests/earnDeposit.test.ts +++ b/extension/e2e-tests/earnDeposit.test.ts @@ -308,14 +308,7 @@ test("pool details sheet shows market stats including Backstop", async ({ // unavailable. await expect(page.getByTestId("earn-pool-backstop")).toContainText("$1.53M"); - // Only the pool's enabled reserves count as accepted tokens. The stubbed - // catalog carries three — XLM, USDC, and a disabled AQUA — and Blend rejects - // a deposit into a disabled reserve, so the cluster must show two icons. - await expect( - page - .getByTestId("earn-pool-accepted-tokens") - .locator(".PoolDetailsSheet__tokens > *"), - ).toHaveCount(2); + await expect(page.getByTestId("earn-pool-docs-link")).toBeVisible(); }); // --------------------------------------------------------------------------- diff --git a/extension/src/popup/components/earn/PoolDetailsSheet/hooks/usePoolReserveIcons.tsx b/extension/src/popup/components/earn/PoolDetailsSheet/hooks/usePoolReserveIcons.tsx deleted file mode 100644 index 9c45de6d4b..0000000000 --- a/extension/src/popup/components/earn/PoolDetailsSheet/hooks/usePoolReserveIcons.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { useEffect, useState } from "react"; -import { useDispatch, useSelector } from "react-redux"; - -import { getCombinedAssetListData } from "@shared/api/helpers/token-list"; -import { BlendCatalogPool } from "@shared/api/types/blend"; -import { AppDispatch } from "popup/App"; -import { - getCatalogAssetIdentity, - resolveEarnAssetIcons, -} from "popup/components/earn/helpers/earnAssetIcons"; -import { getAcceptedReserves } from "popup/components/earn/helpers/poolReserves"; -import { - iconsSelector, - saveIconsForBalances, - tokensListsSelector, -} from "popup/ducks/cache"; -import { - settingsNetworkDetailsSelector, - settingsSelector, -} from "popup/ducks/settings"; - -/** - * Canonical-keyed icons for a pool's accepted tokens, in the shape AssetIcon - * looks assets up by. - * - * The catalog carries no icons, and the balances icon map only covers reserves - * the account happens to hold, so the rest have to be resolved. Usually a cache - * read: the token picker resolves the same assets on the way in and writes them - * to the icon cache. Runs on open rather than with the deposit screen, so an - * unopened sheet costs nothing. - * - * The map stays empty only while resolution is in flight — AssetIcon reads an - * empty map as "still fetching" and holds a loader — so a failed lookup records - * an explicit null instead, letting it fall through to its missing-image state. - */ -export const usePoolReserveIcons = (pool: BlendCatalogPool | null) => { - const reduxDispatch = useDispatch(); - const networkDetails = useSelector(settingsNetworkDetailsSelector); - const { assetsLists } = useSelector(settingsSelector); - const cachedIcons = useSelector(iconsSelector); - const cachedTokenLists = useSelector(tokensListsSelector); - const [icons, setIcons] = useState>({}); - - useEffect(() => { - let isActive = true; - - const resolve = async () => { - // Exactly what the sheet renders — a disabled reserve is never drawn, so - // resolving its icon would be a TOML round-trip for nothing. - const assets = getAcceptedReserves(pool) - .map((reserve) => ({ - assetId: reserve.assetId, - ...getCatalogAssetIdentity({ - symbol: reserve.symbol, - name: reserve.name, - assetId: reserve.assetId, - networkDetails, - }), - })) - // Native's logo ships with the extension, and an asset with no icon key - // has nothing to look up. - .filter((asset) => asset.canonical && !asset.isNative); - - if (!assets.length) { - return; - } - - const seeded = assets.reduce( - (acc, asset) => - asset.canonical in cachedIcons - ? { ...acc, [asset.canonical]: cachedIcons[asset.canonical] } - : acc, - {} as Record, - ); - const missing = assets.filter((asset) => !(asset.canonical in seeded)); - - if (isActive && Object.keys(seeded).length) { - setIcons(seeded); - } - if (!missing.length) { - return; - } - - let resolved: Record = {}; - try { - const assetsListsData = cachedTokenLists.length - ? cachedTokenLists - : await getCombinedAssetListData({ networkDetails, assetsLists }); - resolved = await resolveEarnAssetIcons({ - assets: missing.map(({ code, issuer, assetId }) => ({ - code, - issuer, - assetId, - })), - networkDetails, - cachedIcons, - assetsListsData, - }); - if (Object.keys(resolved).length) { - reduxDispatch(saveIconsForBalances({ icons: resolved })); - } - } catch (e) { - // An unresolved icon is a placeholder, not a broken sheet. - } - - if (!isActive) { - return; - } - setIcons((current) => - missing.reduce( - (acc, asset) => ({ - ...acc, - [asset.canonical]: resolved[asset.canonical] ?? null, - }), - { ...current, ...seeded }, - ), - ); - }; - - resolve(); - return () => { - isActive = false; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [pool?.id]); - - return icons; -}; diff --git a/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx b/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx index af4ae28c52..f088847999 100644 --- a/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx +++ b/extension/src/popup/components/earn/PoolDetailsSheet/index.tsx @@ -1,21 +1,12 @@ import React from "react"; import { Button, Icon, Text } from "@stellar/design-system"; import { useTranslation } from "react-i18next"; -import { useSelector } from "react-redux"; import { BlendCatalogPool } from "@shared/api/types/blend"; -import { AssetIcon } from "popup/components/account/AccountAssets"; -import { NO_FIAT_VALUE } from "popup/helpers/formatters"; -import { settingsNetworkDetailsSelector } from "popup/ducks/settings"; import { PoolIcon } from "popup/components/earn/PoolIcon"; import { BLEND_LENDING_DOCS_URL } from "popup/constants/externalLinks"; import { openTab } from "popup/helpers/navigate"; -import { getCatalogAssetIdentity } from "popup/components/earn/helpers/earnAssetIcons"; -import { getAcceptedReserves } from "popup/components/earn/helpers/poolReserves"; import { StatRow } from "popup/components/earn/StatRow"; - -import { usePoolReserveIcons } from "./hooks/usePoolReserveIcons"; - import { formatCompactUsd, formatRate, @@ -41,16 +32,9 @@ interface PoolDetailsSheetProps { * 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. - * - * "Accepted tokens" shows only the pool's enabled reserves. The catalog reports - * disabled ones too, but Blend rejects a deposit into them, and the token - * picker behind this sheet has already dropped them — see `getAcceptedReserves`. */ export const PoolDetailsSheet = ({ pool, onClose }: PoolDetailsSheetProps) => { const { t } = useTranslation(); - const networkDetails = useSelector(settingsNetworkDetailsSelector); - const reserveIcons = usePoolReserveIcons(pool); - const acceptedReserves = getAcceptedReserves(pool); return (
@@ -80,10 +64,20 @@ export const PoolDetailsSheet = ({ pool, onClose }: PoolDetailsSheetProps) => { data-testid="earn-pool-details-body" >
- + {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.", )} @@ -99,18 +93,23 @@ export const PoolDetailsSheet = ({ pool, onClose }: PoolDetailsSheetProps) => {
- - {t("Pool Details")} + + {t("Pool Performance")}
{
- - {acceptedReserves.map((reserve) => { - // The catalog reports native XLM with no symbol and no - // name, so the identity has to be derived rather than read - // straight off the reserve — AssetIcon needs a code to - // recognise XLM and an issuer to look anything else up. - const { code, issuer } = getCatalogAssetIdentity({ - symbol: reserve.symbol, - name: reserve.name, - assetId: reserve.assetId, - networkDetails, - }); - return ( - - ); - })} -
- ) - } - /> * + * { - margin-left: pxToRem(-4); - } - } } diff --git a/extension/src/popup/components/earn/helpers/__tests__/poolReserves.test.ts b/extension/src/popup/components/earn/helpers/__tests__/poolReserves.test.ts deleted file mode 100644 index ae8338bc30..0000000000 --- a/extension/src/popup/components/earn/helpers/__tests__/poolReserves.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { BlendCatalogPool, BlendCatalogReserve } from "@shared/api/types/blend"; - -import { getAcceptedReserves } from "../poolReserves"; - -const reserve = (assetId: string, enabled: boolean): BlendCatalogReserve => ({ - assetId, - symbol: assetId, - name: null, - decimals: 7, - enabled, - utilization: null, - supplyApy: null, - borrowApy: null, - emissionsSupplyApr: null, - suppliedUsd: null, - borrowedUsd: null, - priceUsd: null, -}); - -const pool = (reserves: BlendCatalogReserve[]): BlendCatalogPool => ({ - id: "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD", - name: "Fixed", - status: "ACTIVE", - suppliedUsd: null, - borrowedUsd: null, - interestApy: null, - netApy: null, - backstopUsd: null, - reserves, -}); - -describe("getAcceptedReserves", () => { - it("drops the reserves Blend would reject a deposit into", () => { - const result = getAcceptedReserves( - pool([ - reserve("XLM", true), - reserve("EURC", false), - reserve("USDC", true), - ]), - ); - - expect(result.map((r) => r.assetId)).toEqual(["XLM", "USDC"]); - }); - - it("keeps the catalog's order for an all enabled pool", () => { - const reserves = [reserve("XLM", true), reserve("USDC", true)]; - - expect(getAcceptedReserves(pool(reserves))).toEqual(reserves); - }); - - it("returns nothing when every reserve is disabled", () => { - expect( - getAcceptedReserves( - pool([reserve("XLM", false), reserve("USDC", false)]), - ), - ).toEqual([]); - }); - - // The sheet's pool is null until the catalog resolves, and the icon hook runs - // against the same value. - it("returns nothing for a pool that has not loaded", () => { - expect(getAcceptedReserves(null)).toEqual([]); - }); -}); diff --git a/extension/src/popup/components/earn/helpers/poolReserves.ts b/extension/src/popup/components/earn/helpers/poolReserves.ts deleted file mode 100644 index 20c90736c8..0000000000 --- a/extension/src/popup/components/earn/helpers/poolReserves.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { BlendCatalogPool, BlendCatalogReserve } from "@shared/api/types/blend"; - -/** - * A pool's reserves that can actually take a deposit. - * - * Blend's `require_action_allowed` panics with `ReserveDisabled` on Supply, - * SupplyCollateral, and Borrow into a reserve whose config has `enabled: false` - * — only Withdraw and Repay stay open — so a disabled reserve is not an - * accepted token however the catalog reports it. - * - * The pools catalog reports every reserve with its flag and leaves the decision - * here, while the backend's earn-options derivation drops disabled reserves - * before the token picker ever sees them. Filtering with the same predicate is - * what keeps the two lists from contradicting each other on the same screen. - */ -export const getAcceptedReserves = ( - pool: BlendCatalogPool | null, -): BlendCatalogReserve[] => - (pool?.reserves || []).filter((reserve) => reserve.enabled); diff --git a/extension/src/popup/ducks/remoteConfig.ts b/extension/src/popup/ducks/remoteConfig.ts index 3ab614f2fc..676169a8f6 100644 --- a/extension/src/popup/ducks/remoteConfig.ts +++ b/extension/src/popup/ducks/remoteConfig.ts @@ -93,7 +93,7 @@ const initialState: RemoteConfigState = { // 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, + earn_deposit: true, maintenance_banner: { enabled: false, payload: undefined }, maintenance_screen: { enabled: false, payload: undefined }, }; diff --git a/extension/src/popup/locales/__tests__/translationParity.test.ts b/extension/src/popup/locales/__tests__/translationParity.test.ts index 40a73ea146..22baffc4f3 100644 --- a/extension/src/popup/locales/__tests__/translationParity.test.ts +++ b/extension/src/popup/locales/__tests__/translationParity.test.ts @@ -58,12 +58,11 @@ const earnKeys = [ "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 Details", + "Pool Performance", "Deposit supported assets into this Blend pool to earn yield. APY may change over time. Withdraw anytime.", "View pool details", - "Lending Interest", - "Current Net APY", - "Accepted tokens", + "Interest", + "Net APY", "Supplied", "Borrowed", "Backstop", diff --git a/extension/src/popup/locales/en/translation.json b/extension/src/popup/locales/en/translation.json index 6dd6ea40e4..da9270b213 100644 --- a/extension/src/popup/locales/en/translation.json +++ b/extension/src/popup/locales/en/translation.json @@ -14,7 +14,6 @@ "About": "About", "About unverified tokens": "About unverified tokens", "About verified tokens": "About verified tokens", - "Accepted tokens": "Accepted tokens", "Account": "Account", "Account details": "Account details", "Account ID": "Account ID", @@ -182,7 +181,6 @@ "Create new wallet": "Create new wallet", "Current APY": "Current APY", "Current APY: {{rate}}*": "Current APY: {{rate}}*", - "Current Net APY": "Current Net APY", "Current Network": "Current Network", "Custom": "Custom", "dApps": "dApps", @@ -353,6 +351,7 @@ "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", @@ -374,7 +373,6 @@ "Leave Feedback": "Leave Feedback", "Leave feedback about Blockaid warnings and messages": "Leave feedback about Blockaid warnings and messages", "Ledger will not display the transaction details in the device display prior to signing so make sure you only interact with applications you know and trust.": "Ledger will not display the transaction details in the device display prior to signing so make sure you only interact with applications you know and trust.", - "Lending Interest": "Lending Interest", "Let’s go": "Let’s go", "Limit": "Limit", "Links": "Links", @@ -430,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", @@ -499,7 +498,7 @@ "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 Details": "Pool Details", + "Pool Performance": "Pool Performance", "Popular tokens": "Popular tokens", "Position": "Position", "powered by": "powered by", diff --git a/extension/src/popup/locales/pt/translation.json b/extension/src/popup/locales/pt/translation.json index 06a2e4a5b1..c0bd411b51 100644 --- a/extension/src/popup/locales/pt/translation.json +++ b/extension/src/popup/locales/pt/translation.json @@ -14,7 +14,6 @@ "About": "Sobre", "About unverified tokens": "Sobre tokens não verificados", "About verified tokens": "Sobre tokens verificados", - "Accepted tokens": "Tokens aceitos", "Account": "Conta", "Account details": "Detalhes da conta", "Account ID": "ID da Conta", @@ -182,7 +181,6 @@ "Create new wallet": "Criar nova carteira", "Current APY": "APY atual", "Current APY: {{rate}}*": "APY atual: {{rate}}*", - "Current Net APY": "APY líquida atual", "Current Network": "Rede Atual", "Custom": "Personalizado", "dApps": "dApps", @@ -353,6 +351,7 @@ "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", @@ -374,7 +373,6 @@ "Leave Feedback": "Deixar Feedback", "Leave feedback about Blockaid warnings and messages": "Deixar feedback sobre avisos e mensagens do Blockaid", "Ledger will not display the transaction details in the device display prior to signing so make sure you only interact with applications you know and trust.": "O Ledger não exibirá os detalhes da transação na tela do dispositivo antes de assinar, então certifique-se de interagir apenas com aplicativos que você conhece e confia.", - "Lending Interest": "Juros de empréstimo", "Let’s go": "Vamos lá", "Limit": "Limite", "Links": "Links", @@ -430,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", @@ -499,7 +498,7 @@ "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 Details": "Detalhes do pool", + "Pool Performance": "Desempenho do pool", "Popular tokens": "Tokens populares", "Position": "Posição", "powered by": "desenvolvido por", From 1d4d137a670e0a9bc7d36f6b802841119a3b5ad7 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Wed, 26 Aug 2026 15:10:15 -0600 Subject: [PATCH 36/43] updates earn icon to new design icon --- extension/src/popup/assets/icon-earn.svg | 3 +++ extension/src/popup/components/account/AccountHeader/index.tsx | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 extension/src/popup/assets/icon-earn.svg 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/account/AccountHeader/index.tsx b/extension/src/popup/components/account/AccountHeader/index.tsx index c88189cdcb..105b76da49 100644 --- a/extension/src/popup/components/account/AccountHeader/index.tsx +++ b/extension/src/popup/components/account/AccountHeader/index.tsx @@ -27,6 +27,7 @@ import { signOut } from "popup/ducks/accountServices"; import { earnDepositSelector } from "popup/ducks/remoteConfig"; import { AccountHeaderModal } from "popup/components/account/AccountHeaderModal"; import { NetworkIcon } from "popup/components/manageNetwork/NetworkIcon"; +import IconEarn from "popup/assets/icon-earn.svg?react"; import { NetworkDetails } from "@shared/constants/stellar"; import { isEarnSupportedNetwork } from "@shared/constants/blend"; import { AccountTabs } from "popup/components/account/AccountTabs"; @@ -423,7 +424,7 @@ export const AccountHeader = ({
- +
{t("Earn")} From 7ac8fb8660f3c39077ce10357e90074003c6e9ca Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Wed, 26 Aug 2026 15:33:19 -0600 Subject: [PATCH 37/43] reverts earn deposit flag from debug back to false default --- extension/src/popup/ducks/remoteConfig.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extension/src/popup/ducks/remoteConfig.ts b/extension/src/popup/ducks/remoteConfig.ts index 676169a8f6..3ab614f2fc 100644 --- a/extension/src/popup/ducks/remoteConfig.ts +++ b/extension/src/popup/ducks/remoteConfig.ts @@ -93,7 +93,7 @@ const initialState: RemoteConfigState = { // 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: true, + earn_deposit: false, maintenance_banner: { enabled: false, payload: undefined }, maintenance_screen: { enabled: false, payload: undefined }, }; From 684ada843ae60f763d2023504370f89fc0c089a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Tue, 3 Nov 2026 14:08:57 -0800 Subject: [PATCH 38/43] fix(earn): render the Earn flow in light and dark themes The branch predates theming, and two of its surfaces were drawn for the dark frame only. - The glow behind the intro and picker was an SVG with the dark frame's green baked in. On the light page it rendered as a grey smudge. It is now a CSS disc (EarnGlow) with a token per theme: green-04 in dark, the nearest token to the original, and green-06 in light. The design has no light frame for it, and green-06 keeps it about as visible on white as the dark glow is on black. filter: blur() takes the Gaussian's standard deviation, so the falloff is still the asset's 100px. Dropping the asset also removes a filter id the two mounted steps would have shared. - The swap sheet hosts the Swap screens, whose View.Content inset now paints the page colour (base-00). That drew a black slab on the gray-01 sheet in dark mode and a white one in light. The insets take the sheet colour, as master does inside SlideupModal. Everything else already takes its colours from SDS tokens. Checked screen by screen in both schemes against the built extension. Co-Authored-By: Claude Opus 5.5 --- extension/src/popup/assets/earn-glow.svg | 12 ----------- .../popup/components/earn/EarnGlow/index.tsx | 19 ++++++++++++++++++ .../components/earn/EarnGlow/styles.scss | 20 +++++++++++++++++++ .../popup/components/earn/EarnIntro/index.tsx | 4 ++-- .../components/earn/EarnIntro/styles.scss | 13 ++++-------- .../components/earn/EarnSwap/styles.scss | 7 +++++++ .../components/earn/EarnTokenPicker/index.tsx | 4 ++-- .../earn/EarnTokenPicker/styles.scss | 12 ++++------- 8 files changed, 58 insertions(+), 33 deletions(-) delete mode 100644 extension/src/popup/assets/earn-glow.svg create mode 100644 extension/src/popup/components/earn/EarnGlow/index.tsx create mode 100644 extension/src/popup/components/earn/EarnGlow/styles.scss diff --git a/extension/src/popup/assets/earn-glow.svg b/extension/src/popup/assets/earn-glow.svg deleted file mode 100644 index d555ed61b4..0000000000 --- a/extension/src/popup/assets/earn-glow.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/extension/src/popup/components/earn/EarnGlow/index.tsx b/extension/src/popup/components/earn/EarnGlow/index.tsx new file mode 100644 index 0000000000..ec2733f7b0 --- /dev/null +++ b/extension/src/popup/components/earn/EarnGlow/index.tsx @@ -0,0 +1,19 @@ +import React from "react"; + +import "./styles.scss"; + +/** + * The blurred green disc behind the Earn screens' Blend mark. + * + * Drawn in CSS rather than as the exported SVG it replaces so its colour can + * follow the theme: the asset baked the dark frame's green, which on the light + * page rendered as a grey smudge. The falloff is unchanged — `filter: blur()` + * takes the Gaussian's standard deviation, the same 100px the asset's + * `feGaussianBlur` used. + * + * Hosts place it (absolutely, centred with a translate) and set any opacity; + * the disc itself only knows its size and colour. + */ +export const EarnGlow = ({ className }: { className: string }) => ( +