diff --git a/__tests__/components/TransactionSettingsBottomSheet.test.tsx b/__tests__/components/TransactionSettingsBottomSheet.test.tsx index cea8fe41e..0b2f959b5 100644 --- a/__tests__/components/TransactionSettingsBottomSheet.test.tsx +++ b/__tests__/components/TransactionSettingsBottomSheet.test.tsx @@ -4,6 +4,7 @@ import TransactionSettingsBottomSheet from "components/TransactionSettingsBottom import { TransactionContext, NETWORKS } from "config/constants"; import { PricedBalance, TokenTypeWithCustomToken } from "config/types"; import { useAuthenticationStore } from "ducks/auth"; +import { useTransactionBuilderStore } from "ducks/transactionBuilder"; import { useTransactionSettingsStore } from "ducks/transactionSettings"; import { isContractId, isSorobanTransaction } from "helpers/soroban"; import { isMuxedAccount, isValidStellarAddress } from "helpers/stellar"; @@ -13,6 +14,11 @@ import React from "react"; import { checkContractSupportsMuxed } from "services/backend"; jest.mock("ducks/transactionSettings"); +jest.mock("ducks/transactionBuilder", () => ({ + useTransactionBuilderStore: jest.fn(() => ({ + isSoroban: false, + })), +})); jest.mock("ducks/auth", () => ({ useAuthenticationStore: jest.fn(), })); @@ -97,6 +103,10 @@ const mockUseTransactionSettingsStore = useTransactionSettingsStore as jest.MockedFunction< typeof useTransactionSettingsStore >; +const mockUseTransactionBuilderStore = + useTransactionBuilderStore as jest.MockedFunction< + typeof useTransactionBuilderStore + >; const mockUseAuthenticationStore = useAuthenticationStore as jest.MockedFunction; @@ -452,6 +462,7 @@ describe("TransactionSettingsBottomSheet - Soroban Transaction Tests", () => { ); // Mock isSorobanTransaction to return true when we have a custom token balance mockIsSorobanTransaction.mockReturnValue(true); + mockUseTransactionBuilderStore.mockReturnValue({ isSoroban: true } as any); mockIsMuxedAccount.mockReturnValue(false); mockIsValidStellarAddress.mockReturnValue(true); // Contract supports muxed (to_muxed) → memo should be enabled @@ -526,6 +537,7 @@ describe("TransactionSettingsBottomSheet - Soroban Transaction Tests", () => { ); // Mock isSorobanTransaction to return true when we have a custom token balance mockIsSorobanTransaction.mockReturnValue(true); + mockUseTransactionBuilderStore.mockReturnValue({ isSoroban: true } as any); mockIsMuxedAccount.mockReturnValue(false); mockIsValidStellarAddress.mockReturnValue(true); @@ -628,6 +640,7 @@ describe("TransactionSettingsBottomSheet - Soroban Transaction Tests", () => { mockUseTransactionSettingsStore.mockReturnValue(mockState); mockIsMuxedAccount.mockReturnValue(false); mockIsValidStellarAddress.mockReturnValue(true); + mockUseTransactionBuilderStore.mockReturnValue({ isSoroban: true } as any); // Contract supports muxed (to_muxed) → memo should be enabled mockCheckContractSupportsMuxed.mockResolvedValue(true); @@ -663,6 +676,7 @@ describe("TransactionSettingsBottomSheet - Soroban Transaction Tests", () => { mockUseTransactionSettingsStore.mockReturnValue(mockState); mockIsMuxedAccount.mockReturnValue(false); mockIsValidStellarAddress.mockReturnValue(true); + mockUseTransactionBuilderStore.mockReturnValue({ isSoroban: true } as any); // Contract doesn't support muxed (to_muxed) → memo should be disabled mockCheckContractSupportsMuxed.mockReset(); mockCheckContractSupportsMuxed.mockResolvedValue(false); diff --git a/__tests__/ducks/transactionBuilder.test.ts b/__tests__/ducks/transactionBuilder.test.ts index 22cb2a8f8..6a9577d02 100644 --- a/__tests__/ducks/transactionBuilder.test.ts +++ b/__tests__/ducks/transactionBuilder.test.ts @@ -53,6 +53,10 @@ describe("transactionBuilder Duck", () => { isSubmitting: false, transactionHash: null, error: null, + requestId: null, + isSoroban: false, + sorobanResourceFeeXlm: null, + sorobanInclusionFeeXlm: null, }); }); @@ -61,7 +65,10 @@ describe("transactionBuilder Duck", () => { ); ( transactionService.simulateContractTransfer as jest.Mock - ).mockResolvedValue(mockPreparedXDR); + ).mockResolvedValue({ + preparedTransaction: mockPreparedXDR, + minResourceFee: "100", + }); (stellarServices.signTransaction as jest.Mock).mockReturnValue( mockSignedXDR, ); @@ -134,6 +141,11 @@ describe("transactionBuilder Duck", () => { expect(state.error).toBeNull(); expect(transactionService.buildPaymentTransaction).toHaveBeenCalled(); expect(transactionService.simulateContractTransfer).toHaveBeenCalled(); + // Soroban fee fields should be populated from the simulation result + expect(state.sorobanResourceFeeXlm).not.toBeNull(); + expect(typeof state.sorobanResourceFeeXlm).toBe("string"); + expect(state.sorobanInclusionFeeXlm).not.toBeNull(); + expect(typeof state.sorobanInclusionFeeXlm).toBe("string"); }); it("should handle errors during buildTransaction", async () => { @@ -287,6 +299,10 @@ describe("transactionBuilder Duck", () => { isSubmitting: false, transactionHash: mockTxHash, error: "Some previous error", + requestId: "some-request-id", + isSoroban: true, + sorobanResourceFeeXlm: "0.0001000", + sorobanInclusionFeeXlm: "0.0001000", }); }); @@ -301,6 +317,10 @@ describe("transactionBuilder Duck", () => { expect(state.isSubmitting).toBe(false); expect(state.transactionHash).toBeNull(); expect(state.error).toBeNull(); + expect(state.requestId).toBeNull(); + expect(state.isSoroban).toBe(false); + expect(state.sorobanResourceFeeXlm).toBeNull(); + expect(state.sorobanInclusionFeeXlm).toBeNull(); }); describe("buildSendCollectibleTransaction", () => { @@ -317,7 +337,10 @@ describe("transactionBuilder Duck", () => { }); ( transactionService.simulateCollectibleTransfer as jest.Mock - ).mockResolvedValue(mockPreparedXDR); + ).mockResolvedValue({ + preparedTransaction: mockPreparedXDR, + minResourceFee: "100", + }); }); it("should build and simulate a collectible transaction successfully", async () => { diff --git a/__tests__/services/transactionService.test.ts b/__tests__/services/transactionService.test.ts index ea8743de0..8ccf4c0dc 100644 --- a/__tests__/services/transactionService.test.ts +++ b/__tests__/services/transactionService.test.ts @@ -291,7 +291,10 @@ describe("simulateCollectibleTransfer", () => { networkDetails: mockNetworkDetails, }); - expect(result).toBe(mockPreparedXdr); + expect(result).toEqual({ + preparedTransaction: mockPreparedXdr, + minResourceFee: undefined, + }); expect(backend.simulateTransaction).toHaveBeenCalledWith({ xdr: mockTransactionXdr, network_url: mockNetworkDetails.sorobanRpcUrl, @@ -299,6 +302,24 @@ describe("simulateCollectibleTransfer", () => { }); }); + it("should correctly plumb minResourceFee when present in simulationResponse", async () => { + const mockResourceFee = "500"; + (backend.simulateTransaction as jest.Mock).mockResolvedValue({ + preparedTransaction: mockPreparedXdr, + simulationResponse: { minResourceFee: mockResourceFee }, + }); + + const result = await simulateCollectibleTransfer({ + transactionXdr: mockTransactionXdr, + networkDetails: mockNetworkDetails, + }); + + expect(result).toEqual({ + preparedTransaction: mockPreparedXdr, + minResourceFee: mockResourceFee, + }); + }); + it("should throw error if Soroban RPC URL is not defined", async () => { const invalidNetworkDetails = { ...mockNetworkDetails, diff --git a/src/components/FeeBreakdownBottomSheet.tsx b/src/components/FeeBreakdownBottomSheet.tsx new file mode 100644 index 000000000..defcb0a8f --- /dev/null +++ b/src/components/FeeBreakdownBottomSheet.tsx @@ -0,0 +1,150 @@ +import BigNumber from "bignumber.js"; +import Icon from "components/sds/Icon"; +import { Text } from "components/sds/Typography"; +import { NATIVE_TOKEN_CODE } from "config/constants"; +import { useTransactionBuilderStore } from "ducks/transactionBuilder"; +import { useTransactionSettingsStore } from "ducks/transactionSettings"; +import { formatTokenForDisplay } from "helpers/formatAmount"; +import useAppTranslation from "hooks/useAppTranslation"; +import useColors from "hooks/useColors"; +import React from "react"; +import { ActivityIndicator, TouchableOpacity, View } from "react-native"; + +type FeeBreakdownBottomSheetProps = { + onClose: () => void; + /** + * Whether the current transaction is Soroban-type (C-token, C-address, or + * collectible), derived from the sending context rather than the builder + * store. Controls row visibility and description text independently of + * whether simulation has completed yet. + */ + isSorobanContext: boolean; +}; + +/** + * FeeBreakdownBottomSheet Component + * + * The mobile equivalent of the extension's FeesPane. + * For Soroban transactions: shows Inclusion Fee + Resource Fee + Total Fee rows. + * For classic transactions: shows only the Total Fee row. + * Shows ActivityIndicator while a build is in progress. + * Includes a contextual description (different text for Soroban vs classic). + */ +const FeeBreakdownBottomSheet: React.FC = ({ + onClose, + isSorobanContext, +}) => { + const { t } = useAppTranslation(); + const { themeColors } = useColors(); + const { + isSoroban, + sorobanResourceFeeXlm, + sorobanInclusionFeeXlm, + isBuilding, + } = useTransactionBuilderStore(); + const { transactionFee } = useTransactionSettingsStore(); + + const totalFeeXlm = + isSoroban && sorobanInclusionFeeXlm && sorobanResourceFeeXlm + ? new BigNumber(sorobanInclusionFeeXlm) + .plus(sorobanResourceFeeXlm) + .toString() + : transactionFee; + + return ( + + {/* Header — lilac icon + close button */} + + + + + + + + + + {/* Title */} + + {t("feeBreakdown.title")} + + + {/* Fee rows card */} + + {isSoroban && ( + + + {t("transactionAmountScreen.details.inclusionFee")} + + {isBuilding || !sorobanInclusionFeeXlm ? ( + + ) : ( + + {formatTokenForDisplay( + sorobanInclusionFeeXlm, + NATIVE_TOKEN_CODE, + )} + + )} + + )} + {isSoroban && ( + + + {t("transactionAmountScreen.details.resourceFee")} + + {isBuilding || !sorobanResourceFeeXlm ? ( + + ) : ( + + {formatTokenForDisplay( + sorobanResourceFeeXlm, + NATIVE_TOKEN_CODE, + )} + + )} + + )} + {/* Total Fee — always shown, accented in lilac */} + + + {t("transactionAmountScreen.details.totalFee")} + + {isBuilding ? ( + + ) : ( + + {formatTokenForDisplay(totalFeeXlm, NATIVE_TOKEN_CODE)} + + )} + + + + {/* Contextual description */} + + + {t( + isSorobanContext + ? "feeBreakdown.descriptionSoroban" + : "feeBreakdown.descriptionClassic", + )} + + + + ); +}; + +export default FeeBreakdownBottomSheet; diff --git a/src/components/TransactionSettingsBottomSheet.tsx b/src/components/TransactionSettingsBottomSheet.tsx index 4566c6fa4..240d5e638 100644 --- a/src/components/TransactionSettingsBottomSheet.tsx +++ b/src/components/TransactionSettingsBottomSheet.tsx @@ -25,10 +25,7 @@ import { formatNumberForDisplay, } from "helpers/formatAmount"; import { getMemoDisabledState } from "helpers/muxedAddress"; -import { - isContractId, - isSorobanTransaction as checkIsSorobanTransaction, -} from "helpers/soroban"; +import { isContractId } from "helpers/soroban"; import { enforceSettingInputDecimalSeparator } from "helpers/transactionSettingsUtils"; import useAppTranslation from "hooks/useAppTranslation"; import { useBalancesList } from "hooks/useBalancesList"; @@ -53,6 +50,7 @@ type TransactionSettingsBottomSheetProps = { onConfirm: () => void; context: TransactionContext; onSettingsChange?: () => void; + onOpenFeeBreakdown?: () => void; }; // Constants @@ -60,7 +58,13 @@ const STEP_SIZE_PERCENT = 0.5; const TransactionSettingsBottomSheet: React.FC< TransactionSettingsBottomSheetProps -> = ({ onCancel, onConfirm, context, onSettingsChange }) => { +> = ({ + onCancel, + onConfirm, + context, + onSettingsChange, + onOpenFeeBreakdown, +}) => { // All hooks at the top const { t } = useAppTranslation(); const { themeColors } = useColors(); @@ -107,16 +111,11 @@ const TransactionSettingsBottomSheet: React.FC< const selectedBalance = balanceItems.find( (item) => item.id === (selectedTokenId || NATIVE_TOKEN_CODE), ); + const isCollectibleTransfer = Boolean(selectedCollectibleDetails?.collectionAddress) && Boolean(selectedCollectibleDetails?.tokenId); - // Soroban transaction: collectible transfer, custom token, or recipient is contract address - const isSorobanTransaction = Boolean( - isCollectibleTransfer || - checkIsSorobanTransaction(selectedBalance, recipientAddress), - ); - // Keep isCustomToken for contractId determination below const isCustomToken = Boolean( selectedBalance && @@ -128,6 +127,13 @@ const TransactionSettingsBottomSheet: React.FC< recipientAddress && isContractId(recipientAddress), ); + // Derived from current context parameters (balance, recipient, collectible) + // rather than the builder store, which may be stale or reflect a different + // transaction flow (e.g. a previous send or a swap transaction). + const isSorobanTransaction = Boolean( + isCollectibleTransfer || isCustomToken || isSorobanRecipient, + ); + // Determine contract ID for Soroban transactions const contractId = useMemo(() => { if (!isSorobanTransaction || !recipientAddress) { @@ -491,10 +497,16 @@ const TransactionSettingsBottomSheet: React.FC< - {t("transactionSettings.feeTitle")} + {isSorobanTransaction + ? t("transactionSettings.inclusionFeeTitle") + : t("transactionSettings.feeTitle")} feeInfoBottomSheetModalRef.current?.present()} + onPress={() => + isSorobanTransaction && onOpenFeeBreakdown + ? onOpenFeeBreakdown() + : feeInfoBottomSheetModalRef.current?.present() + } > @@ -548,6 +560,8 @@ const TransactionSettingsBottomSheet: React.FC< ), [ + isSorobanTransaction, + onOpenFeeBreakdown, localFee, feeError, t, diff --git a/src/components/screens/SendScreen/components/SendReviewBottomSheet.tsx b/src/components/screens/SendScreen/components/SendReviewBottomSheet.tsx index 601d6bd1f..e3ceba592 100644 --- a/src/components/screens/SendScreen/components/SendReviewBottomSheet.tsx +++ b/src/components/screens/SendScreen/components/SendReviewBottomSheet.tsx @@ -1,5 +1,8 @@ +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"; @@ -19,12 +22,13 @@ import { useTransactionSettingsStore } from "ducks/transactionSettings"; import { isLiquidityPool } from "helpers/balances"; import { pxValue } from "helpers/dimensions"; import { formatTokenForDisplay, formatFiatAmount } from "helpers/formatAmount"; +import { isSorobanTransaction } from "helpers/soroban"; import { truncateAddress, isMuxedAccount } from "helpers/stellar"; import useAppTranslation from "hooks/useAppTranslation"; import { useClipboard } from "hooks/useClipboard"; import useColors from "hooks/useColors"; import useGetActiveAccount from "hooks/useGetActiveAccount"; -import React, { useCallback, useMemo } from "react"; +import React, { useCallback, useMemo, useRef } from "react"; import { ActivityIndicator, TouchableOpacity, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -95,7 +99,35 @@ const SendReviewBottomSheet: React.FC = ({ const { account } = useGetActiveAccount(); const { copyToClipboard } = useClipboard(); const slicedAddress = truncateAddress(recipientAddress, 4, 4); - const { transactionXDR, isBuilding, error } = useTransactionBuilderStore(); + const { + transactionXDR, + isBuilding, + error, + isSoroban, + sorobanResourceFeeXlm, + sorobanInclusionFeeXlm, + } = useTransactionBuilderStore(); + + const feeBreakdownSheetRef = useRef(null); + + const handleOpenFeeBreakdown = useCallback(() => { + feeBreakdownSheetRef.current?.present(); + }, []); + + // For Soroban: total = inclusion + resource. For classic: flat transactionFee. + const totalFeeXlm = + sorobanInclusionFeeXlm && sorobanResourceFeeXlm + ? new BigNumber(sorobanInclusionFeeXlm) + .plus(sorobanResourceFeeXlm) + .toString() + : transactionFee; + + // 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); // Use amountError from props (calculated in parent component) const amountError = propAmountError; @@ -230,14 +262,32 @@ 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). { icon: , title: t("transactionAmountScreen.details.fee"), titleColor: themeColors.text.secondary, - trailingContent: ( - - {formatTokenForDisplay(transactionFee, NATIVE_TOKEN_CODE)} - + trailingContent: isBuilding ? ( + + ) : ( + + {isSoroban && ( + + + + )} + + {formatTokenForDisplay(totalFeeXlm, NATIVE_TOKEN_CODE)} + + ), }, { @@ -264,13 +314,15 @@ const SendReviewBottomSheet: React.FC = ({ account?.accountName, account?.publicKey, handleCopyXdr, + handleOpenFeeBreakdown, isBuilding, + isSoroban, renderMemoTitle, renderXdrContent, t, themeColors.foreground.primary, themeColors.text.secondary, - transactionFee, + totalFeeXlm, transactionMemo, transactionXDR, isRecipientMuxed, @@ -353,6 +405,16 @@ const SendReviewBottomSheet: React.FC = ({ analyticsEvent={AnalyticsEvent.VIEW_SEND_TRANSACTION_DETAILS} /> )} + feeBreakdownSheetRef.current?.dismiss()} + customContent={ + feeBreakdownSheetRef.current?.dismiss()} + isSorobanContext={isSorobanContext} + /> + } + /> ); }; @@ -470,8 +532,8 @@ export const SendReviewFooter: React.FC = React.memo( const cancelButton = (