diff --git a/.github/workflows/socket-scan.yml b/.github/workflows/socket-scan.yml
index d4df52626..ee4fe8ebb 100644
--- a/.github/workflows/socket-scan.yml
+++ b/.github/workflows/socket-scan.yml
@@ -27,7 +27,7 @@ name: Socket reachability scan
on:
schedule:
- - cron: '48 8 * * 6'
+ - cron: "48 8 * * 6"
workflow_dispatch:
permissions:
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/TokensCollectiblesInline.test.tsx b/__tests__/components/TokensCollectiblesInline.test.tsx
index a21c159ed..500841a00 100644
--- a/__tests__/components/TokensCollectiblesInline.test.tsx
+++ b/__tests__/components/TokensCollectiblesInline.test.tsx
@@ -171,9 +171,7 @@ describe("TokensCollectiblesInline", () => {
renderComponent();
expect(screen.getByText("Collectibles")).toBeTruthy();
- expect(
- screen.getByTestId("collectibles-inline-error"),
- ).toBeTruthy();
+ expect(screen.getByTestId("collectibles-inline-error")).toBeTruthy();
expect(screen.getByText("Error loading collectibles")).toBeTruthy();
});
@@ -308,9 +306,7 @@ describe("TokensCollectiblesInline", () => {
renderComponent();
- expect(
- screen.getByText("No tokens or collectibles to send"),
- ).toBeTruthy();
+ expect(screen.getByText("No tokens or collectibles to send")).toBeTruthy();
});
it("hides the collectibles section when there are no collectibles", () => {
@@ -321,9 +317,7 @@ describe("TokensCollectiblesInline", () => {
expect(screen.getByText("Tokens")).toBeTruthy();
expect(screen.getByTestId("balances-list-token-row")).toBeTruthy();
expect(screen.queryByText("Collectibles")).toBeNull();
- expect(
- screen.queryByText("No tokens or collectibles to send"),
- ).toBeNull();
+ expect(screen.queryByText("No tokens or collectibles to send")).toBeNull();
});
it("renders both the tokens and collectibles sections when both are present", () => {
@@ -351,9 +345,7 @@ describe("TokensCollectiblesInline", () => {
expect(screen.getByTestId("balances-list-token-row")).toBeTruthy();
expect(screen.getByText("Collectibles")).toBeTruthy();
expect(screen.getByText("Collectible #42")).toBeTruthy();
- expect(
- screen.queryByText("No tokens or collectibles to send"),
- ).toBeNull();
+ expect(screen.queryByText("No tokens or collectibles to send")).toBeNull();
});
it("hides the tokens section when there are no balances", () => {
diff --git a/__tests__/components/TransactionSettingsBottomSheet.test.tsx b/__tests__/components/TransactionSettingsBottomSheet.test.tsx
index cea8fe41e..eb72d8f29 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 a test can simulate the fees changing (e.g. a re-fetch on flow
+// re-entry) and assert the tier doesn't flicker.
+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,258 @@ 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");
+ expect(mockTransactionSettingsState.saveFeePriority).toHaveBeenCalledWith(
+ "low",
+ );
+ });
+ });
+
+ it("does not switch to preset tiers before fee presets are loaded", async () => {
+ mockNetworkFees = {
+ ...mockDefaultNetworkFees,
+ feePresets: {
+ low: "",
+ medium: "",
+ high: "",
+ },
+ };
+
+ const { getByText } = renderWithProviders(
+ ,
+ );
+
+ // Preset selection is ignored until presets are available.
+ fireEvent.press(getByText("transactionSettings.priorityHigh"));
+ fireEvent.press(getByText("common.save"));
+
+ await waitFor(() => {
+ expect(mockTransactionSettingsState.saveFeePriority).toHaveBeenCalledWith(
+ "medium",
+ );
+ });
+ });
+
+ it("scales the preset fee by operationCount (saves the total across ops)", async () => {
+ // Default tier is Med; the Medium preset is 0.001. A 2-op transaction
+ // (e.g. swap-to-new-token) stores the total: 0.001 × 2 = 0.002.
+ const { getByText } = renderWithProviders(
+ ,
+ );
+
+ fireEvent.press(getByText("common.save"));
+
+ await waitFor(() => {
+ expect(
+ mockTransactionSettingsState.saveTransactionFee,
+ ).toHaveBeenCalledWith("0.002");
+ });
+ });
+
+ 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 change (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);
+ });
+
+ // Fees change underfoot (e.g. a refetch on flow re-entry).
+ 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 +588,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/SendScreen/screens/SendCollectibleReview.test.tsx b/__tests__/components/screens/SendScreen/screens/SendCollectibleReview.test.tsx
index 4839370b6..a8ef55368 100644
--- a/__tests__/components/screens/SendScreen/screens/SendCollectibleReview.test.tsx
+++ b/__tests__/components/screens/SendScreen/screens/SendCollectibleReview.test.tsx
@@ -247,6 +247,8 @@ const setupDefaultMocks = () => {
transactionMemo: "",
transactionFee: "0.00001",
transactionTimeout: 30,
+ feePriority: "low",
+ feeManuallyChanged: false,
recipientAddress: mockRecipientAddress,
selectedTokenId: "",
selectedCollectibleDetails: {
@@ -255,6 +257,8 @@ const setupDefaultMocks = () => {
},
saveMemo: jest.fn(),
saveTransactionFee: jest.fn(),
+ saveFeePriority: jest.fn(),
+ markFeeManuallyChanged: jest.fn(),
saveTransactionTimeout: jest.fn(),
saveRecipientAddress: jest.fn(),
saveSelectedTokenId: jest.fn(),
diff --git a/__tests__/components/screens/SendScreen/screens/TransactionProcessingScreen.test.tsx b/__tests__/components/screens/SendScreen/screens/TransactionProcessingScreen.test.tsx
index da31273f0..75dbdc467 100644
--- a/__tests__/components/screens/SendScreen/screens/TransactionProcessingScreen.test.tsx
+++ b/__tests__/components/screens/SendScreen/screens/TransactionProcessingScreen.test.tsx
@@ -53,7 +53,9 @@ jest.mock("@react-navigation/native", () => ({
// Heavy presentational children are irrelevant to the emission under test.
jest.mock("components/BottomSheet", () => () => null);
jest.mock("components/TransactionDetailsBottomSheet", () => () => null);
-jest.mock("components/CollectibleImage", () => ({ CollectibleImage: () => null }));
+jest.mock("components/CollectibleImage", () => ({
+ CollectibleImage: () => null,
+}));
jest.mock("components/TokenIcon", () => ({ TokenIcon: () => null }));
jest.mock("components/Spinner", () => () => null);
jest.mock("components/layout/BaseLayout", () => ({
diff --git a/__tests__/components/screens/SwapScreen/SwapAmountScreen.test.tsx b/__tests__/components/screens/SwapScreen/SwapAmountScreen.test.tsx
index 6366f6b9b..026f51737 100644
--- a/__tests__/components/screens/SwapScreen/SwapAmountScreen.test.tsx
+++ b/__tests__/components/screens/SwapScreen/SwapAmountScreen.test.tsx
@@ -185,6 +185,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
@@ -192,8 +194,9 @@ jest.mock("ducks/swapSettings", () => ({
jest.mock("hooks/useNetworkFees", () => ({
useNetworkFees: () => ({
recommendedFee: "0.001",
- networkCongestion: "LOW",
+ 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/useInitialRecommendedFee.test.tsx b/__tests__/hooks/useInitialRecommendedFee.test.tsx
index 45688da3d..1ac39f300 100644
--- a/__tests__/hooks/useInitialRecommendedFee.test.tsx
+++ b/__tests__/hooks/useInitialRecommendedFee.test.tsx
@@ -1,5 +1,6 @@
import { act, renderHook } from "@testing-library/react-hooks";
import { MIN_TRANSACTION_FEE, TransactionContext } from "config/constants";
+import { FeePriority, NetworkCongestion } from "config/types";
import { useSwapSettingsStore } from "ducks/swapSettings";
import { useTransactionSettingsStore } from "ducks/transactionSettings";
import { useInitialRecommendedFee } from "hooks/useInitialRecommendedFee";
@@ -89,4 +90,62 @@ describe("useInitialRecommendedFee", () => {
expect(useTransactionSettingsStore.getState().transactionFee).toBe("0.001");
});
+
+ it("defaults the fee priority tier to match network congestion (1:1)", () => {
+ (
+ [
+ [NetworkCongestion.LOW, FeePriority.LOW],
+ [NetworkCongestion.MEDIUM, FeePriority.MEDIUM],
+ [NetworkCongestion.HIGH, FeePriority.HIGH],
+ ] as const
+ ).forEach(([congestion, expectedTier]) => {
+ act(() => {
+ useTransactionSettingsStore.getState().resetSettings();
+ });
+
+ renderHook(() =>
+ useInitialRecommendedFee(
+ "0.001",
+ TransactionContext.Send,
+ 1,
+ congestion,
+ ),
+ );
+
+ expect(useTransactionSettingsStore.getState().feePriority).toBe(
+ expectedTier,
+ );
+ });
+ });
+
+ it("stops re-defaulting the tier once the fee is manually changed", () => {
+ const { result, rerender } = renderHook(
+ ({ congestion }: { congestion: NetworkCongestion }) =>
+ useInitialRecommendedFee(
+ "0.001",
+ TransactionContext.Send,
+ 1,
+ congestion,
+ ),
+ { initialProps: { congestion: NetworkCongestion.LOW } },
+ );
+
+ expect(useTransactionSettingsStore.getState().feePriority).toBe(
+ FeePriority.LOW,
+ );
+
+ act(() => {
+ result.current.markAsManuallyChanged();
+ useTransactionSettingsStore
+ .getState()
+ .saveFeePriority(FeePriority.CUSTOM);
+ });
+
+ // Congestion rises, but the user's chosen tier is preserved.
+ rerender({ congestion: NetworkCongestion.HIGH });
+
+ expect(useTransactionSettingsStore.getState().feePriority).toBe(
+ FeePriority.CUSTOM,
+ );
+ });
});
diff --git a/__tests__/hooks/useNetworkFees.test.ts b/__tests__/hooks/useNetworkFees.test.ts
index ee19c7db1..4b86d3de8 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,15 +13,27 @@ 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 () => {
mockGetNetworkFees.mockResolvedValue({
recommendedFee: "0.005",
networkCongestion: NetworkCongestion.HIGH,
+ feePresets: {
+ low: "0.001",
+ medium: "0.003",
+ high: "0.005",
+ },
});
// First mount: starts at the defaults, then fills in from the fetch.
@@ -29,17 +41,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 +54,53 @@ 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,
+ feePresets: {
+ low: "0.001",
+ medium: "0.003",
+ high: "0.005",
+ },
+ });
+
+ 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,
+ feePresets: {
+ low: "0.001",
+ medium: "0.003",
+ high: "0.005",
+ },
+ });
+
+ 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/CollectionSection.tsx b/src/components/CollectionSection.tsx
index 3082c0f0e..2ac0920fa 100644
--- a/src/components/CollectionSection.tsx
+++ b/src/components/CollectionSection.tsx
@@ -82,23 +82,23 @@ export const CollectionSection: React.FC = ({
})
}
>
-
-
-
- {item.isHidden && (
-
+
- )}
+ {item.isHidden && (
+
+
+
+ )}
);
};
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..75db121c1 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
@@ -63,6 +64,11 @@ type TransactionSettingsBottomSheetProps = {
// Constants
const STEP_SIZE_PERCENT = 0.5;
+const PRESET_PRIORITIES: Array = [
+ FeePriority.LOW,
+ FeePriority.MEDIUM,
+ FeePriority.HIGH,
+];
const TransactionSettingsBottomSheet: React.FC<
TransactionSettingsBottomSheetProps
@@ -71,13 +77,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 +94,8 @@ const TransactionSettingsBottomSheet: React.FC<
saveMemo: saveTransactionMemo,
saveTransactionFee,
saveTransactionTimeout,
+ feePriority: txFeePriority,
+ saveFeePriority: saveTxFeePriority,
} = useTransactionSettingsStore();
const {
@@ -98,16 +105,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 +218,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 +242,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 +395,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 +406,57 @@ 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 areFeePresetsLoaded = useMemo(
+ () => PRESET_PRIORITIES.every((priority) => !!feePresets[priority]),
+ [feePresets],
+ );
+
+ const handleFeePriorityChange = useCallback(
+ (value: string | number) => {
+ feeInteractedRef.current = true;
+ const priority = value as FeePriority;
+
+ // Keep preset tiers unavailable until we have a fetched preset snapshot.
+ // Custom remains selectable so users can still set a manual fee.
+ if (priority !== FeePriority.CUSTOM && !areFeePresetsLoaded) {
+ return;
+ }
+
+ // 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],
+ [areFeePresetsLoaded, 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 +471,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 +615,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 +646,26 @@ const TransactionSettingsBottomSheet: React.FC<
}
/>
+
+
+
),
[
isSorobanTransaction,
- onOpenFeeBreakdown,
+ openFeeDetails,
localFee,
feeError,
t,
- themeColors.lilac,
networkCongestion,
- getLocalizedCongestionLevel,
handleFeeChange,
- recommendedFee,
- markAsManuallyChanged,
- operationCount,
+ feePriorityOptions,
+ selectedFeePriority,
+ handleFeePriorityChange,
],
);
@@ -692,23 +736,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 +806,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..e8c51c0e9 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";
@@ -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";
@@ -112,29 +110,29 @@ const SendReviewBottomSheet: React.FC = ({
transactionXDR,
isBuilding,
error,
- isSoroban,
- sorobanResourceFeeXlm,
sorobanInclusionFeeXlm,
+ sorobanResourceFeeXlm,
} = useTransactionBuilderStore();
- const feeBreakdownSheetRef = useRef(null);
+ // Derived from current context (collectible or Soroban token/address) rather
+ // than the builder store so the fee breakdown sheet shows Soroban rows and
+ // description even before simulation has completed.
+ const isSorobanContext =
+ type === SendType.Collectible ||
+ isSorobanTransaction(selectedBalance, recipientAddress);
- const handleOpenFeeBreakdown = useCallback(() => {
- feeBreakdownSheetRef.current?.present();
- }, []);
+ const effectiveInclusionFeeXlm = sorobanInclusionFeeXlm ?? transactionFee;
+ // Match FeeBreakdownBottomSheet: review sheet should show total fee.
const totalFeeXlm = computeTotalFeeXlm(
sorobanInclusionFeeXlm,
sorobanResourceFeeXlm,
- transactionFee,
+ effectiveInclusionFeeXlm,
);
- // Derived from current context (collectible or Soroban token/address) rather
- // than the builder store so the fee breakdown sheet shows Soroban rows and
- // description even before simulation has completed.
- const isSorobanContext =
- type === SendType.Collectible ||
- isSorobanTransaction(selectedBalance, recipientAddress);
+ const { openFeeDetails, feeDetailsSheets } = useFeeDetailsBottomSheet({
+ isSorobanContext,
+ });
// Use amountError from props (calculated in parent component)
const amountError = propAmountError;
@@ -293,8 +291,10 @@ 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 total fee (matching FeeBreakdownBottomSheet).
+ // 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,15 +306,13 @@ const SendReviewBottomSheet: React.FC = ({
/>
) : (
- {isSoroban && (
-
-
-
- )}
+
+
+
{formatTokenForDisplay(totalFeeXlm, NATIVE_TOKEN_CODE)}
@@ -356,9 +354,8 @@ const SendReviewBottomSheet: React.FC = ({
account?.publicKey,
error,
handleCopyXdr,
- handleOpenFeeBreakdown,
+ openFeeDetails,
isBuilding,
- isSoroban,
renderMemoTitle,
renderXdrContent,
t,
@@ -443,16 +440,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/components/TokensCollectiblesInline.tsx b/src/components/screens/SendScreen/components/TokensCollectiblesInline.tsx
index c7a6a8a23..4eaf5ee6b 100644
--- a/src/components/screens/SendScreen/components/TokensCollectiblesInline.tsx
+++ b/src/components/screens/SendScreen/components/TokensCollectiblesInline.tsx
@@ -50,8 +50,11 @@ export const TokensCollectiblesInline: React.FC<
// Tokens state, lifted from the same hook BalancesList uses internally so we
// can drive a single page-level spinner/error.
- const { isLoading: tokensLoading, error: tokensError, noBalances } =
- useBalancesList({ publicKey, network });
+ const {
+ isLoading: tokensLoading,
+ error: tokensError,
+ noBalances,
+ } = useBalancesList({ publicKey, network });
// Collectibles state.
const collectiblesLoading = useCollectiblesStore((state) => state.isLoading);
@@ -86,7 +89,8 @@ export const TokensCollectiblesInline: React.FC<
// still renders its content. A section is "shown" when it either has data or
// has errored.
const tokensSectionShown = Boolean(tokensError) || hasTokens;
- const collectiblesSectionShown = Boolean(collectiblesError) || hasCollectibles;
+ const collectiblesSectionShown =
+ Boolean(collectiblesError) || hasCollectibles;
// The combined empty fallback only applies when both sources succeeded with
// no data; an error in either section takes its place instead.
diff --git a/src/components/screens/SendScreen/screens/SendCollectibleReview.tsx b/src/components/screens/SendScreen/screens/SendCollectibleReview.tsx
index 776733267..57c5243ec 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";
@@ -106,9 +105,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,
@@ -137,7 +141,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
@@ -212,8 +215,16 @@ const SendCollectibleReviewScreen: React.FC<
);
useEffect(() => {
+ let isCancelled = false;
+
const checkContract = async () => {
- if (!collectionAddress || !recipientAddress || !network) {
+ // Contract muxed support only matters for M-address recipients.
+ if (
+ !isRecipientMuxed ||
+ !collectionAddress ||
+ !recipientAddress ||
+ !network
+ ) {
setContractSupportsMuxed(null);
return;
}
@@ -224,15 +235,24 @@ const SendCollectibleReviewScreen: React.FC<
contractId: collectionAddress,
networkDetails,
});
- setContractSupportsMuxed(supportsMuxed);
+
+ if (!isCancelled) {
+ setContractSupportsMuxed(supportsMuxed);
+ }
} catch (error) {
// On error, assume no support for safety
- setContractSupportsMuxed(false);
+ if (!isCancelled) {
+ setContractSupportsMuxed(false);
+ }
}
};
checkContract();
- }, [collectionAddress, recipientAddress, network]);
+
+ return () => {
+ isCancelled = true;
+ };
+ }, [collectionAddress, recipientAddress, network, isRecipientMuxed]);
// Determine if M address + contract doesn't support muxed
const isMuxedAddressWithoutMemoSupport = Boolean(
@@ -710,21 +730,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 ebbee97ee..bcefa9268 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";
@@ -173,7 +172,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);
@@ -222,7 +221,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
@@ -422,6 +420,8 @@ const TransactionAmountScreen: React.FC = ({
useInitialRecommendedFee(
hasEnteredAmount ? "" : recommendedFee,
TransactionContext.Send,
+ 1,
+ networkCongestion,
);
const unfundedContext: UnfundedDestinationContext | undefined = useMemo(
@@ -1140,24 +1140,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 311865188..97e4306ff 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/hooks/useSwapNavigation.ts b/src/components/screens/SwapScreen/hooks/useSwapNavigation.ts
index 491572391..ab235a486 100644
--- a/src/components/screens/SwapScreen/hooks/useSwapNavigation.ts
+++ b/src/components/screens/SwapScreen/hooks/useSwapNavigation.ts
@@ -40,7 +40,10 @@ export const useSwapNavigation = ({
} => {
const openDestinationPicker = useCallback(
(source: SwapPickerEntrypoint = SwapPickerEntrypoint.DROPDOWN) => {
- analytics.track(AnalyticsEvent.SWAP_PICKER_OPENED, { side: "to", source });
+ analytics.track(AnalyticsEvent.SWAP_PICKER_OPENED, {
+ side: "to",
+ source,
+ });
navigation.navigate(SWAP_ROUTES.SWAP_SCREEN, {
selectionType: SWAP_SELECTION_TYPES.DESTINATION,
});
diff --git a/src/components/screens/SwapScreen/screens/SwapAmountScreen.tsx b/src/components/screens/SwapScreen/screens/SwapAmountScreen.tsx
index 14e71a9bf..bb32cef5c 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,
@@ -411,6 +411,7 @@ const SwapAmountScreen: React.FC = ({
hasEnteredSourceAmount ? "" : recommendedFee,
TransactionContext.Swap,
swapOperationCount,
+ networkCongestion,
);
const {
@@ -694,12 +695,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
new file mode 100644
index 000000000..04de1c2ea
--- /dev/null
+++ b/src/components/sds/SegmentedControl.tsx
@@ -0,0 +1,75 @@
+import { Text } from "components/sds/Typography";
+import useColors from "hooks/useColors";
+import React from "react";
+import { TouchableOpacity, View } from "react-native";
+
+interface SegmentedControlOption {
+ label: string;
+ value: string | number;
+}
+
+interface SegmentedControlProps {
+ options: SegmentedControlOption[];
+ selectedValue: string | number;
+ onValueChange: (value: string | number) => void;
+ disabled?: boolean;
+}
+
+// Extra vertical touch area (px) so the compact segments reach ~44pt tall.
+const SEGMENT_HIT_SLOP = 8;
+
+/**
+ * SegmentedControl Component
+ *
+ * A segmented control component that allows users to select from multiple options
+ * in a button group style interface, following the design system patterns.
+ *
+ * @param {SegmentedControlProps} props - Component props
+ * @returns {JSX.Element} The rendered component
+ */
+const SegmentedControl: React.FC = ({
+ options,
+ selectedValue,
+ onValueChange,
+ disabled = false,
+}) => {
+ const { themeColors } = useColors();
+
+ return (
+
+ {options.map((option) => {
+ const isSelected = option.value === selectedValue;
+
+ return (
+ !disabled && onValueChange(option.value)}
+ disabled={disabled}
+ accessibilityRole="radio"
+ accessibilityState={{ selected: isSelected, disabled }}
+ accessibilityLabel={option.label}
+ // Visual padding stays compact (matches the design); hitSlop extends
+ // the vertical touch target toward the ~44pt minimum.
+ hitSlop={{ top: SEGMENT_HIT_SLOP, bottom: SEGMENT_HIT_SLOP }}
+ className={`flex-1 items-center justify-center rounded-md px-[10px] py-[6px] ${
+ isSelected ? "bg-lilac-4" : ""
+ }`}
+ >
+
+ {option.label}
+
+
+ );
+ })}
+
+ );
+};
+
+export default SegmentedControl;
diff --git a/src/config/types.ts b/src/config/types.ts
index 8a1093114..0f75a5fee 100644
--- a/src/config/types.ts
+++ b/src/config/types.ts
@@ -31,6 +31,32 @@ export enum NetworkCongestion {
HIGH = "High",
}
+export enum FeePriority {
+ LOW = "low",
+ MEDIUM = "medium",
+ HIGH = "high",
+ CUSTOM = "custom",
+}
+
+/**
+ * Recommended inclusion fees (in XLM) for each non-custom priority tier,
+ * derived from the Horizon `feeStats().max_fee` percentile distribution.
+ */
+export type FeePresets = Record<
+ FeePriority.LOW | FeePriority.MEDIUM | FeePriority.HIGH,
+ 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/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..a8f61df8c 100644
--- a/src/hooks/useNetworkFees.ts
+++ b/src/hooks/useNetworkFees.ts
@@ -1,55 +1,101 @@
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> = {};
+
+// In-flight fetch per network, so the navigator's prewarm and the screen that
+// mounts right after it share a single feeStats round-trip instead of racing
+// two. Cleared once the fetch settles (and on flow exit).
+const inflightFetches: 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];
+ });
+ (Object.keys(inflightFetches) as NETWORKS[]).forEach((key) => {
+ delete inflightFetches[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 and network congestion level
+ * 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 = () => {
+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);
+ let pending = inflightFetches[network];
+ if (!pending) {
+ const { networkUrl } = mapNetworkToNetworkDetails(network);
+ const server = stellarSdkServer(networkUrl);
+ pending = getNetworkFees(server).finally(() => {
+ delete inflightFetches[network];
+ });
+ inflightFetches[network] = pending;
+ }
- const { recommendedFee: fee, networkCongestion: congestion } =
- await getNetworkFees(server);
+ const data = await pending;
- 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 +103,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 43663d918..458aa5623 100644
--- a/src/i18n/locales/en/translations.json
+++ b/src/i18n/locales/en/translations.json
@@ -705,6 +705,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 d283f5196..9497168ba 100644
--- a/src/i18n/locales/pt/translations.json
+++ b/src/i18n/locales/pt/translations.json
@@ -668,6 +668,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,
+ };
+
+ const safePresetFee = (percentile: string): string => {
+ if (!Number.isFinite(Number(percentile))) {
+ return MIN_TRANSACTION_FEE;
+ }
+
+ const xlmFee = stroopToXlm(percentile).toFixed();
+ return Number.isFinite(Number(xlmFee)) ? xlmFee : 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]: safePresetFee(maxFee.p10),
+ [FeePriority.MEDIUM]: safePresetFee(maxFee.p50),
+ [FeePriority.HIGH]: safePresetFee(maxFee.p90),
+ };
+
+ 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. */