diff --git a/__tests__/components/FeeBreakdownBottomSheet.test.tsx b/__tests__/components/FeeBreakdownBottomSheet.test.tsx index 184fb4591..87538b45c 100644 --- a/__tests__/components/FeeBreakdownBottomSheet.test.tsx +++ b/__tests__/components/FeeBreakdownBottomSheet.test.tsx @@ -240,4 +240,34 @@ describe("FeeBreakdownBottomSheet", () => { expect(queryByText(`${BASE_FEE} XLM`)).toBeNull(); }); }); + + describe("inclusion fee override (preview)", () => { + it("previews the override inclusion fee and total without using the stored fee", () => { + mockUseTransactionBuilderStore.mockReturnValue({ + ...defaultBuilderState, + sorobanInclusionFeeXlm: INCLUSION_FEE, + sorobanResourceFeeXlm: RESOURCE_FEE, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + const overrideFee = "0.027"; + const { getByText, queryByText } = render( + , + ); + + // Inclusion reflects the in-progress override, not the stored fee. + expect(getByText(`${overrideFee} XLM`)).toBeTruthy(); + expect(queryByText(`${INCLUSION_FEE} XLM`)).toBeNull(); + + // Total = override inclusion + resource fee. + const expectedTotal = (parseFloat(overrideFee) + parseFloat(RESOURCE_FEE)) + .toFixed(7) + .replace(/\.?0+$/, ""); + expect(getByText(`${expectedTotal} XLM`)).toBeTruthy(); + }); + }); }); diff --git a/__tests__/components/TransactionSettingsBottomSheet.test.tsx b/__tests__/components/TransactionSettingsBottomSheet.test.tsx index cea8fe41e..51ecba129 100644 --- a/__tests__/components/TransactionSettingsBottomSheet.test.tsx +++ b/__tests__/components/TransactionSettingsBottomSheet.test.tsx @@ -28,6 +28,8 @@ jest.mock("ducks/swapSettings", () => ({ saveSwapFee: jest.fn(), saveSwapTimeout: jest.fn(), saveSwapSlippage: jest.fn(), + feePriority: "medium", + saveFeePriority: jest.fn(), })), })); jest.mock("hooks/useInitialRecommendedFee", () => ({ @@ -48,18 +50,29 @@ jest.mock("hooks/useColors", () => ({ foreground: { primary: "#000000" }, gray: { 8: "#666666" }, status: { error: "#ff0000", warning: "#ffaa00" }, - background: { primary: "#ffffff" }, + background: { primary: "#ffffff", tertiary: "#f0f0f0" }, + text: { secondary: "#999999" }, lilac: { 9: "#6e56cf", + 11: "#9d8bff", }, }, }), })); +// Mutable so individual tests can simulate the 30s-poll refetch returning new +// preset values (must be `mock`-prefixed to be usable inside the jest.mock factory). +const mockDefaultNetworkFees = { + recommendedFee: "100", + networkCongestion: "LOW", + feePresets: { + low: "0.0001", + medium: "0.001", + high: "0.01", + }, +}; +let mockNetworkFees = mockDefaultNetworkFees; jest.mock("hooks/useNetworkFees", () => ({ - useNetworkFees: () => ({ - recommendedFee: "100", - networkCongestion: "LOW", - }), + useNetworkFees: () => mockNetworkFees, })); jest.mock("hooks/useValidateMemo", () => ({ useValidateMemo: () => ({ error: null }), @@ -82,6 +95,18 @@ jest.mock("@gorhom/bottom-sheet", () => ({ jest.mock("helpers/soroban", () => ({ isContractId: jest.fn(), isSorobanTransaction: jest.fn(), + computeTotalFeeXlm: jest.fn(() => "0"), +})); + +// The fee info icon mounts FeeBreakdownBottomSheet (via useFeeDetailsBottomSheet), +// which reads the transaction builder store. +jest.mock("ducks/transactionBuilder", () => ({ + useTransactionBuilderStore: jest.fn(() => ({ + sorobanResourceFeeXlm: null, + sorobanInclusionFeeXlm: null, + isBuilding: false, + error: null, + })), })); jest.mock("services/backend", () => ({ @@ -137,9 +162,11 @@ describe("TransactionSettingsBottomSheet - onSettingsChange Integration", () => collectionAddress: "", tokenId: "", }, + feePriority: "medium", saveMemo: jest.fn(), saveTransactionFee: jest.fn(), saveTransactionTimeout: jest.fn(), + saveFeePriority: jest.fn(), saveRecipientAddress: jest.fn(), saveSelectedTokenId: jest.fn(), saveSelectedCollectibleDetails: jest.fn(), @@ -148,6 +175,7 @@ describe("TransactionSettingsBottomSheet - onSettingsChange Integration", () => beforeEach(() => { jest.clearAllMocks(); + mockNetworkFees = mockDefaultNetworkFees; mockUseTransactionSettingsStore.mockReturnValue( mockTransactionSettingsState, ); @@ -289,6 +317,203 @@ describe("TransactionSettingsBottomSheet - onSettingsChange Integration", () => "Required memo for GA6SXIZIKLJHCZI2KEOBEUUOFMM4JUPPM2UTWX6STAWT25JWIEUFIMFF", ); }); + + it("sets the fee to the High preset when the High tab is pressed", async () => { + const { getByText } = renderWithProviders( + , + ); + + fireEvent.press(getByText("transactionSettings.priorityHigh")); + fireEvent.press(getByText("common.save")); + + await waitFor(() => { + expect( + mockTransactionSettingsState.saveTransactionFee, + ).toHaveBeenCalledWith("0.01"); + // The chosen tier is persisted so it survives refetches and re-entry. + expect(mockTransactionSettingsState.saveFeePriority).toHaveBeenCalledWith( + "high", + ); + }); + }); + + it("sets the fee to the Low preset when the Low tab is pressed", async () => { + const { getByText } = renderWithProviders( + , + ); + + fireEvent.press(getByText("transactionSettings.priorityLow")); + fireEvent.press(getByText("common.save")); + + await waitFor(() => { + expect( + mockTransactionSettingsState.saveTransactionFee, + ).toHaveBeenCalledWith("0.0001"); + }); + }); + + it("locks the fee input for presets and unlocks it for Custom", async () => { + const { getByText, getByTestId } = renderWithProviders( + , + ); + + // The fee hasn't been manually changed, so the default tier is Med and the + // input is locked. + expect(getByTestId("fee-input").props.editable).toBe(false); + + // "Custom" unlocks the input for manual entry. + fireEvent.press(getByText("transactionSettings.priorityCustom")); + await waitFor(() => { + expect(getByTestId("fee-input").props.editable).toBe(true); + }); + + // Selecting a preset locks it again. + fireEvent.press(getByText("transactionSettings.priorityLow")); + await waitFor(() => { + expect(getByTestId("fee-input").props.editable).toBe(false); + }); + }); + + it("opening the fee details does not persist the fee (preview only)", async () => { + mockIsContractId.mockReturnValue(true); + + const { getByText, getByTestId } = renderWithProviders( + , + ); + + // Pick the High preset, then open the fee details via the info icon. + fireEvent.press(getByText("transactionSettings.priorityHigh")); + fireEvent.press(getByTestId("fee-info-button")); + + // The fee is only persisted on Save, not when opening the details. + await waitFor(() => { + expect( + mockTransactionSettingsState.saveTransactionFee, + ).not.toHaveBeenCalled(); + }); + }); + + it("opens on the stored fee priority tier (preset → input locked)", async () => { + // The sheet reflects the persisted tier directly — a stored High tier opens + // locked, regardless of how the fee amount compares to the current presets. + mockUseTransactionSettingsStore.mockReturnValue({ + ...mockTransactionSettingsState, + feePriority: "high", + }); + + const { getByTestId } = renderWithProviders( + , + ); + + await waitFor(() => { + expect(getByTestId("fee-input").props.editable).toBe(false); + }); + }); + + it("opens editable when the stored tier is Custom", async () => { + mockUseTransactionSettingsStore.mockReturnValue({ + ...mockTransactionSettingsState, + feePriority: "custom", + }); + + const { getByTestId } = renderWithProviders( + , + ); + + await waitFor(() => { + expect(getByTestId("fee-input").props.editable).toBe(true); + }); + }); + + it("follows the stored tier when it updates before the user interacts", async () => { + // Stored tier starts Custom (editable); then the store updates (e.g. the + // frozen congestion snapshot lands) and the tab follows + locks the input. + mockUseTransactionSettingsStore.mockReturnValue({ + ...mockTransactionSettingsState, + feePriority: "custom", + }); + const props = { + onCancel: mockOnCancel, + onConfirm: mockOnConfirm, + context: TransactionContext.Send, + onSettingsChange: mockOnSettingsChange, + }; + const { getByTestId, rerender } = renderWithProviders( + , + ); + await waitFor(() => { + expect(getByTestId("fee-input").props.editable).toBe(true); + }); + + mockUseTransactionSettingsStore.mockReturnValue({ + ...mockTransactionSettingsState, + feePriority: "high", + }); + rerender(); + + await waitFor(() => { + expect(getByTestId("fee-input").props.editable).toBe(false); + }); + }); + + it("keeps the selected tier when network presets refetch (no flicker to Custom)", async () => { + // Stored tier is Med (locked input). + const props = { + onCancel: mockOnCancel, + onConfirm: mockOnConfirm, + context: TransactionContext.Send, + onSettingsChange: mockOnSettingsChange, + }; + const { getByTestId, rerender } = renderWithProviders( + , + ); + + await waitFor(() => { + expect(getByTestId("fee-input").props.editable).toBe(false); + }); + + // Simulate the 30s poll returning different preset values. + mockNetworkFees = { + ...mockDefaultNetworkFees, + feePresets: { low: "0.0002", medium: "0.0021", high: "0.02" }, + }; + rerender(); + + // The tier stays Med (input still locked) — it does NOT flip to Custom. + await waitFor(() => { + expect(getByTestId("fee-input").props.editable).toBe(false); + }); + }); }); describe("TransactionSettingsBottomSheet - Soroban Transaction Tests", () => { @@ -308,9 +533,11 @@ describe("TransactionSettingsBottomSheet - Soroban Transaction Tests", () => { collectionAddress: "", tokenId: "", }, + feePriority: "medium", saveMemo: jest.fn(), saveTransactionFee: jest.fn(), saveTransactionTimeout: jest.fn(), + saveFeePriority: jest.fn(), saveRecipientAddress: jest.fn(), saveSelectedTokenId: jest.fn(), saveSelectedCollectibleDetails: jest.fn(), diff --git a/__tests__/components/screens/SwapScreen/SwapAmountScreen.test.tsx b/__tests__/components/screens/SwapScreen/SwapAmountScreen.test.tsx index d8f8a4b7f..180876419 100644 --- a/__tests__/components/screens/SwapScreen/SwapAmountScreen.test.tsx +++ b/__tests__/components/screens/SwapScreen/SwapAmountScreen.test.tsx @@ -184,6 +184,8 @@ jest.mock("ducks/swapSettings", () => ({ saveSwapFee: mockSaveSwapFee, feeManuallyChanged: false, markFeeManuallyChanged: jest.fn(), + feePriority: "medium", + saveFeePriority: jest.fn(), })), })); // Deterministic network fee so the fee-freeze behavior is testable and @@ -193,6 +195,7 @@ jest.mock("hooks/useNetworkFees", () => ({ recommendedFee: "0.001", networkCongestion: "LOW", }), + clearNetworkFeesCache: jest.fn(), })); jest.mock("components/screens/SwapScreen/hooks/useSwapTransaction", () => ({ useSwapTransaction: jest.fn(() => ({ diff --git a/__tests__/ducks/transactionSettings.test.ts b/__tests__/ducks/transactionSettings.test.ts index 3a0a7cf56..ae15a97ff 100644 --- a/__tests__/ducks/transactionSettings.test.ts +++ b/__tests__/ducks/transactionSettings.test.ts @@ -3,6 +3,7 @@ import { DEFAULT_TRANSACTION_TIMEOUT, MIN_TRANSACTION_FEE, } from "config/constants"; +import { FeePriority } from "config/types"; import { useTransactionSettingsStore } from "ducks/transactionSettings"; const store = useTransactionSettingsStore; @@ -23,6 +24,14 @@ describe("transactionSettings Duck", () => { expect(initialState.federationAddress).toBe(""); expect(initialState.recipientName).toBe(""); expect(initialState.selectedTokenId).toBe(""); + expect(initialState.feePriority).toBe(FeePriority.MEDIUM); + }); + + it("should save fee priority", () => { + act(() => { + store.getState().saveFeePriority(FeePriority.HIGH); + }); + expect(store.getState().feePriority).toBe(FeePriority.HIGH); }); it("should save memo", () => { @@ -109,6 +118,7 @@ describe("transactionSettings Duck", () => { store.getState().saveFederationAddress(newFederationAddress); store.getState().saveRecipientName(newRecipientName); store.getState().saveSelectedTokenId(newTokenId); + store.getState().saveFeePriority(FeePriority.HIGH); }); expect(store.getState().transactionMemo).toBe(newMemo); @@ -118,6 +128,7 @@ describe("transactionSettings Duck", () => { expect(store.getState().federationAddress).toBe(newFederationAddress); expect(store.getState().recipientName).toBe(newRecipientName); expect(store.getState().selectedTokenId).toBe(newTokenId); + expect(store.getState().feePriority).toBe(FeePriority.HIGH); act(() => { store.getState().resetSettings(); @@ -132,6 +143,7 @@ describe("transactionSettings Duck", () => { expect(store.getState().federationAddress).toBe(""); expect(store.getState().recipientName).toBe(""); expect(store.getState().selectedTokenId).toBe(""); + expect(store.getState().feePriority).toBe(FeePriority.MEDIUM); }); describe("selectedCollectibleDetails", () => { diff --git a/__tests__/hooks/useNetworkFees.test.ts b/__tests__/hooks/useNetworkFees.test.ts index ee19c7db1..35eae6bdb 100644 --- a/__tests__/hooks/useNetworkFees.test.ts +++ b/__tests__/hooks/useNetworkFees.test.ts @@ -1,6 +1,6 @@ import { renderHook, act } from "@testing-library/react-hooks"; import { NetworkCongestion } from "config/types"; -import { useNetworkFees } from "hooks/useNetworkFees"; +import { clearNetworkFeesCache, useNetworkFees } from "hooks/useNetworkFees"; const mockGetNetworkFees = jest.fn(); @@ -13,9 +13,16 @@ jest.mock("services/stellar", () => ({ stellarSdkServer: jest.fn(() => ({})), })); +const flushPromises = () => + act(async () => { + await Promise.resolve(); + }); + describe("useNetworkFees", () => { beforeEach(() => { mockGetNetworkFees.mockReset(); + // Reset the frozen snapshot so tests don't leak state into each other. + clearNetworkFeesCache(); }); it("seeds a subsequent mount from the last successful fetch (no default flash)", async () => { @@ -29,17 +36,12 @@ describe("useNetworkFees", () => { expect(first.result.current.networkCongestion).toBe(NetworkCongestion.LOW); expect(first.result.current.recommendedFee).toBe(""); - // Flush the fetch promise + its setState. - await act(async () => { - await Promise.resolve(); - }); + await flushPromises(); expect(first.result.current.networkCongestion).toBe(NetworkCongestion.HIGH); expect(first.result.current.recommendedFee).toBe("0.005"); first.unmount(); - // Second mount: a hanging fetch so only the seeded initial state is read. - // It already shows the cached real values — no "Low" / empty flash. - mockGetNetworkFees.mockReturnValue(new Promise(() => {})); + // Second mount reads the cached real values immediately — no flash. const second = renderHook(() => useNetworkFees()); expect(second.result.current.networkCongestion).toBe( NetworkCongestion.HIGH, @@ -47,4 +49,43 @@ describe("useNetworkFees", () => { expect(second.result.current.recommendedFee).toBe("0.005"); second.unmount(); }); + + it("freezes after the first fetch — later mounts reuse the snapshot without refetching", async () => { + mockGetNetworkFees.mockResolvedValue({ + recommendedFee: "0.005", + networkCongestion: NetworkCongestion.HIGH, + }); + + const first = renderHook(() => useNetworkFees()); + await flushPromises(); + first.unmount(); + expect(mockGetNetworkFees).toHaveBeenCalledTimes(1); + + // A later mount within the same flow reuses the cache — no extra fetch. + const second = renderHook(() => useNetworkFees()); + await flushPromises(); + second.unmount(); + expect(mockGetNetworkFees).toHaveBeenCalledTimes(1); + }); + + it("re-fetches after clearNetworkFeesCache (next flow gets fresh values)", async () => { + mockGetNetworkFees.mockResolvedValue({ + recommendedFee: "0.005", + networkCongestion: NetworkCongestion.HIGH, + }); + + const first = renderHook(() => useNetworkFees()); + await flushPromises(); + first.unmount(); + expect(mockGetNetworkFees).toHaveBeenCalledTimes(1); + + // Leaving the flow clears the snapshot, so the next entry fetches again. + act(() => { + clearNetworkFeesCache(); + }); + const second = renderHook(() => useNetworkFees()); + await flushPromises(); + second.unmount(); + expect(mockGetNetworkFees).toHaveBeenCalledTimes(2); + }); }); diff --git a/__tests__/services/stellar.test.ts b/__tests__/services/stellar.test.ts index 841b59982..adf2d309c 100644 --- a/__tests__/services/stellar.test.ts +++ b/__tests__/services/stellar.test.ts @@ -4,12 +4,21 @@ * This test uses the actual functions from stellar.ts */ import { Asset as SdkToken, Operation } from "@stellar/stellar-sdk"; +import { MIN_TRANSACTION_FEE } from "config/constants"; +import { FeePriority, NetworkCongestion } from "config/types"; import { buildChangeTrustOperation, calculateBackoffDelay, + getNetworkFees, isHorizonError, } from "services/stellar"; +type FeeStatsServer = Parameters[0]; + +const buildFeeStatsServer = ( + feeStats: () => Promise, +): FeeStatsServer => ({ feeStats }) as unknown as FeeStatsServer; + describe("stellar service - submitTx retry logic", () => { it("should implement correct delay timing", async () => { jest.useFakeTimers(); @@ -63,6 +72,92 @@ describe("stellar service - submitTx retry logic", () => { }); }); +describe("stellar service - getNetworkFees", () => { + const buildFeeDistribution = (overrides = {}) => ({ + max: "20000", + min: "100", + mode: "500", + p10: "100", + p20: "200", + p30: "300", + p40: "400", + p50: "1000", + p60: "2000", + p70: "3000", + p80: "5000", + p90: "10000", + p95: "15000", + p99: "20000", + ...overrides, + }); + + it("maps max_fee p10/p50/p90 to Low/Med/High presets (XLM)", async () => { + const server = buildFeeStatsServer(() => + Promise.resolve({ + ledger_capacity_usage: "0.2", + max_fee: buildFeeDistribution(), + }), + ); + + const { feePresets } = await getNetworkFees(server); + + expect(feePresets[FeePriority.LOW]).toBe("0.00001"); // p10 = 100 + expect(feePresets[FeePriority.MEDIUM]).toBe("0.0001"); // p50 = 1000 + expect(feePresets[FeePriority.HIGH]).toBe("0.001"); // p90 = 10000 + }); + + it("derives congestion level and a recommended fee matching it (1:1)", async () => { + const lowServer = buildFeeStatsServer(() => + Promise.resolve({ + ledger_capacity_usage: "0.2", + max_fee: buildFeeDistribution(), + }), + ); + const mediumServer = buildFeeStatsServer(() => + Promise.resolve({ + ledger_capacity_usage: "0.6", + max_fee: buildFeeDistribution(), + }), + ); + const highServer = buildFeeStatsServer(() => + Promise.resolve({ + ledger_capacity_usage: "0.9", + max_fee: buildFeeDistribution(), + }), + ); + + const low = await getNetworkFees(lowServer); + expect(low.networkCongestion).toBe(NetworkCongestion.LOW); + expect(low.recommendedFee).toBe(low.feePresets[FeePriority.LOW]); + + const medium = await getNetworkFees(mediumServer); + expect(medium.networkCongestion).toBe(NetworkCongestion.MEDIUM); + expect(medium.recommendedFee).toBe(medium.feePresets[FeePriority.MEDIUM]); + + const high = await getNetworkFees(highServer); + expect(high.networkCongestion).toBe(NetworkCongestion.HIGH); + expect(high.recommendedFee).toBe(high.feePresets[FeePriority.HIGH]); + }); + + it("falls back to defaults when feeStats fails", async () => { + const server = buildFeeStatsServer(() => + Promise.reject(new Error("network error")), + ); + + const { recommendedFee, networkCongestion, feePresets } = + await getNetworkFees(server); + + // Fallbacks are the XLM network minimum (NOT raw stroops): every consumer + // treats these as XLM and converts to stroops at build time, so a stroop + // value here would be a ~1,000,000× fee overpayment. + expect(recommendedFee).toBe(MIN_TRANSACTION_FEE); + expect(networkCongestion).toBe(NetworkCongestion.LOW); + expect(feePresets[FeePriority.LOW]).toBe(MIN_TRANSACTION_FEE); + expect(feePresets[FeePriority.MEDIUM]).toBe(MIN_TRANSACTION_FEE); + expect(feePresets[FeePriority.HIGH]).toBe(MIN_TRANSACTION_FEE); + }); +}); + describe("buildChangeTrustOperation", () => { const ISSUER = "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN"; diff --git a/src/components/FeeBreakdownBottomSheet.tsx b/src/components/FeeBreakdownBottomSheet.tsx index c92bdb421..54c6b5355 100644 --- a/src/components/FeeBreakdownBottomSheet.tsx +++ b/src/components/FeeBreakdownBottomSheet.tsx @@ -19,6 +19,13 @@ type FeeBreakdownBottomSheetProps = { * whether simulation has completed yet. */ isSorobanContext: boolean; + /** + * In-progress inclusion fee (XLM) to preview before it is saved. When the + * breakdown is opened from the settings sheet mid-edit, this reflects the + * user's current (unsaved) selection so the inclusion/total update without + * committing the fee — Cancel still reverts to the stored value. + */ + inclusionFeeXlmOverride?: string; }; /** @@ -35,6 +42,7 @@ type FeeBreakdownBottomSheetProps = { const FeeBreakdownBottomSheet: React.FC = ({ onClose, isSorobanContext, + inclusionFeeXlmOverride, }) => { const { t } = useAppTranslation(); const { themeColors } = useColors(); @@ -46,10 +54,18 @@ const FeeBreakdownBottomSheet: React.FC = ({ } = useTransactionBuilderStore(); const { transactionFee } = useTransactionSettingsStore(); + // Preview the in-progress fee when provided, otherwise the built/stored fee. + const effectiveInclusionFeeXlm = + inclusionFeeXlmOverride ?? sorobanInclusionFeeXlm ?? transactionFee; + + // computeTotalFeeXlm uses the 3rd arg only in the CLASSIC branch (when there's + // no Soroban inclusion+resource pair). There it must be the effective fee, + // which for a preview is the override — hence arg1 and arg3 share the same + // expression by design. const totalFeeXlm = computeTotalFeeXlm( - sorobanInclusionFeeXlm, + inclusionFeeXlmOverride ?? sorobanInclusionFeeXlm, sorobanResourceFeeXlm, - transactionFee, + effectiveInclusionFeeXlm, ); // When simulation has failed the stored fee fields are null — show a dash @@ -102,8 +118,7 @@ const FeeBreakdownBottomSheet: React.FC = ({ {hasBuildError ? "—" : formatTokenForDisplay( - // Pre-simulation: show the user-selected base fee; post-simulation: show the simulated inclusion fee - sorobanInclusionFeeXlm ?? transactionFee, + effectiveInclusionFeeXlm, NATIVE_TOKEN_CODE, )} diff --git a/src/components/TransactionSettingsBottomSheet.tsx b/src/components/TransactionSettingsBottomSheet.tsx index 1cd55a7ae..d974dd4b1 100644 --- a/src/components/TransactionSettingsBottomSheet.tsx +++ b/src/components/TransactionSettingsBottomSheet.tsx @@ -7,6 +7,7 @@ import { Button } from "components/sds/Button"; import Icon from "components/sds/Icon"; import { Input } from "components/sds/Input"; import { NetworkCongestionIndicator } from "components/sds/NetworkCongestionIndicator"; +import SegmentedControl from "components/sds/SegmentedControl"; import { Text } from "components/sds/Typography"; import { MAX_SLIPPAGE, @@ -17,7 +18,7 @@ import { TransactionSetting, mapNetworkToNetworkDetails, } from "config/constants"; -import { NetworkCongestion } from "config/types"; +import { FeePresets, FeePriority } from "config/types"; import { useAuthenticationStore } from "ducks/auth"; import { useSwapSettingsStore } from "ducks/swapSettings"; import { useTransactionSettingsStore } from "ducks/transactionSettings"; @@ -31,6 +32,7 @@ import { enforceSettingInputDecimalSeparator } from "helpers/transactionSettings import useAppTranslation from "hooks/useAppTranslation"; import { useBalancesList } from "hooks/useBalancesList"; import useColors from "hooks/useColors"; +import { useFeeDetailsBottomSheet } from "hooks/useFeeDetailsBottomSheet"; import { useInitialRecommendedFee } from "hooks/useInitialRecommendedFee"; import { useNetworkFees } from "hooks/useNetworkFees"; import { useValidateMemo } from "hooks/useValidateMemo"; @@ -51,7 +53,6 @@ type TransactionSettingsBottomSheetProps = { onConfirm: () => void; context: TransactionContext; onSettingsChange?: () => void; - onOpenFeeBreakdown?: () => void; /** * Number of operations the transaction bundles. The fee is the TOTAL across * all ops, so the recommended default scales by this and the minimum is @@ -71,13 +72,12 @@ const TransactionSettingsBottomSheet: React.FC< onConfirm, context, onSettingsChange, - onOpenFeeBreakdown, operationCount = 1, }) => { // All hooks at the top const { t } = useAppTranslation(); const { themeColors } = useColors(); - const { recommendedFee, networkCongestion } = useNetworkFees(); + const { recommendedFee, networkCongestion, feePresets } = useNetworkFees(); const { transactionMemo, @@ -89,6 +89,8 @@ const TransactionSettingsBottomSheet: React.FC< saveMemo: saveTransactionMemo, saveTransactionFee, saveTransactionTimeout, + feePriority: txFeePriority, + saveFeePriority: saveTxFeePriority, } = useTransactionSettingsStore(); const { @@ -98,16 +100,18 @@ const TransactionSettingsBottomSheet: React.FC< saveSwapFee, saveSwapTimeout, saveSwapSlippage, + feePriority: swapFeePriority, + saveFeePriority: saveSwapFeePriority, } = useSwapSettingsStore(); const { markAsManuallyChanged } = useInitialRecommendedFee( recommendedFee, context, operationCount, + networkCongestion, ); const timeoutInfoBottomSheetModalRef = useRef(null); - const feeInfoBottomSheetModalRef = useRef(null); const memoInfoBottomSheetModalRef = useRef(null); const slippageInfoBottomSheetModalRef = useRef(null); @@ -209,6 +213,12 @@ const TransactionSettingsBottomSheet: React.FC< const memo = context === TransactionContext.Swap ? "" : transactionMemo; const storeFee = context === TransactionContext.Swap ? swapFee : transactionFee; + const storeFeePriority = + context === TransactionContext.Swap ? swapFeePriority : txFeePriority; + const saveFeePriorityForContext = + context === TransactionContext.Swap + ? saveSwapFeePriority + : saveTxFeePriority; const timeout = context === TransactionContext.Swap ? swapTimeout : transactionTimeout; @@ -227,42 +237,61 @@ const TransactionSettingsBottomSheet: React.FC< TransactionSetting.Memo, ]; - // The displayed fee, floored at the effective minimum for this transaction: - // each op needs at least MIN_TRANSACTION_FEE, so a multi-op transaction (e.g. - // a swap-to-new-token's changeTrust + path payment) floors at - // operationCount × that. A stored fee below this would be clamped up to it at - // build time anyway, so showing the floored value avoids a confusing "fee too - // low" error for a fee that's already corrected when the tx is built. + // Each op needs at least MIN_TRANSACTION_FEE, so a multi-op transaction (e.g. + // a swap-to-new-token's changeTrust + path payment) floors the total at + // operationCount × that. const minTotalFee = new BigNumber(MIN_TRANSACTION_FEE).times(operationCount); const storeFeeBn = new BigNumber(storeFee); - const displayedStoreFee = formatNumberForDisplay( + const flooredStoreFee = formatNumberForDisplay( (storeFeeBn.isFinite() ? BigNumber.max(storeFeeBn, minTotalFee) : minTotalFee ).toString(), ); - // State hooks - const [localFee, setLocalFee] = useState(displayedStoreFee); + // Presets are per-op rates; the shown/stored fee is the TOTAL across all ops. + // Returns undefined when the preset hasn't loaded yet (empty/NaN), e.g. on a + // cold start before the first feeStats fetch resolves. + const presetTotalFee = (priority: keyof FeePresets): string | undefined => { + const preset = feePresets[priority]; + if (!preset || new BigNumber(preset).isNaN()) { + return undefined; + } + return new BigNumber(preset).times(operationCount).toString(); + }; + + // The selected tier. Until the user interacts, it mirrors the stored tier + // (set from network congestion once the frozen fee snapshot lands), so the + // tab, the shown fee, and the congestion icon stay consistent during the + // initial load instead of the tab sticking while the others update. Once the + // user picks a tier or types, their choice sticks (no further syncing). + const feeInteractedRef = useRef(false); + const [selectedFeePriority, setSelectedFeePriority] = + useState(storeFeePriority); + useEffect(() => { + if (!feeInteractedRef.current) { + setSelectedFeePriority(storeFeePriority); + } + }, [storeFeePriority]); + // The editable value, used while on the "Custom" tier. + const [customFee, setCustomFee] = useState(flooredStoreFee); const [localMemo, setLocalMemo] = useState(memo); const [localTimeout, setLocalTimeout] = useState(timeout.toString()); const [localSlippage, setLocalSlippage] = useState( enforceSettingInputDecimalSeparator(slippage.toString()), ); - // Mirror the (floored) store fee into the displayed value until the user - // edits it. The store fee can update right after mount — e.g. - // useInitialRecommendedFee re-scales the recommended fee by operationCount in - // an effect — and the useState init above only captures the value at mount, - // which otherwise showed stale until the sheet was closed and reopened. The - // store fee only changes outside of typing, so this never clobbers an - // in-progress edit. - const feeEdited = useRef(false); - useEffect(() => { - if (!feeEdited.current) { - setLocalFee(displayedStoreFee); - } - }, [displayedStoreFee]); + // The value shown in the input: for a preset tier it's the current preset + // total (kept in step with refetched fees); for Custom it's the editable + // amount. (Falls back to the floored store fee if the preset hasn't loaded.) + const presetTotal = + selectedFeePriority === FeePriority.CUSTOM + ? undefined + : presetTotalFee(selectedFeePriority); + const localFee = + selectedFeePriority === FeePriority.CUSTOM + ? customFee + : (presetTotal && formatNumberForDisplay(presetTotal)) || flooredStoreFee; useEffect(() => { if (isMemoDisabled && localMemo) { @@ -361,9 +390,10 @@ const TransactionSettingsBottomSheet: React.FC< ); const handleFeeChange = useCallback((text: string) => { - feeEdited.current = true; + // Manual typing is only possible on the Custom tier; update its value. + feeInteractedRef.current = true; const normalizedText = enforceSettingInputDecimalSeparator(text); - setLocalFee(normalizedText); + setCustomFee(normalizedText); }, []); const handleTimeoutChange = useCallback((text: string) => { @@ -371,22 +401,45 @@ const TransactionSettingsBottomSheet: React.FC< setLocalTimeout(integerOnly); }, []); - const getLocalizedCongestionLevel = useCallback( - (congestion: NetworkCongestion): string => { - switch (congestion) { - case NetworkCongestion.LOW: - return t("low"); - case NetworkCongestion.MEDIUM: - return t("medium"); - case NetworkCongestion.HIGH: - return t("high"); - default: - return t("low"); + const feePriorityOptions = useMemo( + () => [ + { label: t("transactionSettings.priorityLow"), value: FeePriority.LOW }, + { + label: t("transactionSettings.priorityMed"), + value: FeePriority.MEDIUM, + }, + { label: t("transactionSettings.priorityHigh"), value: FeePriority.HIGH }, + { + label: t("transactionSettings.priorityCustom"), + value: FeePriority.CUSTOM, + }, + ], + [t], + ); + + const handleFeePriorityChange = useCallback( + (value: string | number) => { + feeInteractedRef.current = true; + const priority = value as FeePriority; + // Preset tiers derive their shown value automatically; switching to + // Custom seeds the editable input with the amount currently shown so it + // doesn't blank or jump. Persisted on Save (settingSaveCallbacks). + if (priority === FeePriority.CUSTOM) { + setCustomFee(localFee); } + setSelectedFeePriority(priority); }, - [t], + [localFee], ); + // Preview the unsaved fee in the breakdown; skip the override while invalid. + const { openFeeDetails, feeDetailsSheets } = useFeeDetailsBottomSheet({ + isSorobanContext: isSorobanTransaction, + inclusionFeeXlmOverride: feeError + ? undefined + : parseDisplayNumber(localFee).toString(), + }); + // Data objects and configurations const settingErrors = { [TransactionSetting.Memo]: memoError, @@ -401,6 +454,7 @@ const TransactionSettingsBottomSheet: React.FC< saveSlippage(Number(parseDisplayNumber(localSlippage))), [TransactionSetting.Fee]: () => { markAsManuallyChanged(); + saveFeePriorityForContext(selectedFeePriority); saveFee(parseDisplayNumber(localFee).toString()); }, [TransactionSetting.Timeout]: () => saveTimeout(Number(localTimeout)), @@ -544,62 +598,30 @@ const TransactionSettingsBottomSheet: React.FC< ? t("transactionSettings.inclusionFeeTitle") : t("transactionSettings.feeTitle")} - - isSorobanTransaction && onOpenFeeBreakdown - ? onOpenFeeBreakdown() - : feeInfoBottomSheetModalRef.current?.present() - } - > + - { - feeEdited.current = true; - markAsManuallyChanged(); - // Reset to the recommended TOTAL across all ops (the network - // recommendation is a per-op rate). - setLocalFee( - formatNumberForDisplay( - new BigNumber(recommendedFee || MIN_TRANSACTION_FEE) - .times(operationCount) - .toString(), - ), - ); - }} - > - - {t("transactionSettings.resetFee")} + + + + {t("transactionSettings.network")} - + } onChangeText={handleFeeChange} keyboardType="numeric" placeholder={formatNumberForDisplay(MIN_TRANSACTION_FEE)} error={feeError} - note={ - - - - - - {t("transactionSettings.congestion", { - networkCongestion: - getLocalizedCongestionLevel(networkCongestion), - })} - - - - } + // Low/Med/High lock the fee to a network preset; only "Custom" + // allows manual entry. + editable={selectedFeePriority === FeePriority.CUSTOM} rightElement={ {NATIVE_TOKEN_CODE} @@ -607,21 +629,26 @@ const TransactionSettingsBottomSheet: React.FC< } /> + + + ), [ isSorobanTransaction, - onOpenFeeBreakdown, + openFeeDetails, localFee, feeError, t, - themeColors.lilac, networkCongestion, - getLocalizedCongestionLevel, handleFeeChange, - recommendedFee, - markAsManuallyChanged, - operationCount, + feePriorityOptions, + selectedFeePriority, + handleFeePriorityChange, ], ); @@ -692,23 +719,6 @@ const TransactionSettingsBottomSheet: React.FC< }, ], }, - { - IconComponent: Icon.Route, - key: "feeInfo" as const, - modalRef: feeInfoBottomSheetModalRef, - title: t("transactionSettings.feeInfo.title"), - onClose: () => feeInfoBottomSheetModalRef.current?.dismiss(), - texts: [ - { - key: "description", - value: t("transactionSettings.feeInfo.description"), - }, - { - key: "additionalInfo", - value: t("transactionSettings.feeInfo.additionalInfo"), - }, - ], - }, { IconComponent: Icon.ClockRefresh, key: "timeoutInfo" as const, @@ -779,6 +789,7 @@ const TransactionSettingsBottomSheet: React.FC< /> ), )} + {feeDetailsSheets} ); }; diff --git a/src/components/screens/SendScreen/components/SendReviewBottomSheet.tsx b/src/components/screens/SendScreen/components/SendReviewBottomSheet.tsx index 604ed7601..2a7fd36e4 100644 --- a/src/components/screens/SendScreen/components/SendReviewBottomSheet.tsx +++ b/src/components/screens/SendScreen/components/SendReviewBottomSheet.tsx @@ -1,8 +1,5 @@ -import { BottomSheetModal } from "@gorhom/bottom-sheet"; import BigNumber from "bignumber.js"; -import BottomSheet from "components/BottomSheet"; import { CollectibleImage } from "components/CollectibleImage"; -import FeeBreakdownBottomSheet from "components/FeeBreakdownBottomSheet"; import { List, ListItemProps } from "components/List"; import { TokenIcon } from "components/TokenIcon"; import SignTransactionDetails from "components/screens/SignTransactionDetails"; @@ -22,7 +19,7 @@ import { useTransactionSettingsStore } from "ducks/transactionSettings"; import { isLiquidityPool } from "helpers/balances"; import { pxValue } from "helpers/dimensions"; import { formatTokenForDisplay, formatFiatAmount } from "helpers/formatAmount"; -import { computeTotalFeeXlm, isSorobanTransaction } from "helpers/soroban"; +import { isSorobanTransaction } from "helpers/soroban"; import { truncateAddress, truncateFedAddress, @@ -31,8 +28,9 @@ import { import useAppTranslation from "hooks/useAppTranslation"; import { useClipboard } from "hooks/useClipboard"; import useColors from "hooks/useColors"; +import { useFeeDetailsBottomSheet } from "hooks/useFeeDetailsBottomSheet"; import useGetActiveAccount from "hooks/useGetActiveAccount"; -import React, { useCallback, useMemo, useRef } from "react"; +import React, { useCallback, useMemo } from "react"; import { ActivityIndicator, TouchableOpacity, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -108,26 +106,8 @@ const SendReviewBottomSheet: React.FC = ({ const { account } = useGetActiveAccount(); const { copyToClipboard } = useClipboard(); const slicedAddress = truncateAddress(recipientAddress, 4, 4); - const { - transactionXDR, - isBuilding, - error, - isSoroban, - sorobanResourceFeeXlm, - sorobanInclusionFeeXlm, - } = useTransactionBuilderStore(); - - const feeBreakdownSheetRef = useRef(null); - - const handleOpenFeeBreakdown = useCallback(() => { - feeBreakdownSheetRef.current?.present(); - }, []); - - const totalFeeXlm = computeTotalFeeXlm( - sorobanInclusionFeeXlm, - sorobanResourceFeeXlm, - transactionFee, - ); + const { transactionXDR, isBuilding, error, sorobanInclusionFeeXlm } = + useTransactionBuilderStore(); // Derived from current context (collectible or Soroban token/address) rather // than the builder store so the fee breakdown sheet shows Soroban rows and @@ -136,6 +116,14 @@ const SendReviewBottomSheet: React.FC = ({ type === SendType.Collectible || isSorobanTransaction(selectedBalance, recipientAddress); + // Mirror the settings sheet: show the inclusion fee, not the total (the + // inclusion/resource/total split lives in the breakdown). + const inclusionFeeXlm = sorobanInclusionFeeXlm ?? transactionFee; + + const { openFeeDetails, feeDetailsSheets } = useFeeDetailsBottomSheet({ + isSorobanContext, + }); + // Use amountError from props (calculated in parent component) const amountError = propAmountError; @@ -293,8 +281,9 @@ const SendReviewBottomSheet: React.FC = ({ ), } : undefined, - // Single fee row — total fee on the right with an info icon that opens - // FeeBreakdownBottomSheet (where the inclusion/resource split lives). + // Fee row — shows the inclusion fee (mirrors the settings sheet). The + // info icon opens the breakdown for Soroban (inclusion/resource/total) + // or a plain fee info sheet for classic transactions. { icon: , title: t("transactionAmountScreen.details.fee"), @@ -306,17 +295,15 @@ const SendReviewBottomSheet: React.FC = ({ /> ) : ( - {isSoroban && ( - - - - )} + + + - {formatTokenForDisplay(totalFeeXlm, NATIVE_TOKEN_CODE)} + {formatTokenForDisplay(inclusionFeeXlm, NATIVE_TOKEN_CODE)} ), @@ -356,15 +343,14 @@ const SendReviewBottomSheet: React.FC = ({ account?.publicKey, error, handleCopyXdr, - handleOpenFeeBreakdown, + openFeeDetails, isBuilding, - isSoroban, renderMemoTitle, renderXdrContent, t, themeColors.foreground.primary, themeColors.text.secondary, - totalFeeXlm, + inclusionFeeXlm, transactionMemo, transactionXDR, isRecipientMuxed, @@ -443,16 +429,7 @@ const SendReviewBottomSheet: React.FC = ({ analyticsEvent={AnalyticsEvent.VIEW_SEND_TRANSACTION_DETAILS} /> )} - feeBreakdownSheetRef.current?.dismiss()} - customContent={ - feeBreakdownSheetRef.current?.dismiss()} - isSorobanContext={isSorobanContext} - /> - } - /> + {feeDetailsSheets} ); }; diff --git a/src/components/screens/SendScreen/screens/SendCollectibleReview.tsx b/src/components/screens/SendScreen/screens/SendCollectibleReview.tsx index 4f2fe2817..1b922a02a 100644 --- a/src/components/screens/SendScreen/screens/SendCollectibleReview.tsx +++ b/src/components/screens/SendScreen/screens/SendCollectibleReview.tsx @@ -3,7 +3,6 @@ import { BottomSheetModal } from "@gorhom/bottom-sheet"; import { NativeStackScreenProps } from "@react-navigation/native-stack"; import BottomSheet from "components/BottomSheet"; import { CollectibleImage } from "components/CollectibleImage"; -import FeeBreakdownBottomSheet from "components/FeeBreakdownBottomSheet"; import { IconButton } from "components/IconButton"; import InformationBottomSheet from "components/InformationBottomSheet"; import { List, ListItemProps } from "components/List"; @@ -104,9 +103,14 @@ const SendCollectibleReviewScreen: React.FC< } }, [tokenId, collectionAddress, saveSelectedCollectibleDetails]); - const { recommendedFee } = useNetworkFees(); + const { recommendedFee, networkCongestion } = useNetworkFees(); - useInitialRecommendedFee(recommendedFee, TransactionContext.Send); + useInitialRecommendedFee( + recommendedFee, + TransactionContext.Send, + 1, + networkCongestion, + ); const { buildSendCollectibleTransaction, @@ -135,7 +139,6 @@ const SendCollectibleReviewScreen: React.FC< const [isProcessing, setIsProcessing] = useState(false); const addMemoExplanationBottomSheetModalRef = useRef(null); const transactionSettingsBottomSheetModalRef = useRef(null); - const feeBreakdownBottomSheetModalRef = useRef(null); const muxedAddressInfoBottomSheetModalRef = useRef(null); const [transactionScanResult, setTransactionScanResult] = useState< Blockaid.StellarTransactionScanResponse | undefined @@ -692,21 +695,6 @@ const SendCollectibleReviewScreen: React.FC< onCancel={handleCancelTransactionSettings} onConfirm={handleConfirmTransactionSettings} onSettingsChange={handleSettingsChange} - onOpenFeeBreakdown={() => - feeBreakdownBottomSheetModalRef.current?.present() - } - /> - } - /> - - feeBreakdownBottomSheetModalRef.current?.dismiss() - } - customContent={ - feeBreakdownBottomSheetModalRef.current?.dismiss()} - isSorobanContext /> } /> diff --git a/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx b/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx index d498557a6..9856bc8cd 100644 --- a/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx +++ b/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx @@ -4,7 +4,6 @@ import { NativeStackScreenProps } from "@react-navigation/native-stack"; import { BigNumber } from "bignumber.js"; import { AmountCard } from "components/AmountCard"; import BottomSheet from "components/BottomSheet"; -import FeeBreakdownBottomSheet from "components/FeeBreakdownBottomSheet"; import InformationBottomSheet from "components/InformationBottomSheet"; import MuxedAddressWarningBottomSheet from "components/MuxedAddressWarningBottomSheet"; import { PercentageButtons } from "components/PercentageButtons"; @@ -171,7 +170,7 @@ const TransactionAmountScreen: React.FC = ({ useValidateTransactionMemo(transactionXDR); const { scanTransaction } = useBlockaidTransaction(); - const { recommendedFee } = useNetworkFees(); + const { recommendedFee, networkCongestion } = useNetworkFees(); const publicKey = account?.publicKey; const amountInputRef = useRef(null); @@ -220,7 +219,6 @@ const TransactionAmountScreen: React.FC = ({ }, [transactionBuilderError, showToast]); const addMemoExplanationBottomSheetModalRef = useRef(null); const transactionSettingsBottomSheetModalRef = useRef(null); - const feeBreakdownBottomSheetModalRef = useRef(null); const muxedAddressInfoBottomSheetModalRef = useRef(null); const [transactionScanResult, setTransactionScanResult] = useState< Blockaid.StellarTransactionScanResponse | undefined @@ -420,6 +418,8 @@ const TransactionAmountScreen: React.FC = ({ useInitialRecommendedFee( hasEnteredAmount ? "" : recommendedFee, TransactionContext.Send, + 1, + networkCongestion, ); const unfundedContext: UnfundedDestinationContext | undefined = useMemo( @@ -1106,24 +1106,6 @@ const TransactionAmountScreen: React.FC = ({ onCancel={handleCancelTransactionSettings} onConfirm={handleConfirmTransactionSettings} onSettingsChange={handleSettingsChange} - onOpenFeeBreakdown={() => - feeBreakdownBottomSheetModalRef.current?.present() - } - /> - } - /> - - feeBreakdownBottomSheetModalRef.current?.dismiss() - } - customContent={ - feeBreakdownBottomSheetModalRef.current?.dismiss()} - isSorobanContext={isSorobanTransaction( - selectedBalance, - recipientAddress, - )} /> } /> diff --git a/src/components/screens/SwapScreen/components/SwapReviewBottomSheet.tsx b/src/components/screens/SwapScreen/components/SwapReviewBottomSheet.tsx index 97dd285d8..b7f9620df 100644 --- a/src/components/screens/SwapScreen/components/SwapReviewBottomSheet.tsx +++ b/src/components/screens/SwapScreen/components/SwapReviewBottomSheet.tsx @@ -17,16 +17,19 @@ import Icon from "components/sds/Icon"; import { TextButton } from "components/sds/TextButton"; import { Text } from "components/sds/Typography"; import { AnalyticsEvent } from "config/analyticsConfig"; -import { DEFAULT_PADDING } from "config/constants"; +import { DEFAULT_PADDING, NATIVE_TOKEN_CODE } from "config/constants"; import { THEME } from "config/theme"; import { useAuthenticationStore } from "ducks/auth"; import { useSwapStore } from "ducks/swap"; +import { useSwapSettingsStore } from "ducks/swapSettings"; import { useTransactionBuilderStore } from "ducks/transactionBuilder"; import { pxValue } from "helpers/dimensions"; +import { formatTokenForDisplay } from "helpers/formatAmount"; import { truncateAddress } from "helpers/stellar"; import useAppTranslation from "hooks/useAppTranslation"; import { useBalancesList } from "hooks/useBalancesList"; import useColors from "hooks/useColors"; +import { useFeeDetailsBottomSheet } from "hooks/useFeeDetailsBottomSheet"; import useGetActiveAccount from "hooks/useGetActiveAccount"; import React, { useRef } from "react"; import { TouchableOpacity, View } from "react-native"; @@ -59,7 +62,9 @@ const SwapReviewBottomSheet: React.FC = ({ sourceTokenId, destinationToken: destinationTokenDescriptor, } = useSwapStore(); - const { transactionXDR } = useTransactionBuilderStore(); + const { transactionXDR, sorobanInclusionFeeXlm } = + useTransactionBuilderStore(); + const { swapFee } = useSwapSettingsStore(); const transactionDetails = useSignTransactionDetails({ xdr: transactionXDR || "", }); @@ -67,6 +72,13 @@ const SwapReviewBottomSheet: React.FC = ({ useRef(null); const trustlineInfoRef = useRef(null); + // The fee row mirrors the settings sheet: the inclusion fee the user set. + // Swaps are classic, so the info icon opens the fee info sheet (not a breakdown). + const inclusionFeeXlm = sorobanInclusionFeeXlm ?? swapFee; + const { openFeeDetails, feeDetailsSheets } = useFeeDetailsBottomSheet({ + isSorobanContext: false, + }); + const handleOpenTransactionDetails = () => { swapTransactionDetailsBottomSheetModalRef.current?.present(); }; @@ -221,6 +233,30 @@ const SwapReviewBottomSheet: React.FC = ({ ), }, + { + icon: ( + + ), + titleComponent: ( + + {t("transactionAmountScreen.details.fee")} + + ), + trailingContent: ( + + + + + + {formatTokenForDisplay(inclusionFeeXlm, NATIVE_TOKEN_CODE)} + + + ), + }, ]} /> @@ -262,6 +298,7 @@ const SwapReviewBottomSheet: React.FC = ({ /> } /> + {feeDetailsSheets} ); }; diff --git a/src/components/screens/SwapScreen/screens/SwapAmountScreen.tsx b/src/components/screens/SwapScreen/screens/SwapAmountScreen.tsx index 262a699f5..009c82e29 100644 --- a/src/components/screens/SwapScreen/screens/SwapAmountScreen.tsx +++ b/src/components/screens/SwapScreen/screens/SwapAmountScreen.tsx @@ -68,7 +68,7 @@ import { type HeldBalanceItem, useBalancesList } from "hooks/useBalancesList"; import useColors from "hooks/useColors"; import useGetActiveAccount from "hooks/useGetActiveAccount"; import { useInitialRecommendedFee } from "hooks/useInitialRecommendedFee"; -import { useNetworkFees } from "hooks/useNetworkFees"; +import { clearNetworkFeesCache, useNetworkFees } from "hooks/useNetworkFees"; import { useTokenFiatConverter } from "hooks/useTokenFiatConverter"; import { useToast } from "providers/ToastProvider"; import React, { @@ -132,7 +132,7 @@ const SwapAmountScreen: React.FC = ({ network, }); - const { recommendedFee } = useNetworkFees(); + const { recommendedFee, networkCongestion } = useNetworkFees(); const { sourceTokenId, @@ -403,6 +403,7 @@ const SwapAmountScreen: React.FC = ({ hasEnteredSourceAmount ? "" : recommendedFee, TransactionContext.Swap, swapOperationCount, + networkCongestion, ); const { @@ -685,12 +686,14 @@ const SwapAmountScreen: React.FC = ({ xlmReserveBottomSheetRef, ]); - // Reset everything on unmount + // Reset everything on unmount (incl. the frozen network-fee snapshot, so the + // next flow re-fetches fresh values). useEffect( () => () => { resetSwap(); resetTransaction(); resetToDefaults(); + clearNetworkFeesCache(); setActiveError(null); }, [resetSwap, resetTransaction, resetToDefaults, setActiveError], diff --git a/src/components/sds/SegmentedControl.tsx b/src/components/sds/SegmentedControl.tsx index 952d2b389..487eaf19b 100644 --- a/src/components/sds/SegmentedControl.tsx +++ b/src/components/sds/SegmentedControl.tsx @@ -33,25 +33,22 @@ const SegmentedControl: React.FC = ({ const { themeColors } = useColors(); return ( - - {options.map((option, index) => { + + {options.map((option) => { const isSelected = option.value === selectedValue; - const isFirst = index === 0; - const isLast = index === options.length - 1; return ( !disabled && onValueChange(option.value)} disabled={disabled} - className={`flex-1 py-2 px-3 ${isSelected ? "bg-lilac-4" : ""} ${ - isFirst ? "rounded-l-lg" : "" - } ${isLast ? "rounded-r-lg" : ""}`} + className={`flex-1 items-center justify-center rounded-md px-[10px] py-[6px] ${ + isSelected ? "bg-lilac-4" : "" + }`} > ; + +/** Network congestion maps 1:1 to the default fee priority tier. */ +export const CONGESTION_TO_FEE_PRIORITY: Record< + NetworkCongestion, + FeePriority.LOW | FeePriority.MEDIUM | FeePriority.HIGH +> = { + [NetworkCongestion.LOW]: FeePriority.LOW, + [NetworkCongestion.MEDIUM]: FeePriority.MEDIUM, + [NetworkCongestion.HIGH]: FeePriority.HIGH, +}; + export enum HookStatus { IDLE = "idle", LOADING = "loading", diff --git a/src/ducks/swapSettings.ts b/src/ducks/swapSettings.ts index 270ab5497..bf4cf001a 100644 --- a/src/ducks/swapSettings.ts +++ b/src/ducks/swapSettings.ts @@ -3,6 +3,7 @@ import { MIN_TRANSACTION_FEE, DEFAULT_SLIPPAGE, } from "config/constants"; +import { FeePriority } from "config/types"; import { create } from "zustand"; const INITIAL_SWAP_SETTINGS_STATE = { @@ -10,6 +11,9 @@ const INITIAL_SWAP_SETTINGS_STATE = { swapTimeout: DEFAULT_TRANSACTION_TIMEOUT, swapSlippage: DEFAULT_SLIPPAGE, feeManuallyChanged: false, + // See transactionSettings: stored so the sheet shows the chosen tier rather + // than reverse-deriving it from the (drifting) fee amount. + feePriority: FeePriority.MEDIUM, }; interface SwapSettingsState { @@ -17,11 +21,13 @@ interface SwapSettingsState { swapTimeout: number; swapSlippage: number; feeManuallyChanged: boolean; + feePriority: FeePriority; saveSwapFee: (fee: string) => void; saveSwapTimeout: (timeout: number) => void; saveSwapSlippage: (slippage: number) => void; markFeeManuallyChanged: () => void; + saveFeePriority: (feePriority: FeePriority) => void; resetSettings: () => void; resetToDefaults: () => void; } @@ -33,6 +39,7 @@ export const useSwapSettingsStore = create((set) => ({ saveSwapTimeout: (timeout) => set({ swapTimeout: timeout }), saveSwapSlippage: (slippage) => set({ swapSlippage: slippage }), markFeeManuallyChanged: () => set({ feeManuallyChanged: true }), + saveFeePriority: (feePriority) => set({ feePriority }), resetSettings: () => set(INITIAL_SWAP_SETTINGS_STATE), resetToDefaults: () => set(INITIAL_SWAP_SETTINGS_STATE), })); diff --git a/src/ducks/transactionSettings.ts b/src/ducks/transactionSettings.ts index 6aa1bb513..df6d6777f 100644 --- a/src/ducks/transactionSettings.ts +++ b/src/ducks/transactionSettings.ts @@ -2,6 +2,7 @@ import { DEFAULT_TRANSACTION_TIMEOUT, MIN_TRANSACTION_FEE, } from "config/constants"; +import { FeePriority } from "config/types"; import { create } from "zustand"; const INITIAL_TRANSACTION_SETTINGS_STATE = { @@ -18,6 +19,10 @@ const INITIAL_TRANSACTION_SETTINGS_STATE = { tokenId: "", }, feeManuallyChanged: false, + // The selected fee priority tier (Low/Med/High/Custom). Stored as + // first-class state so the sheet shows the user's actual choice rather than + // reverse-deriving it from the fee amount (which drifts as presets refetch). + feePriority: FeePriority.MEDIUM, }; /** @@ -61,6 +66,7 @@ interface TransactionSettingsState { tokenId: string; }; feeManuallyChanged: boolean; + feePriority: FeePriority; saveMemo: (memo: string) => void; saveMemoType: (memoType: string) => void; @@ -75,6 +81,7 @@ interface TransactionSettingsState { tokenId: string; }) => void; markFeeManuallyChanged: () => void; + saveFeePriority: (feePriority: FeePriority) => void; resetSettings: () => void; } @@ -151,6 +158,12 @@ export const useTransactionSettingsStore = create( */ markFeeManuallyChanged: () => set({ feeManuallyChanged: true }), + /** + * Saves the selected fee priority tier (Low/Med/High/Custom) + * @param {FeePriority} feePriority - The selected priority tier + */ + saveFeePriority: (feePriority) => set({ feePriority }), + /** * Resets all transaction settings to their default values */ diff --git a/src/helpers/testUtils.tsx b/src/helpers/testUtils.tsx index 2a8df13ae..facc8e00e 100644 --- a/src/helpers/testUtils.tsx +++ b/src/helpers/testUtils.tsx @@ -33,15 +33,21 @@ jest.mock("helpers/localeUtils"); * const { getByText } = renderWithProviders(); * expect(getByText('Hello')).toBeTruthy(); */ +const AllProviders: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => ( + + + {children} + + +); + export const renderWithProviders: RenderWithProviderType = (component) => { try { - return render( - - - {React.Children.only(component)} - - , - ); + // Use the `wrapper` option (rather than wrapping inline) so the returned + // `rerender` keeps the providers in place across re-renders. + return render(component, { wrapper: AllProviders }); } catch (error) { // eslint-disable-next-line no-console console.error("Error rendering component:", error); diff --git a/src/hooks/useFeeDetailsBottomSheet.tsx b/src/hooks/useFeeDetailsBottomSheet.tsx new file mode 100644 index 000000000..133a09512 --- /dev/null +++ b/src/hooks/useFeeDetailsBottomSheet.tsx @@ -0,0 +1,82 @@ +import { BottomSheetModal } from "@gorhom/bottom-sheet"; +import BottomSheet from "components/BottomSheet"; +import FeeBreakdownBottomSheet from "components/FeeBreakdownBottomSheet"; +import InformationBottomSheet from "components/InformationBottomSheet"; +import Icon from "components/sds/Icon"; +import useAppTranslation from "hooks/useAppTranslation"; +import useColors from "hooks/useColors"; +import React, { useCallback, useRef } from "react"; +import { View } from "react-native"; + +/** + * Shared fee info-icon affordance: Soroban opens the breakdown, classic opens a + * fee info sheet. Returns `openFeeDetails` (the icon's onPress) and + * `feeDetailsSheets` (render once). `inclusionFeeXlmOverride` previews an unsaved + * fee in the breakdown. + */ +export const useFeeDetailsBottomSheet = ({ + isSorobanContext, + inclusionFeeXlmOverride, +}: { + isSorobanContext: boolean; + inclusionFeeXlmOverride?: string; +}): { + openFeeDetails: () => void; + feeDetailsSheets: React.ReactNode; +} => { + const { t } = useAppTranslation(); + const { themeColors } = useColors(); + const breakdownSheetRef = useRef(null); + const infoSheetRef = useRef(null); + + const openFeeDetails = useCallback(() => { + if (isSorobanContext) { + breakdownSheetRef.current?.present(); + } else { + infoSheetRef.current?.present(); + } + }, [isSorobanContext]); + + const feeDetailsSheets = ( + <> + breakdownSheetRef.current?.dismiss()} + customContent={ + breakdownSheetRef.current?.dismiss()} + isSorobanContext={isSorobanContext} + inclusionFeeXlmOverride={inclusionFeeXlmOverride} + /> + } + /> + infoSheetRef.current?.dismiss()} + customContent={ + infoSheetRef.current?.dismiss()} + headerElement={ + + + + } + texts={[ + { + key: "description", + value: t("transactionSettings.feeInfo.description"), + }, + { + key: "additionalInfo", + value: t("transactionSettings.feeInfo.additionalInfo"), + }, + ]} + /> + } + /> + + ); + + return { openFeeDetails, feeDetailsSheets }; +}; diff --git a/src/hooks/useInitialRecommendedFee.ts b/src/hooks/useInitialRecommendedFee.ts index f34119b2f..aaa78bfd9 100644 --- a/src/hooks/useInitialRecommendedFee.ts +++ b/src/hooks/useInitialRecommendedFee.ts @@ -1,13 +1,15 @@ import BigNumber from "bignumber.js"; import { TransactionContext } from "config/constants"; +import { CONGESTION_TO_FEE_PRIORITY, NetworkCongestion } from "config/types"; import { useSwapSettingsStore } from "ducks/swapSettings"; import { useTransactionSettingsStore } from "ducks/transactionSettings"; import { useEffect } from "react"; /** - * Hook to automatically initialize fee with recommended fee if it's still at default - * Uses a global store flag to track if fee was manually changed to prevent overwriting - * user input even when the hook is mounted in multiple places simultaneously. + * Initializes the fee and priority tier from the network recommendation until + * the user manually changes them (tracked by a global store flag so it works + * even when mounted in several places at once). The default tier follows + * network congestion 1:1. * * @param recommendedFee - The recommended fee from the network (a per-operation rate) * @param context - The transaction context (Send or Swap) @@ -15,11 +17,13 @@ import { useEffect } from "react"; * stored fee is the TOTAL across all ops, so the per-op recommended rate is * scaled by this (e.g. 2 for a swap-to-new-token's changeTrust + path * payment). Defaults to 1 (Send / single-op). + * @param networkCongestion - Current congestion; picks the default tier. */ export const useInitialRecommendedFee = ( recommendedFee: string, context: TransactionContext, operationCount = 1, + networkCongestion: NetworkCongestion = NetworkCongestion.LOW, ) => { const isSwap = context === TransactionContext.Swap; @@ -27,12 +31,14 @@ export const useInitialRecommendedFee = ( feeManuallyChanged: txFeeManuallyChanged, markFeeManuallyChanged: markTxFeeManuallyChanged, saveTransactionFee, + saveFeePriority: saveTxFeePriority, } = useTransactionSettingsStore(); const { feeManuallyChanged: swapFeeManuallyChanged, markFeeManuallyChanged: markSwapFeeManuallyChanged, saveSwapFee, + saveFeePriority: saveSwapFeePriority, } = useSwapSettingsStore(); const feeManuallyChanged = isSwap @@ -42,6 +48,7 @@ export const useInitialRecommendedFee = ( ? markSwapFeeManuallyChanged : markTxFeeManuallyChanged; const saveFee = isSwap ? saveSwapFee : saveTransactionFee; + const saveFeePriority = isSwap ? saveSwapFeePriority : saveTxFeePriority; useEffect(() => { if (recommendedFee && !feeManuallyChanged) { @@ -52,8 +59,16 @@ export const useInitialRecommendedFee = ( .times(operationCount) .toString(); saveFee(totalFee); + saveFeePriority(CONGESTION_TO_FEE_PRIORITY[networkCongestion]); } - }, [recommendedFee, saveFee, feeManuallyChanged, operationCount]); + }, [ + recommendedFee, + saveFee, + saveFeePriority, + feeManuallyChanged, + operationCount, + networkCongestion, + ]); return { markAsManuallyChanged }; }; diff --git a/src/hooks/useNetworkFees.ts b/src/hooks/useNetworkFees.ts index 086453362..3b9ff6e0c 100644 --- a/src/hooks/useNetworkFees.ts +++ b/src/hooks/useNetworkFees.ts @@ -1,55 +1,86 @@ import { mapNetworkToNetworkDetails, NETWORKS } from "config/constants"; import { logger } from "config/logger"; -import { NetworkCongestion } from "config/types"; +import { FeePresets, FeePriority, NetworkCongestion } from "config/types"; import { useAuthenticationStore } from "ducks/auth"; import { useEffect, useState } from "react"; import { getNetworkFees, stellarSdkServer } from "services/stellar"; -interface NetworkFeesSnapshot { +export interface NetworkFeesData { recommendedFee: string; networkCongestion: NetworkCongestion; + feePresets: FeePresets; } +// Empty presets until the first fetch resolves: a selected preset tier then +// shows the floored store fee (presetTotalFee returns undefined) rather than a +// bogus value. This is a DIFFERENT "no data" representation than the +// fetch-error path, where `getNetworkFees` falls back to the XLM minimum fee. +const EMPTY_FEE_PRESETS: FeePresets = { + [FeePriority.LOW]: "", + [FeePriority.MEDIUM]: "", + [FeePriority.HIGH]: "", +}; + +const DEFAULT_NETWORK_FEES: NetworkFeesData = { + recommendedFee: "", + networkCongestion: NetworkCongestion.LOW, + feePresets: EMPTY_FEE_PRESETS, +}; + /** - * Last successful fetch per network. New mounts seed their initial state from - * this so a freshly mounted consumer doesn't flash the defaults (empty fee, - * "Low" congestion) before its own fetch resolves. Most visible on the shared - * transaction-settings sheet, which mounts its own useNetworkFees after the - * screen behind it has already loaded the real values. + * Fee snapshot per network, fetched once and then frozen for the duration of a + * send/swap/collectible flow so the congestion level and fees stay consistent + * to the user (and only change when they manually edit). Cleared on flow exit + * via `clearNetworkFeesCache`, so the next flow re-fetches fresh values. */ -const lastNetworkFees: Partial> = {}; +const networkFeesCache: Partial> = {}; + +/** Clears the frozen fee snapshot so the next flow re-fetches. Call on flow exit. */ +export const clearNetworkFeesCache = (): void => { + (Object.keys(networkFeesCache) as NETWORKS[]).forEach((key) => { + delete networkFeesCache[key]; + }); +}; /** - * Hook to retrieve and monitor network fees and congestion levels. + * Hook to retrieve network fees and congestion for the current flow. + * + * The values are fetched once and frozen (no polling): the first consumer to + * mount populates the cache, later consumers (e.g. the settings sheet) read the + * same snapshot, and it stays put for the whole flow. * - * @returns An object containing the recommended fee and network congestion level + * @returns The recommended fee, network congestion, and Low/Med/High presets. */ -export const useNetworkFees = () => { +export const useNetworkFees = (): NetworkFeesData => { const { network } = useAuthenticationStore(); - const cached = lastNetworkFees[network]; - - const [recommendedFee, setRecommendedFee] = useState( - cached?.recommendedFee ?? "", - ); - const [networkCongestion, setNetworkCongestion] = useState( - cached?.networkCongestion ?? NetworkCongestion.LOW, + const [fees, setFees] = useState( + () => networkFeesCache[network] ?? DEFAULT_NETWORK_FEES, ); useEffect(() => { + const cached = networkFeesCache[network]; + if (cached) { + // Frozen snapshot already loaded for this flow — reuse it, don't refetch. + setFees(cached); + return undefined; + } + + let cancelled = false; const fetchNetworkFees = async () => { try { const { networkUrl } = mapNetworkToNetworkDetails(network); const server = stellarSdkServer(networkUrl); - const { recommendedFee: fee, networkCongestion: congestion } = - await getNetworkFees(server); + const data = await getNetworkFees(server); - lastNetworkFees[network] = { - recommendedFee: fee, - networkCongestion: congestion, - }; - setRecommendedFee(fee); - setNetworkCongestion(congestion); + // Guard against an invalid/empty result so the hook never returns + // undefined to consumers. + if (cancelled || !data?.recommendedFee) { + return; + } + + networkFeesCache[network] = data; + setFees(data); } catch (error) { logger.error("[useNetworkFees]", "Error fetching network fees:", error); } @@ -57,12 +88,10 @@ export const useNetworkFees = () => { fetchNetworkFees(); - const interval = setInterval(() => { - fetchNetworkFees(); - }, 30000); - - return () => clearInterval(interval); + return () => { + cancelled = true; + }; }, [network]); - return { recommendedFee, networkCongestion }; + return fees; }; diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index a3a3ce6b8..a9c3d5f8e 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -702,6 +702,11 @@ "timeoutPlaceholder": "Enter timeout", "seconds": "seconds", "congestion": "{{networkCongestion}} congestion", + "network": "Network", + "priorityLow": "Low", + "priorityMed": "Med", + "priorityHigh": "High", + "priorityCustom": "Custom", "memoInfo": { "title": "Memo", "description": "Some destination accounts on the Stellar network require a memo to identify your payment.", diff --git a/src/i18n/locales/pt/translations.json b/src/i18n/locales/pt/translations.json index dc2a3a518..912a21c73 100644 --- a/src/i18n/locales/pt/translations.json +++ b/src/i18n/locales/pt/translations.json @@ -666,6 +666,11 @@ "timeoutPlaceholder": "Digite o tempo limite", "seconds": "segundos", "congestion": "Congestionamento {{networkCongestion}}", + "network": "Rede", + "priorityLow": "Baixa", + "priorityMed": "Méd", + "priorityHigh": "Alta", + "priorityCustom": "Personalizada", "memoInfo": { "title": "Memo", "description": "Algumas contas de destino na rede Stellar requerem um memo para identificar seu pagamento.", diff --git a/src/navigators/SendPaymentNavigator.tsx b/src/navigators/SendPaymentNavigator.tsx index 96f0d3840..5515c0e01 100644 --- a/src/navigators/SendPaymentNavigator.tsx +++ b/src/navigators/SendPaymentNavigator.tsx @@ -19,7 +19,8 @@ import { useTransactionBuilderStore } from "ducks/transactionBuilder"; import { useTransactionSettingsStore } from "ducks/transactionSettings"; import { withTransitionOverride } from "helpers/navigationOptions"; import useAppTranslation from "hooks/useAppTranslation"; -import React from "react"; +import { clearNetworkFeesCache, useNetworkFees } from "hooks/useNetworkFees"; +import React, { useEffect } from "react"; const SendPaymentStack = createNativeStackNavigator(); @@ -49,6 +50,24 @@ const closeSendFlow = ( export const SendPaymentStackNavigator = () => { const { t } = useAppTranslation(); + // Prewarm the network-fee snapshot on flow entry so the amount/settings + // screens read frozen values from cache instead of fetching (and flickering) + // once they're reached. + useNetworkFees(); + + // Reset send-flow state when the whole flow unmounts, so EVERY exit path + // (X, hardware/gesture back, or programmatic) leaves a clean slate. Also clear + // the frozen network-fee snapshot so the next flow re-fetches fresh values. + useEffect( + () => () => { + useSendRecipientStore.getState().resetSendRecipient(); + useTransactionSettingsStore.getState().resetSettings(); + useTransactionBuilderStore.getState().resetTransaction(); + clearNetworkFeesCache(); + }, + [], + ); + return ( (); @@ -16,6 +17,10 @@ const SwapStack = createNativeStackNavigator(); export const SwapStackNavigator = () => { const { t } = useAppTranslation(); + // Prewarm the network-fee snapshot on flow entry so the settings/review read + // frozen values from cache rather than fetching (and flickering) on open. + useNetworkFees(); + return ( { let recommendedFee = ""; let networkCongestion = "" as NetworkCongestion; + // Inclusion-fee presets (XLM) for the Low/Med/High priority tiers, derived + // from the Horizon `max_fee` percentile distribution. Defaults are in XLM + // (the network minimum) — NOT raw stroops — since every consumer treats these + // as XLM and converts to stroops at build time. + let feePresets: FeePresets = { + [FeePriority.LOW]: MIN_TRANSACTION_FEE, + [FeePriority.MEDIUM]: MIN_TRANSACTION_FEE, + [FeePriority.HIGH]: MIN_TRANSACTION_FEE, + }; try { const { max_fee: maxFee, ledger_capacity_usage: ledgerCapacityUsage } = await server.feeStats(); const ledgerCapacityUsageNum = Number(ledgerCapacityUsage); - recommendedFee = stroopToXlm(maxFee.mode).toFixed(); - if (ledgerCapacityUsageNum > 0.5 && ledgerCapacityUsageNum <= 0.75) { + feePresets = { + [FeePriority.LOW]: stroopToXlm(maxFee.p10).toFixed(), + [FeePriority.MEDIUM]: stroopToXlm(maxFee.p50).toFixed(), + [FeePriority.HIGH]: stroopToXlm(maxFee.p90).toFixed(), + }; + + if ( + ledgerCapacityUsageNum > LEDGER_CAPACITY_MEDIUM_THRESHOLD && + ledgerCapacityUsageNum <= LEDGER_CAPACITY_HIGH_THRESHOLD + ) { networkCongestion = NetworkCongestion.MEDIUM; - } else if (ledgerCapacityUsageNum > 0.75) { + } else if (ledgerCapacityUsageNum > LEDGER_CAPACITY_HIGH_THRESHOLD) { networkCongestion = NetworkCongestion.HIGH; } else { networkCongestion = NetworkCongestion.LOW; } + + // Recommended (default) fee = the preset matching current congestion (1:1). + recommendedFee = feePresets[CONGESTION_TO_FEE_PRIORITY[networkCongestion]]; } catch (e) { - // use default values - recommendedFee = DEFAULT_RECOMMENDED_STELLAR_FEE; + // Fall back to the network minimum (XLM); presets stay at their XLM + // defaults set above. + recommendedFee = MIN_TRANSACTION_FEE; networkCongestion = NetworkCongestion.LOW; } - return { recommendedFee, networkCongestion }; + return { recommendedFee, networkCongestion, feePresets }; }; /** Builds a single `changeTrust` operation for a classic asset. */