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