From de7d178a42e1f91537e83cc1d03ba6353e94f3c4 Mon Sep 17 00:00:00 2001 From: leofelix077 Date: Tue, 23 Jun 2026 10:20:25 -0300 Subject: [PATCH 1/6] adjust configure fees design --- .../FeeBreakdownBottomSheet.test.tsx | 30 ++++ .../TransactionSettingsBottomSheet.test.tsx | 128 ++++++++++++++- .../helpers/transactionSettingsUtils.test.ts | 35 +++- __tests__/services/stellar.test.ts | 92 ++++++++++- src/components/FeeBreakdownBottomSheet.tsx | 19 ++- .../TransactionSettingsBottomSheet.tsx | 153 ++++++++++++------ .../screens/SendCollectibleReview.tsx | 12 +- .../screens/TransactionAmountScreen.tsx | 12 +- src/components/sds/SegmentedControl.tsx | 17 +- src/config/types.ts | 16 ++ src/helpers/transactionSettingsUtils.ts | 31 ++++ src/hooks/useNetworkFees.ts | 66 ++++++-- src/i18n/locales/en/translations.json | 5 + src/i18n/locales/pt/translations.json | 5 + src/services/stellar.ts | 31 +++- 15 files changed, 561 insertions(+), 91 deletions(-) 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..14a270fa5 100644 --- a/__tests__/components/TransactionSettingsBottomSheet.test.tsx +++ b/__tests__/components/TransactionSettingsBottomSheet.test.tsx @@ -48,9 +48,11 @@ 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", }, }, }), @@ -59,6 +61,11 @@ jest.mock("hooks/useNetworkFees", () => ({ useNetworkFees: () => ({ recommendedFee: "100", networkCongestion: "LOW", + feePresets: { + low: "0.0001", + medium: "0.001", + high: "0.01", + }, }), })); jest.mock("hooks/useValidateMemo", () => ({ @@ -289,6 +296,125 @@ 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"); + }); + }); + + 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 stored fee (100) matches no preset, so "Custom" is the default tier + // and the input is editable. + expect(getByTestId("fee-input").props.editable).toBe(true); + + // Selecting a preset locks the input. + fireEvent.press(getByText("transactionSettings.priorityMed")); + await waitFor(() => { + expect(getByTestId("fee-input").props.editable).toBe(false); + }); + + // "Custom" unlocks it again for manual entry. + fireEvent.press(getByText("transactionSettings.priorityCustom")); + await waitFor(() => { + expect(getByTestId("fee-input").props.editable).toBe(true); + }); + }); + + it("previews the selected inclusion fee in the breakdown without saving", async () => { + // Make this a Soroban transaction so the info icon opens the fee breakdown. + mockIsContractId.mockReturnValue(true); + const mockOnOpenFeeBreakdown = jest.fn(); + + const { getByText, getByTestId } = renderWithProviders( + , + ); + + // Pick the High preset, then open the breakdown via the info icon. + fireEvent.press(getByText("transactionSettings.priorityHigh")); + fireEvent.press(getByTestId("fee-info-button")); + + await waitFor(() => { + // The breakdown previews the selected fee... + expect(mockOnOpenFeeBreakdown).toHaveBeenCalledWith("0.01"); + }); + + // ...but the fee is NOT persisted until the user presses Save. + expect( + mockTransactionSettingsState.saveTransactionFee, + ).not.toHaveBeenCalled(); + }); + + it("opens on the matching preset tier with the input locked", async () => { + // A stored fee equal to a preset (here the Medium preset) should open the + // sheet on that tier with the input locked. + mockUseTransactionSettingsStore.mockReturnValue({ + ...mockTransactionSettingsState, + transactionFee: "0.001", + }); + + const { getByTestId } = renderWithProviders( + , + ); + + await waitFor(() => { + expect(getByTestId("fee-input").props.editable).toBe(false); + }); + }); }); describe("TransactionSettingsBottomSheet - Soroban Transaction Tests", () => { diff --git a/__tests__/helpers/transactionSettingsUtils.test.ts b/__tests__/helpers/transactionSettingsUtils.test.ts index 7d6420433..1a5321cf4 100644 --- a/__tests__/helpers/transactionSettingsUtils.test.ts +++ b/__tests__/helpers/transactionSettingsUtils.test.ts @@ -1,5 +1,9 @@ // Mock react-native-localize -import { enforceSettingInputDecimalSeparator } from "helpers/transactionSettingsUtils"; +import { FeePresets, FeePriority } from "config/types"; +import { + enforceSettingInputDecimalSeparator, + getFeePriority, +} from "helpers/transactionSettingsUtils"; import { getNumberFormatSettings } from "react-native-localize"; jest.mock("react-native-localize", () => ({ @@ -113,4 +117,33 @@ describe("transactionSettingsUtils", () => { expect(enforceSettingInputDecimalSeparator(",")).toBe(","); }); }); + + describe("getFeePriority", () => { + const presets: FeePresets = { + [FeePriority.LOW]: "0.0001", + [FeePriority.MEDIUM]: "0.001", + [FeePriority.HIGH]: "0.01", + }; + + it("returns the matching priority for an exact preset match", () => { + expect(getFeePriority("0.0001", presets)).toBe(FeePriority.LOW); + expect(getFeePriority("0.001", presets)).toBe(FeePriority.MEDIUM); + expect(getFeePriority("0.01", presets)).toBe(FeePriority.HIGH); + }); + + it("matches regardless of trailing-zero formatting", () => { + expect(getFeePriority("0.00010", presets)).toBe(FeePriority.LOW); + expect(getFeePriority("0.0100", presets)).toBe(FeePriority.HIGH); + }); + + it("returns CUSTOM when the fee does not match any preset", () => { + expect(getFeePriority("0.005", presets)).toBe(FeePriority.CUSTOM); + expect(getFeePriority("1", presets)).toBe(FeePriority.CUSTOM); + }); + + it("returns CUSTOM for an empty or invalid fee", () => { + expect(getFeePriority("", presets)).toBe(FeePriority.CUSTOM); + expect(getFeePriority("abc", presets)).toBe(FeePriority.CUSTOM); + }); + }); }); diff --git a/__tests__/services/stellar.test.ts b/__tests__/services/stellar.test.ts index 0e42b3246..238fbd033 100644 --- a/__tests__/services/stellar.test.ts +++ b/__tests__/services/stellar.test.ts @@ -2,7 +2,19 @@ * Tests for stellar service, focusing on submitTx retry logic with exponential backoff * This test uses the actual isHorizonError function from stellar.ts */ -import { calculateBackoffDelay, isHorizonError } from "services/stellar"; +import { DEFAULT_RECOMMENDED_STELLAR_FEE } from "config/constants"; +import { FeePriority, NetworkCongestion } from "config/types"; +import { + 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 () => { @@ -56,3 +68,81 @@ describe("stellar service - submitTx retry logic", () => { expect(shouldRetry(nonHorizonError)).toBe(false); }); }); + +describe("stellar service - getNetworkFees", () => { + const buildFeeDistribution = (overrides = {}) => ({ + max: "20000", + min: "100", + mode: "100", + 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 (stroops) to Low/Med/High presets in XLM", async () => { + const server = buildFeeStatsServer(() => + Promise.resolve({ + ledger_capacity_usage: "0.2", + max_fee: buildFeeDistribution(), + }), + ); + + const { recommendedFee, networkCongestion, feePresets } = + await getNetworkFees(server); + + expect(networkCongestion).toBe(NetworkCongestion.LOW); + 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 + // The recommended (default) fee matches the Medium preset. + expect(recommendedFee).toBe(feePresets[FeePriority.MEDIUM]); + }); + + it("derives congestion level from ledger capacity usage", async () => { + 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(), + }), + ); + + expect((await getNetworkFees(mediumServer)).networkCongestion).toBe( + NetworkCongestion.MEDIUM, + ); + expect((await getNetworkFees(highServer)).networkCongestion).toBe( + NetworkCongestion.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); + + expect(recommendedFee).toBe(DEFAULT_RECOMMENDED_STELLAR_FEE); + expect(networkCongestion).toBe(NetworkCongestion.LOW); + expect(feePresets[FeePriority.LOW]).toBe(DEFAULT_RECOMMENDED_STELLAR_FEE); + expect(feePresets[FeePriority.MEDIUM]).toBe( + DEFAULT_RECOMMENDED_STELLAR_FEE, + ); + expect(feePresets[FeePriority.HIGH]).toBe(DEFAULT_RECOMMENDED_STELLAR_FEE); + }); +}); diff --git a/src/components/FeeBreakdownBottomSheet.tsx b/src/components/FeeBreakdownBottomSheet.tsx index c92bdb421..20d419e18 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,14 @@ 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; + const totalFeeXlm = computeTotalFeeXlm( - sorobanInclusionFeeXlm, + inclusionFeeXlmOverride ?? sorobanInclusionFeeXlm, sorobanResourceFeeXlm, - transactionFee, + effectiveInclusionFeeXlm, ); // When simulation has failed the stored fee fields are null — show a dash @@ -102,8 +114,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 916b449c3..b6e05f5ed 100644 --- a/src/components/TransactionSettingsBottomSheet.tsx +++ b/src/components/TransactionSettingsBottomSheet.tsx @@ -6,6 +6,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, @@ -16,7 +17,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"; @@ -26,7 +27,10 @@ import { } from "helpers/formatAmount"; import { getMemoDisabledState } from "helpers/muxedAddress"; import { isContractId } from "helpers/soroban"; -import { enforceSettingInputDecimalSeparator } from "helpers/transactionSettingsUtils"; +import { + enforceSettingInputDecimalSeparator, + getFeePriority, +} from "helpers/transactionSettingsUtils"; import useAppTranslation from "hooks/useAppTranslation"; import { useBalancesList } from "hooks/useBalancesList"; import useColors from "hooks/useColors"; @@ -50,7 +54,7 @@ type TransactionSettingsBottomSheetProps = { onConfirm: () => void; context: TransactionContext; onSettingsChange?: () => void; - onOpenFeeBreakdown?: () => void; + onOpenFeeBreakdown?: (inclusionFeeXlm: string) => void; }; // Constants @@ -68,7 +72,7 @@ const TransactionSettingsBottomSheet: React.FC< // All hooks at the top const { t } = useAppTranslation(); const { themeColors } = useColors(); - const { recommendedFee, networkCongestion } = useNetworkFees(); + const { recommendedFee, networkCongestion, feePresets } = useNetworkFees(); const { transactionMemo, @@ -96,6 +100,10 @@ const TransactionSettingsBottomSheet: React.FC< context, ); + // Tracks whether the user explicitly chose a priority tier or typed a fee, so + // the auto-sync effect stops overriding their choice once they interact. + const userPickedPriorityRef = useRef(false); + const timeoutInfoBottomSheetModalRef = useRef(null); const feeInfoBottomSheetModalRef = useRef(null); const memoInfoBottomSheetModalRef = useRef(null); @@ -319,6 +327,8 @@ const TransactionSettingsBottomSheet: React.FC< ); const handleFeeChange = useCallback((text: string) => { + // Manual typing is a deliberate choice — stop auto-syncing the tier. + userPickedPriorityRef.current = true; const normalizedText = enforceSettingInputDecimalSeparator(text); setLocalFee(normalizedText); }, []); @@ -328,22 +338,78 @@ 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"); + // The selected priority tier. Low/Med/High lock the fee to a network preset + // and disable the input; "Custom" unlocks the input for manual entry. + const [selectedFeePriority, setSelectedFeePriority] = useState( + () => getFeePriority(parseDisplayNumber(localFee), feePresets), + ); + + // Keep the local fee in sync with the recommended/store fee until the user + // edits it, so the input shows the pre-loaded recommended fee once it lands + // (the network fees are fetched in the background and may arrive async). + useEffect(() => { + if (userPickedPriorityRef.current) { + return; + } + setLocalFee(formatNumberForDisplay(storeFee)); + }, [storeFee]); + + // Until the user explicitly picks a tier, keep the highlighted tier in sync + // with the active fee as the network presets load in (they arrive async). + useEffect(() => { + if (userPickedPriorityRef.current) { + return; + } + setSelectedFeePriority( + getFeePriority(parseDisplayNumber(localFee), feePresets), + ); + }, [feePresets, localFee]); + + 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) => { + userPickedPriorityRef.current = true; + const priority = value as FeePriority; + setSelectedFeePriority(priority); + + // "Custom" unlocks the input and keeps the current value for editing. + if (priority === FeePriority.CUSTOM) { + return; } + + markAsManuallyChanged(); + setLocalFee( + formatNumberForDisplay(feePresets[priority as keyof FeePresets]), + ); }, - [t], + [feePresets, markAsManuallyChanged], ); + // Opening the fee breakdown previews the current (unsaved) inclusion fee so + // the breakdown reflects what the user typed/selected. The fee is only + // persisted on Save — cancelling reverts to the stored value. + const handleOpenFeeBreakdown = useCallback(() => { + if (feeError) { + return; + } + onOpenFeeBreakdown?.(parseDisplayNumber(localFee).toString()); + }, [feeError, localFee, onOpenFeeBreakdown]); + // Data objects and configurations const settingErrors = { [TransactionSetting.Memo]: memoError, @@ -502,54 +568,36 @@ const TransactionSettingsBottomSheet: React.FC< : t("transactionSettings.feeTitle")} isSorobanTransaction && onOpenFeeBreakdown - ? onOpenFeeBreakdown() + ? handleOpenFeeBreakdown() : feeInfoBottomSheetModalRef.current?.present() } > - { - markAsManuallyChanged(); - setLocalFee( - formatNumberForDisplay(recommendedFee || MIN_TRANSACTION_FEE), - ); - }} - > - - {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} @@ -557,20 +605,27 @@ const TransactionSettingsBottomSheet: React.FC< } /> + + + ), [ isSorobanTransaction, onOpenFeeBreakdown, + handleOpenFeeBreakdown, localFee, feeError, t, - themeColors.lilac, networkCongestion, - getLocalizedCongestionLevel, handleFeeChange, - recommendedFee, - markAsManuallyChanged, + feePriorityOptions, + selectedFeePriority, + handleFeePriorityChange, ], ); diff --git a/src/components/screens/SendScreen/screens/SendCollectibleReview.tsx b/src/components/screens/SendScreen/screens/SendCollectibleReview.tsx index 20919acdc..4d8c7fc3d 100644 --- a/src/components/screens/SendScreen/screens/SendCollectibleReview.tsx +++ b/src/components/screens/SendScreen/screens/SendCollectibleReview.tsx @@ -137,6 +137,10 @@ const SendCollectibleReviewScreen: React.FC< const transactionSettingsBottomSheetModalRef = useRef(null); const feeBreakdownBottomSheetModalRef = useRef(null); const muxedAddressInfoBottomSheetModalRef = useRef(null); + // In-progress inclusion fee previewed in the breakdown (not yet saved). + const [feeBreakdownInclusionFee, setFeeBreakdownInclusionFee] = useState< + string | undefined + >(undefined); const [transactionScanResult, setTransactionScanResult] = useState< Blockaid.StellarTransactionScanResponse | undefined >(undefined); @@ -692,9 +696,10 @@ const SendCollectibleReviewScreen: React.FC< onCancel={handleCancelTransactionSettings} onConfirm={handleConfirmTransactionSettings} onSettingsChange={handleSettingsChange} - onOpenFeeBreakdown={() => - feeBreakdownBottomSheetModalRef.current?.present() - } + onOpenFeeBreakdown={(inclusionFeeXlm) => { + setFeeBreakdownInclusionFee(inclusionFeeXlm); + feeBreakdownBottomSheetModalRef.current?.present(); + }} /> } /> @@ -707,6 +712,7 @@ const SendCollectibleReviewScreen: React.FC< feeBreakdownBottomSheetModalRef.current?.dismiss()} isSorobanContext + inclusionFeeXlmOverride={feeBreakdownInclusionFee} /> } /> diff --git a/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx b/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx index d951ad945..d5c675074 100644 --- a/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx +++ b/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx @@ -241,6 +241,10 @@ const TransactionAmountScreen: React.FC = ({ const transactionSettingsBottomSheetModalRef = useRef(null); const feeBreakdownBottomSheetModalRef = useRef(null); const muxedAddressInfoBottomSheetModalRef = useRef(null); + // In-progress inclusion fee previewed in the breakdown (not yet saved). + const [feeBreakdownInclusionFee, setFeeBreakdownInclusionFee] = useState< + string | undefined + >(undefined); const [transactionScanResult, setTransactionScanResult] = useState< Blockaid.StellarTransactionScanResponse | undefined >(undefined); @@ -1300,9 +1304,10 @@ const TransactionAmountScreen: React.FC = ({ onCancel={handleCancelTransactionSettings} onConfirm={handleConfirmTransactionSettings} onSettingsChange={handleSettingsChange} - onOpenFeeBreakdown={() => - feeBreakdownBottomSheetModalRef.current?.present() - } + onOpenFeeBreakdown={(inclusionFeeXlm) => { + setFeeBreakdownInclusionFee(inclusionFeeXlm); + feeBreakdownBottomSheetModalRef.current?.present(); + }} /> } /> @@ -1318,6 +1323,7 @@ const TransactionAmountScreen: React.FC = ({ selectedBalance, recipientAddress, )} + inclusionFeeXlmOverride={feeBreakdownInclusionFee} /> } /> 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" : "" + }`} > ; + export enum HookStatus { IDLE = "idle", LOADING = "loading", diff --git a/src/helpers/transactionSettingsUtils.ts b/src/helpers/transactionSettingsUtils.ts index 9b231e82e..9763fe480 100644 --- a/src/helpers/transactionSettingsUtils.ts +++ b/src/helpers/transactionSettingsUtils.ts @@ -1,3 +1,5 @@ +import BigNumber from "bignumber.js"; +import { FeePresets, FeePriority } from "config/types"; import { getNumberFormatSettings } from "react-native-localize"; /** @@ -57,3 +59,32 @@ export const enforceSettingInputDecimalSeparator = (value: string): string => { const beforeLastWithoutSeparators = beforeLastSeparator.replace(/[.,]/g, ""); return beforeLastWithoutSeparators + decimalSeparator + afterLastSeparator; }; + +/** + * Derives the fee priority tier that matches a given fee amount. + * + * Returns the matching priority (Low/Med/High) when the fee exactly equals one + * of the network-derived presets, otherwise `FeePriority.CUSTOM`. Used to + * highlight the correct segment in the fee priority selector — typing any value + * that doesn't match a preset naturally lands on "Custom". + * + * @param {string} feeXlm - The fee amount in XLM (plain numeric string, e.g. "0.0051234") + * @param {FeePresets} presets - The network-derived preset fees in XLM + * @returns {FeePriority} The matching priority tier, or CUSTOM + */ +export const getFeePriority = ( + feeXlm: string, + presets: FeePresets, +): FeePriority => { + const fee = new BigNumber(feeXlm); + + if (fee.isNaN()) { + return FeePriority.CUSTOM; + } + + const match = ( + [FeePriority.LOW, FeePriority.MEDIUM, FeePriority.HIGH] as const + ).find((priority) => fee.isEqualTo(new BigNumber(presets[priority]))); + + return match ?? FeePriority.CUSTOM; +}; diff --git a/src/hooks/useNetworkFees.ts b/src/hooks/useNetworkFees.ts index 25de94524..4b4248029 100644 --- a/src/hooks/useNetworkFees.ts +++ b/src/hooks/useNetworkFees.ts @@ -1,33 +1,71 @@ -import { mapNetworkToNetworkDetails } from "config/constants"; +import { NETWORKS, mapNetworkToNetworkDetails } 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"; +const NETWORK_FEES_POLL_INTERVAL_MS = 30000; + +export interface NetworkFeesData { + recommendedFee: string; + networkCongestion: NetworkCongestion; + feePresets: FeePresets; +} + +// Empty presets so the priority selector resolves to "Custom" (not a spurious +// preset match) until the real network fees have loaded. +const EMPTY_FEE_PRESETS: FeePresets = { + [FeePriority.LOW]: "", + [FeePriority.MEDIUM]: "", + [FeePriority.HIGH]: "", +}; + +const DEFAULT_NETWORK_FEES: NetworkFeesData = { + recommendedFee: "", + networkCongestion: NetworkCongestion.LOW, + feePresets: EMPTY_FEE_PRESETS, +}; + +// Session cache keyed by network. The amount screens mount this hook on entry, +// so by the time the settings bottom sheet opens the fees are already loaded +// and read from the cache immediately — no cold-start flicker on each open. +const networkFeesCache: Partial> = {}; + /** * Hook to retrieve and monitor network fees and congestion levels. * - * @returns An object containing the recommended fee and network congestion level + * @returns An object containing the recommended fee, network congestion level + * and the Low/Med/High inclusion-fee presets */ -export const useNetworkFees = () => { - const [recommendedFee, setRecommendedFee] = useState(""); - const [networkCongestion, setNetworkCongestion] = useState( - NetworkCongestion.LOW, - ); +export const useNetworkFees = (): NetworkFeesData => { const { network } = useAuthenticationStore(); + const [fees, setFees] = useState( + () => networkFeesCache[network] ?? DEFAULT_NETWORK_FEES, + ); useEffect(() => { + // Reflect any cached value for this network immediately (e.g. on switch). + const cached = networkFeesCache[network]; + if (cached) { + setFees(cached); + } + 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); + + // Guard against an invalid/empty result so the hook never returns + // undefined to consumers. + if (!data?.recommendedFee) { + return; + } - setRecommendedFee(fee); - setNetworkCongestion(congestion); + networkFeesCache[network] = data; + setFees(data); } catch (error) { logger.error("[useNetworkFees]", "Error fetching network fees:", error); } @@ -37,10 +75,10 @@ export const useNetworkFees = () => { const interval = setInterval(() => { fetchNetworkFees(); - }, 30000); + }, NETWORK_FEES_POLL_INTERVAL_MS); return () => clearInterval(interval); }, [network]); - return { recommendedFee, networkCongestion }; + return fees; }; diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index 035b96b36..30b77d080 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -756,6 +756,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 0d3cb20f4..6fd518473 100644 --- a/src/i18n/locales/pt/translations.json +++ b/src/i18n/locales/pt/translations.json @@ -718,6 +718,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/services/stellar.ts b/src/services/stellar.ts index d89330c21..2fc5160bc 100644 --- a/src/services/stellar.ts +++ b/src/services/stellar.ts @@ -17,7 +17,7 @@ import { SOROBAN_RPC_URLS, } from "config/constants"; import { logger } from "config/logger"; -import { NetworkCongestion } from "config/types"; +import { FeePresets, FeePriority, NetworkCongestion } from "config/types"; import { formatTokenIdentifier } from "helpers/balances"; import { stroopToXlm, xlmToStroop } from "helpers/formatAmount"; import { getIsSwap } from "helpers/history"; @@ -26,6 +26,10 @@ import { getIsSwap } from "helpers/history"; export const SUBMIT_BACKOFF_MAX_ATTEMPTS = 5; export const BASE_BACKOFF_SEC = 1000; // Base delay in milliseconds +// Ledger capacity usage thresholds (0-1) that map to network congestion levels. +const LEDGER_CAPACITY_MEDIUM_THRESHOLD = 0.5; +const LEDGER_CAPACITY_HIGH_THRESHOLD = 0.75; + interface HorizonError { response: { status: number; @@ -167,16 +171,33 @@ export const submitTx = async ( export const getNetworkFees = async (server: Horizon.Server) => { 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. + let feePresets: FeePresets = { + [FeePriority.LOW]: DEFAULT_RECOMMENDED_STELLAR_FEE, + [FeePriority.MEDIUM]: DEFAULT_RECOMMENDED_STELLAR_FEE, + [FeePriority.HIGH]: DEFAULT_RECOMMENDED_STELLAR_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(), + }; + // The recommended (default) fee matches the Medium preset (the median of + // the max-fee distribution), so the settings sheet opens on the "Med" tier. + recommendedFee = feePresets[FeePriority.MEDIUM]; + 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; @@ -187,7 +208,7 @@ export const getNetworkFees = async (server: Horizon.Server) => { networkCongestion = NetworkCongestion.LOW; } - return { recommendedFee, networkCongestion }; + return { recommendedFee, networkCongestion, feePresets }; }; export const buildChangeTrustTx = async (input: BuildChangeTrustTxParams) => { From 9c340c7e177eb91c277651735954ed3c7322b4ac Mon Sep 17 00:00:00 2001 From: leofelix077 Date: Tue, 23 Jun 2026 10:34:01 -0300 Subject: [PATCH 2/6] feat: congestion-based recommended fee tier Co-Authored-By: Claude Opus 4.8 (1M context) --- __tests__/services/stellar.test.ts | 19 ++++++++++--------- src/services/stellar.ts | 19 ++++++++++++++++--- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/__tests__/services/stellar.test.ts b/__tests__/services/stellar.test.ts index 238fbd033..7c725e947 100644 --- a/__tests__/services/stellar.test.ts +++ b/__tests__/services/stellar.test.ts @@ -103,11 +103,11 @@ describe("stellar service - getNetworkFees", () => { 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 - // The recommended (default) fee matches the Medium preset. - expect(recommendedFee).toBe(feePresets[FeePriority.MEDIUM]); + // Low congestion → recommended fee follows the Low preset. + expect(recommendedFee).toBe(feePresets[FeePriority.LOW]); }); - it("derives congestion level from ledger capacity usage", async () => { + it("derives congestion level and a matching recommended fee tier", async () => { const mediumServer = buildFeeStatsServer(() => Promise.resolve({ ledger_capacity_usage: "0.6", @@ -121,12 +121,13 @@ describe("stellar service - getNetworkFees", () => { }), ); - expect((await getNetworkFees(mediumServer)).networkCongestion).toBe( - NetworkCongestion.MEDIUM, - ); - expect((await getNetworkFees(highServer)).networkCongestion).toBe( - NetworkCongestion.HIGH, - ); + 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 () => { diff --git a/src/services/stellar.ts b/src/services/stellar.ts index 2fc5160bc..56ecc4cbc 100644 --- a/src/services/stellar.ts +++ b/src/services/stellar.ts @@ -30,6 +30,17 @@ export const BASE_BACKOFF_SEC = 1000; // Base delay in milliseconds const LEDGER_CAPACITY_MEDIUM_THRESHOLD = 0.5; const LEDGER_CAPACITY_HIGH_THRESHOLD = 0.75; +// The recommended fee tier follows the current network congestion: bid low when +// the network is quiet, higher when it's contested. +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, +}; + interface HorizonError { response: { status: number; @@ -189,9 +200,7 @@ export const getNetworkFees = async (server: Horizon.Server) => { [FeePriority.MEDIUM]: stroopToXlm(maxFee.p50).toFixed(), [FeePriority.HIGH]: stroopToXlm(maxFee.p90).toFixed(), }; - // The recommended (default) fee matches the Medium preset (the median of - // the max-fee distribution), so the settings sheet opens on the "Med" tier. - recommendedFee = feePresets[FeePriority.MEDIUM]; + if ( ledgerCapacityUsageNum > LEDGER_CAPACITY_MEDIUM_THRESHOLD && ledgerCapacityUsageNum <= LEDGER_CAPACITY_HIGH_THRESHOLD @@ -202,6 +211,10 @@ export const getNetworkFees = async (server: Horizon.Server) => { } else { networkCongestion = NetworkCongestion.LOW; } + + // The recommended (default) fee tier follows the current congestion, so the + // settings sheet opens on Low/Med/High to match network conditions. + recommendedFee = feePresets[CONGESTION_TO_FEE_PRIORITY[networkCongestion]]; } catch (e) { // use default values recommendedFee = DEFAULT_RECOMMENDED_STELLAR_FEE; From d954e312a1f01d0032b5e41321fdab107aeb54ea Mon Sep 17 00:00:00 2001 From: leofelix077 Date: Tue, 23 Jun 2026 12:29:55 -0300 Subject: [PATCH 3/6] use med fee preset as source of truth --- .../TransactionSettingsBottomSheet.test.tsx | 52 ++++++-- .../helpers/transactionSettingsUtils.test.ts | 35 +---- __tests__/services/stellar.test.ts | 6 +- .../TransactionSettingsBottomSheet.tsx | 123 ++++++++---------- .../components/SendReviewBottomSheet.tsx | 18 +-- src/ducks/swapSettings.ts | 7 + src/ducks/transactionSettings.ts | 13 ++ src/helpers/transactionSettingsUtils.ts | 31 ----- src/services/stellar.ts | 8 +- 9 files changed, 128 insertions(+), 165 deletions(-) diff --git a/__tests__/components/TransactionSettingsBottomSheet.test.tsx b/__tests__/components/TransactionSettingsBottomSheet.test.tsx index 14a270fa5..96d217eef 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", () => ({ @@ -144,9 +146,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(), @@ -347,20 +351,20 @@ describe("TransactionSettingsBottomSheet - onSettingsChange Integration", () => />, ); - // The stored fee (100) matches no preset, so "Custom" is the default tier - // and the input is editable. - expect(getByTestId("fee-input").props.editable).toBe(true); + // 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); - // Selecting a preset locks the input. - fireEvent.press(getByText("transactionSettings.priorityMed")); + // "Custom" unlocks the input for manual entry. + fireEvent.press(getByText("transactionSettings.priorityCustom")); await waitFor(() => { - expect(getByTestId("fee-input").props.editable).toBe(false); + expect(getByTestId("fee-input").props.editable).toBe(true); }); - // "Custom" unlocks it again for manual entry. - fireEvent.press(getByText("transactionSettings.priorityCustom")); + // Selecting a preset locks it again. + fireEvent.press(getByText("transactionSettings.priorityLow")); await waitFor(() => { - expect(getByTestId("fee-input").props.editable).toBe(true); + expect(getByTestId("fee-input").props.editable).toBe(false); }); }); @@ -394,12 +398,12 @@ describe("TransactionSettingsBottomSheet - onSettingsChange Integration", () => ).not.toHaveBeenCalled(); }); - it("opens on the matching preset tier with the input locked", async () => { - // A stored fee equal to a preset (here the Medium preset) should open the - // sheet on that tier with the input locked. + 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, - transactionFee: "0.001", + feePriority: "high", }); const { getByTestId } = renderWithProviders( @@ -415,6 +419,26 @@ describe("TransactionSettingsBottomSheet - onSettingsChange Integration", () => 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); + }); + }); }); describe("TransactionSettingsBottomSheet - Soroban Transaction Tests", () => { @@ -434,9 +458,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__/helpers/transactionSettingsUtils.test.ts b/__tests__/helpers/transactionSettingsUtils.test.ts index 1a5321cf4..7d6420433 100644 --- a/__tests__/helpers/transactionSettingsUtils.test.ts +++ b/__tests__/helpers/transactionSettingsUtils.test.ts @@ -1,9 +1,5 @@ // Mock react-native-localize -import { FeePresets, FeePriority } from "config/types"; -import { - enforceSettingInputDecimalSeparator, - getFeePriority, -} from "helpers/transactionSettingsUtils"; +import { enforceSettingInputDecimalSeparator } from "helpers/transactionSettingsUtils"; import { getNumberFormatSettings } from "react-native-localize"; jest.mock("react-native-localize", () => ({ @@ -117,33 +113,4 @@ describe("transactionSettingsUtils", () => { expect(enforceSettingInputDecimalSeparator(",")).toBe(","); }); }); - - describe("getFeePriority", () => { - const presets: FeePresets = { - [FeePriority.LOW]: "0.0001", - [FeePriority.MEDIUM]: "0.001", - [FeePriority.HIGH]: "0.01", - }; - - it("returns the matching priority for an exact preset match", () => { - expect(getFeePriority("0.0001", presets)).toBe(FeePriority.LOW); - expect(getFeePriority("0.001", presets)).toBe(FeePriority.MEDIUM); - expect(getFeePriority("0.01", presets)).toBe(FeePriority.HIGH); - }); - - it("matches regardless of trailing-zero formatting", () => { - expect(getFeePriority("0.00010", presets)).toBe(FeePriority.LOW); - expect(getFeePriority("0.0100", presets)).toBe(FeePriority.HIGH); - }); - - it("returns CUSTOM when the fee does not match any preset", () => { - expect(getFeePriority("0.005", presets)).toBe(FeePriority.CUSTOM); - expect(getFeePriority("1", presets)).toBe(FeePriority.CUSTOM); - }); - - it("returns CUSTOM for an empty or invalid fee", () => { - expect(getFeePriority("", presets)).toBe(FeePriority.CUSTOM); - expect(getFeePriority("abc", presets)).toBe(FeePriority.CUSTOM); - }); - }); }); diff --git a/__tests__/services/stellar.test.ts b/__tests__/services/stellar.test.ts index 4a443ab19..ffdb7ec09 100644 --- a/__tests__/services/stellar.test.ts +++ b/__tests__/services/stellar.test.ts @@ -91,7 +91,7 @@ describe("stellar service - getNetworkFees", () => { ...overrides, }); - it("maps max_fee p10/p50/p90 to Low/Med/High presets and mode to the recommended fee (XLM)", async () => { + it("maps max_fee p10/p50/p90 to Low/Med/High presets and the Medium preset to the recommended fee (XLM)", async () => { const server = buildFeeStatsServer(() => Promise.resolve({ ledger_capacity_usage: "0.2", @@ -106,8 +106,8 @@ describe("stellar service - getNetworkFees", () => { 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 - // The recommended (default) fee is the network mode, independent of the tiers. - expect(recommendedFee).toBe("0.00005"); // mode = 500 + // The recommended (default) fee matches the Medium preset (p50). + expect(recommendedFee).toBe(feePresets[FeePriority.MEDIUM]); }); it("derives congestion level from ledger capacity usage", async () => { diff --git a/src/components/TransactionSettingsBottomSheet.tsx b/src/components/TransactionSettingsBottomSheet.tsx index 26910eea7..e172d4d58 100644 --- a/src/components/TransactionSettingsBottomSheet.tsx +++ b/src/components/TransactionSettingsBottomSheet.tsx @@ -28,10 +28,7 @@ import { } from "helpers/formatAmount"; import { getMemoDisabledState } from "helpers/muxedAddress"; import { isContractId } from "helpers/soroban"; -import { - enforceSettingInputDecimalSeparator, - getFeePriority, -} from "helpers/transactionSettingsUtils"; +import { enforceSettingInputDecimalSeparator } from "helpers/transactionSettingsUtils"; import useAppTranslation from "hooks/useAppTranslation"; import { useBalancesList } from "hooks/useBalancesList"; import useColors from "hooks/useColors"; @@ -93,6 +90,8 @@ const TransactionSettingsBottomSheet: React.FC< saveMemo: saveTransactionMemo, saveTransactionFee, saveTransactionTimeout, + feePriority: txFeePriority, + saveFeePriority: saveTxFeePriority, } = useTransactionSettingsStore(); const { @@ -102,6 +101,8 @@ const TransactionSettingsBottomSheet: React.FC< saveSwapFee, saveSwapTimeout, saveSwapSlippage, + feePriority: swapFeePriority, + saveFeePriority: saveSwapFeePriority, } = useSwapSettingsStore(); const { markAsManuallyChanged } = useInitialRecommendedFee( @@ -213,6 +214,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; @@ -231,42 +238,50 @@ 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( - (storeFeeBn.isFinite() - ? BigNumber.max(storeFeeBn, minTotalFee) - : minTotalFee - ).toString(), + const flooredStoreFee = formatNumberForDisplay( + (() => { + const bn = new BigNumber(storeFee); + return ( + bn.isFinite() ? BigNumber.max(bn, 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. + const presetTotalFee = (priority: FeePriority): string | undefined => { + const preset = feePresets[priority as keyof FeePresets]; + if (!preset || new BigNumber(preset).isNaN()) { + return undefined; + } + return new BigNumber(preset).times(operationCount).toString(); + }; + + // The selected tier is the single source of truth, persisted in the store, so + // it reflects the user's actual choice and never flickers to "Custom" when + // the 30s poll refetches presets with slightly different values (Med stays + // Med — only the shown amount tracks the new median). + const [selectedFeePriority, setSelectedFeePriority] = + useState(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. + const presetTotal = presetTotalFee(selectedFeePriority); + const localFee = + selectedFeePriority === FeePriority.CUSTOM + ? customFee + : (presetTotal && formatNumberForDisplay(presetTotal)) || flooredStoreFee; useEffect(() => { if (isMemoDisabled && localMemo) { @@ -365,10 +380,9 @@ const TransactionSettingsBottomSheet: React.FC< ); const handleFeeChange = useCallback((text: string) => { - // Manual typing is a deliberate choice — stop auto-syncing the fee/tier. - feeEdited.current = true; + // Manual typing is only possible on the Custom tier; update its value. const normalizedText = enforceSettingInputDecimalSeparator(text); - setLocalFee(normalizedText); + setCustomFee(normalizedText); }, []); const handleTimeoutChange = useCallback((text: string) => { @@ -376,24 +390,6 @@ const TransactionSettingsBottomSheet: React.FC< setLocalTimeout(integerOnly); }, []); - // The presets are per-operation rates while the fee shown/stored is the TOTAL - // across all ops, so divide by operationCount before matching a tier. - const perOpFeeXlm = (totalFeeXlm: string) => - new BigNumber(parseDisplayNumber(totalFeeXlm)) - .dividedBy(operationCount) - .toString(); - - // The selected priority tier. Low/Med/High lock the fee to a network preset - // and disable the input; "Custom" unlocks it for manual entry. The default - // fee is the network recommendation (mode), which shows as "Custom" and is - // deliberately NOT re-derived when presets refetch — so the fee a user sees - // stays stable through the review screens and only changes when they pick a - // tier or type. Derived once at mount (picks up a matching tier if the stored - // fee already equals a preset, e.g. on re-entry). - const [selectedFeePriority, setSelectedFeePriority] = useState( - () => getFeePriority(perOpFeeXlm(localFee), feePresets), - ); - const feePriorityOptions = useMemo( () => [ { label: t("transactionSettings.priorityLow"), value: FeePriority.LOW }, @@ -412,26 +408,16 @@ const TransactionSettingsBottomSheet: React.FC< const handleFeePriorityChange = useCallback( (value: string | number) => { - feeEdited.current = true; const priority = value as FeePriority; - setSelectedFeePriority(priority); - - // "Custom" unlocks the input and keeps the current value for editing. + // 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) { - return; + setCustomFee(localFee); } - - markAsManuallyChanged(); - // Presets are per-op rates; store the TOTAL across all ops. - setLocalFee( - formatNumberForDisplay( - new BigNumber(feePresets[priority as keyof FeePresets]) - .times(operationCount) - .toString(), - ), - ); + setSelectedFeePriority(priority); }, - [feePresets, markAsManuallyChanged, operationCount], + [localFee], ); // Opening the fee breakdown previews the current (unsaved) inclusion fee so @@ -458,6 +444,7 @@ const TransactionSettingsBottomSheet: React.FC< saveSlippage(Number(parseDisplayNumber(localSlippage))), [TransactionSetting.Fee]: () => { markAsManuallyChanged(); + saveFeePriorityForContext(selectedFeePriority); saveFee(parseDisplayNumber(localFee).toString()); }, [TransactionSetting.Timeout]: () => saveTimeout(Number(localTimeout)), diff --git a/src/components/screens/SendScreen/components/SendReviewBottomSheet.tsx b/src/components/screens/SendScreen/components/SendReviewBottomSheet.tsx index e8b509f0b..afdebbfe5 100644 --- a/src/components/screens/SendScreen/components/SendReviewBottomSheet.tsx +++ b/src/components/screens/SendScreen/components/SendReviewBottomSheet.tsx @@ -112,7 +112,6 @@ const SendReviewBottomSheet: React.FC = ({ transactionXDR, isBuilding, error, - isSoroban, sorobanResourceFeeXlm, sorobanInclusionFeeXlm, } = useTransactionBuilderStore(); @@ -320,15 +319,13 @@ const SendReviewBottomSheet: React.FC = ({ /> ) : ( - {isSoroban && ( - - - - )} + + + {formatTokenForDisplay(totalFeeXlm, NATIVE_TOKEN_CODE)} @@ -361,7 +358,6 @@ const SendReviewBottomSheet: React.FC = ({ handleCopyXdr, handleOpenFeeBreakdown, isBuilding, - isSoroban, renderMemoTitle, renderXdrContent, t, 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/transactionSettingsUtils.ts b/src/helpers/transactionSettingsUtils.ts index 9763fe480..9b231e82e 100644 --- a/src/helpers/transactionSettingsUtils.ts +++ b/src/helpers/transactionSettingsUtils.ts @@ -1,5 +1,3 @@ -import BigNumber from "bignumber.js"; -import { FeePresets, FeePriority } from "config/types"; import { getNumberFormatSettings } from "react-native-localize"; /** @@ -59,32 +57,3 @@ export const enforceSettingInputDecimalSeparator = (value: string): string => { const beforeLastWithoutSeparators = beforeLastSeparator.replace(/[.,]/g, ""); return beforeLastWithoutSeparators + decimalSeparator + afterLastSeparator; }; - -/** - * Derives the fee priority tier that matches a given fee amount. - * - * Returns the matching priority (Low/Med/High) when the fee exactly equals one - * of the network-derived presets, otherwise `FeePriority.CUSTOM`. Used to - * highlight the correct segment in the fee priority selector — typing any value - * that doesn't match a preset naturally lands on "Custom". - * - * @param {string} feeXlm - The fee amount in XLM (plain numeric string, e.g. "0.0051234") - * @param {FeePresets} presets - The network-derived preset fees in XLM - * @returns {FeePriority} The matching priority tier, or CUSTOM - */ -export const getFeePriority = ( - feeXlm: string, - presets: FeePresets, -): FeePriority => { - const fee = new BigNumber(feeXlm); - - if (fee.isNaN()) { - return FeePriority.CUSTOM; - } - - const match = ( - [FeePriority.LOW, FeePriority.MEDIUM, FeePriority.HIGH] as const - ).find((priority) => fee.isEqualTo(new BigNumber(presets[priority]))); - - return match ?? FeePriority.CUSTOM; -}; diff --git a/src/services/stellar.ts b/src/services/stellar.ts index 096ffed6f..275aaca81 100644 --- a/src/services/stellar.ts +++ b/src/services/stellar.ts @@ -184,16 +184,14 @@ export const getNetworkFees = async (server: Horizon.Server) => { await server.feeStats(); const ledgerCapacityUsageNum = Number(ledgerCapacityUsage); - // The recommended (default) fee is the network mode — shown as "Custom" in - // the settings sheet so it stays stable while the user is in the flow. The - // Low/Med/High presets are derived from the max-fee percentile distribution - // and only applied when the user explicitly picks a tier. - recommendedFee = stroopToXlm(maxFee.mode).toFixed(); feePresets = { [FeePriority.LOW]: stroopToXlm(maxFee.p10).toFixed(), [FeePriority.MEDIUM]: stroopToXlm(maxFee.p50).toFixed(), [FeePriority.HIGH]: stroopToXlm(maxFee.p90).toFixed(), }; + // The recommended (default) fee matches the Medium preset (the median of the + // max-fee distribution), so the settings sheet opens on the "Med" tier. + recommendedFee = feePresets[FeePriority.MEDIUM]; if ( ledgerCapacityUsageNum > LEDGER_CAPACITY_MEDIUM_THRESHOLD && From fd3773bfaa730977d760a161c70429dc565dd476 Mon Sep 17 00:00:00 2001 From: leofelix077 Date: Tue, 23 Jun 2026 13:22:40 -0300 Subject: [PATCH 4/6] adjust typings and constants --- .../TransactionSettingsBottomSheet.test.tsx | 56 ++++++++++++++++--- __tests__/ducks/transactionSettings.test.ts | 12 ++++ __tests__/services/stellar.test.ts | 15 ++--- src/components/FeeBreakdownBottomSheet.tsx | 4 ++ .../TransactionSettingsBottomSheet.tsx | 24 ++++---- src/helpers/testUtils.tsx | 20 ++++--- src/hooks/useNetworkFees.ts | 7 ++- src/navigators/SendPaymentNavigator.tsx | 17 +++++- src/services/stellar.ts | 17 +++--- 9 files changed, 129 insertions(+), 43 deletions(-) diff --git a/__tests__/components/TransactionSettingsBottomSheet.test.tsx b/__tests__/components/TransactionSettingsBottomSheet.test.tsx index 96d217eef..032163b1a 100644 --- a/__tests__/components/TransactionSettingsBottomSheet.test.tsx +++ b/__tests__/components/TransactionSettingsBottomSheet.test.tsx @@ -59,16 +59,20 @@ jest.mock("hooks/useColors", () => ({ }, }), })); +// 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", - feePresets: { - low: "0.0001", - medium: "0.001", - high: "0.01", - }, - }), + useNetworkFees: () => mockNetworkFees, })); jest.mock("hooks/useValidateMemo", () => ({ useValidateMemo: () => ({ error: null }), @@ -159,6 +163,7 @@ describe("TransactionSettingsBottomSheet - onSettingsChange Integration", () => beforeEach(() => { jest.clearAllMocks(); + mockNetworkFees = mockDefaultNetworkFees; mockUseTransactionSettingsStore.mockReturnValue( mockTransactionSettingsState, ); @@ -318,6 +323,10 @@ describe("TransactionSettingsBottomSheet - onSettingsChange Integration", () => expect( mockTransactionSettingsState.saveTransactionFee, ).toHaveBeenCalledWith("0.01"); + // The chosen tier is persisted so it survives refetches and re-entry. + expect(mockTransactionSettingsState.saveFeePriority).toHaveBeenCalledWith( + "high", + ); }); }); @@ -439,6 +448,35 @@ describe("TransactionSettingsBottomSheet - onSettingsChange Integration", () => expect(getByTestId("fee-input").props.editable).toBe(true); }); }); + + 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", () => { 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__/services/stellar.test.ts b/__tests__/services/stellar.test.ts index ffdb7ec09..5052be0ba 100644 --- a/__tests__/services/stellar.test.ts +++ b/__tests__/services/stellar.test.ts @@ -4,7 +4,7 @@ * This test uses the actual functions from stellar.ts */ import { Asset as SdkToken, Operation } from "@stellar/stellar-sdk"; -import { DEFAULT_RECOMMENDED_STELLAR_FEE } from "config/constants"; +import { MIN_TRANSACTION_FEE } from "config/constants"; import { FeePriority, NetworkCongestion } from "config/types"; import { buildChangeTrustOperation, @@ -140,13 +140,14 @@ describe("stellar service - getNetworkFees", () => { const { recommendedFee, networkCongestion, feePresets } = await getNetworkFees(server); - expect(recommendedFee).toBe(DEFAULT_RECOMMENDED_STELLAR_FEE); + // 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(DEFAULT_RECOMMENDED_STELLAR_FEE); - expect(feePresets[FeePriority.MEDIUM]).toBe( - DEFAULT_RECOMMENDED_STELLAR_FEE, - ); - expect(feePresets[FeePriority.HIGH]).toBe(DEFAULT_RECOMMENDED_STELLAR_FEE); + expect(feePresets[FeePriority.LOW]).toBe(MIN_TRANSACTION_FEE); + expect(feePresets[FeePriority.MEDIUM]).toBe(MIN_TRANSACTION_FEE); + expect(feePresets[FeePriority.HIGH]).toBe(MIN_TRANSACTION_FEE); }); }); diff --git a/src/components/FeeBreakdownBottomSheet.tsx b/src/components/FeeBreakdownBottomSheet.tsx index 20d419e18..54c6b5355 100644 --- a/src/components/FeeBreakdownBottomSheet.tsx +++ b/src/components/FeeBreakdownBottomSheet.tsx @@ -58,6 +58,10 @@ const FeeBreakdownBottomSheet: React.FC = ({ 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( inclusionFeeXlmOverride ?? sorobanInclusionFeeXlm, sorobanResourceFeeXlm, diff --git a/src/components/TransactionSettingsBottomSheet.tsx b/src/components/TransactionSettingsBottomSheet.tsx index e172d4d58..54162927e 100644 --- a/src/components/TransactionSettingsBottomSheet.tsx +++ b/src/components/TransactionSettingsBottomSheet.tsx @@ -242,18 +242,19 @@ const TransactionSettingsBottomSheet: React.FC< // 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 flooredStoreFee = formatNumberForDisplay( - (() => { - const bn = new BigNumber(storeFee); - return ( - bn.isFinite() ? BigNumber.max(bn, minTotalFee) : minTotalFee - ).toString(); - })(), + (storeFeeBn.isFinite() + ? BigNumber.max(storeFeeBn, minTotalFee) + : minTotalFee + ).toString(), ); // Presets are per-op rates; the shown/stored fee is the TOTAL across all ops. - const presetTotalFee = (priority: FeePriority): string | undefined => { - const preset = feePresets[priority as keyof FeePresets]; + // 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; } @@ -276,8 +277,11 @@ const TransactionSettingsBottomSheet: React.FC< // 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. - const presetTotal = presetTotalFee(selectedFeePriority); + // 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 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/useNetworkFees.ts b/src/hooks/useNetworkFees.ts index 67cc65e50..299c23e9c 100644 --- a/src/hooks/useNetworkFees.ts +++ b/src/hooks/useNetworkFees.ts @@ -13,8 +13,11 @@ export interface NetworkFeesData { feePresets: FeePresets; } -// Empty presets so the priority selector resolves to "Custom" (not a spurious -// preset match) until the real network fees have loaded. +// 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. Note this is a DIFFERENT "no data" representation than the +// fetch-error path, where `getNetworkFees` falls back to the XLM minimum fee +// for all presets. const EMPTY_FEE_PRESETS: FeePresets = { [FeePriority.LOW]: "", [FeePriority.MEDIUM]: "", diff --git a/src/navigators/SendPaymentNavigator.tsx b/src/navigators/SendPaymentNavigator.tsx index 96f0d3840..c13438b67 100644 --- a/src/navigators/SendPaymentNavigator.tsx +++ b/src/navigators/SendPaymentNavigator.tsx @@ -19,7 +19,7 @@ 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 React, { useEffect } from "react"; const SendPaymentStack = createNativeStackNavigator(); @@ -49,6 +49,21 @@ const closeSendFlow = ( export const SendPaymentStackNavigator = () => { const { t } = useAppTranslation(); + // Reset send-flow state when the whole flow unmounts, so EVERY exit path + // (X button, hardware/gesture back, or programmatic) leaves a clean slate — + // e.g. the fee priority tier defaults back to Med on the next entry. The X + // button (closeSendFlow) resets eagerly too; this is the catch-all that also + // covers back-navigation. (The Swap flow already does this via its root + // screen's unmount effect.) + useEffect( + () => () => { + useSendRecipientStore.getState().resetSendRecipient(); + useTransactionSettingsStore.getState().resetSettings(); + useTransactionBuilderStore.getState().resetTransaction(); + }, + [], + ); + 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. + // 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]: DEFAULT_RECOMMENDED_STELLAR_FEE, - [FeePriority.MEDIUM]: DEFAULT_RECOMMENDED_STELLAR_FEE, - [FeePriority.HIGH]: DEFAULT_RECOMMENDED_STELLAR_FEE, + [FeePriority.LOW]: MIN_TRANSACTION_FEE, + [FeePriority.MEDIUM]: MIN_TRANSACTION_FEE, + [FeePriority.HIGH]: MIN_TRANSACTION_FEE, }; try { @@ -204,8 +206,9 @@ export const getNetworkFees = async (server: Horizon.Server) => { networkCongestion = NetworkCongestion.LOW; } } 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; } From 8bf88dce60d788a737acd4ba119fdb7c272ab851 Mon Sep 17 00:00:00 2001 From: leofelix077 Date: Mon, 29 Jun 2026 13:29:48 -0300 Subject: [PATCH 5/6] unify fee info panes --- .../TransactionSettingsBottomSheet.test.tsx | 30 ++++--- .../TransactionSettingsBottomSheet.tsx | 50 +++-------- .../components/SendReviewBottomSheet.tsx | 61 +++++--------- .../screens/SendCollectibleReview.tsx | 23 ------ .../screens/TransactionAmountScreen.tsx | 26 ------ .../components/SwapReviewBottomSheet.tsx | 41 +++++++++- src/hooks/useFeeDetailsBottomSheet.tsx | 82 +++++++++++++++++++ 7 files changed, 171 insertions(+), 142 deletions(-) create mode 100644 src/hooks/useFeeDetailsBottomSheet.tsx diff --git a/__tests__/components/TransactionSettingsBottomSheet.test.tsx b/__tests__/components/TransactionSettingsBottomSheet.test.tsx index 032163b1a..64f5ee472 100644 --- a/__tests__/components/TransactionSettingsBottomSheet.test.tsx +++ b/__tests__/components/TransactionSettingsBottomSheet.test.tsx @@ -95,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", () => ({ @@ -377,10 +389,8 @@ describe("TransactionSettingsBottomSheet - onSettingsChange Integration", () => }); }); - it("previews the selected inclusion fee in the breakdown without saving", async () => { - // Make this a Soroban transaction so the info icon opens the fee breakdown. + it("opening the fee details does not persist the fee (preview only)", async () => { mockIsContractId.mockReturnValue(true); - const mockOnOpenFeeBreakdown = jest.fn(); const { getByText, getByTestId } = renderWithProviders( onConfirm={mockOnConfirm} context={TransactionContext.Send} onSettingsChange={mockOnSettingsChange} - onOpenFeeBreakdown={mockOnOpenFeeBreakdown} />, ); - // Pick the High preset, then open the breakdown via the info icon. + // 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(() => { - // The breakdown previews the selected fee... - expect(mockOnOpenFeeBreakdown).toHaveBeenCalledWith("0.01"); + expect( + mockTransactionSettingsState.saveTransactionFee, + ).not.toHaveBeenCalled(); }); - - // ...but the fee is NOT persisted until the user presses Save. - expect( - mockTransactionSettingsState.saveTransactionFee, - ).not.toHaveBeenCalled(); }); it("opens on the stored fee priority tier (preset → input locked)", async () => { diff --git a/src/components/TransactionSettingsBottomSheet.tsx b/src/components/TransactionSettingsBottomSheet.tsx index 54162927e..0f416aeb7 100644 --- a/src/components/TransactionSettingsBottomSheet.tsx +++ b/src/components/TransactionSettingsBottomSheet.tsx @@ -32,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"; @@ -52,7 +53,6 @@ type TransactionSettingsBottomSheetProps = { onConfirm: () => void; context: TransactionContext; onSettingsChange?: () => void; - onOpenFeeBreakdown?: (inclusionFeeXlm: string) => 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 @@ -72,7 +72,6 @@ const TransactionSettingsBottomSheet: React.FC< onConfirm, context, onSettingsChange, - onOpenFeeBreakdown, operationCount = 1, }) => { // All hooks at the top @@ -112,7 +111,6 @@ const TransactionSettingsBottomSheet: React.FC< ); const timeoutInfoBottomSheetModalRef = useRef(null); - const feeInfoBottomSheetModalRef = useRef(null); const memoInfoBottomSheetModalRef = useRef(null); const slippageInfoBottomSheetModalRef = useRef(null); @@ -424,15 +422,13 @@ const TransactionSettingsBottomSheet: React.FC< [localFee], ); - // Opening the fee breakdown previews the current (unsaved) inclusion fee so - // the breakdown reflects what the user typed/selected. The fee is only - // persisted on Save — cancelling reverts to the stored value. - const handleOpenFeeBreakdown = useCallback(() => { - if (feeError) { - return; - } - onOpenFeeBreakdown?.(parseDisplayNumber(localFee).toString()); - }, [feeError, localFee, onOpenFeeBreakdown]); + // 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 = { @@ -592,14 +588,7 @@ const TransactionSettingsBottomSheet: React.FC< ? t("transactionSettings.inclusionFeeTitle") : t("transactionSettings.feeTitle")} - - isSorobanTransaction && onOpenFeeBreakdown - ? handleOpenFeeBreakdown() - : feeInfoBottomSheetModalRef.current?.present() - } - > + @@ -641,8 +630,7 @@ const TransactionSettingsBottomSheet: React.FC< ), [ isSorobanTransaction, - onOpenFeeBreakdown, - handleOpenFeeBreakdown, + openFeeDetails, localFee, feeError, t, @@ -721,23 +709,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, @@ -808,6 +779,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 f2027c584..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,25 +106,8 @@ const SendReviewBottomSheet: React.FC = ({ const { account } = useGetActiveAccount(); const { copyToClipboard } = useClipboard(); const slicedAddress = truncateAddress(recipientAddress, 4, 4); - const { - transactionXDR, - isBuilding, - error, - 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 @@ -135,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; @@ -292,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,14 +296,14 @@ const SendReviewBottomSheet: React.FC = ({ ) : ( - {formatTokenForDisplay(totalFeeXlm, NATIVE_TOKEN_CODE)} + {formatTokenForDisplay(inclusionFeeXlm, NATIVE_TOKEN_CODE)} ), @@ -353,14 +343,14 @@ const SendReviewBottomSheet: React.FC = ({ account?.publicKey, error, handleCopyXdr, - handleOpenFeeBreakdown, + openFeeDetails, isBuilding, renderMemoTitle, renderXdrContent, t, themeColors.foreground.primary, themeColors.text.secondary, - totalFeeXlm, + inclusionFeeXlm, transactionMemo, transactionXDR, isRecipientMuxed, @@ -439,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 044c43bcb..c62b33df8 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"; @@ -135,12 +134,7 @@ 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); - // In-progress inclusion fee previewed in the breakdown (not yet saved). - const [feeBreakdownInclusionFee, setFeeBreakdownInclusionFee] = useState< - string | undefined - >(undefined); const [transactionScanResult, setTransactionScanResult] = useState< Blockaid.StellarTransactionScanResponse | undefined >(undefined); @@ -696,23 +690,6 @@ const SendCollectibleReviewScreen: React.FC< onCancel={handleCancelTransactionSettings} onConfirm={handleConfirmTransactionSettings} onSettingsChange={handleSettingsChange} - onOpenFeeBreakdown={(inclusionFeeXlm) => { - setFeeBreakdownInclusionFee(inclusionFeeXlm); - feeBreakdownBottomSheetModalRef.current?.present(); - }} - /> - } - /> - - feeBreakdownBottomSheetModalRef.current?.dismiss() - } - customContent={ - feeBreakdownBottomSheetModalRef.current?.dismiss()} - isSorobanContext - inclusionFeeXlmOverride={feeBreakdownInclusionFee} /> } /> diff --git a/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx b/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx index 4fe708505..c621c5d50 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"; @@ -220,12 +219,7 @@ const TransactionAmountScreen: React.FC = ({ }, [transactionBuilderError, showToast]); const addMemoExplanationBottomSheetModalRef = useRef(null); const transactionSettingsBottomSheetModalRef = useRef(null); - const feeBreakdownBottomSheetModalRef = useRef(null); const muxedAddressInfoBottomSheetModalRef = useRef(null); - // In-progress inclusion fee previewed in the breakdown (not yet saved). - const [feeBreakdownInclusionFee, setFeeBreakdownInclusionFee] = useState< - string | undefined - >(undefined); const [transactionScanResult, setTransactionScanResult] = useState< Blockaid.StellarTransactionScanResponse | undefined >(undefined); @@ -1110,26 +1104,6 @@ const TransactionAmountScreen: React.FC = ({ onCancel={handleCancelTransactionSettings} onConfirm={handleConfirmTransactionSettings} onSettingsChange={handleSettingsChange} - onOpenFeeBreakdown={(inclusionFeeXlm) => { - setFeeBreakdownInclusionFee(inclusionFeeXlm); - feeBreakdownBottomSheetModalRef.current?.present(); - }} - /> - } - /> - - feeBreakdownBottomSheetModalRef.current?.dismiss() - } - customContent={ - feeBreakdownBottomSheetModalRef.current?.dismiss()} - isSorobanContext={isSorobanTransaction( - selectedBalance, - recipientAddress, - )} - inclusionFeeXlmOverride={feeBreakdownInclusionFee} /> } /> 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/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 }; +}; From e474ebe8b3b9b9ca980c142c5e4e8d2c56440834 Mon Sep 17 00:00:00 2001 From: leofelix077 Date: Mon, 29 Jun 2026 17:37:43 -0300 Subject: [PATCH 6/6] freeze network congestion and fees on flow start --- .../TransactionSettingsBottomSheet.test.tsx | 31 ++++++++++ .../SwapScreen/SwapAmountScreen.test.tsx | 3 + __tests__/hooks/useNetworkFees.test.ts | 57 ++++++++++++++++--- __tests__/services/stellar.test.ts | 33 ++++++----- .../TransactionSettingsBottomSheet.tsx | 18 ++++-- .../screens/SendCollectibleReview.tsx | 9 ++- .../screens/TransactionAmountScreen.tsx | 4 +- .../SwapScreen/screens/SwapAmountScreen.tsx | 9 ++- src/config/types.ts | 10 ++++ src/hooks/useInitialRecommendedFee.ts | 23 ++++++-- src/hooks/useNetworkFees.ts | 45 ++++++++------- src/navigators/SendPaymentNavigator.tsx | 14 +++-- src/navigators/SwapNavigator.tsx | 5 ++ src/services/stellar.ts | 13 +++-- 14 files changed, 211 insertions(+), 63 deletions(-) diff --git a/__tests__/components/TransactionSettingsBottomSheet.test.tsx b/__tests__/components/TransactionSettingsBottomSheet.test.tsx index 64f5ee472..51ecba129 100644 --- a/__tests__/components/TransactionSettingsBottomSheet.test.tsx +++ b/__tests__/components/TransactionSettingsBottomSheet.test.tsx @@ -455,6 +455,37 @@ describe("TransactionSettingsBottomSheet - onSettingsChange Integration", () => }); }); + 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 = { 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__/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 5052be0ba..adf2d309c 100644 --- a/__tests__/services/stellar.test.ts +++ b/__tests__/services/stellar.test.ts @@ -91,7 +91,7 @@ describe("stellar service - getNetworkFees", () => { ...overrides, }); - it("maps max_fee p10/p50/p90 to Low/Med/High presets and the Medium preset to the recommended fee (XLM)", async () => { + it("maps max_fee p10/p50/p90 to Low/Med/High presets (XLM)", async () => { const server = buildFeeStatsServer(() => Promise.resolve({ ledger_capacity_usage: "0.2", @@ -99,18 +99,20 @@ describe("stellar service - getNetworkFees", () => { }), ); - const { recommendedFee, networkCongestion, feePresets } = - await getNetworkFees(server); + const { feePresets } = await getNetworkFees(server); - expect(networkCongestion).toBe(NetworkCongestion.LOW); 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 - // The recommended (default) fee matches the Medium preset (p50). - expect(recommendedFee).toBe(feePresets[FeePriority.MEDIUM]); }); - it("derives congestion level from ledger capacity usage", async () => { + 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", @@ -124,12 +126,17 @@ describe("stellar service - getNetworkFees", () => { }), ); - expect((await getNetworkFees(mediumServer)).networkCongestion).toBe( - NetworkCongestion.MEDIUM, - ); - expect((await getNetworkFees(highServer)).networkCongestion).toBe( - NetworkCongestion.HIGH, - ); + 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 () => { diff --git a/src/components/TransactionSettingsBottomSheet.tsx b/src/components/TransactionSettingsBottomSheet.tsx index 0f416aeb7..d974dd4b1 100644 --- a/src/components/TransactionSettingsBottomSheet.tsx +++ b/src/components/TransactionSettingsBottomSheet.tsx @@ -108,6 +108,7 @@ const TransactionSettingsBottomSheet: React.FC< recommendedFee, context, operationCount, + networkCongestion, ); const timeoutInfoBottomSheetModalRef = useRef(null); @@ -259,12 +260,19 @@ const TransactionSettingsBottomSheet: React.FC< return new BigNumber(preset).times(operationCount).toString(); }; - // The selected tier is the single source of truth, persisted in the store, so - // it reflects the user's actual choice and never flickers to "Custom" when - // the 30s poll refetches presets with slightly different values (Med stays - // Med — only the shown amount tracks the new median). + // 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); @@ -383,6 +391,7 @@ const TransactionSettingsBottomSheet: React.FC< const handleFeeChange = useCallback((text: string) => { // Manual typing is only possible on the Custom tier; update its value. + feeInteractedRef.current = true; const normalizedText = enforceSettingInputDecimalSeparator(text); setCustomFee(normalizedText); }, []); @@ -410,6 +419,7 @@ const TransactionSettingsBottomSheet: React.FC< 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 diff --git a/src/components/screens/SendScreen/screens/SendCollectibleReview.tsx b/src/components/screens/SendScreen/screens/SendCollectibleReview.tsx index c62b33df8..1b922a02a 100644 --- a/src/components/screens/SendScreen/screens/SendCollectibleReview.tsx +++ b/src/components/screens/SendScreen/screens/SendCollectibleReview.tsx @@ -103,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, diff --git a/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx b/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx index c621c5d50..9856bc8cd 100644 --- a/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx +++ b/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx @@ -170,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); @@ -418,6 +418,8 @@ const TransactionAmountScreen: React.FC = ({ useInitialRecommendedFee( hasEnteredAmount ? "" : recommendedFee, TransactionContext.Send, + 1, + networkCongestion, ); const unfundedContext: UnfundedDestinationContext | undefined = useMemo( 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/config/types.ts b/src/config/types.ts index bdf470444..6bccf3a91 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -47,6 +47,16 @@ export type FeePresets = Record< string >; +/** 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/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 299c23e9c..3b9ff6e0c 100644 --- a/src/hooks/useNetworkFees.ts +++ b/src/hooks/useNetworkFees.ts @@ -5,8 +5,6 @@ import { useAuthenticationStore } from "ducks/auth"; import { useEffect, useState } from "react"; import { getNetworkFees, stellarSdkServer } from "services/stellar"; -const NETWORK_FEES_POLL_INTERVAL_MS = 30000; - export interface NetworkFeesData { recommendedFee: string; networkCongestion: NetworkCongestion; @@ -15,9 +13,8 @@ export interface NetworkFeesData { // 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. Note this is a DIFFERENT "no data" representation than the -// fetch-error path, where `getNetworkFees` falls back to the XLM minimum fee -// for all presets. +// 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]: "", @@ -31,18 +28,28 @@ const DEFAULT_NETWORK_FEES: NetworkFeesData = { }; /** - * Last successful fetch per network. New mounts seed their initial state from - * this so a freshly mounted consumer (most visibly the shared transaction - * settings sheet, which mounts after the screen behind it has already loaded - * the real values) doesn't flash the defaults before its own fetch resolves. + * 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 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. * - * @returns An object containing the recommended fee, network congestion level - * and the Low/Med/High inclusion-fee presets + * 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 The recommended fee, network congestion, and Low/Med/High presets. */ export const useNetworkFees = (): NetworkFeesData => { const { network } = useAuthenticationStore(); @@ -51,12 +58,14 @@ export const useNetworkFees = (): NetworkFeesData => { ); useEffect(() => { - // Reflect any cached value for this network immediately (e.g. on switch). 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); @@ -66,7 +75,7 @@ export const useNetworkFees = (): NetworkFeesData => { // Guard against an invalid/empty result so the hook never returns // undefined to consumers. - if (!data?.recommendedFee) { + if (cancelled || !data?.recommendedFee) { return; } @@ -79,11 +88,9 @@ export const useNetworkFees = (): NetworkFeesData => { fetchNetworkFees(); - const interval = setInterval(() => { - fetchNetworkFees(); - }, NETWORK_FEES_POLL_INTERVAL_MS); - - return () => clearInterval(interval); + return () => { + cancelled = true; + }; }, [network]); return fees; diff --git a/src/navigators/SendPaymentNavigator.tsx b/src/navigators/SendPaymentNavigator.tsx index c13438b67..5515c0e01 100644 --- a/src/navigators/SendPaymentNavigator.tsx +++ b/src/navigators/SendPaymentNavigator.tsx @@ -19,6 +19,7 @@ import { useTransactionBuilderStore } from "ducks/transactionBuilder"; import { useTransactionSettingsStore } from "ducks/transactionSettings"; import { withTransitionOverride } from "helpers/navigationOptions"; import useAppTranslation from "hooks/useAppTranslation"; +import { clearNetworkFeesCache, useNetworkFees } from "hooks/useNetworkFees"; import React, { useEffect } from "react"; const SendPaymentStack = @@ -49,17 +50,20 @@ 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 button, hardware/gesture back, or programmatic) leaves a clean slate — - // e.g. the fee priority tier defaults back to Med on the next entry. The X - // button (closeSendFlow) resets eagerly too; this is the catch-all that also - // covers back-navigation. (The Swap flow already does this via its root - // screen's unmount effect.) + // (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(); }, [], ); diff --git a/src/navigators/SwapNavigator.tsx b/src/navigators/SwapNavigator.tsx index 2fcb98144..acacc36a2 100644 --- a/src/navigators/SwapNavigator.tsx +++ b/src/navigators/SwapNavigator.tsx @@ -9,6 +9,7 @@ import { SWAP_SELECTION_TYPES } from "config/constants"; import { SWAP_ROUTES, SwapStackParamList } from "config/routes"; import { getScreenBottomNavigateOptions } from "helpers/navigationOptions"; import useAppTranslation from "hooks/useAppTranslation"; +import { useNetworkFees } from "hooks/useNetworkFees"; import React from "react"; const SwapStack = createNativeStackNavigator(); @@ -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 ( { [FeePriority.MEDIUM]: stroopToXlm(maxFee.p50).toFixed(), [FeePriority.HIGH]: stroopToXlm(maxFee.p90).toFixed(), }; - // The recommended (default) fee matches the Medium preset (the median of the - // max-fee distribution), so the settings sheet opens on the "Med" tier. - recommendedFee = feePresets[FeePriority.MEDIUM]; if ( ledgerCapacityUsageNum > LEDGER_CAPACITY_MEDIUM_THRESHOLD && @@ -205,6 +207,9 @@ export const getNetworkFees = async (server: Horizon.Server) => { } else { networkCongestion = NetworkCongestion.LOW; } + + // Recommended (default) fee = the preset matching current congestion (1:1). + recommendedFee = feePresets[CONGESTION_TO_FEE_PRIORITY[networkCongestion]]; } catch (e) { // Fall back to the network minimum (XLM); presets stay at their XLM // defaults set above.