From aba2cff610da52a355a2f38175365099336c93a2 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Wed, 19 Aug 2026 11:33:53 -0600 Subject: [PATCH 01/27] feat(earn): add Blend pool constants and API types --- __tests__/config/blend.test.ts | 43 ++++++++ src/config/blend.ts | 39 ++++++++ src/config/blendTypes.ts | 176 +++++++++++++++++++++++++++++++++ 3 files changed, 258 insertions(+) create mode 100644 __tests__/config/blend.test.ts create mode 100644 src/config/blend.ts create mode 100644 src/config/blendTypes.ts diff --git a/__tests__/config/blend.test.ts b/__tests__/config/blend.test.ts new file mode 100644 index 000000000..3a60e7756 --- /dev/null +++ b/__tests__/config/blend.test.ts @@ -0,0 +1,43 @@ +import { + BLEND_DEPOSIT_XLM_FEE_BUFFER, + BLEND_FIXED_POOL_IDS, + getBlendPoolId, + isEarnSupportedNetwork, +} from "config/blend"; +import { NETWORKS } from "config/constants"; + +const details = (network: NETWORKS) => + ({ network }) as unknown as Parameters[0]; + +describe("blend config", () => { + it("maps PUBLIC and TESTNET to their Fixed pool ids", () => { + expect(getBlendPoolId(details(NETWORKS.PUBLIC))).toBe( + "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD", + ); + expect(getBlendPoolId(details(NETWORKS.TESTNET))).toBe( + "CCEBVDYM32YNYCVNRXQKDFFPISJJCV557CDZEIRBEE4NCV4KHPQ44HGF", + ); + }); + + it("reports Earn unsupported for a network with no allowlisted pool", () => { + expect(getBlendPoolId(details(NETWORKS.FUTURENET))).toBeUndefined(); + expect(isEarnSupportedNetwork(details(NETWORKS.FUTURENET))).toBe(false); + }); + + it("reports Earn supported wherever a pool is allowlisted", () => { + expect(isEarnSupportedNetwork(details(NETWORKS.PUBLIC))).toBe(true); + expect(isEarnSupportedNetwork(details(NETWORKS.TESTNET))).toBe(true); + }); + + it("holds back a fee buffer large enough for a Blend submit resource fee", () => { + // One submit measured at ~546,395 stroops (0.0546 XLM) against the live + // mainnet pool. The buffer must comfortably exceed that. + expect(Number(BLEND_DEPOSIT_XLM_FEE_BUFFER)).toBeGreaterThan(0.0546); + }); + + it("exposes exactly the two allowlisted networks", () => { + expect(Object.keys(BLEND_FIXED_POOL_IDS).sort()).toEqual( + [NETWORKS.PUBLIC, NETWORKS.TESTNET].sort(), + ); + }); +}); diff --git a/src/config/blend.ts b/src/config/blend.ts new file mode 100644 index 000000000..f783b4083 --- /dev/null +++ b/src/config/blend.ts @@ -0,0 +1,39 @@ +import { NETWORKS, NetworkDetails } from "config/constants"; + +/** + * 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]; + +/** + * 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. + * + * `calculateSpendableAmount` only subtracts the base reserve and 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/src/config/blendTypes.ts b/src/config/blendTypes.ts new file mode 100644 index 000000000..d14627bd5 --- /dev/null +++ b/src/config/blendTypes.ts @@ -0,0 +1,176 @@ +/* -------------------------------------------------------------------------- */ +/* GET /protocols/blend/earn-options */ +/* -------------------------------------------------------------------------- */ + +export interface ApiBlendEarnPool { + id: string; + name: string | null; + supply_apy: number | null; + emissions_supply_apr: number | null; + supplied_usd: number | null; +} + +export interface ApiBlendEarnAssetOption { + asset_id: string; + symbol: string | null; + name: string | null; + decimals: number | null; + /** Ordered by supplied USD descending (unpriced last). */ + pools: ApiBlendEarnPool[]; +} + +export interface ApiBlendEarnOptionsCatalog { + options: ApiBlendEarnAssetOption[]; +} + +export interface BlendEarnPool { + id: string; + name: string | null; + supplyApy: number | null; + emissionsSupplyApr: number | null; + suppliedUsd: number | null; +} + +export interface BlendEarnAssetOption { + /** The reserve's asset contract address — a SAC for every current reserve. */ + assetId: string; + symbol: string | null; + name: string | null; + decimals: number | null; + pools: BlendEarnPool[]; +} + +/* -------------------------------------------------------------------------- */ +/* GET /protocols/blend/pools */ +/* -------------------------------------------------------------------------- */ + +export interface ApiBlendCatalogReserve { + asset_id: string; + symbol: string | null; + name: string | null; + decimals: number | null; + enabled: boolean; + utilization: number | null; + supply_apy: number | null; + borrow_apy: number | null; + emissions_supply_apr: number | null; + supplied_usd: number | null; + borrowed_usd: number | null; + price_usd: number | null; +} + +export interface ApiBlendCatalogPool { + id: string; + name: string | null; + status: string | null; + supplied_usd: number | null; + borrowed_usd: number | null; + interest_apy: number | null; + net_apy: number | null; + /** + * Optional, unlike its siblings: the backend does not serve this field yet. + * Absent and null both mean "unavailable" to the UI. + */ + backstop_usd?: number | null; + reserves: ApiBlendCatalogReserve[]; +} + +export interface ApiBlendPoolsCatalog { + pools: ApiBlendCatalogPool[]; +} + +export interface BlendCatalogReserve { + assetId: string; + symbol: string | null; + name: string | null; + decimals: number | null; + /** The reserve's own on/off flag, independent of pool status. */ + enabled: boolean; + utilization: number | null; + supplyApy: number | null; + borrowApy: number | null; + emissionsSupplyApr: number | null; + suppliedUsd: number | null; + borrowedUsd: number | null; + priceUsd: number | null; +} + +export interface BlendCatalogPool { + id: string; + name: string | null; + /** + * Upstream enum name: ADMIN_ACTIVE, ACTIVE, ADMIN_ON_ICE, ON_ICE, + * ADMIN_FROZEN, FROZEN, SETUP. The first four accept deposits. Null until the + * pool's config has been ingested. + */ + status: string | null; + suppliedUsd: number | null; + borrowedUsd: number | null; + /** Supplied-USD-weighted supply rate, interest only. */ + interestApy: number | null; + /** As `interestApy`, plus BLND emissions. Supply-side, not netted against borrow. */ + netApy: number | null; + /** Null while unserved or unpriceable; `0` is a real zero. */ + backstopUsd: number | null; + reserves: BlendCatalogReserve[]; +} + +/* -------------------------------------------------------------------------- */ +/* POST /accounts/positions */ +/* -------------------------------------------------------------------------- */ + +export interface ApiBlendSupplyRow { + asset_id: string; + symbol: string | null; + name: string | null; + decimals: number | null; + /** Plain-supply portion, collateral portion, and their sum. All raw units. */ + supplied_tokens: string; + collateral_tokens: string; + total_tokens: string; + usd_value: number | null; + apy: number | null; + emissions_apr: number | null; + interest_earned: string; + interest_earned_usd: number | null; + claimable_blnd: string; + claimable_usd: number | null; + price_usd: number | null; +} + +export interface ApiBlendBorrowRow { + asset_id: string; + symbol: string | null; + name: string | null; + decimals: number | null; + borrowed_tokens: string; + usd_value: number | null; + apy: number | null; + emissions_apr: number | null; + price_usd: number | null; +} + +export interface 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[]; +} From f2f86ceb4c6f03860f1ef0985a73c7b0b023ccea Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Wed, 19 Aug 2026 11:57:29 -0600 Subject: [PATCH 02/27] feat(earn): add Blend deposit transaction encoding primitives --- __tests__/helpers/blend.test.ts | 92 +++++++++++++++++++++++++++++++++ src/helpers/blend.ts | 88 +++++++++++++++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 __tests__/helpers/blend.test.ts create mode 100644 src/helpers/blend.ts diff --git a/__tests__/helpers/blend.test.ts b/__tests__/helpers/blend.test.ts new file mode 100644 index 000000000..a4e37b73d --- /dev/null +++ b/__tests__/helpers/blend.test.ts @@ -0,0 +1,92 @@ +import { Address, xdr } from "@stellar/stellar-sdk"; +import { + BlendRequestType, + buildBlendRequestScVal, + buildBlendSubmitOp, +} from "helpers/blend"; + +const POOL_ID = "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD"; +const USDC_SAC = "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75"; +const PUBLIC_KEY = "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H"; + +describe("buildBlendRequestScVal", () => { + it("encodes Request as a map keyed address, amount, request_type in that order", () => { + const val = buildBlendRequestScVal({ + assetId: USDC_SAC, + amount: "5000000000", + requestType: BlendRequestType.SupplyCollateral, + }); + + const entries = val.map(); + expect(entries).not.toBeNull(); + expect(entries!.map((e) => e.key().sym().toString())).toEqual([ + "address", + "amount", + "request_type", + ]); + }); + + it("encodes the amount as an i128 and the request type as a u32", () => { + const entries = buildBlendRequestScVal({ + assetId: USDC_SAC, + amount: "5000000000", + requestType: BlendRequestType.SupplyCollateral, + }).map()!; + + const byKey = (name: string) => + entries.find((e) => e.key().sym().toString() === name)!.val(); + + expect(byKey("amount").switch().name).toBe("scvI128"); + expect(byKey("request_type").switch().name).toBe("scvU32"); + expect(byKey("request_type").u32()).toBe(2); + expect(byKey("address").switch().name).toBe("scvAddress"); + }); + + it("uses SupplyCollateral (2), not Supply (0), for deposits", () => { + expect(BlendRequestType.SupplyCollateral).toBe(2); + expect(BlendRequestType.Supply).toBe(0); + }); + + it("round-trips through XDR without loss", () => { + const val = buildBlendRequestScVal({ + assetId: USDC_SAC, + amount: "5000000000", + requestType: BlendRequestType.SupplyCollateral, + }); + const restored = xdr.ScVal.fromXDR(val.toXDR()); + expect(restored.toXDR("base64")).toBe(val.toXDR("base64")); + }); +}); + +describe("buildBlendSubmitOp", () => { + it("calls submit with from, spender and to all set to the user", () => { + const op = buildBlendSubmitOp({ + poolId: POOL_ID, + publicKey: PUBLIC_KEY, + requests: [ + buildBlendRequestScVal({ + assetId: USDC_SAC, + amount: "1", + requestType: BlendRequestType.SupplyCollateral, + }), + ], + }); + + const invocation = op + .body() + .invokeHostFunctionOp() + .hostFunction() + .invokeContract(); + + expect(invocation.functionName().toString()).toBe("submit"); + // from, spender, to, requests + expect(invocation.args()).toHaveLength(4); + + // Verify that from, spender, and to are all the user's Address + const expectedUser = new Address(PUBLIC_KEY).toScVal().toXDR("base64"); + const args = invocation.args(); + expect(args[0].toXDR("base64")).toBe(expectedUser); + expect(args[1].toXDR("base64")).toBe(expectedUser); + expect(args[2].toXDR("base64")).toBe(expectedUser); + }); +}); diff --git a/src/helpers/blend.ts b/src/helpers/blend.ts new file mode 100644 index 000000000..4c465aaa2 --- /dev/null +++ b/src/helpers/blend.ts @@ -0,0 +1,88 @@ +import { Address, Contract, XdrLargeInt, xdr } from "@stellar/stellar-sdk"; + +/** + * 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; +} + +/** + * 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. + */ +export const buildBlendRequestScVal = ({ + assetId, + amount, + requestType, +}: BuildBlendRequestScValParams): xdr.ScVal => + xdr.ScVal.scvMap([ + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol("address"), + val: new Address(assetId).toScVal(), + }), + new xdr.ScMapEntry({ + key: xdr.ScVal.scvSymbol("amount"), + val: new 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[]; +} + +/** + * 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, +}: BuildBlendSubmitOpParams) => { + const user = new Address(publicKey).toScVal(); + + return new Contract(poolId).call( + "submit", + user, + user, + user, + xdr.ScVal.scvVec(requests), + ); +}; From a028d4a1a24c04fd6408a930710a7a6fc5279dca Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Wed, 19 Aug 2026 14:13:50 -0600 Subject: [PATCH 03/27] feat(earn): match Blend reserves to held balances by contract id --- __tests__/helpers/balances.test.ts | 112 ++++++++++++++++++++++++++++- src/helpers/balances.ts | 66 +++++++++++++++++ 2 files changed, 177 insertions(+), 1 deletion(-) diff --git a/__tests__/helpers/balances.test.ts b/__tests__/helpers/balances.test.ts index 82628673a..ab1bc004c 100644 --- a/__tests__/helpers/balances.test.ts +++ b/__tests__/helpers/balances.test.ts @@ -1,5 +1,6 @@ +import { Asset, Networks } from "@stellar/stellar-sdk"; import { BigNumber } from "bignumber.js"; -import { NATIVE_TOKEN_CODE } from "config/constants"; +import { NATIVE_TOKEN_CODE, NETWORKS } from "config/constants"; import { ClassicBalance, NonNativeToken, @@ -17,6 +18,7 @@ import { calculateSpendableAmount, isAmountSpendable, getIssuerFromIdentifier, + getBalanceByContractId, } from "helpers/balances"; describe("balances helpers", () => { @@ -451,3 +453,111 @@ describe("balances helpers", () => { }); }); }); + +describe("getBalanceByContractId", () => { + const networkDetails = { + network: NETWORKS.TESTNET, + networkPassphrase: Networks.TESTNET, + } as never; + + const USDC_ISSUER = + "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; + + const nativeBalance = { + total: new BigNumber("100"), + available: new BigNumber("95"), + token: { type: "native", code: "XLM" }, + } as never; + + const usdcBalance = { + total: new BigNumber("50"), + available: new BigNumber("50"), + token: { code: "USDC", issuer: { key: USDC_ISSUER } }, + } as never; + + const balances = { XLM: nativeBalance, [`USDC:${USDC_ISSUER}`]: usdcBalance }; + + it("matches native XLM to its SAC address", () => { + const nativeSac = Asset.native().contractId(Networks.TESTNET); + expect(getBalanceByContractId(nativeSac, balances, networkDetails)).toBe( + nativeBalance, + ); + }); + + it("matches a classic asset to its SAC address", () => { + const usdcSac = new Asset("USDC", USDC_ISSUER).contractId(Networks.TESTNET); + expect(getBalanceByContractId(usdcSac, balances, networkDetails)).toBe( + usdcBalance, + ); + }); + + it("matches a Soroban balance on its own contractId", () => { + const contractId = + "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75"; + const sorobanBalance = { + total: new BigNumber("7"), + available: new BigNumber("7"), + contractId, + token: { code: "ABC", issuer: { key: contractId } }, + } as never; + + expect( + getBalanceByContractId( + contractId, + { ...balances, "ABC:x": sorobanBalance }, + networkDetails, + ), + ).toBe(sorobanBalance); + }); + + it("returns undefined when nothing matches", () => { + // Not derivable from any balance above: neither the native SAC, the USDC + // SAC, nor a contractId any balance carries directly. + const unheld = "CC4QNKPEFDA2DOYRJ7AQZQ66AOSKX3PBUAOBH7YW3CQIQDS4KI42ATS3"; + expect( + getBalanceByContractId(unheld, { XLM: nativeBalance }, networkDetails), + ).toBeUndefined(); + }); + + it("does not throw on a liquidity pool balance, which has no token", () => { + const lpBalance = { + total: new BigNumber("1"), + liquidityPoolId: "abc123", + reserves: [], + } as never; + + expect(() => + getBalanceByContractId( + Asset.native().contractId(Networks.TESTNET), + { "abc123:lp": lpBalance }, + networkDetails, + ), + ).not.toThrow(); + }); + + it("does not mistake a classic asset coded XLM for the native balance", () => { + // A classic (non-native) asset can be issued with the code "XLM" by any + // issuer — asset-code impersonation is a known Stellar phishing pattern. + // Its token has no `type: "native"`, only a matching `code`. + const spoofedXlmIssuer = + "GCELD3NDDG6TUTPWYSZFAMGO7VITAV27DCLW5HDU5WWXX2A2QEXHLW4A"; + const spoofedXlmBalance = { + total: new BigNumber("1000"), + available: new BigNumber("1000"), + token: { code: "XLM", issuer: { key: spoofedXlmIssuer } }, + } as never; + + // Spoof placed FIRST so a code-based check (`token.code === "XLM"`) + // would return it before ever reaching the real native balance. + const spoofedBalances = { + [`XLM:${spoofedXlmIssuer}`]: spoofedXlmBalance, + ...balances, + }; + + const nativeSac = Asset.native().contractId(Networks.TESTNET); + + expect( + getBalanceByContractId(nativeSac, spoofedBalances, networkDetails), + ).toBe(nativeBalance); + }); +}); diff --git a/src/helpers/balances.ts b/src/helpers/balances.ts index 35701e6a6..d36747f4f 100644 --- a/src/helpers/balances.ts +++ b/src/helpers/balances.ts @@ -4,7 +4,9 @@ import { NATIVE_TOKEN_CODE, BASE_RESERVE, MIN_TRANSACTION_FEE, + NetworkDetails, } from "config/constants"; +import { logger } from "config/logger"; import { Balance, NativeToken, @@ -18,6 +20,7 @@ import { Token, } from "config/types"; import { formatFiatAmount, NO_FIAT_VALUE } from "helpers/formatAmount"; +import { isContractId } from "helpers/soroban"; interface GetTokenPriceFromBalanceParams { prices: TokenPricesMap; @@ -592,3 +595,66 @@ export const getTotalUsdLabel = ({ return formatFiatAmount(totalUsd ?? "0"); }; + +/** + * Finds the held balance corresponding to a Soroban asset contract address. + * + * Blend's catalog addresses every reserve by contract ID, while balances are + * keyed by TokenIdentifier ("XLM" / "CODE:ISSUER"). Bridging the two means + * deriving each balance's SAC address and comparing. + * + * Native XLM is the case that breaks naive implementations: it has no issuer, + * so any check shaped like `"issuer" in balance.token` silently skips the most + * common asset in the wallet. It is handled explicitly, first. + * + * Soroban balances already carry their own `contractId` and match directly. + * Liquidity pool balances have no `token` at all and never match. + */ +export const getBalanceByContractId = ( + contractId: string, + balances: PricedBalanceMap, + networkDetails: NetworkDetails, +): PricedBalance | undefined => + Object.values(balances).find((balance) => { + // Soroban tokens carry their contract address directly. + if ("contractId" in balance && balance.contractId === contractId) { + return true; + } + + // Liquidity pool shares have no token and cannot be a Blend reserve. + if (!("token" in balance)) { + return false; + } + + try { + if ("type" in balance.token && balance.token.type === "native") { + return ( + SdkToken.native().contractId(networkDetails.networkPassphrase) === + contractId + ); + } + + if (!("issuer" in balance.token)) { + return false; + } + + const issuerKey = balance.token.issuer.key; + // A contract-ID issuer is not a classic asset and has no derivable SAC. + if (isContractId(issuerKey)) { + return false; + } + + return ( + new SdkToken(balance.token.code, issuerKey).contractId( + networkDetails.networkPassphrase, + ) === contractId + ); + } catch (error) { + logger.error( + "getBalanceByContractId", + "Failed to derive SAC address for balance", + error, + ); + return false; + } + }); From 999aab92b5bde2503a46cdc9ff7497677761fdec Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Wed, 19 Aug 2026 14:26:04 -0600 Subject: [PATCH 04/27] feat(earn): add Blend backend client --- __tests__/services/blend.test.ts | 167 ++++++++++++++++++++++++++++ src/services/blend.ts | 181 +++++++++++++++++++++++++++++++ 2 files changed, 348 insertions(+) create mode 100644 __tests__/services/blend.test.ts create mode 100644 src/services/blend.ts diff --git a/__tests__/services/blend.test.ts b/__tests__/services/blend.test.ts new file mode 100644 index 000000000..8372e5a2b --- /dev/null +++ b/__tests__/services/blend.test.ts @@ -0,0 +1,167 @@ +import { NETWORKS } from "config/constants"; +import { freighterBackendV2 } from "services/backend"; +import { + getBlendEarnOptions, + getBlendPools, + getBlendSuppliedTokens, +} from "services/blend"; + +jest.mock("services/backend", () => ({ + freighterBackendV2: { get: jest.fn(), post: jest.fn() }, +})); + +const mockGet = freighterBackendV2.get as jest.Mock; +const mockPost = freighterBackendV2.post as jest.Mock; +const networkDetails = { network: NETWORKS.PUBLIC } as never; +const POOL_ID = "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD"; +const USDC_SAC = "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75"; + +beforeEach(() => jest.clearAllMocks()); + +describe("getBlendEarnOptions", () => { + it("maps snake_case to camelCase and preserves nulls as nulls", async () => { + mockGet.mockResolvedValue({ + data: { + data: { + options: [ + { + asset_id: USDC_SAC, + symbol: "USDC", + name: null, + decimals: 7, + pools: [ + { + id: POOL_ID, + name: "Fixed", + supply_apy: 0.0523, + emissions_supply_apr: null, + supplied_usd: 0, + }, + ], + }, + ], + }, + }, + }); + + const [option] = await getBlendEarnOptions({ networkDetails }); + + expect(option.assetId).toBe(USDC_SAC); + expect(option.decimals).toBe(7); + // null means unavailable; 0 is a real zero. Neither may become the other. + expect(option.pools[0].emissionsSupplyApr).toBeNull(); + expect(option.pools[0].suppliedUsd).toBe(0); + }); + + it("sends the network as a query param", async () => { + mockGet.mockResolvedValue({ data: { data: { options: [] } } }); + await getBlendEarnOptions({ networkDetails }); + expect(mockGet).toHaveBeenCalledWith("/protocols/blend/earn-options", { + params: { network: NETWORKS.PUBLIC }, + }); + }); + + it("throws when the payload has no data envelope", async () => { + mockGet.mockResolvedValue({ data: {} }); + await expect(getBlendEarnOptions({ networkDetails })).rejects.toThrow(); + }); + + it("tolerates a missing options array", async () => { + mockGet.mockResolvedValue({ data: { data: {} } }); + await expect(getBlendEarnOptions({ networkDetails })).resolves.toEqual([]); + }); +}); + +describe("getBlendPools", () => { + it("normalises an absent backstop_usd to null", async () => { + mockGet.mockResolvedValue({ + data: { + data: { + pools: [ + { + id: POOL_ID, + name: "Fixed", + status: "ACTIVE", + supplied_usd: 50050000, + borrowed_usd: null, + interest_apy: 0.05, + net_apy: 0.07, + reserves: [], + }, + ], + }, + }, + }); + + const [pool] = await getBlendPools({ networkDetails }); + expect(pool.backstopUsd).toBeNull(); + expect(pool.suppliedUsd).toBe(50050000); + expect(pool.borrowedUsd).toBeNull(); + }); +}); + +describe("getBlendSuppliedTokens", () => { + const positionsResponse = (supply: unknown[]) => ({ + data: { + data: [ + { + address: "G...", + total_value_usd: 1, + net_apy: 0.05, + positions: [{ protocol: "blend", id: POOL_ID, blend: { supply } }], + }, + ], + }, + }); + + it("reads total_tokens, not supplied_tokens", async () => { + // Deposits use SupplyCollateral, so the position lands in collateral_tokens + // and supplied_tokens is always "0". Reading it would report no position. + mockPost.mockResolvedValue( + positionsResponse([ + { + asset_id: USDC_SAC, + supplied_tokens: "0", + collateral_tokens: "5000000000", + total_tokens: "5000000000", + }, + ]), + ); + + await expect( + getBlendSuppliedTokens({ + publicKey: "G...", + poolId: POOL_ID, + assetId: USDC_SAC, + networkDetails, + }), + ).resolves.toBe("5000000000"); + }); + + it("returns '0' when the account has no row for that asset", async () => { + mockPost.mockResolvedValue(positionsResponse([])); + await expect( + getBlendSuppliedTokens({ + publicKey: "G...", + poolId: POOL_ID, + assetId: USDC_SAC, + networkDetails, + }), + ).resolves.toBe("0"); + }); + + it("posts the address as a single-element batch", async () => { + mockPost.mockResolvedValue(positionsResponse([])); + await getBlendSuppliedTokens({ + publicKey: "GABC", + poolId: POOL_ID, + assetId: USDC_SAC, + networkDetails, + }); + expect(mockPost).toHaveBeenCalledWith( + "/accounts/positions", + { addresses: ["GABC"] }, + { params: { network: NETWORKS.PUBLIC } }, + ); + }); +}); diff --git a/src/services/blend.ts b/src/services/blend.ts new file mode 100644 index 000000000..77a2b5a64 --- /dev/null +++ b/src/services/blend.ts @@ -0,0 +1,181 @@ +import { + ApiAccountPositions, + ApiBlendCatalogPool, + ApiBlendCatalogReserve, + ApiBlendEarnAssetOption, + ApiBlendEarnOptionsCatalog, + ApiBlendEarnPool, + ApiBlendPoolsCatalog, + BlendCatalogPool, + BlendCatalogReserve, + BlendEarnAssetOption, + BlendEarnPool, +} from "config/blendTypes"; +import { NetworkDetails } from "config/constants"; +import { logger } from "config/logger"; +import { freighterBackendV2 } from "services/backend"; + +/** + * Clients for freighter-backend-v2's Blend endpoints. + * + * `networkDetails.network` is already exactly "PUBLIC" / "TESTNET" (the NETWORKS + * enum), which is what the handler validates against. Networks outside those two + * are rejected with a 400 — callers should gate on `isEarnSupportedNetwork` + * rather than relying on the error. + */ + +const mapEarnPool = (pool: ApiBlendEarnPool): BlendEarnPool => ({ + id: pool.id, + name: pool.name, + supplyApy: pool.supply_apy, + emissionsSupplyApr: pool.emissions_supply_apr, + suppliedUsd: pool.supplied_usd, +}); + +const mapEarnAssetOption = ( + option: ApiBlendEarnAssetOption, +): BlendEarnAssetOption => ({ + assetId: option.asset_id, + symbol: option.symbol, + name: option.name, + decimals: option.decimals, + pools: (option.pools || []).map(mapEarnPool), +}); + +const mapCatalogReserve = ( + reserve: ApiBlendCatalogReserve, +): BlendCatalogReserve => ({ + assetId: reserve.asset_id, + symbol: reserve.symbol, + name: reserve.name, + decimals: reserve.decimals, + enabled: reserve.enabled, + utilization: reserve.utilization, + supplyApy: reserve.supply_apy, + borrowApy: reserve.borrow_apy, + emissionsSupplyApr: reserve.emissions_supply_apr, + suppliedUsd: reserve.supplied_usd, + borrowedUsd: reserve.borrowed_usd, + priceUsd: reserve.price_usd, +}); + +const mapCatalogPool = (pool: ApiBlendCatalogPool): BlendCatalogPool => ({ + id: pool.id, + name: pool.name, + status: pool.status, + suppliedUsd: pool.supplied_usd, + borrowedUsd: pool.borrowed_usd, + interestApy: pool.interest_apy, + netApy: pool.net_apy, + // Normalised to null while the backend still omits the field, so callers have + // one "unavailable" case to render rather than two. + backstopUsd: pool.backstop_usd ?? null, + reserves: (pool.reserves || []).map(mapCatalogReserve), +}); + +/** + * Assets that can be deposited into a Blend pool, with each pool's headline + * rate. Already filtered by the backend's operator-curated allowlist. + * + * Auth-signing note: this call passes `network` via axios's `params` option + * rather than folding it into the URL string (as the browser-extension + * original does). That is safe with this app's interceptor — + * `attachAuth.ts`'s `deriveServerPath` signs `instance.getUri(config)`, which + * is axios's own baseURL+url+params join, i.e. pathname *plus* query string. + * So the signed `methodAndPath` already includes `?network=...` by + * construction, same as the extension; a `params` object cannot produce a + * signature/wire-path mismatch here. (This has not been confirmed against a + * live backend — freighter-backend-v2's Blend routes are only on its + * `main-blend` branch, not deployed to dev/stg/prd — but it is settled from + * reading `attachAuth.ts` + `buildAuthJwt.ts`.) + */ +export const getBlendEarnOptions = async ({ + networkDetails, +}: { + networkDetails: NetworkDetails; +}): Promise => { + const { data } = await freighterBackendV2.get<{ + data?: ApiBlendEarnOptionsCatalog; + }>("/protocols/blend/earn-options", { + params: { network: networkDetails.network }, + }); + + // A 200 without a `data` payload is still a failure — returning undefined + // would violate the return contract. + if (!data?.data) { + logger.error("getBlendEarnOptions", "Missing data envelope", data); + throw new Error("Failed to fetch Blend earn options"); + } + + return (data.data.options || []).map(mapEarnAssetOption); +}; + +/** + * The full pool catalog — unfiltered by the earn allowlist. Powers the pool + * details sheet's Lending Interest / Current Net APY / Supplied / Borrowed rows. + */ +export const getBlendPools = async ({ + networkDetails, +}: { + networkDetails: NetworkDetails; +}): Promise => { + const { data } = await freighterBackendV2.get<{ + data?: ApiBlendPoolsCatalog; + }>("/protocols/blend/pools", { + params: { network: networkDetails.network }, + }); + + if (!data?.data) { + logger.error("getBlendPools", "Missing data envelope", data); + throw new Error("Failed to fetch Blend pools"); + } + + return (data.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 { data } = await freighterBackendV2.post<{ + data?: ApiAccountPositions[]; + }>( + "/accounts/positions", + { addresses: [publicKey] }, + { params: { network: networkDetails.network } }, + ); + + if (!data?.data) { + logger.error("getBlendSuppliedTokens", "Missing data envelope", data); + throw new Error("Failed to fetch Blend positions"); + } + + // The endpoint is a batch: `data` is an array with one entry per requested + // address, so unwrap the single element we asked for. + const supplyRow = data.data[0]?.positions + ?.find((position) => position.id === poolId) + ?.blend?.supply?.find((row) => row.asset_id === assetId); + + return supplyRow?.total_tokens || "0"; +}; From 09154af22a69f73208c698b08ae53d5945b06b3b Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Wed, 19 Aug 2026 14:41:38 -0600 Subject: [PATCH 05/27] feat(earn): add earn duck --- __tests__/ducks/earn.test.ts | 61 +++++++++++++++++++++++++ src/ducks/earn.ts | 86 ++++++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 __tests__/ducks/earn.test.ts create mode 100644 src/ducks/earn.ts diff --git a/__tests__/ducks/earn.test.ts b/__tests__/ducks/earn.test.ts new file mode 100644 index 000000000..0c6595232 --- /dev/null +++ b/__tests__/ducks/earn.test.ts @@ -0,0 +1,61 @@ +import { useEarnStore } from "ducks/earn"; + +const POOL_ID = "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD"; +const USDC_SAC = "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75"; + +beforeEach(() => useEarnStore.getState().resetEarn()); + +describe("earn duck", () => { + it("starts with no selection and a zero position", () => { + const state = useEarnStore.getState(); + expect(state.pool).toBeNull(); + expect(state.selectedAssetId).toBe(""); + expect(state.selectedAssetApy).toBeNull(); + expect(state.currentPositionTokens).toBe("0"); + expect(state.lastSubmitFailed).toBe(false); + }); + + it("captures the asset and its rate together at pick time", () => { + useEarnStore.getState().selectAsset({ + assetId: USDC_SAC, + apy: 0.1694, + code: "USDC", + decimals: 7, + }); + + const state = useEarnStore.getState(); + expect(state.selectedAssetId).toBe(USDC_SAC); + expect(state.selectedAssetApy).toBe(0.1694); + expect(state.selectedAssetCode).toBe("USDC"); + expect(state.selectedAssetDecimals).toBe(7); + }); + + it("keeps a null rate null rather than coercing it to zero", () => { + useEarnStore + .getState() + .selectAsset({ assetId: USDC_SAC, apy: null, code: "USDC", decimals: 7 }); + expect(useEarnStore.getState().selectedAssetApy).toBeNull(); + }); + + it("records a submit failure so the amount screen can show the retry banner", () => { + useEarnStore.getState().setSubmitFailed(true); + expect(useEarnStore.getState().lastSubmitFailed).toBe(true); + }); + + it("clears everything on reset", () => { + useEarnStore.getState().setPool({ id: POOL_ID } as never); + useEarnStore + .getState() + .selectAsset({ assetId: USDC_SAC, apy: 0.1, code: "USDC", decimals: 7 }); + useEarnStore.getState().setCurrentPositionTokens("5000000000"); + useEarnStore.getState().setSubmitFailed(true); + + useEarnStore.getState().resetEarn(); + + const state = useEarnStore.getState(); + expect(state.pool).toBeNull(); + expect(state.selectedAssetId).toBe(""); + expect(state.currentPositionTokens).toBe("0"); + expect(state.lastSubmitFailed).toBe(false); + }); +}); diff --git a/src/ducks/earn.ts b/src/ducks/earn.ts new file mode 100644 index 000000000..a5ffbdb5b --- /dev/null +++ b/src/ducks/earn.ts @@ -0,0 +1,86 @@ +import { BlendCatalogPool } from "config/blendTypes"; +import { create } from "zustand"; + +/** + * Earn-domain state that has no home in `transactionBuilder`. + * + * The deposit's transaction-shaped state (XDR, fees, submit status) deliberately + * lives in `transactionBuilder` instead, so Earn reuses the shared sign/submit + * path rather than duplicating it. What lands here is what must survive that + * store's reset, or has no equivalent field there. + */ +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 later: the pool addresses reserves by contract id, and the + * deposit's Request carries that address, not the code. + */ + selectedAssetId: string; + /** + * The chosen asset's headline rate (supply APY + emissions APR) as a decimal + * fraction, or null when the oracle has no fresh price. Carried from the token + * picker so the amount and review screens don't re-derive it — and so the rate + * cannot shift under the user mid-entry. + */ + selectedAssetApy: number | null; + selectedAssetCode: string; + selectedAssetDecimals: number; + /** + * The account's existing balance in the pool for the chosen asset, in raw + * token units — the "before" side of Review. Defaults to "0", which is also + * what a fetch failure falls back to. + */ + currentPositionTokens: string; + /** + * Drives the "Transaction failed. Try again." banner on the amount screen. + * + * Lives here rather than in component state because it is set on the + * processing screen and read after the stack has popped back to Amount — + * component state would be torn down in between. + */ + lastSubmitFailed: boolean; + + setPool: (pool: BlendCatalogPool | null) => void; + selectAsset: (params: { + assetId: string; + apy: number | null; + code: string; + decimals: number; + }) => void; + setCurrentPositionTokens: (tokens: string) => void; + setSubmitFailed: (failed: boolean) => void; + resetEarn: () => void; +} + +const initialState = { + pool: null, + selectedAssetId: "", + selectedAssetApy: null, + selectedAssetCode: "", + selectedAssetDecimals: 7, + currentPositionTokens: "0", + lastSubmitFailed: false, +}; + +export const useEarnStore = create((set) => ({ + ...initialState, + + setPool: (pool) => set({ pool }), + + selectAsset: ({ assetId, apy, code, decimals }) => + set({ + selectedAssetId: assetId, + selectedAssetApy: apy, + selectedAssetCode: code, + selectedAssetDecimals: decimals, + }), + + setCurrentPositionTokens: (currentPositionTokens) => + set({ currentPositionTokens }), + + setSubmitFailed: (lastSubmitFailed) => set({ lastSubmitFailed }), + + resetEarn: () => set({ ...initialState }), +})); From a7d5310f9ef2edf5dab908e68425d0de751c880c Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Wed, 19 Aug 2026 14:55:49 -0600 Subject: [PATCH 06/27] feat(earn): build and simulate Blend deposit transactions --- __tests__/services/transactionService.test.ts | 113 +++++++++++++++++ src/ducks/transactionBuilder.ts | 77 ++++++++++++ src/services/transactionService.ts | 117 ++++++++++++++++++ 3 files changed, 307 insertions(+) diff --git a/__tests__/services/transactionService.test.ts b/__tests__/services/transactionService.test.ts index 42feb5489..f7f891922 100644 --- a/__tests__/services/transactionService.test.ts +++ b/__tests__/services/transactionService.test.ts @@ -6,11 +6,15 @@ import { Address, Transaction, Operation, + XdrLargeInt, } from "@stellar/stellar-sdk"; +import BigNumber from "bignumber.js"; import { NETWORKS, mapNetworkToNetworkDetails } from "config/constants"; +import { BlendRequestType } from "helpers/blend"; import { analytics } from "services/analytics"; import * as backend from "services/backend"; import { + buildBlendDepositTransaction, buildSendCollectibleTransaction, BuildSendCollectibleParams, buildSwapTransaction, @@ -520,3 +524,112 @@ describe("buildSwapTransaction — includeTrustline", () => { expect(tx.fee).toBe("10000"); }); }); + +describe("buildBlendDepositTransaction", () => { + const USDC_SAC = "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75"; + const SENDER = "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H"; + // Mirrors config/blend.ts BLEND_FIXED_POOL_IDS[NETWORKS.PUBLIC]. + const POOL_ID = "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD"; + const mockPreparedXdr = "mock_prepared_blend_xdr"; + + const baseParams = { + senderAddress: SENDER, + assetId: USDC_SAC, + amount: "500", + decimals: 7, + network: NETWORKS.PUBLIC, + transactionFee: "0.00001", + transactionTimeout: 180, + }; + + beforeEach(() => { + jest.clearAllMocks(); + (backend.simulateTransaction as jest.Mock).mockResolvedValue({ + preparedTransaction: mockPreparedXdr, + simulationResponse: { minResourceFee: "546395" }, + }); + }); + + it("rejects a network with no allowlisted pool", async () => { + await expect( + buildBlendDepositTransaction({ + ...baseParams, + network: NETWORKS.FUTURENET, + }), + ).rejects.toThrow(/not supported/i); + }); + + it("scales the human amount by decimals into an integer string", () => { + // 500 USDC at 7 decimals is 5_000_000_000 stroops-equivalent. An i128 + // cannot carry a fraction and exponential notation would not parse, so the + // scaled value must be a plain integer string. + const scaled = new BigNumber("500") + .multipliedBy(new BigNumber(10).pow(7)) + .toFixed(0); + expect(scaled).toBe("5000000000"); + expect(scaled).not.toMatch(/[.e+]/); + }); + + it("submits a SupplyCollateral request against the allowlisted pool with the scaled amount", async () => { + const result = await buildBlendDepositTransaction(baseParams); + + expect(result.preparedXdr).toBe(mockPreparedXdr); + expect(result.minResourceFee).toBe("546395"); + + const parsedTx = TransactionBuilder.fromXDR( + result.xdr, + Networks.PUBLIC, + ) as Transaction; + + expect(parsedTx.operations).toHaveLength(1); + + const operation = parsedTx.operations[0] as Operation.InvokeHostFunction; + const hostFunction = operation.func; + expect(hostFunction.switch().name).toBe("hostFunctionTypeInvokeContract"); + + const invocation = hostFunction.invokeContract(); + expect(invocation.functionName().toString()).toBe("submit"); + expect(Address.fromScAddress(invocation.contractAddress()).toString()).toBe( + POOL_ID, + ); + + // submit(from, spender, to, requests) — requests is a vec of one Request map. + const requestScVal = invocation.args()[3].vec()![0]; + const requestEntries = requestScVal.map()!; + const byKey = (name: string) => + requestEntries.find((e) => e.key().sym().toString() === name)!.val(); + + expect(byKey("request_type").u32()).toBe(BlendRequestType.SupplyCollateral); + expect(byKey("amount").toXDR("base64")).toBe( + new XdrLargeInt("i128", "5000000000").toI128().toXDR("base64"), + ); + + // transactionFee is the INCLUSION fee only: 0.00001 XLM = 100 stroops. + expect(parsedTx.fee).toBe("100"); + }); + + it("returns minResourceFee as null when the simulation response omits it", async () => { + (backend.simulateTransaction as jest.Mock).mockResolvedValue({ + preparedTransaction: mockPreparedXdr, + simulationResponse: {}, + }); + + const result = await buildBlendDepositTransaction(baseParams); + + // A missing minResourceFee is left unknown (null), never coerced to "0" — + // a zero resource fee would understate the real cost and is + // indistinguishable from a genuine zero. + expect(result.minResourceFee).toBeNull(); + }); + + it("throws a plain error when simulation succeeds with no prepared transaction", async () => { + (backend.simulateTransaction as jest.Mock).mockResolvedValue({ + preparedTransaction: undefined, + simulationResponse: {}, + }); + + await expect(buildBlendDepositTransaction(baseParams)).rejects.toThrow( + /simulate/i, + ); + }); +}); diff --git a/src/ducks/transactionBuilder.ts b/src/ducks/transactionBuilder.ts index 6ee4f05b1..9a6526442 100644 --- a/src/ducks/transactionBuilder.ts +++ b/src/ducks/transactionBuilder.ts @@ -14,6 +14,7 @@ import { t } from "i18next"; import { SimulationTransactionType } from "services/analytics/types"; import { isHorizonError, signTransaction, submitTx } from "services/stellar"; import { + buildBlendDepositTransaction, buildPaymentTransaction, buildSendCollectibleTransaction, buildSwapTransaction, @@ -95,6 +96,16 @@ interface TransactionBuilderState { senderAddress: string; }) => Promise; + buildBlendDepositTransaction: (params: { + assetId: string; + amount: string; + decimals: number; + transactionFee: string; + transactionTimeout: number; + network: NETWORKS; + senderAddress: string; + }) => Promise; + signTransaction: (params: { secretKey: string; network: NETWORKS; @@ -110,6 +121,7 @@ const initialState: Omit< | "buildTransaction" | "buildSwapTransaction" | "buildSendCollectibleTransaction" + | "buildBlendDepositTransaction" | "signTransaction" | "submitTransaction" | "resetTransaction" @@ -462,6 +474,71 @@ export const useTransactionBuilderStore = create( } }, + buildBlendDepositTransaction: async (params) => { + const newRequestId = createRequestId(); + + // A Blend deposit is always a Soroban invoke, so the fee UI should show + // the inclusion/resource split from the start. Clear stale Soroban fees + // so the UI does not show outdated numbers mid-build. + set({ + isBuilding: true, + error: null, + requestId: newRequestId, + isSoroban: true, + sorobanResourceFeeXlm: null, + sorobanInclusionFeeXlm: null, + }); + + try { + const built = await buildBlendDepositTransaction({ + senderAddress: params.senderAddress, + assetId: params.assetId, + amount: params.amount, + decimals: params.decimals, + network: params.network, + transactionFee: params.transactionFee, + transactionTimeout: params.transactionTimeout, + }); + + // minResourceFee is null when simulation omits it — left unknown + // rather than coerced to "0", which would understate the real cost. + let sorobanResourceFeeXlm: string | null = null; + if (built.minResourceFee) { + const resourceFeeBn = new BigNumber(built.minResourceFee); + if (!resourceFeeBn.isNaN()) { + sorobanResourceFeeXlm = stroopToXlm(resourceFeeBn).toFixed(7); + } + } + + // Only update the store if this build is still the latest one. + if (get().requestId === newRequestId) { + set({ + transactionXDR: built.preparedXdr, + isBuilding: false, + signedTransactionXDR: null, + transactionHash: null, + sorobanInclusionFeeXlm: params.transactionFee, + sorobanResourceFeeXlm, + }); + } + + return built.preparedXdr; + } catch (error) { + const errorMessage = extractErrorMessage(error); + logger.error( + "TransactionBuilderStore", + "Failed to build Blend deposit transaction", + error, + ); + + if (get().requestId === newRequestId) { + set({ error: errorMessage, isBuilding: false, transactionXDR: null }); + } + + return null; + } + }, + signTransaction: (params) => { try { const { forceSignTransactionFailure } = useDebugStore.getState(); diff --git a/src/services/transactionService.ts b/src/services/transactionService.ts index 6adca562d..8cc26ec9c 100644 --- a/src/services/transactionService.ts +++ b/src/services/transactionService.ts @@ -11,6 +11,7 @@ import { } from "@stellar/stellar-sdk"; import { AxiosError } from "axios"; import { BigNumber } from "bignumber.js"; +import { getBlendPoolId } from "config/blend"; import { DEFAULT_DECIMALS, MINIMUM_CREATE_ACCOUNT_XLM, @@ -22,6 +23,11 @@ import { import { logger } from "config/logger"; import { Balance, NativeBalance, PricedBalance } from "config/types"; import { isLiquidityPool } from "helpers/balances"; +import { + BlendRequestType, + buildBlendRequestScVal, + buildBlendSubmitOp, +} from "helpers/blend"; import { getPerOperationBaseFeeStroops, xlmToStroop, @@ -863,3 +869,114 @@ export const simulateCollectibleTransfer = async ({ throw error; } }; + +interface BuildBlendDepositParams { + senderAddress: 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; + network: NETWORKS; + /** Inclusion fee in XLM; the resource fee is added by the prepared tx. */ + transactionFee: string; + transactionTimeout: number; +} + +interface BuildBlendDepositTransactionResult { + xdr: string; + preparedXdr: string; + /** + * Resource fee in stroops reported by simulation, or `null` when the + * simulation response omits it. `SorobanSimulationResponse.minResourceFee` + * is optional; a missing value is left unknown here rather than coerced to + * "0" — a zero resource fee would understate the real cost and is + * indistinguishable from a genuine zero. Callers render this as "fee + * unavailable" rather than a real number. + */ + minResourceFee: string | null; +} + +/** + * Builds and simulates a Blend deposit — `pool.submit` with one + * SupplyCollateral request. + * + * Returns the prepared (assembled) XDR ready to sign, plus `minResourceFee` so + * callers can render the fee breakdown. + * + * The account is loaded from Horizon rather than soroban-rpc: the app does not + * call Soroban RPC directly, which is why simulation goes through the v1 + * backend's `/simulate-tx` proxy. + */ +export const buildBlendDepositTransaction = async ({ + senderAddress, + assetId, + amount, + decimals, + network, + transactionFee, + transactionTimeout, +}: BuildBlendDepositParams): Promise => { + const networkDetails = mapNetworkToNetworkDetails(network); + + const poolId = getBlendPoolId(networkDetails); + if (!poolId) { + throw new Error("Earn is not supported on this network"); + } + + // Simulation goes through the Soroban RPC endpoint, matching the other two + // simulateTransaction callers in this file (simulateContractTransfer, + // simulateCollectibleTransfer) — the Horizon URL used for loadAccount below + // is not the right URL for the RPC proxy. + if (!networkDetails.sorobanRpcUrl) { + throw new Error("Soroban RPC URL is not defined for this network"); + } + + const server = stellarSdkServer(networkDetails.networkUrl); + const sourceAccount = await server.loadAccount(senderAddress); + + 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: new BigNumber(amount).multipliedBy(10 ** decimals).toFixed(0), + requestType: BlendRequestType.SupplyCollateral, + }); + + const builtTx = new TransactionBuilder(sourceAccount, { + // Inclusion fee only — the prepared transaction carries the resource fee + // once simulation reports it. + fee: xlmToStroop(transactionFee).toString(), + networkPassphrase: networkDetails.networkPassphrase, + }) + .addOperation( + buildBlendSubmitOp({ + poolId, + publicKey: senderAddress, + requests: [request], + }), + ) + // 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 simulation = await simulateTransaction({ + xdr: builtTx.toXDR(), + network_url: networkDetails.sorobanRpcUrl, + network_passphrase: networkDetails.networkPassphrase, + }); + + if (!simulation?.preparedTransaction) { + // simulateTransaction throws on HTTP failure (it's an axios call), so + // this only guards a 200 response that lacks preparedTransaction. + // There is no `error` field on SimulateTransactionResponse to surface. + throw new Error("Failed to simulate the Blend deposit"); + } + + return { + xdr: builtTx.toXDR(), + preparedXdr: simulation.preparedTransaction, + minResourceFee: simulation.simulationResponse?.minResourceFee ?? null, + }; +}; From eb7357284e6ed9b80374d88d62473d11ce31e12a Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Wed, 19 Aug 2026 15:17:44 -0600 Subject: [PATCH 07/27] feat(earn): add deposit flow helpers --- .../screens/EarnScreen/helpers.test.ts | 286 ++++++++++++++++++ .../EarnScreen/helpers/depositAmount.ts | 25 ++ .../EarnScreen/helpers/earnCtaState.ts | 83 +++++ .../EarnScreen/helpers/formatPoolStats.ts | 39 +++ .../EarnScreen/helpers/getNotEnoughVariant.ts | 90 ++++++ .../screens/EarnScreen/helpers/index.ts | 6 + .../EarnScreen/helpers/poolDescriptions.ts | 27 ++ .../EarnScreen/helpers/projectEarnings.ts | 38 +++ 8 files changed, 594 insertions(+) create mode 100644 __tests__/components/screens/EarnScreen/helpers.test.ts create mode 100644 src/components/screens/EarnScreen/helpers/depositAmount.ts create mode 100644 src/components/screens/EarnScreen/helpers/earnCtaState.ts create mode 100644 src/components/screens/EarnScreen/helpers/formatPoolStats.ts create mode 100644 src/components/screens/EarnScreen/helpers/getNotEnoughVariant.ts create mode 100644 src/components/screens/EarnScreen/helpers/index.ts create mode 100644 src/components/screens/EarnScreen/helpers/poolDescriptions.ts create mode 100644 src/components/screens/EarnScreen/helpers/projectEarnings.ts diff --git a/__tests__/components/screens/EarnScreen/helpers.test.ts b/__tests__/components/screens/EarnScreen/helpers.test.ts new file mode 100644 index 000000000..7d9726829 --- /dev/null +++ b/__tests__/components/screens/EarnScreen/helpers.test.ts @@ -0,0 +1,286 @@ +import BigNumber from "bignumber.js"; +import { + NotEnoughVariant, + formatCompactUsd, + formatProjection, + formatRate, + getEarnCtaState, + getMaxDepositAmount, + getNotEnoughVariant, + getPercentageDepositAmount, + getPoolDescriptionKey, + hasSwappableBalance, + isOnrampableAsset, + needsXlmForFee, + projectEarnings, +} from "components/screens/EarnScreen/helpers"; +import { NETWORKS } from "config/constants"; + +describe("getEarnCtaState", () => { + it("reports insufficient before anything else when there is nothing to spend", () => { + expect( + getEarnCtaState({ + availableBalanceIsZero: true, + amountIsZero: true, + isAmountTooHigh: false, + }), + ).toEqual({ disabled: true, labelKey: "insufficient" }); + }); + + it("asks for an amount once a balance exists", () => { + expect( + getEarnCtaState({ + availableBalanceIsZero: false, + amountIsZero: true, + isAmountTooHigh: false, + }), + ).toEqual({ disabled: true, labelKey: "enter" }); + }); + + it("reports insufficient for an over-large amount", () => { + expect( + getEarnCtaState({ + availableBalanceIsZero: false, + amountIsZero: false, + isAmountTooHigh: true, + }), + ).toEqual({ disabled: true, labelKey: "insufficient" }); + }); + + it("enables review for a valid amount", () => { + expect( + getEarnCtaState({ + availableBalanceIsZero: false, + amountIsZero: false, + isAmountTooHigh: false, + }), + ).toEqual({ disabled: false, labelKey: "review" }); + }); + + it("prefers the enter-amount prompt when a zero amount is also flagged too high", () => { + // The only case that can distinguish guard order: amountIsZero and + // isAmountTooHigh are the one pair that yield different labels, so this + // is the sole test that proves which guard actually wins. + expect( + getEarnCtaState({ + availableBalanceIsZero: false, + amountIsZero: true, + isAmountTooHigh: true, + }), + ).toEqual({ disabled: true, labelKey: "enter" }); + }); +}); + +describe("needsXlmForFee", () => { + it("is true when spendable XLM is below the fee", () => { + expect(needsXlmForFee({ spendableXlm: "0.01", fee: "0.06" })).toBe(true); + }); + + it("is false when spendable XLM covers the fee exactly", () => { + expect(needsXlmForFee({ spendableXlm: "0.06", fee: "0.06" })).toBe(false); + }); +}); + +describe("getMaxDepositAmount", () => { + it("holds back a fee buffer for XLM", () => { + // A Blend submit's resource fee dwarfs the inclusion fee already netted out + // by calculateSpendableAmount, so Max must reserve more. + expect(getMaxDepositAmount({ availableBalance: "100", isXlm: true })).toBe( + "99.5", + ); + }); + + it("never returns a negative amount", () => { + expect(getMaxDepositAmount({ availableBalance: "0.1", isXlm: true })).toBe( + "0", + ); + }); + + it("leaves non-XLM balances untouched — their fee comes from a separate balance", () => { + expect(getMaxDepositAmount({ availableBalance: "100", isXlm: false })).toBe( + "100", + ); + }); +}); + +describe("getPercentageDepositAmount", () => { + it("treats the input as whole percents, not a multiplier", () => { + expect( + getPercentageDepositAmount({ + maxDepositable: "100", + pct: 25, + decimals: 7, + }), + ).toBe("25"); + }); + + it("rounds down so Max never exceeds the spendable amount", () => { + expect( + getPercentageDepositAmount({ + maxDepositable: "10.9999999999", + pct: 100, + decimals: 2, + }), + ).toBe("10.99"); + }); +}); + +describe("projectEarnings", () => { + it("uses simple interest and derives monthly as a twelfth of yearly", () => { + expect(projectEarnings({ depositUsd: "1200", apy: 0.1 })).toEqual({ + yearly: "120.00", + monthly: "10.00", + }); + }); + + it("returns nulls when the rate is unavailable", () => { + expect(projectEarnings({ depositUsd: "1200", apy: null })).toEqual({ + yearly: null, + monthly: null, + }); + }); + + it("returns nulls when the asset is unpriced", () => { + expect(projectEarnings({ depositUsd: null, apy: 0.1 })).toEqual({ + yearly: null, + monthly: null, + }); + }); + + it("renders an unknown projection as --", () => { + expect(formatProjection(null)).toBe("--"); + expect(formatProjection("120.00")).toBe("$120.00"); + }); +}); + +describe("formatCompactUsd", () => { + it("compacts pool-scale figures", () => { + expect(formatCompactUsd(50050000)).toBe("$50.05M"); + expect(formatCompactUsd(2500)).toBe("$2.50K"); + expect(formatCompactUsd(1.5e9)).toBe("$1.50B"); + }); + + it("distinguishes unavailable from zero", () => { + expect(formatCompactUsd(null)).toBe("--"); + expect(formatCompactUsd(0)).toBe("$0.00"); + }); +}); + +describe("formatRate", () => { + it("renders a decimal fraction as a percentage", () => { + expect(formatRate(0.1694)).toBe("16.94%"); + }); + + it("distinguishes unavailable from zero", () => { + expect(formatRate(null)).toBe("--"); + expect(formatRate(0)).toBe("0.00%"); + }); +}); + +describe("getPoolDescriptionKey", () => { + it("keys prose by pool id, because the copy makes pool-specific claims", () => { + expect( + getPoolDescriptionKey( + "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD", + ), + ).toBe("earnPoolDetails.descriptions.fixedPool"); + }); + + it("renders nothing rather than something wrong for an unknown pool", () => { + expect(getPoolDescriptionKey("CUNKNOWN")).toBeNull(); + }); +}); + +describe("isOnrampableAsset", () => { + const mainnet = { network: NETWORKS.PUBLIC } as never; + const testnet = { network: NETWORKS.TESTNET } as never; + + it("allows only Coinbase-listed assets", () => { + expect(isOnrampableAsset("XLM", mainnet)).toBe(true); + expect(isOnrampableAsset("USDC", mainnet)).toBe(true); + // EURC is not Coinbase-listed; offering Buy would open a dead-end page. + expect(isOnrampableAsset("EURC", mainnet)).toBe(false); + }); + + it("is mainnet-only, since testnet assets are worthless", () => { + expect(isOnrampableAsset("XLM", testnet)).toBe(false); + }); +}); + +describe("hasSwappableBalance", () => { + const classic = (code: string, issuer: string, total: string) => + ({ + total: new BigNumber(total), + token: { code, issuer: { key: issuer } }, + }) as never; + + // Real NativeToken always carries `type: "native"` (config/types.ts) — the + // native() mock includes it so this fixture matches production shape, per + // the type-based discriminant used by hasSwappableBalance (see helper file + // for why a code-based check is unsafe). + const native = (total: string) => + ({ + total: new BigNumber(total), + token: { type: "native", code: "XLM" }, + }) as never; + + it("counts native XLM, the most common swap source", () => { + expect(hasSwappableBalance({ XLM: native("10") }, "USDC:GISSUER")).toBe( + true, + ); + }); + + it("excludes the target asset itself so dust does not look swappable", () => { + expect( + hasSwappableBalance( + { "USDC:GISSUER": classic("USDC", "GISSUER", "0.001") }, + "USDC:GISSUER", + ), + ).toBe(false); + }); + + it("excludes zero balances", () => { + expect(hasSwappableBalance({ XLM: native("0") }, "USDC:GISSUER")).toBe( + false, + ); + }); + + it("excludes Soroban-only tokens, which Swap cannot path-payment", () => { + const soroban = { + total: new BigNumber("5"), + contractId: "CABC", + token: { code: "ABC", issuer: { key: "CABC" } }, + } as never; + expect(hasSwappableBalance({ "ABC:CABC": soroban }, "USDC:GISSUER")).toBe( + false, + ); + }); +}); + +describe("getNotEnoughVariant", () => { + it("offers buy and swap when both are possible", () => { + expect(getNotEnoughVariant({ isOnrampable: true, isSwappable: true })).toBe( + NotEnoughVariant.BUY_SWAP_OR_TRANSFER, + ); + }); + + it("offers buy alone", () => { + expect( + getNotEnoughVariant({ isOnrampable: true, isSwappable: false }), + ).toBe(NotEnoughVariant.BUY_OR_TRANSFER); + }); + + it("offers swap alone", () => { + expect( + getNotEnoughVariant({ isOnrampable: false, isSwappable: true }), + ).toBe(NotEnoughVariant.SWAP_OR_TRANSFER); + }); + + it("falls back to transfer, which always works", () => { + // Not in the designs but reachable: an empty account on a non-onrampable + // asset. A sheet with no actions would be worse. + expect( + getNotEnoughVariant({ isOnrampable: false, isSwappable: false }), + ).toBe(NotEnoughVariant.TRANSFER_ONLY); + }); +}); diff --git a/src/components/screens/EarnScreen/helpers/depositAmount.ts b/src/components/screens/EarnScreen/helpers/depositAmount.ts new file mode 100644 index 000000000..15ca2a870 --- /dev/null +++ b/src/components/screens/EarnScreen/helpers/depositAmount.ts @@ -0,0 +1,25 @@ +import BigNumber from "bignumber.js"; + +/** + * The amount a percentage button on the deposit screen commits. + * + * `PercentageButtons` reports whole percents — 25/50/75, and 100 for Max — so + * the fraction has to be derived here; multiplying by the raw value asks for 25x + * the balance and every button trips the insufficient-balance check. + * + * Rounds DOWN at the asset's precision, so Max lands on the maximum spendable + * rather than a hair above it. + */ +export const getPercentageDepositAmount = ({ + maxDepositable, + pct, + decimals, +}: { + maxDepositable: string; + pct: number; + decimals: number; +}) => + new BigNumber(maxDepositable) + .multipliedBy(new BigNumber(pct).dividedBy(100)) + .decimalPlaces(decimals, BigNumber.ROUND_DOWN) + .toFixed(); diff --git a/src/components/screens/EarnScreen/helpers/earnCtaState.ts b/src/components/screens/EarnScreen/helpers/earnCtaState.ts new file mode 100644 index 000000000..70d61346e --- /dev/null +++ b/src/components/screens/EarnScreen/helpers/earnCtaState.ts @@ -0,0 +1,83 @@ +import BigNumber from "bignumber.js"; +import { BLEND_DEPOSIT_XLM_FEE_BUFFER } from "config/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". + * + * 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. + * + * `calculateSpendableAmount` subtracts the base reserve and the *inclusion* + * fee, but a Blend `submit` is dominated by its resource fee — ~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/src/components/screens/EarnScreen/helpers/formatPoolStats.ts b/src/components/screens/EarnScreen/helpers/formatPoolStats.ts new file mode 100644 index 000000000..5fdc8dab2 --- /dev/null +++ b/src/components/screens/EarnScreen/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/src/components/screens/EarnScreen/helpers/getNotEnoughVariant.ts b/src/components/screens/EarnScreen/helpers/getNotEnoughVariant.ts new file mode 100644 index 000000000..91eeaa2a6 --- /dev/null +++ b/src/components/screens/EarnScreen/helpers/getNotEnoughVariant.ts @@ -0,0 +1,90 @@ +import BigNumber from "bignumber.js"; +import { NetworkDetails, NETWORKS } from "config/constants"; +import { PricedBalanceMap } from "config/types"; + +/** + * Assets the Coinbase onramp can sell, by code. + * + * The onramp URL is built from the bare code, so an unlisted asset produces a + * dead-end page rather than an error. EURC is deliberately absent — it is not + * Coinbase-listed, which is why the designs show no Buy button on its sheet. + */ +export const EARN_ONRAMP_ASSETS = new Set(["XLM", "USDC"]); + +/** + * Which button set the "Not enough X" sheet shows. + * + * `TRANSFER_ONLY` is not in the designs but is reachable — an empty account on + * a non-onrampable asset has nothing to buy with and nothing to swap from. + */ +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", +} + +/** Testnet assets are worthless, so the onramp is mainnet-only. */ +export const isOnrampableAsset = ( + code: string, + networkDetails: NetworkDetails, +) => EARN_ONRAMP_ASSETS.has(code) && networkDetails.network === NETWORKS.PUBLIC; + +/** + * 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. + * + * Native detection uses the `type` discriminant (`token.type === "native"`), + * NOT a code comparison against "XLM". `NativeToken` is + * `{ type: TokenType.native; code: "XLM" }` but `NonNativeToken` is + * `{ code: string; issuer: Issuer }` (config/types.ts) — any issuer can mint a + * classic asset coded "XLM", so a code check would misclassify it as native + * and as swappable-into-itself. This is the same defect fixed in + * `getBalanceByContractId` (helpers/balances.ts); that idiom is reused here. + * + * `targetIdentifier` 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: PricedBalanceMap, + targetIdentifier: string, +) => + Object.entries(balances).some(([identifier, balance]) => { + // Contract-only tokens are not swappable, and LP shares are not a token. + if ("contractId" in balance) { + return false; + } + if (!("token" in balance)) { + return false; + } + const isNative = "type" in balance.token && balance.token.type === "native"; + if (!isNative && !("issuer" in balance.token)) { + return false; + } + if (!new BigNumber(balance.total).gt(0)) { + return false; + } + return identifier !== targetIdentifier; + }); + +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/src/components/screens/EarnScreen/helpers/index.ts b/src/components/screens/EarnScreen/helpers/index.ts new file mode 100644 index 000000000..dc1a1f821 --- /dev/null +++ b/src/components/screens/EarnScreen/helpers/index.ts @@ -0,0 +1,6 @@ +export * from "components/screens/EarnScreen/helpers/depositAmount"; +export * from "components/screens/EarnScreen/helpers/earnCtaState"; +export * from "components/screens/EarnScreen/helpers/formatPoolStats"; +export * from "components/screens/EarnScreen/helpers/getNotEnoughVariant"; +export * from "components/screens/EarnScreen/helpers/poolDescriptions"; +export * from "components/screens/EarnScreen/helpers/projectEarnings"; diff --git a/src/components/screens/EarnScreen/helpers/poolDescriptions.ts b/src/components/screens/EarnScreen/helpers/poolDescriptions.ts new file mode 100644 index 000000000..e11fdeb56 --- /dev/null +++ b/src/components/screens/EarnScreen/helpers/poolDescriptions.ts @@ -0,0 +1,27 @@ +/** + * 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. + * + * Returns an i18n KEY, not translated prose — copy lives in the translation + * files like everywhere else in this app (see `src/i18n/locales/*`). The key + * below (`earnPoolDetails.descriptions.fixedPool`) does not exist there yet; + * a later task adds it. + */ +const POOL_DESCRIPTION_KEYS: Record = { + // Fixed Pool v2 — mainnet + CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD: + "earnPoolDetails.descriptions.fixedPool", + // Fixed Pool v2 — testnet + CCEBVDYM32YNYCVNRXQKDFFPISJJCV557CDZEIRBEE4NCV4KHPQ44HGF: + "earnPoolDetails.descriptions.fixedPool", +}; + +export const getPoolDescriptionKey = (poolId: string): string | null => + POOL_DESCRIPTION_KEYS[poolId] ?? null; diff --git a/src/components/screens/EarnScreen/helpers/projectEarnings.ts b/src/components/screens/EarnScreen/helpers/projectEarnings.ts new file mode 100644 index 000000000..e05ffd700 --- /dev/null +++ b/src/components/screens/EarnScreen/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)}`; From 83df164f2761f96ace75a441a94c0672e524c2f7 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Wed, 19 Aug 2026 15:35:16 -0600 Subject: [PATCH 08/27] feat(earn): add Earn navigator and Home entry point --- .../screens/EarnTokenPickerScreen.tsx | 16 +++++++++ .../screens/EarnScreen/screens/index.ts | 1 + .../screens/HomeScreen/HomeScreen.tsx | 33 +++++++++++++++-- src/config/routes.ts | 19 ++++++++++ src/i18n/locales/en/translations.json | 4 +++ src/i18n/locales/pt/translations.json | 4 +++ src/navigators/EarnNavigator.tsx | 35 +++++++++++++++++++ src/navigators/RootNavigator.tsx | 6 ++++ src/navigators/index.ts | 1 + 9 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 src/components/screens/EarnScreen/screens/EarnTokenPickerScreen.tsx create mode 100644 src/components/screens/EarnScreen/screens/index.ts create mode 100644 src/navigators/EarnNavigator.tsx diff --git a/src/components/screens/EarnScreen/screens/EarnTokenPickerScreen.tsx b/src/components/screens/EarnScreen/screens/EarnTokenPickerScreen.tsx new file mode 100644 index 000000000..781921b46 --- /dev/null +++ b/src/components/screens/EarnScreen/screens/EarnTokenPickerScreen.tsx @@ -0,0 +1,16 @@ +import { BaseLayout } from "components/layout/BaseLayout"; +import { Text } from "components/sds/Typography"; +import React from "react"; + +/** + * Placeholder for the Earn token picker. Replaced wholesale in Task 9; it + * exists so the navigator compiles and the Home entry point can be verified + * in isolation. + */ +export const EarnTokenPickerScreen = () => ( + + Earn + +); + +export default EarnTokenPickerScreen; diff --git a/src/components/screens/EarnScreen/screens/index.ts b/src/components/screens/EarnScreen/screens/index.ts new file mode 100644 index 000000000..cedb98b81 --- /dev/null +++ b/src/components/screens/EarnScreen/screens/index.ts @@ -0,0 +1 @@ +export { EarnTokenPickerScreen } from "./EarnTokenPickerScreen"; diff --git a/src/components/screens/HomeScreen/HomeScreen.tsx b/src/components/screens/HomeScreen/HomeScreen.tsx index 51f67b897..7985e3b46 100644 --- a/src/components/screens/HomeScreen/HomeScreen.tsx +++ b/src/components/screens/HomeScreen/HomeScreen.tsx @@ -19,7 +19,12 @@ import ManageAccounts from "components/screens/HomeScreen/ManageAccounts"; import WelcomeBannerBottomSheet from "components/screens/HomeScreen/WelcomeBannerBottomSheet"; import Icon from "components/sds/Icon"; import { Display } from "components/sds/Typography"; -import { DEFAULT_PADDING, NATIVE_TOKEN_CODE } from "config/constants"; +import { isEarnSupportedNetwork } from "config/blend"; +import { + DEFAULT_PADDING, + NATIVE_TOKEN_CODE, + mapNetworkToNetworkDetails, +} from "config/constants"; import { MainTabStackParamList, MAIN_TAB_ROUTES, @@ -29,6 +34,7 @@ import { ADD_FUNDS_ROUTES, SEND_PAYMENT_ROUTES, SWAP_ROUTES, + EARN_ROUTES, } from "config/routes"; import { TokenTypeWithCustomToken } from "config/types"; import { useAccountsFiatTotalsStore } from "ducks/accountsFiatTotals"; @@ -142,7 +148,15 @@ export const HomeScreen: React.FC = React.memo( // for real, since holding one needs no funded account (no trustline, no // reserve). So the pill stands down only when the CTA actually renders. const isCollectiblesGridEmpty = visibleCollectibles.length === 0; - const showCollectiblesPill = !(showEmptyStateCta && isCollectiblesGridEmpty); + const showCollectiblesPill = !( + showEmptyStateCta && isCollectiblesGridEmpty + ); + + // Earn is gated purely on network support (an allowlisted Blend pool) — + // there is deliberately no feature flag alongside it. + const earnEnabled = isEarnSupportedNetwork( + mapNetworkToNetworkDetails(network), + ); const handleManageAccountsPress = useCallback(() => { manageAccountsBottomSheetRef.current?.present(); @@ -253,6 +267,12 @@ export const HomeScreen: React.FC = React.memo( }); }, [navigation]); + const handleEarnPress = useCallback(() => { + navigation.navigate(ROOT_NAVIGATOR_ROUTES.EARN_STACK, { + screen: EARN_ROUTES.EARN_TOKEN_PICKER_SCREEN, + }); + }, [navigation]); + const handleAddTokenPress = useCallback(() => { navigation.navigate(ROOT_NAVIGATOR_ROUTES.MANAGE_TOKENS_STACK, { screen: MANAGE_TOKENS_ROUTES.ADD_TOKEN_SCREEN, @@ -406,6 +426,15 @@ export const HomeScreen: React.FC = React.memo( testID="icon-button-swap" /> )} + {earnEnabled && ( + + )} ; [ROOT_NAVIGATOR_ROUTES.SEND_PAYMENT_STACK]: NavigatorScreenParams; [ROOT_NAVIGATOR_ROUTES.SWAP_STACK]: NavigatorScreenParams; + [ROOT_NAVIGATOR_ROUTES.EARN_STACK]: NavigatorScreenParams; [ROOT_NAVIGATOR_ROUTES.TOKEN_DETAILS_SCREEN]: { tokenId: string; tokenSymbol: string; @@ -266,3 +275,13 @@ export type SwapStackParamList = { tokenSymbol: string; }; }; + +export type EarnStackParamList = { + [EARN_ROUTES.EARN_TOKEN_PICKER_SCREEN]: undefined; + [EARN_ROUTES.EARN_AMOUNT_SCREEN]: { + /** The reserve's asset contract address. */ + assetId: string; + tokenCode: string; + }; + [EARN_ROUTES.EARN_PROCESSING_SCREEN]: undefined; +}; diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index 689115b77..82f2a4b07 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -49,6 +49,7 @@ "buy": "Add", "send": "Send", "swap": "Swap", + "earn": "Earn", "actions": { "settings": "Settings", "myQRCode": "My QR code" @@ -689,6 +690,9 @@ "swapTransactionDetails": { "swapped": "Swapped" }, + "earnTokenPicker": { + "title": "Choose a token" + }, "transactionTokenScreen": { "title": "Sending", "empty": "No tokens or collectibles to send" diff --git a/src/i18n/locales/pt/translations.json b/src/i18n/locales/pt/translations.json index ffeefed19..294aa26fb 100644 --- a/src/i18n/locales/pt/translations.json +++ b/src/i18n/locales/pt/translations.json @@ -49,6 +49,7 @@ "buy": "Adicionar", "send": "Enviar", "swap": "Trocar", + "earn": "Render", "actions": { "settings": "Configurações", "myQRCode": "Meu QR code" @@ -655,6 +656,9 @@ "swapTransactionDetails": { "swapped": "Trocado" }, + "earnTokenPicker": { + "title": "Escolha um token" + }, "transactionTokenScreen": { "title": "Enviando", "empty": "Nenhum token ou colecionável para enviar" diff --git a/src/navigators/EarnNavigator.tsx b/src/navigators/EarnNavigator.tsx new file mode 100644 index 000000000..a18e0b09c --- /dev/null +++ b/src/navigators/EarnNavigator.tsx @@ -0,0 +1,35 @@ +/* eslint-disable react/no-unstable-nested-components */ +import { createNativeStackNavigator } from "@react-navigation/native-stack"; +import CustomNavigationHeader from "components/layout/CustomNavigationHeader"; +import { EarnTokenPickerScreen } from "components/screens/EarnScreen/screens"; +import { EARN_ROUTES, EarnStackParamList } from "config/routes"; +import { getScreenBottomNavigateOptions } from "helpers/navigationOptions"; +import useAppTranslation from "hooks/useAppTranslation"; +import { useNetworkFees } from "hooks/useNetworkFees"; +import React from "react"; + +const EarnStack = createNativeStackNavigator(); + +export const EarnStackNavigator = () => { + const { t } = useAppTranslation(); + + // Prewarm the network-fee snapshot on flow entry so the amount screen and + // review read frozen values from cache rather than fetching (and flickering) + // on open. Same rationale as SwapNavigator. + useNetworkFees(); + + return ( + , + }} + > + + + ); +}; diff --git a/src/navigators/RootNavigator.tsx b/src/navigators/RootNavigator.tsx index 2a1a071a5..d9b6e930b 100644 --- a/src/navigators/RootNavigator.tsx +++ b/src/navigators/RootNavigator.tsx @@ -48,6 +48,7 @@ import { useMaintenanceMode } from "hooks/useMaintenanceMode"; import { AuthNavigator, AddFundsStackNavigator, + EarnStackNavigator, ManageTokensStackNavigator, ManageWalletsStackNavigator, SendPaymentStackNavigator, @@ -251,6 +252,11 @@ export const RootNavigator = () => { component={SwapStackNavigator} options={getStackBottomNavigateOptions()} /> + Date: Wed, 19 Aug 2026 16:03:42 -0600 Subject: [PATCH 09/27] feat(earn): add the Choose Token screen --- .../screens/EarnScreen/useEarnTokens.test.ts | 142 +++++++++++++ .../EarnScreen/components/EarnTokenRow.tsx | 73 +++++++ .../screens/EarnScreen/hooks/useEarnTokens.ts | 198 ++++++++++++++++++ .../screens/EarnTokenPickerScreen.tsx | 158 +++++++++++++- src/i18n/locales/en/translations.json | 10 +- src/i18n/locales/pt/translations.json | 10 +- 6 files changed, 580 insertions(+), 11 deletions(-) create mode 100644 __tests__/components/screens/EarnScreen/useEarnTokens.test.ts create mode 100644 src/components/screens/EarnScreen/components/EarnTokenRow.tsx create mode 100644 src/components/screens/EarnScreen/hooks/useEarnTokens.ts diff --git a/__tests__/components/screens/EarnScreen/useEarnTokens.test.ts b/__tests__/components/screens/EarnScreen/useEarnTokens.test.ts new file mode 100644 index 000000000..2d9054607 --- /dev/null +++ b/__tests__/components/screens/EarnScreen/useEarnTokens.test.ts @@ -0,0 +1,142 @@ +import BigNumber from "bignumber.js"; +import { + buildEarnTokenRows, + headlineApy, +} from "components/screens/EarnScreen/hooks/useEarnTokens"; +import { NETWORKS } from "config/constants"; + +const POOL_ID = "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD"; +const USDC_SAC = "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75"; + +describe("headlineApy", () => { + it("adds emissions to the supply rate", () => { + expect(headlineApy(0.05, 0.02)).toBeCloseTo(0.07); + }); + + it("is unknown when the supply rate is unavailable", () => { + // No fresh oracle price means the whole rate is unknown. + expect(headlineApy(null, 0.02)).toBeNull(); + }); + + it("treats unpriceable emissions as zero rather than blanking a known rate", () => { + expect(headlineApy(0.05, null)).toBeCloseTo(0.05); + }); +}); + +describe("buildEarnTokenRows", () => { + const networkDetails = { network: NETWORKS.PUBLIC } as never; + + const option = (overrides = {}) => ({ + assetId: USDC_SAC, + symbol: "USDC", + name: null, + decimals: 7, + pools: [ + { + id: POOL_ID, + name: "Fixed", + supplyApy: 0.05, + emissionsSupplyApr: null, + suppliedUsd: 0, + }, + ], + ...overrides, + }); + + it("puts a held asset in held and a zero-balance asset in supported", () => { + const balances = { + "USDC:GISSUER": { + total: new BigNumber("50"), + token: { code: "USDC", issuer: { key: "GISSUER" } }, + }, + } as never; + + const { held, supported } = buildEarnTokenRows({ + options: [option()], + poolId: POOL_ID, + balances, + networkDetails, + findBalance: () => balances["USDC:GISSUER"], + }); + + expect(held).toHaveLength(1); + expect(held[0].total).toBe("50"); + expect(supported).toHaveLength(0); + }); + + it("skips assets the allowlisted pool does not offer", () => { + const { held, supported } = buildEarnTokenRows({ + options: [option({ pools: [] })], + poolId: POOL_ID, + balances: {} as never, + networkDetails, + findBalance: () => undefined, + }); + + expect(held).toHaveLength(0); + expect(supported).toHaveLength(0); + }); + + it("falls back through symbol, then balance code, then a truncated id", () => { + // The live catalog returns symbol AND name null for native XLM, so symbol + // cannot be the only source of the display code. + const { supported } = buildEarnTokenRows({ + options: [option({ symbol: null, name: null })], + poolId: POOL_ID, + balances: {} as never, + networkDetails, + findBalance: () => undefined, + }); + + expect(supported[0].code).toBe(`${USDC_SAC.slice(0, 4)}…`); + }); + + it("defaults decimals when the catalog omits them", () => { + const { supported } = buildEarnTokenRows({ + options: [option({ decimals: null })], + poolId: POOL_ID, + balances: {} as never, + networkDetails, + findBalance: () => undefined, + }); + + expect(supported[0].decimals).toBe(7); + }); + + it("flags the reserve whose contract address is the network's native SAC", () => { + // getBalanceByContractId's native trap applies here too: native must be + // recognised by contract-address comparison, never by `code === "XLM"`. + // Live TESTNET catalog data confirms native XLM's assetId equals the + // derived native SAC (verified separately against getNativeContractDetails). + const nativeAssetId = + "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC"; + + const { supported } = buildEarnTokenRows({ + options: [ + option({ + assetId: nativeAssetId, + symbol: null, + name: null, + }), + ], + poolId: POOL_ID, + balances: {} as never, + networkDetails: { network: NETWORKS.TESTNET } as never, + findBalance: () => undefined, + }); + + expect(supported[0].isNative).toBe(true); + }); + + it("does not flag a non-native reserve as native", () => { + const { supported } = buildEarnTokenRows({ + options: [option()], + poolId: POOL_ID, + balances: {} as never, + networkDetails, + findBalance: () => undefined, + }); + + expect(supported[0].isNative).toBe(false); + }); +}); diff --git a/src/components/screens/EarnScreen/components/EarnTokenRow.tsx b/src/components/screens/EarnScreen/components/EarnTokenRow.tsx new file mode 100644 index 000000000..ff16e6197 --- /dev/null +++ b/src/components/screens/EarnScreen/components/EarnTokenRow.tsx @@ -0,0 +1,73 @@ +import BigNumber from "bignumber.js"; +import { TokenIcon } from "components/TokenIcon"; +import { formatRate } from "components/screens/EarnScreen/helpers"; +import { EarnTokenOption } from "components/screens/EarnScreen/hooks/useEarnTokens"; +import { Badge } from "components/sds/Badge"; +import { Text } from "components/sds/Typography"; +import { NATIVE_TOKEN_CODE } from "config/constants"; +import { Balance, Token } from "config/types"; +import { formatTokenForDisplay } from "helpers/formatAmount"; +import React from "react"; +import { TouchableOpacity, View } from "react-native"; + +export interface EarnTokenRowProps { + option: EarnTokenOption; + onPress: () => void; + /** Forwarded to the row's TouchableOpacity so e2e flows can tap a specific token. */ + testID?: string; +} + +/** + * Single row for the Earn token picker: icon + code + held total on the + * left, the pool's headline APY badge on the right. + * + * Modeled on `SwapTokenRow` — same layout classes and press handling — but + * simpler: Earn has no context menu / security-scan variants to switch on. + */ +export const EarnTokenRow: React.FC = ({ + option, + onPress, + testID, +}) => { + const isHeld = new BigNumber(option.total).gt(0); + + // Prefer the real held balance's token shape (correctly discriminates + // native vs. classic vs. Soroban already). For a zero-balance row there is + // no balance to read from, so reconstruct a minimal token: native XLM by + // `isNative` (never by code, per the trap this hook's `buildEarnTokenRows` + // already guards against), otherwise a generic token keyed by the reserve's + // own contract address. + const token: Token | Balance = + option.balance ?? + (option.isNative + ? { type: "native" as const, code: NATIVE_TOKEN_CODE as "XLM" } + : { code: option.code, issuer: { key: option.assetId } }); + + return ( + + + + + + {option.code} + + {isHeld && ( + + {formatTokenForDisplay(option.total, option.code)} + + )} + + + + {formatRate(option.apy)} + + + ); +}; + +export default EarnTokenRow; diff --git a/src/components/screens/EarnScreen/hooks/useEarnTokens.ts b/src/components/screens/EarnScreen/hooks/useEarnTokens.ts new file mode 100644 index 000000000..27c040968 --- /dev/null +++ b/src/components/screens/EarnScreen/hooks/useEarnTokens.ts @@ -0,0 +1,198 @@ +import BigNumber from "bignumber.js"; +import { getBlendPoolId } from "config/blend"; +import { BlendCatalogPool, BlendEarnAssetOption } from "config/blendTypes"; +import { + DEFAULT_DECIMALS, + mapNetworkToNetworkDetails, + NetworkDetails, +} from "config/constants"; +import { logger } from "config/logger"; +import { PricedBalance, PricedBalanceMap } from "config/types"; +import { useAuthenticationStore } from "ducks/auth"; +import { useBalancesStore } from "ducks/balances"; +import { useEarnStore } from "ducks/earn"; +import { getBalanceByContractId } from "helpers/balances"; +import { getNativeContractDetails } from "helpers/soroban"; +import useGetActiveAccount from "hooks/useGetActiveAccount"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { getBlendEarnOptions, getBlendPools } from "services/blend"; + +/** One row in the picker. */ +export interface EarnTokenOption { + /** The reserve's asset contract address (a SAC for every current reserve). */ + assetId: string; + code: string; + 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; + /** + * True when this reserve's contract address is the network's native SAC. + * Decided by comparing `assetId` to the derived native contract address — + * NEVER by `code === "XLM"` — because any issuer can mint a classic asset + * coded "XLM" (the same trap `getBalanceByContractId` guards against). + * Lets `EarnTokenRow` build the right `TokenIcon` input without a held + * balance to read the real token shape from. + */ + isNative: boolean; + /** + * The held balance backing this row, when any. Carries the real Token + * shape so the row renders the exact icon instead of reconstructing one + * from catalog data alone. Undefined for zero-balance rows. + */ + balance?: PricedBalance; +} + +/** + * 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. + */ +export const headlineApy = ( + supplyApy: number | null, + emissionsSupplyApr: number | null, +) => (supplyApy === null ? null : supplyApy + (emissionsSupplyApr ?? 0)); + +/** + * Partitions the catalog into held and zero-balance rows. + * + * Exported and dependency-injected (`findBalance`) so the partitioning rules + * can be tested without a React tree or a network. + */ +export const buildEarnTokenRows = ({ + options, + poolId, + balances, + networkDetails, + findBalance = getBalanceByContractId, +}: { + options: BlendEarnAssetOption[]; + poolId: string; + balances: PricedBalanceMap; + networkDetails: NetworkDetails; + findBalance?: ( + contractId: string, + balances: PricedBalanceMap, + networkDetails: NetworkDetails, + ) => PricedBalance | undefined; +}) => { + const held: EarnTokenOption[] = []; + const supported: EarnTokenOption[] = []; + + const nativeContractId = getNativeContractDetails( + networkDetails.network, + ).contract; + + options.forEach((option) => { + const offer = option.pools.find((pool) => pool.id === poolId); + if (!offer) { + return; + } + + const balance = findBalance(option.assetId, balances, networkDetails); + const total = balance?.total ? new BigNumber(balance.total) : null; + + // `symbol` is null for native XLM on the live catalog, so it cannot be the + // only source of the display code — taking it alone renders the row with no + // token code at all. `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 : ""; + const code = + option.symbol || balanceCode || `${option.assetId.slice(0, 4)}…`; + + const row: EarnTokenOption = { + assetId: option.assetId, + code, + decimals: option.decimals ?? DEFAULT_DECIMALS, + total: total ? total.toFixed() : "0", + apy: headlineApy(offer.supplyApy, offer.emissionsSupplyApr), + poolId: offer.id, + isNative: option.assetId === nativeContractId, + balance, + }; + + if (total && total.gt(0)) { + held.push(row); + } else { + supported.push(row); + } + }); + + return { held, supported }; +}; + +export const useEarnTokens = () => { + const { account } = useGetActiveAccount(); + const { network } = useAuthenticationStore(); + const { pricedBalances } = useBalancesStore(); + const setPool = useEarnStore((state) => state.setPool); + + const networkDetails = useMemo( + () => mapNetworkToNetworkDetails(network), + [network], + ); + + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [held, setHeld] = useState([]); + const [supported, setSupported] = useState([]); + const [pool, setLocalPool] = useState(null); + + const fetchData = useCallback(async () => { + setIsLoading(true); + setError(null); + + try { + 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); + if (!poolId) { + // Defensive only: Home gates the Earn entry point on + // isEarnSupportedNetwork, so this should be unreachable in practice. + throw new Error("Earn is not supported on this network"); + } + + const resolvedPool = pools.find((p) => p.id === poolId) || null; + + const rows = buildEarnTokenRows({ + options: earnOptions, + poolId, + balances: pricedBalances, + networkDetails, + }); + + setHeld(rows.held); + setSupported(rows.supported); + setLocalPool(resolvedPool); + setPool(resolvedPool); + } catch (err) { + logger.error("useEarnTokens", "Failed to fetch earn tokens", err); + setError(err instanceof Error ? err.message : String(err)); + } finally { + setIsLoading(false); + } + }, [pricedBalances, networkDetails, setPool]); + + useEffect(() => { + if (account?.publicKey && network) { + fetchData(); + } + }, [account?.publicKey, network, fetchData]); + + return { isLoading, error, held, supported, pool, refetch: fetchData }; +}; diff --git a/src/components/screens/EarnScreen/screens/EarnTokenPickerScreen.tsx b/src/components/screens/EarnScreen/screens/EarnTokenPickerScreen.tsx index 781921b46..4d97c6998 100644 --- a/src/components/screens/EarnScreen/screens/EarnTokenPickerScreen.tsx +++ b/src/components/screens/EarnScreen/screens/EarnTokenPickerScreen.tsx @@ -1,16 +1,156 @@ +import { NativeStackScreenProps } from "@react-navigation/native-stack"; +import Spinner from "components/Spinner"; import { BaseLayout } from "components/layout/BaseLayout"; +import { EarnTokenRow } from "components/screens/EarnScreen/components/EarnTokenRow"; +import { + EarnTokenOption, + useEarnTokens, +} from "components/screens/EarnScreen/hooks/useEarnTokens"; +import { Button } from "components/sds/Button"; +import Icon from "components/sds/Icon"; import { Text } from "components/sds/Typography"; -import React from "react"; +import { EARN_ROUTES, EarnStackParamList } from "config/routes"; +import { useEarnStore } from "ducks/earn"; +import useAppTranslation from "hooks/useAppTranslation"; +import { useRightHeaderButton } from "hooks/useRightHeader"; +import React, { useCallback } from "react"; +import { SectionList, View } from "react-native"; + +type EarnTokenPickerScreenProps = NativeStackScreenProps< + EarnStackParamList, + typeof EARN_ROUTES.EARN_TOKEN_PICKER_SCREEN +>; + +interface EarnTokenSection { + kind: "held" | "supported"; + title: string; + data: EarnTokenOption[]; +} /** - * Placeholder for the Earn token picker. Replaced wholesale in Task 9; it - * exists so the navigator compiles and the Home entry point can be verified - * in isolation. + * Zero-balance rows aren't deposit-ready — tapping one should open the + * "not enough" sheet Task 11 builds. Left named and empty here so the row's + * press target and styling are correct in the meantime. */ -export const EarnTokenPickerScreen = () => ( - - Earn - -); +const handleUnheldTokenPress = () => {}; + +export const EarnTokenPickerScreen: React.FC = ({ + navigation, +}) => { + const { t } = useAppTranslation(); + const { isLoading, error, held, supported, refetch } = useEarnTokens(); + const selectAsset = useEarnStore((state) => state.selectAsset); + + /** + * Opens the pool details sheet — Task 10 builds the sheet itself. Wired to + * the header now (the trigger is a one-line hook call) so Task 10 only has + * to fill in the body. + */ + const handlePoolInfoPress = useCallback(() => {}, []); + + useRightHeaderButton({ + onPress: handlePoolInfoPress, + icon: Icon.InfoCircle, + }); + + const handleHeldTokenPress = useCallback( + (option: EarnTokenOption) => { + selectAsset({ + assetId: option.assetId, + apy: option.apy, + code: option.code, + decimals: option.decimals, + }); + navigation.navigate(EARN_ROUTES.EARN_AMOUNT_SCREEN, { + assetId: option.assetId, + tokenCode: option.code, + }); + }, + [selectAsset, navigation], + ); + + if (isLoading) { + return ( + + + + + + ); + } + + // Blend's routes are not deployed to staging or production yet — this is + // the flow's most-exercised path in the real app today. It must read as a + // failure to load, never as "no tokens available" (which would be + // actively misleading about why the list is empty). + if (error) { + return ( + + + + {t("earnTokenPicker.error.title")} + + + + {t("earnTokenPicker.error.body")} + + + + + + ); + } + + const sections: EarnTokenSection[] = [ + { + kind: "held" as const, + title: t("earnTokenPicker.inYourAccount"), + data: held, + }, + { + kind: "supported" as const, + title: t("earnTokenPicker.supportedTokens"), + data: supported, + }, + ].filter((section) => section.data.length > 0); + + return ( + + + sections={sections} + keyExtractor={(item) => item.assetId} + showsVerticalScrollIndicator={false} + stickySectionHeadersEnabled={false} + renderSectionHeader={({ section }) => ( + + + {section.title} + + + )} + renderItem={({ item, section }) => ( + + section.kind === "held" + ? handleHeldTokenPress(item) + : handleUnheldTokenPress() + } + /> + )} + ListFooterComponent={ + + + {t("earnTokenPicker.apyDisclaimer")} + + + } + /> + + ); +}; export default EarnTokenPickerScreen; diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index 82f2a4b07..3aa486789 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -691,7 +691,15 @@ "swapped": "Swapped" }, "earnTokenPicker": { - "title": "Choose a token" + "title": "Choose a token", + "inYourAccount": "In your account", + "supportedTokens": "Supported tokens", + "apyDisclaimer": "*APY is an estimate and may change based on pool activity.", + "error": { + "title": "We couldn't load Earn right now", + "body": "Please check your connection and try again.", + "retry": "Try again" + } }, "transactionTokenScreen": { "title": "Sending", diff --git a/src/i18n/locales/pt/translations.json b/src/i18n/locales/pt/translations.json index 294aa26fb..67e46dfce 100644 --- a/src/i18n/locales/pt/translations.json +++ b/src/i18n/locales/pt/translations.json @@ -657,7 +657,15 @@ "swapped": "Trocado" }, "earnTokenPicker": { - "title": "Escolha um token" + "title": "Escolha um token", + "inYourAccount": "Na sua conta", + "supportedTokens": "Tokens suportados", + "apyDisclaimer": "*A APY é uma estimativa e pode mudar de acordo com a atividade do pool.", + "error": { + "title": "Não foi possível carregar o Earn agora", + "body": "Verifique sua conexão e tente novamente.", + "retry": "Tentar novamente" + } }, "transactionTokenScreen": { "title": "Enviando", From b4bc84b9d817e82481e6981ae192c781857058d4 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Thu, 20 Aug 2026 08:36:50 -0600 Subject: [PATCH 10/27] feat(earn): add the pool details sheet --- .../PoolDetailsBottomSheet.test.tsx | 126 +++++++++++++++ .../components/PoolDetailsBottomSheet.tsx | 143 ++++++++++++++++++ .../screens/EarnTokenPickerScreen.tsx | 94 +++++++----- src/i18n/locales/en/translations.json | 10 ++ src/i18n/locales/pt/translations.json | 10 ++ 5 files changed, 347 insertions(+), 36 deletions(-) create mode 100644 __tests__/components/screens/EarnScreen/components/PoolDetailsBottomSheet.test.tsx create mode 100644 src/components/screens/EarnScreen/components/PoolDetailsBottomSheet.tsx diff --git a/__tests__/components/screens/EarnScreen/components/PoolDetailsBottomSheet.test.tsx b/__tests__/components/screens/EarnScreen/components/PoolDetailsBottomSheet.test.tsx new file mode 100644 index 000000000..cbf0db11b --- /dev/null +++ b/__tests__/components/screens/EarnScreen/components/PoolDetailsBottomSheet.test.tsx @@ -0,0 +1,126 @@ +import { BottomSheetModal } from "@gorhom/bottom-sheet"; +import { fireEvent } from "@testing-library/react-native"; +import { PoolDetailsBottomSheet } from "components/screens/EarnScreen/components/PoolDetailsBottomSheet"; +import { BlendCatalogPool } from "config/blendTypes"; +import { renderWithProviders } from "helpers/testUtils"; +import React from "react"; + +// A pool with nulls across every unpriced field — the COMMON case per the +// live backend, not an edge case. `borrowedUsd` is a genuine zero here to +// prove null and zero render differently in the same row set. +const nullHeavyPool: BlendCatalogPool = { + id: "CUNKNOWNPOOL", + name: "YieldBlox Pool v2 model", + status: "ADMIN_ACTIVE", + suppliedUsd: null, + borrowedUsd: 0, + interestApy: null, + netApy: null, + backstopUsd: null, + reserves: [], +}; + +// The Fixed pool (testnet contract id) — has a description entry and fully +// priced figures, including a supply-side rate that legitimately exceeds 1 +// (a decimal fraction, not a pre-multiplied percentage). +const fixedPool: BlendCatalogPool = { + id: "CCEBVDYM32YNYCVNRXQKDFFPISJJCV557CDZEIRBEE4NCV4KHPQ44HGF", + name: "TestnetV2", + status: "ADMIN_ACTIVE", + suppliedUsd: 40385476.30883376, + borrowedUsd: 35717534.91620809, + interestApy: 3.9199825260259216, + netApy: 3.9199825260259216, + backstopUsd: null, + reserves: [], +}; + +describe("PoolDetailsBottomSheet", () => { + it("renders nothing when the pool hasn't resolved yet", () => { + const { queryByTestId } = renderWithProviders( + , + ); + expect(queryByTestId("pool-details-close")).toBeNull(); + }); + + it("renders '--' for every unpriced figure and a real zero as $0.00, distinctly", () => { + const { getByTestId } = renderWithProviders( + , + ); + expect(getByTestId("pool-details-lendingInterest")).toHaveTextContent("--"); + expect(getByTestId("pool-details-currentNetApy")).toHaveTextContent("--"); + expect(getByTestId("pool-details-totalSupplied")).toHaveTextContent("--"); + // A genuine zero must never collapse into the same "--" as null. + expect(getByTestId("pool-details-totalBorrowed")).toHaveTextContent( + "$0.00", + ); + }); + + it("renders no description for a pool with no catalog entry", () => { + const { queryByTestId } = renderWithProviders( + , + ); + expect(queryByTestId("pool-details-description")).toBeNull(); + }); + + it("renders the pool name and formatted figures for a fully-priced pool", () => { + const { getByText, getByTestId } = renderWithProviders( + , + ); + expect(getByText("TestnetV2")).toBeTruthy(); + expect(getByTestId("pool-details-lendingInterest")).toHaveTextContent( + "392.00%", + ); + expect(getByTestId("pool-details-currentNetApy")).toHaveTextContent( + "392.00%", + ); + expect(getByTestId("pool-details-totalSupplied")).toHaveTextContent( + "$40.39M", + ); + expect(getByTestId("pool-details-totalBorrowed")).toHaveTextContent( + "$35.72M", + ); + }); + + it("renders the pool's own name as the prominent heading, with no generic eyebrow label above it", () => { + // Pins the ProtocolDetailsBottomSheet-style hierarchy: the identity + // content the user opened the sheet for (the pool name) takes the + // prominent slot directly, with no "Pool details" eyebrow inverting it. + const { getByText, queryByText } = renderWithProviders( + , + ); + expect(getByText("TestnetV2").props.children).toBe("TestnetV2"); + expect(queryByText("Pool details")).toBeNull(); + }); + + it("renders the fixed-pool description for a pool id present in the catalog", () => { + const { getByTestId } = renderWithProviders( + , + ); + expect(getByTestId("pool-details-description")).toHaveTextContent( + /Permissionless lending pool with no admin/i, + ); + }); + + it("never renders a Backstop row — the backend doesn't serve that figure yet", () => { + const { queryByText } = renderWithProviders( + , + ); + expect(queryByText(/backstop/i)).toBeNull(); + }); + + it("the close button dismisses the sheet via the forwarded ref", () => { + const dismissMock = jest.fn(); + const ref = React.createRef(); + Object.defineProperty(ref, "current", { + value: { dismiss: dismissMock }, + writable: true, + }); + + const { getByTestId } = renderWithProviders( + , + ); + fireEvent.press(getByTestId("pool-details-close")); + expect(dismissMock).toHaveBeenCalled(); + }); +}); diff --git a/src/components/screens/EarnScreen/components/PoolDetailsBottomSheet.tsx b/src/components/screens/EarnScreen/components/PoolDetailsBottomSheet.tsx new file mode 100644 index 000000000..16fc84d9d --- /dev/null +++ b/src/components/screens/EarnScreen/components/PoolDetailsBottomSheet.tsx @@ -0,0 +1,143 @@ +import { BottomSheetModal } from "@gorhom/bottom-sheet"; +import { + formatCompactUsd, + formatRate, + getPoolDescriptionKey, +} from "components/screens/EarnScreen/helpers"; +import Icon from "components/sds/Icon"; +import { Text } from "components/sds/Typography"; +import { BlendCatalogPool } from "config/blendTypes"; +import useAppTranslation from "hooks/useAppTranslation"; +import useColors from "hooks/useColors"; +import React from "react"; +import { TouchableOpacity, View } from "react-native"; + +export interface PoolDetailsBottomSheetProps { + pool: BlendCatalogPool | null; + bottomSheetModalRef?: React.RefObject; +} + +interface PoolDetailRow { + key: string; + label: string; + value: string; +} + +/** + * Content for the Earn pool-details sheet, presented from + * `EarnTokenPickerScreen`'s header info button. + * + * Modeled on `XlmReserveBottomSheet`: pure content (the caller wraps it in + * `components/BottomSheet` and owns the modal ref); `bottomSheetModalRef` is + * used here only to dismiss via the close button. + * + * A Backstop figure is deliberately never rendered: the backend does not + * serve `backstop_usd` for any pool yet (confirmed against the live + * catalog), so there is nothing honest to show for it — not even "--", + * which would misrepresent an omitted field as a checked-and-empty one. + */ +export const PoolDetailsBottomSheet: React.FC = ({ + pool, + bottomSheetModalRef, +}) => { + const { t } = useAppTranslation(); + const { themeColors } = useColors(); + + const handleClose = () => { + bottomSheetModalRef?.current?.dismiss(); + }; + + // Guards a race where the sheet is presented before (or after) the pool + // has resolved — e.g. a stale ref call while useEarnTokens is still + // loading. The wrapping BottomSheet keeps working; there's simply nothing + // to render here. + if (!pool) { + return null; + } + + // Null and undefined both mean "unavailable" to the UI (see + // BlendCatalogPool's own `backstopUsd` comment) — but the row itself is + // never rendered at all, so that distinction doesn't even reach here. + // + // getPoolDescriptionKey's return is deliberately widened to `string | null` + // (Task 7) so the pool-id map can grow without narrowing callers — but that + // means it can't type-check against i18next's literal key union the way a + // hardcoded key can. The cast below is scoped to exactly this one dynamic + // lookup; every other `t(...)` call in this file uses a literal key and is + // fully type-checked. + const descriptionKey = getPoolDescriptionKey(pool.id); + const description = descriptionKey ? t(descriptionKey as never) : null; + + const rows: PoolDetailRow[] = [ + { + key: "lendingInterest", + label: t("earnPoolDetails.lendingInterest"), + value: formatRate(pool.interestApy), + }, + { + key: "currentNetApy", + label: t("earnPoolDetails.currentNetApy"), + value: formatRate(pool.netApy), + }, + { + key: "totalSupplied", + label: t("earnPoolDetails.totalSupplied"), + value: formatCompactUsd(pool.suppliedUsd), + }, + { + key: "totalBorrowed", + label: t("earnPoolDetails.totalBorrowed"), + value: formatCompactUsd(pool.borrowedUsd), + }, + ]; + + return ( + + + + + + + + + + {pool.name && ( + + {pool.name} + + )} + {description && ( + + {description} + + )} + + + + {rows.map((row, index) => ( + + + + {row.label} + + + {row.value} + + + {index < rows.length - 1 && ( + + )} + + ))} + + + ); +}; + +export default PoolDetailsBottomSheet; diff --git a/src/components/screens/EarnScreen/screens/EarnTokenPickerScreen.tsx b/src/components/screens/EarnScreen/screens/EarnTokenPickerScreen.tsx index 4d97c6998..3bc5e47ce 100644 --- a/src/components/screens/EarnScreen/screens/EarnTokenPickerScreen.tsx +++ b/src/components/screens/EarnScreen/screens/EarnTokenPickerScreen.tsx @@ -1,7 +1,10 @@ +import { BottomSheetModal } from "@gorhom/bottom-sheet"; import { NativeStackScreenProps } from "@react-navigation/native-stack"; +import BottomSheet from "components/BottomSheet"; import Spinner from "components/Spinner"; import { BaseLayout } from "components/layout/BaseLayout"; import { EarnTokenRow } from "components/screens/EarnScreen/components/EarnTokenRow"; +import { PoolDetailsBottomSheet } from "components/screens/EarnScreen/components/PoolDetailsBottomSheet"; import { EarnTokenOption, useEarnTokens, @@ -13,7 +16,7 @@ import { EARN_ROUTES, EarnStackParamList } from "config/routes"; import { useEarnStore } from "ducks/earn"; import useAppTranslation from "hooks/useAppTranslation"; import { useRightHeaderButton } from "hooks/useRightHeader"; -import React, { useCallback } from "react"; +import React, { useCallback, useRef } from "react"; import { SectionList, View } from "react-native"; type EarnTokenPickerScreenProps = NativeStackScreenProps< @@ -38,15 +41,20 @@ export const EarnTokenPickerScreen: React.FC = ({ navigation, }) => { const { t } = useAppTranslation(); - const { isLoading, error, held, supported, refetch } = useEarnTokens(); + const { isLoading, error, held, supported, pool, refetch } = useEarnTokens(); const selectAsset = useEarnStore((state) => state.selectAsset); + const poolDetailsBottomSheetModalRef = useRef(null); /** - * Opens the pool details sheet — Task 10 builds the sheet itself. Wired to - * the header now (the trigger is a one-line hook call) so Task 10 only has - * to fill in the body. + * Opens the pool details sheet. Guarded on `pool` because the header + * button is wired up unconditionally (even while useEarnTokens is still + * loading or has errored) — there is nothing to present until the pool + * has resolved. */ - const handlePoolInfoPress = useCallback(() => {}, []); + const handlePoolInfoPress = useCallback(() => { + if (!pool) return; + poolDetailsBottomSheetModalRef.current?.present(); + }, [pool]); useRightHeaderButton({ onPress: handlePoolInfoPress, @@ -117,39 +125,53 @@ export const EarnTokenPickerScreen: React.FC = ({ ].filter((section) => section.data.length > 0); return ( - - - sections={sections} - keyExtractor={(item) => item.assetId} - showsVerticalScrollIndicator={false} - stickySectionHeadersEnabled={false} - renderSectionHeader={({ section }) => ( - - - {section.title} - - - )} - renderItem={({ item, section }) => ( - - section.kind === "held" - ? handleHeldTokenPress(item) - : handleUnheldTokenPress() - } + <> + + + sections={sections} + keyExtractor={(item) => item.assetId} + showsVerticalScrollIndicator={false} + stickySectionHeadersEnabled={false} + renderSectionHeader={({ section }) => ( + + + {section.title} + + + )} + renderItem={({ item, section }) => ( + + section.kind === "held" + ? handleHeldTokenPress(item) + : handleUnheldTokenPress() + } + /> + )} + ListFooterComponent={ + + + {t("earnTokenPicker.apyDisclaimer")} + + + } + /> + + + poolDetailsBottomSheetModalRef.current?.dismiss() + } + customContent={ + - )} - ListFooterComponent={ - - - {t("earnTokenPicker.apyDisclaimer")} - - } /> - + ); }; diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index 3aa486789..7f3eaf206 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -701,6 +701,16 @@ "retry": "Try again" } }, + "earnPoolDetails": { + "title": "Pool details", + "lendingInterest": "Lending interest", + "currentNetApy": "Current net APY", + "totalSupplied": "Total supplied", + "totalBorrowed": "Total borrowed", + "descriptions": { + "fixedPool": "Permissionless lending pool with no admin. Collateral factors, interest curves, and supported assets are locked at deployment." + } + }, "transactionTokenScreen": { "title": "Sending", "empty": "No tokens or collectibles to send" diff --git a/src/i18n/locales/pt/translations.json b/src/i18n/locales/pt/translations.json index 67e46dfce..9944a1bca 100644 --- a/src/i18n/locales/pt/translations.json +++ b/src/i18n/locales/pt/translations.json @@ -667,6 +667,16 @@ "retry": "Tentar novamente" } }, + "earnPoolDetails": { + "title": "Detalhes do pool", + "lendingInterest": "Juros de empréstimo", + "currentNetApy": "APY líquido atual", + "totalSupplied": "Total fornecido", + "totalBorrowed": "Total emprestado", + "descriptions": { + "fixedPool": "Pool de empréstimo sem permissão e sem administrador. Os fatores de garantia, as curvas de juros e os ativos suportados são fixados no momento da implantação." + } + }, "transactionTokenScreen": { "title": "Enviando", "empty": "Nenhum token ou colecionável para enviar" From 59ac45741186bad9edf61eccb486010b8754fe03 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Thu, 20 Aug 2026 09:31:04 -0600 Subject: [PATCH 11/27] feat(earn): add the deposit amount screen --- .../EarnScreen/useSimulateEarnDeposit.test.ts | 149 ++++++ __tests__/navigators/EarnNavigator.test.tsx | 77 +++ .../components/NotEnoughTokenBottomSheet.tsx | 104 ++++ .../hooks/useSimulateEarnDeposit.ts | 112 +++++ .../EarnScreen/screens/EarnAmountScreen.tsx | 451 ++++++++++++++++++ .../screens/EarnTokenPickerScreen.tsx | 109 ++++- .../screens/EarnScreen/screens/index.ts | 1 + src/i18n/locales/en/translations.json | 24 + src/i18n/locales/pt/translations.json | 24 + src/navigators/EarnNavigator.tsx | 34 +- 10 files changed, 1072 insertions(+), 13 deletions(-) create mode 100644 __tests__/components/screens/EarnScreen/useSimulateEarnDeposit.test.ts create mode 100644 __tests__/navigators/EarnNavigator.test.tsx create mode 100644 src/components/screens/EarnScreen/components/NotEnoughTokenBottomSheet.tsx create mode 100644 src/components/screens/EarnScreen/hooks/useSimulateEarnDeposit.ts create mode 100644 src/components/screens/EarnScreen/screens/EarnAmountScreen.tsx diff --git a/__tests__/components/screens/EarnScreen/useSimulateEarnDeposit.test.ts b/__tests__/components/screens/EarnScreen/useSimulateEarnDeposit.test.ts new file mode 100644 index 000000000..2caf7262f --- /dev/null +++ b/__tests__/components/screens/EarnScreen/useSimulateEarnDeposit.test.ts @@ -0,0 +1,149 @@ +/* eslint-disable @fnando/consistent-import/consistent-import */ +import { renderHook, act } from "@testing-library/react-hooks"; +import { useSimulateEarnDeposit } from "components/screens/EarnScreen/hooks/useSimulateEarnDeposit"; +import { NETWORKS } from "config/constants"; + +const mockBuildBlendDepositTransaction = jest.fn(); +const mockScanTransaction = jest.fn(); +const mockGetBuilderState = jest.fn(); + +jest.mock("ducks/transactionBuilder", () => ({ + // The state object is built fresh on every call (not once at module-require + // time), so it always reflects the current mock fns — matching how the + // real zustand hook re-reads live state on every render. + useTransactionBuilderStore: Object.assign( + (selector?: (state: Record) => unknown) => { + const state = { + buildBlendDepositTransaction: mockBuildBlendDepositTransaction, + }; + return selector ? selector(state) : state; + }, + { + getState: () => mockGetBuilderState(), + }, + ), +})); + +jest.mock("hooks/blockaid/useBlockaidTransaction", () => ({ + useBlockaidTransaction: () => ({ scanTransaction: mockScanTransaction }), +})); + +jest.mock("hooks/useAppTranslation", () => ({ + __esModule: true, + default: () => ({ + t: (key: string) => key, + }), +})); + +const baseParams: Parameters< + ReturnType["simulate"] +>[0] = { + assetId: "CASSET", + amount: "10", + decimals: 7, + transactionFee: "0.00001", + transactionTimeout: 30, + network: NETWORKS.TESTNET, + senderAddress: "GSENDER", +}; + +describe("useSimulateEarnDeposit", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetBuilderState.mockReturnValue({ error: null }); + }); + + it("returns the prepared XDR and scan result on a clean success", async () => { + mockBuildBlendDepositTransaction.mockResolvedValue("prepared-xdr"); + mockScanTransaction.mockResolvedValue({ scanned: true }); + + const { result } = renderHook(() => useSimulateEarnDeposit()); + + let outcome; + await act(async () => { + outcome = await result.current.simulate(baseParams); + }); + + expect(outcome).toEqual({ + preparedXdr: "prepared-xdr", + scanResult: { scanned: true }, + }); + expect(result.current.error).toBeNull(); + expect(result.current.isSimulating).toBe(false); + expect(mockScanTransaction).toHaveBeenCalledWith( + "prepared-xdr", + "internal", + ); + }); + + // This is the load-bearing regression test: `scanTransaction` throws + // NETWORK_NOT_SUPPORTED on any non-mainnet network (i.e. every TESTNET + // deposit). The scan failure must degrade to "unable to scan" + // (`scanResult: undefined`) and must NOT fail the simulation itself — + // otherwise every testnet deposit would report "simulation failed" when + // only the security scan was unavailable. + it("does not fail the simulation when the Blockaid scan throws (e.g. testnet)", async () => { + mockBuildBlendDepositTransaction.mockResolvedValue("prepared-xdr"); + mockScanTransaction.mockRejectedValue(new Error("NETWORK_NOT_SUPPORTED")); + + const { result } = renderHook(() => useSimulateEarnDeposit()); + + let outcome; + await act(async () => { + outcome = await result.current.simulate(baseParams); + }); + + expect(outcome).toEqual({ + preparedXdr: "prepared-xdr", + scanResult: undefined, + }); + expect(result.current.error).toBeNull(); + expect(result.current.scanResult).toBeUndefined(); + }); + + it("surfaces the builder store's own error message when the build fails", async () => { + mockBuildBlendDepositTransaction.mockResolvedValue(null); + mockGetBuilderState.mockReturnValue({ + error: "Supply cap exceeded for this reserve", + }); + + const { result } = renderHook(() => useSimulateEarnDeposit()); + + let outcome; + await act(async () => { + outcome = await result.current.simulate(baseParams); + }); + + expect(outcome).toBeNull(); + expect(result.current.error).toBe("Supply cap exceeded for this reserve"); + expect(mockScanTransaction).not.toHaveBeenCalled(); + }); + + it("falls back to a generic message when the build fails with no stored error", async () => { + mockBuildBlendDepositTransaction.mockResolvedValue(null); + mockGetBuilderState.mockReturnValue({ error: null }); + + const { result } = renderHook(() => useSimulateEarnDeposit()); + + let outcome; + await act(async () => { + outcome = await result.current.simulate(baseParams); + }); + + expect(outcome).toBeNull(); + expect(result.current.error).toBe("earnAmount.errors.simulationFailed"); + }); + + it("resets isSimulating to false after settling", async () => { + mockBuildBlendDepositTransaction.mockResolvedValue("prepared-xdr"); + mockScanTransaction.mockResolvedValue(undefined); + + const { result } = renderHook(() => useSimulateEarnDeposit()); + + await act(async () => { + await result.current.simulate(baseParams); + }); + + expect(result.current.isSimulating).toBe(false); + }); +}); diff --git a/__tests__/navigators/EarnNavigator.test.tsx b/__tests__/navigators/EarnNavigator.test.tsx new file mode 100644 index 000000000..bad2b6cb0 --- /dev/null +++ b/__tests__/navigators/EarnNavigator.test.tsx @@ -0,0 +1,77 @@ +/* eslint-disable @fnando/consistent-import/consistent-import */ +import { NavigationContainer } from "@react-navigation/native"; +import { act } from "@testing-library/react-native"; +import { MIN_TRANSACTION_FEE } from "config/constants"; +import { useTransactionBuilderStore } from "ducks/transactionBuilder"; +import { useTransactionSettingsStore } from "ducks/transactionSettings"; +import { renderWithProviders } from "helpers/testUtils"; +import { EarnStackNavigator } from "navigators/EarnNavigator"; +import React from "react"; + +const mockClearNetworkFeesCache = jest.fn(); + +// The real hook fetches network fees over the network — stub it out so the +// test only exercises the navigator's own mount/unmount wiring, and so we +// can assert on `clearNetworkFeesCache` directly. +jest.mock("hooks/useNetworkFees", () => ({ + useNetworkFees: () => ({ + recommendedFee: "", + networkCongestion: "low", + feePresets: {}, + }), + clearNetworkFeesCache: () => mockClearNetworkFeesCache(), +})); + +// `@react-navigation/native-stack` is globally mocked in jest.setup.js +// (Navigator renders children as-is, Screen is a bare jest.fn()), so neither +// EarnTokenPickerScreen nor EarnAmountScreen actually mounts here — this +// test exercises only EarnStackNavigator's own effects. +describe("EarnStackNavigator teardown", () => { + beforeEach(() => { + mockClearNetworkFeesCache.mockClear(); + act(() => { + useTransactionSettingsStore.getState().resetSettings(); + useTransactionBuilderStore.getState().resetTransaction(); + }); + }); + + it("resets the shared transaction-settings + builder stores and clears the network-fee cache on unmount", () => { + // Simulate a fee the user manually customized in a DIFFERENT flow + // (Send) that shares this same global store — this is exactly the leak + // scenario the teardown guards against: without it, Earn would silently + // inherit Send's stale custom inclusion fee. + act(() => { + useTransactionSettingsStore.getState().saveTransactionFee("5"); + useTransactionSettingsStore.getState().markFeeManuallyChanged(); + useTransactionBuilderStore.setState({ + transactionXDR: "stale-xdr-from-another-flow", + }); + }); + + const { unmount } = renderWithProviders( + + + , + ); + + // Not reset merely by mounting — only on unmount. + expect(useTransactionSettingsStore.getState().transactionFee).toBe("5"); + expect(useTransactionBuilderStore.getState().transactionXDR).toBe( + "stale-xdr-from-another-flow", + ); + expect(mockClearNetworkFeesCache).not.toHaveBeenCalled(); + + act(() => { + unmount(); + }); + + expect(useTransactionSettingsStore.getState().transactionFee).toBe( + MIN_TRANSACTION_FEE, + ); + expect(useTransactionSettingsStore.getState().feeManuallyChanged).toBe( + false, + ); + expect(useTransactionBuilderStore.getState().transactionXDR).toBeNull(); + expect(mockClearNetworkFeesCache).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/screens/EarnScreen/components/NotEnoughTokenBottomSheet.tsx b/src/components/screens/EarnScreen/components/NotEnoughTokenBottomSheet.tsx new file mode 100644 index 000000000..25f1ccd26 --- /dev/null +++ b/src/components/screens/EarnScreen/components/NotEnoughTokenBottomSheet.tsx @@ -0,0 +1,104 @@ +import { NotEnoughVariant } from "components/screens/EarnScreen/helpers"; +import { Button } from "components/sds/Button"; +import Icon from "components/sds/Icon"; +import { Text } from "components/sds/Typography"; +import useAppTranslation from "hooks/useAppTranslation"; +import useColors from "hooks/useColors"; +import React from "react"; +import { TouchableOpacity, View } from "react-native"; + +export interface NotEnoughTokenBottomSheetProps { + variant: NotEnoughVariant; + tokenCode: string; + onBuy: () => void; + onReceive: () => void; + onClose: () => void; +} + +/** + * "Not enough X" sheet, presented when the token picker's zero-balance rows + * are tapped — those tokens aren't deposit-ready, so this offers a path to + * acquire some instead of opening the (unusable) amount screen. + * + * Phase 1 scope: the Swap button is intentionally OMITTED from every + * variant. Swapping into the deposit asset from within Earn belongs to a + * separate, deferred cycle — `SWAP_OR_TRANSFER` and `TRANSFER_ONLY` therefore + * render the identical Receive-only button set below, and + * `BUY_SWAP_OR_TRANSFER` renders the same Buy + Receive set as + * `BUY_OR_TRANSFER`. If a future reader is looking for a Swap CTA here, it + * was left out on purpose, not forgotten. + */ +export const NotEnoughTokenBottomSheet: React.FC< + NotEnoughTokenBottomSheetProps +> = ({ variant, tokenCode, onBuy, onReceive, onClose }) => { + const { t } = useAppTranslation(); + const { themeColors } = useColors(); + + const showBuy = + variant === NotEnoughVariant.BUY_SWAP_OR_TRANSFER || + variant === NotEnoughVariant.BUY_OR_TRANSFER; + + return ( + + + + + + + + + + + + {t("earnNotEnough.title", { tokenCode })} + + + + + {t( + showBuy + ? "earnNotEnough.bodyWithBuy" + : "earnNotEnough.bodyReceiveOnly", + { tokenCode }, + )} + + + + + {showBuy && ( + + + + )} + + + + + + ); +}; + +export default NotEnoughTokenBottomSheet; diff --git a/src/components/screens/EarnScreen/hooks/useSimulateEarnDeposit.ts b/src/components/screens/EarnScreen/hooks/useSimulateEarnDeposit.ts new file mode 100644 index 000000000..b9de37701 --- /dev/null +++ b/src/components/screens/EarnScreen/hooks/useSimulateEarnDeposit.ts @@ -0,0 +1,112 @@ +import Blockaid from "@blockaid/client"; +import { NETWORKS } from "config/constants"; +import { logger } from "config/logger"; +import { useTransactionBuilderStore } from "ducks/transactionBuilder"; +import { useBlockaidTransaction } from "hooks/blockaid/useBlockaidTransaction"; +import useAppTranslation from "hooks/useAppTranslation"; +import { useCallback, useState } from "react"; + +export interface SimulateEarnDepositParams { + assetId: string; + amount: string; + decimals: number; + transactionFee: string; + transactionTimeout: number; + network: NETWORKS; + senderAddress: string; +} + +export interface SimulateEarnDepositSuccess { + preparedXdr: string; + /** `undefined` when the scan failed or was unavailable (e.g. testnet) — + * callers should treat that the same as "unable to scan", never as a + * reason to fail the deposit. */ + scanResult: Blockaid.StellarTransactionScanResponse | undefined; +} + +/** + * Builds, simulates and scans a Blend deposit. + * + * The prepared XDR lands in the transactionBuilder duck, which is where the + * submit step reads it from — this hook returns only what the amount screen + * needs to decide whether to open Review. + * + * IMPORTANT — the Blockaid scan is isolated in its own try/catch, separate + * from the simulation above it. `scanTransaction` (services/blockaid/api) + * THROWS `NETWORK_NOT_SUPPORTED` whenever the active network isn't mainnet, + * which is the common case on TESTNET. If the scan shared the simulation's + * try/catch, every testnet deposit would report "simulation failed" even + * though the deposit itself simulated fine and only the security scan was + * unavailable. A scan failure therefore never fails `simulate` — it resolves + * with `scanResult: undefined`, which downstream security-assessment helpers + * (e.g. `assessTransactionSecurity`) already treat as "unable to scan". + */ +export const useSimulateEarnDeposit = () => { + const buildBlendDepositTransaction = useTransactionBuilderStore( + (state) => state.buildBlendDepositTransaction, + ); + const { scanTransaction } = useBlockaidTransaction(); + const { t } = useAppTranslation(); + + const [isSimulating, setIsSimulating] = useState(false); + const [error, setError] = useState(null); + const [scanResult, setScanResult] = useState< + Blockaid.StellarTransactionScanResponse | undefined + >(undefined); + + const simulate = useCallback( + async ( + params: SimulateEarnDepositParams, + ): Promise => { + setIsSimulating(true); + setError(null); + setScanResult(undefined); + + try { + const preparedXdr = await buildBlendDepositTransaction(params); + + if (!preparedXdr) { + // The duck already recorded WHY (a supply cap, frozen pool, or + // stale oracle rejection from simulation) — surface that directly + // rather than a generic message, since it is the only signal the + // user gets about why the deposit will not go through. + throw new Error( + useTransactionBuilderStore.getState().error || + t("earnAmount.errors.simulationFailed"), + ); + } + + // Scanned on the PREPARED (assembled) transaction — the thing the + // user actually signs — not the pre-assembly build. Deliberately its + // own try/catch: see the function-level note above on why a scan + // failure must never fail the simulation. + let freshScanResult: + | Blockaid.StellarTransactionScanResponse + | undefined; + try { + freshScanResult = await scanTransaction(preparedXdr, "internal"); + } catch (scanError) { + logger.error( + "useSimulateEarnDeposit", + "Transaction scan failed", + scanError, + ); + freshScanResult = undefined; + } + + setScanResult(freshScanResult); + return { preparedXdr, scanResult: freshScanResult }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + logger.error("useSimulateEarnDeposit", "Simulation failed", err); + setError(message); + return null; + } finally { + setIsSimulating(false); + } + }, + [buildBlendDepositTransaction, scanTransaction, t], + ); + + return { simulate, isSimulating, error, scanResult }; +}; diff --git a/src/components/screens/EarnScreen/screens/EarnAmountScreen.tsx b/src/components/screens/EarnScreen/screens/EarnAmountScreen.tsx new file mode 100644 index 000000000..45b9ce925 --- /dev/null +++ b/src/components/screens/EarnScreen/screens/EarnAmountScreen.tsx @@ -0,0 +1,451 @@ +import { BottomSheetModal } from "@gorhom/bottom-sheet"; +import { NavigationProp, useNavigation } from "@react-navigation/native"; +import { NativeStackScreenProps } from "@react-navigation/native-stack"; +import BigNumber from "bignumber.js"; +import { AmountCard } from "components/AmountCard"; +import BottomSheet from "components/BottomSheet"; +import InformationBottomSheet from "components/InformationBottomSheet"; +import { PercentageButtons } from "components/PercentageButtons"; +import { BaseLayout } from "components/layout/BaseLayout"; +import { + formatRate, + getEarnCtaState, + getMaxDepositAmount, + getPercentageDepositAmount, + needsXlmForFee, +} from "components/screens/EarnScreen/helpers"; +import { useSimulateEarnDeposit } from "components/screens/EarnScreen/hooks/useSimulateEarnDeposit"; +import { Badge } from "components/sds/Badge"; +import { Button } from "components/sds/Button"; +import Icon from "components/sds/Icon"; +import { + NoticeBanner, + NoticeBannerVariants, +} from "components/sds/NoticeBanner"; +import { Text } from "components/sds/Typography"; +import { + NATIVE_TOKEN_CODE, + TransactionContext, + mapNetworkToNetworkDetails, +} from "config/constants"; +import { + ADD_FUNDS_ROUTES, + EARN_ROUTES, + EarnStackParamList, + ROOT_NAVIGATOR_ROUTES, + RootStackParamList, +} from "config/routes"; +import { useAuthenticationStore } from "ducks/auth"; +import { useBalancesStore } from "ducks/balances"; +import { useEarnStore } from "ducks/earn"; +import { useTransactionBuilderStore } from "ducks/transactionBuilder"; +import { useTransactionSettingsStore } from "ducks/transactionSettings"; +import { + calculateSpendableAmount, + getBalanceByContractId, +} from "helpers/balances"; +import { + formatBalanceAmount, + formatFiatInputDisplay, + formatTokenForDisplay, +} from "helpers/formatAmount"; +import useAppTranslation from "hooks/useAppTranslation"; +import useColors from "hooks/useColors"; +import useGetActiveAccount from "hooks/useGetActiveAccount"; +import { useInitialRecommendedFee } from "hooks/useInitialRecommendedFee"; +import { useNetworkFees } from "hooks/useNetworkFees"; +import { useTokenFiatConverter } from "hooks/useTokenFiatConverter"; +import { useToast } from "providers/ToastProvider"; +import React, { useCallback, useEffect, useMemo, useRef } from "react"; +import { TextInput, View } from "react-native"; + +type EarnAmountScreenProps = NativeStackScreenProps< + EarnStackParamList, + typeof EARN_ROUTES.EARN_AMOUNT_SCREEN +>; + +const EarnAmountScreen: React.FC = ({ route }) => { + const { assetId, tokenCode } = route.params; + const { t } = useAppTranslation(); + const { themeColors } = useColors(); + const { account } = useGetActiveAccount(); + const { network } = useAuthenticationStore(); + const { pricedBalances } = useBalancesStore(); + const { showToast } = useToast(); + + // Cross-stack navigation (Buy XLM lives outside EarnStack) needs the + // root-level param list — the screen's own `navigation` prop is typed to + // EarnStackParamList only. Same pattern as EarnTokenPickerScreen's Buy + // button. + const rootNavigation = useNavigation>(); + + const selectedAssetApy = useEarnStore((state) => state.selectedAssetApy); + const selectedAssetDecimals = useEarnStore( + (state) => state.selectedAssetDecimals, + ); + const lastSubmitFailed = useEarnStore((state) => state.lastSubmitFailed); + const setSubmitFailed = useEarnStore((state) => state.setSubmitFailed); + + const { resetTransaction } = useTransactionBuilderStore(); + const { transactionFee, transactionTimeout } = useTransactionSettingsStore(); + const { recommendedFee, networkCongestion, feePresets } = useNetworkFees(); + + // Earn deposits are single-operation Soroban invokes — same shape as a + // classic Send, so this reuses the Send fee context (and its settings + // store) rather than introducing a dedicated Earn TransactionContext. + useInitialRecommendedFee( + recommendedFee, + TransactionContext.Send, + 1, + networkCongestion, + feePresets, + ); + + const networkDetails = useMemo( + () => mapNetworkToNetworkDetails(network), + [network], + ); + + const depositBalance = useMemo( + () => getBalanceByContractId(assetId, pricedBalances, networkDetails), + [assetId, pricedBalances, networkDetails], + ); + + const isXlm = useMemo( + () => + !!depositBalance && + "token" in depositBalance && + "type" in depositBalance.token && + depositBalance.token.type === "native", + [depositBalance], + ); + + const xlmBalance = pricedBalances[NATIVE_TOKEN_CODE]; + + const amountInputRef = useRef(null); + const networkFeeBottomSheetModalRef = useRef(null); + + const converter = useTokenFiatConverter({ + selectedBalance: depositBalance, + tokenDecimals: selectedAssetDecimals, + }); + const { + tokenAmount, + fiatAmountDisplay, + showFiatAmount, + setTokenAmount, + updateFiatDisplay, + } = converter; + + const maxDepositable = useMemo(() => { + if (!depositBalance) { + return "0"; + } + + const spendable = calculateSpendableAmount({ + balance: depositBalance, + subentryCount: account?.subentryCount ?? 0, + transactionFee, + }); + + return getMaxDepositAmount({ + availableBalance: spendable.toFixed(), + isXlm, + }); + }, [depositBalance, account?.subentryCount, transactionFee, isXlm]); + + const maxDepositableBn = useMemo( + () => new BigNumber(maxDepositable), + [maxDepositable], + ); + + const ctaState = useMemo( + () => + getEarnCtaState({ + availableBalanceIsZero: maxDepositableBn.lte(0), + amountIsZero: new BigNumber(tokenAmount || "0").lte(0), + isAmountTooHigh: new BigNumber(tokenAmount || "0").gt(maxDepositableBn), + }), + [maxDepositableBn, tokenAmount], + ); + + const ctaLabelKeys: Record = { + enter: t("earnAmount.enterAmount"), + insufficient: t("earnAmount.insufficientFunds"), + review: t("earnAmount.review"), + }; + + const { + simulate, + isSimulating, + error: simulateError, + } = useSimulateEarnDeposit(); + + // Surface the pool's own rejection (supply cap, frozen pool, stale oracle) + // reactively off the hook's error state — mirrors the established + // `transactionBuilderError` toast pattern in TransactionAmountScreen. + // Reading `error` synchronously right after `await simulate(...)` inside + // the CTA handler would see a stale pre-update value, since a `setState` + // scheduled inside `simulate` isn't reflected in the handler's own closure. + const previousSimulateErrorRef = useRef(null); + useEffect(() => { + if (simulateError && simulateError !== previousSimulateErrorRef.current) { + previousSimulateErrorRef.current = simulateError; + showToast({ + variant: "error", + title: simulateError, + toastId: "earn-simulate-failed", + duration: 0, + }); + } + }, [simulateError, showToast]); + + // Clear the "Transaction failed" retry banner once the user edits the + // amount — but not on mount, so a banner left over from a previous failed + // submit stays visible until an actual edit happens. + const previousTokenAmountRef = useRef(tokenAmount); + useEffect(() => { + if (tokenAmount !== previousTokenAmountRef.current) { + previousTokenAmountRef.current = tokenAmount; + if (lastSubmitFailed) { + setSubmitFailed(false); + } + } + }, [tokenAmount, lastSubmitFailed, setSubmitFailed]); + + const handlePercentagePress = useCallback( + (percentage: number) => { + const targetAmount = getPercentageDepositAmount({ + maxDepositable, + pct: percentage, + decimals: selectedAssetDecimals, + }); + + if (showFiatAmount && depositBalance?.currentPrice) { + const tokenPrice = depositBalance.currentPrice; + if (!tokenPrice.isZero()) { + const fiatAmount = new BigNumber(targetAmount) + .multipliedBy(tokenPrice) + .toFixed(2); + updateFiatDisplay(fiatAmount); + setTokenAmount(targetAmount); + return; + } + } + + setTokenAmount(targetAmount); + }, + [ + maxDepositable, + selectedAssetDecimals, + showFiatAmount, + depositBalance, + updateFiatDisplay, + setTokenAmount, + ], + ); + + /** + * Placeholder for Task 12, which builds `EarnReviewBottomSheet` and a + * `useEarnPosition` hook and wires this to present the review sheet with + * the prepared XDR / scan result `simulate` just resolved. Named now so + * the CTA's order-of-operations (fee check → simulate → open review) is + * already settled by the time Task 12 fills this in. + */ + const openReviewSheet = useCallback(() => { + // TODO(Task 12): present EarnReviewBottomSheet. + }, []); + + const handleCtaPress = useCallback(async () => { + if (ctaState.disabled) { + return; + } + + if (!account?.publicKey) { + return; + } + + // Checked AFTER the CTA's own insufficient-funds guard (above) so that + // when the deposit asset IS XLM, an unaffordable amount reads as + // insufficient funds on the button rather than as a fee problem. No held + // XLM balance at all can't cover any fee, so treat that the same as + // needing more XLM. + const spendableXlm = xlmBalance + ? calculateSpendableAmount({ + balance: xlmBalance, + subentryCount: account.subentryCount, + transactionFee, + }) + : new BigNumber(0); + + if ( + needsXlmForFee({ + spendableXlm: spendableXlm.toFixed(), + fee: transactionFee, + }) + ) { + networkFeeBottomSheetModalRef.current?.present(); + return; + } + + const result = await simulate({ + assetId, + amount: tokenAmount, + decimals: selectedAssetDecimals, + transactionFee, + transactionTimeout, + network, + senderAddress: account.publicKey, + }); + + if (result) { + openReviewSheet(); + } + // On failure, the effect above surfaces `simulateError` as a toast — + // nothing further to do here. + }, [ + ctaState.disabled, + xlmBalance, + account?.subentryCount, + account?.publicKey, + transactionFee, + simulate, + assetId, + tokenAmount, + selectedAssetDecimals, + transactionTimeout, + network, + openReviewSheet, + ]); + + // Reset the builder's stale XDR/fee snapshot when the user backs out of + // this screen without depositing. Forward navigation to Review/Processing + // (Tasks 12/13) keeps this screen mounted underneath, so this only fires + // on a genuine exit, not on a successful hand-off. + useEffect(() => () => resetTransaction(), [resetTransaction]); + + // The fee-shortfall sheet's action: send the user to buy XLM rather than + // just telling them they need it. Same destination as + // EarnTokenPickerScreen's NotEnoughTokenBottomSheet Buy button. + const handleBuyXlmPress = useCallback(() => { + networkFeeBottomSheetModalRef.current?.dismiss(); + rootNavigation.navigate(ROOT_NAVIGATOR_ROUTES.BUY_XLM_STACK, { + screen: ADD_FUNDS_ROUTES.ADD_FUNDS_SCREEN, + params: { isUnfunded: false }, + }); + }, [rootNavigation]); + + const availableBalanceText = depositBalance + ? `${formatBalanceAmount(depositBalance, tokenCode, maxDepositableBn)} ${t( + "common.available", + )}` + : null; + + const hasUsdPrice = + !!depositBalance?.currentPrice && !depositBalance.currentPrice.isZero(); + + const secondaryAmountText = showFiatAmount + ? formatTokenForDisplay(tokenAmount || "0", tokenCode) + : formatFiatInputDisplay(fiatAmountDisplay || "0"); + + return ( + + + {lastSubmitFailed && ( + + + + )} + + {}} + pickerTestID="earn-amount-token-pill" + inputTestID="earn-amount-input" + focusTriggerTestID="earn-amount-focus-trigger" + fiatToggleTestID="earn-amount-fiat-toggle" + inputRef={amountInputRef} + autoFocus + accessibilityLabel={t("earnAmount.enterAmount")} + accessibilityHint={t("earnAmount.title")} + availableBalanceText={availableBalanceText} + converter={converter} + hasUsdPrice={hasUsdPrice} + secondaryAmountText={secondaryAmountText} + /> + + + + {t("earnAmount.apyLabel")} + + + {formatRate(selectedAssetApy)} + + + + + + + + + + + + + + networkFeeBottomSheetModalRef.current?.dismiss() + } + customContent={ + networkFeeBottomSheetModalRef.current?.dismiss()} + onConfirm={handleBuyXlmPress} + confirmLabel={t("earnAmount.networkFeeSheet.buyXlm")} + headerElement={ + + + + } + texts={[ + { + key: "description", + value: t("earnAmount.networkFeeSheet.description", { + fee: formatTokenForDisplay(transactionFee, NATIVE_TOKEN_CODE), + }), + }, + ]} + /> + } + /> + + ); +}; + +export default EarnAmountScreen; diff --git a/src/components/screens/EarnScreen/screens/EarnTokenPickerScreen.tsx b/src/components/screens/EarnScreen/screens/EarnTokenPickerScreen.tsx index 3bc5e47ce..e0bd6f5f8 100644 --- a/src/components/screens/EarnScreen/screens/EarnTokenPickerScreen.tsx +++ b/src/components/screens/EarnScreen/screens/EarnTokenPickerScreen.tsx @@ -1,10 +1,18 @@ import { BottomSheetModal } from "@gorhom/bottom-sheet"; +import { NavigationProp, useNavigation } from "@react-navigation/native"; import { NativeStackScreenProps } from "@react-navigation/native-stack"; import BottomSheet from "components/BottomSheet"; import Spinner from "components/Spinner"; import { BaseLayout } from "components/layout/BaseLayout"; import { EarnTokenRow } from "components/screens/EarnScreen/components/EarnTokenRow"; +import { NotEnoughTokenBottomSheet } from "components/screens/EarnScreen/components/NotEnoughTokenBottomSheet"; import { PoolDetailsBottomSheet } from "components/screens/EarnScreen/components/PoolDetailsBottomSheet"; +import { + NotEnoughVariant, + getNotEnoughVariant, + hasSwappableBalance, + isOnrampableAsset, +} from "components/screens/EarnScreen/helpers"; import { EarnTokenOption, useEarnTokens, @@ -12,11 +20,21 @@ import { import { Button } from "components/sds/Button"; import Icon from "components/sds/Icon"; import { Text } from "components/sds/Typography"; -import { EARN_ROUTES, EarnStackParamList } from "config/routes"; +import { mapNetworkToNetworkDetails } from "config/constants"; +import { + ADD_FUNDS_ROUTES, + EARN_ROUTES, + EarnStackParamList, + ROOT_NAVIGATOR_ROUTES, + RootStackParamList, +} from "config/routes"; +import { useAuthenticationStore } from "ducks/auth"; +import { useBalancesStore } from "ducks/balances"; import { useEarnStore } from "ducks/earn"; +import { getTokenIdentifier } from "helpers/balances"; import useAppTranslation from "hooks/useAppTranslation"; import { useRightHeaderButton } from "hooks/useRightHeader"; -import React, { useCallback, useRef } from "react"; +import React, { useCallback, useMemo, useRef, useState } from "react"; import { SectionList, View } from "react-native"; type EarnTokenPickerScreenProps = NativeStackScreenProps< @@ -30,20 +48,78 @@ interface EarnTokenSection { data: EarnTokenOption[]; } -/** - * Zero-balance rows aren't deposit-ready — tapping one should open the - * "not enough" sheet Task 11 builds. Left named and empty here so the row's - * press target and styling are correct in the meantime. - */ -const handleUnheldTokenPress = () => {}; - export const EarnTokenPickerScreen: React.FC = ({ navigation, }) => { const { t } = useAppTranslation(); const { isLoading, error, held, supported, pool, refetch } = useEarnTokens(); const selectAsset = useEarnStore((state) => state.selectAsset); + const { network } = useAuthenticationStore(); + const { pricedBalances } = useBalancesStore(); const poolDetailsBottomSheetModalRef = useRef(null); + const notEnoughBottomSheetModalRef = useRef(null); + const [notEnoughOption, setNotEnoughOption] = + useState(null); + + // Cross-stack navigation (Buy / Receive live outside EarnStack) needs the + // root-level param list — the screen's own `navigation` prop is typed to + // EarnStackParamList only. + const rootNavigation = useNavigation>(); + + const networkDetails = useMemo( + () => mapNetworkToNetworkDetails(network), + [network], + ); + + /** + * Zero-balance rows aren't deposit-ready — tapping one opens the "not + * enough" sheet instead of navigating to the (unusable) amount screen. + */ + const handleUnheldTokenPress = useCallback((option: EarnTokenOption) => { + setNotEnoughOption(option); + notEnoughBottomSheetModalRef.current?.present(); + }, []); + + const notEnoughVariant = useMemo(() => { + if (!notEnoughOption) { + return NotEnoughVariant.TRANSFER_ONLY; + } + + const isOnrampable = isOnrampableAsset( + notEnoughOption.code, + networkDetails, + ); + // No trustline for this asset means it can't appear as a key in + // `pricedBalances` at all, so any identifier that isn't a real + // "XLM"/"CODE:ISSUER" key is a safe placeholder — the reserve's own + // contract address works and never collides. + const targetIdentifier = notEnoughOption.balance + ? getTokenIdentifier(notEnoughOption.balance) + : notEnoughOption.assetId; + const isSwappable = hasSwappableBalance(pricedBalances, targetIdentifier); + + return getNotEnoughVariant({ isOnrampable, isSwappable }); + }, [notEnoughOption, networkDetails, pricedBalances]); + + const handleCloseNotEnoughSheet = useCallback(() => { + notEnoughBottomSheetModalRef.current?.dismiss(); + setNotEnoughOption(null); + }, []); + + const handleBuyPress = useCallback(() => { + notEnoughBottomSheetModalRef.current?.dismiss(); + rootNavigation.navigate(ROOT_NAVIGATOR_ROUTES.BUY_XLM_STACK, { + screen: ADD_FUNDS_ROUTES.ADD_FUNDS_SCREEN, + params: { isUnfunded: false }, + }); + }, [rootNavigation]); + + const handleReceivePress = useCallback(() => { + notEnoughBottomSheetModalRef.current?.dismiss(); + rootNavigation.navigate(ROOT_NAVIGATOR_ROUTES.SCAN_RECEIVE_SCREEN, { + initialTab: "receive", + }); + }, [rootNavigation]); /** * Opens the pool details sheet. Guarded on `pool` because the header @@ -146,7 +222,7 @@ export const EarnTokenPickerScreen: React.FC = ({ onPress={() => section.kind === "held" ? handleHeldTokenPress(item) - : handleUnheldTokenPress() + : handleUnheldTokenPress(item) } /> )} @@ -171,6 +247,19 @@ export const EarnTokenPickerScreen: React.FC = ({ /> } /> + + } + /> ); }; diff --git a/src/components/screens/EarnScreen/screens/index.ts b/src/components/screens/EarnScreen/screens/index.ts index cedb98b81..be1fb5f59 100644 --- a/src/components/screens/EarnScreen/screens/index.ts +++ b/src/components/screens/EarnScreen/screens/index.ts @@ -1 +1,2 @@ +export { default as EarnAmountScreen } from "./EarnAmountScreen"; export { EarnTokenPickerScreen } from "./EarnTokenPickerScreen"; diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index 7f3eaf206..84056ccf3 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -711,6 +711,30 @@ "fixedPool": "Permissionless lending pool with no admin. Collateral factors, interest curves, and supported assets are locked at deployment." } }, + "earnAmount": { + "title": "Deposit", + "depositLabel": "Deposit", + "enterAmount": "Enter amount", + "insufficientFunds": "Insufficient funds", + "review": "Review", + "apyLabel": "APY", + "retryBanner": "Transaction failed. Try again.", + "networkFeeSheet": { + "title": "Not enough XLM for the network fee", + "description": "This deposit needs a small amount of XLM ({{fee}}) to pay the network fee. Add more XLM to your account and try again.", + "buyXlm": "Buy XLM" + }, + "errors": { + "simulationFailed": "Failed to simulate the deposit. Please try again." + } + }, + "earnNotEnough": { + "title": "You don't have any {{tokenCode}}", + "bodyWithBuy": "Buy {{tokenCode}} or receive it from another wallet to deposit it into this pool.", + "bodyReceiveOnly": "Receive {{tokenCode}} from another wallet to deposit it into this pool.", + "buy": "Buy {{tokenCode}}", + "receive": "Receive" + }, "transactionTokenScreen": { "title": "Sending", "empty": "No tokens or collectibles to send" diff --git a/src/i18n/locales/pt/translations.json b/src/i18n/locales/pt/translations.json index 9944a1bca..571624e19 100644 --- a/src/i18n/locales/pt/translations.json +++ b/src/i18n/locales/pt/translations.json @@ -677,6 +677,30 @@ "fixedPool": "Pool de empréstimo sem permissão e sem administrador. Os fatores de garantia, as curvas de juros e os ativos suportados são fixados no momento da implantação." } }, + "earnAmount": { + "title": "Depositar", + "depositLabel": "Depositar", + "enterAmount": "Insira o valor", + "insufficientFunds": "Saldo insuficiente", + "review": "Revisar", + "apyLabel": "APY", + "retryBanner": "A transação falhou. Tente novamente.", + "networkFeeSheet": { + "title": "XLM insuficiente para a taxa de rede", + "description": "Este depósito precisa de uma pequena quantia de XLM ({{fee}}) para pagar a taxa de rede. Adicione mais XLM à sua conta e tente novamente.", + "buyXlm": "Comprar XLM" + }, + "errors": { + "simulationFailed": "Falha ao simular o depósito. Tente novamente." + } + }, + "earnNotEnough": { + "title": "Você não possui {{tokenCode}}", + "bodyWithBuy": "Compre {{tokenCode}} ou receba de outra carteira para depositá-lo neste pool.", + "bodyReceiveOnly": "Receba {{tokenCode}} de outra carteira para depositá-lo neste pool.", + "buy": "Comprar {{tokenCode}}", + "receive": "Receber" + }, "transactionTokenScreen": { "title": "Enviando", "empty": "Nenhum token ou colecionável para enviar" diff --git a/src/navigators/EarnNavigator.tsx b/src/navigators/EarnNavigator.tsx index a18e0b09c..caf87aeed 100644 --- a/src/navigators/EarnNavigator.tsx +++ b/src/navigators/EarnNavigator.tsx @@ -1,12 +1,17 @@ /* eslint-disable react/no-unstable-nested-components */ import { createNativeStackNavigator } from "@react-navigation/native-stack"; import CustomNavigationHeader from "components/layout/CustomNavigationHeader"; -import { EarnTokenPickerScreen } from "components/screens/EarnScreen/screens"; +import { + EarnAmountScreen, + EarnTokenPickerScreen, +} from "components/screens/EarnScreen/screens"; import { EARN_ROUTES, EarnStackParamList } from "config/routes"; +import { useTransactionBuilderStore } from "ducks/transactionBuilder"; +import { useTransactionSettingsStore } from "ducks/transactionSettings"; import { getScreenBottomNavigateOptions } from "helpers/navigationOptions"; import useAppTranslation from "hooks/useAppTranslation"; -import { useNetworkFees } from "hooks/useNetworkFees"; -import React from "react"; +import { clearNetworkFeesCache, useNetworkFees } from "hooks/useNetworkFees"; +import React, { useEffect } from "react"; const EarnStack = createNativeStackNavigator(); @@ -18,6 +23,24 @@ export const EarnStackNavigator = () => { // on open. Same rationale as SwapNavigator. useNetworkFees(); + // Reset earn-flow fee/transaction state when the whole flow unmounts, so + // EVERY exit path (X, hardware/gesture back, or programmatic) leaves a + // clean slate — matches SendPaymentNavigator's teardown. `transactionFee`/ + // `transactionTimeout` come from the SHARED `useTransactionSettingsStore` + // (Earn reuses TransactionContext.Send rather than a dedicated context, + // see EarnAmountScreen), so without this reset a fee the user manually + // customized in Send would silently leak into Earn's deposit — a real risk + // given Earn's resource fee runs ~5,000x the inclusion fee. Also clears the + // frozen network-fee snapshot so the next flow re-fetches fresh values. + useEffect( + () => () => { + useTransactionSettingsStore.getState().resetSettings(); + useTransactionBuilderStore.getState().resetTransaction(); + clearNetworkFeesCache(); + }, + [], + ); + return ( { component={EarnTokenPickerScreen} options={getScreenBottomNavigateOptions(t("earnTokenPicker.title"))} /> + ); }; From 075e8fe5b5e30547d62ee9de5c0164bf27fb254d Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Thu, 20 Aug 2026 10:22:14 -0600 Subject: [PATCH 12/27] feat(earn): add the review sheet --- .../EarnScreen/EarnReviewBottomSheet.test.tsx | 236 +++++++++++ .../screens/EarnScreen/helpers.test.ts | 91 +++++ .../EarnScreen/useEarnPosition.test.ts | 108 +++++ .../components/EarnReviewBottomSheet.tsx | 370 ++++++++++++++++++ .../EarnScreen/helpers/earnCtaState.ts | 62 ++- .../EarnScreen/hooks/useEarnPosition.ts | 85 ++++ .../EarnScreen/screens/EarnAmountScreen.tsx | 204 +++++++++- src/i18n/locales/en/translations.json | 11 + src/i18n/locales/pt/translations.json | 11 + 9 files changed, 1165 insertions(+), 13 deletions(-) create mode 100644 __tests__/components/screens/EarnScreen/EarnReviewBottomSheet.test.tsx create mode 100644 __tests__/components/screens/EarnScreen/useEarnPosition.test.ts create mode 100644 src/components/screens/EarnScreen/components/EarnReviewBottomSheet.tsx create mode 100644 src/components/screens/EarnScreen/hooks/useEarnPosition.ts diff --git a/__tests__/components/screens/EarnScreen/EarnReviewBottomSheet.test.tsx b/__tests__/components/screens/EarnScreen/EarnReviewBottomSheet.test.tsx new file mode 100644 index 000000000..a2243dbee --- /dev/null +++ b/__tests__/components/screens/EarnScreen/EarnReviewBottomSheet.test.tsx @@ -0,0 +1,236 @@ +/* eslint-disable @fnando/consistent-import/consistent-import */ +import { userEvent } from "@testing-library/react-native"; +import BigNumber from "bignumber.js"; +import { EarnReviewBottomSheet } from "components/screens/EarnScreen/components/EarnReviewBottomSheet"; +import { useEarnStore } from "ducks/earn"; +import { renderWithProviders } from "helpers/testUtils"; +import React from "react"; +import { SecurityLevel } from "services/blockaid/constants"; +import { createSecurityAssessment } from "services/blockaid/helper"; + +import { mockGestureHandler } from "../../../../__mocks__/gesture-handler"; +import { mockUseColors } from "../../../../__mocks__/use-colors"; + +mockGestureHandler(); +mockUseColors(); + +// A `contractId` field matches `getBalanceByContractId`'s first (Soroban) +// branch by direct equality — no need to replicate its classic-asset SAC +// derivation (which calls into the real Stellar SDK) just to satisfy a mock. +const MOCK_DEPOSIT_ASSET_ID = + "CDEPOSITTESTASSETAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + +const mockDepositBalance = { + id: MOCK_DEPOSIT_ASSET_ID, + contractId: MOCK_DEPOSIT_ASSET_ID, + token: { code: "USDC", type: "" }, + total: new BigNumber("100"), + available: new BigNumber("100"), + tokenCode: "USDC", + displayName: "USDC", + imageUrl: "", + currentPrice: new BigNumber("2"), + percentagePriceChange24h: new BigNumber("0"), + fiatCode: "USD", + fiatTotal: "200", +}; + +jest.mock("ducks/auth", () => ({ + useAuthenticationStore: jest.fn(() => ({ + network: "TESTNET", + verifyActionWithBiometrics: jest.fn((callback: () => void) => callback()), + })), +})); + +jest.mock("ducks/balances", () => ({ + useBalancesStore: jest.fn(() => ({ + pricedBalances: { [MOCK_DEPOSIT_ASSET_ID]: mockDepositBalance }, + })), +})); + +// Selector-style mock (matches how the component reads this store) — +// mirrors the pattern in useSimulateEarnDeposit.test.ts. +jest.mock("ducks/transactionBuilder", () => ({ + useTransactionBuilderStore: ( + selector?: (state: Record) => unknown, + ) => { + const state = { + sorobanInclusionFeeXlm: "0.00001", + sorobanResourceFeeXlm: "0.05463", + }; + return selector ? selector(state) : state; + }, +})); + +const safe = createSecurityAssessment(SecurityLevel.SAFE); +const malicious = createSecurityAssessment(SecurityLevel.MALICIOUS); +const suspicious = createSecurityAssessment(SecurityLevel.SUSPICIOUS); +const unableToScan = createSecurityAssessment(SecurityLevel.UNABLE_TO_SCAN); + +describe("EarnReviewBottomSheet", () => { + const defaultProps = { + bottomSheetModalRef: { + current: { dismiss: jest.fn(), present: jest.fn() }, + } as never, + tokenAmount: "10", + transactionSecurityAssessment: safe, + onSecurityWarningPress: jest.fn(), + onConfirm: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + useEarnStore.getState().resetEarn(); + useEarnStore + .getState() + .setPool({ id: "CPOOL", name: "Fixed Pool" } as never); + useEarnStore.getState().selectAsset({ + assetId: MOCK_DEPOSIT_ASSET_ID, + apy: 0.1694, + code: "USDC", + decimals: 7, + }); + }); + + it("renders the pool name, APY, and fee", () => { + const { getByTestId } = renderWithProviders( + , + ); + + expect(getByTestId("earn-review-pool").props.children).toBe("Fixed Pool"); + expect(getByTestId("earn-review-apy").props.children).toBe("16.94%"); + // 0.00001 + 0.05463 = 0.05464 + expect(getByTestId("earn-review-fee").props.children).toMatch(/0\.05464/); + }); + + it("renders a 0 -> n before/after for a first deposit", () => { + // currentPositionTokens defaults to "0" after resetEarn(). + const { getByText } = renderWithProviders( + , + ); + + expect(getByText(/0\.00 USDC.*10\.00 USDC/)).toBeTruthy(); + }); + + it("renders a non-zero before value for a top-up", () => { + // Raw integer string at 7 decimals -> 500.00 display units. + useEarnStore.getState().setCurrentPositionTokens("5000000000"); + + const { getByText } = renderWithProviders( + , + ); + + expect(getByText(/500\.00 USDC.*510\.00 USDC/)).toBeTruthy(); + }); + + it("renders '--' for APY and projected earnings when the rate is unknown", () => { + useEarnStore.getState().selectAsset({ + assetId: MOCK_DEPOSIT_ASSET_ID, + apy: null, + code: "USDC", + decimals: 7, + }); + + const { getByTestId } = renderWithProviders( + , + ); + + expect(getByTestId("earn-review-apy").props.children).toBe("--"); + expect(getByTestId("earn-review-monthly").props.children).toBe("--"); + expect(getByTestId("earn-review-yearly").props.children).toBe("--"); + }); + + it("shows the malicious banner and forwards a tap to onSecurityWarningPress", async () => { + const user = userEvent.setup(); + const onSecurityWarningPress = jest.fn(); + + const { getByText } = renderWithProviders( + , + ); + + const banner = getByText("This address was flagged as malicious"); + expect(banner).toBeTruthy(); + + await user.press(banner); + expect(onSecurityWarningPress).toHaveBeenCalledTimes(1); + }); + + it("shows the suspicious banner", () => { + const { getByText } = renderWithProviders( + , + ); + + expect(getByText("This address was flagged as suspicious")).toBeTruthy(); + }); + + it("shows the proceed-with-caution banner when the scan was unavailable (e.g. testnet)", () => { + const { getByText } = renderWithProviders( + , + ); + + expect(getByText("Proceed with caution")).toBeTruthy(); + }); + + it("does not show a banner for a safe assessment", () => { + const { queryByTestId } = renderWithProviders( + , + ); + + expect(queryByTestId("security-warning-banner")).toBeNull(); + }); + + it("dismisses and confirms on the trusted Confirm button", async () => { + const user = userEvent.setup(); + const onConfirm = jest.fn(); + const bottomSheetModalRef = { + current: { dismiss: jest.fn(), present: jest.fn() }, + } as never; + + const { getByTestId } = renderWithProviders( + , + ); + + await user.press(getByTestId("earn-review-confirm-button")); + + expect(onConfirm).toHaveBeenCalledTimes(1); + }); + + it("dismisses without confirming on Cancel", async () => { + const user = userEvent.setup(); + const onConfirm = jest.fn(); + + const { getByTestId } = renderWithProviders( + , + ); + + await user.press(getByTestId("earn-review-cancel-button")); + + expect(onConfirm).not.toHaveBeenCalled(); + }); + + it("offers a Confirm Anyway text button instead of a plain Confirm when malicious", () => { + const { getByTestId, queryByTestId } = renderWithProviders( + , + ); + + expect(getByTestId("earn-review-confirm-anyway-button")).toBeTruthy(); + expect(queryByTestId("earn-review-confirm-button")).toBeNull(); + }); +}); diff --git a/__tests__/components/screens/EarnScreen/helpers.test.ts b/__tests__/components/screens/EarnScreen/helpers.test.ts index 7d9726829..6e734c550 100644 --- a/__tests__/components/screens/EarnScreen/helpers.test.ts +++ b/__tests__/components/screens/EarnScreen/helpers.test.ts @@ -1,6 +1,7 @@ import BigNumber from "bignumber.js"; import { NotEnoughVariant, + clampXlmDepositAmount, formatCompactUsd, formatProjection, formatRate, @@ -103,6 +104,96 @@ describe("getMaxDepositAmount", () => { }); }); +describe("clampXlmDepositAmount", () => { + it("reduces the entered amount to fit once the real resource fee is known", () => { + expect( + clampXlmDepositAmount({ + enteredAmount: "99.5", + spendableXlm: "99.5", + resourceFeeXlm: "0.0546", + decimals: 7, + isXlm: true, + }), + ).toBe("99.4454"); + }); + + it("leaves an amount that already fits untouched", () => { + expect( + clampXlmDepositAmount({ + enteredAmount: "50", + spendableXlm: "99.5", + resourceFeeXlm: "0.0546", + decimals: 7, + isXlm: true, + }), + ).toBe("50"); + }); + + it("never returns negative — floors at zero when the fee exceeds spendable", () => { + expect( + clampXlmDepositAmount({ + enteredAmount: "1", + spendableXlm: "0.02", + resourceFeeXlm: "0.0546", + decimals: 7, + isXlm: true, + }), + ).toBe("0"); + }); + + it("rounds DOWN at the asset's decimals rather than up or to nearest", () => { + // 10 - 0.0546000004 = 9.9453999996 -> floors to 9.945399 at 6 decimals, + // never 9.9454 (which would round up and no longer strictly fit). + expect( + clampXlmDepositAmount({ + enteredAmount: "10", + spendableXlm: "10", + resourceFeeXlm: "0.0546000004", + decimals: 6, + isXlm: true, + }), + ).toBe("9.945399"); + }); + + it("is idempotent: re-running on its own output is a no-op", () => { + const params = { + enteredAmount: "99.5", + spendableXlm: "99.5", + resourceFeeXlm: "0.0546", + decimals: 7, + isXlm: true, + }; + const clamped = clampXlmDepositAmount(params); + expect(clampXlmDepositAmount({ ...params, enteredAmount: clamped })).toBe( + clamped, + ); + }); + + it("does not apply to a non-XLM deposit — its fee comes from a separate balance", () => { + expect( + clampXlmDepositAmount({ + enteredAmount: "1000", + spendableXlm: "1", + resourceFeeXlm: "0.0546", + decimals: 7, + isXlm: false, + }), + ).toBe("1000"); + }); + + it("does not apply when the resource fee is unknown (null) rather than treating it as zero", () => { + expect( + clampXlmDepositAmount({ + enteredAmount: "99.5", + spendableXlm: "50", + resourceFeeXlm: null, + decimals: 7, + isXlm: true, + }), + ).toBe("99.5"); + }); +}); + describe("getPercentageDepositAmount", () => { it("treats the input as whole percents, not a multiplier", () => { expect( diff --git a/__tests__/components/screens/EarnScreen/useEarnPosition.test.ts b/__tests__/components/screens/EarnScreen/useEarnPosition.test.ts new file mode 100644 index 000000000..d55a8cef6 --- /dev/null +++ b/__tests__/components/screens/EarnScreen/useEarnPosition.test.ts @@ -0,0 +1,108 @@ +/* eslint-disable @fnando/consistent-import/consistent-import */ +import { renderHook, waitFor } from "@testing-library/react-native"; +import { useEarnPosition } from "components/screens/EarnScreen/hooks/useEarnPosition"; +import { NETWORKS } from "config/constants"; +import { useEarnStore } from "ducks/earn"; + +const mockGetBlendSuppliedTokens = jest.fn(); +const mockLoggerError = jest.fn(); + +jest.mock("services/blend", () => ({ + getBlendSuppliedTokens: (...args: unknown[]) => + mockGetBlendSuppliedTokens(...args), +})); + +jest.mock("config/logger", () => ({ + logger: { + error: (...args: unknown[]) => mockLoggerError(...args), + }, +})); + +const baseParams = { + poolId: "CPOOL", + assetId: "CASSET", + publicKey: "GSENDER", + networkDetails: { network: NETWORKS.TESTNET } as never, +}; + +describe("useEarnPosition", () => { + beforeEach(() => { + jest.clearAllMocks(); + useEarnStore.getState().resetEarn(); + }); + + it("writes the fetched position to the earn duck on success", async () => { + mockGetBlendSuppliedTokens.mockResolvedValue("5000000000"); + + const { result } = renderHook(() => useEarnPosition(baseParams)); + + await waitFor(() => { + expect(result.current.currentPositionTokens).toBe("5000000000"); + }); + + expect(mockGetBlendSuppliedTokens).toHaveBeenCalledWith({ + publicKey: baseParams.publicKey, + poolId: baseParams.poolId, + assetId: baseParams.assetId, + networkDetails: baseParams.networkDetails, + }); + }); + + // Load-bearing regression test: a rejected fetch (network error, backend + // outage, unknown address) must be swallowed, not surfaced as a blocker. + // Review renders the "after" value alone off whatever `currentPositionTokens` + // already is — which must stay at its "0" default here, not be corrupted by + // the failed attempt. + it("is non-fatal on a rejected fetch: leaves currentPositionTokens at its default and logs instead of throwing", async () => { + mockGetBlendSuppliedTokens.mockRejectedValue(new Error("network down")); + + const { result } = renderHook(() => useEarnPosition(baseParams)); + + await waitFor(() => { + expect(mockLoggerError).toHaveBeenCalledWith( + "useEarnPosition", + "Failed to fetch Blend position", + expect.any(Error), + ); + }); + + expect(result.current.currentPositionTokens).toBe("0"); + }); + + it("does not fetch until poolId, assetId, and publicKey are all known", () => { + renderHook(() => + useEarnPosition({ + poolId: "", + assetId: baseParams.assetId, + publicKey: baseParams.publicKey, + networkDetails: baseParams.networkDetails, + }), + ); + + expect(mockGetBlendSuppliedTokens).not.toHaveBeenCalled(); + }); + + it("re-fetches when the asset changes", async () => { + mockGetBlendSuppliedTokens.mockResolvedValue("1000000000"); + + const { rerender } = renderHook( + (props: typeof baseParams) => useEarnPosition(props), + { initialProps: baseParams }, + ); + + await waitFor(() => { + expect(mockGetBlendSuppliedTokens).toHaveBeenCalledTimes(1); + }); + + mockGetBlendSuppliedTokens.mockResolvedValue("2000000000"); + rerender({ ...baseParams, assetId: "CASSET_OTHER" }); + + await waitFor(() => { + expect(mockGetBlendSuppliedTokens).toHaveBeenCalledTimes(2); + }); + + expect(mockGetBlendSuppliedTokens).toHaveBeenLastCalledWith( + expect.objectContaining({ assetId: "CASSET_OTHER" }), + ); + }); +}); diff --git a/src/components/screens/EarnScreen/components/EarnReviewBottomSheet.tsx b/src/components/screens/EarnScreen/components/EarnReviewBottomSheet.tsx new file mode 100644 index 000000000..f5d051aff --- /dev/null +++ b/src/components/screens/EarnScreen/components/EarnReviewBottomSheet.tsx @@ -0,0 +1,370 @@ +import { BottomSheetModal } from "@gorhom/bottom-sheet"; +import BigNumber from "bignumber.js"; +import { List } from "components/List"; +import { TokenIcon } from "components/TokenIcon"; +import { + formatProjection, + formatRate, + projectEarnings, +} from "components/screens/EarnScreen/helpers"; +import { Banner } from "components/sds/Banner"; +import { Button } from "components/sds/Button"; +import Icon from "components/sds/Icon"; +import { TextButton } from "components/sds/TextButton"; +import { Text } from "components/sds/Typography"; +import { + MIN_TRANSACTION_FEE, + NATIVE_TOKEN_CODE, + mapNetworkToNetworkDetails, +} from "config/constants"; +import { THEME } from "config/theme"; +import { useAuthenticationStore } from "ducks/auth"; +import { useBalancesStore } from "ducks/balances"; +import { useEarnStore } from "ducks/earn"; +import { useTransactionBuilderStore } from "ducks/transactionBuilder"; +import { getBalanceByContractId } from "helpers/balances"; +import { + NO_FIAT_VALUE, + formatFiatAmount, + formatTokenForDisplay, +} from "helpers/formatAmount"; +import useAppTranslation from "hooks/useAppTranslation"; +import useColors from "hooks/useColors"; +import { useFeeDetailsBottomSheet } from "hooks/useFeeDetailsBottomSheet"; +import React, { useCallback, useMemo } from "react"; +import { TouchableOpacity, View } from "react-native"; +import type { SecurityAssessment } from "services/blockaid/types"; + +export interface EarnReviewBottomSheetProps { + bottomSheetModalRef: React.RefObject; + /** + * The amount entered on the amount screen, in display (non-raw) units. + * Guaranteed by `EarnAmountScreen`'s CTA handler to already match the + * staged XDR in `transactionBuilder` — see the component doc below. + */ + tokenAmount: string; + transactionSecurityAssessment: SecurityAssessment; + onSecurityWarningPress: () => void; + onConfirm: () => void; +} + +/** + * Earn deposit review. Mirrors `SwapReviewBottomSheet`'s structure (pure + * content; the parent owns the `BottomSheetModal` ref and wraps this in + * ``), but is self-sufficient about most of its own data — + * reading the pool/asset/fee state directly off the earn and + * transactionBuilder ducks — since (unlike Swap) there is exactly one call + * site and no reason to thread every field through props. + * + * `tokenAmount` is the one genuine exception: the entered amount lives in + * the amount screen's local `useTokenFiatConverter` state, not a duck, so it + * has to come in via a prop. It is read-only here — see the invariant note + * further down on why this sheet never needs to correct it. + */ +export const EarnReviewBottomSheet: React.FC = ({ + bottomSheetModalRef, + tokenAmount, + transactionSecurityAssessment, + onSecurityWarningPress, + onConfirm, +}) => { + const { t } = useAppTranslation(); + const { themeColors } = useColors(); + const { network } = useAuthenticationStore(); + const { pricedBalances } = useBalancesStore(); + + const pool = useEarnStore((state) => state.pool); + const selectedAssetId = useEarnStore((state) => state.selectedAssetId); + const selectedAssetApy = useEarnStore((state) => state.selectedAssetApy); + const selectedAssetCode = useEarnStore((state) => state.selectedAssetCode); + const selectedAssetDecimals = useEarnStore( + (state) => state.selectedAssetDecimals, + ); + const currentPositionTokens = useEarnStore( + (state) => state.currentPositionTokens, + ); + + const sorobanInclusionFeeXlm = useTransactionBuilderStore( + (state) => state.sorobanInclusionFeeXlm, + ); + const sorobanResourceFeeXlm = useTransactionBuilderStore( + (state) => state.sorobanResourceFeeXlm, + ); + + const { openFeeDetails, feeDetailsSheets } = useFeeDetailsBottomSheet({ + isSorobanContext: true, + }); + + const networkDetails = useMemo( + () => mapNetworkToNetworkDetails(network), + [network], + ); + + const depositBalance = useMemo( + () => + getBalanceByContractId(selectedAssetId, pricedBalances, networkDetails), + [selectedAssetId, pricedBalances, networkDetails], + ); + + // The re-clamp against the REAL resource fee (see `clampXlmDepositAmount`) + // happens in `EarnAmountScreen`'s CTA handler, BEFORE this sheet is ever + // presented — not here. That is the invariant this sheet relies on: by the + // time it is mounted and visible, the staged XDR in `transactionBuilder` + // already corresponds exactly to `tokenAmount` below, for every asset, + // XLM included. This sheet therefore never needs to touch the amount. + const depositUsd = useMemo(() => { + if (!depositBalance?.currentPrice || depositBalance.currentPrice.isZero()) { + return null; + } + return new BigNumber(tokenAmount || "0") + .multipliedBy(depositBalance.currentPrice) + .toFixed(); + }, [depositBalance, tokenAmount]); + + const { monthly, yearly } = useMemo( + () => projectEarnings({ depositUsd, apy: selectedAssetApy }), + [depositUsd, selectedAssetApy], + ); + + // `currentPositionTokens` is `total_tokens`, not `supplied_tokens` — see + // `getBlendSuppliedTokens`'s docs. Deposits use SupplyCollateral, so the + // plain-supply bucket never reflects a top-up. + const beforeTokens = useMemo( + () => + new BigNumber(currentPositionTokens).shiftedBy(-selectedAssetDecimals), + [currentPositionTokens, selectedAssetDecimals], + ); + const afterTokens = useMemo( + () => beforeTokens.plus(new BigNumber(tokenAmount || "0")), + [beforeTokens, tokenAmount], + ); + + const totalFeeXlm = sorobanResourceFeeXlm + ? new BigNumber(sorobanInclusionFeeXlm ?? MIN_TRANSACTION_FEE) + .plus(sorobanResourceFeeXlm) + .toFixed() + : (sorobanInclusionFeeXlm ?? MIN_TRANSACTION_FEE); + + const { isMalicious, isSuspicious, isUnableToScan } = + transactionSecurityAssessment; + const isTrusted = !isMalicious && !isSuspicious; + + const bannerText = useMemo(() => { + if (isMalicious) { + return t("transactionAmountScreen.errors.malicious"); + } + if (isSuspicious) { + return t("transactionAmountScreen.errors.suspicious"); + } + if (isUnableToScan) { + return t("securityWarning.proceedWithCaution"); + } + return ""; + }, [isMalicious, isSuspicious, isUnableToScan, t]); + + const handleCancel = useCallback(() => { + bottomSheetModalRef.current?.dismiss(); + }, [bottomSheetModalRef]); + + const handleConfirm = useCallback(() => { + bottomSheetModalRef.current?.dismiss(); + onConfirm(); + }, [bottomSheetModalRef, onConfirm]); + + return ( + + + + {t("earnReview.title")} + + + {depositBalance && ( + + + + + {formatTokenForDisplay(tokenAmount || "0", selectedAssetCode)} + + + {depositUsd !== null + ? formatFiatAmount(depositUsd) + : NO_FIAT_VALUE} + + + + )} + + + {(isMalicious || isSuspicious || isUnableToScan) && ( + + )} + + , + titleComponent: ( + + {t("earnReview.pool")} + + ), + trailingContent: ( + + {pool?.name ?? "--"} + + ), + }, + { + icon: , + titleComponent: ( + + {t("earnReview.apy")} + + ), + trailingContent: ( + + {formatRate(selectedAssetApy)} + + ), + }, + { + icon: , + titleComponent: ( + + {t("earnReview.position")} + + ), + trailingContent: ( + + {t("earnReview.positionValue", { + before: formatTokenForDisplay( + beforeTokens, + selectedAssetCode, + ), + after: formatTokenForDisplay(afterTokens, selectedAssetCode), + })} + + ), + }, + { + icon: , + titleComponent: ( + + {t("earnReview.estMonthlyEarnings")} + + ), + trailingContent: ( + + {formatProjection(monthly)} + + ), + }, + { + icon: , + titleComponent: ( + + {t("earnReview.estYearlyEarnings")} + + ), + trailingContent: ( + + {formatProjection(yearly)} + + ), + }, + { + icon: ( + + ), + titleComponent: ( + + {t("transactionAmountScreen.details.fee")} + + ), + trailingContent: ( + + + + + + {formatTokenForDisplay(totalFeeXlm, NATIVE_TOKEN_CODE)} + + + ), + }, + ]} + /> + + + + {t("earnReview.projectionDisclaimer")} + + + + + {isTrusted ? ( + + + + + + + + + ) : ( + + + + + )} + + + {feeDetailsSheets} + + ); +}; + +export default EarnReviewBottomSheet; diff --git a/src/components/screens/EarnScreen/helpers/earnCtaState.ts b/src/components/screens/EarnScreen/helpers/earnCtaState.ts index 70d61346e..24175475d 100644 --- a/src/components/screens/EarnScreen/helpers/earnCtaState.ts +++ b/src/components/screens/EarnScreen/helpers/earnCtaState.ts @@ -59,8 +59,8 @@ export const needsXlmForFee = ({ * fee, but a Blend `submit` is dominated by its resource fee — ~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. + * error. Hold back a buffer; the CTA handler re-checks against the real + * `minResourceFee` once simulation returns (see `clampXlmDepositAmount`). * * 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. @@ -81,3 +81,61 @@ export const getMaxDepositAmount = ({ new BigNumber(0), ).toFixed(); }; + +/** + * Re-clamps the entered deposit amount against the REAL resource fee, once + * simulation has resolved and the fee is actually known — `getMaxDepositAmount`'s + * 0.5 XLM buffer above is a generous pre-simulation estimate, not the real cost. + * + * Pure and side-effect-free by design: the caller (`EarnAmountScreen`'s CTA + * handler) is responsible for actually updating `tokenAmount` and re-simulating + * with the returned value BEFORE opening Review, so the staged transaction in + * `transactionBuilder` always corresponds to what gets displayed. This function + * only computes what the corrected amount SHOULD be. + * + * Returns `enteredAmount` unchanged (a no-op) when: + * - the deposit asset is not XLM — every other asset's fee comes from a + * separate balance, so this cannot squeeze it; + * - `resourceFeeXlm` is `null` — an unknown fee, not a zero one, so there is + * nothing more precise to re-check against than the buffer already applied; + * - the entered amount already fits. + * + * Otherwise returns `spendableXlm - resourceFeeXlm`, floored at zero and + * rounded DOWN at the asset's decimals — so the result never exceeds the + * asset's precision and is never negative. Idempotent: feeding the function's + * own output back in as `enteredAmount` is always a no-op, since the rounded + * result can only be less than or equal to the un-rounded ceiling it was + * derived from. + */ +export const clampXlmDepositAmount = ({ + enteredAmount, + spendableXlm, + resourceFeeXlm, + decimals, + isXlm, +}: { + enteredAmount: string; + spendableXlm: string; + resourceFeeXlm: string | null; + decimals: number; + isXlm: boolean; +}): string => { + if (!isXlm || resourceFeeXlm === null) { + return enteredAmount; + } + + const maxFittingAmount = BigNumber.max( + new BigNumber(spendableXlm).minus(resourceFeeXlm), + new BigNumber(0), + ); + + const entered = new BigNumber(enteredAmount || "0"); + + if (entered.lte(maxFittingAmount)) { + return enteredAmount; + } + + return maxFittingAmount + .decimalPlaces(decimals, BigNumber.ROUND_DOWN) + .toFixed(); +}; diff --git a/src/components/screens/EarnScreen/hooks/useEarnPosition.ts b/src/components/screens/EarnScreen/hooks/useEarnPosition.ts new file mode 100644 index 000000000..b019dfb1d --- /dev/null +++ b/src/components/screens/EarnScreen/hooks/useEarnPosition.ts @@ -0,0 +1,85 @@ +import { NetworkDetails } from "config/constants"; +import { logger } from "config/logger"; +import { useEarnStore } from "ducks/earn"; +import { useEffect } from "react"; +import { getBlendSuppliedTokens } from "services/blend"; + +export interface UseEarnPositionParams { + poolId: string; + assetId: string; + publicKey: string; + networkDetails: NetworkDetails; +} + +export interface UseEarnPositionResult { + /** Raw integer string, in the asset's own base units — the "before" side + * of Review's before -> after row. */ + currentPositionTokens: string; +} + +/** + * Fetches the account's existing Blend position for (poolId, assetId) and + * writes it to the earn duck as the "before" value Review reads for its + * before -> after row. + * + * A no-op until every input has resolved (e.g. before `pool` has loaded on + * the token picker), since `getBlendSuppliedTokens` cannot answer without + * all four. + * + * A failed fetch is deliberately non-fatal: it is logged and swallowed, + * leaving `currentPositionTokens` at whatever the duck already held (its "0" + * default on a fresh flow) rather than blocking the deposit. A stale or + * missing before-value must never gate Review — that is explicit in the + * spec, since the "after" value alone is still a usable review. + */ +export const useEarnPosition = ({ + poolId, + assetId, + publicKey, + networkDetails, +}: UseEarnPositionParams): UseEarnPositionResult => { + const currentPositionTokens = useEarnStore( + (state) => state.currentPositionTokens, + ); + const setCurrentPositionTokens = useEarnStore( + (state) => state.setCurrentPositionTokens, + ); + + useEffect(() => { + if (!poolId || !assetId || !publicKey) { + return undefined; + } + + // Guards against a slow/late response landing after a newer request has + // superseded it (e.g. the user backed out and re-entered with a + // different asset) — the same "only the latest write wins" shape used by + // `transactionBuilder`'s requestId guard, minus the store field, since + // an unmount is enough of a signal here. + let isCancelled = false; + + (async () => { + try { + const tokens = await getBlendSuppliedTokens({ + publicKey, + poolId, + assetId, + networkDetails, + }); + + if (!isCancelled) { + setCurrentPositionTokens(tokens); + } + } catch (err) { + // Non-fatal by design: Review renders the "after" value alone rather + // than blocking the deposit on an unavailable before-value. + logger.error("useEarnPosition", "Failed to fetch Blend position", err); + } + })(); + + return () => { + isCancelled = true; + }; + }, [poolId, assetId, publicKey, networkDetails, setCurrentPositionTokens]); + + return { currentPositionTokens }; +}; diff --git a/src/components/screens/EarnScreen/screens/EarnAmountScreen.tsx b/src/components/screens/EarnScreen/screens/EarnAmountScreen.tsx index 45b9ce925..5e2b62691 100644 --- a/src/components/screens/EarnScreen/screens/EarnAmountScreen.tsx +++ b/src/components/screens/EarnScreen/screens/EarnAmountScreen.tsx @@ -6,14 +6,18 @@ import { AmountCard } from "components/AmountCard"; import BottomSheet from "components/BottomSheet"; import InformationBottomSheet from "components/InformationBottomSheet"; import { PercentageButtons } from "components/PercentageButtons"; +import { SecurityDetailBottomSheet } from "components/blockaid"; import { BaseLayout } from "components/layout/BaseLayout"; +import { EarnReviewBottomSheet } from "components/screens/EarnScreen/components/EarnReviewBottomSheet"; import { + clampXlmDepositAmount, formatRate, getEarnCtaState, getMaxDepositAmount, getPercentageDepositAmount, needsXlmForFee, } from "components/screens/EarnScreen/helpers"; +import { useEarnPosition } from "components/screens/EarnScreen/hooks/useEarnPosition"; import { useSimulateEarnDeposit } from "components/screens/EarnScreen/hooks/useSimulateEarnDeposit"; import { Badge } from "components/sds/Badge"; import { Button } from "components/sds/Button"; @@ -37,6 +41,7 @@ import { } from "config/routes"; import { useAuthenticationStore } from "ducks/auth"; import { useBalancesStore } from "ducks/balances"; +import { useDebugStore } from "ducks/debug"; import { useEarnStore } from "ducks/earn"; import { useTransactionBuilderStore } from "ducks/transactionBuilder"; import { useTransactionSettingsStore } from "ducks/transactionSettings"; @@ -58,6 +63,11 @@ import { useTokenFiatConverter } from "hooks/useTokenFiatConverter"; import { useToast } from "providers/ToastProvider"; import React, { useCallback, useEffect, useMemo, useRef } from "react"; import { TextInput, View } from "react-native"; +import { SecurityLevel } from "services/blockaid/constants"; +import { + assessTransactionSecurity, + extractSecurityWarnings, +} from "services/blockaid/helper"; type EarnAmountScreenProps = NativeStackScreenProps< EarnStackParamList, @@ -79,12 +89,14 @@ const EarnAmountScreen: React.FC = ({ route }) => { // button. const rootNavigation = useNavigation>(); + const pool = useEarnStore((state) => state.pool); const selectedAssetApy = useEarnStore((state) => state.selectedAssetApy); const selectedAssetDecimals = useEarnStore( (state) => state.selectedAssetDecimals, ); const lastSubmitFailed = useEarnStore((state) => state.lastSubmitFailed); const setSubmitFailed = useEarnStore((state) => state.setSubmitFailed); + const { overriddenBlockaidResponse } = useDebugStore(); const { resetTransaction } = useTransactionBuilderStore(); const { transactionFee, transactionTimeout } = useTransactionSettingsStore(); @@ -122,8 +134,23 @@ const EarnAmountScreen: React.FC = ({ route }) => { const xlmBalance = pricedBalances[NATIVE_TOKEN_CODE]; + // Fetches the "before" side of Review's before -> after row as soon as the + // pool/asset/account are known (well ahead of the user reaching Review), + // and writes it to the earn duck. A failed fetch is non-fatal by design — + // see the hook's own docs — so its return value is intentionally unused + // here; Review reads `currentPositionTokens` off the duck directly. + useEarnPosition({ + poolId: pool?.id ?? "", + assetId, + publicKey: account?.publicKey ?? "", + networkDetails, + }); + const amountInputRef = useRef(null); const networkFeeBottomSheetModalRef = useRef(null); + const earnReviewBottomSheetModalRef = useRef(null); + const transactionSecurityWarningBottomSheetModalRef = + useRef(null); const converter = useTokenFiatConverter({ selectedBalance: depositBalance, @@ -179,8 +206,36 @@ const EarnAmountScreen: React.FC = ({ route }) => { simulate, isSimulating, error: simulateError, + scanResult, } = useSimulateEarnDeposit(); + // `scanResult` is undefined both before any simulation has run and when the + // scan itself was unavailable (e.g. testnet, where Blockaid throws + // NETWORK_NOT_SUPPORTED) — `assessTransactionSecurity` already treats both + // the same way: "unable to scan", never a clean bill of health. + const transactionSecurityAssessment = useMemo( + () => assessTransactionSecurity(scanResult, overriddenBlockaidResponse), + [scanResult, overriddenBlockaidResponse], + ); + + const securityWarnings = useMemo( + () => extractSecurityWarnings(scanResult), + [scanResult], + ); + + const earnSecuritySeverity = useMemo(() => { + if (transactionSecurityAssessment.isMalicious) { + return SecurityLevel.MALICIOUS; + } + if (transactionSecurityAssessment.isSuspicious) { + return SecurityLevel.SUSPICIOUS; + } + if (transactionSecurityAssessment.isUnableToScan) { + return SecurityLevel.UNABLE_TO_SCAN; + } + return undefined; + }, [transactionSecurityAssessment]); + // Surface the pool's own rejection (supply cap, frozen pool, stale oracle) // reactively off the hook's error state — mirrors the established // `transactionBuilderError` toast pattern in TransactionAmountScreen. @@ -245,17 +300,58 @@ const EarnAmountScreen: React.FC = ({ route }) => { ], ); + // Applies the CTA handler's post-simulate re-clamp (see `handleCtaPress` + // and `clampXlmDepositAmount`) to this screen's local converter state. + // Mirrors `handlePercentagePress`'s fiat-mode bookkeeping above. + const handleAmountClamped = useCallback( + (newAmount: string) => { + if (showFiatAmount && depositBalance?.currentPrice) { + const tokenPrice = depositBalance.currentPrice; + if (!tokenPrice.isZero()) { + const fiatAmount = new BigNumber(newAmount) + .multipliedBy(tokenPrice) + .toFixed(2); + updateFiatDisplay(fiatAmount); + setTokenAmount(newAmount); + return; + } + } + + setTokenAmount(newAmount); + }, + [showFiatAmount, depositBalance, updateFiatDisplay, setTokenAmount], + ); + + const openReviewSheet = useCallback(() => { + earnReviewBottomSheetModalRef.current?.present(); + }, []); + /** - * Placeholder for Task 12, which builds `EarnReviewBottomSheet` and a - * `useEarnPosition` hook and wires this to present the review sheet with - * the prepared XDR / scan result `simulate` just resolved. Named now so - * the CTA's order-of-operations (fee check → simulate → open review) is - * already settled by the time Task 12 fills this in. + * Placeholder for Task 13, which builds `EarnProcessingScreen` and + * registers it on `EarnStackNavigator`. `EARN_ROUTES.EARN_PROCESSING_SCREEN` + * already exists in `EarnStackParamList`, but is NOT YET a registered + * `` (confirmed against `EarnNavigator.tsx`) — calling + * `navigation.navigate` on it now would throw at runtime. Named clearly so + * wiring it up is a one-line swap once Task 13 lands. */ - const openReviewSheet = useCallback(() => { - // TODO(Task 12): present EarnReviewBottomSheet. + const handleConfirmDeposit = useCallback(() => { + // TODO(Task 13): navigation.navigate(EARN_ROUTES.EARN_PROCESSING_SCREEN); + }, []); + + const handleCancelSecurityWarning = useCallback(() => { + transactionSecurityWarningBottomSheetModalRef.current?.dismiss(); }, []); + // Reached only via the review sheet's banner -> detail sheet path (the + // review sheet's OWN "Confirm anyway" button calls `handleConfirmDeposit` + // directly and dismisses itself) — so this dismisses both sheets before + // proceeding. + const handleConfirmAnywayFromSecuritySheet = useCallback(() => { + transactionSecurityWarningBottomSheetModalRef.current?.dismiss(); + earnReviewBottomSheetModalRef.current?.dismiss(); + handleConfirmDeposit(); + }, [handleConfirmDeposit]); + const handleCtaPress = useCallback(async () => { if (ctaState.disabled) { return; @@ -298,11 +394,57 @@ const EarnAmountScreen: React.FC = ({ route }) => { senderAddress: account.publicKey, }); - if (result) { - openReviewSheet(); + if (!result) { + // On failure, the effect above surfaces `simulateError` as a toast — + // nothing further to do here. + return; + } + + // INVARIANT Review and Task 13's Confirm rely on: the staged XDR in + // `transactionBuilder` always corresponds to the currently displayed + // `tokenAmount` by the time Review opens. + // + // `simulate` just resolved, so the duck's `sorobanResourceFeeXlm` is now + // the REAL fee (not `getMaxDepositAmount`'s pre-simulation buffer + // estimate). Re-check the amount that was just simulated against it and, + // if it no longer fits (XLM only — see `clampXlmDepositAmount`), correct + // `tokenAmount` and re-simulate for the corrected amount BEFORE opening + // Review, rather than opening Review with a display value the staged XDR + // no longer matches. + const { sorobanResourceFeeXlm } = useTransactionBuilderStore.getState(); + + const clampedAmount = clampXlmDepositAmount({ + enteredAmount: tokenAmount, + spendableXlm: spendableXlm.toFixed(), + resourceFeeXlm: sorobanResourceFeeXlm, + decimals: selectedAssetDecimals, + isXlm, + }); + + if (clampedAmount !== tokenAmount) { + handleAmountClamped(clampedAmount); + showToast({ + variant: "warning", + title: t("earnReview.amountReduced"), + toastId: "earn-review-amount-clamped", + }); + + const reSimulateResult = await simulate({ + assetId, + amount: clampedAmount, + decimals: selectedAssetDecimals, + transactionFee, + transactionTimeout, + network, + senderAddress: account.publicKey, + }); + + if (!reSimulateResult) { + return; + } } - // On failure, the effect above surfaces `simulateError` as a toast — - // nothing further to do here. + + openReviewSheet(); }, [ ctaState.disabled, xlmBalance, @@ -315,6 +457,10 @@ const EarnAmountScreen: React.FC = ({ route }) => { selectedAssetDecimals, transactionTimeout, network, + isXlm, + handleAmountClamped, + showToast, + t, openReviewSheet, ]); @@ -444,6 +590,42 @@ const EarnAmountScreen: React.FC = ({ route }) => { /> } /> + + + earnReviewBottomSheetModalRef.current?.dismiss() + } + customContent={ + + transactionSecurityWarningBottomSheetModalRef.current?.present() + } + onConfirm={handleConfirmDeposit} + /> + } + /> + + } + /> ); }; diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index 84056ccf3..bfb967c02 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -728,6 +728,17 @@ "simulationFailed": "Failed to simulate the deposit. Please try again." } }, + "earnReview": { + "title": "Review deposit", + "pool": "Pool", + "apy": "APY", + "position": "Your position", + "positionValue": "{{before}} → {{after}}", + "estMonthlyEarnings": "Est. monthly earnings", + "estYearlyEarnings": "Est. yearly earnings", + "projectionDisclaimer": "Estimated earnings are projections based on the current rate and may change based on pool activity.", + "amountReduced": "Amount reduced to cover the network fee" + }, "earnNotEnough": { "title": "You don't have any {{tokenCode}}", "bodyWithBuy": "Buy {{tokenCode}} or receive it from another wallet to deposit it into this pool.", diff --git a/src/i18n/locales/pt/translations.json b/src/i18n/locales/pt/translations.json index 571624e19..a3ce392a4 100644 --- a/src/i18n/locales/pt/translations.json +++ b/src/i18n/locales/pt/translations.json @@ -694,6 +694,17 @@ "simulationFailed": "Falha ao simular o depósito. Tente novamente." } }, + "earnReview": { + "title": "Revisar depósito", + "pool": "Pool", + "apy": "APY", + "position": "Sua posição", + "positionValue": "{{before}} → {{after}}", + "estMonthlyEarnings": "Ganhos mensais est.", + "estYearlyEarnings": "Ganhos anuais est.", + "projectionDisclaimer": "Os ganhos estimados são projeções baseadas na taxa atual e podem mudar de acordo com a atividade do pool.", + "amountReduced": "Valor reduzido para cobrir a taxa de rede" + }, "earnNotEnough": { "title": "Você não possui {{tokenCode}}", "bodyWithBuy": "Compre {{tokenCode}} ou receba de outra carteira para depositá-lo neste pool.", From 09f28a597cb70e986a2b4321a59c2562e17d363e Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Thu, 20 Aug 2026 11:09:51 -0600 Subject: [PATCH 13/27] feat(earn): add the deposit terminal screen --- .../EarnScreen/useEarnTransaction.test.ts | 320 ++++++++++++++++++ .../EarnScreen/hooks/useEarnTransaction.ts | 210 ++++++++++++ .../EarnScreen/screens/EarnAmountScreen.tsx | 101 +++++- .../screens/EarnProcessingScreen.tsx | 222 ++++++++++++ .../screens/EarnScreen/screens/index.ts | 1 + src/config/routes.ts | 2 - src/i18n/locales/en/translations.json | 7 + src/i18n/locales/pt/translations.json | 7 + 8 files changed, 859 insertions(+), 11 deletions(-) create mode 100644 __tests__/components/screens/EarnScreen/useEarnTransaction.test.ts create mode 100644 src/components/screens/EarnScreen/hooks/useEarnTransaction.ts create mode 100644 src/components/screens/EarnScreen/screens/EarnProcessingScreen.tsx diff --git a/__tests__/components/screens/EarnScreen/useEarnTransaction.test.ts b/__tests__/components/screens/EarnScreen/useEarnTransaction.test.ts new file mode 100644 index 000000000..f2c36661c --- /dev/null +++ b/__tests__/components/screens/EarnScreen/useEarnTransaction.test.ts @@ -0,0 +1,320 @@ +/* eslint-disable @fnando/consistent-import/consistent-import */ +import { renderHook, act } from "@testing-library/react-hooks"; +import { useEarnTransaction } from "components/screens/EarnScreen/hooks/useEarnTransaction"; +import { NETWORKS } from "config/constants"; +import type { ActiveAccount } from "ducks/auth"; + +const mockSignTransaction = jest.fn(); +const mockSubmitTransaction = jest.fn(); +const mockGetBuilderState = jest.fn(); +const mockSetSubmitFailed = jest.fn(); +const mockIsWalletUnlocked = jest.fn(); + +jest.mock("ducks/transactionBuilder", () => ({ + useTransactionBuilderStore: Object.assign( + () => ({ + signTransaction: mockSignTransaction, + submitTransaction: mockSubmitTransaction, + }), + { + getState: () => mockGetBuilderState(), + }, + ), +})); + +jest.mock("ducks/earn", () => ({ + // The hook reads `setSubmitFailed` via a selector + // (`useEarnStore((state) => state.setSubmitFailed)`), so the mock must + // support being called with a selector fn, matching how the real zustand + // hook re-reads live state on every render. + useEarnStore: (selector?: (state: Record) => unknown) => { + const state = { setSubmitFailed: mockSetSubmitFailed }; + return selector ? selector(state) : state; + }, +})); + +jest.mock("hooks/useGetActiveAccount", () => ({ + isWalletUnlocked: () => mockIsWalletUnlocked(), +})); + +const account: ActiveAccount = { + publicKey: "GA...", + privateKey: "SA...", + accountName: "Account 1", + id: "1", + subentryCount: 0, +}; + +const baseParams: Parameters[0] = { + account, + network: NETWORKS.TESTNET, +}; + +describe("useEarnTransaction", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockIsWalletUnlocked.mockReturnValue(true); + mockGetBuilderState.mockReturnValue({ error: "Store error message" }); + }); + + it("starts idle", () => { + const { result } = renderHook(() => useEarnTransaction(baseParams)); + + expect(result.current.status).toBe("idle"); + expect(result.current.transactionHash).toBeNull(); + expect(result.current.error).toBeNull(); + }); + + it("returns to idle WITHOUT signing or submitting when the wallet is locked", async () => { + mockIsWalletUnlocked.mockReturnValue(false); + + const { result } = renderHook(() => useEarnTransaction(baseParams)); + + await act(async () => { + await result.current.submit(); + }); + + expect(mockSignTransaction).not.toHaveBeenCalled(); + expect(mockSubmitTransaction).not.toHaveBeenCalled(); + expect(mockSetSubmitFailed).not.toHaveBeenCalled(); + expect(result.current.status).toBe("idle"); + expect(result.current.error).toBeNull(); + }); + + it("does NOT reject when submit() is called fire-and-forget and the wallet is locked", async () => { + mockIsWalletUnlocked.mockReturnValue(false); + + const { result } = renderHook(() => useEarnTransaction(baseParams)); + + let didReject = false; + await act(async () => { + await result.current.submit().catch(() => { + didReject = true; + }); + }); + + expect(didReject).toBe(false); + }); + + it("sets status to success, records the hash, and calls setSubmitFailed(false) on a clean submit", async () => { + mockSignTransaction.mockReturnValue("signed-xdr"); + mockSubmitTransaction.mockResolvedValue("tx-hash"); + + const { result } = renderHook(() => useEarnTransaction(baseParams)); + + await act(async () => { + await result.current.submit(); + }); + + expect(mockSignTransaction).toHaveBeenCalledWith({ + secretKey: account.privateKey, + network: NETWORKS.TESTNET, + }); + expect(result.current.status).toBe("success"); + expect(result.current.transactionHash).toBe("tx-hash"); + expect(result.current.error).toBeNull(); + expect(mockSetSubmitFailed).toHaveBeenCalledWith(false); + }); + + it("sets status to error and calls setSubmitFailed(true), sourcing the message from the store, when signTransaction returns null", async () => { + mockSignTransaction.mockReturnValue(null); + mockGetBuilderState.mockReturnValue({ + error: "Failed to sign transaction: bad seed", + }); + + const { result } = renderHook(() => useEarnTransaction(baseParams)); + + await act(async () => { + await result.current.submit(); + }); + + expect(mockSubmitTransaction).not.toHaveBeenCalled(); + expect(result.current.status).toBe("error"); + expect(result.current.error).toBe("Failed to sign transaction: bad seed"); + expect(mockSetSubmitFailed).toHaveBeenCalledWith(true); + }); + + it("sets status to error and calls setSubmitFailed(true), sourcing the message from the store, when submitTransaction returns null", async () => { + mockSignTransaction.mockReturnValue("signed-xdr"); + mockSubmitTransaction.mockResolvedValue(null); + mockGetBuilderState.mockReturnValue({ + error: "op_underfunded", + }); + + const { result } = renderHook(() => useEarnTransaction(baseParams)); + + await act(async () => { + await result.current.submit(); + }); + + expect(result.current.status).toBe("error"); + expect(result.current.error).toBe("op_underfunded"); + expect(result.current.transactionHash).toBeNull(); + expect(mockSetSubmitFailed).toHaveBeenCalledWith(true); + }); + + it("does NOT reject when submitTransaction rejects (e.g. the debug forced-failure override, which throws directly)", async () => { + mockSignTransaction.mockReturnValue("signed-xdr"); + mockSubmitTransaction.mockRejectedValue(new Error("DEBUG submit failure")); + + const { result } = renderHook(() => useEarnTransaction(baseParams)); + + let didReject = false; + await act(async () => { + await result.current.submit().catch(() => { + didReject = true; + }); + }); + + expect(didReject).toBe(false); + expect(result.current.status).toBe("error"); + expect(result.current.error).toBe("DEBUG submit failure"); + expect(mockSetSubmitFailed).toHaveBeenCalledWith(true); + }); + + it("reset() returns status to idle and clears error, without touching setSubmitFailed", async () => { + mockSignTransaction.mockReturnValue(null); + + const { result } = renderHook(() => useEarnTransaction(baseParams)); + + await act(async () => { + await result.current.submit(); + }); + + expect(result.current.status).toBe("error"); + + mockSetSubmitFailed.mockClear(); + + act(() => { + result.current.reset(); + }); + + expect(result.current.status).toBe("idle"); + expect(result.current.error).toBeNull(); + expect(mockSetSubmitFailed).not.toHaveBeenCalled(); + }); + + it("is a no-op when there is no active account", async () => { + const { result } = renderHook(() => + useEarnTransaction({ account: null, network: NETWORKS.TESTNET }), + ); + + await act(async () => { + await result.current.submit(); + }); + + expect(mockSignTransaction).not.toHaveBeenCalled(); + expect(result.current.status).toBe("idle"); + }); + + // Regression coverage for the finding: an abandoned submit resolving + // later must never write `setSubmitFailed` (global/persistent on + // useEarnStore) or `status`, in EITHER the success or failure branch — + // otherwise it can corrupt a completely unrelated LATER Earn session's + // retry banner. `abandon()` is what `handleCloseEarnProcessingWhileSubmitting` + // calls before navigating Home mid-submit. + describe("abandonment guard", () => { + it("does NOT call setSubmitFailed or write status when a submit resolves successfully AFTER being abandoned", async () => { + mockSignTransaction.mockReturnValue("signed-xdr"); + let resolveSubmit: (hash: string) => void = () => {}; + mockSubmitTransaction.mockImplementation( + () => + new Promise((resolve) => { + resolveSubmit = resolve; + }), + ); + + const { result } = renderHook(() => useEarnTransaction(baseParams)); + + let submitPromise: Promise = Promise.resolve(); + act(() => { + submitPromise = result.current.submit(); + }); + + // The submit is now suspended awaiting `submitTransaction` — this is + // the abandonment window. Abandon it exactly like + // `handleCloseEarnProcessingWhileSubmitting` does when the user + // closes mid-submit, BEFORE the network call resolves. + expect(result.current.status).toBe("submitting"); + act(() => { + result.current.abandon(); + }); + + await act(async () => { + resolveSubmit("tx-hash"); + await submitPromise; + }); + + // Call-absence, not just resulting state: the abandoned submit must + // never reach the persisted, global setSubmitFailed once abandoned. + expect(mockSetSubmitFailed).not.toHaveBeenCalled(); + // And it must not silently flip status to "success" either. + expect(result.current.status).toBe("submitting"); + expect(result.current.transactionHash).toBeNull(); + }); + + it("does NOT call setSubmitFailed or write status when a submit rejects AFTER being abandoned", async () => { + mockSignTransaction.mockReturnValue("signed-xdr"); + let rejectSubmit: (error: Error) => void = () => {}; + mockSubmitTransaction.mockImplementation( + () => + new Promise((_resolve, reject) => { + rejectSubmit = reject; + }), + ); + + const { result } = renderHook(() => useEarnTransaction(baseParams)); + + let submitPromise: Promise = Promise.resolve(); + act(() => { + submitPromise = result.current.submit(); + }); + + act(() => { + result.current.abandon(); + }); + + await act(async () => { + rejectSubmit(new Error("late failure after abandonment")); + await submitPromise; + }); + + expect(mockSetSubmitFailed).not.toHaveBeenCalled(); + expect(result.current.status).toBe("submitting"); + expect(result.current.error).toBeNull(); + }); + + it("reset() is a no-op while status is submitting (hardening against the same bug class)", async () => { + mockSignTransaction.mockReturnValue("signed-xdr"); + let resolveSubmit: (hash: string) => void = () => {}; + mockSubmitTransaction.mockImplementation( + () => + new Promise((resolve) => { + resolveSubmit = resolve; + }), + ); + + const { result } = renderHook(() => useEarnTransaction(baseParams)); + + let submitPromise: Promise = Promise.resolve(); + act(() => { + submitPromise = result.current.submit(); + }); + + expect(result.current.status).toBe("submitting"); + + act(() => { + result.current.reset(); + }); + + // reset() must not have dropped status back to "idle" mid-submit. + expect(result.current.status).toBe("submitting"); + + // Let the submit settle so it doesn't leak into other tests. + await act(async () => { + resolveSubmit("tx-hash"); + await submitPromise; + }); + }); + }); +}); diff --git a/src/components/screens/EarnScreen/hooks/useEarnTransaction.ts b/src/components/screens/EarnScreen/hooks/useEarnTransaction.ts new file mode 100644 index 000000000..639a0927f --- /dev/null +++ b/src/components/screens/EarnScreen/hooks/useEarnTransaction.ts @@ -0,0 +1,210 @@ +import { NETWORKS } from "config/constants"; +import { logger } from "config/logger"; +import { ActiveAccount } from "ducks/auth"; +import { useEarnStore } from "ducks/earn"; +import { useTransactionBuilderStore } from "ducks/transactionBuilder"; +import { isWalletUnlocked } from "hooks/useGetActiveAccount"; +import { useCallback, useEffect, useRef, useState } from "react"; + +export type EarnTransactionStatus = "idle" | "submitting" | "success" | "error"; + +interface UseEarnTransactionParams { + account: ActiveAccount | null; + network: NETWORKS; +} + +interface UseEarnTransactionResult { + status: EarnTransactionStatus; + transactionHash: string | null; + error: string | null; + /** + * Signs and submits the deposit XDR already staged in `transactionBuilder` + * (built by `useSimulateEarnDeposit` and confirmed to still match the + * displayed amount by `EarnAmountScreen`'s CTA handler before Review ever + * opened — see the INVARIANT comment there). No staleness re-check happens + * here by design; that invariant is what makes one unnecessary. + * + * Setting `status` to "submitting" IS the processing flag: + * `EarnAmountScreen` renders its inline `EarnProcessingScreen` whenever + * `status !== "idle"`, so calling `submit()` both flips that flag and + * kicks off the sign/submit work in a single call. + */ + submit: () => Promise; + /** + * Returns `status` to "idle" without touching the earn duck. This is not + * part of the brief's literal `{ submit, status, transactionHash, error }` + * shape, but is needed by the "error" state's "back to amount" action: + * without it, `EarnAmountScreen`'s inline gate (`status !== "idle"`) would + * have no way to drop back to the normal amount screen, where + * `lastSubmitFailed` (already set by the failed `submit()` below) drives + * the retry banner. + * + * A no-op while `status === "submitting"` — there is no call site that + * reaches `reset()` mid-submit today (the only caller renders solely in + * the "error" state), but this closes off that class of bug rather than + * relying on today's single call site staying that way. Abandoning an + * in-flight submit is `abandon()`'s job, not this one's. + */ + reset: () => void; + /** + * Marks any submit currently in flight as abandoned: when it eventually + * settles, it will skip writing `status`/`error`/`transactionHash` AND + * `useEarnStore.setSubmitFailed` — the latter is what makes this + * necessary. `setSubmitFailed` writes to a store that is global and + * persists across mounts, so without this guard a submit abandoned by + * navigating Home mid-flight could resolve later and flip + * `lastSubmitFailed` out of order, surfacing a misleading retry banner in + * an unrelated later Earn session. + * + * Mirrors the `get().requestId === newRequestId` guard already used + * around every async write in `ducks/transactionBuilder.ts` (see e.g. its + * `buildTransaction`/`submitTransaction`), except the comparison lives in + * a ref here rather than the (also global) transactionBuilder store, + * since `useEarnTransaction`'s status/hash/error are local to this hook. + * + * Called both from this hook's own unmount cleanup (defense in depth — + * unmount timing during a navigation transition isn't guaranteed to be + * synchronous) and explicitly by + * `EarnAmountScreen.handleCloseEarnProcessingWhileSubmitting`, so the + * abandonment is recorded at the moment the user acts, not only whenever + * React gets around to tearing the component down. + */ + abandon: () => void; +} + +/** + * Signs and submits an Earn deposit. Mirrors `useSwapTransaction`'s + * sign/submit path (biometrics guard, error sourcing from the + * `transactionBuilder` store) but has no build/scan step of its own — that + * already happened in `useSimulateEarnDeposit` by the time this runs. + * + * Failure is recorded in `useEarnStore.lastSubmitFailed` rather than + * component state because it is set here, on the processing screen, and + * read after the stack pops back to `EarnAmountScreen` — component state + * would be torn down in between. Because that store is global and + * persistent, `submit()` guards every write to it (and to local state) + * behind an active-request check — see `abandon()` — so a submit that the + * user has already closed away from cannot resolve later and corrupt a + * subsequent session's retry banner. + */ +export const useEarnTransaction = ({ + account, + network, +}: UseEarnTransactionParams): UseEarnTransactionResult => { + const [status, setStatus] = useState("idle"); + const [transactionHash, setTransactionHash] = useState(null); + const [error, setError] = useState(null); + const { signTransaction, submitTransaction } = useTransactionBuilderStore(); + const setSubmitFailed = useEarnStore((state) => state.setSubmitFailed); + + // `nextRequestIdRef` hands out a fresh id per `submit()` call; + // `activeRequestIdRef` names the id (if any) still allowed to write + // state. `abandon()` — and this hook's own unmount cleanup below — clear + // the latter, so a submit whose id no longer matches skips its writes + // entirely when it settles. Local to this hook (unlike + // `transactionBuilder.ts`'s equivalent, which lives on its shared store), + // since nothing outside this hook needs to read it. + const nextRequestIdRef = useRef(0); + const activeRequestIdRef = useRef(null); + + const abandon = useCallback(() => { + activeRequestIdRef.current = null; + }, []); + + // Defense in depth alongside the explicit `abandon()` call from + // `handleCloseEarnProcessingWhileSubmitting`: if this hook's owning + // component unmounts for any other reason while a submit is in flight, + // that submit is abandoned too. + useEffect( + () => () => { + activeRequestIdRef.current = null; + }, + [], + ); + + const submit = useCallback(async () => { + if (!account) { + return; + } + + nextRequestIdRef.current += 1; + const requestId = nextRequestIdRef.current; + activeRequestIdRef.current = requestId; + + setStatus("submitting"); + setError(null); + setTransactionHash(null); + + try { + // Abort cleanly if an auto-lock engaged after Review was confirmed. + // Return (don't throw): being locked isn't a deposit failure, so skip + // the catch's setSubmitFailed/error path — a hard-coded throw would + // also surface as a non-localized error message. Verbatim guard from + // `useSwapTransaction.executeSwap`. No await has happened yet at this + // point, so there is no abandonment window to check for here. + if (!isWalletUnlocked()) { + setStatus("idle"); + return; + } + + const signedXDR = signTransaction({ + secretKey: account.privateKey, + network, + }); + + if (!signedXDR) { + // Read the real reason off the store rather than inventing one. + const { error: signingError } = useTransactionBuilderStore.getState(); + throw new Error(signingError || "Failed to sign transaction"); + } + + // submitTransaction throws directly for the debug forced-failure + // override, and returns null for every other recorded failure — both + // land in this catch block. On null, read the real reason (and result + // codes) off the store rather than inventing a message. + const hash = await submitTransaction({ network }); + + if (!hash) { + const { error: submitError } = useTransactionBuilderStore.getState(); + throw new Error(submitError || "Failed to submit transaction"); + } + + // The `await` above is the abandonment window: the user may have + // closed away from this submit (or, in principle, started another) + // while it was in flight. Skip every write below — including + // `setSubmitFailed`, the global/persistent one — if so. + if (activeRequestIdRef.current !== requestId) { + return; + } + + setTransactionHash(hash); + setSubmitFailed(false); + setStatus("success"); + } catch (err) { + if (activeRequestIdRef.current !== requestId) { + return; + } + + const message = err instanceof Error ? err.message : String(err); + logger.error("useEarnTransaction", "Deposit submission failed", err); + setError(message); + setSubmitFailed(true); + setStatus("error"); + } + }, [account, network, signTransaction, submitTransaction, setSubmitFailed]); + + const reset = useCallback(() => { + // No call site reaches this mid-submit today (the only caller renders + // solely in the "error" state), but a no-op here closes off the whole + // bug class rather than relying on that staying true. Abandoning an + // in-flight submit is `abandon()`'s job. + if (status === "submitting") { + return; + } + + setStatus("idle"); + setError(null); + }, [status]); + + return { status, transactionHash, error, submit, reset, abandon }; +}; diff --git a/src/components/screens/EarnScreen/screens/EarnAmountScreen.tsx b/src/components/screens/EarnScreen/screens/EarnAmountScreen.tsx index 5e2b62691..c2353b27b 100644 --- a/src/components/screens/EarnScreen/screens/EarnAmountScreen.tsx +++ b/src/components/screens/EarnScreen/screens/EarnAmountScreen.tsx @@ -18,7 +18,9 @@ import { needsXlmForFee, } from "components/screens/EarnScreen/helpers"; import { useEarnPosition } from "components/screens/EarnScreen/hooks/useEarnPosition"; +import { useEarnTransaction } from "components/screens/EarnScreen/hooks/useEarnTransaction"; import { useSimulateEarnDeposit } from "components/screens/EarnScreen/hooks/useSimulateEarnDeposit"; +import { EarnProcessingScreen } from "components/screens/EarnScreen/screens"; import { Badge } from "components/sds/Badge"; import { Button } from "components/sds/Button"; import Icon from "components/sds/Icon"; @@ -36,6 +38,7 @@ import { ADD_FUNDS_ROUTES, EARN_ROUTES, EarnStackParamList, + MAIN_TAB_ROUTES, ROOT_NAVIGATOR_ROUTES, RootStackParamList, } from "config/routes"; @@ -74,7 +77,10 @@ type EarnAmountScreenProps = NativeStackScreenProps< typeof EARN_ROUTES.EARN_AMOUNT_SCREEN >; -const EarnAmountScreen: React.FC = ({ route }) => { +const EarnAmountScreen: React.FC = ({ + route, + navigation, +}) => { const { assetId, tokenCode } = route.params; const { t } = useAppTranslation(); const { themeColors } = useColors(); @@ -96,6 +102,7 @@ const EarnAmountScreen: React.FC = ({ route }) => { ); const lastSubmitFailed = useEarnStore((state) => state.lastSubmitFailed); const setSubmitFailed = useEarnStore((state) => state.setSubmitFailed); + const resetEarn = useEarnStore((state) => state.resetEarn); const { overriddenBlockaidResponse } = useDebugStore(); const { resetTransaction } = useTransactionBuilderStore(); @@ -326,17 +333,80 @@ const EarnAmountScreen: React.FC = ({ route }) => { earnReviewBottomSheetModalRef.current?.present(); }, []); + // `EarnProcessingScreen` is rendered INLINE below (not a registered + // route — there is no `EARN_ROUTES.EARN_PROCESSING_SCREEN`), gated on + // `earnTransactionStatus !== "idle"`. This structurally prevents a + // swipe-back gesture from abandoning an in-flight submit, which is + // stronger than a navigator's `gestureEnabled: false`. + const { + status: earnTransactionStatus, + error: earnTransactionError, + submit: submitEarnTransaction, + reset: resetEarnTransactionStatus, + abandon: abandonEarnTransaction, + } = useEarnTransaction({ account, network }); + /** - * Placeholder for Task 13, which builds `EarnProcessingScreen` and - * registers it on `EarnStackNavigator`. `EARN_ROUTES.EARN_PROCESSING_SCREEN` - * already exists in `EarnStackParamList`, but is NOT YET a registered - * `` (confirmed against `EarnNavigator.tsx`) — calling - * `navigation.navigate` on it now would throw at runtime. Named clearly so - * wiring it up is a one-line swap once Task 13 lands. + * Confirms the deposit from the review sheet. `submitEarnTransaction` + * flips `earnTransactionStatus` to "submitting" synchronously — which is + * what gates the inline `EarnProcessingScreen` render below — so calling + * it both sets the processing flag and kicks off the sign/submit work. */ const handleConfirmDeposit = useCallback(() => { - // TODO(Task 13): navigation.navigate(EARN_ROUTES.EARN_PROCESSING_SCREEN); - }, []); + submitEarnTransaction(); + }, [submitEarnTransaction]); + + // Close during "submitting": returns Home WITHOUT waiting for the result. + // There is no background-submission infrastructure to show the outcome + // once this screen is gone — but closing does not just hope the abandoned + // submit is harmless: `abandonEarnTransaction()` marks it so, and + // `useEarnTransaction`'s request-id guard (see `abandon()`'s doc there) + // makes sure that when it eventually settles, it skips every write — + // including the persisted `setSubmitFailed` duck flag — so it cannot + // surface here OR corrupt a later Earn session's retry banner. This is + // intentional, matching the flow's spec, not an oversight. + const handleCloseEarnProcessingWhileSubmitting = useCallback(() => { + abandonEarnTransaction(); + navigation.reset({ + index: 0, + routes: [ + { + // @ts-expect-error: Cross-stack navigation to MainTabStack with Home tab + name: ROOT_NAVIGATOR_ROUTES.MAIN_TAB_STACK, + state: { + routes: [{ name: MAIN_TAB_ROUTES.TAB_HOME }], + index: 0, + }, + }, + ], + }); + }, [navigation, abandonEarnTransaction]); + + // Success's "Done" action: clear the earn duck (pool/asset selection, + // lastSubmitFailed) now that the flow completed, then return Home. + const handleEarnProcessingDone = useCallback(() => { + resetEarn(); + navigation.reset({ + index: 0, + routes: [ + { + // @ts-expect-error: Cross-stack navigation to MainTabStack with Home tab + name: ROOT_NAVIGATOR_ROUTES.MAIN_TAB_STACK, + state: { + routes: [{ name: MAIN_TAB_ROUTES.TAB_HOME }], + index: 0, + }, + }, + ], + }); + }, [navigation, resetEarn]); + + // Error's action: drop back to this screen (no navigation) — the retry + // banner shows because `lastSubmitFailed` was already set by the failed + // submit. + const handleEarnProcessingBackToAmount = useCallback(() => { + resetEarnTransactionStatus(); + }, [resetEarnTransactionStatus]); const handleCancelSecurityWarning = useCallback(() => { transactionSecurityWarningBottomSheetModalRef.current?.dismiss(); @@ -481,6 +551,19 @@ const EarnAmountScreen: React.FC = ({ route }) => { }); }, [rootNavigation]); + if (earnTransactionStatus !== "idle") { + return ( + + ); + } + const availableBalanceText = depositBalance ? `${formatBalanceAmount(depositBalance, tokenCode, maxDepositableBn)} ${t( "common.available", diff --git a/src/components/screens/EarnScreen/screens/EarnProcessingScreen.tsx b/src/components/screens/EarnScreen/screens/EarnProcessingScreen.tsx new file mode 100644 index 000000000..1263317f0 --- /dev/null +++ b/src/components/screens/EarnScreen/screens/EarnProcessingScreen.tsx @@ -0,0 +1,222 @@ +import { useNavigation } from "@react-navigation/native"; +import Spinner from "components/Spinner"; +import { TokenIcon } from "components/TokenIcon"; +import { BaseLayout } from "components/layout/BaseLayout"; +import { EarnTransactionStatus } from "components/screens/EarnScreen/hooks/useEarnTransaction"; +import { Button } from "components/sds/Button"; +import Icon from "components/sds/Icon"; +import { Display, Text } from "components/sds/Typography"; +import { mapNetworkToNetworkDetails } from "config/constants"; +import { useAuthenticationStore } from "ducks/auth"; +import { useBalancesStore } from "ducks/balances"; +import { useEarnStore } from "ducks/earn"; +import { getBalanceByContractId } from "helpers/balances"; +import { formatTokenForDisplay } from "helpers/formatAmount"; +import useAppTranslation from "hooks/useAppTranslation"; +import useColors from "hooks/useColors"; +import React, { useEffect, useLayoutEffect, useMemo } from "react"; +import { View } from "react-native"; + +export interface EarnProcessingScreenProps { + /** Excludes "idle" — `EarnAmountScreen` only renders this screen once a + * submit is underway; "idle" means the normal amount screen is showing. */ + status: Exclude; + /** The amount entered on the amount screen, in display (non-raw) units. */ + tokenAmount: string; + /** The real reason sourced from `transactionBuilder`/`useEarnTransaction` + * — never a fabricated message. Only rendered when `status === "error"`. */ + error: string | null; + /** + * Close while a submit is still in flight. Per the flow's design this + * returns the user Home WITHOUT waiting for the result — there is no + * background-submission infrastructure to keep tracking the deposit once + * this screen (and the amount screen mounted underneath it) unmount. This + * is intentional: the caller (`EarnAmountScreen`) marks the in-flight + * submit abandoned via `useEarnTransaction`'s `abandon()` before + * navigating away, so when it eventually settles it skips every write — + * including the persisted `useEarnStore.setSubmitFailed` flag — meaning + * nothing surfaces here or corrupts a later Earn session's retry banner. + */ + onCloseWhileSubmitting: () => void; + /** Success's "Done" action: resets the earn duck and returns Home. */ + onDone: () => void; + /** Error's action: drops back to the amount screen, where + * `lastSubmitFailed` (already set by the failed submit) shows the retry + * banner. */ + onBackToAmount: () => void; +} + +/** + * Earn deposit terminal screen. Mirrors `SwapProcessingScreen`'s structure + * (icon + status text + a card summarizing what happened) but, like + * `EarnReviewBottomSheet`, reads the pool/asset it's summarizing directly + * off `useEarnStore` and `useBalancesStore` rather than threading them + * through props — `tokenAmount` is the one exception, since it lives in the + * amount screen's local `useTokenFiatConverter` state, not a duck. + * + * Rendered INLINE from `EarnAmountScreen` (not a registered route) behind a + * `status !== "idle"` gate — see `useEarnTransaction`. This structurally + * prevents a swipe-back gesture from abandoning an in-flight submit, which + * is stronger than relying on a navigator's `gestureEnabled: false`. + */ +const EarnProcessingScreen: React.FC = ({ + status, + tokenAmount, + error, + onCloseWhileSubmitting, + onDone, + onBackToAmount, +}) => { + const { t } = useAppTranslation(); + const { themeColors } = useColors(); + const navigation = useNavigation(); + const { network } = useAuthenticationStore(); + const { pricedBalances } = useBalancesStore(); + + const selectedAssetId = useEarnStore((state) => state.selectedAssetId); + const selectedAssetCode = useEarnStore((state) => state.selectedAssetCode); + + const networkDetails = useMemo( + () => mapNetworkToNetworkDetails(network), + [network], + ); + + const depositBalance = useMemo( + () => + getBalanceByContractId(selectedAssetId, pricedBalances, networkDetails), + [selectedAssetId, pricedBalances, networkDetails], + ); + + // This screen replaces EarnAmountScreen's body inline, but that screen + // still has a registered header (via EarnNavigator) — hide it while this + // is showing and restore it on unmount, same as SwapProcessingScreen. + useLayoutEffect(() => { + navigation.setOptions({ + headerShown: false, + }); + }, [navigation]); + + useEffect( + () => () => + navigation.setOptions({ + headerShown: true, + }), + [navigation], + ); + + const getStatusText = () => { + switch (status) { + case "success": + return t("earnProcessing.deposited"); + case "error": + return t("earnProcessing.failed"); + case "submitting": + default: + return t("earnProcessing.depositing"); + } + }; + + const getStatusIcon = () => { + switch (status) { + case "success": + return ( + + ); + case "error": + return ; + case "submitting": + default: + return ; + } + }; + + return ( + + + + + {getStatusIcon()} + + + + {getStatusText()} + + + + + + {depositBalance && ( + + )} + + + + + {formatTokenForDisplay(tokenAmount, selectedAssetCode)} + + + + + {status === "error" && error && ( + + + {error} + + + )} + + + + {status === "success" && ( + + + + )} + + {status === "error" && ( + + + + )} + + {status === "submitting" && ( + + + {t("earnProcessing.closeMessage")} + + + + )} + + + ); +}; + +export default EarnProcessingScreen; diff --git a/src/components/screens/EarnScreen/screens/index.ts b/src/components/screens/EarnScreen/screens/index.ts index be1fb5f59..42440db91 100644 --- a/src/components/screens/EarnScreen/screens/index.ts +++ b/src/components/screens/EarnScreen/screens/index.ts @@ -1,2 +1,3 @@ export { default as EarnAmountScreen } from "./EarnAmountScreen"; +export { default as EarnProcessingScreen } from "./EarnProcessingScreen"; export { EarnTokenPickerScreen } from "./EarnTokenPickerScreen"; diff --git a/src/config/routes.ts b/src/config/routes.ts index cdd90e7ca..bec2c572a 100644 --- a/src/config/routes.ts +++ b/src/config/routes.ts @@ -124,7 +124,6 @@ export const SWAP_ROUTES = { export const EARN_ROUTES = { EARN_TOKEN_PICKER_SCREEN: "EarnTokenPickerScreen", EARN_AMOUNT_SCREEN: "EarnAmountScreen", - EARN_PROCESSING_SCREEN: "EarnProcessingScreen", } as const; /** @@ -283,5 +282,4 @@ export type EarnStackParamList = { assetId: string; tokenCode: string; }; - [EARN_ROUTES.EARN_PROCESSING_SCREEN]: undefined; }; diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index bfb967c02..be72c6eae 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -746,6 +746,13 @@ "buy": "Buy {{tokenCode}}", "receive": "Receive" }, + "earnProcessing": { + "depositing": "Depositing", + "deposited": "Deposited!", + "failed": "Deposit failed", + "closeMessage": "You can close this screen, your deposit should be complete in less than a minute", + "backToAmount": "Back to amount" + }, "transactionTokenScreen": { "title": "Sending", "empty": "No tokens or collectibles to send" diff --git a/src/i18n/locales/pt/translations.json b/src/i18n/locales/pt/translations.json index a3ce392a4..b5993b0e2 100644 --- a/src/i18n/locales/pt/translations.json +++ b/src/i18n/locales/pt/translations.json @@ -712,6 +712,13 @@ "buy": "Comprar {{tokenCode}}", "receive": "Receber" }, + "earnProcessing": { + "depositing": "Depositando", + "deposited": "Depositado!", + "failed": "Falha no depósito", + "closeMessage": "Você pode fechar esta tela, seu depósito deve ser concluído em menos de um minuto", + "backToAmount": "Voltar ao valor" + }, "transactionTokenScreen": { "title": "Enviando", "empty": "Nenhum token ou colecionável para enviar" From a066b0561e8e80c5c044f1a4138b8aaf18ad4a52 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Thu, 20 Aug 2026 11:35:02 -0600 Subject: [PATCH 14/27] feat(earn): add earn analytics events and translations --- .../EarnScreen/useEarnTransaction.test.ts | 59 +++++++++++++++++-- .../EarnScreen/hooks/useEarnTransaction.ts | 29 +++++++++ .../EarnScreen/screens/EarnAmountScreen.tsx | 2 + .../screens/EarnProcessingScreen.tsx | 35 ++++++++++- src/config/analyticsConfig.ts | 50 ++++++++++++++++ src/services/analytics/index.ts | 8 +++ src/services/analytics/transactions.ts | 28 +++++++++ src/services/analytics/types.ts | 16 +++++ 8 files changed, 221 insertions(+), 6 deletions(-) diff --git a/__tests__/components/screens/EarnScreen/useEarnTransaction.test.ts b/__tests__/components/screens/EarnScreen/useEarnTransaction.test.ts index f2c36661c..4f126eae2 100644 --- a/__tests__/components/screens/EarnScreen/useEarnTransaction.test.ts +++ b/__tests__/components/screens/EarnScreen/useEarnTransaction.test.ts @@ -9,6 +9,9 @@ const mockSubmitTransaction = jest.fn(); const mockGetBuilderState = jest.fn(); const mockSetSubmitFailed = jest.fn(); const mockIsWalletUnlocked = jest.fn(); +const mockGetEarnState = jest.fn(); +const mockTrackEarnDepositSuccess = jest.fn(); +const mockTrackEarnDepositFail = jest.fn(); jest.mock("ducks/transactionBuilder", () => ({ useTransactionBuilderStore: Object.assign( @@ -26,17 +29,34 @@ jest.mock("ducks/earn", () => ({ // The hook reads `setSubmitFailed` via a selector // (`useEarnStore((state) => state.setSubmitFailed)`), so the mock must // support being called with a selector fn, matching how the real zustand - // hook re-reads live state on every render. - useEarnStore: (selector?: (state: Record) => unknown) => { - const state = { setSubmitFailed: mockSetSubmitFailed }; - return selector ? selector(state) : state; - }, + // hook re-reads live state on every render. It also reads + // pool/selectedAssetCode/selectedAssetApy via `useEarnStore.getState()` + // (for the earn_deposit.completed/.failed analytics payload), matching how + // the real zustand store exposes both the hook and a static `getState`. + useEarnStore: Object.assign( + (selector?: (state: Record) => unknown) => { + const state = { setSubmitFailed: mockSetSubmitFailed }; + return selector ? selector(state) : state; + }, + { + getState: () => mockGetEarnState(), + }, + ), })); jest.mock("hooks/useGetActiveAccount", () => ({ isWalletUnlocked: () => mockIsWalletUnlocked(), })); +jest.mock("services/analytics", () => ({ + analytics: { + trackEarnDepositSuccess: (...args: unknown[]) => + mockTrackEarnDepositSuccess(...args), + trackEarnDepositFail: (...args: unknown[]) => + mockTrackEarnDepositFail(...args), + }, +})); + const account: ActiveAccount = { publicKey: "GA...", privateKey: "SA...", @@ -55,6 +75,11 @@ describe("useEarnTransaction", () => { jest.clearAllMocks(); mockIsWalletUnlocked.mockReturnValue(true); mockGetBuilderState.mockReturnValue({ error: "Store error message" }); + mockGetEarnState.mockReturnValue({ + pool: { id: "pool-1" }, + selectedAssetCode: "USDC", + selectedAssetApy: 0.05, + }); }); it("starts idle", () => { @@ -114,6 +139,14 @@ describe("useEarnTransaction", () => { expect(result.current.transactionHash).toBe("tx-hash"); expect(result.current.error).toBeNull(); expect(mockSetSubmitFailed).toHaveBeenCalledWith(false); + + // earn_deposit.completed carries only identifiers -- no amount/fiat value. + expect(mockTrackEarnDepositSuccess).toHaveBeenCalledWith({ + assetCode: "USDC", + poolId: "pool-1", + apy: 0.05, + }); + expect(mockTrackEarnDepositFail).not.toHaveBeenCalled(); }); it("sets status to error and calls setSubmitFailed(true), sourcing the message from the store, when signTransaction returns null", async () => { @@ -139,6 +172,7 @@ describe("useEarnTransaction", () => { mockSubmitTransaction.mockResolvedValue(null); mockGetBuilderState.mockReturnValue({ error: "op_underfunded", + submitErrorResultCodes: { operations: ["op_underfunded"] }, }); const { result } = renderHook(() => useEarnTransaction(baseParams)); @@ -151,6 +185,18 @@ describe("useEarnTransaction", () => { expect(result.current.error).toBe("op_underfunded"); expect(result.current.transactionHash).toBeNull(); expect(mockSetSubmitFailed).toHaveBeenCalledWith(true); + + // earn_deposit.failed carries the same identifiers plus reason_code -- + // sourced from `submitErrorResultCodes` (NOT threaded through this + // hook's own return shape), never the free-text error message, and no + // amount/fiat value. + expect(mockTrackEarnDepositFail).toHaveBeenCalledWith({ + assetCode: "USDC", + poolId: "pool-1", + apy: 0.05, + errorCode: "op_underfunded", + }); + expect(mockTrackEarnDepositSuccess).not.toHaveBeenCalled(); }); it("does NOT reject when submitTransaction rejects (e.g. the debug forced-failure override, which throws directly)", async () => { @@ -251,6 +297,8 @@ describe("useEarnTransaction", () => { // And it must not silently flip status to "success" either. expect(result.current.status).toBe("submitting"); expect(result.current.transactionHash).toBeNull(); + // Nor should an abandoned submit fire a business-outcome event. + expect(mockTrackEarnDepositSuccess).not.toHaveBeenCalled(); }); it("does NOT call setSubmitFailed or write status when a submit rejects AFTER being abandoned", async () => { @@ -282,6 +330,7 @@ describe("useEarnTransaction", () => { expect(mockSetSubmitFailed).not.toHaveBeenCalled(); expect(result.current.status).toBe("submitting"); expect(result.current.error).toBeNull(); + expect(mockTrackEarnDepositFail).not.toHaveBeenCalled(); }); it("reset() is a no-op while status is submitting (hardening against the same bug class)", async () => { diff --git a/src/components/screens/EarnScreen/hooks/useEarnTransaction.ts b/src/components/screens/EarnScreen/hooks/useEarnTransaction.ts index 639a0927f..39d593092 100644 --- a/src/components/screens/EarnScreen/hooks/useEarnTransaction.ts +++ b/src/components/screens/EarnScreen/hooks/useEarnTransaction.ts @@ -5,6 +5,7 @@ import { useEarnStore } from "ducks/earn"; import { useTransactionBuilderStore } from "ducks/transactionBuilder"; import { isWalletUnlocked } from "hooks/useGetActiveAccount"; import { useCallback, useEffect, useRef, useState } from "react"; +import { analytics } from "services/analytics"; export type EarnTransactionStatus = "idle" | "submitting" | "success" | "error"; @@ -180,6 +181,17 @@ export const useEarnTransaction = ({ setTransactionHash(hash); setSubmitFailed(false); setStatus("success"); + + // No amount/fiat value on this event by design (product decision, + // non-negotiable) -- only the identifiers, matching payment.completed / + // swap.completed's shape. + const { pool, selectedAssetCode, selectedAssetApy } = + useEarnStore.getState(); + analytics.trackEarnDepositSuccess({ + assetCode: selectedAssetCode, + poolId: pool?.id, + apy: selectedAssetApy, + }); } catch (err) { if (activeRequestIdRef.current !== requestId) { return; @@ -190,6 +202,23 @@ export const useEarnTransaction = ({ setError(message); setSubmitFailed(true); setStatus("error"); + + // `submitErrorResultCodes` isn't threaded through this hook's own + // return shape (see the hook doc), so read it directly off the store, + // same as the sign/submit failure branches above. Null for a signing + // failure (there's no Horizon result yet) -- reason_code falls back to + // "unknown" in that case, same as trackTransactionError elsewhere. + const { pool, selectedAssetCode, selectedAssetApy } = + useEarnStore.getState(); + const { submitErrorResultCodes } = useTransactionBuilderStore.getState(); + analytics.trackEarnDepositFail({ + assetCode: selectedAssetCode, + poolId: pool?.id, + apy: selectedAssetApy, + errorCode: + submitErrorResultCodes?.operations?.[0] || + submitErrorResultCodes?.transaction, + }); } }, [account, network, signTransaction, submitTransaction, setSubmitFailed]); diff --git a/src/components/screens/EarnScreen/screens/EarnAmountScreen.tsx b/src/components/screens/EarnScreen/screens/EarnAmountScreen.tsx index c2353b27b..61ec337f2 100644 --- a/src/components/screens/EarnScreen/screens/EarnAmountScreen.tsx +++ b/src/components/screens/EarnScreen/screens/EarnAmountScreen.tsx @@ -29,6 +29,7 @@ import { NoticeBannerVariants, } from "components/sds/NoticeBanner"; import { Text } from "components/sds/Typography"; +import { AnalyticsEvent } from "config/analyticsConfig"; import { NATIVE_TOKEN_CODE, TransactionContext, @@ -679,6 +680,7 @@ const EarnAmountScreen: React.FC = ({ handleCloseModal={() => earnReviewBottomSheetModalRef.current?.dismiss() } + analyticsEvent={AnalyticsEvent.VIEW_EARN_REVIEW} customContent={ = ({ const selectedAssetId = useEarnStore((state) => state.selectedAssetId); const selectedAssetCode = useEarnStore((state) => state.selectedAssetCode); + const hasEmittedSuccessView = useRef(false); const networkDetails = useMemo( () => mapNetworkToNetworkDetails(network), @@ -87,6 +90,36 @@ const EarnProcessingScreen: React.FC = ({ [selectedAssetId, pricedBalances, networkDetails], ); + // Neither this screen nor the review sheet it follows is a registered + // route (see the component doc above), so screen.viewed for the + // earn_processing funnel stage doesn't come free from route-based + // analytics -- emit it manually. The component only ever mounts once + // `status` has left "idle" (see `EarnAmountScreen`'s inline gate), so a + // bare mount effect fires this exactly once per submission, mirroring + // `TransactionProcessingScreen`'s VIEW_SEND_PROCESSING emission. + useEffect(() => { + track( + AnalyticsEvent.SCREEN_VIEWED, + buildScreenViewedProps(AnalyticsEvent.VIEW_EARN_PROCESSING), + ); + }, []); + + // This same screen also renders the terminal success state (see + // `getStatusText`/`getStatusIcon` below), so emit the earn_success funnel + // stage when `status` settles into "success" -- completing + // select_token -> amount -> review -> processing -> success cross-platform. + // Guarded to fire at most once per mount, mirroring + // `TransactionProcessingScreen`'s VIEW_SEND_SUCCESS emission. + useEffect(() => { + if (status === "success" && !hasEmittedSuccessView.current) { + hasEmittedSuccessView.current = true; + track( + AnalyticsEvent.SCREEN_VIEWED, + buildScreenViewedProps(AnalyticsEvent.VIEW_EARN_SUCCESS), + ); + } + }, [status]); + // This screen replaces EarnAmountScreen's body inline, but that screen // still has a registered header (via EarnNavigator) — hide it while this // is showing and restore it on unmount, same as SwapProcessingScreen. diff --git a/src/config/analyticsConfig.ts b/src/config/analyticsConfig.ts index 96746d5f0..e2178e650 100644 --- a/src/config/analyticsConfig.ts +++ b/src/config/analyticsConfig.ts @@ -79,6 +79,19 @@ export enum AnalyticsEvent { VIEW_SEARCH_TOKEN = "search_asset", VIEW_ADD_TOKEN_MANUALLY = "add_asset_manually", + // Earn (Blend deposit). Values match the extension's decided funnel + // (earn_intro -> earn_select_token -> earn_amount -> earn_review -> + // earn_processing -> earn_success) so the two platforms merge into one + // funnel in Amplitude. Mobile has no earn_intro (descoped from Phase 1); + // this covers earn_select_token onward. VIEW_EARN_REVIEW/_PROCESSING/ + // _SUCCESS are fired manually (see SCREEN_CATALOG / call sites below) since + // none of the three is a registered route. + VIEW_EARN_TOKEN_PICKER = "earn_select_token", + VIEW_EARN_AMOUNT = "earn_amount", + VIEW_EARN_REVIEW = "earn_review", + VIEW_EARN_PROCESSING = "earn_processing", + VIEW_EARN_SUCCESS = "earn_success", + // --------------------------------------------------------------------------- // Domain (action / outcome) events (#2883) // @@ -150,6 +163,13 @@ export enum AnalyticsEvent { SWAP_XLM_RESERVE_INSUFFICIENT_SHOWN = "swap.xlm_reserve_insufficient_shown", SWAP_QUOTE_EXPIRED = "swap.quote_expired", + // Earn deposit (Blend). No amounts/fiat on either event (product decision, + // non-negotiable) -- only asset_code, pool_id, apy, matching how + // payment.completed / swap.completed are shaped. + EARN_DEPOSIT_SUCCESS = "earn_deposit.completed", + // carries reason_code + EARN_DEPOSIT_FAIL = "earn_deposit.failed", + // Send collectible SEND_COLLECTIBLE_SUCCESS = "collectible_send.completed", // carries reason_code @@ -308,6 +328,7 @@ export enum AnalyticsFlow { DISCOVERY = "discovery", SECURITY = "security", HISTORY = "history", + EARN = "earn", } /** @@ -515,6 +536,29 @@ const SCREEN_CATALOG: Record = { [AnalyticsEvent.VIEW_MANAGE_WALLETS]: { flow: AnalyticsFlow.SETTINGS, }, + // Earn (Blend deposit). VIEW_EARN_REVIEW/_PROCESSING/_SUCCESS are not + // routes -- they're retargeted to screen.viewed manually (BottomSheet's + // analyticsEvent prop for the review sheet; a direct track() call for the + // inline processing/success screen), same mechanism as the Scan/Receive + // per-tab views above. + [AnalyticsEvent.VIEW_EARN_TOKEN_PICKER]: { + flow: AnalyticsFlow.EARN, + }, + [AnalyticsEvent.VIEW_EARN_AMOUNT]: { + flow: AnalyticsFlow.EARN, + }, + [AnalyticsEvent.VIEW_EARN_REVIEW]: { + flow: AnalyticsFlow.EARN, + step: "confirm", + }, + [AnalyticsEvent.VIEW_EARN_PROCESSING]: { + flow: AnalyticsFlow.EARN, + step: "processing", + }, + [AnalyticsEvent.VIEW_EARN_SUCCESS]: { + flow: AnalyticsFlow.EARN, + step: "success", + }, }; /** @@ -624,6 +668,12 @@ export const CUSTOM_ROUTE_MAPPINGS: Record = { // Buy XLM override BuyXLMScreen: AnalyticsEvent.VIEW_BUY_XLM, + + // Earn override: auto-derivation would produce "earn_token_picker", which + // does not match the extension's "earn_select_token" funnel stage. + // EarnAmountScreen needs no entry here -- it auto-derives to "earn_amount", + // which already matches. + EarnTokenPickerScreen: AnalyticsEvent.VIEW_EARN_TOKEN_PICKER, }; /** diff --git a/src/services/analytics/index.ts b/src/services/analytics/index.ts index 9dbbc5410..06b9b2ed0 100644 --- a/src/services/analytics/index.ts +++ b/src/services/analytics/index.ts @@ -45,6 +45,8 @@ import { trackGrantAccessBlocked, trackHistoryOpenItem, trackSendCollectibleSuccess, + trackEarnDepositSuccess, + trackEarnDepositFail, } from "services/analytics/transactions"; import { identifyUser } from "services/analytics/user"; @@ -99,6 +101,10 @@ export interface AnalyticsInstance { // History analytics readonly trackHistoryOpenItem: typeof trackHistoryOpenItem; + // Earn (Blend deposit) analytics + readonly trackEarnDepositSuccess: typeof trackEarnDepositSuccess; + readonly trackEarnDepositFail: typeof trackEarnDepositFail; + // Discover analytics readonly trackDiscoverProtocolOpened: typeof trackDiscoverProtocolOpened; readonly trackDiscoverProtocolDetailsViewed: typeof trackDiscoverProtocolDetailsViewed; @@ -163,6 +169,8 @@ export const analytics: AnalyticsInstance = { trackGrantAccessFail, trackGrantAccessBlocked, trackHistoryOpenItem, + trackEarnDepositSuccess, + trackEarnDepositFail, trackDiscoverProtocolOpened, trackDiscoverProtocolDetailsViewed, trackDiscoverProtocolOpenedFromDetails, diff --git a/src/services/analytics/transactions.ts b/src/services/analytics/transactions.ts index 4edd6a574..6fc511f0b 100644 --- a/src/services/analytics/transactions.ts +++ b/src/services/analytics/transactions.ts @@ -10,6 +10,8 @@ import type { TransactionSuccessEvent, SwapSuccessEvent, TransactionErrorEvent, + EarnDepositSuccessEvent, + EarnDepositErrorEvent, } from "services/analytics/types"; // `origin` is the bare dApp hostname (never a full URL) — matches the @@ -282,6 +284,32 @@ export const trackGrantAccessBlocked = ( }); }; +// earn_deposit.completed carries asset_code + pool_id + apy. NO amount/fiat +// value -- product decision (matches payment.completed / swap.completed's +// no-amount shape), non-negotiable per the cross-platform funnel agreement. +export const trackEarnDepositSuccess = ( + data: EarnDepositSuccessEvent, +): void => { + track(AnalyticsEvent.EARN_DEPOSIT_SUCCESS, { + asset_code: data.assetCode, + pool_id: data.poolId, + apy: data.apy, + }); +}; + +// earn_deposit.failed carries the same identifiers plus reason_code. Like +// trackTransactionError, reason_code is the machine-readable Horizon result +// code (falling back to "unknown") -- never the free-text error message, to +// avoid unbounded reason_code cardinality. +export const trackEarnDepositFail = (data: EarnDepositErrorEvent): void => { + track(AnalyticsEvent.EARN_DEPOSIT_FAIL, { + asset_code: data.assetCode, + pool_id: data.poolId, + apy: data.apy, + reason_code: data.errorCode ?? "unknown", + }); +}; + export const trackHistoryOpenItem = (source: string): void => { // history.item_opened carries `source` (not the tx hash). track(AnalyticsEvent.HISTORY_OPEN_ITEM, { source }); diff --git a/src/services/analytics/types.ts b/src/services/analytics/types.ts index f74b21821..a26519210 100644 --- a/src/services/analytics/types.ts +++ b/src/services/analytics/types.ts @@ -64,3 +64,19 @@ export interface QRScanEvent { timeToScan?: number; error?: string; } + +// Earn deposit (Blend). No amount/fiat fields by design (product decision, +// non-negotiable) -- only identifiers, matching payment.completed / +// swap.completed's shape. +export interface EarnDepositSuccessEvent { + assetCode: string; + poolId?: string; + apy?: number | null; +} + +export interface EarnDepositErrorEvent { + assetCode: string; + poolId?: string; + apy?: number | null; + errorCode?: string; +} From 03aa0372b5730d34aec2784e3f1225d464c78c24 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Thu, 20 Aug 2026 12:54:41 -0600 Subject: [PATCH 15/27] fix(earn): address final review findings --- .../EarnScreen/EarnAmountScreen.test.tsx | 476 ++++++++++++++++++ .../screens/EarnScreen/helpers.test.ts | 13 + .../screens/EarnScreen/useEarnTokens.test.ts | 37 ++ __tests__/ducks/earn.test.ts | 19 + __tests__/navigators/EarnNavigator.test.tsx | 46 ++ .../EarnScreen/helpers/getNotEnoughVariant.ts | 5 +- .../EarnScreen/helpers/poolDescriptions.ts | 4 +- .../EarnScreen/screens/EarnAmountScreen.tsx | 16 +- src/ducks/earn.ts | 7 + src/i18n/locales/en/translations.json | 2 +- src/i18n/locales/pt/translations.json | 2 +- src/navigators/EarnNavigator.tsx | 29 +- 12 files changed, 640 insertions(+), 16 deletions(-) create mode 100644 __tests__/components/screens/EarnScreen/EarnAmountScreen.test.tsx diff --git a/__tests__/components/screens/EarnScreen/EarnAmountScreen.test.tsx b/__tests__/components/screens/EarnScreen/EarnAmountScreen.test.tsx new file mode 100644 index 000000000..73ccbe563 --- /dev/null +++ b/__tests__/components/screens/EarnScreen/EarnAmountScreen.test.tsx @@ -0,0 +1,476 @@ +/* eslint-disable @fnando/consistent-import/consistent-import */ +import { NativeStackScreenProps } from "@react-navigation/native-stack"; +import { fireEvent } from "@testing-library/react-native"; +import BigNumber from "bignumber.js"; +import EarnAmountScreen from "components/screens/EarnScreen/screens/EarnAmountScreen"; +import { EARN_ROUTES, EarnStackParamList } from "config/routes"; +import { useEarnStore } from "ducks/earn"; +import { useTransactionBuilderStore } from "ducks/transactionBuilder"; +import { useTransactionSettingsStore } from "ducks/transactionSettings"; +import { renderWithProviders } from "helpers/testUtils"; +import useGetActiveAccount from "hooks/useGetActiveAccount"; +import { useTokenFiatConverter } from "hooks/useTokenFiatConverter"; +import React from "react"; + +import { mockGestureHandler } from "../../../../__mocks__/gesture-handler"; +import { mockUseColors } from "../../../../__mocks__/use-colors"; + +mockGestureHandler(); +mockUseColors(); + +// This is the screen the final whole-branch review flagged as untested +// (FIX 3): it owns the CTA precedence, the fee-gate ordering, the +// clamp/re-simulate sequence, the inline processing gate, and the retry- +// banner lifecycle. It is also where FIX 2 (the ~5,000x-too-low fee-headroom +// gate) actually lives, so the fee-gate tests below double as FIX 2's +// coverage. +// +// Mocking approach modeled on +// __tests__/components/screens/SwapScreen/SwapAmountScreen.test.tsx: a +// stubbed that records each sheet's imperative ref (by +// declaration order) instead of rendering `customContent`, so we can assert +// which sheet was presented — and in what order relative to `simulate` — +// without needing to mount the real review/fee/security sheets. + +type SheetRefSpy = { + present: jest.Mock; + dismiss: jest.Mock; +}; +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace, vars-on-top, no-var, no-underscore-dangle, @typescript-eslint/naming-convention + var __earnAmountMockSheetRefs: SheetRefSpy[]; +} +// eslint-disable-next-line no-underscore-dangle +globalThis.__earnAmountMockSheetRefs = []; + +jest.mock("components/BottomSheet", () => { + /* eslint-disable global-require, @typescript-eslint/no-var-requires, @typescript-eslint/no-shadow */ + const ReactModule = require("react"); + const RNModule = require("react-native"); + /* eslint-enable global-require, @typescript-eslint/no-var-requires, @typescript-eslint/no-shadow */ + + const NoopSheet = (props: { modalRef?: React.RefObject }) => { + const { modalRef } = props; + ReactModule.useImperativeHandle(modalRef, () => { + const spy: SheetRefSpy = { present: jest.fn(), dismiss: jest.fn() }; + // eslint-disable-next-line no-underscore-dangle + globalThis.__earnAmountMockSheetRefs.push(spy); + return spy; + }, []); + // Don't render customContent — mirrors Swap's stub. The child sheets + // (fee/review/security) are never mounted, so they need no mocks of + // their own. + return ReactModule.createElement(RNModule.View); + }; + return { __esModule: true, default: NoopSheet }; +}); + +// EarnAmountScreen renders EarnProcessingScreen inline (not a registered +// route) whenever mockEarnTransactionStatus !== "idle". Stub it via the barrel +// it's imported through — EarnAmountScreen itself is imported by its own +// path below, so this mock only affects that one barrel import. +jest.mock("components/screens/EarnScreen/screens", () => { + // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires + const RNModule = require("react-native"); + return { + EarnProcessingScreen: ({ status }: { status: string }) => ( + + ), + }; +}); + +const mockSimulate = jest.fn(); +const mockSubmitEarnTransaction = jest.fn(); +const mockResetEarnTransactionStatus = jest.fn(); +const mockAbandonEarnTransaction = jest.fn(); + +let mockEarnTransactionStatus: "idle" | "submitting" | "success" | "error" = + "idle"; + +jest.mock("components/screens/EarnScreen/hooks/useSimulateEarnDeposit", () => ({ + useSimulateEarnDeposit: () => ({ + simulate: mockSimulate, + isSimulating: false, + error: null, + scanResult: undefined, + }), +})); + +jest.mock("components/screens/EarnScreen/hooks/useEarnTransaction", () => ({ + useEarnTransaction: () => ({ + status: mockEarnTransactionStatus, + error: null, + submit: mockSubmitEarnTransaction, + reset: mockResetEarnTransactionStatus, + abandon: mockAbandonEarnTransaction, + }), +})); + +jest.mock("components/screens/EarnScreen/hooks/useEarnPosition", () => ({ + useEarnPosition: () => ({ currentPositionTokens: "0" }), +})); + +jest.mock("hooks/useGetActiveAccount"); +jest.mock("hooks/useTokenFiatConverter"); + +jest.mock("hooks/useNetworkFees", () => ({ + useNetworkFees: () => ({ + recommendedFee: "", + networkCongestion: "low", + feePresets: {}, + }), + clearNetworkFeesCache: jest.fn(), +})); + +jest.mock("hooks/useInitialRecommendedFee", () => ({ + useInitialRecommendedFee: jest.fn(), +})); + +const mockCalculateSpendableAmount = jest.fn(); +const mockGetBalanceByContractId = jest.fn(); +jest.mock("helpers/balances", () => ({ + // Real implementation for everything else — `TokenIcon`/`TokenIconWithBadge` + // (rendered by the real, unmocked `AmountCard`) reach into this module too + // (`isLiquidityPool`, `getTokenIdentifier`), and a full replacement would + // silently drop those. + // eslint-disable-next-line global-require, @typescript-eslint/no-var-requires + ...jest.requireActual("helpers/balances"), + calculateSpendableAmount: (...args: unknown[]) => + mockCalculateSpendableAmount(...args), + getBalanceByContractId: (...args: unknown[]) => + mockGetBalanceByContractId(...args), +})); + +jest.mock("ducks/auth", () => ({ + useAuthenticationStore: () => ({ network: "TESTNET" }), +})); + +// eslint-disable-next-line prefer-const +let mockPricedBalances: Record = {}; +jest.mock("ducks/balances", () => ({ + useBalancesStore: () => ({ pricedBalances: mockPricedBalances }), +})); + +jest.mock("ducks/debug", () => ({ + useDebugStore: () => ({ overriddenBlockaidResponse: undefined }), +})); + +jest.mock("services/blockaid/helper", () => ({ + assessTransactionSecurity: () => ({ + isMalicious: false, + isSuspicious: false, + isUnableToScan: false, + }), + extractSecurityWarnings: () => [], +})); + +const mockShowToast = jest.fn(); +jest.mock("providers/ToastProvider", () => { + /* eslint-disable global-require, @typescript-eslint/no-var-requires */ + const ReactModule = require("react"); + const RNModule = require("react-native"); + /* eslint-enable global-require, @typescript-eslint/no-var-requires */ + return { + ToastProvider: ({ children }: { children: React.ReactNode }) => + ReactModule.createElement(RNModule.View, null, children), + useToast: () => ({ showToast: mockShowToast }), + }; +}); + +// Plain-key translation, matching the established pattern in +// TransactionAmountScreen.test.tsx — assertions target the i18n key itself +// rather than locale copy. +jest.mock("hooks/useAppTranslation", () => ({ + __esModule: true, + default: () => ({ t: (key: string) => key }), +})); + +const USDC_ASSET_ID = + "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75"; +const POOL_ID = "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD"; + +const USDC_BALANCE = { + id: USDC_ASSET_ID, + contractId: USDC_ASSET_ID, + token: { code: "USDC", type: "credit_alphanum4" }, + total: new BigNumber("1000"), + available: new BigNumber("1000"), + currentPrice: new BigNumber("1"), +} as never; + +const XLM_BALANCE = { + id: "XLM", + token: { type: "native", code: "XLM" }, + total: new BigNumber("10"), + available: new BigNumber("10"), +} as never; + +type Props = NativeStackScreenProps< + EarnStackParamList, + typeof EARN_ROUTES.EARN_AMOUNT_SCREEN +>; + +const makeNavigation = () => + ({ + navigate: jest.fn(), + goBack: jest.fn(), + reset: jest.fn(), + }) as unknown as Props["navigation"]; + +const makeRoute = () => + ({ + key: "earn-amount", + name: EARN_ROUTES.EARN_AMOUNT_SCREEN, + params: { assetId: USDC_ASSET_ID, tokenCode: "USDC" }, + }) as unknown as Props["route"]; + +/** + * Configures the two `calculateSpendableAmount` call sites EarnAmountScreen + * makes: once for the deposit asset (drives `maxDepositable`) and once for + * XLM (drives the fee-headroom check). When the deposit asset IS XLM, both + * calls target the same balance object, so `xlm` wins for both — matching + * production, where there is only one balance to ask. + */ +const setSpendable = ({ deposit, xlm }: { deposit: string; xlm: string }) => { + mockCalculateSpendableAmount.mockImplementation( + ({ balance }: { balance: unknown }) => + balance === XLM_BALANCE ? new BigNumber(xlm) : new BigNumber(deposit), + ); +}; + +const setTokenAmount = ( + tokenAmount: string, + overrides: Record = {}, +) => { + (useTokenFiatConverter as jest.Mock).mockReturnValue({ + tokenAmount, + tokenAmountDisplay: tokenAmount, + tokenAmountDisplayRaw: null, + fiatAmount: "0", + fiatAmountDisplay: "0", + fiatAmountDisplayRaw: null, + showFiatAmount: false, + pasteRejectNonce: 0, + setTokenAmount: jest.fn(), + setFiatAmount: jest.fn(), + setShowFiatAmount: jest.fn(), + setDisplayAmountFromText: jest.fn(), + updateFiatDisplay: jest.fn(), + ...overrides, + }); +}; + +describe("EarnAmountScreen", () => { + beforeEach(() => { + jest.clearAllMocks(); + // eslint-disable-next-line no-underscore-dangle + globalThis.__earnAmountMockSheetRefs = []; + mockEarnTransactionStatus = "idle"; + mockPricedBalances = { XLM: XLM_BALANCE, [USDC_ASSET_ID]: USDC_BALANCE }; + + useEarnStore.getState().resetEarn(); + useEarnStore.getState().setPool({ id: POOL_ID, name: "Fixed" } as never); + useEarnStore.getState().selectAsset({ + assetId: USDC_ASSET_ID, + apy: 0.1694, + code: "USDC", + decimals: 7, + }); + + useTransactionSettingsStore.getState().resetSettings(); + useTransactionBuilderStore.getState().resetTransaction(); + + (useGetActiveAccount as jest.Mock).mockReturnValue({ + account: { + publicKey: "GSENDER", + privateKey: "SPRIVATE", + subentryCount: 0, + }, + }); + + mockGetBalanceByContractId.mockReturnValue(USDC_BALANCE); + // Generous defaults: enough of both assets that no guard fires unless a + // test deliberately narrows it. + setSpendable({ deposit: "1000", xlm: "10" }); + setTokenAmount("10"); + + mockSimulate.mockResolvedValue({ + preparedXdr: "prepared-xdr", + scanResult: undefined, + }); + }); + + const renderScreen = () => + renderWithProviders( + , + ); + + describe("CTA state machine", () => { + it("disables the CTA with 'insufficient' when the spendable balance is zero", () => { + setSpendable({ deposit: "0", xlm: "10" }); + setTokenAmount("0"); + + const { getByTestId } = renderScreen(); + const cta = getByTestId("earn-amount-cta"); + + expect(cta).toHaveTextContent("earnAmount.insufficientFunds"); + expect(cta.props.accessibilityState?.disabled).toBe(true); + }); + + it("disables the CTA with 'enter' when the amount is zero", () => { + setTokenAmount("0"); + + const { getByTestId } = renderScreen(); + const cta = getByTestId("earn-amount-cta"); + + expect(cta).toHaveTextContent("earnAmount.enterAmount"); + expect(cta.props.accessibilityState?.disabled).toBe(true); + }); + + it("disables the CTA with 'insufficient' when the amount exceeds the max depositable", () => { + setSpendable({ deposit: "10", xlm: "10" }); + setTokenAmount("9999"); + + const { getByTestId } = renderScreen(); + const cta = getByTestId("earn-amount-cta"); + + expect(cta).toHaveTextContent("earnAmount.insufficientFunds"); + expect(cta.props.accessibilityState?.disabled).toBe(true); + }); + + it("enables the CTA with 'review' once a valid amount is entered", () => { + const { getByTestId } = renderScreen(); + const cta = getByTestId("earn-amount-cta"); + + expect(cta).toHaveTextContent("earnAmount.review"); + expect(cta.props.accessibilityState?.disabled).toBeFalsy(); + }); + }); + + describe("fee-headroom gate (FIX 2)", () => { + it("opens the fee sheet BEFORE calling simulate when spendable XLM sits in the resource-fee band", async () => { + // transactionFee defaults to MIN_TRANSACTION_FEE ("0.00001"). The + // buffered threshold is transactionFee + BLEND_DEPOSIT_XLM_FEE_BUFFER + // ("0.5") = "0.50001". 0.1 clears the bare inclusion fee (the + // pre-fix gate) but falls inside the real resource-fee band, which is + // exactly the ~5,000x gap FIX 2 closes. + setSpendable({ deposit: "1000", xlm: "0.1" }); + setTokenAmount("10"); + + const { getByTestId } = renderScreen(); + + await fireEvent.press(getByTestId("earn-amount-cta")); + await Promise.resolve(); + + // eslint-disable-next-line no-underscore-dangle + const [feeSheet] = globalThis.__earnAmountMockSheetRefs; + expect(feeSheet.present).toHaveBeenCalledTimes(1); + expect(mockSimulate).not.toHaveBeenCalled(); + }); + + it("does not trip the gate for an XLM deposit that leaves >= 0.5 XLM spendable", async () => { + // Regression guard noted in the review: the CTA's own insufficient- + // funds check already keeps any XLM amount that reaches this gate at + // >= 0.5 XLM spendable, so the raised threshold must not fire here. + mockGetBalanceByContractId.mockReturnValue(XLM_BALANCE); + setSpendable({ deposit: "0.6", xlm: "0.6" }); + setTokenAmount("0.05"); + + useTransactionBuilderStore.setState({ sorobanResourceFeeXlm: "0.01" }); + + const { getByTestId } = renderScreen(); + + await fireEvent.press(getByTestId("earn-amount-cta")); + await Promise.resolve(); + await Promise.resolve(); + + // eslint-disable-next-line no-underscore-dangle + const [feeSheet] = globalThis.__earnAmountMockSheetRefs; + expect(feeSheet.present).not.toHaveBeenCalled(); + expect(mockSimulate).toHaveBeenCalledTimes(1); + }); + }); + + describe("simulate -> Review ordering", () => { + it("opens Review only after simulate resolves successfully", async () => { + mockSimulate.mockResolvedValue({ + preparedXdr: "prepared-xdr", + scanResult: undefined, + }); + useTransactionBuilderStore.setState({ sorobanResourceFeeXlm: "0.01" }); + + const { getByTestId } = renderScreen(); + + await fireEvent.press(getByTestId("earn-amount-cta")); + await Promise.resolve(); + await Promise.resolve(); + + expect(mockSimulate).toHaveBeenCalledTimes(1); + // eslint-disable-next-line no-underscore-dangle + const [, reviewSheet] = globalThis.__earnAmountMockSheetRefs; + expect(reviewSheet.present).toHaveBeenCalledTimes(1); + }); + + it("does not open Review when simulate fails", async () => { + mockSimulate.mockResolvedValue(null); + + const { getByTestId } = renderScreen(); + + await fireEvent.press(getByTestId("earn-amount-cta")); + await Promise.resolve(); + await Promise.resolve(); + + expect(mockSimulate).toHaveBeenCalledTimes(1); + // eslint-disable-next-line no-underscore-dangle + const [, reviewSheet] = globalThis.__earnAmountMockSheetRefs; + expect(reviewSheet.present).not.toHaveBeenCalled(); + }); + }); + + describe("inline processing gate", () => { + it("renders the processing screen inline once status leaves idle", () => { + mockEarnTransactionStatus = "submitting"; + + const { getByTestId, queryByTestId } = renderScreen(); + + expect(getByTestId("earn-processing-mock")).toBeTruthy(); + expect(queryByTestId("earn-amount-screen")).toBeNull(); + }); + + it("renders the normal amount screen while status is idle", () => { + const { getByTestId, queryByTestId } = renderScreen(); + + expect(getByTestId("earn-amount-screen")).toBeTruthy(); + expect(queryByTestId("earn-processing-mock")).toBeNull(); + }); + }); + + describe("retry banner lifecycle", () => { + it("shows the retry banner when lastSubmitFailed is true, and clears it on an amount edit", () => { + useEarnStore.setState({ lastSubmitFailed: true }); + + const { getByText, queryByText, rerender } = renderScreen(); + + expect(getByText("earnAmount.retryBanner")).toBeTruthy(); + + // Simulate the user editing the amount: the tokenAmount the screen + // reads changes across a re-render of the SAME mounted instance. + setTokenAmount("5"); + rerender( + , + ); + + expect(useEarnStore.getState().lastSubmitFailed).toBe(false); + expect(queryByText("earnAmount.retryBanner")).toBeNull(); + }); + + it("does not show the retry banner when lastSubmitFailed is false", () => { + const { queryByText } = renderScreen(); + + expect(queryByText("earnAmount.retryBanner")).toBeNull(); + }); + }); +}); diff --git a/__tests__/components/screens/EarnScreen/helpers.test.ts b/__tests__/components/screens/EarnScreen/helpers.test.ts index 6e734c550..ffcbfce28 100644 --- a/__tests__/components/screens/EarnScreen/helpers.test.ts +++ b/__tests__/components/screens/EarnScreen/helpers.test.ts @@ -321,6 +321,19 @@ describe("hasSwappableBalance", () => { ); }); + // Every other `true` case in this file goes through the native() fixture, + // so a held classic asset never actually exercised the `true` branch of + // the "is this a classic token" check — a regression that inverted the + // `issuer in balance.token` guard would have passed the whole suite. + it("counts a held classic asset other than the target as swappable", () => { + expect( + hasSwappableBalance( + { "AQUA:GISSUER2": classic("AQUA", "GISSUER2", "100") }, + "USDC:GISSUER", + ), + ).toBe(true); + }); + it("excludes the target asset itself so dust does not look swappable", () => { expect( hasSwappableBalance( diff --git a/__tests__/components/screens/EarnScreen/useEarnTokens.test.ts b/__tests__/components/screens/EarnScreen/useEarnTokens.test.ts index 2d9054607..fdff42041 100644 --- a/__tests__/components/screens/EarnScreen/useEarnTokens.test.ts +++ b/__tests__/components/screens/EarnScreen/useEarnTokens.test.ts @@ -61,9 +61,25 @@ describe("buildEarnTokenRows", () => { expect(held).toHaveLength(1); expect(held[0].total).toBe("50"); + // The held row must carry the real balance through — `EarnTokenRow` + // relies on it to render the exact icon rather than reconstructing one + // from catalog data alone. + expect(held[0].balance).toBe(balances["USDC:GISSUER"]); expect(supported).toHaveLength(0); }); + it("leaves `balance` undefined for a zero-balance (supported) row", () => { + const { supported } = buildEarnTokenRows({ + options: [option()], + poolId: POOL_ID, + balances: {} as never, + networkDetails, + findBalance: () => undefined, + }); + + expect(supported[0].balance).toBeUndefined(); + }); + it("skips assets the allowlisted pool does not offer", () => { const { held, supported } = buildEarnTokenRows({ options: [option({ pools: [] })], @@ -91,6 +107,27 @@ describe("buildEarnTokenRows", () => { expect(supported[0].code).toBe(`${USDC_SAC.slice(0, 4)}…`); }); + it("falls back to the held balance's own code when the catalog symbol is missing", () => { + // The middle rung of the fallback chain: `option.symbol` absent but a + // held balance exists, so its `token.code` should be used in preference + // to the truncated-id last resort. Only the truncated-id branch (no + // symbol AND no balance) was previously covered. + const balance = { + total: new BigNumber("50"), + token: { code: "USDC", issuer: { key: "GISSUER" } }, + } as never; + + const { held } = buildEarnTokenRows({ + options: [option({ symbol: null, name: null })], + poolId: POOL_ID, + balances: {} as never, + networkDetails, + findBalance: () => balance, + }); + + expect(held[0].code).toBe("USDC"); + }); + it("defaults decimals when the catalog omits them", () => { const { supported } = buildEarnTokenRows({ options: [option({ decimals: null })], diff --git a/__tests__/ducks/earn.test.ts b/__tests__/ducks/earn.test.ts index 0c6595232..8da1836cf 100644 --- a/__tests__/ducks/earn.test.ts +++ b/__tests__/ducks/earn.test.ts @@ -30,6 +30,25 @@ describe("earn duck", () => { expect(state.selectedAssetDecimals).toBe(7); }); + // FIX 1: without this, picking asset A (whose position fetch succeeds), + // backing out, then picking asset B (whose fetch fails, per + // `useEarnPosition`'s deliberately non-fatal design) would carry A's + // position into B's Review "before" value — even without ever leaving + // the Earn flow, so the navigator-level `resetEarn()` alone would not + // have caught it. + it("clears currentPositionTokens on an asset switch, even mid-flow", () => { + useEarnStore.getState().setCurrentPositionTokens("5000000000"); + + useEarnStore.getState().selectAsset({ + assetId: "CANOTHERASSET", + apy: 0.05, + code: "XLM", + decimals: 7, + }); + + expect(useEarnStore.getState().currentPositionTokens).toBe("0"); + }); + it("keeps a null rate null rather than coercing it to zero", () => { useEarnStore .getState() diff --git a/__tests__/navigators/EarnNavigator.test.tsx b/__tests__/navigators/EarnNavigator.test.tsx index bad2b6cb0..783dc2187 100644 --- a/__tests__/navigators/EarnNavigator.test.tsx +++ b/__tests__/navigators/EarnNavigator.test.tsx @@ -2,6 +2,7 @@ import { NavigationContainer } from "@react-navigation/native"; import { act } from "@testing-library/react-native"; import { MIN_TRANSACTION_FEE } from "config/constants"; +import { useEarnStore } from "ducks/earn"; import { useTransactionBuilderStore } from "ducks/transactionBuilder"; import { useTransactionSettingsStore } from "ducks/transactionSettings"; import { renderWithProviders } from "helpers/testUtils"; @@ -32,6 +33,7 @@ describe("EarnStackNavigator teardown", () => { act(() => { useTransactionSettingsStore.getState().resetSettings(); useTransactionBuilderStore.getState().resetTransaction(); + useEarnStore.getState().resetEarn(); }); }); @@ -74,4 +76,48 @@ describe("EarnStackNavigator teardown", () => { expect(useTransactionBuilderStore.getState().transactionXDR).toBeNull(); expect(mockClearNetworkFeesCache).toHaveBeenCalledTimes(1); }); + + // FIX 1: prior to this fix, `resetEarn()` was only ever called from the + // success-Done handler — every other exit (back from the picker, back + // from Amount, close-while-submitting, error -> back -> out) left the + // earn duck populated, which meant a stale `lastSubmitFailed` retry banner + // and (worse) a stale `currentPositionTokens` "before" value could leak + // into a later, unrelated Earn session. Verifies the teardown clears the + // duck on every unmount, matching the other two stores above. + it("resets the earn duck on unmount", () => { + act(() => { + useEarnStore.getState().setPool({ id: "CPOOL", name: "Fixed" } as never); + useEarnStore.getState().selectAsset({ + assetId: "CASSET", + apy: 0.05, + code: "USDC", + decimals: 7, + }); + useEarnStore.getState().setCurrentPositionTokens("5000000000"); + useEarnStore.getState().setSubmitFailed(true); + }); + + const { unmount } = renderWithProviders( + + + , + ); + + // Not reset merely by mounting — only on unmount, same as the stores above. + expect(useEarnStore.getState().pool).toEqual({ + id: "CPOOL", + name: "Fixed", + }); + expect(useEarnStore.getState().lastSubmitFailed).toBe(true); + + act(() => { + unmount(); + }); + + const state = useEarnStore.getState(); + expect(state.pool).toBeNull(); + expect(state.selectedAssetId).toBe(""); + expect(state.currentPositionTokens).toBe("0"); + expect(state.lastSubmitFailed).toBe(false); + }); }); diff --git a/src/components/screens/EarnScreen/helpers/getNotEnoughVariant.ts b/src/components/screens/EarnScreen/helpers/getNotEnoughVariant.ts index 91eeaa2a6..1272cf683 100644 --- a/src/components/screens/EarnScreen/helpers/getNotEnoughVariant.ts +++ b/src/components/screens/EarnScreen/helpers/getNotEnoughVariant.ts @@ -53,10 +53,13 @@ export const hasSwappableBalance = ( targetIdentifier: string, ) => Object.entries(balances).some(([identifier, balance]) => { - // Contract-only tokens are not swappable, and LP shares are not a token. + // Contract-only (Soroban) balances are not swappable. if ("contractId" in balance) { return false; } + // LiquidityPoolBalance carries no `token` property (its tokens live + // under `reserves` instead) — this is what actually excludes LP shares, + // not the contractId check above. if (!("token" in balance)) { return false; } diff --git a/src/components/screens/EarnScreen/helpers/poolDescriptions.ts b/src/components/screens/EarnScreen/helpers/poolDescriptions.ts index e11fdeb56..ce7cba6ee 100644 --- a/src/components/screens/EarnScreen/helpers/poolDescriptions.ts +++ b/src/components/screens/EarnScreen/helpers/poolDescriptions.ts @@ -11,8 +11,8 @@ * * Returns an i18n KEY, not translated prose — copy lives in the translation * files like everywhere else in this app (see `src/i18n/locales/*`). The key - * below (`earnPoolDetails.descriptions.fixedPool`) does not exist there yet; - * a later task adds it. + * below (`earnPoolDetails.descriptions.fixedPool`) exists in both `en` and + * `pt`. */ const POOL_DESCRIPTION_KEYS: Record = { // Fixed Pool v2 — mainnet diff --git a/src/components/screens/EarnScreen/screens/EarnAmountScreen.tsx b/src/components/screens/EarnScreen/screens/EarnAmountScreen.tsx index 61ec337f2..d63aebf13 100644 --- a/src/components/screens/EarnScreen/screens/EarnAmountScreen.tsx +++ b/src/components/screens/EarnScreen/screens/EarnAmountScreen.tsx @@ -30,6 +30,7 @@ import { } from "components/sds/NoticeBanner"; import { Text } from "components/sds/Typography"; import { AnalyticsEvent } from "config/analyticsConfig"; +import { BLEND_DEPOSIT_XLM_FEE_BUFFER } from "config/blend"; import { NATIVE_TOKEN_CODE, TransactionContext, @@ -445,10 +446,21 @@ const EarnAmountScreen: React.FC = ({ }) : new BigNumber(0); + // `transactionFee` alone is just the inclusion fee (~0.00001 XLM). A + // Blend `submit` is dominated by its resource fee, which this gate + // cannot know precisely before simulating — so, like + // `getMaxDepositAmount`, it checks against the same generous + // `BLEND_DEPOSIT_XLM_FEE_BUFFER` pre-simulation estimate rather than the + // bare inclusion fee. Without this, a non-XLM deposit with XLM spendable + // somewhere in the ~0.00001-0.055 XLM band would clear this gate, simulate + // fine (the invocation never touches XLM), and only fail at Horizon + // submission — the exact case this sheet exists to catch. if ( needsXlmForFee({ spendableXlm: spendableXlm.toFixed(), - fee: transactionFee, + fee: new BigNumber(transactionFee) + .plus(BLEND_DEPOSIT_XLM_FEE_BUFFER) + .toFixed(), }) ) { networkFeeBottomSheetModalRef.current?.present(); @@ -596,7 +608,7 @@ const EarnAmountScreen: React.FC = ({ label={t("earnAmount.depositLabel")} selectedToken={depositBalance} pickerLabel={tokenCode} - onPickerPress={() => {}} + onPickerPress={() => navigation.goBack()} pickerTestID="earn-amount-token-pill" inputTestID="earn-amount-input" focusTriggerTestID="earn-amount-focus-trigger" diff --git a/src/ducks/earn.ts b/src/ducks/earn.ts index a5ffbdb5b..16a52b356 100644 --- a/src/ducks/earn.ts +++ b/src/ducks/earn.ts @@ -69,12 +69,19 @@ export const useEarnStore = create((set) => ({ setPool: (pool) => set({ pool }), + // Resets `currentPositionTokens` back to its "0" default on every asset + // switch, mid-flow or not. `useEarnPosition`'s fetch is non-fatal by + // design (see its docs) and leaves the duck holding whatever it already + // had on failure — without this reset, picking asset A (fetch succeeds), + // backing out, then picking asset B (fetch fails) would carry A's + // position into B's Review "before" value. selectAsset: ({ assetId, apy, code, decimals }) => set({ selectedAssetId: assetId, selectedAssetApy: apy, selectedAssetCode: code, selectedAssetDecimals: decimals, + currentPositionTokens: initialState.currentPositionTokens, }), setCurrentPositionTokens: (currentPositionTokens) => diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index be72c6eae..01bcea160 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -697,7 +697,7 @@ "apyDisclaimer": "*APY is an estimate and may change based on pool activity.", "error": { "title": "We couldn't load Earn right now", - "body": "Please check your connection and try again.", + "body": "Earn is temporarily unavailable. Please try again shortly.", "retry": "Try again" } }, diff --git a/src/i18n/locales/pt/translations.json b/src/i18n/locales/pt/translations.json index b5993b0e2..7182247d3 100644 --- a/src/i18n/locales/pt/translations.json +++ b/src/i18n/locales/pt/translations.json @@ -663,7 +663,7 @@ "apyDisclaimer": "*A APY é uma estimativa e pode mudar de acordo com a atividade do pool.", "error": { "title": "Não foi possível carregar o Earn agora", - "body": "Verifique sua conexão e tente novamente.", + "body": "O Earn está temporariamente indisponível. Tente novamente em breve.", "retry": "Tentar novamente" } }, diff --git a/src/navigators/EarnNavigator.tsx b/src/navigators/EarnNavigator.tsx index caf87aeed..908105b94 100644 --- a/src/navigators/EarnNavigator.tsx +++ b/src/navigators/EarnNavigator.tsx @@ -6,6 +6,7 @@ import { EarnTokenPickerScreen, } from "components/screens/EarnScreen/screens"; import { EARN_ROUTES, EarnStackParamList } from "config/routes"; +import { useEarnStore } from "ducks/earn"; import { useTransactionBuilderStore } from "ducks/transactionBuilder"; import { useTransactionSettingsStore } from "ducks/transactionSettings"; import { getScreenBottomNavigateOptions } from "helpers/navigationOptions"; @@ -23,19 +24,29 @@ export const EarnStackNavigator = () => { // on open. Same rationale as SwapNavigator. useNetworkFees(); - // Reset earn-flow fee/transaction state when the whole flow unmounts, so - // EVERY exit path (X, hardware/gesture back, or programmatic) leaves a - // clean slate — matches SendPaymentNavigator's teardown. `transactionFee`/ - // `transactionTimeout` come from the SHARED `useTransactionSettingsStore` - // (Earn reuses TransactionContext.Send rather than a dedicated context, - // see EarnAmountScreen), so without this reset a fee the user manually - // customized in Send would silently leak into Earn's deposit — a real risk - // given Earn's resource fee runs ~5,000x the inclusion fee. Also clears the - // frozen network-fee snapshot so the next flow re-fetches fresh values. + // Reset earn-flow state when the whole flow unmounts, so EVERY exit path + // (X, hardware/gesture back, or programmatic) leaves a clean slate. + // `transactionFee`/`transactionTimeout` come from the SHARED + // `useTransactionSettingsStore` (Earn reuses TransactionContext.Send rather + // than a dedicated context, see EarnAmountScreen), so without this reset a + // fee the user manually customized in Send would silently leak into Earn's + // deposit — a real risk given Earn's resource fee runs ~5,000x the + // inclusion fee. Also clears the frozen network-fee snapshot so the next + // flow re-fetches fresh values. + // + // `useEarnStore.resetEarn()` is Earn's equivalent of + // `SendPaymentNavigator`'s `useSendRecipientStore.resetSendRecipient()` — + // its own domain duck, reset here for the same reason: prior to this fix, + // `resetEarn()` was only ever called from the success "Done" handler, so + // every other exit (back from the picker, back from Amount, close-while- + // submitting, error -> back -> out) left `pool`/asset selection, + // `currentPositionTokens`, and `lastSubmitFailed` populated for a later, + // unrelated Earn session to inherit. useEffect( () => () => { useTransactionSettingsStore.getState().resetSettings(); useTransactionBuilderStore.getState().resetTransaction(); + useEarnStore.getState().resetEarn(); clearNetworkFeesCache(); }, [], From a0da0ad32fa743e1f50e8fb886253dea9446d442 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Thu, 20 Aug 2026 14:47:00 -0600 Subject: [PATCH 16/27] fix(earn): replace the XLM fee buffer with a measured shortfall check --- .../EarnScreen/EarnAmountScreen.test.tsx | 230 ++++++++++++++++-- .../screens/EarnScreen/helpers.test.ts | 151 +++++------- .../EarnScreen/useEarnTransaction.test.ts | 6 +- __tests__/config/blend.test.ts | 7 - .../components/EarnReviewBottomSheet.tsx | 14 +- .../EarnScreen/helpers/earnCtaState.ts | 130 ++++------ .../EarnScreen/screens/EarnAmountScreen.tsx | 185 +++++++------- src/config/analyticsConfig.ts | 10 +- src/config/blend.ts | 14 -- src/i18n/locales/en/translations.json | 7 +- src/i18n/locales/pt/translations.json | 7 +- src/services/analytics/transactions.ts | 7 +- 12 files changed, 455 insertions(+), 313 deletions(-) diff --git a/__tests__/components/screens/EarnScreen/EarnAmountScreen.test.tsx b/__tests__/components/screens/EarnScreen/EarnAmountScreen.test.tsx index 73ccbe563..2309874fd 100644 --- a/__tests__/components/screens/EarnScreen/EarnAmountScreen.test.tsx +++ b/__tests__/components/screens/EarnScreen/EarnAmountScreen.test.tsx @@ -2,6 +2,7 @@ import { NativeStackScreenProps } from "@react-navigation/native-stack"; import { fireEvent } from "@testing-library/react-native"; import BigNumber from "bignumber.js"; +import { UNKNOWN_RESOURCE_FEE_FLOOR_XLM } from "components/screens/EarnScreen/helpers"; import EarnAmountScreen from "components/screens/EarnScreen/screens/EarnAmountScreen"; import { EARN_ROUTES, EarnStackParamList } from "config/routes"; import { useEarnStore } from "ducks/earn"; @@ -90,11 +91,16 @@ const mockAbandonEarnTransaction = jest.fn(); let mockEarnTransactionStatus: "idle" | "submitting" | "success" | "error" = "idle"; +// Settable per-test so the failed-simulation message-swap effect (see +// `EarnAmountScreen`'s `simulateError` useEffect) can be exercised without +// actually driving a rejected `simulate()` call through this mock. +let mockSimulateError: string | null = null; + jest.mock("components/screens/EarnScreen/hooks/useSimulateEarnDeposit", () => ({ useSimulateEarnDeposit: () => ({ simulate: mockSimulate, isSimulating: false, - error: null, + error: mockSimulateError, scanResult: undefined, }), })); @@ -269,6 +275,7 @@ describe("EarnAmountScreen", () => { // eslint-disable-next-line no-underscore-dangle globalThis.__earnAmountMockSheetRefs = []; mockEarnTransactionStatus = "idle"; + mockSimulateError = null; mockPricedBalances = { XLM: XLM_BALANCE, [USDC_ASSET_ID]: USDC_BALANCE }; useEarnStore.getState().resetEarn(); @@ -348,16 +355,30 @@ describe("EarnAmountScreen", () => { expect(cta).toHaveTextContent("earnAmount.review"); expect(cta.props.accessibilityState?.disabled).toBeFalsy(); }); + + it("offers the whole spendable balance for an XLM deposit -- no fee buffer held back", () => { + // A prior design held back a guessed 0.5 XLM buffer from Max, which + // made depositing the full spendable balance read as "insufficient". + // That buffer is gone: the full balance is now a valid amount. + mockGetBalanceByContractId.mockReturnValue(XLM_BALANCE); + setSpendable({ deposit: "100", xlm: "100" }); + setTokenAmount("100"); + + const { getByTestId } = renderScreen(); + const cta = getByTestId("earn-amount-cta"); + + expect(cta).toHaveTextContent("earnAmount.review"); + expect(cta.props.accessibilityState?.disabled).toBeFalsy(); + }); }); - describe("fee-headroom gate (FIX 2)", () => { - it("opens the fee sheet BEFORE calling simulate when spendable XLM sits in the resource-fee band", async () => { - // transactionFee defaults to MIN_TRANSACTION_FEE ("0.00001"). The - // buffered threshold is transactionFee + BLEND_DEPOSIT_XLM_FEE_BUFFER - // ("0.5") = "0.50001". 0.1 clears the bare inclusion fee (the - // pre-fix gate) but falls inside the real resource-fee band, which is - // exactly the ~5,000x gap FIX 2 closes. - setSpendable({ deposit: "1000", xlm: "0.1" }); + describe("fee-headroom gate", () => { + it("opens the fee sheet BEFORE calling simulate when the account has no spendable XLM at all", async () => { + // The 0.5 XLM buffer is gone, so this pre-simulation gate now only + // needs to catch a zero-XLM account -- anything else clears it and is + // caught post-simulation instead (see "post-simulation fee shortfall" + // below), once the real resource fee is known. + setSpendable({ deposit: "1000", xlm: "0" }); setTokenAmount("10"); const { getByTestId } = renderScreen(); @@ -371,12 +392,12 @@ describe("EarnAmountScreen", () => { expect(mockSimulate).not.toHaveBeenCalled(); }); - it("does not trip the gate for an XLM deposit that leaves >= 0.5 XLM spendable", async () => { - // Regression guard noted in the review: the CTA's own insufficient- - // funds check already keeps any XLM amount that reaches this gate at - // >= 0.5 XLM spendable, so the raised threshold must not fire here. + it("does not trip the gate once there is any spendable XLM, even far less than a Blend submit's resource fee", async () => { + // This is exactly the case the buffer used to guess at pre-simulation. + // Now it clears this gate (it is not "zero XLM") and simulate runs; + // whether it actually covers the real fee is the shortfall check's job. mockGetBalanceByContractId.mockReturnValue(XLM_BALANCE); - setSpendable({ deposit: "0.6", xlm: "0.6" }); + setSpendable({ deposit: "0.1", xlm: "0.1" }); setTokenAmount("0.05"); useTransactionBuilderStore.setState({ sorobanResourceFeeXlm: "0.01" }); @@ -394,6 +415,187 @@ describe("EarnAmountScreen", () => { }); }); + describe("post-simulation fee shortfall", () => { + it("blocks Review and surfaces the reduce-by amount when the deposit does not leave enough XLM for the measured fee", async () => { + // The whole spendable balance is offered (no buffer held back), so + // depositing all of it leaves nothing for the resource fee simulation + // is about to report. + mockGetBalanceByContractId.mockReturnValue(XLM_BALANCE); + setSpendable({ deposit: "100", xlm: "100" }); + setTokenAmount("100"); + + mockSimulate.mockImplementation(() => { + useTransactionBuilderStore.setState({ + sorobanResourceFeeXlm: "0.0546395", + }); + return { preparedXdr: "prepared-xdr", scanResult: undefined }; + }); + + const { getByTestId } = renderScreen(); + + await fireEvent.press(getByTestId("earn-amount-cta")); + await Promise.resolve(); + await Promise.resolve(); + + expect(mockSimulate).toHaveBeenCalledTimes(1); + // eslint-disable-next-line no-underscore-dangle + const [, reviewSheet] = globalThis.__earnAmountMockSheetRefs; + expect(reviewSheet.present).not.toHaveBeenCalled(); + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + variant: "error", + toastId: "earn-fee-shortfall", + }), + ); + }); + + it("never computes a shortfall for a non-XLM deposit -- its fee comes from an untouched XLM balance", async () => { + // Deposit asset is USDC (this suite's default balance); depositing the + // whole USDC balance cannot squeeze the separate XLM balance the fee + // is actually paid from. + setSpendable({ deposit: "1000", xlm: "10" }); + setTokenAmount("1000"); + + mockSimulate.mockImplementation(() => { + useTransactionBuilderStore.setState({ + sorobanResourceFeeXlm: "0.0546395", + }); + return { preparedXdr: "prepared-xdr", scanResult: undefined }; + }); + + const { getByTestId } = renderScreen(); + + await fireEvent.press(getByTestId("earn-amount-cta")); + await Promise.resolve(); + await Promise.resolve(); + + // eslint-disable-next-line no-underscore-dangle + const [, reviewSheet] = globalThis.__earnAmountMockSheetRefs; + expect(reviewSheet.present).toHaveBeenCalledTimes(1); + expect(mockShowToast).not.toHaveBeenCalledWith( + expect.objectContaining({ toastId: "earn-fee-shortfall" }), + ); + }); + + it("blocks with the shortfall message when the resource fee is unknown (null) and the deposit is near-max", async () => { + // `null` must never be treated as "0": a near-max deposit leaves less + // room than the unknown-fee floor, so this must still block rather + // than silently letting Review open (the bug the fix report's finding + // flagged -- feeding "0" here would report "0" every time, since + // tokenAmount <= spendableXlm always holds by construction). + mockGetBalanceByContractId.mockReturnValue(XLM_BALANCE); + setSpendable({ deposit: "100", xlm: "100" }); + setTokenAmount("99.95"); + + mockSimulate.mockImplementation(() => { + useTransactionBuilderStore.setState({ sorobanResourceFeeXlm: null }); + return { preparedXdr: "prepared-xdr", scanResult: undefined }; + }); + + const { getByTestId } = renderScreen(); + + await fireEvent.press(getByTestId("earn-amount-cta")); + await Promise.resolve(); + await Promise.resolve(); + + // eslint-disable-next-line no-underscore-dangle + const [, reviewSheet] = globalThis.__earnAmountMockSheetRefs; + expect(reviewSheet.present).not.toHaveBeenCalled(); + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + variant: "error", + toastId: "earn-fee-shortfall", + }), + ); + }); + + it("still lets Review open when the resource fee is unknown (null) but the deposit leaves well over the unknown-fee floor", async () => { + mockGetBalanceByContractId.mockReturnValue(XLM_BALANCE); + setSpendable({ deposit: "100", xlm: "100" }); + setTokenAmount("10"); + + mockSimulate.mockImplementation(() => { + useTransactionBuilderStore.setState({ sorobanResourceFeeXlm: null }); + return { preparedXdr: "prepared-xdr", scanResult: undefined }; + }); + + const { getByTestId } = renderScreen(); + + await fireEvent.press(getByTestId("earn-amount-cta")); + await Promise.resolve(); + await Promise.resolve(); + + // eslint-disable-next-line no-underscore-dangle + const [, reviewSheet] = globalThis.__earnAmountMockSheetRefs; + expect(reviewSheet.present).toHaveBeenCalledTimes(1); + expect(mockShowToast).not.toHaveBeenCalledWith( + expect.objectContaining({ toastId: "earn-fee-shortfall" }), + ); + }); + + it("leaves Max and the enterable amount unaffected by the unknown-fee floor", () => { + // The floor only feeds the post-simulation shortfall check on the rare + // null-fee branch -- it must never reduce what Max/the percentage + // buttons offer or what the CTA treats as "too high", regardless of + // what `sorobanResourceFeeXlm` happens to be. Depositing the FULL + // spendable balance must stay enabled even with the floor sitting in + // the duck. + mockGetBalanceByContractId.mockReturnValue(XLM_BALANCE); + setSpendable({ deposit: "100", xlm: "100" }); + setTokenAmount("100"); + useTransactionBuilderStore.setState({ + sorobanResourceFeeXlm: UNKNOWN_RESOURCE_FEE_FLOOR_XLM, + }); + + const { getByTestId } = renderScreen(); + const cta = getByTestId("earn-amount-cta"); + + expect(cta).toHaveTextContent("earnAmount.review"); + expect(cta.props.accessibilityState?.disabled).toBeFalsy(); + }); + }); + + describe("failed-simulation message", () => { + it("replaces a balance rejection on an XLM deposit with the fee-specific message", () => { + mockGetBalanceByContractId.mockReturnValue(XLM_BALANCE); + setSpendable({ deposit: "100", xlm: "100" }); + mockSimulateError = + "host invocation failed: HostError: Error(Contract, #10)"; + + renderScreen(); + + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ + title: "earnAmount.errors.insufficientBalanceForFee", + }), + ); + }); + + it("keeps the pool's own rejection for a non-balance failure on an XLM deposit", () => { + mockGetBalanceByContractId.mockReturnValue(XLM_BALANCE); + setSpendable({ deposit: "100", xlm: "100" }); + mockSimulateError = "pool is frozen"; + + renderScreen(); + + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ title: "pool is frozen" }), + ); + }); + + it("keeps the pool's own rejection for a non-XLM deposit even on a balance-shaped message", () => { + // USDC is this suite's default deposit asset -- a fee-specific reword + // would be wrong here since the fee comes from a separate XLM balance. + mockSimulateError = "Error(Contract, #10)"; + + renderScreen(); + + expect(mockShowToast).toHaveBeenCalledWith( + expect.objectContaining({ title: "Error(Contract, #10)" }), + ); + }); + }); + describe("simulate -> Review ordering", () => { it("opens Review only after simulate resolves successfully", async () => { mockSimulate.mockResolvedValue({ diff --git a/__tests__/components/screens/EarnScreen/helpers.test.ts b/__tests__/components/screens/EarnScreen/helpers.test.ts index ffcbfce28..1a89be817 100644 --- a/__tests__/components/screens/EarnScreen/helpers.test.ts +++ b/__tests__/components/screens/EarnScreen/helpers.test.ts @@ -1,16 +1,17 @@ import BigNumber from "bignumber.js"; import { NotEnoughVariant, - clampXlmDepositAmount, + UNKNOWN_RESOURCE_FEE_FLOOR_XLM, formatCompactUsd, formatProjection, formatRate, getEarnCtaState, - getMaxDepositAmount, getNotEnoughVariant, getPercentageDepositAmount, getPoolDescriptionKey, + getXlmFeeShortfall, hasSwappableBalance, + isInsufficientBalanceFailure, isOnrampableAsset, needsXlmForFee, projectEarnings, @@ -82,115 +83,89 @@ describe("needsXlmForFee", () => { }); }); -describe("getMaxDepositAmount", () => { - it("holds back a fee buffer for XLM", () => { - // A Blend submit's resource fee dwarfs the inclusion fee already netted out - // by calculateSpendableAmount, so Max must reserve more. - expect(getMaxDepositAmount({ availableBalance: "100", isXlm: true })).toBe( - "99.5", - ); - }); - - it("never returns a negative amount", () => { - expect(getMaxDepositAmount({ availableBalance: "0.1", isXlm: true })).toBe( - "0", - ); - }); - - it("leaves non-XLM balances untouched — their fee comes from a separate balance", () => { - expect(getMaxDepositAmount({ availableBalance: "100", isXlm: false })).toBe( - "100", - ); +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"); }); -}); -describe("clampXlmDepositAmount", () => { - it("reduces the entered amount to fit once the real resource fee is known", () => { + 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( - clampXlmDepositAmount({ - enteredAmount: "99.5", - spendableXlm: "99.5", - resourceFeeXlm: "0.0546", - decimals: 7, - isXlm: true, + getXlmFeeShortfall({ + spendableXlm: "100", + amount: "100", + resourceFee: "0.0546395", }), - ).toBe("99.4454"); + ).toBe("0.0546395"); }); - it("leaves an amount that already fits untouched", () => { + it("reports only the part of the fee that is not covered", () => { expect( - clampXlmDepositAmount({ - enteredAmount: "50", - spendableXlm: "99.5", - resourceFeeXlm: "0.0546", - decimals: 7, - isXlm: true, + getXlmFeeShortfall({ + spendableXlm: "100", + amount: "99.99", + resourceFee: "0.0546395", }), - ).toBe("50"); + ).toBe("0.0446395"); }); - it("never returns negative — floors at zero when the fee exceeds spendable", () => { + it("is zero when the remainder exactly covers the fee", () => { expect( - clampXlmDepositAmount({ - enteredAmount: "1", - spendableXlm: "0.02", - resourceFeeXlm: "0.0546", - decimals: 7, - isXlm: true, + getXlmFeeShortfall({ + spendableXlm: "100", + amount: "99.9453605", + resourceFee: "0.0546395", }), ).toBe("0"); }); - it("rounds DOWN at the asset's decimals rather than up or to nearest", () => { - // 10 - 0.0546000004 = 9.9453999996 -> floors to 9.945399 at 6 decimals, - // never 9.9454 (which would round up and no longer strictly fit). + it("does not lose precision on a large balance", () => { expect( - clampXlmDepositAmount({ - enteredAmount: "10", - spendableXlm: "10", - resourceFeeXlm: "0.0546000004", - decimals: 6, - isXlm: true, + getXlmFeeShortfall({ + spendableXlm: "1691.6912345", + amount: "1691.6912345", + resourceFee: "0.0546395", }), - ).toBe("9.945399"); - }); - - it("is idempotent: re-running on its own output is a no-op", () => { - const params = { - enteredAmount: "99.5", - spendableXlm: "99.5", - resourceFeeXlm: "0.0546", - decimals: 7, - isXlm: true, - }; - const clamped = clampXlmDepositAmount(params); - expect(clampXlmDepositAmount({ ...params, enteredAmount: clamped })).toBe( - clamped, - ); + ).toBe("0.0546395"); }); +}); - it("does not apply to a non-XLM deposit — its fee comes from a separate balance", () => { +describe("UNKNOWN_RESOURCE_FEE_FLOOR_XLM", () => { + it("sits comfortably above the measured Blend submit resource fee", () => { + // Measured at ~546,395 stroops (0.0546 XLM) against the live mainnet + // pool. The floor must exceed that to actually catch an unreported fee. + expect(Number(UNKNOWN_RESOURCE_FEE_FLOOR_XLM)).toBeGreaterThan(0.0546); + }); +}); + +describe("isInsufficientBalanceFailure", () => { + it("matches the asset contract's BalanceError", () => { expect( - clampXlmDepositAmount({ - enteredAmount: "1000", - spendableXlm: "1", - resourceFeeXlm: "0.0546", - decimals: 7, - isXlm: false, - }), - ).toBe("1000"); + 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("does not apply when the resource fee is unknown (null) rather than treating it as zero", () => { + 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( - clampXlmDepositAmount({ - enteredAmount: "99.5", - spendableXlm: "50", - resourceFeeXlm: null, - decimals: 7, - isXlm: true, - }), - ).toBe("99.5"); + isInsufficientBalanceFailure("HostError: Error(Contract, #1206)"), + ).toBe(false); + expect(isInsufficientBalanceFailure("pool is frozen")).toBe(false); }); }); diff --git a/__tests__/components/screens/EarnScreen/useEarnTransaction.test.ts b/__tests__/components/screens/EarnScreen/useEarnTransaction.test.ts index 4f126eae2..86ffcd234 100644 --- a/__tests__/components/screens/EarnScreen/useEarnTransaction.test.ts +++ b/__tests__/components/screens/EarnScreen/useEarnTransaction.test.ts @@ -31,7 +31,7 @@ jest.mock("ducks/earn", () => ({ // support being called with a selector fn, matching how the real zustand // hook re-reads live state on every render. It also reads // pool/selectedAssetCode/selectedAssetApy via `useEarnStore.getState()` - // (for the earn_deposit.completed/.failed analytics payload), matching how + // (for the earn.deposit_completed/.failed analytics payload), matching how // the real zustand store exposes both the hook and a static `getState`. useEarnStore: Object.assign( (selector?: (state: Record) => unknown) => { @@ -140,7 +140,7 @@ describe("useEarnTransaction", () => { expect(result.current.error).toBeNull(); expect(mockSetSubmitFailed).toHaveBeenCalledWith(false); - // earn_deposit.completed carries only identifiers -- no amount/fiat value. + // earn.deposit_completed carries only identifiers -- no amount/fiat value. expect(mockTrackEarnDepositSuccess).toHaveBeenCalledWith({ assetCode: "USDC", poolId: "pool-1", @@ -186,7 +186,7 @@ describe("useEarnTransaction", () => { expect(result.current.transactionHash).toBeNull(); expect(mockSetSubmitFailed).toHaveBeenCalledWith(true); - // earn_deposit.failed carries the same identifiers plus reason_code -- + // earn.deposit_failed carries the same identifiers plus reason_code -- // sourced from `submitErrorResultCodes` (NOT threaded through this // hook's own return shape), never the free-text error message, and no // amount/fiat value. diff --git a/__tests__/config/blend.test.ts b/__tests__/config/blend.test.ts index 3a60e7756..5d24643fc 100644 --- a/__tests__/config/blend.test.ts +++ b/__tests__/config/blend.test.ts @@ -1,5 +1,4 @@ import { - BLEND_DEPOSIT_XLM_FEE_BUFFER, BLEND_FIXED_POOL_IDS, getBlendPoolId, isEarnSupportedNetwork, @@ -29,12 +28,6 @@ describe("blend config", () => { expect(isEarnSupportedNetwork(details(NETWORKS.TESTNET))).toBe(true); }); - it("holds back a fee buffer large enough for a Blend submit resource fee", () => { - // One submit measured at ~546,395 stroops (0.0546 XLM) against the live - // mainnet pool. The buffer must comfortably exceed that. - expect(Number(BLEND_DEPOSIT_XLM_FEE_BUFFER)).toBeGreaterThan(0.0546); - }); - it("exposes exactly the two allowlisted networks", () => { expect(Object.keys(BLEND_FIXED_POOL_IDS).sort()).toEqual( [NETWORKS.PUBLIC, NETWORKS.TESTNET].sort(), diff --git a/src/components/screens/EarnScreen/components/EarnReviewBottomSheet.tsx b/src/components/screens/EarnScreen/components/EarnReviewBottomSheet.tsx index f5d051aff..ee59b2a93 100644 --- a/src/components/screens/EarnScreen/components/EarnReviewBottomSheet.tsx +++ b/src/components/screens/EarnScreen/components/EarnReviewBottomSheet.tsx @@ -106,12 +106,14 @@ export const EarnReviewBottomSheet: React.FC = ({ [selectedAssetId, pricedBalances, networkDetails], ); - // The re-clamp against the REAL resource fee (see `clampXlmDepositAmount`) - // happens in `EarnAmountScreen`'s CTA handler, BEFORE this sheet is ever - // presented — not here. That is the invariant this sheet relies on: by the - // time it is mounted and visible, the staged XDR in `transactionBuilder` - // already corresponds exactly to `tokenAmount` below, for every asset, - // XLM included. This sheet therefore never needs to touch the amount. + // INVARIANT this sheet relies on: by the time it is mounted and visible, + // the staged XDR in `transactionBuilder` already corresponds exactly to + // `tokenAmount` below, for every asset, XLM included. `EarnAmountScreen`'s + // CTA handler checks the measured resource fee against an XLM deposit's + // remaining balance (see `getXlmFeeShortfall`) BEFORE this sheet is ever + // presented, and blocks there rather than adjusting the amount — it never + // opens Review with a display value the staged XDR doesn't match. This + // sheet therefore never needs to touch or re-check the amount itself. const depositUsd = useMemo(() => { if (!depositBalance?.currentPrice || depositBalance.currentPrice.isZero()) { return null; diff --git a/src/components/screens/EarnScreen/helpers/earnCtaState.ts b/src/components/screens/EarnScreen/helpers/earnCtaState.ts index 24175475d..62a6472e7 100644 --- a/src/components/screens/EarnScreen/helpers/earnCtaState.ts +++ b/src/components/screens/EarnScreen/helpers/earnCtaState.ts @@ -1,5 +1,4 @@ import BigNumber from "bignumber.js"; -import { BLEND_DEPOSIT_XLM_FEE_BUFFER } from "config/blend"; /** * Pure CTA state machine for the deposit amount screen. Precedence matters: @@ -53,89 +52,68 @@ 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. * - * `calculateSpendableAmount` subtracts the base reserve and the *inclusion* - * fee, but a Blend `submit` is dominated by its resource fee — ~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; the CTA handler re-checks against the real - * `minResourceFee` once simulation returns (see `clampXlmDepositAmount`). + * 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. * - * 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. + * `spendableXlm` is expected to come from `calculateSpendableAmount`, which + * already 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. */ -export const getMaxDepositAmount = ({ - availableBalance, - isXlm, +export const getXlmFeeShortfall = ({ + spendableXlm, + amount, + resourceFee, }: { - 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(); + spendableXlm: string; + /** Cleaned deposit amount — no group separators. */ + amount: string; + /** Measured resource fee from simulation, in XLM. */ + resourceFee: string; +}): string => { + const remaining = new BigNumber(spendableXlm).minus(new BigNumber(amount)); + const shortfall = new BigNumber(resourceFee).minus(remaining); + return BigNumber.max(shortfall, new BigNumber(0)).toFixed(); }; /** - * Re-clamps the entered deposit amount against the REAL resource fee, once - * simulation has resolved and the fee is actually known — `getMaxDepositAmount`'s - * 0.5 XLM buffer above is a generous pre-simulation estimate, not the real cost. + * Fallback fee fed to `getXlmFeeShortfall` when simulation did not report a + * measured resource fee (`sorobanResourceFeeXlm === null` — the backend + * omitted or returned an unparsable `minResourceFee`). * - * Pure and side-effect-free by design: the caller (`EarnAmountScreen`'s CTA - * handler) is responsible for actually updating `tokenAmount` and re-simulating - * with the returned value BEFORE opening Review, so the staged transaction in - * `transactionBuilder` always corresponds to what gets displayed. This function - * only computes what the corrected amount SHOULD be. + * `null` means UNKNOWN, not zero. Feeding "0" in that case would make + * `getXlmFeeShortfall` report "0" any time `amount <= spendableXlm` — which, + * given the CTA's own `isAmountTooHigh` guard, is every single time it would + * be called — silently disabling the shortfall check entirely rather than + * skipping it visibly. This floor is a stand-in fee, not a stand-in "no fee": + * it sits comfortably above the ~0.0546 XLM resource fee measured against the + * live pool, so an unreported fee still gets caught here instead of passing + * through undetected. * - * Returns `enteredAmount` unchanged (a no-op) when: - * - the deposit asset is not XLM — every other asset's fee comes from a - * separate balance, so this cannot squeeze it; - * - `resourceFeeXlm` is `null` — an unknown fee, not a zero one, so there is - * nothing more precise to re-check against than the buffer already applied; - * - the entered amount already fits. - * - * Otherwise returns `spendableXlm - resourceFeeXlm`, floored at zero and - * rounded DOWN at the asset's decimals — so the result never exceeds the - * asset's precision and is never negative. Idempotent: feeding the function's - * own output back in as `enteredAmount` is always a no-op, since the rounded - * result can only be less than or equal to the un-rounded ceiling it was - * derived from. + * This is NOT the removed Max-deposit buffer come back. That buffer applied + * unconditionally and reduced the amount Max/the percentage buttons offered. + * This floor applies only on the rare unknown-fee branch of the + * post-simulation shortfall check — it never changes what amount is + * enterable. */ -export const clampXlmDepositAmount = ({ - enteredAmount, - spendableXlm, - resourceFeeXlm, - decimals, - isXlm, -}: { - enteredAmount: string; - spendableXlm: string; - resourceFeeXlm: string | null; - decimals: number; - isXlm: boolean; -}): string => { - if (!isXlm || resourceFeeXlm === null) { - return enteredAmount; - } - - const maxFittingAmount = BigNumber.max( - new BigNumber(spendableXlm).minus(resourceFeeXlm), - new BigNumber(0), - ); - - const entered = new BigNumber(enteredAmount || "0"); - - if (entered.lte(maxFittingAmount)) { - return enteredAmount; - } +export const UNKNOWN_RESOURCE_FEE_FLOOR_XLM = "0.1"; - return maxFittingAmount - .decimalPlaces(decimals, BigNumber.ROUND_DOWN) - .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): boolean => + /Error\(Contract, #10\)|insufficient[ _]balance/i.test(message); diff --git a/src/components/screens/EarnScreen/screens/EarnAmountScreen.tsx b/src/components/screens/EarnScreen/screens/EarnAmountScreen.tsx index d63aebf13..83f4c3d16 100644 --- a/src/components/screens/EarnScreen/screens/EarnAmountScreen.tsx +++ b/src/components/screens/EarnScreen/screens/EarnAmountScreen.tsx @@ -10,11 +10,12 @@ import { SecurityDetailBottomSheet } from "components/blockaid"; import { BaseLayout } from "components/layout/BaseLayout"; import { EarnReviewBottomSheet } from "components/screens/EarnScreen/components/EarnReviewBottomSheet"; import { - clampXlmDepositAmount, + UNKNOWN_RESOURCE_FEE_FLOOR_XLM, formatRate, getEarnCtaState, - getMaxDepositAmount, getPercentageDepositAmount, + getXlmFeeShortfall, + isInsufficientBalanceFailure, needsXlmForFee, } from "components/screens/EarnScreen/helpers"; import { useEarnPosition } from "components/screens/EarnScreen/hooks/useEarnPosition"; @@ -30,12 +31,12 @@ import { } from "components/sds/NoticeBanner"; import { Text } from "components/sds/Typography"; import { AnalyticsEvent } from "config/analyticsConfig"; -import { BLEND_DEPOSIT_XLM_FEE_BUFFER } from "config/blend"; import { NATIVE_TOKEN_CODE, TransactionContext, mapNetworkToNetworkDetails, } from "config/constants"; +import { logger } from "config/logger"; import { ADD_FUNDS_ROUTES, EARN_ROUTES, @@ -173,36 +174,38 @@ const EarnAmountScreen: React.FC = ({ updateFiatDisplay, } = converter; - const maxDepositable = useMemo(() => { + // Max/percentage buttons and the CTA's insufficient-funds guard work off + // the plain spendable balance — nothing is held back for a Blend submit's + // resource fee here. That fee is only known once simulation returns, and + // is checked post-simulation instead (see `handleCtaPress`'s + // `getXlmFeeShortfall` call), rather than guessed at and reserved upfront. + const availableBalance = useMemo(() => { if (!depositBalance) { return "0"; } - const spendable = calculateSpendableAmount({ + return calculateSpendableAmount({ balance: depositBalance, subentryCount: account?.subentryCount ?? 0, transactionFee, - }); - - return getMaxDepositAmount({ - availableBalance: spendable.toFixed(), - isXlm, - }); - }, [depositBalance, account?.subentryCount, transactionFee, isXlm]); + }).toFixed(); + }, [depositBalance, account?.subentryCount, transactionFee]); - const maxDepositableBn = useMemo( - () => new BigNumber(maxDepositable), - [maxDepositable], + const availableBalanceBn = useMemo( + () => new BigNumber(availableBalance), + [availableBalance], ); const ctaState = useMemo( () => getEarnCtaState({ - availableBalanceIsZero: maxDepositableBn.lte(0), + availableBalanceIsZero: availableBalanceBn.lte(0), amountIsZero: new BigNumber(tokenAmount || "0").lte(0), - isAmountTooHigh: new BigNumber(tokenAmount || "0").gt(maxDepositableBn), + isAmountTooHigh: new BigNumber(tokenAmount || "0").gt( + availableBalanceBn, + ), }), - [maxDepositableBn, tokenAmount], + [availableBalanceBn, tokenAmount], ); const ctaLabelKeys: Record = { @@ -255,14 +258,23 @@ const EarnAmountScreen: React.FC = ({ useEffect(() => { if (simulateError && simulateError !== previousSimulateErrorRef.current) { previousSimulateErrorRef.current = simulateError; + // 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 — supply cap, frozen pool, stale oracle — is the + // pool's own and reads better in its own words. + const title = + isXlm && isInsufficientBalanceFailure(simulateError) + ? t("earnAmount.errors.insufficientBalanceForFee") + : simulateError; showToast({ variant: "error", - title: simulateError, + title, toastId: "earn-simulate-failed", duration: 0, }); } - }, [simulateError, showToast]); + }, [simulateError, showToast, isXlm, t]); // Clear the "Transaction failed" retry banner once the user edits the // amount — but not on mount, so a banner left over from a previous failed @@ -280,7 +292,7 @@ const EarnAmountScreen: React.FC = ({ const handlePercentagePress = useCallback( (percentage: number) => { const targetAmount = getPercentageDepositAmount({ - maxDepositable, + maxDepositable: availableBalance, pct: percentage, decimals: selectedAssetDecimals, }); @@ -300,7 +312,7 @@ const EarnAmountScreen: React.FC = ({ setTokenAmount(targetAmount); }, [ - maxDepositable, + availableBalance, selectedAssetDecimals, showFiatAmount, depositBalance, @@ -309,28 +321,6 @@ const EarnAmountScreen: React.FC = ({ ], ); - // Applies the CTA handler's post-simulate re-clamp (see `handleCtaPress` - // and `clampXlmDepositAmount`) to this screen's local converter state. - // Mirrors `handlePercentagePress`'s fiat-mode bookkeeping above. - const handleAmountClamped = useCallback( - (newAmount: string) => { - if (showFiatAmount && depositBalance?.currentPrice) { - const tokenPrice = depositBalance.currentPrice; - if (!tokenPrice.isZero()) { - const fiatAmount = new BigNumber(newAmount) - .multipliedBy(tokenPrice) - .toFixed(2); - updateFiatDisplay(fiatAmount); - setTokenAmount(newAmount); - return; - } - } - - setTokenAmount(newAmount); - }, - [showFiatAmount, depositBalance, updateFiatDisplay, setTokenAmount], - ); - const openReviewSheet = useCallback(() => { earnReviewBottomSheetModalRef.current?.present(); }, []); @@ -446,21 +436,18 @@ const EarnAmountScreen: React.FC = ({ }) : new BigNumber(0); - // `transactionFee` alone is just the inclusion fee (~0.00001 XLM). A - // Blend `submit` is dominated by its resource fee, which this gate - // cannot know precisely before simulating — so, like - // `getMaxDepositAmount`, it checks against the same generous - // `BLEND_DEPOSIT_XLM_FEE_BUFFER` pre-simulation estimate rather than the - // bare inclusion fee. Without this, a non-XLM deposit with XLM spendable - // somewhere in the ~0.00001-0.055 XLM band would clear this gate, simulate - // fine (the invocation never touches XLM), and only fail at Horizon - // submission — the exact case this sheet exists to catch. + // `transactionFee` is just the inclusion fee (~0.00001 XLM) — this gate + // only catches "no meaningful XLM headroom at all" (e.g. no XLM held at + // all). A Blend `submit`'s resource fee is far larger and only known once + // simulation returns, so it is NOT folded in here as a pre-simulation + // guess anymore — the `getXlmFeeShortfall` check below, after simulate + // resolves, is what catches "some XLM, but not enough for the measured + // fee". Both cases must still surface something: this sheet for the + // former, an inline message for the latter. if ( needsXlmForFee({ spendableXlm: spendableXlm.toFixed(), - fee: new BigNumber(transactionFee) - .plus(BLEND_DEPOSIT_XLM_FEE_BUFFER) - .toFixed(), + fee: transactionFee, }) ) { networkFeeBottomSheetModalRef.current?.present(); @@ -483,48 +470,59 @@ const EarnAmountScreen: React.FC = ({ return; } - // INVARIANT Review and Task 13's Confirm rely on: the staged XDR in - // `transactionBuilder` always corresponds to the currently displayed - // `tokenAmount` by the time Review opens. - // // `simulate` just resolved, so the duck's `sorobanResourceFeeXlm` is now - // the REAL fee (not `getMaxDepositAmount`'s pre-simulation buffer - // estimate). Re-check the amount that was just simulated against it and, - // if it no longer fits (XLM only — see `clampXlmDepositAmount`), correct - // `tokenAmount` and re-simulate for the corrected amount BEFORE opening - // Review, rather than opening Review with a display value the staged XDR - // no longer matches. + // the REAL, measured resource fee (never a pre-simulation guess). For an + // XLM deposit, check whether the amount just simulated leaves enough XLM + // to cover it. const { sorobanResourceFeeXlm } = useTransactionBuilderStore.getState(); - const clampedAmount = clampXlmDepositAmount({ - enteredAmount: tokenAmount, - spendableXlm: spendableXlm.toFixed(), - resourceFeeXlm: sorobanResourceFeeXlm, - decimals: selectedAssetDecimals, - isXlm, - }); - - if (clampedAmount !== tokenAmount) { - handleAmountClamped(clampedAmount); - showToast({ - variant: "warning", - title: t("earnReview.amountReduced"), - toastId: "earn-review-amount-clamped", + let shortfall = "0"; + if (isXlm) { + // The backend omitted `minResourceFee` from this simulation, so the fee + // is genuinely UNKNOWN, not zero. `tokenAmount` is already <= + // `spendableXlm` by construction (the CTA's own `isAmountTooHigh` guard, + // and `availableBalance === spendableXlm` for an XLM deposit), so + // feeding "0" here would make `getXlmFeeShortfall` report "0" in every + // case that reaches this point — silently disabling the check rather + // than skipping it visibly, and reintroducing the exact failure this + // whole check exists to prevent: a max-balance XLM deposit signing + // successfully and only failing at submission. Fall back to a + // conservative floor instead (see its doc for why "0.1" specifically) + // — this is logged too, since it is still a gap in what simulation told + // us, even though it is now covered rather than skipped. + if (sorobanResourceFeeXlm === null) { + logger.warn( + "EarnAmountScreen", + "sorobanResourceFeeXlm is null after a successful simulation; falling back to the unknown-fee floor for the shortfall check", + ); + } + shortfall = getXlmFeeShortfall({ + spendableXlm: spendableXlm.toFixed(), + amount: tokenAmount || "0", + resourceFee: sorobanResourceFeeXlm ?? UNKNOWN_RESOURCE_FEE_FLOOR_XLM, }); + } - const reSimulateResult = await simulate({ - assetId, - amount: clampedAmount, - decimals: selectedAssetDecimals, - transactionFee, - transactionTimeout, - network, - senderAddress: account.publicKey, + // INVARIANT Review and Task 13's Confirm rely on: the staged XDR in + // `transactionBuilder` always corresponds to the currently displayed + // `tokenAmount` by the time Review opens. This check never adjusts + // `tokenAmount` — on a shortfall it blocks here and asks the user to + // reduce the amount themselves, rather than correcting it and + // re-simulating (as an earlier design did). The invariant therefore + // holds trivially on this path: nothing changes the displayed amount + // without a matching re-simulation, and this branch either opens Review + // with the amount `simulate` was just called with, or doesn't open it + // at all. + if (new BigNumber(shortfall).gt(0)) { + showToast({ + variant: "error", + title: t("earnAmount.errors.feeShortfall", { + amount: formatTokenForDisplay(shortfall), + }), + toastId: "earn-fee-shortfall", + duration: 0, }); - - if (!reSimulateResult) { - return; - } + return; } openReviewSheet(); @@ -541,7 +539,6 @@ const EarnAmountScreen: React.FC = ({ transactionTimeout, network, isXlm, - handleAmountClamped, showToast, t, openReviewSheet, @@ -578,7 +575,7 @@ const EarnAmountScreen: React.FC = ({ } const availableBalanceText = depositBalance - ? `${formatBalanceAmount(depositBalance, tokenCode, maxDepositableBn)} ${t( + ? `${formatBalanceAmount(depositBalance, tokenCode, availableBalanceBn)} ${t( "common.available", )}` : null; diff --git a/src/config/analyticsConfig.ts b/src/config/analyticsConfig.ts index e2178e650..8882f91ed 100644 --- a/src/config/analyticsConfig.ts +++ b/src/config/analyticsConfig.ts @@ -165,10 +165,14 @@ export enum AnalyticsEvent { // Earn deposit (Blend). No amounts/fiat on either event (product decision, // non-negotiable) -- only asset_code, pool_id, apy, matching how - // payment.completed / swap.completed are shaped. - EARN_DEPOSIT_SUCCESS = "earn_deposit.completed", + // payment.completed / swap.completed are shaped. Named earn.deposit_* to + // match the extension's event name exactly (it uses earn.deposit_completed + // / earn.deposit_failed) rather than mobile's usual _ shape, so + // the two platforms land in the same Amplitude funnel instead of splitting + // it -- the VIEW_EARN_* events above already match the extension this way. + EARN_DEPOSIT_SUCCESS = "earn.deposit_completed", // carries reason_code - EARN_DEPOSIT_FAIL = "earn_deposit.failed", + EARN_DEPOSIT_FAIL = "earn.deposit_failed", // Send collectible SEND_COLLECTIBLE_SUCCESS = "collectible_send.completed", diff --git a/src/config/blend.ts b/src/config/blend.ts index f783b4083..50d1f2929 100644 --- a/src/config/blend.ts +++ b/src/config/blend.ts @@ -23,17 +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. - * - * `calculateSpendableAmount` only subtracts the base reserve and 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/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index 01bcea160..a01ed8232 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -725,7 +725,9 @@ "buyXlm": "Buy XLM" }, "errors": { - "simulationFailed": "Failed to simulate the deposit. Please try again." + "simulationFailed": "Failed to simulate the deposit. Please try again.", + "feeShortfall": "Not enough XLM left for the network fee. Reduce your deposit by at least {{amount}} XLM.", + "insufficientBalanceForFee": "Not enough XLM to cover the network fee. Try depositing a smaller amount." } }, "earnReview": { @@ -736,8 +738,7 @@ "positionValue": "{{before}} → {{after}}", "estMonthlyEarnings": "Est. monthly earnings", "estYearlyEarnings": "Est. yearly earnings", - "projectionDisclaimer": "Estimated earnings are projections based on the current rate and may change based on pool activity.", - "amountReduced": "Amount reduced to cover the network fee" + "projectionDisclaimer": "Estimated earnings are projections based on the current rate and may change based on pool activity." }, "earnNotEnough": { "title": "You don't have any {{tokenCode}}", diff --git a/src/i18n/locales/pt/translations.json b/src/i18n/locales/pt/translations.json index 7182247d3..3dcbac4ef 100644 --- a/src/i18n/locales/pt/translations.json +++ b/src/i18n/locales/pt/translations.json @@ -691,7 +691,9 @@ "buyXlm": "Comprar XLM" }, "errors": { - "simulationFailed": "Falha ao simular o depósito. Tente novamente." + "simulationFailed": "Falha ao simular o depósito. Tente novamente.", + "feeShortfall": "Não há XLM suficiente para a taxa de rede. Reduza seu depósito em pelo menos {{amount}} XLM.", + "insufficientBalanceForFee": "Não há XLM suficiente para cobrir a taxa de rede. Tente depositar um valor menor." } }, "earnReview": { @@ -702,8 +704,7 @@ "positionValue": "{{before}} → {{after}}", "estMonthlyEarnings": "Ganhos mensais est.", "estYearlyEarnings": "Ganhos anuais est.", - "projectionDisclaimer": "Os ganhos estimados são projeções baseadas na taxa atual e podem mudar de acordo com a atividade do pool.", - "amountReduced": "Valor reduzido para cobrir a taxa de rede" + "projectionDisclaimer": "Os ganhos estimados são projeções baseadas na taxa atual e podem mudar de acordo com a atividade do pool." }, "earnNotEnough": { "title": "Você não possui {{tokenCode}}", diff --git a/src/services/analytics/transactions.ts b/src/services/analytics/transactions.ts index 6fc511f0b..120dee207 100644 --- a/src/services/analytics/transactions.ts +++ b/src/services/analytics/transactions.ts @@ -284,9 +284,12 @@ export const trackGrantAccessBlocked = ( }); }; -// earn_deposit.completed carries asset_code + pool_id + apy. NO amount/fiat +// earn.deposit_completed carries asset_code + pool_id + apy. NO amount/fiat // value -- product decision (matches payment.completed / swap.completed's // no-amount shape), non-negotiable per the cross-platform funnel agreement. +// Named earn.deposit_completed (not earn_deposit.completed) to match the +// extension's event name -- the two platforms must land in the same +// Amplitude funnel. export const trackEarnDepositSuccess = ( data: EarnDepositSuccessEvent, ): void => { @@ -297,7 +300,7 @@ export const trackEarnDepositSuccess = ( }); }; -// earn_deposit.failed carries the same identifiers plus reason_code. Like +// earn.deposit_failed carries the same identifiers plus reason_code. Like // trackTransactionError, reason_code is the machine-readable Horizon result // code (falling back to "unknown") -- never the free-text error message, to // avoid unbounded reason_code cardinality. From d2f42c6aef02a8ff131a6117d44181249017b627 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Thu, 20 Aug 2026 15:25:10 -0600 Subject: [PATCH 17/27] fix(earn): use the Figma asterisk icon for the Home Earn tile --- src/components/screens/HomeScreen/HomeScreen.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/screens/HomeScreen/HomeScreen.tsx b/src/components/screens/HomeScreen/HomeScreen.tsx index 7985e3b46..3f79ba215 100644 --- a/src/components/screens/HomeScreen/HomeScreen.tsx +++ b/src/components/screens/HomeScreen/HomeScreen.tsx @@ -428,7 +428,7 @@ export const HomeScreen: React.FC = React.memo( )} {earnEnabled && ( Date: Thu, 20 Aug 2026 16:05:16 -0600 Subject: [PATCH 18/27] feat(earn): add the first-run Earn intro sheet --- .../EarnScreen/EarnTokenPickerScreen.test.tsx | 219 ++++++++++++++++++ .../components/EarnIntroBottomSheet.test.tsx | 60 +++++ __tests__/ducks/preferences.test.ts | 39 ++++ .../components/EarnIntroBottomSheet.tsx | 95 ++++++++ .../screens/EarnTokenPickerScreen.tsx | 60 ++++- src/config/analyticsConfig.ts | 20 +- src/ducks/preferences.ts | 12 + src/i18n/locales/en/translations.json | 5 + src/i18n/locales/pt/translations.json | 5 + 9 files changed, 506 insertions(+), 9 deletions(-) create mode 100644 __tests__/components/screens/EarnScreen/EarnTokenPickerScreen.test.tsx create mode 100644 __tests__/components/screens/EarnScreen/components/EarnIntroBottomSheet.test.tsx create mode 100644 src/components/screens/EarnScreen/components/EarnIntroBottomSheet.tsx diff --git a/__tests__/components/screens/EarnScreen/EarnTokenPickerScreen.test.tsx b/__tests__/components/screens/EarnScreen/EarnTokenPickerScreen.test.tsx new file mode 100644 index 000000000..005ddb888 --- /dev/null +++ b/__tests__/components/screens/EarnScreen/EarnTokenPickerScreen.test.tsx @@ -0,0 +1,219 @@ +/* eslint-disable @fnando/consistent-import/consistent-import */ +import { NativeStackScreenProps } from "@react-navigation/native-stack"; +import { act } from "@testing-library/react-native"; +import EarnTokenPickerScreen from "components/screens/EarnScreen/screens/EarnTokenPickerScreen"; +import { EARN_ROUTES, EarnStackParamList } from "config/routes"; +import { usePreferencesStore } from "ducks/preferences"; +import { renderWithProviders } from "helpers/testUtils"; +import React from "react"; + +// This screen presents the first-run Earn intro sheet on mount, gated on +// `usePreferencesStore.hasSeenEarnIntro` -- Task 13 removed the dead +// `EARN_ROUTES` intro entry, so this is a bottom sheet over the token +// picker rather than its own route/screen. Mocking approach modeled on +// EarnAmountScreen.test.tsx: a stubbed that records each +// sheet's imperative ref (by declaration order) and the props it was given, +// so we can assert which sheet was presented -- and inspect the +// intro sheet's own `onDismiss` wiring -- without mounting the real +// gorhom-based sheets. + +type SheetRefSpy = { + present: jest.Mock; + dismiss: jest.Mock; +}; +type CapturedSheet = { + ref: SheetRefSpy; + props: Record; +}; +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace, vars-on-top, no-var, no-underscore-dangle, @typescript-eslint/naming-convention + var __earnTokenPickerMockSheets: CapturedSheet[]; +} +// eslint-disable-next-line no-underscore-dangle +globalThis.__earnTokenPickerMockSheets = []; + +jest.mock("components/BottomSheet", () => { + /* eslint-disable global-require, @typescript-eslint/no-var-requires, @typescript-eslint/no-shadow */ + const ReactModule = require("react"); + const RNModule = require("react-native"); + /* eslint-enable global-require, @typescript-eslint/no-var-requires, @typescript-eslint/no-shadow */ + + const NoopSheet = (props: { modalRef?: React.RefObject }) => { + const { modalRef } = props; + ReactModule.useImperativeHandle(modalRef, () => { + const spy: SheetRefSpy = { present: jest.fn(), dismiss: jest.fn() }; + // eslint-disable-next-line no-underscore-dangle + globalThis.__earnTokenPickerMockSheets.push({ ref: spy, props }); + return spy; + }, []); + // Don't render customContent -- the real EarnIntroBottomSheet/ + // PoolDetailsBottomSheet/NotEnoughTokenBottomSheet are covered by their + // own component tests. + return ReactModule.createElement(RNModule.View); + }; + return { __esModule: true, default: NoopSheet }; +}); + +jest.mock("@react-navigation/native", () => ({ + useNavigation: () => ({ + navigate: jest.fn(), + setOptions: jest.fn(), + }), +})); + +// Sidesteps CustomHeaderButton/ContextMenuButton's native dependencies -- +// this screen's pool-info header button is unrelated to the intro sheet +// under test here. +jest.mock("hooks/useRightHeader"); + +jest.mock("ducks/auth", () => ({ + useAuthenticationStore: () => ({ network: "TESTNET" }), +})); + +jest.mock("ducks/balances", () => ({ + useBalancesStore: () => ({ pricedBalances: {} }), +})); + +jest.mock("hooks/useAppTranslation", () => ({ + __esModule: true, + default: () => ({ t: (key: string) => key }), +})); + +let mockUseEarnTokens: { + isLoading: boolean; + error: string | null; + held: unknown[]; + supported: unknown[]; + pool: unknown; + refetch: jest.Mock; +}; + +jest.mock("components/screens/EarnScreen/hooks/useEarnTokens", () => ({ + useEarnTokens: () => mockUseEarnTokens, +})); + +type Props = NativeStackScreenProps< + EarnStackParamList, + typeof EARN_ROUTES.EARN_TOKEN_PICKER_SCREEN +>; + +const makeNavigation = () => + ({ + navigate: jest.fn(), + goBack: jest.fn(), + }) as unknown as Props["navigation"]; + +const makeRoute = () => + ({ + key: "earn-token-picker", + name: EARN_ROUTES.EARN_TOKEN_PICKER_SCREEN, + params: undefined, + }) as unknown as Props["route"]; + +const renderScreen = () => + renderWithProviders( + , + ); + +/** The earn-intro `` is declared first in the JSX, so it's + * always index 0 in mount order. */ +const getEarnIntroSheet = () => + // eslint-disable-next-line no-underscore-dangle + globalThis.__earnTokenPickerMockSheets[0]; + +describe("EarnTokenPickerScreen", () => { + beforeEach(() => { + jest.clearAllMocks(); + // eslint-disable-next-line no-underscore-dangle + globalThis.__earnTokenPickerMockSheets = []; + mockUseEarnTokens = { + isLoading: false, + error: null, + held: [], + supported: [], + pool: null, + refetch: jest.fn(), + }; + }); + + describe("first-entry presentation", () => { + it("presents the intro sheet on mount when the flag is unset", () => { + usePreferencesStore.getState().setHasSeenEarnIntro(false); + + renderScreen(); + + expect(getEarnIntroSheet().ref.present).toHaveBeenCalledTimes(1); + }); + + it("does NOT present the intro sheet when the flag is already set", () => { + usePreferencesStore.getState().setHasSeenEarnIntro(true); + + renderScreen(); + + expect(getEarnIntroSheet().ref.present).not.toHaveBeenCalled(); + }); + + it("does not present while the token list is still loading", () => { + usePreferencesStore.getState().setHasSeenEarnIntro(false); + mockUseEarnTokens.isLoading = true; + + renderScreen(); + + // The loading branch returns before the BottomSheet tree mounts at + // all, so there is nothing captured to present. + // eslint-disable-next-line no-underscore-dangle + expect(globalThis.__earnTokenPickerMockSheets).toHaveLength(0); + }); + + it("does not present when the token fetch errored", () => { + usePreferencesStore.getState().setHasSeenEarnIntro(false); + mockUseEarnTokens.error = "boom"; + + renderScreen(); + + // eslint-disable-next-line no-underscore-dangle + expect(globalThis.__earnTokenPickerMockSheets).toHaveLength(0); + }); + }); + + describe("dismiss wiring", () => { + beforeEach(() => { + usePreferencesStore.getState().setHasSeenEarnIntro(false); + }); + + it("marks the intro seen when the sheet's onDismiss fires (CTA or close, from inside EarnIntroBottomSheet)", () => { + renderScreen(); + + const introSheet = getEarnIntroSheet(); + const customContent = introSheet.props + .customContent as React.ReactElement<{ + onDismiss: () => void; + }>; + + expect(usePreferencesStore.getState().hasSeenEarnIntro).toBe(false); + + act(() => { + customContent.props.onDismiss(); + }); + + expect(usePreferencesStore.getState().hasSeenEarnIntro).toBe(true); + }); + + it("also marks the intro seen via the sheet's native onDismiss (swipe/backdrop dismissal)", () => { + renderScreen(); + + const introSheet = getEarnIntroSheet(); + const bottomSheetModalProps = introSheet.props.bottomSheetModalProps as { + onDismiss: () => void; + }; + + expect(usePreferencesStore.getState().hasSeenEarnIntro).toBe(false); + + act(() => { + bottomSheetModalProps.onDismiss(); + }); + + expect(usePreferencesStore.getState().hasSeenEarnIntro).toBe(true); + }); + }); +}); diff --git a/__tests__/components/screens/EarnScreen/components/EarnIntroBottomSheet.test.tsx b/__tests__/components/screens/EarnScreen/components/EarnIntroBottomSheet.test.tsx new file mode 100644 index 000000000..474ac87ed --- /dev/null +++ b/__tests__/components/screens/EarnScreen/components/EarnIntroBottomSheet.test.tsx @@ -0,0 +1,60 @@ +import { BottomSheetModal } from "@gorhom/bottom-sheet"; +import { fireEvent } from "@testing-library/react-native"; +import { EarnIntroBottomSheet } from "components/screens/EarnScreen/components/EarnIntroBottomSheet"; +import { renderWithProviders } from "helpers/testUtils"; +import React from "react"; + +const makeRef = (dismissMock: jest.Mock) => { + const ref = React.createRef(); + Object.defineProperty(ref, "current", { + value: { dismiss: dismissMock }, + writable: true, + }); + return ref; +}; + +describe("EarnIntroBottomSheet", () => { + it("renders the extension's verbatim copy", () => { + const { getByText } = renderWithProviders( + , + ); + + expect(getByText("Make your tokens earn for you")).toBeTruthy(); + expect( + getByText( + "Deposit supported tokens into DeFi pools and start earning rewards.", + ), + ).toBeTruthy(); + expect(getByText("Start earning")).toBeTruthy(); + }); + + it("dismisses via the forwarded ref and marks the intro seen when the CTA is pressed", () => { + const dismissMock = jest.fn(); + const onDismiss = jest.fn(); + const ref = makeRef(dismissMock); + + const { getByTestId } = renderWithProviders( + , + ); + + fireEvent.press(getByTestId("earn-intro-start")); + + expect(dismissMock).toHaveBeenCalledTimes(1); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); + + it("dismisses via the forwarded ref and marks the intro seen when the close control is pressed", () => { + const dismissMock = jest.fn(); + const onDismiss = jest.fn(); + const ref = makeRef(dismissMock); + + const { getByTestId } = renderWithProviders( + , + ); + + fireEvent.press(getByTestId("earn-intro-close")); + + expect(dismissMock).toHaveBeenCalledTimes(1); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); +}); diff --git a/__tests__/ducks/preferences.test.ts b/__tests__/ducks/preferences.test.ts index 3a6bb756f..dced5ffbb 100644 --- a/__tests__/ducks/preferences.test.ts +++ b/__tests__/ducks/preferences.test.ts @@ -81,4 +81,43 @@ describe("preferences store", () => { expect(result.current.autoLockTimer).toBe(AUTO_LOCK_TIMER.FIFTEEN_MINUTES); }); + + describe("hasSeenEarnIntro", () => { + it("defaults to false", () => { + const { result } = renderHook(() => usePreferencesStore()); + + expect(result.current.hasSeenEarnIntro).toBe(false); + }); + + it("is set via setHasSeenEarnIntro", () => { + const { result } = renderHook(() => usePreferencesStore()); + + act(() => { + result.current.setHasSeenEarnIntro(true); + }); + + expect(result.current.hasSeenEarnIntro).toBe(true); + }); + + it("is included in the persisted (partialized) state, so it survives a rehydrate", () => { + const { result } = renderHook(() => usePreferencesStore()); + + act(() => { + result.current.setHasSeenEarnIntro(true); + }); + + // Exercises the same `partialize` function the `persist` middleware + // calls before writing to storage -- if `hasSeenEarnIntro` were + // missing from its allowlist, this would come back without the key + // and the flag would revert to false on every app launch, showing the + // intro every time despite the user having dismissed it. + const persistedState = usePreferencesStore.persist + .getOptions() + .partialize?.(usePreferencesStore.getState()) as { + hasSeenEarnIntro?: boolean; + }; + + expect(persistedState.hasSeenEarnIntro).toBe(true); + }); + }); }); diff --git a/src/components/screens/EarnScreen/components/EarnIntroBottomSheet.tsx b/src/components/screens/EarnScreen/components/EarnIntroBottomSheet.tsx new file mode 100644 index 000000000..0ea77789b --- /dev/null +++ b/src/components/screens/EarnScreen/components/EarnIntroBottomSheet.tsx @@ -0,0 +1,95 @@ +import { BottomSheetModal } from "@gorhom/bottom-sheet"; +import { Button } from "components/sds/Button"; +import Icon from "components/sds/Icon"; +import { Text } from "components/sds/Typography"; +import useAppTranslation from "hooks/useAppTranslation"; +import useColors from "hooks/useColors"; +import React from "react"; +import { TouchableOpacity, View } from "react-native"; + +export interface EarnIntroBottomSheetProps { + bottomSheetModalRef?: React.RefObject; + /** + * Called (in addition to the sheet dismissing) by both the CTA and the + * close control. The caller wires this to `setHasSeenEarnIntro(true)` so + * the sheet never shows again on this install, regardless of which + * control the user pressed. + */ + onDismiss: () => void; +} + +/** + * One-time interstitial shown the first time the user opens the Earn token + * picker, presented as a bottom sheet over it rather than a route -- Task 13 + * removed `EARN_ROUTES`' dead intro entry precisely because a + * declared-but-unregistered route throws at runtime, and there is no + * navigation reason to add one back for a single first-run sheet. + * + * Pure content: mirrors `PoolDetailsBottomSheet`'s convention exactly (the + * caller wraps this in `components/BottomSheet` and owns the modal ref, + * passed through here only so the close control and the CTA can dismiss it + * themselves). + * + * The art region is a placeholder -- `Icon.PiggyBank01` in a large badge. + * The extension's illustration has no mobile asset equivalent yet; design + * may want to replace this with a real asset later. + */ +export const EarnIntroBottomSheet: React.FC = ({ + bottomSheetModalRef, + onDismiss, +}) => { + const { t } = useAppTranslation(); + const { themeColors } = useColors(); + + // Shared by the close control and the CTA: per product decision, both + // dismiss the sheet AND mark it seen -- the user should not see this + // again on this install either way. + const handleDismiss = () => { + bottomSheetModalRef?.current?.dismiss(); + onDismiss(); + }; + + return ( + + + + + + + + + + + + {t("earnIntro.title")} + + + + + {t("earnIntro.body")} + + + + + + ); +}; + +export default EarnIntroBottomSheet; diff --git a/src/components/screens/EarnScreen/screens/EarnTokenPickerScreen.tsx b/src/components/screens/EarnScreen/screens/EarnTokenPickerScreen.tsx index e0bd6f5f8..e9ba9b402 100644 --- a/src/components/screens/EarnScreen/screens/EarnTokenPickerScreen.tsx +++ b/src/components/screens/EarnScreen/screens/EarnTokenPickerScreen.tsx @@ -4,6 +4,7 @@ import { NativeStackScreenProps } from "@react-navigation/native-stack"; import BottomSheet from "components/BottomSheet"; import Spinner from "components/Spinner"; import { BaseLayout } from "components/layout/BaseLayout"; +import { EarnIntroBottomSheet } from "components/screens/EarnScreen/components/EarnIntroBottomSheet"; import { EarnTokenRow } from "components/screens/EarnScreen/components/EarnTokenRow"; import { NotEnoughTokenBottomSheet } from "components/screens/EarnScreen/components/NotEnoughTokenBottomSheet"; import { PoolDetailsBottomSheet } from "components/screens/EarnScreen/components/PoolDetailsBottomSheet"; @@ -20,6 +21,7 @@ import { import { Button } from "components/sds/Button"; import Icon from "components/sds/Icon"; import { Text } from "components/sds/Typography"; +import { AnalyticsEvent } from "config/analyticsConfig"; import { mapNetworkToNetworkDetails } from "config/constants"; import { ADD_FUNDS_ROUTES, @@ -31,10 +33,17 @@ import { import { useAuthenticationStore } from "ducks/auth"; import { useBalancesStore } from "ducks/balances"; import { useEarnStore } from "ducks/earn"; +import { usePreferencesStore } from "ducks/preferences"; import { getTokenIdentifier } from "helpers/balances"; import useAppTranslation from "hooks/useAppTranslation"; import { useRightHeaderButton } from "hooks/useRightHeader"; -import React, { useCallback, useMemo, useRef, useState } from "react"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import { SectionList, View } from "react-native"; type EarnTokenPickerScreenProps = NativeStackScreenProps< @@ -56,8 +65,15 @@ export const EarnTokenPickerScreen: React.FC = ({ const selectAsset = useEarnStore((state) => state.selectAsset); const { network } = useAuthenticationStore(); const { pricedBalances } = useBalancesStore(); + const hasSeenEarnIntro = usePreferencesStore( + (state) => state.hasSeenEarnIntro, + ); + const setHasSeenEarnIntro = usePreferencesStore( + (state) => state.setHasSeenEarnIntro, + ); const poolDetailsBottomSheetModalRef = useRef(null); const notEnoughBottomSheetModalRef = useRef(null); + const earnIntroBottomSheetModalRef = useRef(null); const [notEnoughOption, setNotEnoughOption] = useState(null); @@ -153,6 +169,36 @@ export const EarnTokenPickerScreen: React.FC = ({ [selectAsset, navigation], ); + const handleDismissEarnIntro = useCallback(() => { + setHasSeenEarnIntro(true); + }, [setHasSeenEarnIntro]); + + // Presents the first-run Earn intro sheet exactly once per mount, gated on + // the persisted flag (see `usePreferencesStore.hasSeenEarnIntro`) rather + // than a route: Task 13 removed the dead `EARN_ROUTES` intro entry because + // a declared-but-unregistered route throws at runtime, so this is a sheet + // over the token picker instead of a step in the stack. + // + // `hasPresentedEarnIntroRef` (rather than depending on `isLoading` alone) + // guards against re-presenting after the sheet is dismissed: dismissing + // sets `hasSeenEarnIntro` true, which would otherwise be a legitimate + // dependency change that re-runs this effect on every subsequent + // isLoading/error toggle (e.g. a later retry) with a stale `false` still + // in a race. The ref makes "present at most once per mount" explicit + // regardless of how those other dependencies settle. + const hasPresentedEarnIntroRef = useRef(false); + useEffect(() => { + if ( + !isLoading && + !error && + !hasSeenEarnIntro && + !hasPresentedEarnIntroRef.current + ) { + hasPresentedEarnIntroRef.current = true; + earnIntroBottomSheetModalRef.current?.present(); + } + }, [isLoading, error, hasSeenEarnIntro]); + if (isLoading) { return ( @@ -235,6 +281,18 @@ export const EarnTokenPickerScreen: React.FC = ({ } /> + + } + /> diff --git a/src/config/analyticsConfig.ts b/src/config/analyticsConfig.ts index 8882f91ed..ea17d6427 100644 --- a/src/config/analyticsConfig.ts +++ b/src/config/analyticsConfig.ts @@ -82,10 +82,11 @@ export enum AnalyticsEvent { // Earn (Blend deposit). Values match the extension's decided funnel // (earn_intro -> earn_select_token -> earn_amount -> earn_review -> // earn_processing -> earn_success) so the two platforms merge into one - // funnel in Amplitude. Mobile has no earn_intro (descoped from Phase 1); - // this covers earn_select_token onward. VIEW_EARN_REVIEW/_PROCESSING/ - // _SUCCESS are fired manually (see SCREEN_CATALOG / call sites below) since - // none of the three is a registered route. + // funnel in Amplitude. VIEW_EARN_INTRO/_REVIEW/_PROCESSING/_SUCCESS are + // fired manually (see SCREEN_CATALOG / call sites below) since none of the + // four is a registered route — the intro is a first-entry bottom sheet, not + // a screen. + VIEW_EARN_INTRO = "earn_intro", VIEW_EARN_TOKEN_PICKER = "earn_select_token", VIEW_EARN_AMOUNT = "earn_amount", VIEW_EARN_REVIEW = "earn_review", @@ -540,11 +541,14 @@ const SCREEN_CATALOG: Record = { [AnalyticsEvent.VIEW_MANAGE_WALLETS]: { flow: AnalyticsFlow.SETTINGS, }, - // Earn (Blend deposit). VIEW_EARN_REVIEW/_PROCESSING/_SUCCESS are not + // Earn (Blend deposit). VIEW_EARN_INTRO/_REVIEW/_PROCESSING/_SUCCESS are not // routes -- they're retargeted to screen.viewed manually (BottomSheet's - // analyticsEvent prop for the review sheet; a direct track() call for the - // inline processing/success screen), same mechanism as the Scan/Receive - // per-tab views above. + // analyticsEvent prop for the intro and review sheets; a direct track() + // call for the inline processing/success screen), same mechanism as the + // Scan/Receive per-tab views above. + [AnalyticsEvent.VIEW_EARN_INTRO]: { + flow: AnalyticsFlow.EARN, + }, [AnalyticsEvent.VIEW_EARN_TOKEN_PICKER]: { flow: AnalyticsFlow.EARN, }, diff --git a/src/ducks/preferences.ts b/src/ducks/preferences.ts index f0bba7043..1c8210742 100644 --- a/src/ducks/preferences.ts +++ b/src/ducks/preferences.ts @@ -12,6 +12,14 @@ interface PreferencesState { setIsMemoValidationEnabled: (isMemoValidationEnabled: boolean) => void; isBiometricsEnabled: boolean | undefined; setIsBiometricsEnabled: (isBiometricsEnabled: boolean) => void; + /** + * Whether the user has already seen the Earn intro sheet. Global (not + * per-account) and once-per-install by design — the `persist` middleware + * below gives us that for free, so it is never reset from anywhere else in + * the app. + */ + hasSeenEarnIntro: boolean; + setHasSeenEarnIntro: (hasSeenEarnIntro: boolean) => void; autoLockTimer: AUTO_LOCK_TIMER; setAutoLockTimer: (autoLockTimer: AUTO_LOCK_TIMER) => void; hydrateAutoLockTimer: () => Promise; @@ -21,6 +29,7 @@ const INITIAL_PREFERENCES_STATE = { isHideDustEnabled: true, isMemoValidationEnabled: true, isBiometricsEnabled: undefined, + hasSeenEarnIntro: false, autoLockTimer: DEFAULT_AUTO_LOCK_TIMER, }; @@ -43,6 +52,8 @@ export const usePreferencesStore = create()( set({ isMemoValidationEnabled }), setIsBiometricsEnabled: (isBiometricsEnabled: boolean) => set({ isBiometricsEnabled }), + setHasSeenEarnIntro: (hasSeenEarnIntro: boolean) => + set({ hasSeenEarnIntro }), setAutoLockTimer: (autoLockTimer: AUTO_LOCK_TIMER) => { const previousAutoLockTimer = get().autoLockTimer; set({ autoLockTimer }); @@ -101,6 +112,7 @@ export const usePreferencesStore = create()( isHideDustEnabled: state.isHideDustEnabled, isMemoValidationEnabled: state.isMemoValidationEnabled, isBiometricsEnabled: state.isBiometricsEnabled, + hasSeenEarnIntro: state.hasSeenEarnIntro, }), }, ), diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index a01ed8232..0fe140e78 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -690,6 +690,11 @@ "swapTransactionDetails": { "swapped": "Swapped" }, + "earnIntro": { + "title": "Make your tokens earn for you", + "body": "Deposit supported tokens into DeFi pools and start earning rewards.", + "startEarning": "Start earning" + }, "earnTokenPicker": { "title": "Choose a token", "inYourAccount": "In your account", diff --git a/src/i18n/locales/pt/translations.json b/src/i18n/locales/pt/translations.json index 3dcbac4ef..8f61c329c 100644 --- a/src/i18n/locales/pt/translations.json +++ b/src/i18n/locales/pt/translations.json @@ -656,6 +656,11 @@ "swapTransactionDetails": { "swapped": "Trocado" }, + "earnIntro": { + "title": "Faça seus tokens trabalharem para você", + "body": "Deposite tokens suportados em pools de DeFi e comece a ganhar recompensas.", + "startEarning": "Comece a ganhar" + }, "earnTokenPicker": { "title": "Escolha um token", "inYourAccount": "Na sua conta", From d8711f3014623d9e741c734484740d515d2f6025 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Fri, 21 Aug 2026 09:04:36 -0600 Subject: [PATCH 19/27] fix(earn): match the intro sheet to the Figma design --- .../components/EarnIntroBottomSheet.tsx | 79 ++++++++++++------- .../screens/EarnTokenPickerScreen.tsx | 8 ++ 2 files changed, 60 insertions(+), 27 deletions(-) diff --git a/src/components/screens/EarnScreen/components/EarnIntroBottomSheet.tsx b/src/components/screens/EarnScreen/components/EarnIntroBottomSheet.tsx index 0ea77789b..6e33b7ff7 100644 --- a/src/components/screens/EarnScreen/components/EarnIntroBottomSheet.tsx +++ b/src/components/screens/EarnScreen/components/EarnIntroBottomSheet.tsx @@ -30,9 +30,11 @@ export interface EarnIntroBottomSheetProps { * passed through here only so the close control and the CTA can dismiss it * themselves). * - * The art region is a placeholder -- `Icon.PiggyBank01` in a large badge. - * The extension's illustration has no mobile asset equivalent yet; design - * may want to replace this with a real asset later. + * Visual composition matches Figma node `9457:46686` ("First time", Earn + * section) exactly -- see the report for the full geometry-to-token mapping. + * The art region is a flat placeholder fill (no artwork asset exists on + * either platform yet, mirroring the extension's own placeholder -- + * `EarnIntro/styles.scss:11-18`). */ export const EarnIntroBottomSheet: React.FC = ({ bottomSheetModalRef, @@ -51,43 +53,66 @@ export const EarnIntroBottomSheet: React.FC = ({ return ( - - - - + {/* Full-bleed art region. The `-mt-6 -mx-6` cancels the wrapping + BottomSheetView's own 24px top/left/right padding (see + `components/BottomSheet`'s `fixedContent`) so this reaches the + sheet's actual edges, matching the Figma frame's full-bleed 360x248 + "image 1" placed at the very top of the sheet. + Flat grey fill -- no artwork asset exists yet on either platform + (the extension's own EarnIntro uses the same kind of placeholder, + see EarnIntro/styles.scss). `themeColors.gray[9]` is the closest + existing token to the render's flat fill (#697177 vs. gray-9's + #707070 -- a few points off per channel, well within "nearest + token" territory; no gray step lands any closer). */} + + {/* Dark circular close control floating on the art, 24px from the + top and right edges of the art region -- not the app's usual + header-style close. `gray[6]` is the nearest token to the + render's circle fill (#313334 vs. gray-6's #343434); the X glyph + itself samples as pure white, i.e. `base[1]`. */} - + - - {t("earnIntro.title")} - + + + {t("earnIntro.title")} + + - + {t("earnIntro.body")} - + + {/* `tertiary` is the closest existing Button variant to the render's + light-pill/dark-text CTA (bg #fcfcfc / text #171717 vs. the + render's #ededed / #161616 -- same light-on-dark pairing, off by + a shade because Button's variant colors are hand-ported fixed + values rather than theme tokens). Already the established choice + for this exact role elsewhere (MaintenanceBannerBottomSheet, + ConnectedAppsBottomSheet). */} + + ); }; diff --git a/src/components/screens/EarnScreen/screens/EarnTokenPickerScreen.tsx b/src/components/screens/EarnScreen/screens/EarnTokenPickerScreen.tsx index e9ba9b402..75fae3aaf 100644 --- a/src/components/screens/EarnScreen/screens/EarnTokenPickerScreen.tsx +++ b/src/components/screens/EarnScreen/screens/EarnTokenPickerScreen.tsx @@ -286,6 +286,14 @@ export const EarnTokenPickerScreen: React.FC = ({ handleCloseModal={handleDismissEarnIntro} analyticsEvent={AnalyticsEvent.VIEW_EARN_INTRO} bottomSheetModalProps={{ onDismiss: handleDismissEarnIntro }} + // Figma node 9457:46768 sizes the sheet to 484 of a 600-tall canvas + // (~81% of screen height) rather than sizing to its (short) content + // -- an explicit snap point reproduces that proportion; dynamic + // sizing is disabled so it doesn't shrink-to-fit instead (same + // pairing SignTransactionDetails already uses for its own + // fixed-height sheet). + enableDynamicSizing={false} + snapPoints={["81%"]} customContent={ Date: Fri, 21 Aug 2026 10:01:33 -0600 Subject: [PATCH 20/27] fix(earn): match the deposit flow to the Figma design --- .../EarnScreen/EarnTokenPickerScreen.test.tsx | 8 +- src/components/AmountCard.tsx | 21 ++- src/components/PercentageButtons.tsx | 2 +- .../components/EarnIntroBottomSheet.tsx | 12 +- .../EarnScreen/components/EarnTokenRow.tsx | 77 +++++++++-- .../EarnScreen/components/PoolCard.tsx | 100 ++++++++++++++ .../components/PoolDetailsBottomSheet.tsx | 8 +- .../EarnScreen/screens/EarnAmountScreen.tsx | 59 ++++++--- .../screens/EarnTokenPickerScreen.tsx | 124 ++++++++---------- src/i18n/locales/en/translations.json | 16 ++- src/i18n/locales/pt/translations.json | 16 ++- src/navigators/EarnNavigator.tsx | 14 +- 12 files changed, 325 insertions(+), 132 deletions(-) create mode 100644 src/components/screens/EarnScreen/components/PoolCard.tsx diff --git a/__tests__/components/screens/EarnScreen/EarnTokenPickerScreen.test.tsx b/__tests__/components/screens/EarnScreen/EarnTokenPickerScreen.test.tsx index 005ddb888..258b9d8b7 100644 --- a/__tests__/components/screens/EarnScreen/EarnTokenPickerScreen.test.tsx +++ b/__tests__/components/screens/EarnScreen/EarnTokenPickerScreen.test.tsx @@ -47,8 +47,7 @@ jest.mock("components/BottomSheet", () => { return spy; }, []); // Don't render customContent -- the real EarnIntroBottomSheet/ - // PoolDetailsBottomSheet/NotEnoughTokenBottomSheet are covered by their - // own component tests. + // NotEnoughTokenBottomSheet are covered by their own component tests. return ReactModule.createElement(RNModule.View); }; return { __esModule: true, default: NoopSheet }; @@ -61,11 +60,6 @@ jest.mock("@react-navigation/native", () => ({ }), })); -// Sidesteps CustomHeaderButton/ContextMenuButton's native dependencies -- -// this screen's pool-info header button is unrelated to the intro sheet -// under test here. -jest.mock("hooks/useRightHeader"); - jest.mock("ducks/auth", () => ({ useAuthenticationStore: () => ({ network: "TESTNET" }), })); diff --git a/src/components/AmountCard.tsx b/src/components/AmountCard.tsx index 4a5345d25..4d074a323 100644 --- a/src/components/AmountCard.tsx +++ b/src/components/AmountCard.tsx @@ -107,6 +107,10 @@ type AmountCardEditableProps = AmountCardCommonProps & { autoFocus?: boolean; accessibilityLabel?: string; accessibilityHint?: string; + /** Renders the entered amount in the error color (e.g. the deposit exceeds + * the spendable balance). Additive -- defaults to false, matching every + * existing caller's (Send/Swap) behavior unchanged. */ + isError?: boolean; }; type AmountCardReadOnlyProps = AmountCardCommonProps & { @@ -239,6 +243,7 @@ const EditableAmountCard: React.FC = ({ autoFocus, accessibilityLabel, accessibilityHint, + isError, }) => { const { themeColors } = useColors(); @@ -264,6 +269,16 @@ const EditableAmountCard: React.FC = ({ const amountFontSize = getAmountFontSize(primaryText.length); // PickerChip resolves its own fallback when pickerLabel is undefined. + // `isError` takes precedence over the placeholder/value distinction -- + // an over-balance amount is never empty, but keeping the check explicit + // avoids relying on that. + const amountTextColor = isError + ? themeColors.status.error + : themeColors.text.primary; + const amountPlaceholderColor = isError + ? themeColors.status.error + : themeColors.text.secondary; + // iOS focus-retry workaround: focus() can silently drop when the input is // hidden/animated; re-attempt on the next tick if isFocused() is still // false. @@ -335,9 +350,7 @@ const EditableAmountCard: React.FC = ({ fontSize: amountFontSize, letterSpacing: AMOUNT_LETTER_SPACING, fontWeight: "500", - color: isEmpty - ? themeColors.text.secondary - : themeColors.text.primary, + color: isEmpty ? amountPlaceholderColor : amountTextColor, padding: 0, includeFontPadding: false, }} @@ -365,7 +378,7 @@ const EditableAmountCard: React.FC = ({ fontSize: amountFontSize, letterSpacing: AMOUNT_LETTER_SPACING, fontWeight: "500", - color: themeColors.text.primary, + color: amountTextColor, padding: 0, includeFontPadding: false, }} diff --git a/src/components/PercentageButtons.tsx b/src/components/PercentageButtons.tsx index 8e664b2fa..a92733899 100644 --- a/src/components/PercentageButtons.tsx +++ b/src/components/PercentageButtons.tsx @@ -34,7 +34,7 @@ export const PercentageButtons: React.FC = ({ { value: 100, label: t("transactionAmountScreen.percentageButtons.max") }, ]; return ( - + {items.map(({ value, label }) => ( + + + + + + + + + + + {t("earnNotEnough.or")} + + + + + + + + {t("earnNotEnough.transfer")} + + + + ); + case NotEnoughVariant.BUY_OR_TRANSFER: + return ( + + + + + ); + case NotEnoughVariant.SWAP_OR_TRANSFER: + return ( + + + + + ); + case NotEnoughVariant.TRANSFER_ONLY: + default: + return ( + + ); + } + }; return ( - - - + - + {t("earnNotEnough.title", { tokenCode })} - + - {t( - showBuy - ? "earnNotEnough.bodyWithBuy" - : "earnNotEnough.bodyReceiveOnly", - { tokenCode }, - )} + {t(bodyKey, { tokenCode })} - - {showBuy && ( - - - - )} - - - - + {renderActions()} ); }; diff --git a/src/components/screens/EarnScreen/components/PoolDetailsBottomSheet.tsx b/src/components/screens/EarnScreen/components/PoolDetailsBottomSheet.tsx index 338f875dc..d8cfa23a8 100644 --- a/src/components/screens/EarnScreen/components/PoolDetailsBottomSheet.tsx +++ b/src/components/screens/EarnScreen/components/PoolDetailsBottomSheet.tsx @@ -1,12 +1,18 @@ import { BottomSheetModal } from "@gorhom/bottom-sheet"; +import { TokenIcon } from "components/TokenIcon"; import { formatCompactUsd, formatRate, getPoolDescriptionKey, } from "components/screens/EarnScreen/helpers"; +import { Button } from "components/sds/Button"; import Icon from "components/sds/Icon"; import { Text } from "components/sds/Typography"; -import { BlendCatalogPool } from "config/blendTypes"; +import { BlendCatalogPool, BlendCatalogReserve } from "config/blendTypes"; +import { NATIVE_TOKEN_CODE } from "config/constants"; +import { Token } from "config/types"; +import { useAuthenticationStore } from "ducks/auth"; +import { getNativeContractDetails } from "helpers/soroban"; import useAppTranslation from "hooks/useAppTranslation"; import useColors from "hooks/useColors"; import React from "react"; @@ -20,9 +26,69 @@ export interface PoolDetailsBottomSheetProps { interface PoolDetailRow { key: string; label: string; - value: string; + /** Either a plain string value, or a custom renderer (the "Accepted + * tokens" row's icon stack). Exactly one is provided. */ + value?: string; + renderValue?: () => React.ReactNode; + /** Explicit value color, e.g. the green "Current Net APY" figure. */ + valueColor?: string; + testID?: string; } +/** The design's own icon-stack width (`9448:19027`, 4 icons at a 12px pitch) + * -- more reserves than this collapse into a "+N" trailer rather than + * spilling the row onto a second line. */ +const MAX_VISIBLE_RESERVE_ICONS = 4; + +/** + * Builds the `Token` shape `TokenIcon` expects from a Blend catalog reserve. + * + * Mirrors `EarnTokenRow`'s zero-balance fallback (there is no held balance to + * read a real token shape from here either): native XLM is decided by + * comparing the reserve's own contract address to the network's derived + * native SAC -- never by code, since any issuer can mint a classic asset + * coded "XLM". + */ +const reserveToToken = ( + reserve: BlendCatalogReserve, + nativeContractId: string, +): Token => + reserve.assetId === nativeContractId + ? { type: "native" as const, code: NATIVE_TOKEN_CODE as "XLM" } + : { + code: reserve.symbol || `${reserve.assetId.slice(0, 4)}…`, + issuer: { key: reserve.assetId }, + }; + +/** + * Renders one `PoolDetailRow[]` inside a rounded card, with a hairline + * divider between rows (never after the last one) -- shared by both stat + * cards (design node `9448:19005` / `9448:19023`). + */ +const PoolDetailsCard: React.FC<{ rows: PoolDetailRow[] }> = ({ rows }) => ( + + {rows.map((row, index) => ( + + + + {row.label} + + {row.renderValue ? ( + row.renderValue() + ) : ( + + {row.value} + + )} + + {index < rows.length - 1 && ( + + )} + + ))} + +); + /** * Content for the Earn pool-details sheet. Previously presented from * `EarnTokenPickerScreen`'s header info button, which was never in the @@ -33,12 +99,13 @@ interface PoolDetailRow { * * Modeled on `XlmReserveBottomSheet`: pure content (the caller wraps it in * `components/BottomSheet` and owns the modal ref); `bottomSheetModalRef` is - * used here only to dismiss via the close button. + * used here for both the close button and the bottom "Close" CTA. * - * A Backstop figure is deliberately never rendered: the backend does not - * serve `backstop_usd` for any pool yet (confirmed against the live - * catalog), so there is nothing honest to show for it — not even "--", - * which would misrepresent an omitted field as a checked-and-empty one. + * Structure follows the render (`9448:18518`) rather than the raw 360×600 + * popup geometry verbatim, per the design owner's ratios-of-canvas rule: + * header (pool artwork + name/"by Blend" + close) -> "Description" eyebrow + + * prose -> "Pool Details" eyebrow -> two stat cards -> full-width "Close" + * CTA. */ export const PoolDetailsBottomSheet: React.FC = ({ pool, @@ -46,6 +113,7 @@ export const PoolDetailsBottomSheet: React.FC = ({ }) => { const { t } = useAppTranslation(); const { themeColors } = useColors(); + const { network } = useAuthenticationStore(); const handleClose = () => { bottomSheetModalRef?.current?.dismiss(); @@ -59,10 +127,6 @@ export const PoolDetailsBottomSheet: React.FC = ({ return null; } - // Null and undefined both mean "unavailable" to the UI (see - // BlendCatalogPool's own `backstopUsd` comment) — but the row itself is - // never rendered at all, so that distinction doesn't even reach here. - // // getPoolDescriptionKey's return is deliberately widened to `string | null` // (Task 7) so the pool-id map can grow without narrowing callers — but that // means it can't type-check against i18next's literal key union the way a @@ -72,33 +136,130 @@ export const PoolDetailsBottomSheet: React.FC = ({ const descriptionKey = getPoolDescriptionKey(pool.id); const description = descriptionKey ? t(descriptionKey as never) : null; - const rows: PoolDetailRow[] = [ + const nativeContractId = getNativeContractDetails(network).contract; + const visibleReserves = pool.reserves.slice(0, MAX_VISIBLE_RESERVE_ICONS); + const overflowReserveCount = pool.reserves.length - visibleReserves.length; + + const statRows: PoolDetailRow[] = [ { key: "lendingInterest", label: t("earnPoolDetails.lendingInterest"), value: formatRate(pool.interestApy), + testID: "pool-details-lendingInterest", }, { key: "currentNetApy", label: t("earnPoolDetails.currentNetApy"), value: formatRate(pool.netApy), + // Exact hex match for this row's Figma fill (`Colors/Green/10`, + // `#3cb179`) -- confirmed against the node's own variable, not the + // `#4cc38a` figure recorded in the drift doc's visual notes for this + // sheet (that shade belongs to a *different* green variable used on + // the Review sheet's own "Current APY" row, not this one). SDS + // `Badge`'s "success" variant resolves to lime and is wrong for this + // green, per the parallel correction on `EarnTokenRow`'s APY pill — + // this is plain text, not a pill, so it goes through `Text`'s `color` + // prop directly rather than `Badge`. + valueColor: themeColors.green[10], + testID: "pool-details-currentNetApy", + }, + ]; + + const detailRows: PoolDetailRow[] = [ + { + key: "acceptedTokens", + label: t("earnPoolDetails.acceptedTokens"), + renderValue: () => + pool.reserves.length === 0 ? ( + // Empty reserve list: nothing resolved yet (e.g. a pool row built + // before `useEarnTokens` populates it). Same "unavailable" signal + // as every other null figure on this sheet, not a bare blank. + + -- + + ) : ( + + + {visibleReserves.map((reserve, index) => ( + + + + ))} + + {overflowReserveCount > 0 && ( + + + {`+${overflowReserveCount}`} + + + )} + + ), }, { - key: "totalSupplied", - label: t("earnPoolDetails.totalSupplied"), + key: "supplied", + label: t("earnPoolDetails.supplied"), value: formatCompactUsd(pool.suppliedUsd), + testID: "pool-details-supplied", }, { - key: "totalBorrowed", - label: t("earnPoolDetails.totalBorrowed"), + key: "borrowed", + label: t("earnPoolDetails.borrowed"), value: formatCompactUsd(pool.borrowedUsd), + testID: "pool-details-borrowed", + }, + { + // The backend does not serve `backstop_usd` for any pool today (see + // `BlendCatalogPool.backstopUsd`'s own comment and `mapCatalogPool`), + // so this reads "--" in practice for every live pool right now. The + // row itself still renders — matching the design's structure and this + // app's "null means unavailable, never silently omitted" rule — and + // will start showing a real figure automatically the day the backend + // adds the field, with no further changes here. + key: "backstop", + label: t("earnPoolDetails.backstop"), + value: formatCompactUsd(pool.backstopUsd), + testID: "pool-details-backstop", }, ]; return ( - + + {/* No pool-artwork asset exists yet. Sized 28px, matching + `PoolCard`'s identical placeholder treatment on the amount + screen -- not this sheet's own 32px header-icon geometry + (`9448:18861`) -- to keep the two surfaces' shared "no artwork + yet" identity treatment visually consistent with each other. */} + + + {pool.name && ( + + {pool.name} + + )} + + {t("earnPoolDetails.byBlend")} + + + = ({ - - {pool.name && ( - - {pool.name} + {description && ( + + + {t("earnPoolDetails.description")} - )} - {description && ( {description} - )} - + + )} - - {rows.map((row, index) => ( - - - - {row.label} - - - {row.value} - - - {index < rows.length - 1 && ( - - )} - - ))} + + + {t("earnPoolDetails.poolDetails")} + + + + + ); }; diff --git a/src/components/screens/EarnScreen/components/ReceiveFundsBottomSheet.tsx b/src/components/screens/EarnScreen/components/ReceiveFundsBottomSheet.tsx new file mode 100644 index 000000000..1decf4e49 --- /dev/null +++ b/src/components/screens/EarnScreen/components/ReceiveFundsBottomSheet.tsx @@ -0,0 +1,113 @@ +import { BottomSheetModal } from "@gorhom/bottom-sheet"; +import { AccountQrDisplay } from "components/AccountQrDisplay"; +import { Button, IconPosition } from "components/sds/Button"; +import { BUTTON_THEME } from "components/sds/Button/theme"; +import Icon from "components/sds/Icon"; +import { Text } from "components/sds/Typography"; +import useAppTranslation from "hooks/useAppTranslation"; +import { useClipboard } from "hooks/useClipboard"; +import useColors from "hooks/useColors"; +import useGetActiveAccount from "hooks/useGetActiveAccount"; +import React from "react"; +import { TouchableOpacity, View } from "react-native"; + +export interface ReceiveFundsBottomSheetProps { + bottomSheetModalRef?: React.RefObject; +} + +/** + * In-flow "Receive funds" sheet (design node `9457:46184`) -- opened from + * "Transfer from another account" on both the token picker's + * `NotEnoughTokenBottomSheet` and the amount screen's + * `XlmFeeShortfallBottomSheet`. Previously that action navigated cross-stack + * to `ROOT_NAVIGATOR_ROUTES.SCAN_RECEIVE_SCREEN`, ejecting the user from + * Earn entirely; this sheet is presented on top of whichever sheet triggered + * it (mirrors the review sheet's own security-detail sheet, and the mock + * itself, which composites this sheet directly over the fee sheet) so + * dismissing it always lands back exactly where the user was, still inside + * Earn. + * + * Shares `AccountQrDisplay` (avatar/name/address + QR + network pill) with + * `ScanReceiveScreen`'s Receive tab -- see that component's own doc for why + * the copy action isn't folded into it. This sheet's copy action uses the + * same plain `useClipboard` the screen uses -- a receive address is public + * and is copied precisely so the user can paste it elsewhere, often well + * after `useSecureClipboard`'s 30s auto-clear window, so the two surfaces + * behave identically here. + * + * Pure content, following this feature's convention: the caller wraps this + * in `components/BottomSheet` and owns the modal ref, passed through here + * only so the close control can dismiss it. + */ +export const ReceiveFundsBottomSheet: React.FC< + ReceiveFundsBottomSheetProps +> = ({ bottomSheetModalRef }) => { + const { t } = useAppTranslation(); + const { themeColors } = useColors(); + const { account } = useGetActiveAccount(); + const { copyToClipboard } = useClipboard(); + + const publicKey = account?.publicKey ?? ""; + + const handleClose = () => { + bottomSheetModalRef?.current?.dismiss(); + }; + + const handleCopy = () => { + copyToClipboard(publicKey); + }; + + return ( + + + + {t("earnReceiveFunds.title")} + + + + + + + + + {/* Design rhythm (node `9457:46184`): network pill -> 32 -> footnote -> + 16 -> CTA. `mt-8` (32) and `gap-4` (16) below match that exactly -- + this block isn't shared with `ReceiveTabView` (see + `AccountQrDisplay`'s doc comment for why the CTA stays + caller-owned), so there's no existing treatment to stay + consistent with here. */} + + + {t("scanReceiveScreen.receive.networkSupport")} + + + + + ); +}; + +export default ReceiveFundsBottomSheet; diff --git a/src/components/screens/EarnScreen/components/XlmFeeShortfallBottomSheet.tsx b/src/components/screens/EarnScreen/components/XlmFeeShortfallBottomSheet.tsx new file mode 100644 index 000000000..193d4d266 --- /dev/null +++ b/src/components/screens/EarnScreen/components/XlmFeeShortfallBottomSheet.tsx @@ -0,0 +1,107 @@ +import { BottomSheetModal } from "@gorhom/bottom-sheet"; +import { TokenIcon } from "components/TokenIcon"; +import { Button } from "components/sds/Button"; +import Icon from "components/sds/Icon"; +import { Text } from "components/sds/Typography"; +import { NATIVE_TOKEN_CODE } from "config/constants"; +import useAppTranslation from "hooks/useAppTranslation"; +import useColors from "hooks/useColors"; +import React from "react"; +import { TouchableOpacity, View } from "react-native"; + +export interface XlmFeeShortfallBottomSheetProps { + bottomSheetModalRef?: React.RefObject; + /** Buy XLM via the onramp. Same destination as the token picker's + * `NotEnoughTokenBottomSheet` Buy button. */ + onBuy: () => void; + /** Cross-stack jump to the global Receive screen -- a separate batch + * replaces this with an in-flow "Receive funds" QR sheet (design node + * `9457:46184`); this sheet just needs to call whatever handler the + * caller currently wires here. */ + onReceive: () => void; +} + +/** + * Sheet shown when the account has no XLM at all to cover the network fee + * (the pre-simulation `needsXlmForFee` gate) -- distinct from the + * measured-resource-fee shortfall, which stays a toast (`earnAmount.errors. + * feeShortfall`/`insufficientBalanceForFee`) and is untouched by this + * component. + * + * Previously the generic `InformationBottomSheet` with a single "Buy XLM" + * action; this is a dedicated sheet matching design node `9457:45927` + * exactly: the XLM asset icon (not an amber warning glyph), and **two** + * stacked full-width actions -- "Buy with Coinbase" (light `tertiary` pill) + * above "Transfer from another account" (outlined `secondary` pill) -- the + * same two-action stack shape as `NotEnoughTokenBottomSheet`'s + * `BUY_OR_TRANSFER` variant. + * + * Pure content: mirrors `PoolDetailsBottomSheet`'s convention (the caller + * wraps this in `components/BottomSheet` and owns the modal ref, passed + * through here only so the close control and actions can dismiss it). + */ +export const XlmFeeShortfallBottomSheet: React.FC< + XlmFeeShortfallBottomSheetProps +> = ({ bottomSheetModalRef, onBuy, onReceive }) => { + const { t } = useAppTranslation(); + const { themeColors } = useColors(); + + const handleClose = () => { + bottomSheetModalRef?.current?.dismiss(); + }; + + return ( + + + + + + + + + + {t("earnAmount.networkFeeSheet.title")} + + + + + {t("earnAmount.networkFeeSheet.body")} + + + + + + + + + ); +}; + +export default XlmFeeShortfallBottomSheet; diff --git a/src/components/screens/EarnScreen/helpers/getNotEnoughVariant.ts b/src/components/screens/EarnScreen/helpers/getNotEnoughVariant.ts index 1272cf683..395b2b9de 100644 --- a/src/components/screens/EarnScreen/helpers/getNotEnoughVariant.ts +++ b/src/components/screens/EarnScreen/helpers/getNotEnoughVariant.ts @@ -14,8 +14,9 @@ export const EARN_ONRAMP_ASSETS = new Set(["XLM", "USDC"]); /** * Which button set the "Not enough X" sheet shows. * - * `TRANSFER_ONLY` is not in the designs but is reachable — an empty account on - * a non-onrampable asset has nothing to buy with and nothing to swap from. + * `TRANSFER_ONLY` is designed (Figma node `9457:46530`) and reachable — an + * empty account on a non-onrampable asset has nothing to buy with and + * nothing to swap from. */ export enum NotEnoughVariant { SWAP_OR_TRANSFER = "swap-or-transfer", diff --git a/src/components/screens/EarnScreen/helpers/projectEarnings.ts b/src/components/screens/EarnScreen/helpers/projectEarnings.ts index e05ffd700..7595df243 100644 --- a/src/components/screens/EarnScreen/helpers/projectEarnings.ts +++ b/src/components/screens/EarnScreen/helpers/projectEarnings.ts @@ -36,3 +36,32 @@ 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)}`; + +/** + * Projected earnings on the position as it stands right now, before a + * pending deposit lands -- the "before" side of the Review sheet's + * before → after earnings rows (Figma node `9448:29319`). + * + * Purely additive: `projectEarnings` already takes a generic USD amount and + * a rate, so this is the exact same simple-interest math (see its doc for + * why it is intentionally not compounded) applied to the position's current + * USD value instead of the incoming deposit's. Kept as its own named export + * -- rather than calling `projectEarnings` directly at the review sheet's + * call site -- so the "before" side has its own name and its own test + * coverage, and so `projectEarnings`'s own signature and behavior stay + * completely untouched. + * + * Returns null when either input is unavailable, for the same reason as + * `projectEarnings`: a missing rate or an unpriced position means + * "unknown", not "zero". Callers render null as "--". + */ +export const projectCurrentEarnings = ({ + currentPositionUsd, + apy, +}: { + /** USD value of the position today; null when unpriced. */ + currentPositionUsd: string | null; + /** Rate as a decimal fraction (0.1694 = 16.94%); null when unavailable. */ + apy: number | null; +}): { monthly: string | null; yearly: string | null } => + projectEarnings({ depositUsd: currentPositionUsd, apy }); diff --git a/src/components/screens/EarnScreen/hooks/useEarnTransaction.ts b/src/components/screens/EarnScreen/hooks/useEarnTransaction.ts index 39d593092..87282eda8 100644 --- a/src/components/screens/EarnScreen/hooks/useEarnTransaction.ts +++ b/src/components/screens/EarnScreen/hooks/useEarnTransaction.ts @@ -27,22 +27,25 @@ interface UseEarnTransactionResult { * * Setting `status` to "submitting" IS the processing flag: * `EarnAmountScreen` renders its inline `EarnProcessingScreen` whenever - * `status !== "idle"`, so calling `submit()` both flips that flag and - * kicks off the sign/submit work in a single call. + * `status` is "submitting" or "success", so calling `submit()` both flips + * that flag and kicks off the sign/submit work in a single call. */ submit: () => Promise; /** * Returns `status` to "idle" without touching the earn duck. This is not * part of the brief's literal `{ submit, status, transactionHash, error }` - * shape, but is needed by the "error" state's "back to amount" action: - * without it, `EarnAmountScreen`'s inline gate (`status !== "idle"`) would - * have no way to drop back to the normal amount screen, where + * shape, but is needed to drop back to the normal amount screen on + * failure: without it, `EarnAmountScreen`'s inline gate (`status === + * "submitting" || "success"`) would have no way back from "error", where * `lastSubmitFailed` (already set by the failed `submit()` below) drives - * the retry banner. + * the retry banner. Called automatically by an effect in + * `EarnAmountScreen` as soon as `status` becomes "error" — the design has + * no failure screen with a user-facing "back to amount" button for this to + * live behind. * * A no-op while `status === "submitting"` — there is no call site that - * reaches `reset()` mid-submit today (the only caller renders solely in - * the "error" state), but this closes off that class of bug rather than + * reaches `reset()` mid-submit today (the only caller fires on the + * "error" transition), but this closes off that class of bug rather than * relying on today's single call site staying that way. Abandoning an * in-flight submit is `abandon()`'s job, not this one's. */ diff --git a/src/components/screens/EarnScreen/screens/EarnAmountScreen.tsx b/src/components/screens/EarnScreen/screens/EarnAmountScreen.tsx index 2fc3978c0..cd462155a 100644 --- a/src/components/screens/EarnScreen/screens/EarnAmountScreen.tsx +++ b/src/components/screens/EarnScreen/screens/EarnAmountScreen.tsx @@ -4,13 +4,15 @@ import { NativeStackScreenProps } from "@react-navigation/native-stack"; import BigNumber from "bignumber.js"; import { AmountCard } from "components/AmountCard"; import BottomSheet from "components/BottomSheet"; -import InformationBottomSheet from "components/InformationBottomSheet"; import { PercentageButtons } from "components/PercentageButtons"; +import TransactionSettingsBottomSheet from "components/TransactionSettingsBottomSheet"; import { SecurityDetailBottomSheet } from "components/blockaid"; import { BaseLayout } from "components/layout/BaseLayout"; import { EarnReviewBottomSheet } from "components/screens/EarnScreen/components/EarnReviewBottomSheet"; import { PoolCard } from "components/screens/EarnScreen/components/PoolCard"; import { PoolDetailsBottomSheet } from "components/screens/EarnScreen/components/PoolDetailsBottomSheet"; +import { ReceiveFundsBottomSheet } from "components/screens/EarnScreen/components/ReceiveFundsBottomSheet"; +import { XlmFeeShortfallBottomSheet } from "components/screens/EarnScreen/components/XlmFeeShortfallBottomSheet"; import { UNKNOWN_RESOURCE_FEE_FLOOR_XLM, getEarnCtaState, @@ -24,7 +26,6 @@ import { useEarnTransaction } from "components/screens/EarnScreen/hooks/useEarnT import { useSimulateEarnDeposit } from "components/screens/EarnScreen/hooks/useSimulateEarnDeposit"; import { EarnProcessingScreen } from "components/screens/EarnScreen/screens"; import { Button } from "components/sds/Button"; -import Icon from "components/sds/Icon"; import { NoticeBanner, NoticeBannerVariants, @@ -60,7 +61,6 @@ import { formatTokenForDisplay, } from "helpers/formatAmount"; import useAppTranslation from "hooks/useAppTranslation"; -import useColors from "hooks/useColors"; import useGetActiveAccount from "hooks/useGetActiveAccount"; import { useInitialRecommendedFee } from "hooks/useInitialRecommendedFee"; import { useNetworkFees } from "hooks/useNetworkFees"; @@ -85,7 +85,6 @@ const EarnAmountScreen: React.FC = ({ }) => { const { assetId, tokenCode } = route.params; const { t } = useAppTranslation(); - const { themeColors } = useColors(); const { account } = useGetActiveAccount(); const { network } = useAuthenticationStore(); const { pricedBalances } = useBalancesStore(); @@ -161,6 +160,8 @@ const EarnAmountScreen: React.FC = ({ const transactionSecurityWarningBottomSheetModalRef = useRef(null); const poolDetailsBottomSheetModalRef = useRef(null); + const transactionSettingsBottomSheetModalRef = useRef(null); + const receiveFundsBottomSheetModalRef = useRef(null); const converter = useTokenFiatConverter({ selectedBalance: depositBalance, @@ -338,14 +339,68 @@ const EarnAmountScreen: React.FC = ({ poolDetailsBottomSheetModalRef.current?.present(); }, []); + // Review sheet's footer gear (design `9448:29608`) -- same + // `TransactionSettingsBottomSheet`/`TransactionContext.Send` reuse as the + // fee/timeout context above, wired the same way Send/Swap's own review + // sheets open theirs (see `TransactionAmountScreen`'s + // `handleOpenSettingsFromReview`). + const openTransactionSettings = useCallback(() => { + transactionSettingsBottomSheetModalRef.current?.present(); + }, []); + + const handleCancelTransactionSettings = useCallback(() => { + transactionSettingsBottomSheetModalRef.current?.dismiss(); + }, []); + + const handleConfirmTransactionSettings = useCallback(() => { + transactionSettingsBottomSheetModalRef.current?.dismiss(); + }, []); + + // Rebuilds the staged deposit with the just-saved fee/timeout so Review's + // staged XDR keeps matching what Confirm will actually submit -- the same + // rebuild Send's own `handleSettingsChange` triggers after a fee/timeout + // save. Reads the settings store's value via `getState()` rather than the + // `transactionFee`/`transactionTimeout` closed over above: those are still + // the PRE-save values at the instant this fires, since + // `TransactionSettingsBottomSheet`'s `handleConfirm` calls + // `onSettingsChange` synchronously, before this screen re-renders with the + // just-saved store value (same reasoning as the `getState()` read in + // `handleCtaPress` above). + const handleEarnSettingsChange = useCallback(() => { + if (isSimulating || !account?.publicKey) { + return; + } + const freshSettings = useTransactionSettingsStore.getState(); + simulate({ + assetId, + amount: tokenAmount, + decimals: selectedAssetDecimals, + transactionFee: freshSettings.transactionFee, + transactionTimeout: freshSettings.transactionTimeout, + network, + senderAddress: account.publicKey, + }); + }, [ + isSimulating, + account?.publicKey, + simulate, + assetId, + tokenAmount, + selectedAssetDecimals, + network, + ]); + // `EarnProcessingScreen` is rendered INLINE below (not a registered // route — there is no `EARN_ROUTES.EARN_PROCESSING_SCREEN`), gated on - // `earnTransactionStatus !== "idle"`. This structurally prevents a - // swipe-back gesture from abandoning an in-flight submit, which is - // stronger than a navigator's `gestureEnabled: false`. + // `earnTransactionStatus` being "submitting" or "success". This + // structurally prevents a swipe-back gesture from abandoning an in-flight + // submit, which is stronger than a navigator's `gestureEnabled: false`. + // "error" deliberately falls through to the normal amount screen instead + // (see the auto-reset effect and render gate below) — the design has no + // failure step. const { status: earnTransactionStatus, - error: earnTransactionError, + transactionHash: earnTransactionHash, submit: submitEarnTransaction, reset: resetEarnTransactionStatus, abandon: abandonEarnTransaction, @@ -406,13 +461,28 @@ const EarnAmountScreen: React.FC = ({ }); }, [navigation, resetEarn]); - // Error's action: drop back to this screen (no navigation) — the retry - // banner shows because `lastSubmitFailed` was already set by the failed - // submit. + // Drop back to this screen (no navigation) — the retry banner shows + // because `lastSubmitFailed` was already set by the failed submit. Per the + // design (`9599:40192`) there is no dedicated failure screen to trigger + // this from a button anymore -- the effect below calls it automatically as + // soon as `earnTransactionStatus` becomes "error". const handleEarnProcessingBackToAmount = useCallback(() => { resetEarnTransactionStatus(); }, [resetEarnTransactionStatus]); + // Failure returns AUTOMATICALLY to the amount screen (design `9599:40192` + // is the amount screen with a retry banner, not a separate failure step). + // `resetEarnTransactionStatus` drops `earnTransactionStatus` back to + // "idle" so the render gate below falls through to the normal amount + // screen; `lastSubmitFailed` (already set by the failed submit inside + // `useEarnTransaction`) is what actually drives the retry banner, and is + // untouched by this reset. + useEffect(() => { + if (earnTransactionStatus === "error") { + handleEarnProcessingBackToAmount(); + } + }, [earnTransactionStatus, handleEarnProcessingBackToAmount]); + const handleCancelSecurityWarning = useCallback(() => { transactionSecurityWarningBottomSheetModalRef.current?.dismiss(); }, []); @@ -574,15 +644,35 @@ const EarnAmountScreen: React.FC = ({ }); }, [rootNavigation]); - if (earnTransactionStatus !== "idle") { + // The fee-shortfall sheet's second action (design node `9457:45927`): an + // in-flow "Receive funds" QR sheet (`9457:46184`), presented ON TOP of the + // fee sheet rather than replacing it -- the mock itself composites the + // receive sheet directly over this one, and this mirrors the review + // sheet's own security-detail sheet (`onSecurityWarningPress` below), the + // established pattern in this feature for stacking a sheet without + // dismissing the one beneath. Dismissing it leaves the fee sheet exactly + // as it was -- the user never leaves Earn. Previously this navigated + // cross-stack to `ROOT_NAVIGATOR_ROUTES.SCAN_RECEIVE_SCREEN`, ejecting the + // user from the flow entirely -- same drift as + // `EarnTokenPickerScreen`'s `NotEnoughTokenBottomSheet`. + const handleReceiveXlmPress = useCallback(() => { + receiveFundsBottomSheetModalRef.current?.present(); + }, []); + + // "error" deliberately falls through to the normal amount screen below + // (see the auto-reset effect above) rather than rendering + // `EarnProcessingScreen` — the design has no failure step for it to show. + if ( + earnTransactionStatus === "submitting" || + earnTransactionStatus === "success" + ) { return ( ); } @@ -670,27 +760,10 @@ const EarnAmountScreen: React.FC = ({ networkFeeBottomSheetModalRef.current?.dismiss() } customContent={ - networkFeeBottomSheetModalRef.current?.dismiss()} - onConfirm={handleBuyXlmPress} - confirmLabel={t("earnAmount.networkFeeSheet.buyXlm")} - headerElement={ - - - - } - texts={[ - { - key: "description", - value: t("earnAmount.networkFeeSheet.description", { - fee: formatTokenForDisplay(transactionFee, NATIVE_TOKEN_CODE), - }), - }, - ]} + } /> @@ -710,6 +783,19 @@ const EarnAmountScreen: React.FC = ({ transactionSecurityWarningBottomSheetModalRef.current?.present() } onConfirm={handleConfirmDeposit} + onSettingsPress={openTransactionSettings} + /> + } + /> + } /> @@ -743,6 +829,17 @@ const EarnAmountScreen: React.FC = ({ /> } /> + + receiveFundsBottomSheetModalRef.current?.dismiss() + } + customContent={ + + } + /> ); }; diff --git a/src/components/screens/EarnScreen/screens/EarnProcessingScreen.tsx b/src/components/screens/EarnScreen/screens/EarnProcessingScreen.tsx index 99074f08f..306aebfbd 100644 --- a/src/components/screens/EarnScreen/screens/EarnProcessingScreen.tsx +++ b/src/components/screens/EarnScreen/screens/EarnProcessingScreen.tsx @@ -8,26 +8,45 @@ import Icon from "components/sds/Icon"; import { Display, Text } from "components/sds/Typography"; import { AnalyticsEvent, buildScreenViewedProps } from "config/analyticsConfig"; import { mapNetworkToNetworkDetails } from "config/constants"; +import { logger } from "config/logger"; import { useAuthenticationStore } from "ducks/auth"; import { useBalancesStore } from "ducks/balances"; import { useEarnStore } from "ducks/earn"; import { getBalanceByContractId } from "helpers/balances"; import { formatTokenForDisplay } from "helpers/formatAmount"; +import { getStellarExpertUrl } from "helpers/stellarExpert"; import useAppTranslation from "hooks/useAppTranslation"; import useColors from "hooks/useColors"; -import React, { useEffect, useLayoutEffect, useMemo, useRef } from "react"; +import { useInAppBrowser } from "hooks/useInAppBrowser"; +import React, { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, +} from "react"; import { View } from "react-native"; import { track } from "services/analytics/core"; export interface EarnProcessingScreenProps { - /** Excludes "idle" — `EarnAmountScreen` only renders this screen once a - * submit is underway; "idle" means the normal amount screen is showing. */ - status: Exclude; + /** + * Only "submitting" and "success" — per the design (`9599:40192`) there is + * no dedicated failure screen: on failure `EarnAmountScreen` returns + * automatically to the normal amount screen, where the retry banner + * (driven by the earn duck's `lastSubmitFailed`) takes over. Excluding + * "idle" and "error" here makes that illegal state unrepresentable rather + * than relying on the caller to simply not pass them. + */ + status: Exclude; /** The amount entered on the amount screen, in display (non-raw) units. */ tokenAmount: string; - /** The real reason sourced from `transactionBuilder`/`useEarnTransaction` - * — never a fabricated message. Only rendered when `status === "error"`. */ - error: string | null; + /** + * Set once `submit()` resolves successfully; null while still submitting. + * Threaded through as a prop for the same reason `tokenAmount` is (see + * below): it lives in `useEarnTransaction`'s local state, not a duck. + * Powers the "View transaction" explorer link on the success state. + */ + transactionHash: string | null; /** * Close while a submit is still in flight. Per the flow's design this * returns the user Home WITHOUT waiting for the result — there is no @@ -42,10 +61,6 @@ export interface EarnProcessingScreenProps { onCloseWhileSubmitting: () => void; /** Success's "Done" action: resets the earn duck and returns Home. */ onDone: () => void; - /** Error's action: drops back to the amount screen, where - * `lastSubmitFailed` (already set by the failed submit) shows the retry - * banner. */ - onBackToAmount: () => void; } /** @@ -53,30 +68,36 @@ export interface EarnProcessingScreenProps { * (icon + status text + a card summarizing what happened) but, like * `EarnReviewBottomSheet`, reads the pool/asset it's summarizing directly * off `useEarnStore` and `useBalancesStore` rather than threading them - * through props — `tokenAmount` is the one exception, since it lives in the - * amount screen's local `useTokenFiatConverter` state, not a duck. + * through props — `tokenAmount` and `transactionHash` are the exceptions, + * since they live in the amount screen's local hook state + * (`useTokenFiatConverter` / `useEarnTransaction`), not a duck. * - * Rendered INLINE from `EarnAmountScreen` (not a registered route) behind a - * `status !== "idle"` gate — see `useEarnTransaction`. This structurally - * prevents a swipe-back gesture from abandoning an in-flight submit, which - * is stronger than relying on a navigator's `gestureEnabled: false`. + * Rendered INLINE from `EarnAmountScreen` (not a registered route) whenever + * status is "submitting" or "success" — see `useEarnTransaction`. This + * structurally prevents a swipe-back gesture from abandoning an in-flight + * submit, which is stronger than relying on a navigator's + * `gestureEnabled: false`. There used to be a third, "error" state rendered + * here (a full-screen "Deposit failed" step) — the design (`9599:40192`) has + * no such screen, so it was removed; `EarnAmountScreen` now returns + * automatically to the normal amount screen on failure instead. */ const EarnProcessingScreen: React.FC = ({ status, tokenAmount, - error, + transactionHash, onCloseWhileSubmitting, onDone, - onBackToAmount, }) => { const { t } = useAppTranslation(); const { themeColors } = useColors(); const navigation = useNavigation(); const { network } = useAuthenticationStore(); const { pricedBalances } = useBalancesStore(); + const { open: openInAppBrowser } = useInAppBrowser(); const selectedAssetId = useEarnStore((state) => state.selectedAssetId); const selectedAssetCode = useEarnStore((state) => state.selectedAssetCode); + const pool = useEarnStore((state) => state.pool); const hasEmittedSuccessView = useRef(false); const networkDetails = useMemo( @@ -137,31 +158,37 @@ const EarnProcessingScreen: React.FC = ({ [navigation], ); - const getStatusText = () => { - switch (status) { - case "success": - return t("earnProcessing.deposited"); - case "error": - return t("earnProcessing.failed"); - case "submitting": - default: - return t("earnProcessing.depositing"); - } - }; - - const getStatusIcon = () => { - switch (status) { - case "success": - return ( - - ); - case "error": - return ; - case "submitting": - default: - return ; + const getStatusText = () => + status === "success" + ? t("earnProcessing.deposited") + : t("earnProcessing.depositing"); + + const getStatusIcon = () => + status === "success" ? ( + + ) : ( + + ); + + // "View transaction" (success only, design node `9449:29739`). Same + // stellar.expert URL construction `SwapProcessingScreen`'s transaction + // details sheet and `ManageAccounts`' "view on explorer" action use — + // reused directly rather than hand-building the URL. + const handleViewTransaction = useCallback(() => { + if (!transactionHash) { + return; } - }; + + const explorerUrl = `${getStellarExpertUrl(network)}/tx/${transactionHash}`; + + openInAppBrowser(explorerUrl).catch((err) => + logger.error( + "EarnProcessingScreen", + "Error opening transaction explorer", + err, + ), + ); + }, [transactionHash, network, openInAppBrowser]); return ( @@ -176,38 +203,60 @@ const EarnProcessingScreen: React.FC = ({ + {/* Triptych (design node `9449:29733`/`9449:29814`): the + deposit asset's icon, a secondary double-chevron connector, then + the pool's identity icon — no pool-artwork asset exists yet, so + this reuses `PoolCard`'s own lilac-square placeholder for + consistency across the flow, same reasoning as that + component's doc comment. */} - + {depositBalance && ( )} + + - - {formatTokenForDisplay(tokenAmount, selectedAssetCode)} - - - - - {status === "error" && error && ( - - {error} + {formatTokenForDisplay(tokenAmount, selectedAssetCode)} + + {` ${t("earnProcessing.to")} `} + + {pool?.name} - )} + {status === "success" && ( + {transactionHash && ( + + )} - - )} - {status === "submitting" && ( diff --git a/src/components/screens/EarnScreen/screens/EarnTokenPickerScreen.tsx b/src/components/screens/EarnScreen/screens/EarnTokenPickerScreen.tsx index 98a1d5374..b232394c4 100644 --- a/src/components/screens/EarnScreen/screens/EarnTokenPickerScreen.tsx +++ b/src/components/screens/EarnScreen/screens/EarnTokenPickerScreen.tsx @@ -7,6 +7,7 @@ import { BaseLayout } from "components/layout/BaseLayout"; import { EarnIntroBottomSheet } from "components/screens/EarnScreen/components/EarnIntroBottomSheet"; import { EarnTokenRow } from "components/screens/EarnScreen/components/EarnTokenRow"; import { NotEnoughTokenBottomSheet } from "components/screens/EarnScreen/components/NotEnoughTokenBottomSheet"; +import { ReceiveFundsBottomSheet } from "components/screens/EarnScreen/components/ReceiveFundsBottomSheet"; import { NotEnoughVariant, getNotEnoughVariant, @@ -20,7 +21,10 @@ import { import { Button } from "components/sds/Button"; import { Text } from "components/sds/Typography"; import { AnalyticsEvent } from "config/analyticsConfig"; -import { mapNetworkToNetworkDetails } from "config/constants"; +import { + NATIVE_TOKEN_CODE, + mapNetworkToNetworkDetails, +} from "config/constants"; import { ADD_FUNDS_ROUTES, EARN_ROUTES, @@ -28,6 +32,7 @@ import { ROOT_NAVIGATOR_ROUTES, RootStackParamList, } from "config/routes"; +import { Balance, Token } from "config/types"; import { useAuthenticationStore } from "ducks/auth"; import { useBalancesStore } from "ducks/balances"; import { useEarnStore } from "ducks/earn"; @@ -70,6 +75,7 @@ export const EarnTokenPickerScreen: React.FC = ({ ); const notEnoughBottomSheetModalRef = useRef(null); const earnIntroBottomSheetModalRef = useRef(null); + const receiveFundsBottomSheetModalRef = useRef(null); const [notEnoughOption, setNotEnoughOption] = useState(null); @@ -113,6 +119,23 @@ export const EarnTokenPickerScreen: React.FC = ({ return getNotEnoughVariant({ isOnrampable, isSwappable }); }, [notEnoughOption, networkDetails, pricedBalances]); + // The sheet's header renders the deposit asset's real icon (design nodes + // `9457:46399`/`9457:46345`/`9457:46530`), not a generic glyph -- same + // fallback as `EarnTokenRow`'s: prefer the held balance's real token + // shape, otherwise reconstruct a minimal one from catalog data (native XLM + // decided by `isNative`, never by code -- see that row's own comment for + // why). `undefined` while no option is selected, matching `tokenCode`'s + // own `?? ""` fallback below. + const notEnoughToken: Token | Balance | undefined = notEnoughOption + ? (notEnoughOption.balance ?? + (notEnoughOption.isNative + ? { type: "native" as const, code: NATIVE_TOKEN_CODE as "XLM" } + : { + code: notEnoughOption.code, + issuer: { key: notEnoughOption.assetId }, + })) + : undefined; + const handleCloseNotEnoughSheet = useCallback(() => { notEnoughBottomSheetModalRef.current?.dismiss(); setNotEnoughOption(null); @@ -126,12 +149,31 @@ export const EarnTokenPickerScreen: React.FC = ({ }); }, [rootNavigation]); + // The swap-within-earn branch (prefilling a swap into the deposit asset) + // doesn't exist yet on this branch -- the design owner has decided to + // build it, so the button is wired to a named handler now rather than + // routed to `SWAP_STACK` (that would jump the user out of Earn, the exact + // drift this pass is correcting elsewhere) or omitted. Landing a named, + // inert handler ahead of the real implementation mirrors this feature's + // own established pattern (`handleUnheldTokenPress`, `openReviewSheet` + // were both landed this way and filled in later). + const handleSwapForToken = useCallback(() => { + // Intentionally empty -- the swap-within-earn branch fills this in. + }, []); + + // Design node `9457:46184`: an in-flow "Receive funds" QR sheet, presented + // ON TOP of the "not enough" sheet rather than replacing it -- the mock + // itself composites this sheet directly over the one that opened it, and + // this mirrors the review sheet's own security-detail sheet + // (`EarnAmountScreen`'s `onSecurityWarningPress`), the established pattern + // in this feature for stacking a sheet without dismissing the one beneath. + // Dismissing this sheet leaves the "not enough" sheet exactly as it was -- + // the user never leaves Earn. Previously this navigated cross-stack to + // `ROOT_NAVIGATOR_ROUTES.SCAN_RECEIVE_SCREEN`, ejecting the user from the + // flow entirely. const handleReceivePress = useCallback(() => { - notEnoughBottomSheetModalRef.current?.dismiss(); - rootNavigation.navigate(ROOT_NAVIGATOR_ROUTES.SCAN_RECEIVE_SCREEN, { - initialTab: "receive", - }); - }, [rootNavigation]); + receiveFundsBottomSheetModalRef.current?.present(); + }, []); // There used to be a header-right info button here that opened // `PoolDetailsBottomSheet`, but that button was never in the design and @@ -302,12 +344,25 @@ export const EarnTokenPickerScreen: React.FC = ({ } /> + + receiveFundsBottomSheetModalRef.current?.dismiss() + } + customContent={ + + } + /> ); }; diff --git a/src/components/screens/ScanReceiveScreen/ReceiveTabView.tsx b/src/components/screens/ScanReceiveScreen/ReceiveTabView.tsx index 115f98f53..a6545bbe1 100644 --- a/src/components/screens/ScanReceiveScreen/ReceiveTabView.tsx +++ b/src/components/screens/ScanReceiveScreen/ReceiveTabView.tsx @@ -1,19 +1,13 @@ -import { logos } from "assets/logos"; -import { Avatar } from "components/sds/Avatar"; +import { AccountQrDisplay } from "components/AccountQrDisplay"; import { Button, IconPosition } from "components/sds/Button"; import { BUTTON_THEME } from "components/sds/Button/theme"; import Icon from "components/sds/Icon"; import { Text } from "components/sds/Typography"; -import { pxValue } from "helpers/dimensions"; -import { truncateAddress } from "helpers/stellar"; import useAppTranslation from "hooks/useAppTranslation"; import { useClipboard } from "hooks/useClipboard"; import useGetActiveAccount from "hooks/useGetActiveAccount"; import React from "react"; -import { Image, View } from "react-native"; -import QRCode from "react-native-qrcode-svg"; - -const QR_SIZE = 210; +import { View } from "react-native"; /** * Receive tab body for ScanReceiveScreen: the active account's QR code with a @@ -29,44 +23,11 @@ export const ReceiveTabView: React.FC = () => { return ( - - - - - - {account?.accountName ?? ""} - - - {truncateAddress(publicKey)} - - - - - - - - - - - - - {t("scanReceiveScreen.receive.network")} - - - + + diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index e5a8c4b7c..d71dea4c0 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -707,11 +707,15 @@ } }, "earnPoolDetails": { - "title": "Pool details", - "lendingInterest": "Lending interest", - "currentNetApy": "Current net APY", - "totalSupplied": "Total supplied", - "totalBorrowed": "Total borrowed", + "byBlend": "by Blend", + "description": "Description", + "poolDetails": "Pool Details", + "lendingInterest": "Lending Interest", + "currentNetApy": "Current Net APY", + "acceptedTokens": "Accepted tokens", + "supplied": "Supplied", + "borrowed": "Borrowed", + "backstop": "Backstop", "descriptions": { "fixedPool": "Permissionless lending pool with no admin. Collateral factors, interest curves, and supported assets are locked at deployment." } @@ -729,9 +733,10 @@ }, "retryBanner": "Transaction failed. Try again.", "networkFeeSheet": { - "title": "Not enough XLM for the network fee", - "description": "This deposit needs a small amount of XLM ({{fee}}) to pay the network fee. Add more XLM to your account and try again.", - "buyXlm": "Buy XLM" + "title": "You need some XLM for network fee", + "body": "Add XLM to your wallet to continue", + "buyWithCoinbase": "Buy with Coinbase", + "transfer": "Transfer from another account" }, "errors": { "simulationFailed": "Failed to simulate the deposit. Please try again.", @@ -740,28 +745,38 @@ } }, "earnReview": { - "title": "Review deposit", - "pool": "Pool", - "apy": "APY", - "position": "Your position", + "title": "You are depositing", + "to": "To", + "apy": "Current APY", + "position": "Position", "positionValue": "{{before}} → {{after}}", - "estMonthlyEarnings": "Est. monthly earnings", - "estYearlyEarnings": "Est. yearly earnings", + "estMonthlyEarnings": "Monthly earnings (est.)", + "estYearlyEarnings": "Yearly earnings (est.)", + "monthlyEarningsValue": "{{before}} → {{after}}", + "yearlyEarningsValue": "{{before}} → {{after}}", + "transactionDetails": "Transaction details", "projectionDisclaimer": "Estimated earnings are projections based on the current rate and may change based on pool activity." }, "earnNotEnough": { - "title": "You don't have any {{tokenCode}}", - "bodyWithBuy": "Buy {{tokenCode}} or receive it from another wallet to deposit it into this pool.", - "bodyReceiveOnly": "Receive {{tokenCode}} from another wallet to deposit it into this pool.", + "title": "Not enough {{tokenCode}}", + "bodyWithBuy": "You'll need {{tokenCode}} to deposit into this pool. Buy or swap for {{tokenCode}} to continue.", + "bodySwapOnly": "You'll need {{tokenCode}} to deposit into this pool. Swap or transfer {{tokenCode}} to continue.", + "bodyTransferOnly": "You'll need {{tokenCode}} to deposit into this pool. Transfer {{tokenCode}} from another account to continue.", "buy": "Buy {{tokenCode}}", - "receive": "Receive" + "buyWithCoinbase": "Buy with Coinbase", + "swap": "Swap for {{tokenCode}}", + "transfer": "Transfer from another account", + "or": "or" + }, + "earnReceiveFunds": { + "title": "Receive funds" }, "earnProcessing": { "depositing": "Depositing", "deposited": "Deposited!", - "failed": "Deposit failed", - "closeMessage": "You can close this screen, your deposit should be complete in less than a minute", - "backToAmount": "Back to amount" + "to": "to", + "closeMessage": "You can close this screen, your transaction should be complete in less than a minute", + "viewTransaction": "View transaction" }, "transactionTokenScreen": { "title": "Sending", diff --git a/src/i18n/locales/pt/translations.json b/src/i18n/locales/pt/translations.json index 7f18f18ef..a2f9e93db 100644 --- a/src/i18n/locales/pt/translations.json +++ b/src/i18n/locales/pt/translations.json @@ -673,11 +673,15 @@ } }, "earnPoolDetails": { - "title": "Detalhes do pool", + "byBlend": "por Blend", + "description": "Descrição", + "poolDetails": "Detalhes do pool", "lendingInterest": "Juros de empréstimo", "currentNetApy": "APY líquido atual", - "totalSupplied": "Total fornecido", - "totalBorrowed": "Total emprestado", + "acceptedTokens": "Tokens aceitos", + "supplied": "Fornecido", + "borrowed": "Emprestado", + "backstop": "Backstop", "descriptions": { "fixedPool": "Pool de empréstimo sem permissão e sem administrador. Os fatores de garantia, as curvas de juros e os ativos suportados são fixados no momento da implantação." } @@ -695,9 +699,10 @@ }, "retryBanner": "A transação falhou. Tente novamente.", "networkFeeSheet": { - "title": "XLM insuficiente para a taxa de rede", - "description": "Este depósito precisa de uma pequena quantia de XLM ({{fee}}) para pagar a taxa de rede. Adicione mais XLM à sua conta e tente novamente.", - "buyXlm": "Comprar XLM" + "title": "Você precisa de XLM para a taxa de rede", + "body": "Adicione XLM à sua carteira para continuar", + "buyWithCoinbase": "Comprar com a Coinbase", + "transfer": "Transferir de outra conta" }, "errors": { "simulationFailed": "Falha ao simular o depósito. Tente novamente.", @@ -706,28 +711,38 @@ } }, "earnReview": { - "title": "Revisar depósito", - "pool": "Pool", - "apy": "APY", - "position": "Sua posição", + "title": "Você está depositando", + "to": "Para", + "apy": "APY atual", + "position": "Posição", "positionValue": "{{before}} → {{after}}", - "estMonthlyEarnings": "Ganhos mensais est.", - "estYearlyEarnings": "Ganhos anuais est.", + "estMonthlyEarnings": "Ganhos mensais (est.)", + "estYearlyEarnings": "Ganhos anuais (est.)", + "monthlyEarningsValue": "{{before}} → {{after}}", + "yearlyEarningsValue": "{{before}} → {{after}}", + "transactionDetails": "Detalhes da transação", "projectionDisclaimer": "Os ganhos estimados são projeções baseadas na taxa atual e podem mudar de acordo com a atividade do pool." }, "earnNotEnough": { - "title": "Você não possui {{tokenCode}}", - "bodyWithBuy": "Compre {{tokenCode}} ou receba de outra carteira para depositá-lo neste pool.", - "bodyReceiveOnly": "Receba {{tokenCode}} de outra carteira para depositá-lo neste pool.", + "title": "{{tokenCode}} insuficiente", + "bodyWithBuy": "Você precisará de {{tokenCode}} para depositar neste pool. Compre ou troque por {{tokenCode}} para continuar.", + "bodySwapOnly": "Você precisará de {{tokenCode}} para depositar neste pool. Troque ou transfira {{tokenCode}} para continuar.", + "bodyTransferOnly": "Você precisará de {{tokenCode}} para depositar neste pool. Transfira {{tokenCode}} de outra conta para continuar.", "buy": "Comprar {{tokenCode}}", - "receive": "Receber" + "buyWithCoinbase": "Comprar com a Coinbase", + "swap": "Trocar por {{tokenCode}}", + "transfer": "Transferir de outra conta", + "or": "ou" + }, + "earnReceiveFunds": { + "title": "Receber fundos" }, "earnProcessing": { "depositing": "Depositando", "deposited": "Depositado!", - "failed": "Falha no depósito", - "closeMessage": "Você pode fechar esta tela, seu depósito deve ser concluído em menos de um minuto", - "backToAmount": "Voltar ao valor" + "to": "para", + "closeMessage": "Você pode fechar esta tela, sua transação deve ser concluída em menos de um minuto", + "viewTransaction": "Ver transação" }, "transactionTokenScreen": { "title": "Enviando", From d652ebe7e17ac7f238ecf0ba99f6509d29351bf9 Mon Sep 17 00:00:00 2001 From: Aristides Staffieri Date: Mon, 24 Aug 2026 13:52:16 -0600 Subject: [PATCH 22/27] feat(earn): rebuild the Earn flow against the mobile Figma redesigns --- .../EarnScreen/EarnTokenPickerScreen.test.tsx | 145 +++++--- .../components/EarnIntroBottomSheet.test.tsx | 60 ---- src/assets/logos/blend-icon.png | Bin 0 -> 10427 bytes src/assets/logos/blend-logo.svg | 10 + .../EarnScreen/components/EarnGlow.tsx | 95 +++++ .../components/EarnIntroBottomSheet.tsx | 130 ------- .../components/EarnReviewBottomSheet.tsx | 76 ++-- .../components/EarnScreenHeader.tsx | 49 +++ .../EarnScreen/components/EarnTokenRow.tsx | 10 +- .../EarnScreen/components/PoolCard.tsx | 78 +++-- .../components/PoolDetailsBottomSheet.tsx | 22 +- .../EarnScreen/screens/EarnIntroScreen.tsx | 175 ++++++++++ .../screens/EarnProcessingScreen.tsx | 24 +- .../screens/EarnTokenPickerScreen.tsx | 328 +++++++++++------- .../screens/EarnScreen/screens/index.ts | 1 + src/config/analyticsConfig.ts | 4 + src/i18n/locales/en/translations.json | 25 +- src/i18n/locales/pt/translations.json | 25 +- src/navigators/EarnNavigator.tsx | 10 +- 19 files changed, 807 insertions(+), 460 deletions(-) delete mode 100644 __tests__/components/screens/EarnScreen/components/EarnIntroBottomSheet.test.tsx create mode 100644 src/assets/logos/blend-icon.png create mode 100644 src/assets/logos/blend-logo.svg create mode 100644 src/components/screens/EarnScreen/components/EarnGlow.tsx delete mode 100644 src/components/screens/EarnScreen/components/EarnIntroBottomSheet.tsx create mode 100644 src/components/screens/EarnScreen/components/EarnScreenHeader.tsx create mode 100644 src/components/screens/EarnScreen/screens/EarnIntroScreen.tsx diff --git a/__tests__/components/screens/EarnScreen/EarnTokenPickerScreen.test.tsx b/__tests__/components/screens/EarnScreen/EarnTokenPickerScreen.test.tsx index 258b9d8b7..df2d7ab3d 100644 --- a/__tests__/components/screens/EarnScreen/EarnTokenPickerScreen.test.tsx +++ b/__tests__/components/screens/EarnScreen/EarnTokenPickerScreen.test.tsx @@ -1,16 +1,17 @@ /* eslint-disable @fnando/consistent-import/consistent-import */ import { NativeStackScreenProps } from "@react-navigation/native-stack"; -import { act } from "@testing-library/react-native"; +import { act, fireEvent } from "@testing-library/react-native"; import EarnTokenPickerScreen from "components/screens/EarnScreen/screens/EarnTokenPickerScreen"; import { EARN_ROUTES, EarnStackParamList } from "config/routes"; import { usePreferencesStore } from "ducks/preferences"; import { renderWithProviders } from "helpers/testUtils"; import React from "react"; -// This screen presents the first-run Earn intro sheet on mount, gated on -// `usePreferencesStore.hasSeenEarnIntro` -- Task 13 removed the dead -// `EARN_ROUTES` intro entry, so this is a bottom sheet over the token -// picker rather than its own route/screen. Mocking approach modeled on +// This screen renders the first-run Earn intro as a full-screen early +// return, gated on `usePreferencesStore.hasSeenEarnIntro` -- `EARN_ROUTES` +// has no intro entry, so the intro replaces the picker's own body rather +// than being a route or (as previously) a bottom sheet over it. Mocking +// approach for the REMAINING sheets is modeled on // EarnAmountScreen.test.tsx: a stubbed that records each // sheet's imperative ref (by declaration order) and the props it was given, // so we can assert which sheet was presented -- and inspect the @@ -46,8 +47,8 @@ jest.mock("components/BottomSheet", () => { globalThis.__earnTokenPickerMockSheets.push({ ref: spy, props }); return spy; }, []); - // Don't render customContent -- the real EarnIntroBottomSheet/ - // NotEnoughTokenBottomSheet are covered by their own component tests. + // Don't render customContent -- the real NotEnoughTokenBottomSheet / + // ReceiveFundsBottomSheet are covered by their own component tests. return ReactModule.createElement(RNModule.View); }; return { __esModule: true, default: NoopSheet }; @@ -91,11 +92,11 @@ type Props = NativeStackScreenProps< typeof EARN_ROUTES.EARN_TOKEN_PICKER_SCREEN >; -const makeNavigation = () => - ({ - navigate: jest.fn(), - goBack: jest.fn(), - }) as unknown as Props["navigation"]; +// Shared across a render so the intro's close wiring (`navigation.goBack`) +// is assertable; re-created per test in `beforeEach`. +let mockNavigation: { navigate: jest.Mock; goBack: jest.Mock }; + +const makeNavigation = () => mockNavigation as unknown as Props["navigation"]; const makeRoute = () => ({ @@ -109,17 +110,23 @@ const renderScreen = () => , ); -/** The earn-intro `` is declared first in the JSX, so it's - * always index 0 in mount order. */ -const getEarnIntroSheet = () => - // eslint-disable-next-line no-underscore-dangle - globalThis.__earnTokenPickerMockSheets[0]; +const makeOption = (code: string, total = "0") => ({ + assetId: `C${code}FAKESACADDRESS`, + code, + decimals: 7, + total, + apy: 0.1694, + poolId: "CPOOLFAKE", + isNative: false, + balance: undefined, +}); describe("EarnTokenPickerScreen", () => { beforeEach(() => { jest.clearAllMocks(); // eslint-disable-next-line no-underscore-dangle globalThis.__earnTokenPickerMockSheets = []; + mockNavigation = { navigate: jest.fn(), goBack: jest.fn() }; mockUseEarnTokens = { isLoading: false, error: null, @@ -131,83 +138,115 @@ describe("EarnTokenPickerScreen", () => { }); describe("first-entry presentation", () => { - it("presents the intro sheet on mount when the flag is unset", () => { + it("renders the intro instead of the picker when the flag is unset", () => { usePreferencesStore.getState().setHasSeenEarnIntro(false); - renderScreen(); + const { getByTestId } = renderScreen(); - expect(getEarnIntroSheet().ref.present).toHaveBeenCalledTimes(1); + expect(getByTestId("earn-intro-screen")).toBeTruthy(); }); - it("does NOT present the intro sheet when the flag is already set", () => { + it("renders the picker, not the intro, when the flag is already set", () => { usePreferencesStore.getState().setHasSeenEarnIntro(true); - renderScreen(); + const { queryByTestId } = renderScreen(); - expect(getEarnIntroSheet().ref.present).not.toHaveBeenCalled(); + expect(queryByTestId("earn-intro-screen")).toBeNull(); }); - it("does not present while the token list is still loading", () => { + // The intro is deliberately gated AHEAD of the loading/error branches: + // it doesn't depend on the token list, and gating it on the fetch would + // flash a spinner before the first thing a new user ever sees. Both + // cases previously suppressed the intro entirely. + it("shows the intro even while the token list is still loading", () => { usePreferencesStore.getState().setHasSeenEarnIntro(false); mockUseEarnTokens.isLoading = true; - renderScreen(); + const { getByTestId, queryByTestId } = renderScreen(); - // The loading branch returns before the BottomSheet tree mounts at - // all, so there is nothing captured to present. - // eslint-disable-next-line no-underscore-dangle - expect(globalThis.__earnTokenPickerMockSheets).toHaveLength(0); + expect(getByTestId("earn-intro-screen")).toBeTruthy(); + expect(queryByTestId("earn-token-picker-spinner")).toBeNull(); }); - it("does not present when the token fetch errored", () => { + it("shows the intro even when the token fetch errored", () => { usePreferencesStore.getState().setHasSeenEarnIntro(false); mockUseEarnTokens.error = "boom"; - renderScreen(); + const { getByTestId } = renderScreen(); - // eslint-disable-next-line no-underscore-dangle - expect(globalThis.__earnTokenPickerMockSheets).toHaveLength(0); + expect(getByTestId("earn-intro-screen")).toBeTruthy(); }); }); - describe("dismiss wiring", () => { + describe("intro dismissal", () => { beforeEach(() => { usePreferencesStore.getState().setHasSeenEarnIntro(false); }); - it("marks the intro seen when the sheet's onDismiss fires (CTA or close, from inside EarnIntroBottomSheet)", () => { - renderScreen(); - - const introSheet = getEarnIntroSheet(); - const customContent = introSheet.props - .customContent as React.ReactElement<{ - onDismiss: () => void; - }>; + it("Continue marks the intro seen and reveals the picker", () => { + const { getByTestId, queryByTestId } = renderScreen(); expect(usePreferencesStore.getState().hasSeenEarnIntro).toBe(false); act(() => { - customContent.props.onDismiss(); + fireEvent.press(getByTestId("earn-intro-continue")); }); expect(usePreferencesStore.getState().hasSeenEarnIntro).toBe(true); + // Same screen, no navigation -- the intro is an early return, so the + // picker simply takes over on the next render. + expect(queryByTestId("earn-intro-screen")).toBeNull(); + expect(mockNavigation.goBack).not.toHaveBeenCalled(); }); - it("also marks the intro seen via the sheet's native onDismiss (swipe/backdrop dismissal)", () => { - renderScreen(); - - const introSheet = getEarnIntroSheet(); - const bottomSheetModalProps = introSheet.props.bottomSheetModalProps as { - onDismiss: () => void; - }; - - expect(usePreferencesStore.getState().hasSeenEarnIntro).toBe(false); + it("close marks the intro seen and leaves the Earn flow", () => { + const { getByTestId } = renderScreen(); act(() => { - bottomSheetModalProps.onDismiss(); + fireEvent.press(getByTestId("earn-intro-close")); }); + // Marked seen as well as dismissed: a user who closed the pitch should + // not be shown it again on this install. expect(usePreferencesStore.getState().hasSeenEarnIntro).toBe(true); + expect(mockNavigation.goBack).toHaveBeenCalledTimes(1); + }); + }); + // Figma `13701:332629`: the held section is replaced by explanatory prose + // when the user holds none of the pool's reserves -- the only variant the + // mock draws. + describe("list structure", () => { + beforeEach(() => { + usePreferencesStore.getState().setHasSeenEarnIntro(true); + }); + + it("shows the empty-held prose instead of an 'in your account' section", () => { + mockUseEarnTokens.supported = [makeOption("USDC")]; + + const { getByTestId, queryByText } = renderScreen(); + + expect(getByTestId("earn-token-picker-empty-held")).toBeTruthy(); + expect(queryByText("earnTokenPicker.inYourAccount")).toBeNull(); + expect(getByTestId("earn-token-option-USDC")).toBeTruthy(); + }); + + it("shows the held section, and no empty-held prose, when the user holds a reserve", () => { + mockUseEarnTokens.held = [makeOption("XLM", "100")]; + mockUseEarnTokens.supported = [makeOption("USDC")]; + + const { getByText, queryByTestId } = renderScreen(); + + expect(getByText("earnTokenPicker.inYourAccount")).toBeTruthy(); + expect(queryByTestId("earn-token-picker-empty-held")).toBeNull(); + }); + + it("renders the disclaimer and the ambient glow", () => { + mockUseEarnTokens.supported = [makeOption("USDC")]; + + const { getByText, getByTestId } = renderScreen(); + + expect(getByText("earnTokenPicker.apyDisclaimer")).toBeTruthy(); + expect(getByTestId("earn-glow")).toBeTruthy(); }); }); }); diff --git a/__tests__/components/screens/EarnScreen/components/EarnIntroBottomSheet.test.tsx b/__tests__/components/screens/EarnScreen/components/EarnIntroBottomSheet.test.tsx deleted file mode 100644 index 474ac87ed..000000000 --- a/__tests__/components/screens/EarnScreen/components/EarnIntroBottomSheet.test.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { BottomSheetModal } from "@gorhom/bottom-sheet"; -import { fireEvent } from "@testing-library/react-native"; -import { EarnIntroBottomSheet } from "components/screens/EarnScreen/components/EarnIntroBottomSheet"; -import { renderWithProviders } from "helpers/testUtils"; -import React from "react"; - -const makeRef = (dismissMock: jest.Mock) => { - const ref = React.createRef(); - Object.defineProperty(ref, "current", { - value: { dismiss: dismissMock }, - writable: true, - }); - return ref; -}; - -describe("EarnIntroBottomSheet", () => { - it("renders the extension's verbatim copy", () => { - const { getByText } = renderWithProviders( - , - ); - - expect(getByText("Make your tokens earn for you")).toBeTruthy(); - expect( - getByText( - "Deposit supported tokens into DeFi pools and start earning rewards.", - ), - ).toBeTruthy(); - expect(getByText("Start earning")).toBeTruthy(); - }); - - it("dismisses via the forwarded ref and marks the intro seen when the CTA is pressed", () => { - const dismissMock = jest.fn(); - const onDismiss = jest.fn(); - const ref = makeRef(dismissMock); - - const { getByTestId } = renderWithProviders( - , - ); - - fireEvent.press(getByTestId("earn-intro-start")); - - expect(dismissMock).toHaveBeenCalledTimes(1); - expect(onDismiss).toHaveBeenCalledTimes(1); - }); - - it("dismisses via the forwarded ref and marks the intro seen when the close control is pressed", () => { - const dismissMock = jest.fn(); - const onDismiss = jest.fn(); - const ref = makeRef(dismissMock); - - const { getByTestId } = renderWithProviders( - , - ); - - fireEvent.press(getByTestId("earn-intro-close")); - - expect(dismissMock).toHaveBeenCalledTimes(1); - expect(onDismiss).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/assets/logos/blend-icon.png b/src/assets/logos/blend-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..4aa81b88c7044ec10345a422a99ccb61373dda0c GIT binary patch literal 10427 zcmV;sC`8wZP)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91mY@Rw1ONa40RR91mH+?%02fP0AOHX;B}qgtk_Yk*eePuMZ}H;ML-Y{Q32%>>59^O4bnTILulFW|IX|> zyEi+#cei)RC3*Qxrnk56ro4IchA0PeFc1a|Z)1hyk_i)@Lw zoKE{=0K`xR<}sMLFkZkr{xpP%IN6zFg;-E%Q`KFZQs6~mMmF9t5&Td z^wAO%6GJ8@k-+li&8sCRCtK@( zI{ST^U(f14efqREY0@O^#v5 z+i$<6Uw{488ttjScb5iqZR2uOHt#OFaO#k+>;w5Or|!>AwylR5sN1$}6XW;8vk+!4 z%pRD1>F$kSqOO5ETS82vdkvy$fI9yN6DQVP3XEN zWhjw${7X#dQs~0D|5{Q!|H=P65*KsoPo6wUSlj8bCE>VM_lP$7x)c7NVWz^&%udo0 z5goCBXckXd1ydHrfR8`^n4WmziLek<{(LcXXV*s5sa{Dcp>3xNsYi@@WsY1i1*qBc zt7TC}*y6>Dsczl6hCcwu_elFO2#kMNSsEfeVvPni4Wdd-O{HGFdJ%Sbo+3Fs}kvx z@BXySfAPf^Q~C1cjre@|^7;IgDpm5?ID;han+tds#Pz6)bWxWik|WkzB6bE18Wb{7 z<%`Kh{d%>cwzY~FqL|l{A)?X1Bv4FhlK;AkXvwQaJA^z3#R2V2E#K+M?J+GpxsuiF(A2E3^ zJ$xvCP3rp0lt7!NOkb;3EqeOtr|Fhkqynk)k+}niOTBYY){wa(u^Nd8v=M6=Hf&fx zL{+?K9KCd3dzv}CD@}PkiLNfQnPQKx#t7v-QE@wq(H^IyZhA^SEu**PPYpj9M=8x1=XmAr5 zK~8I{5_t|}C{b}6ThwI<=kUS}H{3vT=gzfO@}@N)8vT(XtnDIlc*Ih{pS$8!(GiKO zUAaU=>R~<}q&394_)y-obPyw>#T_ZaGVRT;xZ(;Wex4dOh4RFziBDO82ay#M^ZJ!? z|NZw91PN*L=FOCnlH!}r4<0<|V?8Wgx|F6)ok}mi{4z0{!7c3Av4h@z`)zCTe%63! z^G9lp)fI>_PImbS2O`5tT)qEvSE^l5eYB!1AjC+ma3yN~_`fGFd}D`qb-8o9{~cbq zaDk>xn?~1OD?LUuwXSgC!o&my`Fb(DB-UC7#DHjWyT?>$z4{s1kLyM7@L#lOQQa%G zpBnKiHLX(*5HWv9~pm!maW?%tqWJesB`De(l}Y*Pe1*noEBqaWBmvgKgfuEA6c72 z35&>xmEJ&bI4MPMERR}!`}Wnnw)ULuENK&YIFYZSEm2*4_0`n2ZQFnv)uxT<@WC&y zZ-mB;Y!UEdtS$PZ$sG~G0)kk0X%&oVt9RRNw>jeKnRkDtm?Hm45;$fEF+p1H0<>sf z0`(d=%daHf?eTF8OSb0EpWiQD6#zMXV(AAJ{Povg)@b$i*_t{N`NK|FASyn$SPWA} z?Dwcqqv*;juM~r9-7}M8ZR0$N$eowAo=T)QemqWXURh0#fA}{YI)1?@`jJN-q20T8 z)9l%^sdnw)2N9bCXrms92+<`*WRy9=I){U(`U4+ti8$dk)m?YpML+)dqZq7o^GajE z;3jn8B#W1O0~+O`gBMEDr1?AP`}y1H^jW?`VTMmW`Gjy^k$C;%1^oW|@1&X%8#Zi6 z&?_NU^*NSVeZ+uhafj^?8btLdkYFOJ1q&7sE`9oTSAif1N?_>EJ&DwyNa%!h42t`H zS(-v)XC_n1iA{dZU?IH^Km3qdw{A_$GVl_3v!Vjtbz0!RZK)+9vwOobVu7e`MaRd8 zo#$N?AGn@5b4Cny?Pe*>*#qlh)bc~VvJ5R(45g$c()_iDX~N8nwByiOX?ot_4IVt0 zF1_?pX}lMK%&)RhjpQqyDO0k8Fv8n%y|kFr6ak^(%jXD=*yp$(%uyH^)VI`e*N{=eRAnuPj`Bo zWHul3#Kfp+g-0x5R9l4+tCp;+Y!$}qDi{NRB&}MtYP>3TWy+K(qv80($gev<2uA*A z`?S~Q4)3JR7X~{(At+@eyKqYOfNR#HtU>FwUOf_!1_<`L|pa+ zbiN48K@l-G-E@;9B7rSm8XTEu=g*%Pvm0)VV6My2%VQht3=KD`HAo^DWo4v;H3^FC zv${)_Dy3@PZ-^|1JlPA-=@&$-U|pp|BCun`gMTAtHQZQRIjVCM%em*Cd$jMr|K6wG z5yFHB;*@CjrdzN}MfDvLv zl+15OR9w*Gk3a4!qfw(q+6yndph31t!$v@3bw<$yX=!O1?ohhIhYUxoX$Lp2WnKOu z%M%xRJzVTLIO~px+`4tEm?g`)16gOv2ulxxaHI@Jvk(}UWeE(OGcA%J#HS=Ih~mnS zlqJI;#i7K7PB?CCR@5tj`B|B;PU{`!VcQ8wV0P-svxJTFPwm}z-_;<(tbu!P>Jx5# zYa>UF)ULbkI!C-X;f>>2*Qx}DXF1|RCyR+4W!<1E1hGcrtN^@rnlWRBPY)`Nn>4&; zq0OH^U)eRzvwr=0ZQ#Ix8aVw*JgIQVyI%?1l%!!yWK-RiVYNDc=$k4M2V(&it z>@x%L`mam0pL*&kF?ptS^J4_e;<)q9J2mFFiGAR;6m!wN1}3miKmF8HmsqdWec5G~ z$(qmv>7pOdvqEC;_+y|~wx)9B%3|_N<>n)$>C>lcci(-t^f3Z%J2PdO0Ky44c-N{{ zt!j%y5x)}k;J-&dRi*a;NH$J(aWmKvAsKlPY$XULCdjaBx8HudX2&Lhc0TQgAAZny z@f|7Pc0oqW5tsZL2k%NP&^k*H7dkaf>==aQln9YLcqC>IyPKyu9+~dF_g-6K;)k7{ zfBt#9XXV1)My`NIMm~8EM<|*5rI%i^wr56GA*=yn&z^hkIVCb6lce#-jWj@zPUBMn z{+06w;&zlQS<=TUXx6Nm_VB|G`$TKjty>rArrFlVk0?MUONr0YrAy1E=cpBHHhvVq zN>dRtyjzj|vc#|vBSwf>BI4%5>RH%*@tI>Z0n3qzxRbegsT`t&aP7j>uqS@jK1@dTAuL^VTmA4Sr^bs)!3J3#{GX8|cvw=oODzCc$+#;<)I! zfv^r8I%v4nqu~~f6-gQ5Pd%Tiy|QGe*5HRuTEibYYoD$kt6jifM!?vyV+}lsIMdzC zhm$~t><+_m{c8j|`Y?_?a`x0`>wbv6NNeQzWM!W`606s;|DIi zo$jc5qixMc&K{#t8^_azBdlPD=+dH>(CtWHG+zUbEv1v5Eml~<+HeH87wiht zJx?JnY|d~H7V912yX(dER^v7KfYtthH$(h{8y@-Ai;1|s_Q*!MbB_P6y?Fj&G_AuJ zX?wGFX|x8=QglhxpKrmRy$xlskkp7M*jrDintOt zFL#B$&Y%;A;kZTY87soT;i?#nM4qXSo*VJ#s*e^>|$ZQ!3(6}>GG*(Dr2>t0(MhrT}bL3RmtpV~`5Zs);J4Wl5|t5Y~~#-t@iS;MYV{B(41fZ2dp(XI+4(_v>- z`f$XBi?Hqy8#`dY0MRRT^TUx&=~kw!ljgMs?mvByc7X`Jf~(86pnIy{>NC~jjo*u@ z_@siROn#LosbEP}G@ZF{h6biUyBL2SyF@?u& z&j|}1VEqCe^>(gUv4U{vK=<0({Ne-u8dZ=Agu8`6;hJ|*-rO;Mbr#DPPXp^eOU+6*@QYi1Xf;Nu2k6@^Kbqq9ryime zDQit(x`%b6s(^Kw@=LY?@THfF1bi~Uw*n)>}Hc)EUX%@_2`&RKqa z&;w@fnn$%0YUp0W=J$P6fNw!lrc5DbmPFD@FKlf=P~M3zKtJIjoLVYy8rKPB~fuZXYG+s zhs;FA4I*V%CQqJhjgQ!L7*@iPD8x5)6gNyvez%^dYd=JzTaKV|aq{!kO-D^TBDckc zJ>6pP-`-sDzVD=fN8K}NXC3S09WEa&$pA9@vS1^OH%*o^+C~`3$`}d4=15L}GSADU3vctljuT;O_&==&$|D zXu-Zk6zop*_uUJqMo|-Lech^Cx30B&etyivCyRpQq$9a%c) z*__x1EIk^ka#=pck)09pbaelvy?uniAC$N8D^k{k#P!zuRRTNCG9WZ2x-xO4Ck3RyO69I_WfLg7QKv`R$b-(xiW;h6Op9snD^lKAS#N)DCpfE-eIs$;Yj9<}RKL{DB`Os9(Rn ze}oVs*&TKlqQm0!ny?IxoYT|1EDaed3|aMYs6|9g{w>&O>^Qkw+Fo1`mWJzrl0)e( zSPwfNb?5~IVS7zjJH$4qo)O_gQfA@>1H7|)@~_wEk9~`S$sV3 zt~6)P9K)aPFwS$W5nlMcCampZqnsW*a^lL1W$A8~wdywGZko6EFFh#R*t2ZT4&&qF zeb2+F5Ft5~Hjds@`GSq}ny_|?4Z(FK(d*}S;>=q!yteEe8Uk?=Hv)Cmyjj7|jM4*O zW5~d1I+O_ib;XR4S5}r6VO|r~2C-4in>QD|e(pEleA6$?F5r>V)%jp!JpR-lLw&Oc#eDV}kl57>X z^x$ebihGMz;8PB#8$1#jt*{CIc}iI6oi#CB_z7GUKm)NVnnrD!;5#l+Cdx*UX_SVI zi|H1Q(R-&GJbe3U?OKz3S8=Sf)aw z@MTsfh!nFl9E8OyY(9_j0kp|Dz5>rzzGPs>5R|3OjA54)u0(yS-9hivdzIQGUFBI5 z=VQdO-n7;Wf8&iebf?sotg~*ECJZsqbHc)0e;jQxwr_m+*|n>BD(7c(Wx&#x(Fz0W zJVPJU8%WodX-_o^SEZ+`KS-Z99!d%Mih9ze1$!4s%fX{i(r`VHXNYavw&`BBlQ11R z%ioAOT(~_atn@lfqKi(1wRrJjf*MD^=CTqEzSR7qJl96Lh8a?oIF6Cl3y9TJ9N6iBUxvuhdIAmoCCr_Sm@k-RBh}IOXl%?jS7#d#hHDwy7NWLz%^x!IKvR=J;ObW;U2ZZNm$>C9b*w%CoyDRYssKZ*=DlfOc_=twv_93-COw< z*J))5=j~l63-;j|PtQr?^gs!-^aef73p;Ftl|CIByF)1Lw`!X6YrjQdm=WJLil)QZ zJbVP_4oc)rpppfgujSj7w&&D-+IC{6G#c+hO2hR)X7S+Z8QsfvSub~oVI!>c76&vP ze*N{=QaO0Op;EFyaS;igxbMHDH~rdb9L;F;4gK1B9QCVyw^SciAeE(@F?}q@tT$B* zO-gv-{-x47>eO+blKDcb1=?HuL3_hZSd+w-G26juI$XJOrI^KVw<+uG$-|?U_Mz_O zIvIqNOUp%_liJb`Ex({ZrZb~~>5jEWH(7&}{<_6$DdU|Y7w$JnEEvi0oQvbSRx%y7 zH3|sP5mv&&bjUwngRRb5`dYMTp=>f<9F@km^>Ce4I3^B6)J9rQY_03ukNoVlna7pn&{KY<7lR2;K%#mc@#2`LcxoS7ACVDENbRhLy0=TV>+L zIqeRi8=hnrEiNlo-FJaB=$;B0`&PdTV$ni?#hDwtruXVO>-4f@GrR}SrWkG~NB%s< zwJfKk(s*V&*y+(~*s!5Aj~>V`Sy<%ZV-VL;j}OI;1Y!LkHn4klwFnSzh4~a-2JJkW z4yzZbEGym=RwPd$Vgd{XX6*5kFSjj5rsLE$u}#E^Ll`9`z$zu3gf01|v;{m7{tG){ zVLIH4h8xH6ETiRg7mgS4GHB}&Va4ZlH~n{rf9Rk;ub2ulkiG{u>!~!b)$0X%WvFlvOZj&>-mzo=c%cCJ@~Tc6H=- zxdvjQy62Kx?TS%`k68biGG32NIUCq6QqL#ahZXuyo%Ca{mgSv0O)QHlV%fpEBSlz~ z#RfVZN^@F^4oxHb)#XCp9R@NAwJROmk$0k}ZsXK7x-G>a9j3#rNMh`ZD^{%Nczbuk1QQF)sG%K{nuQ9fY3_BI7L6$q zbajs`dXY}2nRZ>KvcTb>Z~pP)C7IH=@#N2~-Tz$QVR#3i!yX@sLEBvMUt>4iVf-6v#03wbEl@JTIYTv&DG^X8?{y* zUT>7;#!*$F<=81dzgThGf`0Vsnqjo+*g86%c9M3V-Ak{m8A_A3PjkxdD!yDHwR{&| zXjPWO8fh$Ge}ZjEXy=U>VPQJlh(;LuVxA5!zx;B$cKO03M-IO5P|ps>Y%$TevJD)W z5P-I3yTTUiUqnwVd5wC^=|gucc#QtoFF$a%%dZN*q(~)IG~o?wWjwFLEa09IXUV90 zBSu*16`FR3tQ1TIygQUZ#X{w&QsD}+C|6;;#ov8qpR2T-=!DIqOs)$EbHWx$jcsdS zW-}`pRspksM8F_$RP+bq&QQW~T4(X|X$KA*koGH7X6fLyA4$uV1#Uj(yt|X-i$qv! zXk$*uZ!bsBPxCp!l~-OVCJF4YQ_Ly2BT88KC9FW*1~-p&JFtwl3Z_kyuR}tW(OPq4 z1O2vto~mjiZsuO~FWyL)0gf9k?xL*qtFOLN#@DJ<%Nj420JM7l2 zo3e>~gNKiKtiaP@IALKr} zYdOnRWkO&E@q10)P%hZH+0&;_SH2z1X(XeS>7D|o2^Ke(l~O#AnLF=ZDG3$;A9b z0czui9<_^i9;JRqXAbKf;>XBkM5D*EZFiy7D^VJ;uFT|Sg1WUzcbeFCB=yDvP)IxN;!oNmzr~&A{(RJ6#QD5fGU~94V=UYFui`VcGQ>!vfLe@pW(;h#~%g@Da-MZCvLtlF7C3^MMSFP!5fUwqC14H#^ zG+|we4j2rh{rmS*sZynseOkSGwXMQjey4C8*bkWj_+97*iC04LM(4k7jVSGiO7pp*up2cMIv%P)hJwZ%l zkpLE1Xn6V3z%u_Vp3Rc9p+MiheM!Qq;a^^T;BZ?gWvlYa zzm6)0$8kVpD-_8LQy=h}p;Dzvx>ss%*|J5MW(nS2er(|@#J7aQ0j#f!#desWct;m1 zFflO~eB8KkzG?2vnb6zKQlYHOPVyl69<;Y5h>9h8!v+~nSllxpmTxy0iz}89TD8jl zER=P_zx?vcN~CN!l1vXSc$WC3FfV|`e|fFc0&AvALXd&i+rq@ek{hE(k0v|izIxg~ zZN8Pm8zDYqw!k*{nW(l}!aet97Q*5MCQ>R~C3WWQ{5+;0KL(K`(nVT9uFE zbpBGKQgyKaZ{m9c+9{7yJhKE=tXM%TeD}czA2=lsI6fgEfrbqmMz`O7yDTqDhPDP# z?UY4&9hO-Lt1ddWSnnL~e)!kMziK;p|NZyrp@$x_i#CO?KD>dZ?U+fk@hsaRiTFhH>C#0THENWGkrv$C?IWMA|jiT%%#f=w@W%zHWkf7^X-#zfaVb z4a6kEm;eh>*B=m)sHVJLcp!zT;fY}l2feP_VI>qde(;?%DE`;Rk00-rn7DhBVA@CR z2ajaKfvBO3F&!Xgt3e^Y>yO%~Q6miuR6To8Hm_fBpHYKktLu8*5RWwt7{OYbqQmT?m8Ghg$t4VPEl7E5LN(Y9gG1RH*VB;p>F*!Du06p4KxS`YS5%&lw;@M z#rNL5d$oxZCu+DA98f)~Hpt@Y)vKos7%)J?D_nNXk%j+_!2U2LP-av>rr={)F$DzH zMwm&ymJg=+fI3)U?KZ6YoR5o&7A@*~ONOuTv(Ou+pJ0o1m5=p+va&hWRe|-Ot2{{b z8w}s^n+*Pwab3=B=A3jk7yxlS1Y-icx1)jE9@Y^B%?Y`DUVKAnQG=L@2Hg{;J}5k= z5H}g-4w!fwwB!VPfdL@+Z5R{q^ybCK_qq(+GSZ+fKpQ)DtakM1QB#{09-gPagt-o; zfEPH+DZ?cOfbdZ;CfKxTlZI(A!wHOcayXast>t+4=n{4!-3KsjU7{c-S-3EOkMNcC zqw#`!$dDmg{`~nPN?_0>rQLVmeHxz(;2_-EEIa=OM7#ylFkCpwDK|(A;G+zXeKkl6 zXt<#gR^nm??BkC=uFai0SJni(u*HaY9;QZ+UCzlCDGcBv-EH?3p;^VptwvisAu?t= zJonsl+L9$p?5dQ7|AxRvVEhE3B88*OQctF2H>i+=p7^x|eyyDi z2t$_4FO1)v0dtjFw{GowNGd+w^~P*63Y!Qs4Qzp0MFGaV^8WzLB=V<5{BmbrWX`9N?*;p35qjV`xz4(=Tw lqV2GAAO{0E7|84x_&>J}BS+GpbAkW>002ovPDHLkV1g0lKX3p5 literal 0 HcmV?d00001 diff --git a/src/assets/logos/blend-logo.svg b/src/assets/logos/blend-logo.svg new file mode 100644 index 000000000..20fcf78b9 --- /dev/null +++ b/src/assets/logos/blend-logo.svg @@ -0,0 +1,10 @@ + + + diff --git a/src/components/screens/EarnScreen/components/EarnGlow.tsx b/src/components/screens/EarnScreen/components/EarnGlow.tsx new file mode 100644 index 000000000..7d0572bfb --- /dev/null +++ b/src/components/screens/EarnScreen/components/EarnGlow.tsx @@ -0,0 +1,95 @@ +import React from "react"; +import { View } from "react-native"; +import Svg, { Circle, Defs, RadialGradient, Stop } from "react-native-svg"; + +/** + * The Earn designs' ambient glow is a solid #11351E circle of radius 132 + * under an SVG `feGaussianBlur` of stdDeviation 100. Shipping that node as an + * asset renders as a hard-edged disc on device -- react-native-svg parses the + * filter but does not apply it -- so the blur is reproduced as the radial + * falloff it mathematically is. + * + * A disc convolved with a 2D Gaussian has a closed-form radial profile, + * `I(d) = 1/s^2 * integral(0..R) exp(-(d^2+p^2)/2s^2) * I0(d*p/s^2) * p dp`, + * normalised so an infinite plane is 1. The stops below are that profile + * sampled at R=132, s=100. Two consequences are easy to get wrong by eye and + * are why the numbers are computed rather than hand-tuned: + * + * - Peak opacity is 0.58, not 1. Because the blur radius is comparable to the + * circle's own (100 vs 132), even the center pixel loses most of its weight + * to the transparent surround; painting the center at full opacity would + * make the glow markedly heavier than the design. + * - The gradient's radius is 460, not the source node's 332. The falloff is + * still at ~1.2% opacity at 332, so stopping there leaves a faint but + * visible circular seam; 460 is where it reaches ~0.02%. + */ +const GLOW_COLOR = "#11351E"; +const GLOW_RADIUS = 460; +const GLOW_SIZE = GLOW_RADIUS * 2; + +/** The sampled profile: [gradient offset, opacity at that offset]. */ +const GLOW_STOPS: Array<[string, number]> = [ + ["0", 0.5816], + ["0.1", 0.5441], + ["0.2", 0.4445], + ["0.29", 0.3282], + ["0.36", 0.2384], + ["0.43", 0.1603], + ["0.5", 0.0995], + ["0.58", 0.052], + ["0.7", 0.0159], + ["0.85", 0.0025], + ["1", 0], +]; + +export interface EarnGlowProps { + /** + * Y coordinate of the glow's center, in points from the top of the screen, + * taken straight from the Figma frame (both frames are 402 wide, so the + * glow is always horizontally centered). + */ + centerY: number; + /** + * The source circle's own `fill-opacity`, which differs per frame (the + * intro's is 1, the picker's 0.7). Multiplies the whole profile. + */ + opacity?: number; +} + +/** + * Blurred green ambient glow shared by the Earn screens. + * + * `pointerEvents="none"` because the gradient's box is far larger than its + * visible falloff and would otherwise swallow taps on whatever it overlaps. + */ +export const EarnGlow: React.FC = ({ centerY, opacity = 1 }) => ( + + + + + {GLOW_STOPS.map(([offset, stopOpacity]) => ( + + ))} + + + + + +); + +export default EarnGlow; diff --git a/src/components/screens/EarnScreen/components/EarnIntroBottomSheet.tsx b/src/components/screens/EarnScreen/components/EarnIntroBottomSheet.tsx deleted file mode 100644 index 881dbcc43..000000000 --- a/src/components/screens/EarnScreen/components/EarnIntroBottomSheet.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import { BottomSheetModal } from "@gorhom/bottom-sheet"; -import { Button } from "components/sds/Button"; -import Icon from "components/sds/Icon"; -import { Text } from "components/sds/Typography"; -import useAppTranslation from "hooks/useAppTranslation"; -import useColors from "hooks/useColors"; -import React from "react"; -import { TouchableOpacity, View } from "react-native"; - -export interface EarnIntroBottomSheetProps { - bottomSheetModalRef?: React.RefObject; - /** - * Called (in addition to the sheet dismissing) by both the CTA and the - * close control. The caller wires this to `setHasSeenEarnIntro(true)` so - * the sheet never shows again on this install, regardless of which - * control the user pressed. - */ - onDismiss: () => void; -} - -/** - * One-time interstitial shown the first time the user opens the Earn token - * picker, presented as a bottom sheet over it rather than a route -- Task 13 - * removed `EARN_ROUTES`' dead intro entry precisely because a - * declared-but-unregistered route throws at runtime, and there is no - * navigation reason to add one back for a single first-run sheet. - * - * Pure content: mirrors `PoolDetailsBottomSheet`'s convention exactly (the - * caller wraps this in `components/BottomSheet` and owns the modal ref, - * passed through here only so the close control and the CTA can dismiss it - * themselves). - * - * Visual composition matches Figma node `9457:46686` ("First time", Earn - * section) exactly -- see the report for the full geometry-to-token mapping. - * The art region is a flat placeholder fill (no artwork asset exists on - * either platform yet, mirroring the extension's own placeholder -- - * `EarnIntro/styles.scss:11-18`). - */ -export const EarnIntroBottomSheet: React.FC = ({ - bottomSheetModalRef, - onDismiss, -}) => { - const { t } = useAppTranslation(); - const { themeColors } = useColors(); - - // Shared by the close control and the CTA: per product decision, both - // dismiss the sheet AND mark it seen -- the user should not see this - // again on this install either way. - const handleDismiss = () => { - bottomSheetModalRef?.current?.dismiss(); - onDismiss(); - }; - - return ( - - {/* Full-bleed art region. The `-mt-6 -mx-6` cancels the wrapping - BottomSheetView's own 24px top/left/right padding (see - `components/BottomSheet`'s `fixedContent`) so this reaches the - sheet's actual edges, matching the Figma frame's full-bleed 360x248 - "image 1" placed at the very top of the sheet. - `rounded-t-3xl` MUST match `components/BottomSheet.tsx`'s - `renderHandle` (line ~163) exactly: that handle is a thin band, too - short to render its own corner radius's full arc on its own -- it - reads as rounded today only because everything below it shares the - same flat background color, so the curve is invisible rather than - absent. Once a differently-colored block (this one) sits flush - against the top, it needs the identical radius itself to continue - that same curve instead of squaring it off. A hardcoded px value - here would silently drift from the handle's if that class ever - changes, so this stays a literal copy of that same utility class. - Flat grey fill -- no artwork asset exists yet on either platform - (the extension's own EarnIntro uses the same kind of placeholder, - see EarnIntro/styles.scss). `themeColors.gray[9]` is the closest - existing token to the render's flat fill (#697177 vs. gray-9's - #707070 -- a few points off per channel, well within "nearest - token" territory; no gray step lands any closer). */} - - {/* Dark circular close control floating on the art, 24px from the - top and right edges of the art region -- not the app's usual - header-style close. `gray[6]` is the nearest token to the - render's circle fill (#313334 vs. gray-6's #343434); the X glyph - itself samples as pure white, i.e. `base[1]`. */} - - - - - - - - {t("earnIntro.title")} - - - - - - {t("earnIntro.body")} - - - - - {/* `tertiary` is the closest existing Button variant to the render's - light-pill/dark-text CTA (bg #fcfcfc / text #171717 vs. the - render's #ededed / #161616 -- same light-on-dark pairing, off by - a shade because Button's variant colors are hand-ported fixed - values rather than theme tokens). Already the established choice - for this exact role elsewhere (MaintenanceBannerBottomSheet, - ConnectedAppsBottomSheet). */} - - - - ); -}; - -export default EarnIntroBottomSheet; diff --git a/src/components/screens/EarnScreen/components/EarnReviewBottomSheet.tsx b/src/components/screens/EarnScreen/components/EarnReviewBottomSheet.tsx index 6b8dc6f19..32850637b 100644 --- a/src/components/screens/EarnScreen/components/EarnReviewBottomSheet.tsx +++ b/src/components/screens/EarnScreen/components/EarnReviewBottomSheet.tsx @@ -1,5 +1,7 @@ import { BottomSheetModal } from "@gorhom/bottom-sheet"; +import blendIcon from "assets/logos/blend-icon.png"; import BigNumber from "bignumber.js"; +import BottomSheet from "components/BottomSheet"; import { List } from "components/List"; import { TokenIcon } from "components/TokenIcon"; import { @@ -8,15 +10,19 @@ import { projectCurrentEarnings, projectEarnings, } from "components/screens/EarnScreen/helpers"; +import SignTransactionDetailsBottomSheet from "components/screens/SignTransactionDetails/components/SignTransactionDetailsBottomSheet"; +import { useSignTransactionDetails } from "components/screens/SignTransactionDetails/hooks/useSignTransactionDetails"; import { Banner } from "components/sds/Banner"; import { Button } from "components/sds/Button"; import Icon from "components/sds/Icon"; import { TextButton } from "components/sds/TextButton"; import { Text } from "components/sds/Typography"; +import { AnalyticsEvent } from "config/analyticsConfig"; import { mapNetworkToNetworkDetails } from "config/constants"; import { useAuthenticationStore } from "ducks/auth"; import { useBalancesStore } from "ducks/balances"; import { useEarnStore } from "ducks/earn"; +import { useTransactionBuilderStore } from "ducks/transactionBuilder"; import { getBalanceByContractId } from "helpers/balances"; import { pxValue } from "helpers/dimensions"; import { @@ -26,9 +32,8 @@ import { } from "helpers/formatAmount"; import useAppTranslation from "hooks/useAppTranslation"; import useColors from "hooks/useColors"; -import { useFeeDetailsBottomSheet } from "hooks/useFeeDetailsBottomSheet"; -import React, { useCallback, useMemo } from "react"; -import { TouchableOpacity, View } from "react-native"; +import React, { useCallback, useMemo, useRef } from "react"; +import { Image, TouchableOpacity, View } from "react-native"; import type { SecurityAssessment } from "services/blockaid/types"; export interface EarnReviewBottomSheetProps { @@ -89,13 +94,20 @@ export const EarnReviewBottomSheet: React.FC = ({ (state) => state.currentPositionTokens, ); - // Fee no longer renders on the face of the sheet (design `9448:29319`) -- - // it lives behind the "Transaction details" row below, which reuses this - // same hook's `openFeeDetails`/`feeDetailsSheets` (the Send/Swap review - // sheets' own fee-breakdown affordance). - const { openFeeDetails, feeDetailsSheets } = useFeeDetailsBottomSheet({ - isSorobanContext: true, + // "Transaction details" opens the FULL decoded transaction -- summary + // (fee, sequence, memo, raw XDR), auth entries, and operations -- exactly + // as the dapp signing review and the Send/Swap reviews do, via the shared + // `useSignTransactionDetails` + `SignTransactionDetailsBottomSheet` pair. + // + // It previously opened `useFeeDetailsBottomSheet`'s fee breakdown, which is + // a much narrower surface: the fee alone. The fee is still reachable here, + // as the summary's own `feeXlm` row, so nothing is lost by pointing the row + // at the full details instead. + const { transactionXDR } = useTransactionBuilderStore(); + const transactionDetails = useSignTransactionDetails({ + xdr: transactionXDR || "", }); + const transactionDetailsSheetRef = useRef(null); const networkDetails = useMemo( () => mapNetworkToNetworkDetails(network), @@ -251,14 +263,17 @@ export const EarnReviewBottomSheet: React.FC = ({ - {/* No pool-artwork asset exists yet — reuses `PoolCard`'s own - placeholder treatment for pool identity, for consistency - between the two surfaces (see that component's doc). */} - @@ -374,12 +389,11 @@ export const EarnReviewBottomSheet: React.FC = ({ - {/* "Transaction details" row (design `9448:29603`) — the fee's only - home now that it is off the face of the sheet. Reuses the same - `openFeeDetails` affordance Send/Swap already show inline next to - their fee row, just moved onto its own full-row tap target. */} + {/* "Transaction details" row (design `9448:29603`) — opens the full + decoded transaction, the same sheet the dapp signing review and the + Send/Swap reviews use. */} transactionDetailsSheetRef.current?.present()} className="mt-[16px] flex-row items-center gap-[8px] p-[16px] rounded-[16px] bg-background-tertiary" testID="earn-review-transaction-details" > @@ -457,7 +471,23 @@ export const EarnReviewBottomSheet: React.FC = ({ )} - {feeDetailsSheets} + {transactionDetails && ( + transactionDetailsSheetRef.current?.dismiss()} + enableDynamicSizing={false} + useInsetsBottomPadding={false} + enablePanDownToClose={false} + analyticsEvent={AnalyticsEvent.VIEW_EARN_TRANSACTION_DETAILS} + snapPoints={["90%"]} + customContent={ + transactionDetailsSheetRef.current?.dismiss()} + /> + } + /> + )} ); }; diff --git a/src/components/screens/EarnScreen/components/EarnScreenHeader.tsx b/src/components/screens/EarnScreen/components/EarnScreenHeader.tsx new file mode 100644 index 000000000..ebf7a4494 --- /dev/null +++ b/src/components/screens/EarnScreen/components/EarnScreenHeader.tsx @@ -0,0 +1,49 @@ +import Icon from "components/sds/Icon"; +import useAppTranslation from "hooks/useAppTranslation"; +import useColors from "hooks/useColors"; +import React from "react"; +import { TouchableOpacity, View } from "react-native"; + +/** + * Both redesigned Earn frames (Figma `13701:332561` and `13701:332629`) put a + * bare 24px X at the top left with nothing else in the header -- no title, no + * right-hand control. The second icon present in each node carries + * `opacity="0"`; it is an alignment spacer in the mock, not a control, so it + * is deliberately not rendered here. + * + * Both frames place the icon's box at y=70..94 over a 59pt safe area, hence + * the 11pt offset below the inset. On devices with a shallower inset the icon + * simply follows it down, keeping the same visual relationship to the notch. + * + * The band wraps the icon exactly (24pt tall) so callers can express their + * content's offset as the design's own icon-bottom-to-content distance + * without having to net out padding baked in here. + */ +export interface EarnScreenHeaderProps { + onClose: () => void; + testID?: string; +} + +export const EarnScreenHeader: React.FC = ({ + onClose, + testID = "earn-header-close", +}) => { + const { t } = useAppTranslation(); + const { themeColors } = useColors(); + + return ( + + + + + + ); +}; + +export default EarnScreenHeader; diff --git a/src/components/screens/EarnScreen/components/EarnTokenRow.tsx b/src/components/screens/EarnScreen/components/EarnTokenRow.tsx index 85da925f6..77f9984ee 100644 --- a/src/components/screens/EarnScreen/components/EarnTokenRow.tsx +++ b/src/components/screens/EarnScreen/components/EarnTokenRow.tsx @@ -76,7 +76,7 @@ export const EarnTokenRow: React.FC = ({ testID={testID} onPress={onPress} activeOpacity={0.7} - className="flex-row justify-between items-center px-4 py-3 mb-3 rounded-2xl bg-background-tertiary" + className="flex-row justify-between items-center px-4 py-3 rounded-2xl bg-background-tertiary" > @@ -106,12 +106,12 @@ export const EarnTokenRow: React.FC = ({ // match this teal-green, so this pill is built directly from the // `green` theme scale instead of `Badge`. // - // The trailing "*" ties the figure to the disclaimer footnote below - // the list. Appended here, not in `formatRate`, which is shared and - // unit-tested elsewhere. + // No trailing "*": the redesign's disclaimer ("APY may change based on + // protocol conditions.") is standalone prose, not a footnote keyed to + // a marker on each figure. - {`${formatRate(option.apy)}*`} + {formatRate(option.apy)} )} diff --git a/src/components/screens/EarnScreen/components/PoolCard.tsx b/src/components/screens/EarnScreen/components/PoolCard.tsx index 1ba8ab885..0c774d49f 100644 --- a/src/components/screens/EarnScreen/components/PoolCard.tsx +++ b/src/components/screens/EarnScreen/components/PoolCard.tsx @@ -1,3 +1,4 @@ +import blendIcon from "assets/logos/blend-icon.png"; import { formatRate } from "components/screens/EarnScreen/helpers"; import Icon from "components/sds/Icon"; import { Text } from "components/sds/Typography"; @@ -5,7 +6,7 @@ import { BlendCatalogPool } from "config/blendTypes"; import useAppTranslation from "hooks/useAppTranslation"; import useColors from "hooks/useColors"; import React from "react"; -import { TouchableOpacity, View } from "react-native"; +import { Image, TouchableOpacity, View } from "react-native"; export interface PoolCardProps { pool: BlendCatalogPool | null; @@ -18,32 +19,30 @@ export interface PoolCardProps { } /** - * Amount screen's pool-identity card (Figma node `9448:29157`): a green - * "Current APY" ribbon tucked above a pool row (identity + a chevron - * button), which is this screen's only entry into `PoolDetailsBottomSheet`. + * Amount screen's pool-identity card, redesigned per Figma `12607:42834` + * (nodes `12607:42851`/`42852` for the tab, `I12607:42855;4603:1211` for the + * row): a "Current APY" tab sitting flush on top of a pool row (identity + + * a chevron button), which is this screen's only entry into + * `PoolDetailsBottomSheet`. * - * The ribbon's fill/text are copied verbatim from `EarnTokenRow`'s APY pill - * (`bg-green-10` / `themeColors.green[4]`) rather than SDS `Badge`'s - * "success" variant, which resolves to lime, not this teal-green -- see that - * component's own comment for the pixel-sampling that confirmed it. When the - * asset has no fresh APY (`apy === null`), the ribbon is omitted entirely, - * mirroring `EarnTokenRow`'s same-shaped fallback rather than inventing an - * unspecified "no rate" ribbon state. + * Three things the redesign changed, all of which the previous pass had + * inferred from an older mock and got wrong: * - * No pool-artwork asset exists yet, so the identity icon reuses - * `PoolDetailsBottomSheet`'s own placeholder treatment (lilac `InfoCircle` - * in a themed square) for visual consistency between the two surfaces, - * rather than introducing a second, different placeholder. + * - The tab is a DARK green (`Success/Background/Secondary` #0c1f17, an exact + * match for `green[2]`) with `green/9` text -- not the solid `green[10]` + * fill with `green[4]` text that `EarnTokenRow`'s pill uses. The two are + * deliberately different treatments now. + * - Its shape is a top-rounded strip inset 16 on each side, not a full-width + * pill, and it sits FLUSH above the card rather than tucked under it by a + * negative margin -- the design lists them as non-overlapping siblings, and + * the inset alone produces the tab motif. + * - The identity icon is the real Blend mark. It is byte-identical to the + * token picker's badge glyph, so it reuses that same asset rather than the + * lilac `InfoCircle` placeholder that stood in while no artwork existed. * - * In the mock the ribbon visually tucks behind the card below it (a "tab" - * reading), while the raw geometry lists them as flush, non-overlapping - * siblings. This renders that "tab" reading with a small negative margin -- - * just enough to tuck the pill's rounded bottom corners under the card's - * rounded top ones -- rather than the literal flush layout, since the - * visual note is explicit about the overlap and a flush stack would lose - * the ribbon/tab motif entirely. The overlap is kept shallow (4px, well - * under the pill's own vertical text inset) so it never crops the "Current - * APY" text itself, unlike a literal half-height overlap would. + * When the asset has no fresh APY (`apy === null`), the tab is omitted + * entirely, mirroring `EarnTokenRow`'s same-shaped fallback rather than + * inventing an unspecified "no rate" state. */ export const PoolCard: React.FC = ({ pool, @@ -61,10 +60,15 @@ export const PoolCard: React.FC = ({ return ( {apy !== null && ( - - - {t("earnAmount.poolCard.currentApy", { rate: formatRate(apy) })} - + + + + {t("earnAmount.poolCard.currentApy", { rate: formatRate(apy) })} + + )} @@ -77,20 +81,30 @@ export const PoolCard: React.FC = ({ accessibilityLabel={t("earnAmount.poolCard.accessibilityLabel")} > - + {pool.name && ( {pool.name} )} - + {t("earnAmount.poolCard.byBlend")} - - + {/* 34x34 (10 padding around a 14 glyph) on the page background, so it + reads as a well punched into the card rather than a raised chip. */} + + diff --git a/src/components/screens/EarnScreen/components/PoolDetailsBottomSheet.tsx b/src/components/screens/EarnScreen/components/PoolDetailsBottomSheet.tsx index d8cfa23a8..f74d1679a 100644 --- a/src/components/screens/EarnScreen/components/PoolDetailsBottomSheet.tsx +++ b/src/components/screens/EarnScreen/components/PoolDetailsBottomSheet.tsx @@ -1,4 +1,5 @@ import { BottomSheetModal } from "@gorhom/bottom-sheet"; +import blendIcon from "assets/logos/blend-icon.png"; import { TokenIcon } from "components/TokenIcon"; import { formatCompactUsd, @@ -16,7 +17,7 @@ import { getNativeContractDetails } from "helpers/soroban"; import useAppTranslation from "hooks/useAppTranslation"; import useColors from "hooks/useColors"; import React from "react"; -import { TouchableOpacity, View } from "react-native"; +import { Image, TouchableOpacity, View } from "react-native"; export interface PoolDetailsBottomSheetProps { pool: BlendCatalogPool | null; @@ -243,12 +244,19 @@ export const PoolDetailsBottomSheet: React.FC = ({ - {/* No pool-artwork asset exists yet. Sized 28px, matching - `PoolCard`'s identical placeholder treatment on the amount - screen -- not this sheet's own 32px header-icon geometry - (`9448:18861`) -- to keep the two surfaces' shared "no artwork - yet" identity treatment visually consistent with each other. */} - + {/* The real Blend mark, shared with `PoolCard` and the token + picker's badge. This was a lilac `InfoCircle` placeholder while + no artwork existed, sized 28 to match `PoolCard`'s identical + stand-in; now that the asset exists each surface takes its own + designed size, so this follows this sheet's own header-icon + geometry (`9448:18861`) at 32 rather than the amount screen's + 40. */} + {pool.name && ( diff --git a/src/components/screens/EarnScreen/screens/EarnIntroScreen.tsx b/src/components/screens/EarnScreen/screens/EarnIntroScreen.tsx new file mode 100644 index 000000000..e60853663 --- /dev/null +++ b/src/components/screens/EarnScreen/screens/EarnIntroScreen.tsx @@ -0,0 +1,175 @@ +import BlendLogo from "assets/logos/blend-logo.svg"; +import { BaseLayout } from "components/layout/BaseLayout"; +import { EarnGlow } from "components/screens/EarnScreen/components/EarnGlow"; +import { EarnScreenHeader } from "components/screens/EarnScreen/components/EarnScreenHeader"; +import { Button } from "components/sds/Button"; +import Icon from "components/sds/Icon"; +import { Display, Text } from "components/sds/Typography"; +import { AnalyticsEvent, buildScreenViewedProps } from "config/analyticsConfig"; +import useAppTranslation from "hooks/useAppTranslation"; +import useColors from "hooks/useColors"; +import React, { useEffect } from "react"; +import { View } from "react-native"; +import { track } from "services/analytics/core"; + +/** + * Geometry from Figma `13701:332561` ("Blend intro (first time user)"), whose + * frame is 402x874 -- an iPhone 14/15 Pro, i.e. a real device canvas rather + * than the extension's 360x600 popup. Absolute offsets in that frame are + * therefore translated into the equivalent safe-area-relative flex layout + * instead of being reproduced literally: + * + * - `EarnScreenHeader` places the X box at the design's y=70..94. + * - Content starts at y=176, i.e. 82 below the icon's bottom edge. + * - The CTA's top is y=766 in an 874-tall frame, leaving 60 beneath it; + * safe-area bottom is 34, so 26 (~`pb-6`) of real padding. The gap above it + * is slack, which is why the CTA is pinned to the bottom with a flexible + * spacer rather than positioned at a fixed offset. + */ +const CONTENT_TOP_OFFSET = 82; + +/** Figma frame center of the glow: x=201 (402/2, i.e. screen center), y=180. */ +const GLOW_CENTER_Y = 180; + +export interface EarnIntroScreenProps { + /** + * Continue. The caller marks the intro seen and drops through to the token + * picker underneath -- see `EarnTokenPickerScreen`. + */ + onContinue: () => void; + /** + * The header X. Per the design this leaves Earn entirely rather than + * advancing to the picker, but it still marks the intro seen: a user who + * dismissed the pitch should not be shown it again on this install. + */ + onClose: () => void; +} + +/** + * First-run interstitial for the Earn (Blend) flow, shown once per install. + * + * Rendered INLINE by `EarnTokenPickerScreen` as a full-screen early return, + * not as a registered route -- the same pattern (and for the same reason) as + * `EarnProcessingScreen`: `EARN_ROUTES` carries no intro entry, and a + * declared-but-unregistered route throws at runtime. It renders its own bare-X + * header; the stack header is off for this route at the navigator level (both + * redesigned Earn frames drop it), so nothing needs toggling here. + * + * This replaced a bottom sheet: the design was reworked into a full screen + * (Figma `13701:332561`), so the sheet, its wrapper, and its `startEarning` + * copy are gone. + */ +export const EarnIntroScreen: React.FC = ({ + onContinue, + onClose, +}) => { + const { t } = useAppTranslation(); + const { themeColors } = useColors(); + + // Retargeted to `screen.viewed` manually because this is not a route, so + // the navigator's automatic route-name tracking never fires for it. The + // sheet this replaced got the same event from `BottomSheet`'s + // `analyticsEvent` prop; the direct `track()` call matches what the other + // non-route Earn screen (`EarnProcessingScreen`) already does. + useEffect(() => { + track( + AnalyticsEvent.SCREEN_VIEWED, + buildScreenViewedProps(AnalyticsEvent.VIEW_EARN_INTRO), + ); + }, []); + + const features = [ + { + key: "variableYield", + title: t("earnIntro.variableYield.title"), + body: t("earnIntro.variableYield.body"), + }, + { + key: "stayInControl", + title: t("earnIntro.stayInControl.title"), + body: t("earnIntro.stayInControl.body"), + }, + ]; + + return ( + + + + + + + + + + + {/* Title and subtitle are a single gapless stack in the design -- + their 40 and 20 line boxes already carry the spacing. */} + + {/* Display `sm` is 32/40, exactly Figma's Display/SM/500. The + -1.28 tracking has no SDS prop, so it comes through + `style`. */} + + {t("earnIntro.title")} + + + {t("earnIntro.subtitle")} + + + + + + {features.map((feature) => ( + + {/* 40 rounded-5 tile; the glyph inside is 26.67 in the + design, which rounds to 27. */} + + + + + + {feature.title} + + + {feature.body} + + + + ))} + + + + {/* Slack between the feature list and the pinned CTA. */} + + + + + + + + ); +}; + +export default EarnIntroScreen; diff --git a/src/components/screens/EarnScreen/screens/EarnProcessingScreen.tsx b/src/components/screens/EarnScreen/screens/EarnProcessingScreen.tsx index 306aebfbd..a68bd6612 100644 --- a/src/components/screens/EarnScreen/screens/EarnProcessingScreen.tsx +++ b/src/components/screens/EarnScreen/screens/EarnProcessingScreen.tsx @@ -1,4 +1,5 @@ import { useNavigation } from "@react-navigation/native"; +import blendIcon from "assets/logos/blend-icon.png"; import Spinner from "components/Spinner"; import { TokenIcon } from "components/TokenIcon"; import { BaseLayout } from "components/layout/BaseLayout"; @@ -25,7 +26,7 @@ import React, { useMemo, useRef, } from "react"; -import { View } from "react-native"; +import { Image, View } from "react-native"; import { track } from "services/analytics/core"; export interface EarnProcessingScreenProps { @@ -205,10 +206,13 @@ const EarnProcessingScreen: React.FC = ({ {/* Triptych (design node `9449:29733`/`9449:29814`): the deposit asset's icon, a secondary double-chevron connector, then - the pool's identity icon — no pool-artwork asset exists yet, so - this reuses `PoolCard`'s own lilac-square placeholder for - consistency across the flow, same reasoning as that - component's doc comment. */} + the pool's identity icon — the real Blend mark, shared with + `PoolCard`, the review sheet, the pool details sheet, and the + token picker's badge. Sized 40 to match the deposit asset's + `TokenIcon size="lg"` beside it; the lilac `InfoCircle` + placeholder it replaces was 28, so the two never matched. + Rendered for both the submitting and success states — this block + sits outside the status branch below. */} {depositBalance && ( @@ -218,11 +222,11 @@ const EarnProcessingScreen: React.FC = ({ size={16} color={themeColors.text.secondary} /> - diff --git a/src/components/screens/EarnScreen/screens/EarnTokenPickerScreen.tsx b/src/components/screens/EarnScreen/screens/EarnTokenPickerScreen.tsx index b232394c4..e29c35aa8 100644 --- a/src/components/screens/EarnScreen/screens/EarnTokenPickerScreen.tsx +++ b/src/components/screens/EarnScreen/screens/EarnTokenPickerScreen.tsx @@ -1,10 +1,12 @@ import { BottomSheetModal } from "@gorhom/bottom-sheet"; import { NavigationProp, useNavigation } from "@react-navigation/native"; import { NativeStackScreenProps } from "@react-navigation/native-stack"; +import blendIcon from "assets/logos/blend-icon.png"; import BottomSheet from "components/BottomSheet"; import Spinner from "components/Spinner"; import { BaseLayout } from "components/layout/BaseLayout"; -import { EarnIntroBottomSheet } from "components/screens/EarnScreen/components/EarnIntroBottomSheet"; +import { EarnGlow } from "components/screens/EarnScreen/components/EarnGlow"; +import { EarnScreenHeader } from "components/screens/EarnScreen/components/EarnScreenHeader"; import { EarnTokenRow } from "components/screens/EarnScreen/components/EarnTokenRow"; import { NotEnoughTokenBottomSheet } from "components/screens/EarnScreen/components/NotEnoughTokenBottomSheet"; import { ReceiveFundsBottomSheet } from "components/screens/EarnScreen/components/ReceiveFundsBottomSheet"; @@ -18,9 +20,12 @@ import { EarnTokenOption, useEarnTokens, } from "components/screens/EarnScreen/hooks/useEarnTokens"; +// Imported by module path rather than through `screens/index.ts`: this file is +// itself re-exported from that barrel, so going through it would close an +// import cycle. +import { EarnIntroScreen } from "components/screens/EarnScreen/screens/EarnIntroScreen"; import { Button } from "components/sds/Button"; import { Text } from "components/sds/Typography"; -import { AnalyticsEvent } from "config/analyticsConfig"; import { NATIVE_TOKEN_CODE, mapNetworkToNetworkDetails, @@ -39,30 +44,29 @@ import { useEarnStore } from "ducks/earn"; import { usePreferencesStore } from "ducks/preferences"; import { getTokenIdentifier } from "helpers/balances"; import useAppTranslation from "hooks/useAppTranslation"; -import React, { - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; -import { SectionList, View } from "react-native"; +import useColors from "hooks/useColors"; +import React, { useCallback, useMemo, useRef, useState } from "react"; +import { Image, ScrollView, View } from "react-native"; type EarnTokenPickerScreenProps = NativeStackScreenProps< EarnStackParamList, typeof EARN_ROUTES.EARN_TOKEN_PICKER_SCREEN >; -interface EarnTokenSection { - kind: "held" | "supported"; - title: string; - data: EarnTokenOption[]; -} +/** + * Figma `13701:332629` places content at y=118 and the X's box at y=70..94, + * i.e. 24 below the icon. The glow's circle is centered at (201, 725) with a + * 0.7 fill opacity, unlike the intro's full-opacity one. + */ +const CONTENT_TOP_OFFSET = 24; +const GLOW_CENTER_Y = 725; +const GLOW_OPACITY = 0.7; export const EarnTokenPickerScreen: React.FC = ({ navigation, }) => { const { t } = useAppTranslation(); + const { themeColors } = useColors(); const { isLoading, error, held, supported, refetch } = useEarnTokens(); const selectAsset = useEarnStore((state) => state.selectAsset); const { network } = useAuthenticationStore(); @@ -74,7 +78,6 @@ export const EarnTokenPickerScreen: React.FC = ({ (state) => state.setHasSeenEarnIntro, ); const notEnoughBottomSheetModalRef = useRef(null); - const earnIntroBottomSheetModalRef = useRef(null); const receiveFundsBottomSheetModalRef = useRef(null); const [notEnoughOption, setNotEnoughOption] = useState(null); @@ -198,41 +201,57 @@ export const EarnTokenPickerScreen: React.FC = ({ [selectAsset, navigation], ); - const handleDismissEarnIntro = useCallback(() => { + // Continue: mark the intro seen and fall through to the picker rendered + // below. `setHasSeenEarnIntro` writes synchronously, so the very next + // render drops the intro -- no navigation involved, since the intro is a + // full-screen early return from this same screen rather than its own route. + const handleContinueEarnIntro = useCallback(() => { setHasSeenEarnIntro(true); }, [setHasSeenEarnIntro]); - // Presents the first-run Earn intro sheet exactly once per mount, gated on - // the persisted flag (see `usePreferencesStore.hasSeenEarnIntro`) rather - // than a route: Task 13 removed the dead `EARN_ROUTES` intro entry because - // a declared-but-unregistered route throws at runtime, so this is a sheet - // over the token picker instead of a step in the stack. + // The intro's X leaves Earn entirely, matching what this screen's own + // header X does (`getScreenBottomNavigateOptions` -> `CustomHeaderButton` + // -> `navigation.goBack()`, which pops the whole stack from its initial + // route). It still marks the intro seen: a user who dismissed the pitch + // should not be shown it again on this install. + const handleCloseEarnIntro = useCallback(() => { + setHasSeenEarnIntro(true); + navigation.goBack(); + }, [setHasSeenEarnIntro, navigation]); + + // First-run interstitial, once per install (see + // `usePreferencesStore.hasSeenEarnIntro`). Rendered INLINE as a full-screen + // early return rather than as a route: `EARN_ROUTES` has no intro entry, + // and a declared-but-unregistered route throws at runtime. Same pattern as + // `EarnAmountScreen`'s inline `EarnProcessingScreen`. // - // `hasPresentedEarnIntroRef` (rather than depending on `isLoading` alone) - // guards against re-presenting after the sheet is dismissed: dismissing - // sets `hasSeenEarnIntro` true, which would otherwise be a legitimate - // dependency change that re-runs this effect on every subsequent - // isLoading/error toggle (e.g. a later retry) with a stale `false` still - // in a race. The ref makes "present at most once per mount" explicit - // regardless of how those other dependencies settle. - const hasPresentedEarnIntroRef = useRef(false); - useEffect(() => { - if ( - !isLoading && - !error && - !hasSeenEarnIntro && - !hasPresentedEarnIntroRef.current - ) { - hasPresentedEarnIntroRef.current = true; - earnIntroBottomSheetModalRef.current?.present(); - } - }, [isLoading, error, hasSeenEarnIntro]); + // Deliberately gated ahead of the `isLoading`/`error` branches below so it + // shows immediately on entry, with the token fetch resolving behind it -- + // the pitch does not depend on the token list, and gating it on the fetch + // would flash a spinner before the first thing a new user ever sees. + if (!hasSeenEarnIntro) { + return ( + + ); + } + // The loading and error branches carry the same bare-X header as the list + // below: the stack header is off for this route (see `EarnNavigator`), so + // without it these states would have no way out of the flow. if (isLoading) { return ( - - - + + + navigation.goBack()} /> + + + ); @@ -244,99 +263,156 @@ export const EarnTokenPickerScreen: React.FC = ({ // actively misleading about why the list is empty). if (error) { return ( - - - - {t("earnTokenPicker.error.title")} - - - - {t("earnTokenPicker.error.body")} - - - + + + navigation.goBack()} /> + + + {t("earnTokenPicker.error.title")} + + + + {t("earnTokenPicker.error.body")} + + + + ); } - const sections: EarnTokenSection[] = [ - { - kind: "held" as const, - title: t("earnTokenPicker.inYourAccount"), - data: held, - }, - { - kind: "supported" as const, - title: t("earnTokenPicker.supportedTokens"), - data: supported, - }, - ].filter((section) => section.data.length > 0); + // Figma `13701:332629` groups the list as: an "in your account" area + // (replaced by an empty-state block when the user holds none of the + // reserves), then "Supported tokens". The mock only draws the empty + // variant, so the held variant keeps this screen's existing section header + // and rows, restyled to the new spec. + const heldSection = held.length > 0; return ( <> - - {/* Figma node 8828:19263: the disclaimer sits at the bottom of the - screen (y=522 of a 600 canvas), well below where the list itself - ends (y=356) -- not immediately after the last row. Wrapping the - list in its own `flex-1` and keeping the disclaimer as a sibling - (rather than a `ListFooterComponent`, which would scroll with the - list and sit flush under the last row) pins it to the bottom of - the screen on any device while the list scrolls above it. */} - - - sections={sections} - keyExtractor={(item) => item.assetId} - showsVerticalScrollIndicator={false} - stickySectionHeadersEnabled={false} - renderSectionHeader={({ section }) => ( - - - {section.title} - + + {/* Design node `13701:332635`: same blurred circle as the intro but at + 70% fill opacity, centered low on the screen behind the disclaimer + rather than behind a logo. */} + + + + navigation.goBack()} /> + + + + {/* Title block: the Blend badge, then heading + subheading. + These moved OUT of the navigation header in this redesign -- + the header is now a bare X (see `EarnScreenHeader`). */} + + + {/* A different Blend mark from the intro's outlined jar -- + a filled glyph on a transparent ground -- so it ships as + its own asset rather than reusing `blend-logo.svg`. */} + + + {t("earnTokenPicker.protocolName")} + + + + + {/* Text/LG/500 (18/26) -- SDS `Text lg` is 18/26 exactly. */} + + {t("earnTokenPicker.heading")} + + + {t("earnTokenPicker.subheading")} + + - )} - renderItem={({ item, section }) => ( - - section.kind === "held" - ? handleHeldTokenPress(item) - : handleUnheldTokenPress(item) - } - /> - )} - /> - - - - {t("earnTokenPicker.apyDisclaimer")} - + + + {heldSection ? ( + + + {t("earnTokenPicker.inYourAccount")} + + + {held.map((option) => ( + handleHeldTokenPress(option)} + /> + ))} + + + ) : ( + // Design nodes `13701:332739`/`332774`: where the held + // section would be, prose explaining why it is absent. + + + {t("earnTokenPicker.noSupportedAssets.title")} + + + {t("earnTokenPicker.noSupportedAssets.body")} + + + )} + + {supported.length > 0 && ( + + + {t("earnTokenPicker.supportedTokens")} + + + {supported.map((option) => ( + handleUnheldTokenPress(option)} + /> + ))} + + + )} + + + + + {/* Node `13701:332636`: the disclaimer is pinned near the bottom of + the screen, well below where the list ends -- not flush under the + last row. Keeping it a sibling of the scroll area (rather than + inside it) holds it there on any device while the list scrolls. */} + + + {t("earnTokenPicker.apyDisclaimer")} + + - - } - /> = { flow: AnalyticsFlow.EARN, step: "confirm", }, + [AnalyticsEvent.VIEW_EARN_TRANSACTION_DETAILS]: { + flow: AnalyticsFlow.EARN, + }, [AnalyticsEvent.VIEW_EARN_PROCESSING]: { flow: AnalyticsFlow.EARN, step: "processing", diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index d71dea4c0..c498357b4 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -691,15 +691,28 @@ "swapped": "Swapped" }, "earnIntro": { - "title": "Make your tokens earn for you", - "body": "Deposit supported tokens into DeFi pools and start earning rewards.", - "startEarning": "Start earning" + "title": "Earn with Blend", + "subtitle": "Supply assets to Blend and earn variable yield.", + "variableYield": { + "title": "Earn variable yield", + "body": "Supply supported assets and earn based on current APY." + }, + "stayInControl": { + "title": "Stay in control", + "body": "Manage and withdraw your supplied assets from your wallet." + } }, "earnTokenPicker": { - "title": "Choose Token to earn", + "protocolName": "Blend", + "heading": "Choose an asset", + "subheading": "Supply assets to Blend and earn variable yield.", "inYourAccount": "In your account", "supportedTokens": "Supported tokens", - "apyDisclaimer": "*APY is an estimate based on current protocol conditions and changes with market activity. It is not guaranteed by Freighter, SDF, or the protocol.", + "noSupportedAssets": { + "title": "No supported assets in your wallet", + "body": "Add a supported asset to start earning." + }, + "apyDisclaimer": "APY may change based on protocol conditions.", "error": { "title": "We couldn't load Earn right now", "body": "Earn is temporarily unavailable. Please try again shortly.", @@ -723,7 +736,7 @@ "earnAmount": { "title": "Deposit", "depositLabel": "You deposit", - "enterAmount": "enter an amount", + "enterAmount": "Enter an amount", "insufficientFunds": "Insufficient funds", "review": "Review deposit", "poolCard": { diff --git a/src/i18n/locales/pt/translations.json b/src/i18n/locales/pt/translations.json index a2f9e93db..f0c0fb900 100644 --- a/src/i18n/locales/pt/translations.json +++ b/src/i18n/locales/pt/translations.json @@ -657,15 +657,28 @@ "swapped": "Trocado" }, "earnIntro": { - "title": "Faça seus tokens trabalharem para você", - "body": "Deposite tokens suportados em pools de DeFi e comece a ganhar recompensas.", - "startEarning": "Comece a ganhar" + "title": "Ganhe com a Blend", + "subtitle": "Forneça ativos à Blend e ganhe rendimento variável.", + "variableYield": { + "title": "Ganhe rendimento variável", + "body": "Forneça ativos suportados e ganhe com base na APY atual." + }, + "stayInControl": { + "title": "Mantenha o controle", + "body": "Gerencie e retire seus ativos fornecidos direto da sua carteira." + } }, "earnTokenPicker": { - "title": "Escolha o token para ganhar", + "protocolName": "Blend", + "heading": "Escolha um ativo", + "subheading": "Forneça ativos à Blend e ganhe rendimento variável.", "inYourAccount": "Na sua conta", "supportedTokens": "Tokens suportados", - "apyDisclaimer": "*A APY é uma estimativa baseada nas condições atuais do protocolo e muda de acordo com a atividade do mercado. Não é garantida pela Freighter, pela SDF ou pelo protocolo.", + "noSupportedAssets": { + "title": "Nenhum ativo suportado na sua carteira", + "body": "Adicione um ativo suportado para começar a ganhar." + }, + "apyDisclaimer": "A APY pode mudar conforme as condições do protocolo.", "error": { "title": "Não foi possível carregar o Earn agora", "body": "O Earn está temporariamente indisponível. Tente novamente em breve.", @@ -689,7 +702,7 @@ "earnAmount": { "title": "Depositar", "depositLabel": "Você deposita", - "enterAmount": "insira um valor", + "enterAmount": "Insira um valor", "insufficientFunds": "Saldo insuficiente", "review": "Revisar depósito", "poolCard": { diff --git a/src/navigators/EarnNavigator.tsx b/src/navigators/EarnNavigator.tsx index d351d9be3..6f0b4894f 100644 --- a/src/navigators/EarnNavigator.tsx +++ b/src/navigators/EarnNavigator.tsx @@ -10,8 +10,8 @@ import { useEarnStore } from "ducks/earn"; import { useTransactionBuilderStore } from "ducks/transactionBuilder"; import { useTransactionSettingsStore } from "ducks/transactionSettings"; import { - getScreenBottomNavigateOptions, getScreenOptionsWithCustomHeader, + getStackBottomNavigateOptions, } from "helpers/navigationOptions"; import useAppTranslation from "hooks/useAppTranslation"; import { clearNetworkFeesCache, useNetworkFees } from "hooks/useNetworkFees"; @@ -65,7 +65,13 @@ export const EarnStackNavigator = () => { Date: Mon, 24 Aug 2026 16:16:44 -0600 Subject: [PATCH 23/27] feat(earn): add swap-within-Earn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Swap for {CODE}" was an inert stub. It now opens a swap sheet stacked over the token picker, with the destination locked to the reserve the user was trying to deposit into, and runs through to submission — source picker, review, and processing included. Presented as sheets over the still-visible picker (Figma 13722:341980 composites it on a dimmed picker) rather than routing into SWAP_STACK, which would eject the user from Earn and leave them to find their way back to the reserve they wanted. Everything reuses Swap's own hooks — useSwapBalances, useSwapAmountError, useSwapPathFinding, useSwapCtaState, useSwapSecurityAssessments, useSwapTransaction, useSwapFooter — so quoting, security scanning, and submission behave identically. Only two things are Earn-specific: the destination is fixed, and the source defaults to the account's most valuable swappable holding so the sheet opens ready to type. Earn addresses reserves by SAC contract id but swapping is classic-only — pathPaymentStrictSend rejects contract-id assets, and a SAC address is a hash that cannot be decomposed back into code + issuer. The catalog's `name` field already carries the canonical "CODE:ISSUER"; useEarnTokens was discarding it. It now threads through as `canonicalId`, and buildEarnSwapDestination parses it, returning null when a reserve has no classic form rather than falling back to `assetId` and building a descriptor the path finder fails on silently. Two additive changes to shared Swap code, both defaulting to current behavior: useSwapTransaction gains `onProcessingClose` (its hard reset onto the History tab strands an Earn user mid-flow) and makes `navigation` optional, since Earn drives it from a sheet rather than a route. AmountCard gains `isLocked`, which renders the picker chip as a plain View — hiding only the chevron would leave an invisible tap target that still fired onPress. Also fixes a pre-existing defect this surfaced: useEarnTokens' fetch depended on `pricedBalances`, which the balances store repolls every 30s, so the effect re-ran, flipped isLoading, and unmounted the screen — and every sheet mounted over it — on a 30-second cadence. The catalog is now fetched on account/network only and rows are derived from balances, which also moves a row between sections immediately after a deposit or swap. The screen's loading and error branches are additionally gated on having no rows, since they early-return past the sheet tree. Corrects the "not enough token" sheet: the design has two layouts, not one. Two actions stack a filled primary above an outlined secondary; three actions (13132:50277) put Buy and Swap side by side with an "or" rule and Receive as a bare text button. An earlier pass had generalized from the two-action frames and flattened all of them. XlmFeeShortfallBottomSheet gets the same header rework and 13722:341905's copy. --- .../NotEnoughTokenBottomSheet.test.tsx | 26 +- .../XlmFeeShortfallBottomSheet.test.tsx | 6 +- .../EarnScreen/earnSwapDestination.test.ts | 81 +++++ src/components/AmountCard.tsx | 28 +- .../components/EarnSwapBottomSheet.tsx | 189 +++++++++++ .../components/EarnSwapFromBottomSheet.tsx | 124 +++++++ .../components/NotEnoughTokenBottomSheet.tsx | 71 ++-- .../components/XlmFeeShortfallBottomSheet.tsx | 41 +-- .../EarnScreen/helpers/earnSwapDestination.ts | 70 ++++ .../screens/EarnScreen/helpers/index.ts | 1 + .../screens/EarnScreen/hooks/useEarnSwap.ts | 315 ++++++++++++++++++ .../screens/EarnScreen/hooks/useEarnTokens.ts | 67 +++- .../screens/EarnTokenPickerScreen.tsx | 284 +++++++++++++++- .../SwapScreen/hooks/useSwapTransaction.ts | 25 +- src/config/analyticsConfig.ts | 9 + src/i18n/locales/en/translations.json | 17 +- src/i18n/locales/pt/translations.json | 17 +- 17 files changed, 1266 insertions(+), 105 deletions(-) create mode 100644 __tests__/components/screens/EarnScreen/earnSwapDestination.test.ts create mode 100644 src/components/screens/EarnScreen/components/EarnSwapBottomSheet.tsx create mode 100644 src/components/screens/EarnScreen/components/EarnSwapFromBottomSheet.tsx create mode 100644 src/components/screens/EarnScreen/helpers/earnSwapDestination.ts create mode 100644 src/components/screens/EarnScreen/hooks/useEarnSwap.ts diff --git a/__tests__/components/screens/EarnScreen/components/NotEnoughTokenBottomSheet.test.tsx b/__tests__/components/screens/EarnScreen/components/NotEnoughTokenBottomSheet.test.tsx index cbc7d995d..aac18d71b 100644 --- a/__tests__/components/screens/EarnScreen/components/NotEnoughTokenBottomSheet.test.tsx +++ b/__tests__/components/screens/EarnScreen/components/NotEnoughTokenBottomSheet.test.tsx @@ -56,8 +56,8 @@ describe("NotEnoughTokenBottomSheet", () => { expect(onClose).toHaveBeenCalledTimes(1); }); - describe("BUY_SWAP_OR_TRANSFER (design node 9457:46345)", () => { - it("renders Buy/Swap side by side and 'Transfer from another account' as a link, each wired to its own handler", () => { + describe("BUY_SWAP_OR_TRANSFER (design node 13132:50277)", () => { + it("renders Buy/Swap side by side with Receive as a text button, each wired to its own handler", () => { const onBuy = jest.fn(); const onSwap = jest.fn(); const onReceive = jest.fn(); @@ -73,9 +73,13 @@ describe("NotEnoughTokenBottomSheet", () => { />, ); + // Design `13132:50277`: two filled pills, an "or" rule, then Receive + // as a bare text button -- NOT three stacked pills. Note "Buy USDC", + // not "Buy with Coinbase": the long label is the two-action variant's. expect(getByText("Buy USDC")).toBeTruthy(); expect(getByText("Swap for USDC")).toBeTruthy(); expect(getByText("or")).toBeTruthy(); + expect(getByText("Receive USDC")).toBeTruthy(); // Not a button in this variant -- there is no secondary/outlined // "Transfer" button, only the centred text link. expect(queryByTestId("not-enough-token-receive-button")).toBeNull(); @@ -90,8 +94,8 @@ describe("NotEnoughTokenBottomSheet", () => { }); }); - describe("BUY_OR_TRANSFER (design node 9457:46399)", () => { - it("renders 'Buy with Coinbase' (not 'Buy USDC') stacked above 'Transfer from another account'", () => { + describe("BUY_OR_TRANSFER (design node 13701:332804)", () => { + it("renders 'Buy with Coinbase' (not 'Buy USDC') stacked above 'Receive USDC'", () => { const onBuy = jest.fn(); const onReceive = jest.fn(); const { getByTestId, getByText, queryByText } = renderWithProviders( @@ -107,7 +111,7 @@ describe("NotEnoughTokenBottomSheet", () => { ); expect(getByText("Buy with Coinbase")).toBeTruthy(); - expect(getByText("Transfer from another account")).toBeTruthy(); + expect(getByText("Receive USDC")).toBeTruthy(); expect(queryByText("Buy USDC")).toBeNull(); fireEvent.press(getByTestId("not-enough-token-buy-button")); @@ -118,8 +122,8 @@ describe("NotEnoughTokenBottomSheet", () => { }); }); - describe("SWAP_OR_TRANSFER (design node 9457:42846)", () => { - it("renders 'Swap for {code}' stacked above 'Transfer from another account'", () => { + describe("SWAP_OR_TRANSFER (design node 13717:333036)", () => { + it("renders 'Swap for {code}' stacked above 'Receive {code}'", () => { const onSwap = jest.fn(); const onReceive = jest.fn(); const { getByTestId, getByText } = renderWithProviders( @@ -135,7 +139,7 @@ describe("NotEnoughTokenBottomSheet", () => { ); expect(getByText("Swap for EURC")).toBeTruthy(); - expect(getByText("Transfer from another account")).toBeTruthy(); + expect(getByText("Receive EURC")).toBeTruthy(); fireEvent.press(getByTestId("not-enough-token-swap-button")); fireEvent.press(getByTestId("not-enough-token-receive-button")); @@ -145,8 +149,8 @@ describe("NotEnoughTokenBottomSheet", () => { }); }); - describe("TRANSFER_ONLY (design node 9457:46530)", () => { - it("renders a single 'Transfer from another account' button", () => { + describe("TRANSFER_ONLY (undesigned; Receive as the sole action)", () => { + it("renders a single 'Receive {code}' button", () => { const onReceive = jest.fn(); const { getByTestId, getByText, queryByTestId } = renderWithProviders( { />, ); - expect(getByText("Transfer from another account")).toBeTruthy(); + expect(getByText("Receive EURC")).toBeTruthy(); expect(queryByTestId("not-enough-token-buy-button")).toBeNull(); expect(queryByTestId("not-enough-token-swap-button")).toBeNull(); diff --git a/__tests__/components/screens/EarnScreen/components/XlmFeeShortfallBottomSheet.test.tsx b/__tests__/components/screens/EarnScreen/components/XlmFeeShortfallBottomSheet.test.tsx index 602153391..87eb40463 100644 --- a/__tests__/components/screens/EarnScreen/components/XlmFeeShortfallBottomSheet.test.tsx +++ b/__tests__/components/screens/EarnScreen/components/XlmFeeShortfallBottomSheet.test.tsx @@ -14,15 +14,15 @@ const makeRef = (dismissMock: jest.Mock) => { }; describe("XlmFeeShortfallBottomSheet", () => { - it("renders the design's copy (node 9457:45927): title, one-line body, and both action labels", () => { + it("renders the design's copy (node 13722:341905): title, one-line body, and both action labels", () => { const { getByText } = renderWithProviders( , ); - expect(getByText("You need some XLM for network fee")).toBeTruthy(); + expect(getByText("XLM needed for network fees")).toBeTruthy(); expect(getByText("Add XLM to your wallet to continue")).toBeTruthy(); expect(getByText("Buy with Coinbase")).toBeTruthy(); - expect(getByText("Transfer from another account")).toBeTruthy(); + expect(getByText("Receive XLM")).toBeTruthy(); }); it("calls onBuy when 'Buy with Coinbase' is pressed", () => { diff --git a/__tests__/components/screens/EarnScreen/earnSwapDestination.test.ts b/__tests__/components/screens/EarnScreen/earnSwapDestination.test.ts new file mode 100644 index 000000000..9550ccef6 --- /dev/null +++ b/__tests__/components/screens/EarnScreen/earnSwapDestination.test.ts @@ -0,0 +1,81 @@ +import { buildEarnSwapDestination } from "components/screens/EarnScreen/helpers"; +import { EarnTokenOption } from "components/screens/EarnScreen/hooks/useEarnTokens"; + +const makeOption = (over: Partial = {}): EarnTokenOption => ({ + assetId: "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75", + code: "USDC", + decimals: 7, + total: "0", + apy: 0.0651, + poolId: "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD", + canonicalId: "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", + isNative: false, + balance: undefined, + ...over, +}); + +describe("buildEarnSwapDestination", () => { + it("splits the catalog's canonical id into code and issuer", () => { + const result = buildEarnSwapDestination(makeOption()); + + expect(result).toEqual({ + id: "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", + tokenCode: "USDC", + issuer: "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", + decimals: 7, + tokenType: "credit_alphanum4", + requiresTrustline: true, + }); + }); + + it("describes native XLM without an issuer or a trustline", () => { + const result = buildEarnSwapDestination( + makeOption({ code: "XLM", isNative: true, canonicalId: null }), + ); + + expect(result).toMatchObject({ + id: "XLM", + tokenCode: "XLM", + tokenType: "native", + requiresTrustline: false, + }); + expect(result).not.toHaveProperty("issuer"); + }); + + it("classifies codes longer than 4 characters as alphanum12", () => { + expect( + buildEarnSwapDestination( + makeOption({ code: "LONGCODE", canonicalId: "LONGCODE:GISSUER" }), + ), + ).toMatchObject({ tokenType: "credit_alphanum12" }); + }); + + it("reports no trustline requirement when the account already holds some", () => { + expect( + buildEarnSwapDestination( + makeOption({ balance: { total: "0.0000001" } as never }), + ), + ).toMatchObject({ requiresTrustline: false }); + }); + + // A SAC address is a hash: it cannot be decomposed back into code + issuer, + // so a non-native reserve the catalog left unnamed has no classic form. The + // caller must treat null as "no swap path" -- falling back to `assetId` + // would build a descriptor the path finder silently fails on. + it("returns null for a non-native reserve with no canonical id", () => { + expect( + buildEarnSwapDestination(makeOption({ canonicalId: null })), + ).toBeNull(); + }); + + it.each([ + ["no separator", "USDCGA5ZSEJY"], + ["empty code", ":GA5ZSEJY"], + ["empty issuer", "USDC:"], + ])( + "returns null for a malformed canonical id (%s)", + (_label, canonicalId) => { + expect(buildEarnSwapDestination(makeOption({ canonicalId }))).toBeNull(); + }, + ); +}); diff --git a/src/components/AmountCard.tsx b/src/components/AmountCard.tsx index 4d074a323..bd629b5e6 100644 --- a/src/components/AmountCard.tsx +++ b/src/components/AmountCard.tsx @@ -83,6 +83,13 @@ type AmountCardCommonProps = { * back to a 2-letter avatar until the trustline is added. */ pickerIconUrl?: string; onPickerPress: () => void; + /** Renders the picker chip as a static label -- no chevron, not tappable. + * For surfaces where the token is fixed rather than chosen: the Earn swap + * sheet's "You receive" side is locked to the asset being deposited into + * (design `13722:342108` draws that chip with no chevron, unlike the + * "You sell" chip beside it). Additive -- defaults to false, so every + * existing Send/Swap caller is unchanged. */ + isLocked?: boolean; pickerTestID?: string; testID?: string; /** Shown on the right of the label row. Hidden when null/undefined. */ @@ -176,8 +183,9 @@ const PickerChip: React.FC<{ securityLevel?: SecurityLevel; iconUrl?: string; onPress: () => void; + isLocked?: boolean; testID?: string; -}> = ({ token, label, securityLevel, iconUrl, onPress, testID }) => { +}> = ({ token, label, securityLevel, iconUrl, onPress, isLocked, testID }) => { const { themeColors } = useColors(); // PricedBalance / Balance carry `tokenCode`; bare `Token` carries `code`. // Fall back to whichever exists so non-held descriptors still get a label. @@ -187,9 +195,13 @@ const PickerChip: React.FC<{ } else if (token && "code" in token) { fallbackLabel = token.code; } + // A locked chip is a label, not a control, so it renders as a plain View: + // suppressing only the chevron would leave an invisible tap target that + // still fires `onPress`. + const Container = isLocked ? View : TouchableOpacity; return ( - @@ -218,8 +230,10 @@ const PickerChip: React.FC<{ {label ?? fallbackLabel ?? ""} - - + {!isLocked && ( + + )} + ); }; @@ -230,6 +244,7 @@ const EditableAmountCard: React.FC = ({ pickerSecurityLevel, pickerIconUrl, onPickerPress, + isLocked, pickerTestID, testID, availableBalanceText, @@ -392,6 +407,7 @@ const EditableAmountCard: React.FC = ({ securityLevel={pickerSecurityLevel} iconUrl={pickerIconUrl} onPress={onPickerPress} + isLocked={isLocked} testID={pickerTestID} /> @@ -432,6 +448,7 @@ const ReadOnlyAmountCard: React.FC = ({ pickerSecurityLevel, pickerIconUrl, onPickerPress, + isLocked, pickerTestID, testID, availableBalanceText, @@ -472,6 +489,7 @@ const ReadOnlyAmountCard: React.FC = ({ securityLevel={pickerSecurityLevel} iconUrl={pickerIconUrl} onPress={onPickerPress} + isLocked={isLocked} testID={pickerTestID} /> diff --git a/src/components/screens/EarnScreen/components/EarnSwapBottomSheet.tsx b/src/components/screens/EarnScreen/components/EarnSwapBottomSheet.tsx new file mode 100644 index 000000000..9b99ae33e --- /dev/null +++ b/src/components/screens/EarnScreen/components/EarnSwapBottomSheet.tsx @@ -0,0 +1,189 @@ +import { AmountCard } from "components/AmountCard"; +import { PercentageButtons } from "components/PercentageButtons"; +import { Button } from "components/sds/Button"; +import Icon from "components/sds/Icon"; +import { Text } from "components/sds/Typography"; +import { PricedBalance, Token } from "config/types"; +import useAppTranslation from "hooks/useAppTranslation"; +import useColors from "hooks/useColors"; +import { UseTokenFiatConverterResult } from "hooks/useTokenFiatConverter"; +import React from "react"; +import { TouchableOpacity, View } from "react-native"; + +export interface EarnSwapBottomSheetProps { + /** The held balance being sold. Undefined until a source is chosen. */ + sourceBalance?: PricedBalance; + /** Label for the sell chip -- the source token's code. */ + sourceLabel?: string; + /** "1,691.69 XLM available", or null to hide the line. */ + availableBalanceText?: string | null; + /** Opens the source picker (design `13723:343723`, "swap From"). */ + onSourcePickerPress: () => void; + /** Drives the sell card's input + fiat toggle. */ + converter: UseTokenFiatConverterResult; + /** Whether the source has a usable USD price; hides the fiat line if not. */ + hasUsdPrice: boolean; + /** Small text under the sell amount (the fiat/token counterpart). */ + sourceSecondaryText?: string; + + /** + * The asset being deposited into the pool. Fixed, never chosen here -- + * that is the whole point of entering swap from Earn. + */ + destinationToken: Token; + destinationLabel: string; + /** Quoted receive amount, already formatted. */ + destinationAmount: string; + /** Fiat counterpart of the quote, already formatted. */ + destinationSecondaryText?: string; + + onPercentagePress: (percentage: number) => void; + onSettingsPress: () => void; + onClose: () => void; + /** Null disables the CTA; the label still renders (e.g. "Enter an amount"). */ + onReview: (() => void) | null; + ctaLabel: string; + isReviewLoading?: boolean; +} + +/** + * Swap-within-Earn, entered from `NotEnoughTokenBottomSheet`'s "Swap for + * {CODE}" when the user lacks the asset a pool reserve needs. + * + * Presented as a sheet OVER the still-visible token picker (design + * `13722:341980` composites it on a dimmed picker), rather than navigating + * into `SWAP_STACK` -- routing away would eject the user from Earn, and they + * would have to find their way back to the reserve they were trying to + * deposit into. + * + * Two deliberate differences from `SwapAmountScreen`, both from the design: + * + * - **The receive chip is locked** (`13722:342108` draws it with no chevron, + * unlike the sell chip beside it). The destination is the pool asset, so + * there is nothing to choose -- see `AmountCard`'s `isLocked`. + * - **The control between the cards is decorative.** `SwapAmountScreen` puts + * a direction toggle there; here the exported glyph is a plain + * `chevron-down` (`13722:342112`), and a direction swap would break the + * locked-destination invariant anyway, so it renders as a static divider. + */ +export const EarnSwapBottomSheet: React.FC = ({ + sourceBalance, + sourceLabel, + availableBalanceText, + onSourcePickerPress, + converter, + hasUsdPrice, + sourceSecondaryText, + destinationToken, + destinationLabel, + destinationAmount, + destinationSecondaryText, + onPercentagePress, + onSettingsPress, + onClose, + onReview, + ctaLabel, + isReviewLoading, +}) => { + const { t } = useAppTranslation(); + const { themeColors } = useColors(); + + return ( + + {/* Header `13722:342081`: settings at the left, centred title, and a + 40px circled X at the right that the sheet owns itself. */} + + + + + + + + {t("swapScreen.title")} + + + + + + + + + + + + + {}} + pickerTestID="earn-swap-receive-pill" + primaryAmount={destinationAmount} + secondaryAmount={destinationSecondaryText} + /> + + {/* Static divider `13722:342111`, overlapping both cards' edges. + `pointerEvents="none"` so it never steals a tap from the card + beneath it. */} + + + + + + + + + + + + + ); +}; + +export default EarnSwapBottomSheet; diff --git a/src/components/screens/EarnScreen/components/EarnSwapFromBottomSheet.tsx b/src/components/screens/EarnScreen/components/EarnSwapFromBottomSheet.tsx new file mode 100644 index 000000000..90e8f26ae --- /dev/null +++ b/src/components/screens/EarnScreen/components/EarnSwapFromBottomSheet.tsx @@ -0,0 +1,124 @@ +import { SwapTokenRow } from "components/screens/SwapScreen/components/SwapTokenRow"; +import Icon from "components/sds/Icon"; +import { Input } from "components/sds/Input"; +import { Text } from "components/sds/Typography"; +import { NETWORKS } from "config/constants"; +import useAppTranslation from "hooks/useAppTranslation"; +import { HeldBalanceItem } from "hooks/useBalancesList"; +import useColors from "hooks/useColors"; +import React, { useMemo, useState } from "react"; +import { ScrollView, TouchableOpacity, View } from "react-native"; + +export interface EarnSwapFromBottomSheetProps { + /** Everything the account can sell into the pool asset. */ + balances: HeldBalanceItem[]; + network: NETWORKS; + onSelect: (balance: HeldBalanceItem) => void; + /** Returns to the swap sheet without changing the source. */ + onBack: () => void; + /** Leaves the swap branch entirely. */ + onClose: () => void; +} + +/** + * Source picker for swap-within-Earn (design `13723:343723`, "Swap from"). + * + * Unlike Swap's own destination picker, this lists ONLY what the account + * already holds -- you cannot sell a token you do not have -- so it reuses + * `SwapTokenRow`'s "held" variant and needs no search-by-address lookup. The + * design's Paste button is therefore omitted: pasting a contract address is + * meaningless against a list restricted to your own balances. The search + * field is kept and filters that list by code or name. + * + * The header carries a back arrow as well as a close: back returns to the + * swap sheet with the source unchanged, close leaves the branch. Both are in + * the design, and they are not interchangeable. + */ +export const EarnSwapFromBottomSheet: React.FC< + EarnSwapFromBottomSheetProps +> = ({ balances, network, onSelect, onBack, onClose }) => { + const { t } = useAppTranslation(); + const { themeColors } = useColors(); + const [query, setQuery] = useState(""); + + const filtered = useMemo(() => { + const needle = query.trim().toLowerCase(); + if (!needle) { + return balances; + } + return balances.filter((item) => { + const code = (item.tokenCode ?? "").toLowerCase(); + const name = (item.displayName ?? "").toLowerCase(); + return code.includes(needle) || name.includes(needle); + }); + }, [balances, query]); + + return ( + + + + + + + + + {t("earnSwapFrom.title")} + + + + + + + + + + } + testID="earn-swap-from-search" + /> + + + + {t("earnSwapFrom.yourTokens")} + + + {filtered.length === 0 ? ( + + {t("earnSwapFrom.noResults")} + + ) : ( + + {filtered.map((item) => ( + onSelect(item)} + testID={`earn-swap-from-${item.tokenCode}`} + /> + ))} + + )} + + + ); +}; + +export default EarnSwapFromBottomSheet; diff --git a/src/components/screens/EarnScreen/components/NotEnoughTokenBottomSheet.tsx b/src/components/screens/EarnScreen/components/NotEnoughTokenBottomSheet.tsx index 043871b17..79e5bc2f4 100644 --- a/src/components/screens/EarnScreen/components/NotEnoughTokenBottomSheet.tsx +++ b/src/components/screens/EarnScreen/components/NotEnoughTokenBottomSheet.tsx @@ -85,10 +85,20 @@ export const NotEnoughTokenBottomSheet: React.FC< const renderActions = () => { switch (variant) { + // THREE-action layout, from design `13132:50277` (inside frame + // `12615:43940`): two filled pills side by side, an "or" rule, then + // Receive as a bare TEXT button -- not a third stacked pill. + // + // The two-action frames (`13701:332804`, `13717:333036`) stack a filled + // primary above an outlined secondary; that shape does NOT extend to + // three actions, which is what the design uses this layout for. case NotEnoughVariant.BUY_SWAP_OR_TRANSFER: return ( - - + + ); @@ -176,7 +187,7 @@ export const NotEnoughTokenBottomSheet: React.FC< onPress={onReceive} testID="not-enough-token-receive-button" > - {t("earnNotEnough.transfer")} + {t("earnNotEnough.receive", { tokenCode })} ); @@ -190,7 +201,7 @@ export const NotEnoughTokenBottomSheet: React.FC< onPress={onReceive} testID="not-enough-token-receive-button" > - {t("earnNotEnough.transfer")} + {t("earnNotEnough.receive", { tokenCode })} ); } @@ -198,13 +209,19 @@ export const NotEnoughTokenBottomSheet: React.FC< return ( - - - + {/* Design `13717:333143`: the asset icon and title share a left-hand + column (gap 12), with the close control opposite at the TOP rather + than vertically centred on a full-width icon row. Title is 20/28 + (`Text xl`), previously 18/26. The 24 gaps below come from the + modal's own `13717:333139`/`333141` stacks; they were 12 and 16. */} + + + + + {t("earnNotEnough.title", { tokenCode })} + + + - - {t("earnNotEnough.title", { tokenCode })} - - - + {t(bodyKey, { tokenCode })} - {renderActions()} + {renderActions()} ); }; diff --git a/src/components/screens/EarnScreen/components/XlmFeeShortfallBottomSheet.tsx b/src/components/screens/EarnScreen/components/XlmFeeShortfallBottomSheet.tsx index 193d4d266..c87429365 100644 --- a/src/components/screens/EarnScreen/components/XlmFeeShortfallBottomSheet.tsx +++ b/src/components/screens/EarnScreen/components/XlmFeeShortfallBottomSheet.tsx @@ -28,13 +28,13 @@ export interface XlmFeeShortfallBottomSheetProps { * feeShortfall`/`insufficientBalanceForFee`) and is untouched by this * component. * - * Previously the generic `InformationBottomSheet` with a single "Buy XLM" - * action; this is a dedicated sheet matching design node `9457:45927` - * exactly: the XLM asset icon (not an amber warning glyph), and **two** - * stacked full-width actions -- "Buy with Coinbase" (light `tertiary` pill) - * above "Transfer from another account" (outlined `secondary` pill) -- the - * same two-action stack shape as `NotEnoughTokenBottomSheet`'s - * `BUY_OR_TRANSFER` variant. + * Matches design node `13722:341905`: the XLM asset icon (not an amber + * warning glyph) and **two** stacked full-width actions -- "Buy with + * Coinbase" (light `tertiary` pill) above "Receive XLM" (outlined + * `secondary` pill). Structurally identical to + * `NotEnoughTokenBottomSheet`'s header and action stack, and redesigned in + * the same pass: icon and title share a left column (gap 12) with the close + * control opposite at the top, title at 20/28, and 24 gaps beneath. * * Pure content: mirrors `PoolDetailsBottomSheet`'s convention (the caller * wraps this in `components/BottomSheet` and owns the modal ref, passed @@ -52,13 +52,20 @@ export const XlmFeeShortfallBottomSheet: React.FC< return ( - - + + + + + {t("earnAmount.networkFeeSheet.title")} + + - - {t("earnAmount.networkFeeSheet.title")} - - - + {t("earnAmount.networkFeeSheet.body")} - + diff --git a/src/components/screens/EarnScreen/helpers/earnSwapDestination.ts b/src/components/screens/EarnScreen/helpers/earnSwapDestination.ts new file mode 100644 index 000000000..72c00dd45 --- /dev/null +++ b/src/components/screens/EarnScreen/helpers/earnSwapDestination.ts @@ -0,0 +1,70 @@ +import { EarnTokenOption } from "components/screens/EarnScreen/hooks/useEarnTokens"; +import { DestinationTokenDescriptor } from "components/screens/SwapScreen/helpers"; +import { NATIVE_TOKEN_CODE } from "config/constants"; +import { TokenTypeWithCustomToken } from "config/types"; + +/** + * Classic asset codes are alphanum4 up to 4 characters, alphanum12 beyond. + * Blend's current reserves are all <= 5 characters (XLM/USDC/EURC), but the + * catalog is operator-configured, so this is derived rather than assumed. + */ +const ALPHANUM4_MAX_CODE_LENGTH = 4; + +/** + * Build the swap destination for "Swap for {CODE}" from the Earn reserve the + * user was trying to deposit into. + * + * Earn addresses its reserves by Stellar Asset Contract id, but swapping is + * classic-only -- `pathPaymentStrictSend` rejects contract-id assets outright + * -- so the destination has to be expressed as code + issuer. A SAC address + * is a hash and cannot be decomposed back into one, which is why this reads + * `canonicalId` (the catalog's "CODE:ISSUER") instead of `assetId`. + * + * Returns null when the reserve cannot be expressed classically: native XLM + * is handled explicitly, but a non-native reserve with no `canonicalId` has + * no issuer to swap toward. Callers must treat null as "no swap path" rather + * than falling back to `assetId`, which would build a descriptor the path + * finder silently fails on. + */ +export const buildEarnSwapDestination = ( + option: EarnTokenOption, +): DestinationTokenDescriptor | null => { + if (option.isNative) { + return { + id: NATIVE_TOKEN_CODE, + tokenCode: NATIVE_TOKEN_CODE, + decimals: option.decimals, + tokenType: "native" as TokenTypeWithCustomToken, + // Native XLM never needs one, and the account demonstrably exists -- + // it is holding the balance that funded getting this far. + requiresTrustline: false, + }; + } + + const canonical = option.canonicalId; + if (!canonical) { + return null; + } + + const separatorIndex = canonical.indexOf(":"); + if (separatorIndex <= 0 || separatorIndex === canonical.length - 1) { + return null; + } + + const tokenCode = canonical.slice(0, separatorIndex); + const issuer = canonical.slice(separatorIndex + 1); + + return { + id: canonical, + tokenCode, + issuer, + decimals: option.decimals, + tokenType: (tokenCode.length > ALPHANUM4_MAX_CODE_LENGTH + ? "credit_alphanum12" + : "credit_alphanum4") as TokenTypeWithCustomToken, + // The sheet is only ever reached from a zero-balance reserve row, so the + // account holds none of it. `option.balance` stays authoritative anyway: + // a dust balance below the deposit minimum would still be a trustline. + requiresTrustline: !option.balance, + }; +}; diff --git a/src/components/screens/EarnScreen/helpers/index.ts b/src/components/screens/EarnScreen/helpers/index.ts index dc1a1f821..5c6ae04fe 100644 --- a/src/components/screens/EarnScreen/helpers/index.ts +++ b/src/components/screens/EarnScreen/helpers/index.ts @@ -1,5 +1,6 @@ export * from "components/screens/EarnScreen/helpers/depositAmount"; export * from "components/screens/EarnScreen/helpers/earnCtaState"; +export * from "components/screens/EarnScreen/helpers/earnSwapDestination"; export * from "components/screens/EarnScreen/helpers/formatPoolStats"; export * from "components/screens/EarnScreen/helpers/getNotEnoughVariant"; export * from "components/screens/EarnScreen/helpers/poolDescriptions"; diff --git a/src/components/screens/EarnScreen/hooks/useEarnSwap.ts b/src/components/screens/EarnScreen/hooks/useEarnSwap.ts new file mode 100644 index 000000000..7f5efe7d7 --- /dev/null +++ b/src/components/screens/EarnScreen/hooks/useEarnSwap.ts @@ -0,0 +1,315 @@ +import BigNumber from "bignumber.js"; +import { buildEarnSwapDestination } from "components/screens/EarnScreen/helpers"; +import { EarnTokenOption } from "components/screens/EarnScreen/hooks/useEarnTokens"; +import { + useSwapAmountError, + useSwapBalances, + useSwapCtaState, + useSwapPathFinding, + useSwapSecurityAssessments, +} from "components/screens/SwapScreen/hooks"; +import { useSwapTransaction } from "components/screens/SwapScreen/hooks/useSwapTransaction"; +import { TokenTypeWithCustomToken } from "config/types"; +import { useAuthenticationStore } from "ducks/auth"; +import { useDebugStore } from "ducks/debug"; +import { descriptorAsPathBalance, useSwapStore } from "ducks/swap"; +import { useSwapSettingsStore } from "ducks/swapSettings"; +import { useTransactionBuilderStore } from "ducks/transactionBuilder"; +import { calculateSpendableAmount } from "helpers/balances"; +import { HeldBalanceItem, useBalancesList } from "hooks/useBalancesList"; +import useGetActiveAccount from "hooks/useGetActiveAccount"; +import { useTokenFiatConverter } from "hooks/useTokenFiatConverter"; +import { useCallback, useEffect, useMemo } from "react"; + +/** + * Token types `pathPaymentStrictSend` can spend. Soroban/custom tokens and + * liquidity-pool shares are excluded: Swap is classic-only, so neither is a + * viable source however large the balance. + */ +const SWAPPABLE_SOURCE_TYPES = new Set([ + TokenTypeWithCustomToken.NATIVE, + TokenTypeWithCustomToken.CREDIT_ALPHANUM4, + TokenTypeWithCustomToken.CREDIT_ALPHANUM12, +]); + +export interface UseEarnSwapParams { + /** + * The reserve the user was trying to deposit into and does not hold. Null + * while the sheet is closed -- the hook then does no work and writes + * nothing to the swap store. + */ + option: EarnTokenOption | null; + /** + * Where to go when the swap's processing screen closes. Earn returns the + * user to the token picker holding their new balance, rather than Swap's + * default reset onto the History tab -- see `useSwapTransaction`. + */ + onProcessingClose: () => void; +} + +/** + * Owns swap-within-Earn's state: destination locked to the pool reserve, + * source chosen from what the account actually holds, and the amount / + * path-finding / CTA machinery reused wholesale from Swap. + * + * Everything here is Swap's own hooks, deliberately. The only Earn-specific + * parts are (a) the destination is fixed rather than picked, and (b) the + * source defaults to the account's best swappable balance so the sheet opens + * ready to type rather than on an empty picker. + */ +export const useEarnSwap = ({ + option, + onProcessingClose, +}: UseEarnSwapParams) => { + const { account } = useGetActiveAccount(); + const { network } = useAuthenticationStore(); + const { swapFee, swapSlippage } = useSwapSettingsStore(); + const { isBuilding, transactionXDR } = useTransactionBuilderStore(); + const publicKey = account?.publicKey ?? ""; + + const { balanceItems, scanResults } = useBalancesList({ publicKey, network }); + const { overriddenBlockaidResponse } = useDebugStore(); + + const { + sourceTokenId, + sourceTokenSymbol, + sourceAmount, + destinationAmount, + destinationToken: destinationTokenDescriptor, + pathResult, + isLoadingPath, + pathError, + setSourceToken, + setDestinationToken, + setSourceAmount, + setSourceAmountDisplay, + } = useSwapStore(); + + // Rebuilt from the catalog's canonical "CODE:ISSUER" because Earn addresses + // reserves by SAC contract id and swapping is classic-only. Null means the + // reserve has no classic form, i.e. there is no swap path to offer at all. + const destination = useMemo( + () => (option ? buildEarnSwapDestination(option) : null), + [option], + ); + + /** + * Everything the account could sell into the destination. Excludes the + * destination itself (swapping USDC into USDC is not a thing) and anything + * Swap cannot spend -- contract-only balances and liquidity-pool shares are + * not classic-path sources. Also backs the "Swap from" picker. + */ + const swappableBalances = useMemo( + () => + balanceItems.filter((item) => { + if (item.id === destination?.id) { + return false; + } + if (!new BigNumber(item.total ?? 0).gt(0)) { + return false; + } + // `tokenType` is the discriminant `HeldBalanceItem` actually carries. + return SWAPPABLE_SOURCE_TYPES.has(item.tokenType); + }), + [balanceItems, destination], + ); + + /** The opening source: whichever swappable holding is worth the most. */ + const defaultSourceBalance = useMemo( + () => + swappableBalances.reduce((best, item) => { + const value = item.fiatTotal ?? new BigNumber(0); + const bestValue = best?.fiatTotal ?? new BigNumber(0); + return value.gt(bestValue) ? item : best; + }, undefined), + [swappableBalances], + ); + + // Seed the swap store when the sheet opens. Guarded on the destination + // actually changing so re-renders (and the user then picking a different + // source) do not stomp the selection back to the default. + useEffect(() => { + if (!destination) { + return; + } + setDestinationToken(destination); + }, [destination, setDestinationToken]); + + useEffect(() => { + if (!destination || sourceTokenId || !defaultSourceBalance) { + return; + } + setSourceToken( + defaultSourceBalance.id, + defaultSourceBalance.tokenCode ?? "", + ); + }, [destination, sourceTokenId, defaultSourceBalance, setSourceToken]); + + const { sourceBalance, destinationBalance } = useSwapBalances({ + balanceItems, + sourceTokenId, + destinationTokenDescriptor, + }); + + const converter = useTokenFiatConverter({ selectedBalance: sourceBalance }); + const { tokenAmount, tokenAmountDisplay, setTokenAmount } = converter; + + // The store's `sourceAmount` stays the single source of truth for + // path-finding, exactly as `SwapAmountScreen` does it. + useEffect(() => { + setSourceAmount(tokenAmount); + setSourceAmountDisplay(tokenAmountDisplay); + }, [ + tokenAmount, + tokenAmountDisplay, + setSourceAmount, + setSourceAmountDisplay, + ]); + + const spendableAmount = useMemo(() => { + if (!sourceBalance || !account) { + return null; + } + return calculateSpendableAmount({ + balance: sourceBalance, + subentryCount: account.subentryCount || 0, + transactionFee: swapFee, + }); + }, [sourceBalance, account, swapFee]); + + const { amountError } = useSwapAmountError({ + sourceBalance, + sourceAmount, + balanceItems, + swapFee, + subentryCount: account?.subentryCount || 0, + transactionHash: null, + spendableAmount, + sourceTokenSymbol, + pathError, + pathResult, + destinationTokenDescriptor, + }); + + // The destination is never held here (the whole sheet exists because the + // account has none of it), so this is always the descriptor shim rather + // than a real balance row -- the same one `SwapAmountScreen` feeds + // path-finding for its own non-held destinations. + const destinationForPath: HeldBalanceItem | undefined = useMemo( + () => + destinationTokenDescriptor + ? descriptorAsPathBalance(destinationTokenDescriptor) + : undefined, + [destinationTokenDescriptor], + ); + + useSwapPathFinding({ + sourceBalance, + destinationTokenForPath: destinationForPath, + sourceAmount, + swapSlippage, + network, + publicKey, + amountError, + }); + + const { ctaLabel, isCtaDisabled } = useSwapCtaState({ + sourceBalance, + destinationTokenDescriptor, + sourceAmount, + spendableAmount, + isLoadingPath, + isBuilding, + pathResult, + pathError, + amountError, + }); + + const { + isProcessing, + executeSwap, + setupSwapTransaction, + handleProcessingScreenClose, + transactionScanResult, + sourceToken, + destinationToken, + } = useSwapTransaction({ + sourceAmount, + sourceBalance, + destinationTokenInput: destinationForPath, + pathResult, + account, + network, + // No `navigation`: this runs from a sheet, not a `SWAP_STACK` route. + onProcessingClose, + }); + + const { + transactionSecurityAssessment, + sourceSecurityAssessment, + destinationSecurityAssessment, + isMalicious, + isSuspicious, + } = useSwapSecurityAssessments({ + transactionScanResult, + overriddenBlockaidResponse, + sourceBalance, + destinationBalance, + destinationTokenDescriptor, + scanResults, + sourceTokenId, + }); + + const handlePercentagePress = useCallback( + (percentage: number) => { + if (!spendableAmount) { + return; + } + const next = spendableAmount.multipliedBy(percentage).dividedBy(100); + setTokenAmount(next.toFixed()); + }, + [spendableAmount, setTokenAmount], + ); + + const selectSource = useCallback( + (balance: HeldBalanceItem) => { + setSourceToken(balance.id, balance.tokenCode ?? ""); + // Amounts are denominated in the OLD source; carrying one over would + // silently re-quote a different value than the user typed. + setTokenAmount("0"); + }, + [setSourceToken, setTokenAmount], + ); + + return { + destination, + swappableBalances, + selectSource, + sourceBalance, + sourceTokenSymbol, + converter, + spendableAmount, + amountError, + pathResult, + isLoadingPath, + ctaLabel, + isCtaDisabled, + handlePercentagePress, + isProcessing, + executeSwap, + setupSwapTransaction, + handleProcessingScreenClose, + // Processing-screen inputs. + sourceToken, + destinationToken, + sourceAmount, + destinationAmount, + transactionSecurityAssessment, + sourceSecurityAssessment, + destinationSecurityAssessment, + isMalicious, + isSuspicious, + isBuilding, + transactionXDR, + }; +}; diff --git a/src/components/screens/EarnScreen/hooks/useEarnTokens.ts b/src/components/screens/EarnScreen/hooks/useEarnTokens.ts index 27c040968..647334f2f 100644 --- a/src/components/screens/EarnScreen/hooks/useEarnTokens.ts +++ b/src/components/screens/EarnScreen/hooks/useEarnTokens.ts @@ -31,6 +31,17 @@ export interface EarnTokenOption { */ apy: number | null; poolId: string; + /** + * The catalog's canonical asset id -- "CODE:ISSUER" for classic assets, + * null for native XLM (and for anything the catalog leaves unnamed). + * + * Carried because Earn's reserves are addressed by SAC contract id, but + * swapping into one is classic-only (`pathPaymentStrictSend` rejects + * contract-id assets), so the swap destination has to be rebuilt from the + * issuer. `assetId` cannot supply it -- a SAC address is not decomposable + * back into code + issuer. + */ + canonicalId: string | null; /** * True when this reserve's contract address is the network's native SAC. * Decided by comparing `assetId` to the derived native contract address — @@ -116,6 +127,7 @@ export const buildEarnTokenRows = ({ total: total ? total.toFixed() : "0", apy: headlineApy(offer.supplyApy, offer.emissionsSupplyApr), poolId: offer.id, + canonicalId: option.name, isNative: option.assetId === nativeContractId, balance, }; @@ -141,11 +153,24 @@ export const useEarnTokens = () => { [network], ); + /** + * The fetched catalog, kept RAW. + * + * Rows are derived from it separately (below) rather than being built here, + * because they also depend on `pricedBalances` -- which the balances store + * repolls every 30s. Folding that into the fetch made `fetchData` a new + * callback on every poll, re-running the effect, flipping `isLoading` back + * to true, and unmounting this screen (and every bottom sheet mounted under + * it) on a 30-second cadence. Deriving instead keeps the network call tied + * to the account/network only. + */ + const [catalog, setCatalog] = useState<{ + options: BlendEarnAssetOption[]; + poolId: string; + pool: BlendCatalogPool | null; + } | null>(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - const [held, setHeld] = useState([]); - const [supported, setSupported] = useState([]); - const [pool, setLocalPool] = useState(null); const fetchData = useCallback(async () => { setIsLoading(true); @@ -169,16 +194,7 @@ export const useEarnTokens = () => { const resolvedPool = pools.find((p) => p.id === poolId) || null; - const rows = buildEarnTokenRows({ - options: earnOptions, - poolId, - balances: pricedBalances, - networkDetails, - }); - - setHeld(rows.held); - setSupported(rows.supported); - setLocalPool(resolvedPool); + setCatalog({ options: earnOptions, poolId, pool: resolvedPool }); setPool(resolvedPool); } catch (err) { logger.error("useEarnTokens", "Failed to fetch earn tokens", err); @@ -186,7 +202,7 @@ export const useEarnTokens = () => { } finally { setIsLoading(false); } - }, [pricedBalances, networkDetails, setPool]); + }, [networkDetails, setPool]); useEffect(() => { if (account?.publicKey && network) { @@ -194,5 +210,26 @@ export const useEarnTokens = () => { } }, [account?.publicKey, network, fetchData]); - return { isLoading, error, held, supported, pool, refetch: fetchData }; + // Re-derived whenever balances change, so a deposit or swap moves a row + // between sections immediately -- with no refetch and no remount. + const { held, supported } = useMemo(() => { + if (!catalog) { + return { held: [], supported: [] }; + } + return buildEarnTokenRows({ + options: catalog.options, + poolId: catalog.poolId, + balances: pricedBalances, + networkDetails, + }); + }, [catalog, pricedBalances, networkDetails]); + + return { + isLoading, + error, + held, + supported, + pool: catalog?.pool ?? null, + refetch: fetchData, + }; }; diff --git a/src/components/screens/EarnScreen/screens/EarnTokenPickerScreen.tsx b/src/components/screens/EarnScreen/screens/EarnTokenPickerScreen.tsx index e29c35aa8..382c0b221 100644 --- a/src/components/screens/EarnScreen/screens/EarnTokenPickerScreen.tsx +++ b/src/components/screens/EarnScreen/screens/EarnTokenPickerScreen.tsx @@ -7,6 +7,8 @@ import Spinner from "components/Spinner"; import { BaseLayout } from "components/layout/BaseLayout"; import { EarnGlow } from "components/screens/EarnScreen/components/EarnGlow"; import { EarnScreenHeader } from "components/screens/EarnScreen/components/EarnScreenHeader"; +import { EarnSwapBottomSheet } from "components/screens/EarnScreen/components/EarnSwapBottomSheet"; +import { EarnSwapFromBottomSheet } from "components/screens/EarnScreen/components/EarnSwapFromBottomSheet"; import { EarnTokenRow } from "components/screens/EarnScreen/components/EarnTokenRow"; import { NotEnoughTokenBottomSheet } from "components/screens/EarnScreen/components/NotEnoughTokenBottomSheet"; import { ReceiveFundsBottomSheet } from "components/screens/EarnScreen/components/ReceiveFundsBottomSheet"; @@ -16,6 +18,7 @@ import { hasSwappableBalance, isOnrampableAsset, } from "components/screens/EarnScreen/helpers"; +import { useEarnSwap } from "components/screens/EarnScreen/hooks/useEarnSwap"; import { EarnTokenOption, useEarnTokens, @@ -24,12 +27,18 @@ import { // itself re-exported from that barrel, so going through it would close an // import cycle. import { EarnIntroScreen } from "components/screens/EarnScreen/screens/EarnIntroScreen"; +import SwapReviewBottomSheet from "components/screens/SwapScreen/components/SwapReviewBottomSheet"; +import { SWAP_TOAST_IDS } from "components/screens/SwapScreen/hooks/useSwapAmountError"; +import { useSwapFooter } from "components/screens/SwapScreen/hooks/useSwapFooter"; +import { SwapProcessingScreen } from "components/screens/SwapScreen/screens"; import { Button } from "components/sds/Button"; import { Text } from "components/sds/Typography"; +import { AnalyticsEvent } from "config/analyticsConfig"; import { NATIVE_TOKEN_CODE, mapNetworkToNetworkDetails, } from "config/constants"; +import { logger } from "config/logger"; import { ADD_FUNDS_ROUTES, EARN_ROUTES, @@ -43,8 +52,11 @@ import { useBalancesStore } from "ducks/balances"; import { useEarnStore } from "ducks/earn"; import { usePreferencesStore } from "ducks/preferences"; import { getTokenIdentifier } from "helpers/balances"; +import { formatBalanceAmount } from "helpers/formatAmount"; import useAppTranslation from "hooks/useAppTranslation"; +import { HeldBalanceItem } from "hooks/useBalancesList"; import useColors from "hooks/useColors"; +import { useToast } from "providers/ToastProvider"; import React, { useCallback, useMemo, useRef, useState } from "react"; import { Image, ScrollView, View } from "react-native"; @@ -66,6 +78,7 @@ export const EarnTokenPickerScreen: React.FC = ({ navigation, }) => { const { t } = useAppTranslation(); + const { showToast } = useToast(); const { themeColors } = useColors(); const { isLoading, error, held, supported, refetch } = useEarnTokens(); const selectAsset = useEarnStore((state) => state.selectAsset); @@ -79,8 +92,109 @@ export const EarnTokenPickerScreen: React.FC = ({ ); const notEnoughBottomSheetModalRef = useRef(null); const receiveFundsBottomSheetModalRef = useRef(null); + const swapBottomSheetModalRef = useRef(null); + const swapFromBottomSheetModalRef = useRef(null); const [notEnoughOption, setNotEnoughOption] = useState(null); + /** + * The reserve the swap branch is working toward, held SEPARATELY from + * `notEnoughOption`. + * + * They look interchangeable -- swap is always entered from the "not enough" + * sheet, so they start equal -- but that sheet OWNS `notEnoughOption` and + * clears it on close. Driving the swap sheet from it meant any dismissal of + * the sheet underneath blanked the swap sheet's content and reset its whole + * store state mid-flow, which read as "the modal closed by itself". + */ + const [swapOption, setSwapOption] = useState(null); + + // Swap-within-Earn state. Driven by `notEnoughOption` -- the reserve whose + // row was tapped -- so the destination is already locked by the time the + // sheet presents. Null while no row is pending, which no-ops the hook. + const swapReviewBottomSheetModalRef = useRef(null); + + // A completed swap leaves the user holding the reserve they came for, so + // close the whole swap branch and drop them back on the picker -- refetching + // so the row they wanted moves from "Supported tokens" into "In your + // account". Swap's own default (reset onto the History tab) would strand + // them outside Earn entirely. + const handleSwapProcessingClose = useCallback(() => { + swapReviewBottomSheetModalRef.current?.dismiss(); + swapBottomSheetModalRef.current?.dismiss(); + setSwapOption(null); + setNotEnoughOption(null); + refetch(); + }, [refetch]); + + const earnSwap = useEarnSwap({ + option: swapOption, + onProcessingClose: handleSwapProcessingClose, + }); + + // "1,691.69 XLM available" on the sell card. Null hides the line, matching + // `AmountCard`'s own contract. + const earnSwapAvailableText = useMemo(() => { + if (!earnSwap.sourceBalance || !earnSwap.spendableAmount) { + return null; + } + return `${formatBalanceAmount( + earnSwap.sourceBalance, + earnSwap.sourceBalance.tokenCode ?? "", + earnSwap.spendableAmount, + )} ${t("common.available")}`; + }, [earnSwap.sourceBalance, earnSwap.spendableAmount, t]); + + // "Swap from" (design `13723:343723`) stacks over the swap sheet rather + // than replacing it, so its back arrow can return with the source + // unchanged. The swap sheet stays mounted underneath. + const handleSwapSourcePickerPress = useCallback(() => { + swapFromBottomSheetModalRef.current?.present(); + }, []); + + const handleSwapSourceSelected = useCallback( + (balance: HeldBalanceItem) => { + earnSwap.selectSource(balance); + swapFromBottomSheetModalRef.current?.dismiss(); + }, + [earnSwap], + ); + + // Build + Blockaid-scan, then open Review -- mirroring `SwapAmountScreen`'s + // own CTA handler. + // + // The review sheet is presented ONLY on success. Presenting it + // unconditionally is what made a failed path look like "the modal closed": + // stacking a second sheet hides the swap sheet beneath it, so a review that + // opened with nothing to show left the user staring at an empty sheet with + // their amount gone. On failure the swap sheet must simply stay put and the + // reason surface as a toast, exactly as the amount screen does. + const handleSwapReview = useCallback(async () => { + try { + await earnSwap.setupSwapTransaction(); + swapReviewBottomSheetModalRef.current?.present(); + } catch (setupError) { + logger.error( + "EarnTokenPickerScreen", + "Failed to setup the earn swap transaction", + setupError, + ); + showToast({ + variant: "error", + title: + setupError instanceof Error + ? setupError.message + : t("swapScreen.errors.failedToSetupTransaction"), + toastId: SWAP_TOAST_IDS.FAILED_TO_SETUP_TRANSACTION, + duration: 0, + }); + } + }, [earnSwap, showToast, t]); + + // Swap's own settings sheet is reached from `SwapAmountScreen`'s header and + // is route-scoped to `SWAP_STACK`; wiring it from here needs that sheet + // lifted out of the stack first. The control is in the design, so it is + // rendered and named rather than dropped -- see the branch's follow-ups. + const handleSwapSettingsPress = useCallback(() => {}, []); // Cross-stack navigation (Buy / Receive live outside EarnStack) needs the // root-level param list — the screen's own `navigation` prop is typed to @@ -152,16 +266,68 @@ export const EarnTokenPickerScreen: React.FC = ({ }); }, [rootNavigation]); - // The swap-within-earn branch (prefilling a swap into the deposit asset) - // doesn't exist yet on this branch -- the design owner has decided to - // build it, so the button is wired to a named handler now rather than - // routed to `SWAP_STACK` (that would jump the user out of Earn, the exact - // drift this pass is correcting elsewhere) or omitted. Landing a named, - // inert handler ahead of the real implementation mirrors this feature's - // own established pattern (`handleUnheldTokenPress`, `openReviewSheet` - // were both landed this way and filled in later). + // Swap-within-Earn (design `13722:341980`): opens a sheet stacked OVER the + // still-visible picker, with the destination locked to the reserve the + // user just failed to deposit. Deliberately NOT a jump to `SWAP_STACK`, + // which would eject them from Earn and leave them to find their way back + // to the reserve they wanted. + // + // The "not enough" sheet underneath is dismissed first: the two would + // otherwise stack three deep with the picker, and returning to it after a + // completed swap would be wrong -- the balance it is complaining about no + // longer applies. const handleSwapForToken = useCallback(() => { - // Intentionally empty -- the swap-within-earn branch fills this in. + // Capture before dismissing: the sheet below clears `notEnoughOption` as + // it closes, so the swap branch needs its own copy to survive that. + setSwapOption(notEnoughOption); + notEnoughBottomSheetModalRef.current?.dismiss(); + swapBottomSheetModalRef.current?.present(); + }, [notEnoughOption]); + + // Confirm: tear down EVERY swap sheet, then submit. + // + // Dismissing only the review was not enough. `executeSwap` flips + // `isProcessing`, which early-returns the processing screen and unmounts + // the whole sheet tree -- but unmounting a `BottomSheetModal` that is still + // PRESENTED leaves gorhom rendering it over the screen beneath. The swap + // sheet stayed on top of the processing screen, so a submit that actually + // succeeded looked like being dumped back on the configuration sheet. + // + // `swapOption` is deliberately NOT cleared here: the submit reads the + // destination from the swap store, and the processing screen still needs + // the source/destination tokens to render. `handleSwapProcessingClose` + // releases it once the user is done. + // + // Errors are handled inside `useSwapTransaction` so they survive this + // component unmounting -- the same reason `SwapAmountScreen` calls + // `executeSwap` bare rather than awaiting it. + const handleConfirmSwap = useCallback(() => { + swapReviewBottomSheetModalRef.current?.dismiss(); + swapFromBottomSheetModalRef.current?.dismiss(); + swapBottomSheetModalRef.current?.dismiss(); + earnSwap.executeSwap(); + }, [earnSwap]); + + // The review sheet's footer (settings / Cancel / Confirm) is NOT part of + // `SwapReviewBottomSheet` -- it is a separate component the sheet takes as + // `scrollViewFooterComponent`, which also requires `scrollable`. Omitting + // both is what left the review with no way to submit. + const { renderFooterComponent: renderSwapReviewFooter } = useSwapFooter({ + swapReviewBottomSheetModalRef, + onConfirm: handleConfirmSwap, + isBuilding: earnSwap.isBuilding, + isMalicious: earnSwap.isMalicious, + isSuspicious: earnSwap.isSuspicious, + transactionXDR: earnSwap.transactionXDR, + onSettingsPress: handleSwapSettingsPress, + }); + + // Leaves the swap branch entirely, releasing its captured reserve. + const handleCloseSwap = useCallback(() => { + swapFromBottomSheetModalRef.current?.dismiss(); + swapReviewBottomSheetModalRef.current?.dismiss(); + swapBottomSheetModalRef.current?.dismiss(); + setSwapOption(null); }, []); // Design node `9457:46184`: an in-flow "Receive funds" QR sheet, presented @@ -238,10 +404,37 @@ export const EarnTokenPickerScreen: React.FC = ({ ); } + // The swap's submit lifecycle. `executeSwap` flips this the moment Confirm + // is pressed; without rendering it the user saw the review dismiss and the + // swap sheet reappear with no feedback at all -- which reads as "Confirm + // did nothing", even though the transaction was in flight. Regular Swap + // early-returns the same screen from `SwapAmountScreen`. + // + // Sits above the sheet tree deliberately: the processing screen owns the + // whole surface, so the sheets underneath should be gone by then. + if (earnSwap.isProcessing) { + return ( + + ); + } + + // Both full-screen states are gated on having NOTHING to show, not merely + // on the flag: they early-return past the whole sheet tree below, so + // firing one during a refresh unmounts every open bottom sheet -- which is + // what made the swap sheet close by itself mid-configuration. A refresh + // that already has rows keeps rendering them. + const hasEarnRows = held.length > 0 || supported.length > 0; + // The loading and error branches carry the same bare-X header as the list // below: the stack header is off for this route (see `EarnNavigator`), so // without it these states would have no way out of the flow. - if (isLoading) { + if (isLoading && !hasEarnRows) { return ( = ({ // the flow's most-exercised path in the real app today. It must read as a // failure to load, never as "no tokens available" (which would be // actively misleading about why the list is empty). - if (error) { + if (error && !hasEarnRows) { return ( = ({ /> } /> + swapBottomSheetModalRef.current?.dismiss()} + enableDynamicSizing={false} + snapPoints={["90%"]} + analyticsEvent={AnalyticsEvent.VIEW_EARN_SWAP} + customContent={ + swapOption && earnSwap.destination ? ( + + ) : null + } + /> + + swapReviewBottomSheetModalRef.current?.dismiss() + } + scrollable + analyticsEvent={AnalyticsEvent.VIEW_EARN_SWAP_REVIEW} + scrollViewFooterComponent={renderSwapReviewFooter} + customContent={ + + } + /> + swapFromBottomSheetModalRef.current?.dismiss()} + enableDynamicSizing={false} + snapPoints={["90%"]} + customContent={ + swapFromBottomSheetModalRef.current?.dismiss()} + onClose={handleCloseSwap} + /> + } + /> diff --git a/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts b/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts index f41bbf081..2113c3bb6 100644 --- a/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts +++ b/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts @@ -40,10 +40,25 @@ interface SwapTransactionParams { pathResult: SwapPathResult | null; account: ActiveAccount | null; network: NETWORKS; - navigation: NativeStackNavigationProp< + /** + * Optional because swap-within-Earn drives this hook from a bottom sheet + * rather than a `SWAP_STACK` route, and supplies `onProcessingClose` + * instead. Every route-based caller still passes it. + */ + navigation?: NativeStackNavigationProp< SwapStackParamList, typeof SWAP_ROUTES.SWAP_AMOUNT_SCREEN >; + /** + * Where to go when the processing screen is closed. Defaults to resetting + * onto the History tab, which is right for a standalone swap. + * + * Swap-within-Earn overrides it: there the swap exists only to obtain the + * asset the user was already trying to deposit, so dumping them on History + * strands them away from the flow they were in. History is still refreshed + * either way -- only the destination changes. + */ + onProcessingClose?: () => void; } interface UseSwapTransactionResult { @@ -73,6 +88,7 @@ export const useSwapTransaction = ({ account, network, navigation, + onProcessingClose, }: SwapTransactionParams): UseSwapTransactionResult => { const [isProcessing, setIsProcessing] = useState(false); const [transactionScanResult, setTransactionScanResult] = @@ -367,7 +383,12 @@ export const useSwapTransaction = ({ }); } - navigation.reset({ + if (onProcessingClose) { + onProcessingClose(); + return; + } + + navigation?.reset({ index: 0, routes: [ { diff --git a/src/config/analyticsConfig.ts b/src/config/analyticsConfig.ts index ceaa4f91b..93be7a8f3 100644 --- a/src/config/analyticsConfig.ts +++ b/src/config/analyticsConfig.ts @@ -91,6 +91,8 @@ export enum AnalyticsEvent { VIEW_EARN_AMOUNT = "earn_amount", VIEW_EARN_REVIEW = "earn_review", VIEW_EARN_TRANSACTION_DETAILS = "earn_transaction_details", + VIEW_EARN_SWAP = "earn_swap", + VIEW_EARN_SWAP_REVIEW = "earn_swap_review", VIEW_EARN_PROCESSING = "earn_processing", VIEW_EARN_SUCCESS = "earn_success", @@ -563,6 +565,13 @@ const SCREEN_CATALOG: Record = { [AnalyticsEvent.VIEW_EARN_TRANSACTION_DETAILS]: { flow: AnalyticsFlow.EARN, }, + [AnalyticsEvent.VIEW_EARN_SWAP]: { + flow: AnalyticsFlow.EARN, + }, + [AnalyticsEvent.VIEW_EARN_SWAP_REVIEW]: { + flow: AnalyticsFlow.EARN, + step: "confirm", + }, [AnalyticsEvent.VIEW_EARN_PROCESSING]: { flow: AnalyticsFlow.EARN, step: "processing", diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index c498357b4..a3febfb54 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -42,7 +42,8 @@ "tokenId": "Token ID", "message": "Message", "linkOpenError": "Unable to open link", - "authEntry": "Auth Entry" + "authEntry": "Auth Entry", + "back": "Back" }, "home": { "title": "Home", @@ -746,10 +747,10 @@ }, "retryBanner": "Transaction failed. Try again.", "networkFeeSheet": { - "title": "You need some XLM for network fee", + "title": "XLM needed for network fees", "body": "Add XLM to your wallet to continue", "buyWithCoinbase": "Buy with Coinbase", - "transfer": "Transfer from another account" + "receive": "Receive XLM" }, "errors": { "simulationFailed": "Failed to simulate the deposit. Please try again.", @@ -772,13 +773,13 @@ }, "earnNotEnough": { "title": "Not enough {{tokenCode}}", - "bodyWithBuy": "You'll need {{tokenCode}} to deposit into this pool. Buy or swap for {{tokenCode}} to continue.", + "bodyWithBuy": "Get {{tokenCode}} to deposit into this pool. Buy or transfer {{tokenCode}} to continue.", "bodySwapOnly": "You'll need {{tokenCode}} to deposit into this pool. Swap or transfer {{tokenCode}} to continue.", "bodyTransferOnly": "You'll need {{tokenCode}} to deposit into this pool. Transfer {{tokenCode}} from another account to continue.", "buy": "Buy {{tokenCode}}", "buyWithCoinbase": "Buy with Coinbase", "swap": "Swap for {{tokenCode}}", - "transfer": "Transfer from another account", + "receive": "Receive {{tokenCode}}", "or": "or" }, "earnReceiveFunds": { @@ -1383,5 +1384,11 @@ "title": "Cannot run on this device", "description": "This device is rooted or jailbroken, which prevents the app from protecting your wallet keys.", "disclaimer": "The app is disabled on compromised systems. Install and use it on a device with standard security intact." + }, + "earnSwapFrom": { + "title": "Swap from", + "searchPlaceholder": "Search token name or address", + "yourTokens": "Your tokens", + "noResults": "No matching tokens in your wallet." } } diff --git a/src/i18n/locales/pt/translations.json b/src/i18n/locales/pt/translations.json index f0c0fb900..3b0c2ea76 100644 --- a/src/i18n/locales/pt/translations.json +++ b/src/i18n/locales/pt/translations.json @@ -42,7 +42,8 @@ "collection": "Coleção", "tokenId": "ID do Token", "linkOpenError": "Não foi possível abrir o link", - "authEntry": "Entrada de Autorização" + "authEntry": "Entrada de Autorização", + "back": "Voltar" }, "home": { "title": "Início", @@ -712,10 +713,10 @@ }, "retryBanner": "A transação falhou. Tente novamente.", "networkFeeSheet": { - "title": "Você precisa de XLM para a taxa de rede", + "title": "XLM necessário para taxas de rede", "body": "Adicione XLM à sua carteira para continuar", "buyWithCoinbase": "Comprar com a Coinbase", - "transfer": "Transferir de outra conta" + "receive": "Receber XLM" }, "errors": { "simulationFailed": "Falha ao simular o depósito. Tente novamente.", @@ -738,13 +739,13 @@ }, "earnNotEnough": { "title": "{{tokenCode}} insuficiente", - "bodyWithBuy": "Você precisará de {{tokenCode}} para depositar neste pool. Compre ou troque por {{tokenCode}} para continuar.", + "bodyWithBuy": "Obtenha {{tokenCode}} para depositar neste pool. Compre ou transfira {{tokenCode}} para continuar.", "bodySwapOnly": "Você precisará de {{tokenCode}} para depositar neste pool. Troque ou transfira {{tokenCode}} para continuar.", "bodyTransferOnly": "Você precisará de {{tokenCode}} para depositar neste pool. Transfira {{tokenCode}} de outra conta para continuar.", "buy": "Comprar {{tokenCode}}", "buyWithCoinbase": "Comprar com a Coinbase", "swap": "Trocar por {{tokenCode}}", - "transfer": "Transferir de outra conta", + "receive": "Receber {{tokenCode}}", "or": "ou" }, "earnReceiveFunds": { @@ -1383,5 +1384,11 @@ "title": "Não é possível executar neste dispositivo", "description": "Este dispositivo possui root ou jailbreak, o que impede o aplicativo de proteger as chaves da sua carteira.", "disclaimer": "O aplicativo está desabilitado em sistemas comprometidos. Instale e use-o em um dispositivo com segurança padrão intacta." + }, + "earnSwapFrom": { + "title": "Trocar de", + "searchPlaceholder": "Busque pelo nome ou endereço do token", + "yourTokens": "Seus tokens", + "noResults": "Nenhum token correspondente na sua carteira." } } From b5745a9d3ff97293887f15aad5618dc81edee866 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Tue, 10 Nov 2026 17:23:57 -0800 Subject: [PATCH 24/27] fix(earn): render the Earn glow in light and dark themes The glow behind the intro and the token picker was baked to the dark design's green (#11351E). On Varial's light page it rendered as a grey smudge. It now takes a theme token: green-4 in dark, the nearest token to the design's color, and green-6 in light, where green-4 all but vanishes. This matches the extension's EarnGlow. The blur profile is unchanged. Co-Authored-By: Claude Opus 5.5 --- .../EarnScreen/components/EarnGlow.tsx | 76 +++++++++++-------- 1 file changed, 44 insertions(+), 32 deletions(-) diff --git a/src/components/screens/EarnScreen/components/EarnGlow.tsx b/src/components/screens/EarnScreen/components/EarnGlow.tsx index 7d0572bfb..0d92474e5 100644 --- a/src/components/screens/EarnScreen/components/EarnGlow.tsx +++ b/src/components/screens/EarnScreen/components/EarnGlow.tsx @@ -1,9 +1,11 @@ +import { DEFAULT_COLOR_SCHEME } from "config/theme"; +import useColors from "hooks/useColors"; import React from "react"; -import { View } from "react-native"; +import { View, useColorScheme } from "react-native"; import Svg, { Circle, Defs, RadialGradient, Stop } from "react-native-svg"; /** - * The Earn designs' ambient glow is a solid #11351E circle of radius 132 + * The Earn designs' ambient glow is a solid green circle of radius 132 * under an SVG `feGaussianBlur` of stdDeviation 100. Shipping that node as an * asset renders as a hard-edged disc on device -- react-native-svg parses the * filter but does not apply it -- so the blur is reproduced as the radial @@ -22,8 +24,12 @@ import Svg, { Circle, Defs, RadialGradient, Stop } from "react-native-svg"; * - The gradient's radius is 460, not the source node's 332. The falloff is * still at ~1.2% opacity at 332, so stopping there leaves a faint but * visible circular seam; 460 is where it reaches ~0.02%. + * + * The green follows the theme. Dark uses green-4, the nearest token to the + * dark design's #11351E. The design has no light frame for this glow, so + * light uses green-6: it stands off the light page about as far as green-4 + * stands off the dark one, where green-4 itself all but disappears. */ -const GLOW_COLOR = "#11351E"; const GLOW_RADIUS = 460; const GLOW_SIZE = GLOW_RADIUS * 2; @@ -62,34 +68,40 @@ export interface EarnGlowProps { * `pointerEvents="none"` because the gradient's box is far larger than its * visible falloff and would otherwise swallow taps on whatever it overlaps. */ -export const EarnGlow: React.FC = ({ centerY, opacity = 1 }) => ( - - - - - {GLOW_STOPS.map(([offset, stopOpacity]) => ( - - ))} - - - - - -); +export const EarnGlow: React.FC = ({ centerY, opacity = 1 }) => { + const { themeColors } = useColors(); + const isLight = (useColorScheme() ?? DEFAULT_COLOR_SCHEME) === "light"; + const glowColor = isLight ? themeColors.green[6] : themeColors.green[4]; + + return ( + + + + + {GLOW_STOPS.map(([offset, stopOpacity]) => ( + + ))} + + + + + + ); +}; export default EarnGlow; From 0720d76a2d97287a0f7d3cf48f065eeed64af520 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Tue, 10 Nov 2026 17:25:02 -0800 Subject: [PATCH 25/27] feat(earn): move Earn's sheets to the Varial sheet header Earn's sheets were the last ones still drawing their own circled X. The Varial bottom sheet restyle moved every other sheet to the shared, labelled header. - XLM fee shortfall and Not enough: stacked header with the token icon leading, as in main's "Not enough XLM" sheet. - Receive funds: centered header. - Pool details, Swap and Swap from keep their own header rows (a two-line pool name; a settings or back control left of a centered title). They now use the header's X, which BottomSheetHeader exports as BottomSheetCloseButton. - Deposit review: a "Review deposit" title above the card, matching the Send and Swap reviews. Every X now has a "Close" accessibility label, and the swap sheet's settings control has a "Settings" label. Co-Authored-By: Claude Opus 5.5 --- src/components/BottomSheetHeader.tsx | 45 ++++++++++++++----- .../components/EarnReviewBottomSheet.tsx | 7 +++ .../components/EarnSwapBottomSheet.tsx | 17 +++---- .../components/EarnSwapFromBottomSheet.tsx | 15 +++---- .../components/NotEnoughTokenBottomSheet.tsx | 38 ++++------------ .../components/PoolDetailsBottomSheet.tsx | 17 +++---- .../components/ReceiveFundsBottomSheet.tsx | 28 ++++-------- .../components/XlmFeeShortfallBottomSheet.tsx | 45 ++++++------------- src/i18n/locales/en/translations.json | 1 + src/i18n/locales/pt/translations.json | 1 + 10 files changed, 92 insertions(+), 122 deletions(-) diff --git a/src/components/BottomSheetHeader.tsx b/src/components/BottomSheetHeader.tsx index e43f8f02e..94121aaaf 100644 --- a/src/components/BottomSheetHeader.tsx +++ b/src/components/BottomSheetHeader.tsx @@ -30,6 +30,39 @@ export interface BottomSheetHeaderProps { titleTestID?: string; } +export interface BottomSheetCloseButtonProps { + onPress: () => void; + disabled?: boolean; + testID?: string; +} + +/** + * The sheet header's plain X on its own, for headers whose layout + * `BottomSheetHeader` doesn't cover (e.g. a back or settings control on the + * left of a centered title). + */ +export const BottomSheetCloseButton: React.FC = ({ + onPress, + disabled, + testID, +}) => { + const { t } = useAppTranslation(); + const { themeColors } = useColors(); + + return ( + + + + ); +}; + /** * Shared bottom sheet header: a title and a plain X in one of three layouts. * Renders nothing without a title, leading element or `onClose`. Adds no @@ -44,9 +77,6 @@ export const BottomSheetHeader: React.FC = ({ closeTestID, titleTestID, }) => { - const { t } = useAppTranslation(); - const { themeColors } = useColors(); - const resolvedLayout: BottomSheetHeaderLayout = layout ?? (leading ? "stacked" : "inline"); @@ -55,16 +85,11 @@ export const BottomSheetHeader: React.FC = ({ } const closeButton = onClose ? ( - - - + /> ) : null; const titleText = title ? ( diff --git a/src/components/screens/EarnScreen/components/EarnReviewBottomSheet.tsx b/src/components/screens/EarnScreen/components/EarnReviewBottomSheet.tsx index 857743842..cc7344f0e 100644 --- a/src/components/screens/EarnScreen/components/EarnReviewBottomSheet.tsx +++ b/src/components/screens/EarnScreen/components/EarnReviewBottomSheet.tsx @@ -2,6 +2,7 @@ import { BottomSheetModal } from "@gorhom/bottom-sheet"; import blendIcon from "assets/logos/blend-icon.png"; import BigNumber from "bignumber.js"; import BottomSheet from "components/BottomSheet"; +import { BottomSheetHeader } from "components/BottomSheetHeader"; import { List } from "components/List"; import { TokenIcon } from "components/TokenIcon"; import { @@ -246,6 +247,12 @@ export const EarnReviewBottomSheet: React.FC = ({ return ( + + + {t("earnReview.title")} diff --git a/src/components/screens/EarnScreen/components/EarnSwapBottomSheet.tsx b/src/components/screens/EarnScreen/components/EarnSwapBottomSheet.tsx index 9b99ae33e..ecdc3839c 100644 --- a/src/components/screens/EarnScreen/components/EarnSwapBottomSheet.tsx +++ b/src/components/screens/EarnScreen/components/EarnSwapBottomSheet.tsx @@ -1,4 +1,5 @@ import { AmountCard } from "components/AmountCard"; +import { BottomSheetCloseButton } from "components/BottomSheetHeader"; import { PercentageButtons } from "components/PercentageButtons"; import { Button } from "components/sds/Button"; import Icon from "components/sds/Icon"; @@ -90,32 +91,26 @@ export const EarnSwapBottomSheet: React.FC = ({ return ( - {/* Header `13722:342081`: settings at the left, centred title, and a - 40px circled X at the right that the sheet owns itself. */} + {/* Settings at the left, centred title, and the shared sheet X at the + right -- a layout `BottomSheetHeader` itself doesn't offer. */} - + {t("swapScreen.title")} - - - + diff --git a/src/components/screens/EarnScreen/components/EarnSwapFromBottomSheet.tsx b/src/components/screens/EarnScreen/components/EarnSwapFromBottomSheet.tsx index 90e8f26ae..6c7efeced 100644 --- a/src/components/screens/EarnScreen/components/EarnSwapFromBottomSheet.tsx +++ b/src/components/screens/EarnScreen/components/EarnSwapFromBottomSheet.tsx @@ -1,3 +1,4 @@ +import { BottomSheetCloseButton } from "components/BottomSheetHeader"; import { SwapTokenRow } from "components/screens/SwapScreen/components/SwapTokenRow"; import Icon from "components/sds/Icon"; import { Input } from "components/sds/Input"; @@ -67,19 +68,15 @@ export const EarnSwapFromBottomSheet: React.FC< - + {t("earnSwapFrom.title")} - - - + void; - /** - * The swap-within-earn branch doesn't exist yet on this branch (see the - * caller's `handleSwapForToken`) -- this prop is wired now, ahead of that - * work, so the button renders per the design without a dead branch to add - * later. - */ + /** Opens the swap-within-Earn sheet for this token. */ onSwap: () => void; onReceive: () => void; onClose: () => void; @@ -61,7 +55,6 @@ export const NotEnoughTokenBottomSheet: React.FC< NotEnoughTokenBottomSheetProps > = ({ variant, tokenCode, token, onBuy, onSwap, onReceive, onClose }) => { const { t } = useAppTranslation(); - const { themeColors } = useColors(); // No option selected yet -- the sheet isn't presented in this state, so // there is nothing to render (mirrors `PoolDetailsBottomSheet`'s guard for @@ -209,27 +202,12 @@ export const NotEnoughTokenBottomSheet: React.FC< return ( - {/* Design `13717:333143`: the asset icon and title share a left-hand - column (gap 12), with the close control opposite at the TOP rather - than vertically centred on a full-width icon row. Title is 20/28 - (`Text xl`), previously 18/26. The 24 gaps below come from the - modal's own `13717:333139`/`333141` stacks; they were 12 and 16. */} - - - - - {t("earnNotEnough.title", { tokenCode })} - - - - - - + } + onClose={onClose} + closeTestID="not-enough-token-close" + /> diff --git a/src/components/screens/EarnScreen/components/PoolDetailsBottomSheet.tsx b/src/components/screens/EarnScreen/components/PoolDetailsBottomSheet.tsx index f74d1679a..53d5f417b 100644 --- a/src/components/screens/EarnScreen/components/PoolDetailsBottomSheet.tsx +++ b/src/components/screens/EarnScreen/components/PoolDetailsBottomSheet.tsx @@ -1,5 +1,6 @@ import { BottomSheetModal } from "@gorhom/bottom-sheet"; import blendIcon from "assets/logos/blend-icon.png"; +import { BottomSheetCloseButton } from "components/BottomSheetHeader"; import { TokenIcon } from "components/TokenIcon"; import { formatCompactUsd, @@ -7,7 +8,6 @@ import { getPoolDescriptionKey, } from "components/screens/EarnScreen/helpers"; import { Button } from "components/sds/Button"; -import Icon from "components/sds/Icon"; import { Text } from "components/sds/Typography"; import { BlendCatalogPool, BlendCatalogReserve } from "config/blendTypes"; import { NATIVE_TOKEN_CODE } from "config/constants"; @@ -17,7 +17,7 @@ import { getNativeContractDetails } from "helpers/soroban"; import useAppTranslation from "hooks/useAppTranslation"; import useColors from "hooks/useColors"; import React from "react"; -import { Image, TouchableOpacity, View } from "react-native"; +import { Image, View } from "react-native"; export interface PoolDetailsBottomSheetProps { pool: BlendCatalogPool | null; @@ -268,15 +268,10 @@ export const PoolDetailsBottomSheet: React.FC = ({ - - - + {description && ( diff --git a/src/components/screens/EarnScreen/components/ReceiveFundsBottomSheet.tsx b/src/components/screens/EarnScreen/components/ReceiveFundsBottomSheet.tsx index 5259805a8..c9a0f1400 100644 --- a/src/components/screens/EarnScreen/components/ReceiveFundsBottomSheet.tsx +++ b/src/components/screens/EarnScreen/components/ReceiveFundsBottomSheet.tsx @@ -1,14 +1,14 @@ import { BottomSheetModal } from "@gorhom/bottom-sheet"; import { AccountQrDisplay } from "components/AccountQrDisplay"; +import { BottomSheetHeader } from "components/BottomSheetHeader"; import { Button, IconPosition } from "components/sds/Button"; import Icon from "components/sds/Icon"; import { Text } from "components/sds/Typography"; import useAppTranslation from "hooks/useAppTranslation"; import { useClipboard } from "hooks/useClipboard"; -import useColors from "hooks/useColors"; import useGetActiveAccount from "hooks/useGetActiveAccount"; import React from "react"; -import { TouchableOpacity, View } from "react-native"; +import { View } from "react-native"; export interface ReceiveFundsBottomSheetProps { bottomSheetModalRef?: React.RefObject; @@ -42,7 +42,6 @@ export const ReceiveFundsBottomSheet: React.FC< ReceiveFundsBottomSheetProps > = ({ bottomSheetModalRef }) => { const { t } = useAppTranslation(); - const { themeColors } = useColors(); const { account } = useGetActiveAccount(); const { copyToClipboard } = useClipboard(); @@ -58,22 +57,13 @@ export const ReceiveFundsBottomSheet: React.FC< return ( - - - {t("earnReceiveFunds.title")} - - - - + + ; /** Buy XLM via the onramp. Same destination as the token picker's * `NotEnoughTokenBottomSheet` Buy button. */ onBuy: () => void; - /** Cross-stack jump to the global Receive screen -- a separate batch - * replaces this with an in-flow "Receive funds" QR sheet (design node - * `9457:46184`); this sheet just needs to call whatever handler the - * caller currently wires here. */ + /** Opens the in-flow "Receive funds" sheet on top of this one. */ onReceive: () => void; } @@ -28,13 +24,10 @@ export interface XlmFeeShortfallBottomSheetProps { * feeShortfall`/`insufficientBalanceForFee`) and is untouched by this * component. * - * Matches design node `13722:341905`: the XLM asset icon (not an amber - * warning glyph) and **two** stacked full-width actions -- "Buy with + * The XLM asset icon (not an amber warning glyph) leads the shared stacked + * sheet header, followed by **two** stacked full-width actions -- "Buy with * Coinbase" (light `tertiary` pill) above "Receive XLM" (outlined - * `secondary` pill). Structurally identical to - * `NotEnoughTokenBottomSheet`'s header and action stack, and redesigned in - * the same pass: icon and title share a left column (gap 12) with the close - * control opposite at the top, title at 20/28, and 24 gaps beneath. + * `secondary` pill). Structurally identical to `NotEnoughTokenBottomSheet`. * * Pure content: mirrors `PoolDetailsBottomSheet`'s convention (the caller * wraps this in `components/BottomSheet` and owns the modal ref, passed @@ -44,7 +37,6 @@ export const XlmFeeShortfallBottomSheet: React.FC< XlmFeeShortfallBottomSheetProps > = ({ bottomSheetModalRef, onBuy, onReceive }) => { const { t } = useAppTranslation(); - const { themeColors } = useColors(); const handleClose = () => { bottomSheetModalRef?.current?.dismiss(); @@ -52,30 +44,19 @@ export const XlmFeeShortfallBottomSheet: React.FC< return ( - - + - - {t("earnAmount.networkFeeSheet.title")} - - - - - - + } + onClose={handleClose} + closeTestID="xlm-fee-shortfall-close" + /> diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index 3ef984b5b..83c78f5d7 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -845,6 +845,7 @@ } }, "earnReview": { + "sheetTitle": "Review deposit", "title": "You are depositing", "to": "To", "apy": "Current APY", diff --git a/src/i18n/locales/pt/translations.json b/src/i18n/locales/pt/translations.json index 4594e26a6..a7a4a9e8f 100644 --- a/src/i18n/locales/pt/translations.json +++ b/src/i18n/locales/pt/translations.json @@ -811,6 +811,7 @@ } }, "earnReview": { + "sheetTitle": "Revisar depósito", "title": "Você está depositando", "to": "Para", "apy": "APY atual", From 5faaa670fd37332a083e30c0db534fa576de9814 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Tue, 10 Nov 2026 17:27:16 -0800 Subject: [PATCH 26/27] refactor(earn): follow main's XDR and asset-identity conventions - The Blend deposit build and its tests use stellar-sdk 17's toXdr and fromXdr. Main's source no longer uses the deprecated toXDR/fromXDR aliases. The output is byte-identical. - getNotEnoughVariant identifies native XLM with isNativeToken, as main's asset-identity lint rule requires. Co-Authored-By: Claude Opus 5.5 --- __tests__/helpers/blend.test.ts | 12 ++++++------ __tests__/services/transactionService.test.ts | 6 +++--- .../EarnScreen/helpers/getNotEnoughVariant.ts | 3 ++- src/services/transactionService.ts | 4 ++-- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/__tests__/helpers/blend.test.ts b/__tests__/helpers/blend.test.ts index c207df599..bbdc7c300 100644 --- a/__tests__/helpers/blend.test.ts +++ b/__tests__/helpers/blend.test.ts @@ -59,8 +59,8 @@ describe("buildBlendRequestScVal", () => { amount: "5000000000", requestType: BlendRequestType.SupplyCollateral, }); - const restored = xdr.ScVal.fromXDR(val.toXDR()); - expect(restored.toXDR("base64")).toBe(val.toXDR("base64")); + const restored = xdr.ScVal.fromXdr(val.toXdr()); + expect(restored.toXdr("base64")).toBe(val.toXdr("base64")); }); }); @@ -89,10 +89,10 @@ describe("buildBlendSubmitOp", () => { expect(invocation.args).toHaveLength(4); // Verify that from, spender, and to are all the user's Address - const expectedUser = new Address(PUBLIC_KEY).toScVal().toXDR("base64"); + const expectedUser = new Address(PUBLIC_KEY).toScVal().toXdr("base64"); const { args } = invocation; - expect(args[0].toXDR("base64")).toBe(expectedUser); - expect(args[1].toXDR("base64")).toBe(expectedUser); - expect(args[2].toXDR("base64")).toBe(expectedUser); + expect(args[0].toXdr("base64")).toBe(expectedUser); + expect(args[1].toXdr("base64")).toBe(expectedUser); + expect(args[2].toXdr("base64")).toBe(expectedUser); }); }); diff --git a/__tests__/services/transactionService.test.ts b/__tests__/services/transactionService.test.ts index c7b92f656..afa59249e 100644 --- a/__tests__/services/transactionService.test.ts +++ b/__tests__/services/transactionService.test.ts @@ -1003,7 +1003,7 @@ describe("buildBlendDepositTransaction", () => { expect(result.preparedXdr).toBe(mockPreparedXdr); expect(result.minResourceFee).toBe("546395"); - const parsedTx = TransactionBuilder.fromXDR( + const parsedTx = TransactionBuilder.fromXdr( result.xdr, Networks.PUBLIC, ) as Transaction; @@ -1036,8 +1036,8 @@ describe("buildBlendDepositTransaction", () => { expect(xdr.expectUnionVariant(byKey("request_type"), "scvU32").u32).toBe( BlendRequestType.SupplyCollateral, ); - expect(byKey("amount").toXDR("base64")).toBe( - new XdrLargeInt("i128", "5000000000").toI128().toXDR("base64"), + expect(byKey("amount").toXdr("base64")).toBe( + new XdrLargeInt("i128", "5000000000").toI128().toXdr("base64"), ); // transactionFee is the INCLUSION fee only: 0.00001 XLM = 100 stroops. diff --git a/src/components/screens/EarnScreen/helpers/getNotEnoughVariant.ts b/src/components/screens/EarnScreen/helpers/getNotEnoughVariant.ts index 395b2b9de..2e9723e51 100644 --- a/src/components/screens/EarnScreen/helpers/getNotEnoughVariant.ts +++ b/src/components/screens/EarnScreen/helpers/getNotEnoughVariant.ts @@ -1,6 +1,7 @@ import BigNumber from "bignumber.js"; import { NetworkDetails, NETWORKS } from "config/constants"; import { PricedBalanceMap } from "config/types"; +import { isNativeToken } from "helpers/assetIdentity"; /** * Assets the Coinbase onramp can sell, by code. @@ -64,7 +65,7 @@ export const hasSwappableBalance = ( if (!("token" in balance)) { return false; } - const isNative = "type" in balance.token && balance.token.type === "native"; + const isNative = isNativeToken(balance.token); if (!isNative && !("issuer" in balance.token)) { return false; } diff --git a/src/services/transactionService.ts b/src/services/transactionService.ts index 85339b100..f777e7df9 100644 --- a/src/services/transactionService.ts +++ b/src/services/transactionService.ts @@ -1071,7 +1071,7 @@ export const buildBlendDepositTransaction = async ({ .build(); const simulation = await simulateTransaction({ - xdr: builtTx.toXDR(), + xdr: builtTx.toXdr(), network_url: networkDetails.sorobanRpcUrl, network_passphrase: networkDetails.networkPassphrase, }); @@ -1084,7 +1084,7 @@ export const buildBlendDepositTransaction = async ({ } return { - xdr: builtTx.toXDR(), + xdr: builtTx.toXdr(), preparedXdr: simulation.preparedTransaction, minResourceFee: simulation.simulationResponse?.minResourceFee ?? null, }; From 6747ac43810b56aa612be8f82953ddfcfe1afa4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ca=CC=81ssio=20Marcos=20Goulart?= Date: Tue, 10 Nov 2026 17:33:51 -0800 Subject: [PATCH 27/27] feat(earn): deposit into the Blend v2.1 Fixed pools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Earn now targets the v2.1 deployment's Fixed pools: - mainnet "Fixed v2.1": CBAKAZUEJBAFA2DCGBRS2WKJT3FNIMJBHTFL76AY7XMAVEINQNNIQWAO - testnet "Testnet v2.1": CB3447A446DY3USGWPIDXTNQHF5EX24XDQA2EN6TAZP62JTPBTNOA52Q Both run the same pool contract code as the v2 pools they replace (same wasm hash on both networks), so the request encoding and the submit call are unchanged. freighter-backend-v2's earn-pools.json allowlist (main-blend) lists the same two IDs. The pool sheet's description was keyed by pool ID because its copy ("Permissionless lending pool with no admin…") claims something only true of one deployment. That claim was true of the v2 mainnet pool, whose admin account is burned. It was never true of the v2 testnet pool, and the v2.1 pools both have a contract admin. The sheet now shows the extension's generic description for every pool, in en and pt. Unit tests read BLEND_FIXED_POOL_IDS instead of a copied literal, as on the extension. The allowlist's own test keeps the literal IDs. Co-Authored-By: Claude Opus 5.5 --- .../EarnScreen/EarnAmountScreen.test.tsx | 4 +- .../PoolDetailsBottomSheet.test.tsx | 39 ++++++++++++------- .../EarnScreen/earnSwapDestination.test.ts | 4 +- .../screens/EarnScreen/helpers.test.ts | 15 ------- .../screens/EarnScreen/useEarnTokens.test.ts | 3 +- __tests__/config/blend.test.ts | 4 +- __tests__/ducks/earn.test.ts | 4 +- __tests__/helpers/blend.test.ts | 4 +- __tests__/services/blend.test.ts | 3 +- __tests__/services/transactionService.test.ts | 4 +- .../components/PoolDetailsBottomSheet.tsx | 31 ++++++--------- .../screens/EarnScreen/helpers/index.ts | 1 - .../EarnScreen/helpers/poolDescriptions.ts | 27 ------------- src/config/blend.ts | 15 ++++--- src/i18n/locales/en/translations.json | 4 +- src/i18n/locales/pt/translations.json | 4 +- 16 files changed, 67 insertions(+), 99 deletions(-) delete mode 100644 src/components/screens/EarnScreen/helpers/poolDescriptions.ts diff --git a/__tests__/components/screens/EarnScreen/EarnAmountScreen.test.tsx b/__tests__/components/screens/EarnScreen/EarnAmountScreen.test.tsx index 06e3e6031..e4a79007e 100644 --- a/__tests__/components/screens/EarnScreen/EarnAmountScreen.test.tsx +++ b/__tests__/components/screens/EarnScreen/EarnAmountScreen.test.tsx @@ -4,6 +4,8 @@ import { fireEvent } from "@testing-library/react-native"; import BigNumber from "bignumber.js"; import { UNKNOWN_RESOURCE_FEE_FLOOR_XLM } from "components/screens/EarnScreen/helpers"; import EarnAmountScreen from "components/screens/EarnScreen/screens/EarnAmountScreen"; +import { BLEND_FIXED_POOL_IDS } from "config/blend"; +import { NETWORKS } from "config/constants"; import { EARN_ROUTES, EarnStackParamList } from "config/routes"; import { useEarnStore } from "ducks/earn"; import { useTransactionBuilderStore } from "ducks/transactionBuilder"; @@ -196,7 +198,7 @@ jest.mock("hooks/useAppTranslation", () => ({ const USDC_ASSET_ID = "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75"; -const POOL_ID = "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD"; +const POOL_ID = BLEND_FIXED_POOL_IDS[NETWORKS.PUBLIC]!; const USDC_BALANCE = { id: USDC_ASSET_ID, diff --git a/__tests__/components/screens/EarnScreen/components/PoolDetailsBottomSheet.test.tsx b/__tests__/components/screens/EarnScreen/components/PoolDetailsBottomSheet.test.tsx index 3398f8f63..7a6c693c8 100644 --- a/__tests__/components/screens/EarnScreen/components/PoolDetailsBottomSheet.test.tsx +++ b/__tests__/components/screens/EarnScreen/components/PoolDetailsBottomSheet.test.tsx @@ -1,7 +1,9 @@ import { BottomSheetModal } from "@gorhom/bottom-sheet"; import { fireEvent } from "@testing-library/react-native"; import { PoolDetailsBottomSheet } from "components/screens/EarnScreen/components/PoolDetailsBottomSheet"; +import { BLEND_FIXED_POOL_IDS } from "config/blend"; import { BlendCatalogPool, BlendCatalogReserve } from "config/blendTypes"; +import { NETWORKS } from "config/constants"; import { renderWithProviders } from "helpers/testUtils"; import React from "react"; @@ -38,12 +40,12 @@ const nullHeavyPool: BlendCatalogPool = { reserves: [], }; -// The Fixed pool (testnet contract id) — has a description entry and fully -// priced figures, including a supply-side rate that legitimately exceeds 1 -// (a decimal fraction, not a pre-multiplied percentage). +// The testnet Fixed pool — fully priced figures, including a supply-side rate +// that legitimately exceeds 1 (a decimal fraction, not a pre-multiplied +// percentage). const fixedPool: BlendCatalogPool = { - id: "CCEBVDYM32YNYCVNRXQKDFFPISJJCV557CDZEIRBEE4NCV4KHPQ44HGF", - name: "TestnetV2", + id: BLEND_FIXED_POOL_IDS[NETWORKS.TESTNET]!, + name: "Testnet v2.1", status: "ADMIN_ACTIVE", suppliedUsd: 40385476.30883376, borrowedUsd: 35717534.91620809, @@ -76,18 +78,25 @@ describe("PoolDetailsBottomSheet", () => { expect(getByTestId("pool-details-backstop")).toHaveTextContent("--"); }); - it("renders no description for a pool with no catalog entry", () => { - const { queryByTestId } = renderWithProviders( - , - ); - expect(queryByTestId("pool-details-description")).toBeNull(); + it("renders the same generic description for every pool", () => { + const generic = /Deposit supported assets into this Blend pool/i; + + [nullHeavyPool, fixedPool].forEach((pool) => { + const { getByTestId, unmount } = renderWithProviders( + , + ); + expect(getByTestId("pool-details-description")).toHaveTextContent( + generic, + ); + unmount(); + }); }); it("renders the pool name and formatted figures for a fully-priced pool", () => { const { getByText, getByTestId } = renderWithProviders( , ); - expect(getByText("TestnetV2")).toBeTruthy(); + expect(getByText("Testnet v2.1")).toBeTruthy(); expect(getByTestId("pool-details-lendingInterest")).toHaveTextContent( "392.00%", ); @@ -106,17 +115,17 @@ describe("PoolDetailsBottomSheet", () => { const { getByText } = renderWithProviders( , ); - expect(getByText("TestnetV2").props.children).toBe("TestnetV2"); + expect(getByText("Testnet v2.1").props.children).toBe("Testnet v2.1"); expect(getByText("Pool Details")).toBeTruthy(); expect(getByText("by Blend")).toBeTruthy(); }); - it("renders the fixed-pool description for a pool id present in the catalog", () => { + it("makes no per-pool claims, such as 'no admin', that a new deployment can falsify", () => { const { getByTestId } = renderWithProviders( , ); - expect(getByTestId("pool-details-description")).toHaveTextContent( - /Permissionless lending pool with no admin/i, + expect(getByTestId("pool-details-description")).not.toHaveTextContent( + /no admin/i, ); }); diff --git a/__tests__/components/screens/EarnScreen/earnSwapDestination.test.ts b/__tests__/components/screens/EarnScreen/earnSwapDestination.test.ts index 9550ccef6..026ffa30e 100644 --- a/__tests__/components/screens/EarnScreen/earnSwapDestination.test.ts +++ b/__tests__/components/screens/EarnScreen/earnSwapDestination.test.ts @@ -1,5 +1,7 @@ import { buildEarnSwapDestination } from "components/screens/EarnScreen/helpers"; import { EarnTokenOption } from "components/screens/EarnScreen/hooks/useEarnTokens"; +import { BLEND_FIXED_POOL_IDS } from "config/blend"; +import { NETWORKS } from "config/constants"; const makeOption = (over: Partial = {}): EarnTokenOption => ({ assetId: "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75", @@ -7,7 +9,7 @@ const makeOption = (over: Partial = {}): EarnTokenOption => ({ decimals: 7, total: "0", apy: 0.0651, - poolId: "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD", + poolId: BLEND_FIXED_POOL_IDS[NETWORKS.PUBLIC]!, canonicalId: "USDC:GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN", isNative: false, balance: undefined, diff --git a/__tests__/components/screens/EarnScreen/helpers.test.ts b/__tests__/components/screens/EarnScreen/helpers.test.ts index b00d99006..40f8c3ce5 100644 --- a/__tests__/components/screens/EarnScreen/helpers.test.ts +++ b/__tests__/components/screens/EarnScreen/helpers.test.ts @@ -8,7 +8,6 @@ import { getEarnCtaState, getNotEnoughVariant, getPercentageDepositAmount, - getPoolDescriptionKey, getXlmFeeShortfall, hasSwappableBalance, isInsufficientBalanceFailure, @@ -272,20 +271,6 @@ describe("formatRate", () => { }); }); -describe("getPoolDescriptionKey", () => { - it("keys prose by pool id, because the copy makes pool-specific claims", () => { - expect( - getPoolDescriptionKey( - "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD", - ), - ).toBe("earnPoolDetails.descriptions.fixedPool"); - }); - - it("renders nothing rather than something wrong for an unknown pool", () => { - expect(getPoolDescriptionKey("CUNKNOWN")).toBeNull(); - }); -}); - describe("isOnrampableAsset", () => { const mainnet = { network: NETWORKS.PUBLIC } as never; const testnet = { network: NETWORKS.TESTNET } as never; diff --git a/__tests__/components/screens/EarnScreen/useEarnTokens.test.ts b/__tests__/components/screens/EarnScreen/useEarnTokens.test.ts index fdff42041..64e8379b9 100644 --- a/__tests__/components/screens/EarnScreen/useEarnTokens.test.ts +++ b/__tests__/components/screens/EarnScreen/useEarnTokens.test.ts @@ -3,9 +3,10 @@ import { buildEarnTokenRows, headlineApy, } from "components/screens/EarnScreen/hooks/useEarnTokens"; +import { BLEND_FIXED_POOL_IDS } from "config/blend"; import { NETWORKS } from "config/constants"; -const POOL_ID = "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD"; +const POOL_ID = BLEND_FIXED_POOL_IDS[NETWORKS.PUBLIC]!; const USDC_SAC = "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75"; describe("headlineApy", () => { diff --git a/__tests__/config/blend.test.ts b/__tests__/config/blend.test.ts index 5d24643fc..5d2b1499f 100644 --- a/__tests__/config/blend.test.ts +++ b/__tests__/config/blend.test.ts @@ -11,10 +11,10 @@ const details = (network: NETWORKS) => describe("blend config", () => { it("maps PUBLIC and TESTNET to their Fixed pool ids", () => { expect(getBlendPoolId(details(NETWORKS.PUBLIC))).toBe( - "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD", + "CBAKAZUEJBAFA2DCGBRS2WKJT3FNIMJBHTFL76AY7XMAVEINQNNIQWAO", ); expect(getBlendPoolId(details(NETWORKS.TESTNET))).toBe( - "CCEBVDYM32YNYCVNRXQKDFFPISJJCV557CDZEIRBEE4NCV4KHPQ44HGF", + "CB3447A446DY3USGWPIDXTNQHF5EX24XDQA2EN6TAZP62JTPBTNOA52Q", ); }); diff --git a/__tests__/ducks/earn.test.ts b/__tests__/ducks/earn.test.ts index 8da1836cf..08788d434 100644 --- a/__tests__/ducks/earn.test.ts +++ b/__tests__/ducks/earn.test.ts @@ -1,6 +1,8 @@ +import { BLEND_FIXED_POOL_IDS } from "config/blend"; +import { NETWORKS } from "config/constants"; import { useEarnStore } from "ducks/earn"; -const POOL_ID = "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD"; +const POOL_ID = BLEND_FIXED_POOL_IDS[NETWORKS.PUBLIC]!; const USDC_SAC = "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75"; beforeEach(() => useEarnStore.getState().resetEarn()); diff --git a/__tests__/helpers/blend.test.ts b/__tests__/helpers/blend.test.ts index bbdc7c300..f3760dd30 100644 --- a/__tests__/helpers/blend.test.ts +++ b/__tests__/helpers/blend.test.ts @@ -1,11 +1,13 @@ import { Address, xdr } from "@stellar/stellar-sdk"; +import { BLEND_FIXED_POOL_IDS } from "config/blend"; +import { NETWORKS } from "config/constants"; import { BlendRequestType, buildBlendRequestScVal, buildBlendSubmitOp, } from "helpers/blend"; -const POOL_ID = "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD"; +const POOL_ID = BLEND_FIXED_POOL_IDS[NETWORKS.PUBLIC]!; const USDC_SAC = "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75"; const PUBLIC_KEY = "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H"; diff --git a/__tests__/services/blend.test.ts b/__tests__/services/blend.test.ts index 8372e5a2b..d077ba0b7 100644 --- a/__tests__/services/blend.test.ts +++ b/__tests__/services/blend.test.ts @@ -1,3 +1,4 @@ +import { BLEND_FIXED_POOL_IDS } from "config/blend"; import { NETWORKS } from "config/constants"; import { freighterBackendV2 } from "services/backend"; import { @@ -13,7 +14,7 @@ jest.mock("services/backend", () => ({ const mockGet = freighterBackendV2.get as jest.Mock; const mockPost = freighterBackendV2.post as jest.Mock; const networkDetails = { network: NETWORKS.PUBLIC } as never; -const POOL_ID = "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD"; +const POOL_ID = BLEND_FIXED_POOL_IDS[NETWORKS.PUBLIC]!; const USDC_SAC = "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75"; beforeEach(() => jest.clearAllMocks()); diff --git a/__tests__/services/transactionService.test.ts b/__tests__/services/transactionService.test.ts index afa59249e..adc20dd26 100644 --- a/__tests__/services/transactionService.test.ts +++ b/__tests__/services/transactionService.test.ts @@ -16,6 +16,7 @@ import { MUXED_CONTRACT_ID_1234, } from "__tests__/support/muxedContractFixtures"; import { BigNumber } from "bignumber.js"; +import { BLEND_FIXED_POOL_IDS } from "config/blend"; import { NETWORKS, mapNetworkToNetworkDetails } from "config/constants"; import { TokenTypeWithCustomToken, type PricedBalance } from "config/types"; import { BlendRequestType } from "helpers/blend"; @@ -955,8 +956,7 @@ describe("buildSwapTransaction — includeTrustline", () => { describe("buildBlendDepositTransaction", () => { const USDC_SAC = "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75"; const SENDER = "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H"; - // Mirrors config/blend.ts BLEND_FIXED_POOL_IDS[NETWORKS.PUBLIC]. - const POOL_ID = "CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD"; + const POOL_ID = BLEND_FIXED_POOL_IDS[NETWORKS.PUBLIC]!; const mockPreparedXdr = "mock_prepared_blend_xdr"; const baseParams = { diff --git a/src/components/screens/EarnScreen/components/PoolDetailsBottomSheet.tsx b/src/components/screens/EarnScreen/components/PoolDetailsBottomSheet.tsx index 53d5f417b..ce8b582e4 100644 --- a/src/components/screens/EarnScreen/components/PoolDetailsBottomSheet.tsx +++ b/src/components/screens/EarnScreen/components/PoolDetailsBottomSheet.tsx @@ -5,7 +5,6 @@ import { TokenIcon } from "components/TokenIcon"; import { formatCompactUsd, formatRate, - getPoolDescriptionKey, } from "components/screens/EarnScreen/helpers"; import { Button } from "components/sds/Button"; import { Text } from "components/sds/Typography"; @@ -128,15 +127,6 @@ export const PoolDetailsBottomSheet: React.FC = ({ return null; } - // getPoolDescriptionKey's return is deliberately widened to `string | null` - // (Task 7) so the pool-id map can grow without narrowing callers — but that - // means it can't type-check against i18next's literal key union the way a - // hardcoded key can. The cast below is scoped to exactly this one dynamic - // lookup; every other `t(...)` call in this file uses a literal key and is - // fully type-checked. - const descriptionKey = getPoolDescriptionKey(pool.id); - const description = descriptionKey ? t(descriptionKey as never) : null; - const nativeContractId = getNativeContractDetails(network).contract; const visibleReserves = pool.reserves.slice(0, MAX_VISIBLE_RESERVE_ICONS); const overflowReserveCount = pool.reserves.length - visibleReserves.length; @@ -274,16 +264,17 @@ export const PoolDetailsBottomSheet: React.FC = ({ /> - {description && ( - - - {t("earnPoolDetails.description")} - - - {description} - - - )} + {/* One description for every pool, as on the extension. Per-pool + claims only hold for a specific deployment: "no admin" was true of + the v2 Fixed pool, but the v2.1 pools have a contract admin. */} + + + {t("earnPoolDetails.description")} + + + {t("earnPoolDetails.poolDescription")} + + diff --git a/src/components/screens/EarnScreen/helpers/index.ts b/src/components/screens/EarnScreen/helpers/index.ts index 5c6ae04fe..8748bbc48 100644 --- a/src/components/screens/EarnScreen/helpers/index.ts +++ b/src/components/screens/EarnScreen/helpers/index.ts @@ -3,5 +3,4 @@ export * from "components/screens/EarnScreen/helpers/earnCtaState"; export * from "components/screens/EarnScreen/helpers/earnSwapDestination"; export * from "components/screens/EarnScreen/helpers/formatPoolStats"; export * from "components/screens/EarnScreen/helpers/getNotEnoughVariant"; -export * from "components/screens/EarnScreen/helpers/poolDescriptions"; export * from "components/screens/EarnScreen/helpers/projectEarnings"; diff --git a/src/components/screens/EarnScreen/helpers/poolDescriptions.ts b/src/components/screens/EarnScreen/helpers/poolDescriptions.ts deleted file mode 100644 index ce7cba6ee..000000000 --- a/src/components/screens/EarnScreen/helpers/poolDescriptions.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * 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. - * - * Returns an i18n KEY, not translated prose — copy lives in the translation - * files like everywhere else in this app (see `src/i18n/locales/*`). The key - * below (`earnPoolDetails.descriptions.fixedPool`) exists in both `en` and - * `pt`. - */ -const POOL_DESCRIPTION_KEYS: Record = { - // Fixed Pool v2 — mainnet - CAJJZSGMMM3PD7N33TAPHGBUGTB43OC73HVIK2L2G6BNGGGYOSSYBXBD: - "earnPoolDetails.descriptions.fixedPool", - // Fixed Pool v2 — testnet - CCEBVDYM32YNYCVNRXQKDFFPISJJCV557CDZEIRBEE4NCV4KHPQ44HGF: - "earnPoolDetails.descriptions.fixedPool", -}; - -export const getPoolDescriptionKey = (poolId: string): string | null => - POOL_DESCRIPTION_KEYS[poolId] ?? null; diff --git a/src/config/blend.ts b/src/config/blend.ts index 50d1f2929..1c52c7da0 100644 --- a/src/config/blend.ts +++ b/src/config/blend.ts @@ -1,16 +1,21 @@ import { NETWORKS, NetworkDetails } from "config/constants"; /** - * 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. + * The Blend v2.1 Fixed pool per network: "Fixed v2.1" on mainnet and + * "Testnet v2.1" on testnet. They run the same pool contract code as the v2 + * pools they replace, so the request encoding and the `submit` call are + * unchanged. + * + * 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.PUBLIC]: "CBAKAZUEJBAFA2DCGBRS2WKJT3FNIMJBHTFL76AY7XMAVEINQNNIQWAO", [NETWORKS.TESTNET]: - "CCEBVDYM32YNYCVNRXQKDFFPISJJCV557CDZEIRBEE4NCV4KHPQ44HGF", + "CB3447A446DY3USGWPIDXTNQHF5EX24XDQA2EN6TAZP62JTPBTNOA52Q", }; export const getBlendPoolId = (networkDetails: NetworkDetails) => diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index 83c78f5d7..a04a9c3e7 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -816,9 +816,7 @@ "supplied": "Supplied", "borrowed": "Borrowed", "backstop": "Backstop", - "descriptions": { - "fixedPool": "Permissionless lending pool with no admin. Collateral factors, interest curves, and supported assets are locked at deployment." - } + "poolDescription": "Deposit supported assets into this Blend pool to earn yield. APY may change over time. Withdraw anytime." }, "earnAmount": { "title": "Deposit", diff --git a/src/i18n/locales/pt/translations.json b/src/i18n/locales/pt/translations.json index a7a4a9e8f..488142c46 100644 --- a/src/i18n/locales/pt/translations.json +++ b/src/i18n/locales/pt/translations.json @@ -782,9 +782,7 @@ "supplied": "Fornecido", "borrowed": "Emprestado", "backstop": "Backstop", - "descriptions": { - "fixedPool": "Pool de empréstimo sem permissão e sem administrador. Os fatores de garantia, as curvas de juros e os ativos suportados são fixados no momento da implantação." - } + "poolDescription": "Deposite ativos compatíveis neste pool da Blend para gerar rendimento. A APY pode mudar ao longo do tempo. Saque quando quiser." }, "earnAmount": { "title": "Depositar",