diff --git a/__tests__/components/AccountQrDisplay.test.tsx b/__tests__/components/AccountQrDisplay.test.tsx new file mode 100644 index 000000000..71291c7bb --- /dev/null +++ b/__tests__/components/AccountQrDisplay.test.tsx @@ -0,0 +1,37 @@ +import { AccountQrDisplay } from "components/AccountQrDisplay"; +import { renderWithProviders } from "helpers/testUtils"; +import React from "react"; + +jest.mock("react-native-qrcode-svg"); + +jest.mock("hooks/useAppTranslation", () => () => ({ + t: (key: string) => { + const translations: Record = { + "scanReceiveScreen.receive.network": "Stellar", + }; + return translations[key] || key; + }, +})); + +const publicKey = "GACJYENHYW2LGHBNNGNZ4NCBGZYVTGTZM4CJLQIOQQ5IUZU3SYWOW5EK"; + +describe("AccountQrDisplay", () => { + it("renders the account name, truncated address, and the network pill", () => { + const { getByText } = renderWithProviders( + , + ); + + expect(getByText("Account 1")).toBeTruthy(); + expect(getByText("GACJ...W5EK")).toBeTruthy(); + expect(getByText("Stellar")).toBeTruthy(); + }); + + it("renders an empty name line when no account name is provided", () => { + const { getByText, queryByText } = renderWithProviders( + , + ); + + expect(getByText("GACJ...W5EK")).toBeTruthy(); + expect(queryByText("undefined")).toBeNull(); + }); +}); diff --git a/__tests__/components/screens/EarnScreen/EarnAmountScreen.test.tsx b/__tests__/components/screens/EarnScreen/EarnAmountScreen.test.tsx new file mode 100644 index 000000000..e4a79007e --- /dev/null +++ b/__tests__/components/screens/EarnScreen/EarnAmountScreen.test.tsx @@ -0,0 +1,705 @@ +/* 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 { 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"; +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 { mockUseColors } from "../../../../__mocks__/use-colors"; + +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"; +let mockEarnTransactionHash: string | null = null; + +// 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: mockSimulateError, + scanResult: undefined, + }), +})); + +jest.mock("components/screens/EarnScreen/hooks/useEarnTransaction", () => ({ + useEarnTransaction: () => ({ + status: mockEarnTransactionStatus, + transactionHash: mockEarnTransactionHash, + 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 = BLEND_FIXED_POOL_IDS[NETWORKS.PUBLIC]!; + +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"; + mockEarnTransactionHash = null; + mockSimulateError = null; + 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(); + }); + + 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", () => { + 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(); + + 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 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.1", xlm: "0.1" }); + 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("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({ + 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 while status is submitting", () => { + mockEarnTransactionStatus = "submitting"; + + const { getByTestId, queryByTestId } = renderScreen(); + + expect(getByTestId("earn-processing-mock")).toBeTruthy(); + expect(queryByTestId("earn-amount-screen")).toBeNull(); + }); + + it("renders the processing screen inline while status is success", () => { + mockEarnTransactionStatus = "success"; + + 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(); + }); + + // Design node `9599:40192` has no dedicated failure screen: on failure + // the user lands directly back on the amount screen, where the retry + // banner (driven by `lastSubmitFailed`, not by this local status) takes + // over. `handleEarnProcessingBackToAmount` used to be wired to a button + // on the now-removed failure screen; it is called automatically instead. + it("renders the normal amount screen (not the processing screen) when status is error, and resets the status automatically", () => { + mockEarnTransactionStatus = "error"; + + const { getByTestId, queryByTestId } = renderScreen(); + + expect(getByTestId("earn-amount-screen")).toBeTruthy(); + expect(queryByTestId("earn-processing-mock")).toBeNull(); + expect(mockResetEarnTransactionStatus).toHaveBeenCalledTimes(1); + }); + }); + + 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/EarnReviewBottomSheet.test.tsx b/__tests__/components/screens/EarnScreen/EarnReviewBottomSheet.test.tsx new file mode 100644 index 000000000..845e27416 --- /dev/null +++ b/__tests__/components/screens/EarnScreen/EarnReviewBottomSheet.test.tsx @@ -0,0 +1,256 @@ +/* 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 { mockUseColors } from "../../../../__mocks__/use-colors"; + +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, + }); + }); + + // The fee no longer renders on the face of the sheet (design `9448:29319`, + // correction R7) -- it lives behind the "Transaction details" row, which + // this test checks for instead of a face-visible fee value. + it("renders the pool name and APY, with fee behind Transaction details", () => { + const { getByTestId } = renderWithProviders( + , + ); + + expect(getByTestId("earn-review-pool").props.children).toBe("Fixed Pool"); + expect(getByTestId("earn-review-apy").props.children).toBe("16.94%"); + expect(getByTestId("earn-review-transaction-details")).toBeTruthy(); + }); + + 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("projects before/after earnings off the total position, not the deposit alone", () => { + // 500 USDC already supplied (price $2) plus a 10 USDC top-up (also + // price $2) at 16.94% APY: before = $1000 at the rate, after = $1020. + // If this instead projected off the bare $20 deposit, "after" would + // read far lower than "before" -- earnings appearing to drop after + // depositing more would be actively misleading. + useEarnStore.getState().setCurrentPositionTokens("5000000000"); + + const { getByTestId } = renderWithProviders( + , + ); + + expect(getByTestId("earn-review-monthly").props.children).toBe( + "$14.12 → $14.40", + ); + expect(getByTestId("earn-review-yearly").props.children).toBe( + "$169.40 → $172.79", + ); + }); + + 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/EarnTokenPickerScreen.test.tsx b/__tests__/components/screens/EarnScreen/EarnTokenPickerScreen.test.tsx new file mode 100644 index 000000000..df2d7ab3d --- /dev/null +++ b/__tests__/components/screens/EarnScreen/EarnTokenPickerScreen.test.tsx @@ -0,0 +1,252 @@ +/* eslint-disable @fnando/consistent-import/consistent-import */ +import { NativeStackScreenProps } from "@react-navigation/native-stack"; +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 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 +// 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 NotEnoughTokenBottomSheet / + // ReceiveFundsBottomSheet 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(), + }), +})); + +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 +>; + +// 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 = () => + ({ + key: "earn-token-picker", + name: EARN_ROUTES.EARN_TOKEN_PICKER_SCREEN, + params: undefined, + }) as unknown as Props["route"]; + +const renderScreen = () => + renderWithProviders( + , + ); + +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, + held: [], + supported: [], + pool: null, + refetch: jest.fn(), + }; + }); + + describe("first-entry presentation", () => { + it("renders the intro instead of the picker when the flag is unset", () => { + usePreferencesStore.getState().setHasSeenEarnIntro(false); + + const { getByTestId } = renderScreen(); + + expect(getByTestId("earn-intro-screen")).toBeTruthy(); + }); + + it("renders the picker, not the intro, when the flag is already set", () => { + usePreferencesStore.getState().setHasSeenEarnIntro(true); + + const { queryByTestId } = renderScreen(); + + expect(queryByTestId("earn-intro-screen")).toBeNull(); + }); + + // 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; + + const { getByTestId, queryByTestId } = renderScreen(); + + expect(getByTestId("earn-intro-screen")).toBeTruthy(); + expect(queryByTestId("earn-token-picker-spinner")).toBeNull(); + }); + + it("shows the intro even when the token fetch errored", () => { + usePreferencesStore.getState().setHasSeenEarnIntro(false); + mockUseEarnTokens.error = "boom"; + + const { getByTestId } = renderScreen(); + + expect(getByTestId("earn-intro-screen")).toBeTruthy(); + }); + }); + + describe("intro dismissal", () => { + beforeEach(() => { + usePreferencesStore.getState().setHasSeenEarnIntro(false); + }); + + it("Continue marks the intro seen and reveals the picker", () => { + const { getByTestId, queryByTestId } = renderScreen(); + + expect(usePreferencesStore.getState().hasSeenEarnIntro).toBe(false); + + act(() => { + 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("close marks the intro seen and leaves the Earn flow", () => { + const { getByTestId } = renderScreen(); + + act(() => { + 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/NotEnoughTokenBottomSheet.test.tsx b/__tests__/components/screens/EarnScreen/components/NotEnoughTokenBottomSheet.test.tsx new file mode 100644 index 000000000..aac18d71b --- /dev/null +++ b/__tests__/components/screens/EarnScreen/components/NotEnoughTokenBottomSheet.test.tsx @@ -0,0 +1,175 @@ +import { fireEvent } from "@testing-library/react-native"; +import { NotEnoughTokenBottomSheet } from "components/screens/EarnScreen/components/NotEnoughTokenBottomSheet"; +import { NotEnoughVariant } from "components/screens/EarnScreen/helpers"; +import { renderWithProviders } from "helpers/testUtils"; +import React from "react"; + +const usdcToken = { + code: "USDC", + issuer: { key: "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN" }, +}; + +describe("NotEnoughTokenBottomSheet", () => { + it("renders nothing when no token is resolved yet", () => { + const { queryByTestId } = renderWithProviders( + , + ); + expect(queryByTestId("not-enough-token-bottom-sheet")).toBeNull(); + }); + + it("renders the shared title for every variant", () => { + const { getByText } = renderWithProviders( + , + ); + expect(getByText("Not enough USDC")).toBeTruthy(); + }); + + it("closes via the close control", () => { + const onClose = jest.fn(); + const { getByTestId } = renderWithProviders( + , + ); + fireEvent.press(getByTestId("not-enough-token-close")); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + 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(); + const { getByTestId, getByText, queryByTestId } = renderWithProviders( + , + ); + + // 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(); + + fireEvent.press(getByTestId("not-enough-token-buy-button")); + fireEvent.press(getByTestId("not-enough-token-swap-button")); + fireEvent.press(getByTestId("not-enough-token-receive-link")); + + expect(onBuy).toHaveBeenCalledTimes(1); + expect(onSwap).toHaveBeenCalledTimes(1); + expect(onReceive).toHaveBeenCalledTimes(1); + }); + }); + + 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( + , + ); + + expect(getByText("Buy with Coinbase")).toBeTruthy(); + expect(getByText("Receive USDC")).toBeTruthy(); + expect(queryByText("Buy USDC")).toBeNull(); + + fireEvent.press(getByTestId("not-enough-token-buy-button")); + fireEvent.press(getByTestId("not-enough-token-receive-button")); + + expect(onBuy).toHaveBeenCalledTimes(1); + expect(onReceive).toHaveBeenCalledTimes(1); + }); + }); + + 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( + , + ); + + expect(getByText("Swap for EURC")).toBeTruthy(); + expect(getByText("Receive EURC")).toBeTruthy(); + + fireEvent.press(getByTestId("not-enough-token-swap-button")); + fireEvent.press(getByTestId("not-enough-token-receive-button")); + + expect(onSwap).toHaveBeenCalledTimes(1); + expect(onReceive).toHaveBeenCalledTimes(1); + }); + }); + + 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("Receive EURC")).toBeTruthy(); + expect(queryByTestId("not-enough-token-buy-button")).toBeNull(); + expect(queryByTestId("not-enough-token-swap-button")).toBeNull(); + + fireEvent.press(getByTestId("not-enough-token-receive-button")); + expect(onReceive).toHaveBeenCalledTimes(1); + }); + }); +}); 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..7a6c693c8 --- /dev/null +++ b/__tests__/components/screens/EarnScreen/components/PoolDetailsBottomSheet.test.tsx @@ -0,0 +1,218 @@ +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"; + +const buildReserve = ( + assetId: string, + symbol: string, +): BlendCatalogReserve => ({ + assetId, + symbol, + name: symbol, + decimals: 7, + enabled: true, + utilization: null, + supplyApy: null, + borrowApy: null, + emissionsSupplyApr: null, + suppliedUsd: null, + borrowedUsd: null, + priceUsd: null, +}); + +// 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 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: BLEND_FIXED_POOL_IDS[NETWORKS.TESTNET]!, + name: "Testnet v2.1", + 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-supplied")).toHaveTextContent("--"); + // A genuine zero must never collapse into the same "--" as null. + expect(getByTestId("pool-details-borrowed")).toHaveTextContent("$0.00"); + // Backstop: the backend serves no `backstop_usd` for any pool today, so + // this is null (not omitted) for the same "unavailable" reason as the + // other three -- extends this regression to the new row. + expect(getByTestId("pool-details-backstop")).toHaveTextContent("--"); + }); + + 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("Testnet v2.1")).toBeTruthy(); + expect(getByTestId("pool-details-lendingInterest")).toHaveTextContent( + "392.00%", + ); + expect(getByTestId("pool-details-currentNetApy")).toHaveTextContent( + "392.00%", + ); + expect(getByTestId("pool-details-supplied")).toHaveTextContent("$40.39M"); + expect(getByTestId("pool-details-borrowed")).toHaveTextContent("$35.72M"); + }); + + it("renders the pool's own name directly in the header, and a separate 'Pool Details' eyebrow above the stat cards", () => { + // Updated for the design correction (`9448:18518`): the header itself + // still has no eyebrow stacked directly above the pool name -- but + // unlike before, the sheet now has a "Pool Details" eyebrow of its own, + // positioned above the two stat cards rather than the header. + const { getByText } = renderWithProviders( + , + ); + expect(getByText("Testnet v2.1").props.children).toBe("Testnet v2.1"); + expect(getByText("Pool Details")).toBeTruthy(); + expect(getByText("by Blend")).toBeTruthy(); + }); + + it("makes no per-pool claims, such as 'no admin', that a new deployment can falsify", () => { + const { getByTestId } = renderWithProviders( + , + ); + expect(getByTestId("pool-details-description")).not.toHaveTextContent( + /no admin/i, + ); + }); + + it("renders the Backstop row structurally, reading '--' because the backend doesn't serve that figure yet", () => { + // Design decision (overriding this file's earlier omission): the row + // always renders, matching the design's structure and this app's + // "null means unavailable, never silently omitted" rule. It happens to + // read "--" for every live pool today because the backend serves no + // `backstop_usd` field yet (`fixedPool.backstopUsd` is null here) -- + // and will start showing a real figure automatically once it does. + const { getByText, getByTestId } = renderWithProviders( + , + ); + expect(getByText("Backstop")).toBeTruthy(); + expect(getByTestId("pool-details-backstop")).toHaveTextContent("--"); + }); + + it("renders '--' for Accepted tokens when the reserve list is empty", () => { + const { getByTestId } = renderWithProviders( + , + ); + expect(getByTestId("pool-details-acceptedTokens")).toHaveTextContent("--"); + }); + + it("renders an icon stack (no '+N' trailer) for a reserve list at or under the visible cap", () => { + const poolWithReserves: BlendCatalogPool = { + ...fixedPool, + reserves: [ + buildReserve("CUSDC...", "USDC"), + buildReserve("CEURC...", "EURC"), + ], + }; + const { getByTestId, queryByTestId } = renderWithProviders( + , + ); + expect(getByTestId("pool-details-acceptedTokens")).toBeTruthy(); + expect(queryByTestId("pool-details-acceptedTokens-overflow")).toBeNull(); + }); + + it("collapses reserves beyond the visible cap into a '+N' trailer", () => { + const poolWithManyReserves: BlendCatalogPool = { + ...fixedPool, + reserves: [ + buildReserve("C1...", "AAA"), + buildReserve("C2...", "BBB"), + buildReserve("C3...", "CCC"), + buildReserve("C4...", "DDD"), + buildReserve("C5...", "EEE"), + buildReserve("C6...", "FFF"), + ], + }; + const { getByTestId } = renderWithProviders( + , + ); + // 6 reserves, 4 visible -> "+2". + expect( + getByTestId("pool-details-acceptedTokens-overflow"), + ).toHaveTextContent("+2"); + }); + + 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(); + }); + + it("the bottom 'Close' CTA also 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-cta")); + expect(dismissMock).toHaveBeenCalled(); + }); +}); diff --git a/__tests__/components/screens/EarnScreen/components/ReceiveFundsBottomSheet.test.tsx b/__tests__/components/screens/EarnScreen/components/ReceiveFundsBottomSheet.test.tsx new file mode 100644 index 000000000..7b367f2d3 --- /dev/null +++ b/__tests__/components/screens/EarnScreen/components/ReceiveFundsBottomSheet.test.tsx @@ -0,0 +1,83 @@ +import { BottomSheetModal } from "@gorhom/bottom-sheet"; +import { fireEvent } from "@testing-library/react-native"; +import { ReceiveFundsBottomSheet } from "components/screens/EarnScreen/components/ReceiveFundsBottomSheet"; +import { renderWithProviders } from "helpers/testUtils"; +import React from "react"; + +jest.mock("react-native-qrcode-svg"); + +const mockAccount = { + publicKey: "GACJYENHYW2LGHBNNGNZ4NCBGZYVTGTZM4CJLQIOQQ5IUZU3SYWOW5EK", + accountName: "Account 1", +}; + +jest.mock("hooks/useGetActiveAccount", () => ({ + __esModule: true, + default: () => ({ account: mockAccount }), +})); + +const mockCopyToClipboard = jest.fn(); +jest.mock("hooks/useClipboard", () => ({ + useClipboard: () => ({ + copyToClipboard: mockCopyToClipboard, + getClipboardText: jest.fn(), + }), +})); + +jest.mock("hooks/useAppTranslation", () => () => ({ + t: (key: string) => { + const translations: Record = { + "earnReceiveFunds.title": "Receive funds", + "scanReceiveScreen.receive.network": "Stellar", + "scanReceiveScreen.receive.networkSupport": + "This address supports Stellar network.", + "scanReceiveScreen.receive.copyButton": "Copy wallet address", + }; + return translations[key] || key; + }, +})); + +const makeRef = (dismissMock: jest.Mock) => { + const ref = React.createRef(); + Object.defineProperty(ref, "current", { + value: { dismiss: dismissMock }, + writable: true, + }); + return ref; +}; + +describe("ReceiveFundsBottomSheet", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("renders the design's copy (node 9457:46184): title, account identity, and the copy CTA", () => { + const { getByText } = renderWithProviders(); + + expect(getByText("Receive funds")).toBeTruthy(); + expect(getByText("Account 1")).toBeTruthy(); + expect(getByText("GACJ...W5EK")).toBeTruthy(); + expect(getByText("Stellar")).toBeTruthy(); + expect(getByText("This address supports Stellar network.")).toBeTruthy(); + expect(getByText("Copy wallet address")).toBeTruthy(); + }); + + it("copies the public key through the plain clipboard path (same as ReceiveTabView) when the CTA is pressed", () => { + const { getByTestId } = renderWithProviders(); + + fireEvent.press(getByTestId("receive-funds-copy-button")); + + expect(mockCopyToClipboard).toHaveBeenCalledWith(mockAccount.publicKey); + }); + + it("dismisses via the forwarded ref when the close control is pressed", () => { + const dismissMock = jest.fn(); + const ref = makeRef(dismissMock); + const { getByTestId } = renderWithProviders( + , + ); + + fireEvent.press(getByTestId("receive-funds-close")); + expect(dismissMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/__tests__/components/screens/EarnScreen/components/XlmFeeShortfallBottomSheet.test.tsx b/__tests__/components/screens/EarnScreen/components/XlmFeeShortfallBottomSheet.test.tsx new file mode 100644 index 000000000..87eb40463 --- /dev/null +++ b/__tests__/components/screens/EarnScreen/components/XlmFeeShortfallBottomSheet.test.tsx @@ -0,0 +1,62 @@ +import { BottomSheetModal } from "@gorhom/bottom-sheet"; +import { fireEvent } from "@testing-library/react-native"; +import { XlmFeeShortfallBottomSheet } from "components/screens/EarnScreen/components/XlmFeeShortfallBottomSheet"; +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("XlmFeeShortfallBottomSheet", () => { + it("renders the design's copy (node 13722:341905): title, one-line body, and both action labels", () => { + const { getByText } = renderWithProviders( + , + ); + + 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("Receive XLM")).toBeTruthy(); + }); + + it("calls onBuy when 'Buy with Coinbase' is pressed", () => { + const onBuy = jest.fn(); + const { getByTestId } = renderWithProviders( + , + ); + + fireEvent.press(getByTestId("xlm-fee-shortfall-buy-button")); + expect(onBuy).toHaveBeenCalledTimes(1); + }); + + it("calls onReceive when 'Transfer from another account' is pressed", () => { + const onReceive = jest.fn(); + const { getByTestId } = renderWithProviders( + , + ); + + fireEvent.press(getByTestId("xlm-fee-shortfall-receive-button")); + expect(onReceive).toHaveBeenCalledTimes(1); + }); + + it("dismisses via the forwarded ref when the close control is pressed", () => { + const dismissMock = jest.fn(); + const ref = makeRef(dismissMock); + const { getByTestId } = renderWithProviders( + , + ); + + fireEvent.press(getByTestId("xlm-fee-shortfall-close")); + expect(dismissMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/__tests__/components/screens/EarnScreen/earnSwapDestination.test.ts b/__tests__/components/screens/EarnScreen/earnSwapDestination.test.ts new file mode 100644 index 000000000..026ffa30e --- /dev/null +++ b/__tests__/components/screens/EarnScreen/earnSwapDestination.test.ts @@ -0,0 +1,83 @@ +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", + code: "USDC", + decimals: 7, + total: "0", + apy: 0.0651, + poolId: BLEND_FIXED_POOL_IDS[NETWORKS.PUBLIC]!, + 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/__tests__/components/screens/EarnScreen/helpers.test.ts b/__tests__/components/screens/EarnScreen/helpers.test.ts new file mode 100644 index 000000000..40f8c3ce5 --- /dev/null +++ b/__tests__/components/screens/EarnScreen/helpers.test.ts @@ -0,0 +1,379 @@ +import BigNumber from "bignumber.js"; +import { + NotEnoughVariant, + UNKNOWN_RESOURCE_FEE_FLOOR_XLM, + formatCompactUsd, + formatProjection, + formatRate, + getEarnCtaState, + getNotEnoughVariant, + getPercentageDepositAmount, + getXlmFeeShortfall, + hasSwappableBalance, + isInsufficientBalanceFailure, + isOnrampableAsset, + needsXlmForFee, + projectCurrentEarnings, + 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("getXlmFeeShortfall", () => { + it("is zero when the deposit leaves more than the resource fee", () => { + expect( + getXlmFeeShortfall({ + spendableXlm: "100", + amount: "50", + resourceFee: "0.0546395", + }), + ).toBe("0"); + }); + + it("reports the shortfall when the whole spendable balance is deposited", () => { + // The full balance is depositable — nothing is held back — so the entire + // resource fee is missing. + expect( + getXlmFeeShortfall({ + spendableXlm: "100", + amount: "100", + resourceFee: "0.0546395", + }), + ).toBe("0.0546395"); + }); + + it("reports only the part of the fee that is not covered", () => { + expect( + getXlmFeeShortfall({ + spendableXlm: "100", + amount: "99.99", + resourceFee: "0.0546395", + }), + ).toBe("0.0446395"); + }); + + it("is zero when the remainder exactly covers the fee", () => { + expect( + getXlmFeeShortfall({ + spendableXlm: "100", + amount: "99.9453605", + resourceFee: "0.0546395", + }), + ).toBe("0"); + }); + + it("does not lose precision on a large balance", () => { + expect( + getXlmFeeShortfall({ + spendableXlm: "1691.6912345", + amount: "1691.6912345", + resourceFee: "0.0546395", + }), + ).toBe("0.0546395"); + }); +}); + +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( + isInsufficientBalanceFailure( + "host invocation failed: HostError: Error(Contract, #10)", + ), + ).toBe(true); + }); + + it("matches a classic insufficient-balance result code", () => { + expect(isInsufficientBalanceFailure("tx_insufficient_balance")).toBe(true); + expect(isInsufficientBalanceFailure("txINSUFFICIENT_BALANCE")).toBe(true); + }); + + it("leaves the pool's own rejections alone", () => { + // Supply cap, frozen pool, stale oracle — these must keep surfacing their + // own message rather than being retold as a fee problem. + expect( + isInsufficientBalanceFailure("HostError: Error(Contract, #1206)"), + ).toBe(false); + expect(isInsufficientBalanceFailure("pool is frozen")).toBe(false); + }); +}); + +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("projectCurrentEarnings", () => { + it("mirrors projectEarnings' simple-interest math for the position's current USD value", () => { + expect( + projectCurrentEarnings({ currentPositionUsd: "1200", apy: 0.1 }), + ).toEqual({ yearly: "120.00", monthly: "10.00" }); + }); + + it("is zero, not null, for a genuinely zero position at a known rate", () => { + // A brand-new position (nothing held yet) is a real, known zero -- not + // "unknown" -- so it must render 0.00, never --. + expect( + projectCurrentEarnings({ currentPositionUsd: "0", apy: 0.1694 }), + ).toEqual({ yearly: "0.00", monthly: "0.00" }); + }); + + it("returns nulls when the rate is unavailable", () => { + expect( + projectCurrentEarnings({ currentPositionUsd: "1200", apy: null }), + ).toEqual({ yearly: null, monthly: null }); + }); + + it("returns nulls when the position is unpriced", () => { + expect( + projectCurrentEarnings({ currentPositionUsd: null, apy: 0.1 }), + ).toEqual({ yearly: null, monthly: null }); + }); +}); + +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("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, + ); + }); + + // 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( + { "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/__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/__tests__/components/screens/EarnScreen/useEarnTokens.test.ts b/__tests__/components/screens/EarnScreen/useEarnTokens.test.ts new file mode 100644 index 000000000..64e8379b9 --- /dev/null +++ b/__tests__/components/screens/EarnScreen/useEarnTokens.test.ts @@ -0,0 +1,180 @@ +import BigNumber from "bignumber.js"; +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 = BLEND_FIXED_POOL_IDS[NETWORKS.PUBLIC]!; +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"); + // 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: [] })], + 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("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 })], + 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/__tests__/components/screens/EarnScreen/useEarnTransaction.test.ts b/__tests__/components/screens/EarnScreen/useEarnTransaction.test.ts new file mode 100644 index 000000000..d74393d56 --- /dev/null +++ b/__tests__/components/screens/EarnScreen/useEarnTransaction.test.ts @@ -0,0 +1,388 @@ +/* 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"; +import type { SubmitTransactionOutcome } from "ducks/transactionBuilder"; + +const mockSignTransaction = jest.fn(); +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( + () => ({ + 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. 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...", + accountName: "Account 1", + id: "1", + subentryCount: 0, +}; + +const baseParams: Parameters[0] = { + account, + network: NETWORKS.TESTNET, +}; + +const submitOutcome = ( + overrides: Partial, +): SubmitTransactionOutcome => ({ + hash: null, + resultXdr: null, + error: null, + resultCodes: null, + httpStatus: null, + isProtocolAnswer: false, + ...overrides, +}); + +describe("useEarnTransaction", () => { + beforeEach(() => { + 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", () => { + 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(submitOutcome({ hash: "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); + + // 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 () => { + 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 and result codes from the submit outcome, when submitTransaction resolves without a hash", async () => { + mockSignTransaction.mockReturnValue("signed-xdr"); + mockSubmitTransaction.mockResolvedValue( + submitOutcome({ + error: "op_underfunded", + resultCodes: { operations: ["op_underfunded"] }, + }), + ); + // The store holds a different, stale failure: the hook must read this + // attempt's own outcome, never the store, which a mid-submit Close resets. + mockGetBuilderState.mockReturnValue({ + error: "stale store error", + submitErrorResultCodes: { operations: ["op_stale"] }, + }); + + 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); + + // earn.deposit_failed carries the same identifiers plus reason_code -- + // sourced from this attempt's own submit outcome, 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 () => { + 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: (outcome: SubmitTransactionOutcome) => 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(submitOutcome({ hash: "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(); + // 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 () => { + 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(); + expect(mockTrackEarnDepositFail).not.toHaveBeenCalled(); + }); + + it("reset() is a no-op while status is submitting (hardening against the same bug class)", async () => { + mockSignTransaction.mockReturnValue("signed-xdr"); + let resolveSubmit: (outcome: SubmitTransactionOutcome) => 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(submitOutcome({ hash: "tx-hash" })); + await submitPromise; + }); + }); + }); +}); 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__/config/blend.test.ts b/__tests__/config/blend.test.ts new file mode 100644 index 000000000..5d2b1499f --- /dev/null +++ b/__tests__/config/blend.test.ts @@ -0,0 +1,36 @@ +import { + 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( + "CBAKAZUEJBAFA2DCGBRS2WKJT3FNIMJBHTFL76AY7XMAVEINQNNIQWAO", + ); + expect(getBlendPoolId(details(NETWORKS.TESTNET))).toBe( + "CB3447A446DY3USGWPIDXTNQHF5EX24XDQA2EN6TAZP62JTPBTNOA52Q", + ); + }); + + 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("exposes exactly the two allowlisted networks", () => { + expect(Object.keys(BLEND_FIXED_POOL_IDS).sort()).toEqual( + [NETWORKS.PUBLIC, NETWORKS.TESTNET].sort(), + ); + }); +}); diff --git a/__tests__/ducks/earn.test.ts b/__tests__/ducks/earn.test.ts new file mode 100644 index 000000000..08788d434 --- /dev/null +++ b/__tests__/ducks/earn.test.ts @@ -0,0 +1,82 @@ +import { BLEND_FIXED_POOL_IDS } from "config/blend"; +import { NETWORKS } from "config/constants"; +import { useEarnStore } from "ducks/earn"; + +const POOL_ID = BLEND_FIXED_POOL_IDS[NETWORKS.PUBLIC]!; +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); + }); + + // 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() + .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/__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/__tests__/helpers/balances.test.ts b/__tests__/helpers/balances.test.ts index b7351a364..ea1548f08 100644 --- a/__tests__/helpers/balances.test.ts +++ b/__tests__/helpers/balances.test.ts @@ -1,6 +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, @@ -18,6 +18,7 @@ import { calculateSpendableAmount, isAmountSpendable, getIssuerFromIdentifier, + getBalanceByContractId, isSacContractForAsset, } from "helpers/balances"; @@ -549,3 +550,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/__tests__/helpers/blend.test.ts b/__tests__/helpers/blend.test.ts new file mode 100644 index 000000000..f3760dd30 --- /dev/null +++ b/__tests__/helpers/blend.test.ts @@ -0,0 +1,100 @@ +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 = BLEND_FIXED_POOL_IDS[NETWORKS.PUBLIC]!; +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 = xdr.expectUnionVariant(val, "scvMap").map; + expect(entries).not.toBeNull(); + expect( + entries!.map((e) => + xdr.expectUnionVariant(e.key, "scvSymbol").sym.toString(), + ), + ).toEqual(["address", "amount", "request_type"]); + }); + + it("encodes the amount as an i128 and the request type as a u32", () => { + const entries = xdr.expectUnionVariant( + buildBlendRequestScVal({ + assetId: USDC_SAC, + amount: "5000000000", + requestType: BlendRequestType.SupplyCollateral, + }), + "scvMap", + ).map!; + + const byKey = (name: string) => + entries.find( + (e) => + xdr.expectUnionVariant(e.key, "scvSymbol").sym.toString() === name, + )!.val; + + expect(byKey("amount").type).toBe("scvI128"); + expect(byKey("request_type").type).toBe("scvU32"); + expect(xdr.expectUnionVariant(byKey("request_type"), "scvU32").u32).toBe(2); + expect(byKey("address").type).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 = xdr.expectUnionVariant( + xdr.expectUnionVariant(op.body, "invokeHostFunction").invokeHostFunctionOp + .hostFunction, + "hostFunctionTypeInvokeContract", + ).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; + 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__/navigators/EarnNavigator.test.tsx b/__tests__/navigators/EarnNavigator.test.tsx new file mode 100644 index 000000000..783dc2187 --- /dev/null +++ b/__tests__/navigators/EarnNavigator.test.tsx @@ -0,0 +1,123 @@ +/* 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 { useEarnStore } from "ducks/earn"; +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(); + useEarnStore.getState().resetEarn(); + }); + }); + + 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); + }); + + // 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/__tests__/services/blend.test.ts b/__tests__/services/blend.test.ts new file mode 100644 index 000000000..d077ba0b7 --- /dev/null +++ b/__tests__/services/blend.test.ts @@ -0,0 +1,168 @@ +import { BLEND_FIXED_POOL_IDS } from "config/blend"; +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 = BLEND_FIXED_POOL_IDS[NETWORKS.PUBLIC]!; +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/__tests__/services/transactionService.test.ts b/__tests__/services/transactionService.test.ts index fc2714d46..adc20dd26 100644 --- a/__tests__/services/transactionService.test.ts +++ b/__tests__/services/transactionService.test.ts @@ -8,6 +8,7 @@ import { Address, Transaction, Operation, + XdrLargeInt, xdr, } from "@stellar/stellar-sdk"; import { @@ -15,11 +16,14 @@ 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"; import { analytics } from "services/analytics"; import * as backend from "services/backend"; import { + buildBlendDepositTransaction, buildPaymentTransaction, buildSendCollectibleTransaction, BuildSendCollectibleParams, @@ -948,3 +952,120 @@ describe("buildSwapTransaction — includeTrustline", () => { expect(tx.fee).toBe("10000"); }); }); + +describe("buildBlendDepositTransaction", () => { + const USDC_SAC = "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75"; + const SENDER = "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H"; + const POOL_ID = BLEND_FIXED_POOL_IDS[NETWORKS.PUBLIC]!; + 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.type).toBe("hostFunctionTypeInvokeContract"); + + const invocation = xdr.expectUnionVariant( + hostFunction, + "hostFunctionTypeInvokeContract", + ).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 = xdr.expectUnionVariant(invocation.args[3], "scvVec") + .vec![0]; + const requestEntries = xdr.expectUnionVariant(requestScVal, "scvMap").map!; + const byKey = (name: string) => + requestEntries.find( + (e) => + xdr.expectUnionVariant(e.key, "scvSymbol").sym.toString() === name, + )!.val; + + expect(xdr.expectUnionVariant(byKey("request_type"), "scvU32").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/assets/logos/blend-icon.png b/src/assets/logos/blend-icon.png new file mode 100644 index 000000000..4aa81b88c Binary files /dev/null and b/src/assets/logos/blend-icon.png differ 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/AccountQrDisplay.tsx b/src/components/AccountQrDisplay.tsx new file mode 100644 index 000000000..7bd0b1e4c --- /dev/null +++ b/src/components/AccountQrDisplay.tsx @@ -0,0 +1,98 @@ +import { logos } from "assets/logos"; +import { Avatar } from "components/sds/Avatar"; +import { Text } from "components/sds/Typography"; +import { pxValue } from "helpers/dimensions"; +import { truncateAddress } from "helpers/stellar"; +import useAppTranslation from "hooks/useAppTranslation"; +import React from "react"; +import { Image, View } from "react-native"; +import QRCode from "react-native-qrcode-svg"; + +const QR_SIZE = 210; + +export interface AccountQrDisplayProps { + /** The account's Stellar public key -- both the QR payload and the + * truncated address line are derived from this. */ + publicKey: string; + /** Display name shown above the truncated address. Renders an empty + * string when omitted (mirrors the account-not-yet-resolved state the + * original `ReceiveTabView` guarded the same way). */ + accountName?: string; + testID?: string; +} + +/** + * Account identity + QR + network-pill block shared between + * `ScanReceiveScreen`'s Receive tab and the Earn flow's in-flow "Receive + * funds" sheet -- both surfaces show the exact same avatar/name/address row, + * white QR card, and "Stellar" network pill for the active account. + * + * Deliberately excludes the network-support caption and the copy CTA: both + * callers now copy through the same plain `useClipboard` hook, but each has + * its own surrounding layout (a full-screen tab body vs. sheet content) and + * its own copy-button styling, so that action stays owned by each caller + * rather than baked in here. + * + * QR size (210) and the white card's padding/radius intentionally match the + * existing `ReceiveTabView` treatment rather than the Figma mock's 168px -- + * that figure is an artifact of the mock's fixed 360-wide popup canvas, and + * this component's whole point is to keep the app's one existing "your + * account's QR code" treatment visually identical everywhere it appears. + * + * Same reasoning for the identity-row -> QR gap: this component's outer + * `gap-8` (32) carries over `ReceiveTabView`'s existing spacing rather than + * the mock's 24 -- an 8px difference not worth a prop just to shave, given + * every other rhythm value here (avatar/QR/pill sizing, the 24 QR<->pill + * gap) already matches the design. + */ +export const AccountQrDisplay: React.FC = ({ + publicKey, + accountName, + testID, +}) => { + const { t } = useAppTranslation(); + + return ( + + + + + + {accountName ?? ""} + + + {truncateAddress(publicKey)} + + + + + + + + + + + + + {t("scanReceiveScreen.receive.network")} + + + + + ); +}; + +export default AccountQrDisplay; diff --git a/src/components/AmountCard.tsx b/src/components/AmountCard.tsx index 79190e332..49d179710 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. */ @@ -107,6 +114,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 & { @@ -172,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. @@ -183,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 ( - @@ -208,8 +224,10 @@ const PickerChip: React.FC<{ {label ?? fallbackLabel ?? ""} - - + {!isLocked && ( + + )} + ); }; @@ -220,6 +238,7 @@ const EditableAmountCard: React.FC = ({ pickerSecurityLevel, pickerIconUrl, onPickerPress, + isLocked, pickerTestID, testID, availableBalanceText, @@ -233,6 +252,7 @@ const EditableAmountCard: React.FC = ({ autoFocus, accessibilityLabel, accessibilityHint, + isError, }) => { const { themeColors } = useColors(); @@ -258,6 +278,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. @@ -329,9 +359,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, }} @@ -359,7 +387,7 @@ const EditableAmountCard: React.FC = ({ fontSize: amountFontSize, letterSpacing: AMOUNT_LETTER_SPACING, fontWeight: "500", - color: themeColors.text.primary, + color: amountTextColor, padding: 0, includeFontPadding: false, }} @@ -373,6 +401,7 @@ const EditableAmountCard: React.FC = ({ securityLevel={pickerSecurityLevel} iconUrl={pickerIconUrl} onPress={onPickerPress} + isLocked={isLocked} testID={pickerTestID} /> @@ -413,6 +442,7 @@ const ReadOnlyAmountCard: React.FC = ({ pickerSecurityLevel, pickerIconUrl, onPickerPress, + isLocked, pickerTestID, testID, availableBalanceText, @@ -453,6 +483,7 @@ const ReadOnlyAmountCard: React.FC = ({ securityLevel={pickerSecurityLevel} iconUrl={pickerIconUrl} onPress={onPickerPress} + isLocked={isLocked} testID={pickerTestID} /> 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/PercentageButtons.tsx b/src/components/PercentageButtons.tsx index d51c60cff..56a6b7ae0 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 }) => ( + + + + + + ) : ( + + + + + )} + + + {transactionDetails && ( + + } + /> + )} + + ); +}; + +export default EarnReviewBottomSheet; 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/EarnSwapBottomSheet.tsx b/src/components/screens/EarnScreen/components/EarnSwapBottomSheet.tsx new file mode 100644 index 000000000..ecdc3839c --- /dev/null +++ b/src/components/screens/EarnScreen/components/EarnSwapBottomSheet.tsx @@ -0,0 +1,184 @@ +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"; +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 ( + + {/* Settings at the left, centred title, and the shared sheet X at the + right -- a layout `BottomSheetHeader` itself doesn't offer. */} + + + + + + + + {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..6c7efeced --- /dev/null +++ b/src/components/screens/EarnScreen/components/EarnSwapFromBottomSheet.tsx @@ -0,0 +1,121 @@ +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"; +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/EarnTokenRow.tsx b/src/components/screens/EarnScreen/components/EarnTokenRow.tsx new file mode 100644 index 000000000..5a230fb69 --- /dev/null +++ b/src/components/screens/EarnScreen/components/EarnTokenRow.tsx @@ -0,0 +1,120 @@ +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 useColors from "hooks/useColors"; +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 + display name + balance on the + * left, the pool's headline APY pill on the right. + * + * Card surface: the design's row fill is the `Background/Tertiary` variable, + * i.e. this app's `background.tertiary` token, not `background.secondary` + * despite the latter being the more common "card directly on the page" + * choice elsewhere. Paired with `rounded-2xl` (16px), the same radius already + * used for other tertiary-background cards sitting directly on the page + * background (e.g. `AddFundsScreen`, `CollectibleDetailsScreen`). + */ +export const EarnTokenRow: React.FC = ({ + option, + onPress, + testID, +}) => { + const { themeColors } = useColors(); + 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 } }); + + // Figma node 8828:19263 renders the token's display name ("Stellar Lumens" + // for native XLM, otherwise the code) as the primary line -- + // `PricedBalance.displayName` (ducks/balances.ts) already produces exactly + // that. Zero-balance rows have no `balance` to read it from, so fall back + // to `option.code` (the same display-code `useEarnTokens` already resolved + // for the row). + const primaryText = option.balance?.displayName ?? option.code; + + // Design shows the balance line unconditionally, including zero-balance + // rows ("0 EUROC") -- but `formatTokenForDisplay` is a shared, unit-tested + // helper that always renders a minimum of 2 decimal places (`"0"` -> + // `"0.00"`; see __tests__/helpers/formatAmount.test.ts), which would print + // "0.00 EUROC" instead of the design's literal "0 EUROC". Every + // zero-balance row's `option.total` is exactly `"0"` by construction (see + // `buildEarnTokenRows`), so branching on `isHeld` reproduces the design's + // text without touching that shared helper. + const balanceText = isHeld + ? formatTokenForDisplay(option.total, option.code) + : `0 ${option.code}`; + + return ( + + + + + + {primaryText} + + + {balanceText} + + + + {option.apy === null ? ( + // No fresh oracle price for this reserve -- not represented in the + // design at all. Falls back to the SDS `Badge`'s neutral variant + // rather than inventing an unspecified pill style for a state the + // design doesn't define. + + {formatRate(option.apy)} + + ) : ( + // The design's APY pill is a solid `green/10` fill with `green/4` + // text -- confirmed by sampling the Figma render's pixels + // (fill #3cb179, text #113123) against `global.css`'s dark-theme + // `--color-green-10`/`--color-green-4`, which match exactly. SDS + // `Badge`'s "success" variant is `lime`, a yellow-green that doesn't + // match this teal-green, so this pill is built directly from the + // `green` theme scale instead of `Badge`. + // + // 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)} + + + )} + + ); +}; + +export default EarnTokenRow; diff --git a/src/components/screens/EarnScreen/components/NotEnoughTokenBottomSheet.tsx b/src/components/screens/EarnScreen/components/NotEnoughTokenBottomSheet.tsx new file mode 100644 index 000000000..84b46501c --- /dev/null +++ b/src/components/screens/EarnScreen/components/NotEnoughTokenBottomSheet.tsx @@ -0,0 +1,223 @@ +import { BottomSheetHeader } from "components/BottomSheetHeader"; +import { TokenIcon } from "components/TokenIcon"; +import { NotEnoughVariant } from "components/screens/EarnScreen/helpers"; +import { Button } from "components/sds/Button"; +import { Text } from "components/sds/Typography"; +import { Balance, Token } from "config/types"; +import useAppTranslation from "hooks/useAppTranslation"; +import React from "react"; +import { TouchableOpacity, View } from "react-native"; + +export interface NotEnoughTokenBottomSheetProps { + variant: NotEnoughVariant; + tokenCode: string; + /** + * The deposit asset's real token shape, so the header renders its actual + * icon (Figma nodes `9457:46399`/`9457:46345`/`9457:46530`) instead of a + * generic glyph. Undefined only while no option is selected (the sheet + * isn't presented in that state), in which case this renders nothing. + */ + token?: Token | Balance; + onBuy: () => void; + /** Opens the swap-within-Earn sheet for this token. */ + onSwap: () => 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. + * + * Four variants, one per `NotEnoughVariant`, each matching its own Figma + * render exactly rather than a shared layout with parts hidden: + * - `BUY_SWAP_OR_TRANSFER` (`9457:46345`): "Buy {code}" and "Swap for + * {code}" side by side (50/50), a horizontal rule with a centred "or" + * below, then "Transfer from another account" as a centred text link + * (not a button). + * - `BUY_OR_TRANSFER` (`9457:46399`): "Buy with Coinbase" stacked full-width + * above "Transfer from another account" -- note the Buy label here is + * "Buy with Coinbase", not "Buy {code}"; the two variants render different + * copy for the same action per their own renders. + * - `SWAP_OR_TRANSFER` (`9457:42846`): "Swap for {code}" stacked full-width + * above "Transfer from another account" -- the same two-action stack as + * `BUY_OR_TRANSFER`, with Swap standing in for Buy. + * - `TRANSFER_ONLY` (`9457:46530`): a single "Transfer from another + * account" button, full-width. + * + * In the two-action stacks, the top action is the light `tertiary` pill and + * "Transfer from another account" is the outlined `secondary` pill; in the + * three-action layout, both acquire actions are `tertiary` pills and + * "Transfer from another account" is plain secondary text, not a button. + */ +export const NotEnoughTokenBottomSheet: React.FC< + NotEnoughTokenBottomSheetProps +> = ({ variant, tokenCode, token, onBuy, onSwap, onReceive, onClose }) => { + const { t } = useAppTranslation(); + + // No option selected yet -- the sheet isn't presented in this state, so + // there is nothing to render (mirrors `PoolDetailsBottomSheet`'s guard for + // its own not-yet-resolved `pool` prop). + if (!token) { + return null; + } + + const bodyKey = (() => { + switch (variant) { + case NotEnoughVariant.BUY_OR_TRANSFER: + case NotEnoughVariant.BUY_SWAP_OR_TRANSFER: + return "earnNotEnough.bodyWithBuy"; + case NotEnoughVariant.SWAP_OR_TRANSFER: + return "earnNotEnough.bodySwapOnly"; + case NotEnoughVariant.TRANSFER_ONLY: + default: + return "earnNotEnough.bodyTransferOnly"; + } + })(); + + 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 ( + + + + + + + + + + + + + + {t("earnNotEnough.or")} + + + + + {/* Bare text button (`13132:50285`): no fill, no border. The + pt-8/pb-16 is the design's own, not pill padding. */} + + + {t("earnNotEnough.receive", { tokenCode })} + + + + ); + + case NotEnoughVariant.BUY_OR_TRANSFER: + return ( + + + + + ); + case NotEnoughVariant.SWAP_OR_TRANSFER: + return ( + + + + + ); + case NotEnoughVariant.TRANSFER_ONLY: + default: + return ( + + ); + } + }; + + return ( + + } + onClose={onClose} + closeTestID="not-enough-token-close" + /> + + + + {t(bodyKey, { tokenCode })} + + + + {renderActions()} + + ); +}; + +export default NotEnoughTokenBottomSheet; diff --git a/src/components/screens/EarnScreen/components/PoolCard.tsx b/src/components/screens/EarnScreen/components/PoolCard.tsx new file mode 100644 index 000000000..0c774d49f --- /dev/null +++ b/src/components/screens/EarnScreen/components/PoolCard.tsx @@ -0,0 +1,114 @@ +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"; +import { BlendCatalogPool } from "config/blendTypes"; +import useAppTranslation from "hooks/useAppTranslation"; +import useColors from "hooks/useColors"; +import React from "react"; +import { Image, TouchableOpacity, View } from "react-native"; + +export interface PoolCardProps { + pool: BlendCatalogPool | null; + /** Selected asset's headline APY, as read off the earn duck. */ + apy: number | null; + /** Opens `PoolDetailsBottomSheet` -- the design's only route there now + * that the token picker's invented header info-button is gone. */ + onPress: () => void; + testID?: string; +} + +/** + * 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`. + * + * Three things the redesign changed, all of which the previous pass had + * inferred from an older mock and got wrong: + * + * - 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. + * + * 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, + apy, + onPress, + testID, +}) => { + const { t } = useAppTranslation(); + const { themeColors } = useColors(); + + if (!pool) { + return null; + } + + return ( + + {apy !== null && ( + + + + {t("earnAmount.poolCard.currentApy", { rate: formatRate(apy) })} + + + + )} + + + + + + {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. */} + + + + + + ); +}; + +export default PoolCard; diff --git a/src/components/screens/EarnScreen/components/PoolDetailsBottomSheet.tsx b/src/components/screens/EarnScreen/components/PoolDetailsBottomSheet.tsx new file mode 100644 index 000000000..ce8b582e4 --- /dev/null +++ b/src/components/screens/EarnScreen/components/PoolDetailsBottomSheet.tsx @@ -0,0 +1,300 @@ +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, + formatRate, +} from "components/screens/EarnScreen/helpers"; +import { Button } from "components/sds/Button"; +import { Text } from "components/sds/Typography"; +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"; +import { Image, View } from "react-native"; + +export interface PoolDetailsBottomSheetProps { + pool: BlendCatalogPool | null; + bottomSheetModalRef?: React.RefObject; +} + +interface PoolDetailRow { + key: string; + label: 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 + * design and has been removed there. Now triggered from the amount screen's + * `PoolCard` chevron (design node `9448:29157`/`9448:18518`) -- the design's + * only route into this sheet -- mirroring the extension's + * `EarnAmount/PoolCard.tsx` `onOpenDetails`. + * + * Modeled on `XlmReserveBottomSheet`: pure content (the caller wraps it in + * `components/BottomSheet` and owns the modal ref); `bottomSheetModalRef` is + * used here for both the close button and the bottom "Close" CTA. + * + * 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, + bottomSheetModalRef, +}) => { + const { t } = useAppTranslation(); + const { themeColors } = useColors(); + const { network } = useAuthenticationStore(); + + 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; + } + + 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: "supplied", + label: t("earnPoolDetails.supplied"), + value: formatCompactUsd(pool.suppliedUsd), + testID: "pool-details-supplied", + }, + { + 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 ( + + + + {/* 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 && ( + + {pool.name} + + )} + + {t("earnPoolDetails.byBlend")} + + + + + + + {/* 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")} + + + + + + {t("earnPoolDetails.poolDetails")} + + + + + + + + ); +}; + +export default PoolDetailsBottomSheet; diff --git a/src/components/screens/EarnScreen/components/ReceiveFundsBottomSheet.tsx b/src/components/screens/EarnScreen/components/ReceiveFundsBottomSheet.tsx new file mode 100644 index 000000000..c9a0f1400 --- /dev/null +++ b/src/components/screens/EarnScreen/components/ReceiveFundsBottomSheet.tsx @@ -0,0 +1,100 @@ +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 useGetActiveAccount from "hooks/useGetActiveAccount"; +import React from "react"; +import { View } from "react-native"; + +export interface ReceiveFundsBottomSheetProps { + bottomSheetModalRef?: React.RefObject; +} + +/** + * In-flow "Receive funds" sheet -- 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 { account } = useGetActiveAccount(); + const { copyToClipboard } = useClipboard(); + + const publicKey = account?.publicKey ?? ""; + + const handleClose = () => { + bottomSheetModalRef?.current?.dismiss(); + }; + + const handleCopy = () => { + copyToClipboard(publicKey); + }; + + return ( + + + + + + + + {/* Design rhythm: 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..0ca12b589 --- /dev/null +++ b/src/components/screens/EarnScreen/components/XlmFeeShortfallBottomSheet.tsx @@ -0,0 +1,91 @@ +import { BottomSheetModal } from "@gorhom/bottom-sheet"; +import { BottomSheetHeader } from "components/BottomSheetHeader"; +import { TokenIcon } from "components/TokenIcon"; +import { Button } from "components/sds/Button"; +import { Text } from "components/sds/Typography"; +import { NATIVE_TOKEN_CODE } from "config/constants"; +import useAppTranslation from "hooks/useAppTranslation"; +import React from "react"; +import { 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; + /** Opens the in-flow "Receive funds" sheet on top of this one. */ + 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. + * + * 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`. + * + * 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 handleClose = () => { + bottomSheetModalRef?.current?.dismiss(); + }; + + return ( + + + } + onClose={handleClose} + closeTestID="xlm-fee-shortfall-close" + /> + + + + {t("earnAmount.networkFeeSheet.body")} + + + + + + + + + ); +}; + +export default XlmFeeShortfallBottomSheet; 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..62a6472e7 --- /dev/null +++ b/src/components/screens/EarnScreen/helpers/earnCtaState.ts @@ -0,0 +1,119 @@ +import BigNumber from "bignumber.js"; + +/** + * Pure CTA state machine for the deposit amount screen. Precedence matters: + * each guard short-circuits, so the label reflects the most specific blocker. + */ +export type EarnCtaLabelKey = "enter" | "insufficient" | "review"; + +export interface EarnCtaInputs { + /** Spendable balance of the deposit asset, net of reserve and fee. */ + availableBalanceIsZero: boolean; + amountIsZero: boolean; + isAmountTooHigh: boolean; +} + +export const getEarnCtaState = ({ + availableBalanceIsZero, + amountIsZero, + isAmountTooHigh, +}: EarnCtaInputs): { disabled: boolean; labelKey: EarnCtaLabelKey } => { + // Nothing enterable is valid with zero spendable balance, so surface the + // blocker directly rather than inviting an amount that cannot work. + if (availableBalanceIsZero) { + return { disabled: true, labelKey: "insufficient" }; + } + if (amountIsZero) { + return { disabled: true, labelKey: "enter" }; + } + if (isAmountTooHigh) { + return { disabled: true, labelKey: "insufficient" }; + } + return { disabled: false, labelKey: "review" }; +}; + +/** + * Does the account lack the XLM to pay this transaction's fee? + * + * A Soroban invoke's fee is XLM-only and no trustline is involved, so this is + * simply "spendable XLM < fee". + * + * 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)); + +/** + * How much XLM a deposit is short of its own network fee, or "0" if it fits. + * + * A Blend `submit` is dominated by its resource fee — ~0.0546 XLM against the + * live pool, roughly 5,000x the inclusion fee — and that figure is only known + * once simulation returns. Rather than hold a guessed buffer back from the + * balance (which locks XLM the user may well want to deposit), the deposit + * screen offers the whole spendable balance and checks the *measured* fee + * here, after simulation and before the review sheet. + * + * `spendableXlm` is expected to come from `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 getXlmFeeShortfall = ({ + spendableXlm, + amount, + resourceFee, +}: { + 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(); +}; + +/** + * Fallback fee fed to `getXlmFeeShortfall` when simulation did not report a + * measured resource fee (`sorobanResourceFeeXlm === null` — the backend + * omitted or returned an unparsable `minResourceFee`). + * + * `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. + * + * 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 UNKNOWN_RESOURCE_FEE_FLOOR_XLM = "0.1"; + +/** + * 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/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/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..2e9723e51 --- /dev/null +++ b/src/components/screens/EarnScreen/helpers/getNotEnoughVariant.ts @@ -0,0 +1,95 @@ +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. + * + * 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 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", + 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 (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; + } + const isNative = isNativeToken(balance.token); + 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..8748bbc48 --- /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/earnSwapDestination"; +export * from "components/screens/EarnScreen/helpers/formatPoolStats"; +export * from "components/screens/EarnScreen/helpers/getNotEnoughVariant"; +export * from "components/screens/EarnScreen/helpers/projectEarnings"; diff --git a/src/components/screens/EarnScreen/helpers/projectEarnings.ts b/src/components/screens/EarnScreen/helpers/projectEarnings.ts new file mode 100644 index 000000000..7595df243 --- /dev/null +++ b/src/components/screens/EarnScreen/helpers/projectEarnings.ts @@ -0,0 +1,67 @@ +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)}`; + +/** + * 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/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/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 new file mode 100644 index 000000000..647334f2f --- /dev/null +++ b/src/components/screens/EarnScreen/hooks/useEarnTokens.ts @@ -0,0 +1,235 @@ +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; + /** + * 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 — + * 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, + canonicalId: option.name, + 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], + ); + + /** + * 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 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; + + setCatalog({ options: earnOptions, poolId, pool: 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); + } + }, [networkDetails, setPool]); + + useEffect(() => { + if (account?.publicKey && network) { + fetchData(); + } + }, [account?.publicKey, network, 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/hooks/useEarnTransaction.ts b/src/components/screens/EarnScreen/hooks/useEarnTransaction.ts new file mode 100644 index 000000000..90a9480e3 --- /dev/null +++ b/src/components/screens/EarnScreen/hooks/useEarnTransaction.ts @@ -0,0 +1,252 @@ +import { NETWORKS } from "config/constants"; +import { logger } from "config/logger"; +import { ActiveAccount } from "ducks/auth"; +import { useEarnStore } from "ducks/earn"; +import { + SubmitResultCodes, + 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"; + +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` 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 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. 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 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. + */ + 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 only for the debug forced-failure override; + // otherwise it resolves with this attempt's own outcome. Read the + // reason and result codes off that outcome, never off the store: a + // mid-submit Close resets the store, so it may not hold this attempt's + // result. The codes ride the thrown error into the catch block below. + const submitOutcome = await submitTransaction({ network }); + const { hash } = submitOutcome; + + if (!hash) { + const submitFailure = new Error( + submitOutcome.error || "Failed to submit transaction", + ) as Error & { resultCodes?: SubmitResultCodes | null }; + submitFailure.resultCodes = submitOutcome.resultCodes; + throw submitFailure; + } + + // 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"); + + // 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; + } + + const message = err instanceof Error ? err.message : String(err); + logger.error("useEarnTransaction", "Deposit submission failed", err); + setError(message); + setSubmitFailed(true); + setStatus("error"); + + // Result codes come off this attempt's own submit outcome, carried on + // the thrown error. Absent for a signing failure (there's no Horizon + // result yet) -- reason_code falls back to "unknown" in that case, same + // as trackTransactionError elsewhere. + const submitResultCodes = + err instanceof Error + ? (err as Error & { resultCodes?: SubmitResultCodes | null }) + .resultCodes + : undefined; + const { pool, selectedAssetCode, selectedAssetApy } = + useEarnStore.getState(); + analytics.trackEarnDepositFail({ + assetCode: selectedAssetCode, + poolId: pool?.id, + apy: selectedAssetApy, + errorCode: + submitResultCodes?.operations?.[0] || submitResultCodes?.transaction, + }); + } + }, [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/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..df6270e70 --- /dev/null +++ b/src/components/screens/EarnScreen/screens/EarnAmountScreen.tsx @@ -0,0 +1,830 @@ +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 { 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, + getPercentageDepositAmount, + getXlmFeeShortfall, + isInsufficientBalanceFailure, + 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 { Button } from "components/sds/Button"; +import { + NoticeBanner, + NoticeBannerVariants, +} from "components/sds/NoticeBanner"; +import { AnalyticsEvent } from "config/analyticsConfig"; +import { + NATIVE_TOKEN_CODE, + TransactionContext, + mapNetworkToNetworkDetails, +} from "config/constants"; +import { logger } from "config/logger"; +import { + ADD_FUNDS_ROUTES, + EARN_ROUTES, + EarnStackParamList, + MAIN_TAB_ROUTES, + ROOT_NAVIGATOR_ROUTES, + RootStackParamList, +} 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"; +import { isNativeBalance } from "helpers/assetIdentity"; +import { + calculateSpendableAmount, + getBalanceByContractId, +} from "helpers/balances"; +import { + formatBalanceAmount, + formatFiatInputDisplay, + formatTokenForDisplay, +} from "helpers/formatAmount"; +import useAppTranslation from "hooks/useAppTranslation"; +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"; +import { SecurityLevel } from "services/blockaid/constants"; +import { + assessTransactionSecurity, + extractSecurityWarnings, +} from "services/blockaid/helper"; + +type EarnAmountScreenProps = NativeStackScreenProps< + EarnStackParamList, + typeof EARN_ROUTES.EARN_AMOUNT_SCREEN +>; + +const EarnAmountScreen: React.FC = ({ + route, + navigation, +}) => { + const { assetId, tokenCode } = route.params; + const { t } = useAppTranslation(); + 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 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 resetEarn = useEarnStore((state) => state.resetEarn); + const { overriddenBlockaidResponse } = useDebugStore(); + + 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 && isNativeBalance(depositBalance), + [depositBalance], + ); + + 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 poolDetailsBottomSheetModalRef = useRef(null); + const transactionSettingsBottomSheetModalRef = useRef(null); + const receiveFundsBottomSheetModalRef = useRef(null); + + const converter = useTokenFiatConverter({ + selectedBalance: depositBalance, + tokenDecimals: selectedAssetDecimals, + }); + const { + tokenAmount, + fiatAmountDisplay, + showFiatAmount, + setTokenAmount, + updateFiatDisplay, + } = converter; + + // 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"; + } + + return calculateSpendableAmount({ + balance: depositBalance, + subentryCount: account?.subentryCount ?? 0, + transactionFee, + }).toFixed(); + }, [depositBalance, account?.subentryCount, transactionFee]); + + const availableBalanceBn = useMemo( + () => new BigNumber(availableBalance), + [availableBalance], + ); + + // Shared between the CTA state machine and the amount card's red-text + // treatment (design `9599:40119`) -- both must agree on exactly the same + // "over balance" condition. + const isAmountTooHigh = useMemo( + () => new BigNumber(tokenAmount || "0").gt(availableBalanceBn), + [tokenAmount, availableBalanceBn], + ); + + const ctaState = useMemo( + () => + getEarnCtaState({ + availableBalanceIsZero: availableBalanceBn.lte(0), + amountIsZero: new BigNumber(tokenAmount || "0").lte(0), + isAmountTooHigh, + }), + [availableBalanceBn, tokenAmount, isAmountTooHigh], + ); + + const ctaLabelKeys: Record = { + enter: t("earnAmount.enterAmount"), + insufficient: t("earnAmount.insufficientFunds"), + review: t("earnAmount.review"), + }; + + const { + 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. + // 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; + // 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, + toastId: "earn-simulate-failed", + duration: 0, + }); + } + }, [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 + // 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: availableBalance, + 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); + }, + [ + availableBalance, + selectedAssetDecimals, + showFiatAmount, + depositBalance, + updateFiatDisplay, + setTokenAmount, + ], + ); + + const openReviewSheet = useCallback(() => { + earnReviewBottomSheetModalRef.current?.present(); + }, []); + + // The pool card's chevron -- design `9448:29157` -- is the only route into + // `PoolDetailsBottomSheet` now that the token picker's header info-button + // has been removed (see that sheet's own doc comment). + const handleOpenPoolDetails = useCallback(() => { + 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` 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, + transactionHash: earnTransactionHash, + submit: submitEarnTransaction, + reset: resetEarnTransactionStatus, + abandon: abandonEarnTransaction, + } = useEarnTransaction({ account, network }); + + /** + * 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(() => { + 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]); + + // 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(); + }, []); + + // 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; + } + + 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); + + // `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: transactionFee, + }) + ) { + networkFeeBottomSheetModalRef.current?.present(); + return; + } + + const result = await simulate({ + assetId, + amount: tokenAmount, + decimals: selectedAssetDecimals, + transactionFee, + transactionTimeout, + network, + senderAddress: account.publicKey, + }); + + if (!result) { + // On failure, the effect above surfaces `simulateError` as a toast — + // nothing further to do here. + return; + } + + // `simulate` just resolved, so the duck's `sorobanResourceFeeXlm` is now + // 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(); + + 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, + }); + } + + // 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, + }); + return; + } + + openReviewSheet(); + }, [ + ctaState.disabled, + xlmBalance, + account?.subentryCount, + account?.publicKey, + transactionFee, + simulate, + assetId, + tokenAmount, + selectedAssetDecimals, + transactionTimeout, + network, + isXlm, + showToast, + t, + 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]); + + // 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 ( + + ); + } + + const availableBalanceText = depositBalance + ? `${formatBalanceAmount(depositBalance, tokenCode, availableBalanceBn)} ${t( + "common.available", + )}` + : null; + + const hasUsdPrice = + !!depositBalance?.currentPrice && !depositBalance.currentPrice.isZero(); + + const secondaryAmountText = showFiatAmount + ? formatTokenForDisplay(tokenAmount || "0", tokenCode) + : formatFiatInputDisplay(fiatAmountDisplay || "0"); + + return ( + + + {lastSubmitFailed && ( + + + + )} + + navigation.goBack()} + 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} + isError={isAmountTooHigh} + /> + + + + + + + + + + + + + + + + } + /> + + + transactionSecurityWarningBottomSheetModalRef.current?.present() + } + onConfirm={handleConfirmDeposit} + onSettingsPress={openTransactionSettings} + /> + } + /> + + } + /> + + } + /> + + } + /> + + } + /> + + ); +}; + +export default EarnAmountScreen; 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 new file mode 100644 index 000000000..a68bd6612 --- /dev/null +++ b/src/components/screens/EarnScreen/screens/EarnProcessingScreen.tsx @@ -0,0 +1,295 @@ +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"; +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 { 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 { useInAppBrowser } from "hooks/useInAppBrowser"; +import React, { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, +} from "react"; +import { Image, View } from "react-native"; +import { track } from "services/analytics/core"; + +export interface EarnProcessingScreenProps { + /** + * 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; + /** + * 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 + * 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; +} + +/** + * 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` 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) 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, + transactionHash, + onCloseWhileSubmitting, + onDone, +}) => { + 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( + () => mapNetworkToNetworkDetails(network), + [network], + ); + + const depositBalance = useMemo( + () => + getBalanceByContractId(selectedAssetId, pricedBalances, networkDetails), + [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. + useLayoutEffect(() => { + navigation.setOptions({ + headerShown: false, + }); + }, [navigation]); + + useEffect( + () => () => + navigation.setOptions({ + headerShown: true, + }), + [navigation], + ); + + 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 ( + + + + + {getStatusIcon()} + + + + {getStatusText()} + + + + {/* Triptych (design node `9449:29733`/`9449:29814`): the + deposit asset's icon, a secondary double-chevron connector, then + 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 && ( + + )} + + + + + + + {formatTokenForDisplay(tokenAmount, selectedAssetCode)} + + {` ${t("earnProcessing.to")} `} + + {pool?.name} + + + + + + + {status === "success" && ( + + {transactionHash && ( + + )} + + + )} + + {status === "submitting" && ( + + + {t("earnProcessing.closeMessage")} + + + + )} + + + ); +}; + +export default EarnProcessingScreen; diff --git a/src/components/screens/EarnScreen/screens/EarnTokenPickerScreen.tsx b/src/components/screens/EarnScreen/screens/EarnTokenPickerScreen.tsx new file mode 100644 index 000000000..791f6a135 --- /dev/null +++ b/src/components/screens/EarnScreen/screens/EarnTokenPickerScreen.tsx @@ -0,0 +1,699 @@ +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 { 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"; +import { + NotEnoughVariant, + getNotEnoughVariant, + hasSwappableBalance, + isOnrampableAsset, +} from "components/screens/EarnScreen/helpers"; +import { useEarnSwap } from "components/screens/EarnScreen/hooks/useEarnSwap"; +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 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, + EarnStackParamList, + 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"; +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"; + +type EarnTokenPickerScreenProps = NativeStackScreenProps< + EarnStackParamList, + typeof EARN_ROUTES.EARN_TOKEN_PICKER_SCREEN +>; + +/** + * 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 { showToast } = useToast(); + const { themeColors } = useColors(); + const { isLoading, error, held, supported, refetch } = useEarnTokens(); + 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 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 + // 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]); + + // 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); + }, []); + + 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]); + + // 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(() => { + // 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 + // 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(() => { + 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 + // the design owner confirmed pool info isn't accessed from this screen. + // `PoolDetailsBottomSheet` now has its real trigger on the amount screen's + // `PoolCard` chevron instead (mirroring the extension's + // `EarnAmount/PoolCard.tsx`'s `onOpenDetails`) — see `EarnAmountScreen`. + + 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], + ); + + // 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]); + + // 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`. + // + // 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 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 && !hasEarnRows) { + return ( + + + navigation.goBack()} /> + + + + + + ); + } + + // 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 && !hasEarnRows) { + return ( + + + navigation.goBack()} /> + + + {t("earnTokenPicker.error.title")} + + + + {t("earnTokenPicker.error.body")} + + + + + + + ); + } + + // 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 ( + <> + + {/* 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")} + + + + + + {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")} + + + + + + } + /> + + ) : null + } + /> + + } + /> + swapFromBottomSheetModalRef.current?.dismiss()} + onClose={handleCloseSwap} + /> + } + /> + + } + /> + + ); +}; + +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..119e7d59f --- /dev/null +++ b/src/components/screens/EarnScreen/screens/index.ts @@ -0,0 +1,4 @@ +export { default as EarnAmountScreen } from "./EarnAmountScreen"; +export { EarnIntroScreen } from "./EarnIntroScreen"; +export { default as EarnProcessingScreen } from "./EarnProcessingScreen"; +export { EarnTokenPickerScreen } from "./EarnTokenPickerScreen"; diff --git a/src/components/screens/HomeScreen/HomeScreen.tsx b/src/components/screens/HomeScreen/HomeScreen.tsx index 4d853b5b0..cfa227d92 100644 --- a/src/components/screens/HomeScreen/HomeScreen.tsx +++ b/src/components/screens/HomeScreen/HomeScreen.tsx @@ -16,7 +16,11 @@ import ConnectedApps from "components/screens/HomeScreen/ConnectedApps"; import ManageAccounts from "components/screens/HomeScreen/ManageAccounts"; import Icon from "components/sds/Icon"; import { Display, DISPLAY_SIZES } from "components/sds/Typography"; -import { NATIVE_TOKEN_CODE } from "config/constants"; +import { isEarnSupportedNetwork } from "config/blend"; +import { + NATIVE_TOKEN_CODE, + mapNetworkToNetworkDetails, +} from "config/constants"; import { MainTabStackParamList, MAIN_TAB_ROUTES, @@ -26,6 +30,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"; @@ -160,6 +165,12 @@ export const HomeScreen: React.FC = React.memo( const showAddCollectibleButton = !isCollectiblesLoading && !isCollectiblesGridEmpty && !collectiblesError; + // 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(); }, []); @@ -261,6 +272,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, @@ -427,6 +444,15 @@ export const HomeScreen: React.FC = React.memo( testID="icon-button-swap" /> )} + {earnEnabled && ( + + )} { return ( - - - - - - {account?.accountName ?? ""} - - - {truncateAddress(publicKey)} - - - - - - - - - - - - - {t("scanReceiveScreen.receive.network")} - - - + + diff --git a/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts b/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts index 8751fc21e..df8f44174 100644 --- a/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts +++ b/src/components/screens/SwapScreen/hooks/useSwapTransaction.ts @@ -77,10 +77,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 { @@ -110,6 +125,7 @@ export const useSwapTransaction = ({ account, network, navigation, + onProcessingClose, }: SwapTransactionParams): UseSwapTransactionResult => { const [isProcessing, setIsProcessing] = useState(false); const [transactionScanResult, setTransactionScanResult] = @@ -647,7 +663,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 0bd86bbfb..e29ee5329 100644 --- a/src/config/analyticsConfig.ts +++ b/src/config/analyticsConfig.ts @@ -75,6 +75,23 @@ 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. 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", + 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", + // --------------------------------------------------------------------------- // Domain (action / outcome) events (#2883) // @@ -142,6 +159,17 @@ 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. 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", + // Send collectible SEND_COLLECTIBLE_SUCCESS = "collectible_send.completed", // carries reason_code @@ -322,6 +350,7 @@ export enum AnalyticsFlow { DISCOVERY = "discovery", SECURITY = "security", HISTORY = "history", + EARN = "earn", } /** @@ -510,6 +539,42 @@ const SCREEN_CATALOG: Record = { [AnalyticsEvent.VIEW_MANAGE_WALLETS]: { flow: AnalyticsFlow.SETTINGS, }, + // 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 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, + }, + [AnalyticsEvent.VIEW_EARN_AMOUNT]: { + flow: AnalyticsFlow.EARN, + }, + [AnalyticsEvent.VIEW_EARN_REVIEW]: { + flow: AnalyticsFlow.EARN, + step: "confirm", + }, + [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", + }, + [AnalyticsEvent.VIEW_EARN_SUCCESS]: { + flow: AnalyticsFlow.EARN, + step: "success", + }, }; /** @@ -614,6 +679,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/config/blend.ts b/src/config/blend.ts new file mode 100644 index 000000000..1c52c7da0 --- /dev/null +++ b/src/config/blend.ts @@ -0,0 +1,30 @@ +import { NETWORKS, NetworkDetails } from "config/constants"; + +/** + * 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]: "CBAKAZUEJBAFA2DCGBRS2WKJT3FNIMJBHTFL76AY7XMAVEINQNNIQWAO", + [NETWORKS.TESTNET]: + "CB3447A446DY3USGWPIDXTNQHF5EX24XDQA2EN6TAZP62JTPBTNOA52Q", +}; + +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)); 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[]; +} diff --git a/src/config/routes.ts b/src/config/routes.ts index 32ea6967b..1f7cc03ac 100644 --- a/src/config/routes.ts +++ b/src/config/routes.ts @@ -51,6 +51,7 @@ export const ROOT_NAVIGATOR_ROUTES = { BUY_XLM_STACK: "BuyXLMStack", SEND_PAYMENT_STACK: "SendPaymentStack", SWAP_STACK: "SwapStack", + EARN_STACK: "EarnStack", MANAGE_TOKENS_STACK: "ManageAssetsStack", MANAGE_WALLETS_STACK: "ManageWalletsStack", // This screen can be called on both stacks. @@ -119,6 +120,11 @@ export const SWAP_ROUTES = { SWAP_AMOUNT_SCREEN: "SwapAmountScreen", } as const; +export const EARN_ROUTES = { + EARN_TOKEN_PICKER_SCREEN: "EarnTokenPickerScreen", + EARN_AMOUNT_SCREEN: "EarnAmountScreen", +} as const; + /** * ALL_ROUTE_OBJECTS - Centralized export for analytics * @@ -139,6 +145,7 @@ export const ALL_ROUTES_OBJECT = [ ADD_FUNDS_ROUTES, SEND_PAYMENT_ROUTES, SWAP_ROUTES, + EARN_ROUTES, ] as const; export type RootStackParamList = { @@ -162,6 +169,7 @@ export type RootStackParamList = { [ROOT_NAVIGATOR_ROUTES.BUY_XLM_STACK]: NavigatorScreenParams; [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; @@ -269,3 +277,12 @@ export type SwapStackParamList = { source?: "token_details"; }; }; + +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; + }; +}; diff --git a/src/ducks/earn.ts b/src/ducks/earn.ts new file mode 100644 index 000000000..16a52b356 --- /dev/null +++ b/src/ducks/earn.ts @@ -0,0 +1,93 @@ +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 }), + + // 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) => + set({ currentPositionTokens }), + + setSubmitFailed: (lastSubmitFailed) => set({ lastSubmitFailed }), + + resetEarn: () => set({ ...initialState }), +})); 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/ducks/transactionBuilder.ts b/src/ducks/transactionBuilder.ts index f9b57d16e..6e7bb9547 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, @@ -135,6 +136,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; @@ -152,6 +163,7 @@ const initialState: Omit< | "buildTransaction" | "buildSwapTransaction" | "buildSendCollectibleTransaction" + | "buildBlendDepositTransaction" | "signTransaction" | "submitTransaction" | "resetTransaction" @@ -558,6 +570,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/helpers/balances.ts b/src/helpers/balances.ts index cb66cc97e..c84ff1da7 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, @@ -23,6 +25,7 @@ import { isNativeToken, } from "helpers/assetIdentity"; import { formatFiatAmount, NO_FIAT_VALUE } from "helpers/formatAmount"; +import { isContractId } from "helpers/soroban"; interface GetTokenPriceFromBalanceParams { prices: TokenPricesMap; @@ -643,3 +646,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 (isNativeToken(balance.token)) { + 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; + } + }); 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), + ); +}; diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index 630864cf3..a04a9c3e7 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -50,6 +50,7 @@ "buy": "Add", "send": "Send", "swap": "Swap", + "earn": "Earn", "actions": { "settings": "Settings", "myQRCode": "My QR code" @@ -776,6 +777,106 @@ "swapTransactionDetails": { "swapped": "Swapped" }, + "earnIntro": { + "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": { + "protocolName": "Blend", + "heading": "Choose an asset", + "subheading": "Supply assets to Blend and earn variable yield.", + "inYourAccount": "In your account", + "supportedTokens": "Supported tokens", + "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.", + "retry": "Try again" + } + }, + "earnPoolDetails": { + "byBlend": "by Blend", + "description": "Description", + "poolDetails": "Pool Details", + "lendingInterest": "Lending Interest", + "currentNetApy": "Current Net APY", + "acceptedTokens": "Accepted tokens", + "supplied": "Supplied", + "borrowed": "Borrowed", + "backstop": "Backstop", + "poolDescription": "Deposit supported assets into this Blend pool to earn yield. APY may change over time. Withdraw anytime." + }, + "earnAmount": { + "title": "Deposit", + "depositLabel": "You deposit", + "enterAmount": "Enter an amount", + "insufficientFunds": "Insufficient funds", + "review": "Review deposit", + "poolCard": { + "currentApy": "Current APY: {{rate}}*", + "byBlend": "by Blend", + "accessibilityLabel": "View pool details" + }, + "retryBanner": "Transaction failed. Try again.", + "networkFeeSheet": { + "title": "XLM needed for network fees", + "body": "Add XLM to your wallet to continue", + "buyWithCoinbase": "Buy with Coinbase", + "receive": "Receive XLM" + }, + "errors": { + "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": { + "sheetTitle": "Review deposit", + "title": "You are depositing", + "to": "To", + "apy": "Current APY", + "position": "Position", + "positionValue": "{{before}} → {{after}}", + "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": "Not enough {{tokenCode}}", + "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}}", + "receive": "Receive {{tokenCode}}", + "or": "or" + }, + "earnReceiveFunds": { + "title": "Receive funds" + }, + "earnProcessing": { + "depositing": "Depositing", + "deposited": "Deposited!", + "to": "to", + "closeMessage": "You can close this screen, your transaction should be complete in less than a minute", + "viewTransaction": "View transaction" + }, "transactionTokenScreen": { "title": "Sending", "empty": "No tokens or collectibles to send" @@ -1387,5 +1488,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 8cb0bbc76..488142c46 100644 --- a/src/i18n/locales/pt/translations.json +++ b/src/i18n/locales/pt/translations.json @@ -50,6 +50,7 @@ "buy": "Adicionar", "send": "Enviar", "swap": "Trocar", + "earn": "Render", "actions": { "settings": "Configurações", "myQRCode": "Meu QR code" @@ -742,6 +743,106 @@ "swapTransactionDetails": { "swapped": "Trocado" }, + "earnIntro": { + "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": { + "protocolName": "Blend", + "heading": "Escolha um ativo", + "subheading": "Forneça ativos à Blend e ganhe rendimento variável.", + "inYourAccount": "Na sua conta", + "supportedTokens": "Tokens suportados", + "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.", + "retry": "Tentar novamente" + } + }, + "earnPoolDetails": { + "byBlend": "por Blend", + "description": "Descrição", + "poolDetails": "Detalhes do pool", + "lendingInterest": "Juros de empréstimo", + "currentNetApy": "APY líquido atual", + "acceptedTokens": "Tokens aceitos", + "supplied": "Fornecido", + "borrowed": "Emprestado", + "backstop": "Backstop", + "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", + "depositLabel": "Você deposita", + "enterAmount": "Insira um valor", + "insufficientFunds": "Saldo insuficiente", + "review": "Revisar depósito", + "poolCard": { + "currentApy": "APY atual: {{rate}}*", + "byBlend": "por Blend", + "accessibilityLabel": "Ver detalhes do pool" + }, + "retryBanner": "A transação falhou. Tente novamente.", + "networkFeeSheet": { + "title": "XLM necessário para taxas de rede", + "body": "Adicione XLM à sua carteira para continuar", + "buyWithCoinbase": "Comprar com a Coinbase", + "receive": "Receber XLM" + }, + "errors": { + "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": { + "sheetTitle": "Revisar depósito", + "title": "Você está depositando", + "to": "Para", + "apy": "APY atual", + "position": "Posição", + "positionValue": "{{before}} → {{after}}", + "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": "{{tokenCode}} insuficiente", + "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}}", + "receive": "Receber {{tokenCode}}", + "or": "ou" + }, + "earnReceiveFunds": { + "title": "Receber fundos" + }, + "earnProcessing": { + "depositing": "Depositando", + "deposited": "Depositado!", + "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", "empty": "Nenhum token ou colecionável para enviar" @@ -1387,5 +1488,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." } } diff --git a/src/navigators/EarnNavigator.tsx b/src/navigators/EarnNavigator.tsx new file mode 100644 index 000000000..6f0b4894f --- /dev/null +++ b/src/navigators/EarnNavigator.tsx @@ -0,0 +1,90 @@ +/* eslint-disable react/no-unstable-nested-components */ +import { createNativeStackNavigator } from "@react-navigation/native-stack"; +import CustomNavigationHeader from "components/layout/CustomNavigationHeader"; +import { + EarnAmountScreen, + 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 { + getScreenOptionsWithCustomHeader, + getStackBottomNavigateOptions, +} from "helpers/navigationOptions"; +import useAppTranslation from "hooks/useAppTranslation"; +import { clearNetworkFeesCache, useNetworkFees } from "hooks/useNetworkFees"; +import React, { useEffect } 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(); + + // 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(); + }, + [], + ); + + return ( + , + }} + > + + `, i.e. `Icon.ArrowLeft` + + // `navigation.goBack()` -- an existing, general (not Earn-specific) + // back-arrow variant rather than a new one-off helper. + options={getScreenOptionsWithCustomHeader(t("earnAmount.title"))} + /> + + ); +}; diff --git a/src/navigators/RootNavigator.tsx b/src/navigators/RootNavigator.tsx index 8575cd73b..68c888ec5 100644 --- a/src/navigators/RootNavigator.tsx +++ b/src/navigators/RootNavigator.tsx @@ -54,6 +54,7 @@ import { useMaintenanceMode } from "hooks/useMaintenanceMode"; import { AuthNavigator, AddFundsStackNavigator, + EarnStackNavigator, ManageTokensStackNavigator, ManageWalletsStackNavigator, SendPaymentStackNavigator, @@ -332,6 +333,11 @@ export const RootNavigator = () => { component={SwapStackNavigator} options={getStackBottomNavigateOptions()} /> + { + 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 75171819a..d2113cf77 100644 --- a/src/services/analytics/types.ts +++ b/src/services/analytics/types.ts @@ -127,3 +127,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; +} 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"; +}; diff --git a/src/services/transactionService.ts b/src/services/transactionService.ts index 402e21f7d..f777e7df9 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, @@ -27,6 +28,11 @@ import { getNativeContractId, } from "helpers/assetIdentity"; import { isLiquidityPool } from "helpers/balances"; +import { + BlendRequestType, + buildBlendRequestScVal, + buildBlendSubmitOp, +} from "helpers/blend"; import { getPerOperationBaseFeeStroops, xlmToStroop, @@ -972,3 +978,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, + }; +};