From 1b5ea92cd7b626014321d0fd494574454ee5dc53 Mon Sep 17 00:00:00 2001 From: leofelix077 Date: Tue, 17 Mar 2026 10:00:20 -0300 Subject: [PATCH 1/7] add initial version of better resource and inclusion fees display --- __tests__/ducks/transactionBuilder.test.ts | 10 +- __tests__/services/transactionService.test.ts | 5 +- src/components/FeeBreakdownBottomSheet.tsx | 121 ++++++++++++++++++ .../TransactionSettingsBottomSheet.tsx | 21 ++- .../components/SendReviewBottomSheet.tsx | 63 ++++++++- .../screens/TransactionAmountScreen.tsx | 60 ++++++++- src/ducks/transactionBuilder.ts | 40 +++++- src/i18n/locales/en/translations.json | 9 ++ src/i18n/locales/pt/translations.json | 9 ++ src/services/backend.ts | 6 +- src/services/transactionService.ts | 10 +- 11 files changed, 328 insertions(+), 26 deletions(-) create mode 100644 src/components/FeeBreakdownBottomSheet.tsx diff --git a/__tests__/ducks/transactionBuilder.test.ts b/__tests__/ducks/transactionBuilder.test.ts index 22cb2a8f8..114bf8bd1 100644 --- a/__tests__/ducks/transactionBuilder.test.ts +++ b/__tests__/ducks/transactionBuilder.test.ts @@ -61,7 +61,10 @@ describe("transactionBuilder Duck", () => { ); ( transactionService.simulateContractTransfer as jest.Mock - ).mockResolvedValue(mockPreparedXDR); + ).mockResolvedValue({ + preparedTransaction: mockPreparedXDR, + minResourceFee: "100", + }); (stellarServices.signTransaction as jest.Mock).mockReturnValue( mockSignedXDR, ); @@ -317,7 +320,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..b89a3ca89 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, diff --git a/src/components/FeeBreakdownBottomSheet.tsx b/src/components/FeeBreakdownBottomSheet.tsx new file mode 100644 index 000000000..3f3f6de74 --- /dev/null +++ b/src/components/FeeBreakdownBottomSheet.tsx @@ -0,0 +1,121 @@ +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; +}; + +/** + * 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 on Total Fee while a build is in progress. + * Includes a contextual description (different text for Soroban vs classic). + */ +const FeeBreakdownBottomSheet: React.FC = ({ + onClose, +}) => { + const { t } = useAppTranslation(); + const { themeColors } = useColors(); + const { sorobanResourceFeeXlm, sorobanInclusionFeeXlm, isBuilding } = + useTransactionBuilderStore(); + const { transactionFee } = useTransactionSettingsStore(); + + const isSoroban = + sorobanResourceFeeXlm !== null && sorobanInclusionFeeXlm !== null; + + 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 && sorobanInclusionFeeXlm && ( + + + {t("transactionAmountScreen.details.inclusionFee")} + + + {formatTokenForDisplay(sorobanInclusionFeeXlm, NATIVE_TOKEN_CODE)} + + + )} + {isSoroban && sorobanResourceFeeXlm && ( + + + {t("transactionAmountScreen.details.resourceFee")} + + + {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( + isSoroban + ? "feeBreakdown.descriptionSoroban" + : "feeBreakdown.descriptionClassic", + )} + + + + ); +}; + +export default FeeBreakdownBottomSheet; diff --git a/src/components/TransactionSettingsBottomSheet.tsx b/src/components/TransactionSettingsBottomSheet.tsx index 4566c6fa4..e36c932e4 100644 --- a/src/components/TransactionSettingsBottomSheet.tsx +++ b/src/components/TransactionSettingsBottomSheet.tsx @@ -53,6 +53,7 @@ type TransactionSettingsBottomSheetProps = { onConfirm: () => void; context: TransactionContext; onSettingsChange?: () => void; + onOpenFeeBreakdown?: () => void; }; // Constants @@ -60,7 +61,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(); @@ -491,10 +498,16 @@ const TransactionSettingsBottomSheet: React.FC< - {t("transactionSettings.feeTitle")} + {isSorobanTransaction + ? t("transactionSettings.inclusionFeeTitle") + : t("transactionSettings.feeTitle")} feeInfoBottomSheetModalRef.current?.present()} + onPress={() => + isSorobanTransaction + ? onOpenFeeBreakdown?.() + : feeInfoBottomSheetModalRef.current?.present() + } > @@ -548,6 +561,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..179c249e3 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"; @@ -24,7 +27,7 @@ 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 +98,27 @@ 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, + 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; // Use amountError from props (calculated in parent component) const amountError = propAmountError; @@ -230,14 +253,30 @@ 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 ? ( + + ) : ( + + + + + + {formatTokenForDisplay(totalFeeXlm, NATIVE_TOKEN_CODE)} + + ), }, { @@ -264,13 +303,14 @@ const SendReviewBottomSheet: React.FC = ({ account?.accountName, account?.publicKey, handleCopyXdr, + handleOpenFeeBreakdown, isBuilding, renderMemoTitle, renderXdrContent, t, themeColors.foreground.primary, themeColors.text.secondary, - transactionFee, + totalFeeXlm, transactionMemo, transactionXDR, isRecipientMuxed, @@ -353,6 +393,15 @@ const SendReviewBottomSheet: React.FC = ({ analyticsEvent={AnalyticsEvent.VIEW_SEND_TRANSACTION_DETAILS} /> )} + feeBreakdownSheetRef.current?.dismiss()} + customContent={ + feeBreakdownSheetRef.current?.dismiss()} + /> + } + /> ); }; diff --git a/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx b/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx index 5f3dc77a8..a26fd211a 100644 --- a/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx +++ b/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx @@ -4,6 +4,7 @@ import { NativeStackScreenProps } from "@react-navigation/native-stack"; import { BigNumber } from "bignumber.js"; import { BalanceRow } from "components/BalanceRow"; import BottomSheet from "components/BottomSheet"; +import FeeBreakdownBottomSheet from "components/FeeBreakdownBottomSheet"; import { IconButton } from "components/IconButton"; import InformationBottomSheet from "components/InformationBottomSheet"; import MuxedAddressWarningBottomSheet from "components/MuxedAddressWarningBottomSheet"; @@ -55,6 +56,7 @@ import { formatFiatInputDisplay, } from "helpers/formatAmount"; import { checkContractMuxedSupport } from "helpers/muxedAddress"; +import { isSorobanTransaction } from "helpers/soroban"; import { isMuxedAccount } from "helpers/stellar"; import { useBlockaidTransaction } from "hooks/blockaid/useBlockaidTransaction"; import useAppTranslation from "hooks/useAppTranslation"; @@ -215,6 +217,7 @@ const TransactionAmountScreen: React.FC = ({ const isSmallScreen = deviceSize === DeviceSize.XS; const addMemoExplanationBottomSheetModalRef = useRef(null); const transactionSettingsBottomSheetModalRef = useRef(null); + const feeBreakdownBottomSheetModalRef = useRef(null); const muxedAddressInfoBottomSheetModalRef = useRef(null); const [transactionScanResult, setTransactionScanResult] = useState< Blockaid.StellarTransactionScanResponse | undefined @@ -562,13 +565,14 @@ const TransactionAmountScreen: React.FC = ({ ); const prepareTransaction = useCallback( - async (shouldOpenReview = false) => { - const numberTokenAmount = new BigNumber(tokenAmount); + async (shouldOpenReview = false, feeEstimationAmount?: string) => { + const effectiveAmount = feeEstimationAmount ?? tokenAmount; + const numberEffectiveAmount = new BigNumber(effectiveAmount); const hasRequiredParams = recipientAddress && selectedBalance && - numberTokenAmount.isGreaterThan(0); + numberEffectiveAmount.isGreaterThan(0); if (!hasRequiredParams) { return; } @@ -583,7 +587,7 @@ const TransactionAmountScreen: React.FC = ({ } = useTransactionSettingsStore.getState(); const finalXDR = await buildTransaction({ - tokenAmount, + tokenAmount: effectiveAmount, selectedBalance, recipientAddress: storeRecipientAddress, transactionMemo: freshTransactionMemo, @@ -593,6 +597,10 @@ const TransactionAmountScreen: React.FC = ({ senderAddress: publicKey, }); + // Skip scan when building only for fee estimation (dummy amount). + // A scan result from a non-real amount would be meaningless. + if (feeEstimationAmount) return; + if (!finalXDR) return; // Always scan the transaction to keep the hook updated @@ -622,9 +630,35 @@ const TransactionAmountScreen: React.FC = ({ ], ); + // Auto-simulate Soroban fee breakdown as soon as token + recipient are set. + // Soroban resource fees are per-token (not per-amount), so we use a minimal + // dummy amount "1" and skip re-simulation when only the amount changes. + useEffect(() => { + let timer: ReturnType | undefined; + + if ( + isSorobanTransaction(selectedBalance, recipientAddress) && + recipientAddress && + selectedBalance + ) { + timer = setTimeout(() => { + prepareTransaction(false, "1"); + }, 300); + } + + return () => { + if (timer !== undefined) clearTimeout(timer); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedBalance?.id, recipientAddress]); + const handleSettingsChange = () => { - // Settings have changed, rebuild the transaction with new values - prepareTransaction(false); + // For Soroban, fees are per-token so simulate with a dummy amount when the + // user hasn't entered one yet (so settings-modal fee changes stay in sync). + const needsFallback = + isSorobanTransaction(selectedBalance, recipientAddress) && + !new BigNumber(tokenAmount).isGreaterThan(0); + prepareTransaction(false, needsFallback ? "1" : undefined); }; const handleTransactionConfirmation = useCallback(() => { @@ -1049,6 +1083,20 @@ const TransactionAmountScreen: React.FC = ({ onCancel={handleCancelTransactionSettings} onConfirm={handleConfirmTransactionSettings} onSettingsChange={handleSettingsChange} + onOpenFeeBreakdown={() => + feeBreakdownBottomSheetModalRef.current?.present() + } + /> + } + /> + + feeBreakdownBottomSheetModalRef.current?.dismiss() + } + customContent={ + feeBreakdownBottomSheetModalRef.current?.dismiss()} /> } /> diff --git a/src/ducks/transactionBuilder.ts b/src/ducks/transactionBuilder.ts index b8c244e94..50c8892fe 100644 --- a/src/ducks/transactionBuilder.ts +++ b/src/ducks/transactionBuilder.ts @@ -1,8 +1,12 @@ -import { NETWORKS, mapNetworkToNetworkDetails } from "config/constants"; +import { + MIN_TRANSACTION_FEE, + NETWORKS, + mapNetworkToNetworkDetails, +} from "config/constants"; import { logger } from "config/logger"; import { PricedBalance } from "config/types"; import { useDebugStore } from "ducks/debug"; -import { xlmToStroop } from "helpers/formatAmount"; +import { stroopToXlm, xlmToStroop } from "helpers/formatAmount"; import { isContractId } from "helpers/soroban"; import { isMuxedAccount } from "helpers/stellar"; import { t } from "i18next"; @@ -30,6 +34,8 @@ interface TransactionBuilderState { transactionHash: string | null; error: string | null; requestId: string | null; + sorobanResourceFeeXlm: string | null; + sorobanInclusionFeeXlm: string | null; buildTransaction: (params: { tokenAmount: string; @@ -93,6 +99,8 @@ const initialState: Omit< transactionHash: null, error: null, requestId: null, + sorobanResourceFeeXlm: null, + sorobanInclusionFeeXlm: null, }; // Unique id to correlate async responses to the latest request @@ -135,6 +143,8 @@ export const useTransactionBuilderStore = create( } let finalXdr = builtTxResult.xdr; + let sorobanResourceFeeXlm: string | null = null; + let sorobanInclusionFeeXlm: string | null = null; const isRecipientContract = params.recipientAddress && isContractId(params.recipientAddress); @@ -166,7 +176,7 @@ export const useTransactionBuilderStore = create( ? "" : params.transactionMemo || ""; - finalXdr = await simulateContractTransfer({ + const simulateResult = await simulateContractTransfer({ transaction: builtTxResult.tx, networkDetails, memo: memoForSimulation, @@ -177,6 +187,16 @@ export const useTransactionBuilderStore = create( }, contractAddress: builtTxResult.contractId!, }); + + finalXdr = simulateResult.preparedTransaction; + + if (simulateResult.minResourceFee) { + sorobanResourceFeeXlm = stroopToXlm( + simulateResult.minResourceFee, + ).toFixed(7); + sorobanInclusionFeeXlm = + params.transactionFee || MIN_TRANSACTION_FEE; + } } // Only update store if this build request is still the latest one. @@ -188,6 +208,8 @@ export const useTransactionBuilderStore = create( isBuilding: false, signedTransactionXDR: null, transactionHash: null, + sorobanResourceFeeXlm, + sorobanInclusionFeeXlm, }); } @@ -319,11 +341,19 @@ export const useTransactionBuilderStore = create( // Simulate the collectible transfer transaction to get proper fees and resources // The transaction XDR already contains the muxed address (if applicable) from buildSendCollectibleTransaction // which checks contract muxed support and creates muxed addresses according to the behavior matrix - const finalXdr = await simulateCollectibleTransfer({ + const simulateResult = await simulateCollectibleTransfer({ transactionXdr: builtTxResult.tx.toXDR(), networkDetails, }); + const finalXdr = simulateResult.preparedTransaction; + const sorobanResourceFeeXlm = simulateResult.minResourceFee + ? stroopToXlm(simulateResult.minResourceFee).toFixed(7) + : null; + const sorobanInclusionFeeXlm = simulateResult.minResourceFee + ? params.transactionFee || MIN_TRANSACTION_FEE + : null; + // Only update store if this build request is still the latest one. // This prevents race conditions where a slow async response from // an older transaction overwrites state from a newer one. @@ -333,6 +363,8 @@ export const useTransactionBuilderStore = create( isBuilding: false, signedTransactionXDR: null, transactionHash: null, + sorobanResourceFeeXlm, + sorobanInclusionFeeXlm, }); } diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index 5b7fa700a..70196ed48 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -694,6 +694,7 @@ "transactionSettings": { "title": "Transaction settings", "feeTitle": "Transaction fee", + "inclusionFeeTitle": "Inclusion fee", "memoTitle": "Memo", "slippageTitle": "Slippage", "timeoutTitle": "Transaction timeout", @@ -774,6 +775,9 @@ "memo": "Memo", "none": "None", "fee": "Fee", + "inclusionFee": "Inclusion fee", + "resourceFee": "Resource fee", + "totalFee": "Total fee", "xdr": "XDR" }, "errors": { @@ -794,6 +798,11 @@ }, "confirmAnyway": "Confirm anyway" }, + "feeBreakdown": { + "title": "Fees", + "descriptionClassic": "These fees go to the network to process your transaction.", + "descriptionSoroban": "These fees go to the network to process your transaction. For Soroban transactions, we run a quick simulation first, so you might see the total fee adjust." + }, "addMemoExplanationBottomSheet": { "title": "Memo is required", "description": "A destination account requires the use of the memo field which is not present in the transaction you're about to sign.", diff --git a/src/i18n/locales/pt/translations.json b/src/i18n/locales/pt/translations.json index 4feeb471c..20dbdb4e7 100644 --- a/src/i18n/locales/pt/translations.json +++ b/src/i18n/locales/pt/translations.json @@ -658,6 +658,7 @@ "transactionSettings": { "title": "Configurações da transação", "feeTitle": "Taxa da transação", + "inclusionFeeTitle": "Taxa de inclusão", "memoTitle": "Memo", "slippageTitle": "Deslizamento permitido", "timeoutTitle": "Tempo limite da transação", @@ -738,6 +739,9 @@ "memo": "Memo", "none": "Nenhum", "fee": "Taxa", + "inclusionFee": "Taxa de inclusão", + "resourceFee": "Taxa de recursos", + "totalFee": "Taxa total", "xdr": "XDR" }, "errors": { @@ -758,6 +762,11 @@ }, "confirmAnyway": "Confirmar mesmo assim" }, + "feeBreakdown": { + "title": "Taxas", + "descriptionClassic": "Essas taxas vão para a rede para processar sua transação.", + "descriptionSoroban": "Essas taxas vão para a rede para processar sua transação. Para transações Soroban, executamos uma simulação primeiro, então você pode ver a taxa total ser ajustada." + }, "addMemoExplanationBottomSheet": { "title": "Memo é obrigatório", "description": "Uma conta de destino requer o uso do campo memo que não está presente na transação que você está prestes a assinar.", diff --git a/src/services/backend.ts b/src/services/backend.ts index 8cf023d6e..76920043a 100644 --- a/src/services/backend.ts +++ b/src/services/backend.ts @@ -739,10 +739,14 @@ export interface SimulateTokenTransferParams { * @property {string} preparedTransaction - XDR-encoded prepared transaction */ export interface SimulateTransactionResponse { - simulationResponse: unknown; + simulationResponse: SorobanSimulationResponse; preparedTransaction: string; } +export interface SorobanSimulationResponse { + minResourceFee?: string; +} + /** * Simulates a token transfer operation * @async diff --git a/src/services/transactionService.ts b/src/services/transactionService.ts index 0ed8f2fb5..e9ef96194 100644 --- a/src/services/transactionService.ts +++ b/src/services/transactionService.ts @@ -732,7 +732,10 @@ export const simulateContractTransfer = async ({ // Use the preparedTransaction XDR directly from the backend // The backend builds, simulates, and prepares the transaction - return result.preparedTransaction; + return { + preparedTransaction: result.preparedTransaction, + minResourceFee: result.simulationResponse?.minResourceFee, + }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); @@ -762,7 +765,10 @@ export const simulateCollectibleTransfer = async ({ network_passphrase: networkDetails.networkPassphrase, }); - return result.preparedTransaction; + return { + preparedTransaction: result.preparedTransaction, + minResourceFee: result.simulationResponse?.minResourceFee, + }; } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); From fe46ee5295f7fe93e01ad3c8bd531c3ef879038a Mon Sep 17 00:00:00 2001 From: leofelix077 Date: Fri, 20 Mar 2026 10:09:15 -0300 Subject: [PATCH 2/7] adjust typings and tests for soroban minResourceFee --- __tests__/ducks/transactionBuilder.test.ts | 7 ++++ __tests__/services/transactionService.test.ts | 18 +++++++++ src/components/FeeBreakdownBottomSheet.tsx | 11 +++++- .../TransactionSettingsBottomSheet.tsx | 4 +- .../components/SendReviewBottomSheet.tsx | 5 +++ .../screens/TransactionAmountScreen.tsx | 39 ++++++++++++++++--- src/ducks/transactionBuilder.ts | 22 +++++++++-- src/services/backend.ts | 2 +- 8 files changed, 94 insertions(+), 14 deletions(-) diff --git a/__tests__/ducks/transactionBuilder.test.ts b/__tests__/ducks/transactionBuilder.test.ts index 114bf8bd1..df1c65724 100644 --- a/__tests__/ducks/transactionBuilder.test.ts +++ b/__tests__/ducks/transactionBuilder.test.ts @@ -53,6 +53,8 @@ describe("transactionBuilder Duck", () => { isSubmitting: false, transactionHash: null, error: null, + sorobanResourceFeeXlm: null, + sorobanInclusionFeeXlm: null, }); }); @@ -137,6 +139,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 () => { diff --git a/__tests__/services/transactionService.test.ts b/__tests__/services/transactionService.test.ts index b89a3ca89..8ccf4c0dc 100644 --- a/__tests__/services/transactionService.test.ts +++ b/__tests__/services/transactionService.test.ts @@ -302,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 index 3f3f6de74..dd10315b1 100644 --- a/src/components/FeeBreakdownBottomSheet.tsx +++ b/src/components/FeeBreakdownBottomSheet.tsx @@ -12,6 +12,10 @@ import { ActivityIndicator, TouchableOpacity, View } from "react-native"; type FeeBreakdownBottomSheetProps = { onClose: () => void; + /** Explicit flag for Soroban context. When provided, overrides the inference + * from fee store values so the component is never misclassified before the + * first simulation completes. */ + isSorobanTransaction?: boolean; }; /** @@ -25,6 +29,7 @@ type FeeBreakdownBottomSheetProps = { */ const FeeBreakdownBottomSheet: React.FC = ({ onClose, + isSorobanTransaction: isSorobanProp, }) => { const { t } = useAppTranslation(); const { themeColors } = useColors(); @@ -32,8 +37,12 @@ const FeeBreakdownBottomSheet: React.FC = ({ useTransactionBuilderStore(); const { transactionFee } = useTransactionSettingsStore(); + // Prefer the explicit prop when provided; fall back to inferring from fee + // values so callers that don't (yet) pass the prop continue to work. const isSoroban = - sorobanResourceFeeXlm !== null && sorobanInclusionFeeXlm !== null; + isSorobanProp !== undefined + ? isSorobanProp + : sorobanResourceFeeXlm !== null && sorobanInclusionFeeXlm !== null; const totalFeeXlm = isSoroban && sorobanInclusionFeeXlm && sorobanResourceFeeXlm diff --git a/src/components/TransactionSettingsBottomSheet.tsx b/src/components/TransactionSettingsBottomSheet.tsx index e36c932e4..3100c80a6 100644 --- a/src/components/TransactionSettingsBottomSheet.tsx +++ b/src/components/TransactionSettingsBottomSheet.tsx @@ -504,8 +504,8 @@ const TransactionSettingsBottomSheet: React.FC< - isSorobanTransaction - ? onOpenFeeBreakdown?.() + isSorobanTransaction && onOpenFeeBreakdown + ? onOpenFeeBreakdown() : feeInfoBottomSheetModalRef.current?.present() } > diff --git a/src/components/screens/SendScreen/components/SendReviewBottomSheet.tsx b/src/components/screens/SendScreen/components/SendReviewBottomSheet.tsx index 179c249e3..5441d4bb4 100644 --- a/src/components/screens/SendScreen/components/SendReviewBottomSheet.tsx +++ b/src/components/screens/SendScreen/components/SendReviewBottomSheet.tsx @@ -22,6 +22,7 @@ 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"; @@ -399,6 +400,10 @@ const SendReviewBottomSheet: React.FC = ({ customContent={ feeBreakdownSheetRef.current?.dismiss()} + isSorobanTransaction={ + isSorobanTransaction(selectedBalance) || + type === SendType.Collectible + } /> } /> diff --git a/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx b/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx index a26fd211a..7050c3069 100644 --- a/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx +++ b/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx @@ -631,8 +631,11 @@ const TransactionAmountScreen: React.FC = ({ ); // Auto-simulate Soroban fee breakdown as soon as token + recipient are set. - // Soroban resource fees are per-token (not per-amount), so we use a minimal - // dummy amount "1" and skip re-simulation when only the amount changes. + // Soroban resource fees are per-token (not per-amount), so we use the token's + // own available balance as the estimation amount — it's already in the correct + // decimal precision and is guaranteed to pass balance validation. + // When balance is zero (e.g. the user is about to acquire the token), fall + // back to the smallest representable unit derived from the token's decimals. useEffect(() => { let timer: ReturnType | undefined; @@ -641,8 +644,15 @@ const TransactionAmountScreen: React.FC = ({ recipientAddress && selectedBalance ) { + const minUnit = + "decimals" in selectedBalance + ? new BigNumber(1).shiftedBy(-selectedBalance.decimals).toString() + : "1"; + const estimationAmount = selectedBalance.total.isGreaterThan(0) + ? selectedBalance.total.toString() + : minUnit; timer = setTimeout(() => { - prepareTransaction(false, "1"); + prepareTransaction(false, estimationAmount); }, 300); } @@ -653,12 +663,25 @@ const TransactionAmountScreen: React.FC = ({ }, [selectedBalance?.id, recipientAddress]); const handleSettingsChange = () => { - // For Soroban, fees are per-token so simulate with a dummy amount when the - // user hasn't entered one yet (so settings-modal fee changes stay in sync). + // For Soroban, fees are per-token so simulate with the available balance + // when the user hasn't entered an amount yet. Use the smallest + // representable unit (derived from the token's decimals) as a fallback + // when the balance is also zero. const needsFallback = isSorobanTransaction(selectedBalance, recipientAddress) && !new BigNumber(tokenAmount).isGreaterThan(0); - prepareTransaction(false, needsFallback ? "1" : undefined); + if (needsFallback && selectedBalance) { + const minUnit = + "decimals" in selectedBalance + ? new BigNumber(1).shiftedBy(-selectedBalance.decimals).toString() + : "1"; + const estimationAmount = selectedBalance.total.isGreaterThan(0) + ? selectedBalance.total.toString() + : minUnit; + prepareTransaction(false, estimationAmount); + } else { + prepareTransaction(false, undefined); + } }; const handleTransactionConfirmation = useCallback(() => { @@ -1097,6 +1120,10 @@ const TransactionAmountScreen: React.FC = ({ customContent={ feeBreakdownBottomSheetModalRef.current?.dismiss()} + isSorobanTransaction={isSorobanTransaction( + selectedBalance, + recipientAddress, + )} /> } /> diff --git a/src/ducks/transactionBuilder.ts b/src/ducks/transactionBuilder.ts index 50c8892fe..7b6724e61 100644 --- a/src/ducks/transactionBuilder.ts +++ b/src/ducks/transactionBuilder.ts @@ -123,8 +123,15 @@ export const useTransactionBuilderStore = create( // Tag this build cycle const newRequestId = createRequestId(); - // Mark new cycle and reset flags - set({ isBuilding: true, error: null, requestId: newRequestId }); + // Mark new cycle and reset flags (clear stale Soroban fees so UI doesn't + // show outdated data while the new build is in progress) + set({ + isBuilding: true, + error: null, + requestId: newRequestId, + sorobanResourceFeeXlm: null, + sorobanInclusionFeeXlm: null, + }); try { const builtTxResult = await buildPaymentTransaction({ @@ -317,8 +324,15 @@ export const useTransactionBuilderStore = create( // Tag this build cycle const newRequestId = createRequestId(); - // Mark new cycle and reset flags - set({ isBuilding: true, error: null, requestId: newRequestId }); + // Mark new cycle and reset flags (clear stale Soroban fees so UI doesn't + // show outdated data while the new build is in progress) + set({ + isBuilding: true, + error: null, + requestId: newRequestId, + sorobanResourceFeeXlm: null, + sorobanInclusionFeeXlm: null, + }); try { const builtTxResult = await buildSendCollectibleTransaction({ diff --git a/src/services/backend.ts b/src/services/backend.ts index 76920043a..421646db9 100644 --- a/src/services/backend.ts +++ b/src/services/backend.ts @@ -735,7 +735,7 @@ export interface SimulateTokenTransferParams { /** * Response from token transfer simulation * @interface SimulateTransactionResponse - * @property {unknown} simulationResponse - Raw simulation response from backend + * @property {SorobanSimulationResponse} simulationResponse - Soroban simulation response from backend * @property {string} preparedTransaction - XDR-encoded prepared transaction */ export interface SimulateTransactionResponse { From 99e918f8154811370b6defab78d8477e37b755d3 Mon Sep 17 00:00:00 2001 From: leofelix077 Date: Fri, 20 Mar 2026 15:13:13 -0300 Subject: [PATCH 3/7] adjust logic to display and resimulate soroban fees --- .../TransactionSettingsBottomSheet.test.tsx | 13 ++ src/components/FeeBreakdownBottomSheet.tsx | 58 +++++--- .../TransactionSettingsBottomSheet.tsx | 16 +- .../components/SendReviewBottomSheet.tsx | 23 ++- .../screens/SendCollectibleReview.tsx | 26 ++++ .../screens/TransactionAmountScreen.tsx | 48 ++---- src/ducks/transactionBuilder.ts | 139 ++++++++++++------ src/services/backend.ts | 4 +- src/services/transactionService.ts | 23 ++- 9 files changed, 214 insertions(+), 136 deletions(-) diff --git a/__tests__/components/TransactionSettingsBottomSheet.test.tsx b/__tests__/components/TransactionSettingsBottomSheet.test.tsx index cea8fe41e..9b19323c9 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); @@ -663,6 +675,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/src/components/FeeBreakdownBottomSheet.tsx b/src/components/FeeBreakdownBottomSheet.tsx index dd10315b1..6ae1cf177 100644 --- a/src/components/FeeBreakdownBottomSheet.tsx +++ b/src/components/FeeBreakdownBottomSheet.tsx @@ -12,10 +12,6 @@ import { ActivityIndicator, TouchableOpacity, View } from "react-native"; type FeeBreakdownBottomSheetProps = { onClose: () => void; - /** Explicit flag for Soroban context. When provided, overrides the inference - * from fee store values so the component is never misclassified before the - * first simulation completes. */ - isSorobanTransaction?: boolean; }; /** @@ -24,26 +20,22 @@ type FeeBreakdownBottomSheetProps = { * 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 on Total Fee while a build is in progress. + * Shows ActivityIndicator while a build is in progress. * Includes a contextual description (different text for Soroban vs classic). */ const FeeBreakdownBottomSheet: React.FC = ({ onClose, - isSorobanTransaction: isSorobanProp, }) => { const { t } = useAppTranslation(); const { themeColors } = useColors(); - const { sorobanResourceFeeXlm, sorobanInclusionFeeXlm, isBuilding } = - useTransactionBuilderStore(); + const { + isSoroban, + sorobanResourceFeeXlm, + sorobanInclusionFeeXlm, + isBuilding, + } = useTransactionBuilderStore(); const { transactionFee } = useTransactionSettingsStore(); - // Prefer the explicit prop when provided; fall back to inferring from fee - // values so callers that don't (yet) pass the prop continue to work. - const isSoroban = - isSorobanProp !== undefined - ? isSorobanProp - : sorobanResourceFeeXlm !== null && sorobanInclusionFeeXlm !== null; - const totalFeeXlm = isSoroban && sorobanInclusionFeeXlm && sorobanResourceFeeXlm ? new BigNumber(sorobanInclusionFeeXlm) @@ -75,24 +67,44 @@ const FeeBreakdownBottomSheet: React.FC = ({ {/* Fee rows card */} - {isSoroban && sorobanInclusionFeeXlm && ( + {isSoroban && ( {t("transactionAmountScreen.details.inclusionFee")} - - {formatTokenForDisplay(sorobanInclusionFeeXlm, NATIVE_TOKEN_CODE)} - + {isBuilding || !sorobanInclusionFeeXlm ? ( + + ) : ( + + {formatTokenForDisplay( + sorobanInclusionFeeXlm, + NATIVE_TOKEN_CODE, + )} + + )} )} - {isSoroban && sorobanResourceFeeXlm && ( + {isSoroban && ( {t("transactionAmountScreen.details.resourceFee")} - - {formatTokenForDisplay(sorobanResourceFeeXlm, NATIVE_TOKEN_CODE)} - + {isBuilding || !sorobanResourceFeeXlm ? ( + + ) : ( + + {formatTokenForDisplay( + sorobanResourceFeeXlm, + NATIVE_TOKEN_CODE, + )} + + )} )} {/* Total Fee — always shown, accented in lilac */} diff --git a/src/components/TransactionSettingsBottomSheet.tsx b/src/components/TransactionSettingsBottomSheet.tsx index 3100c80a6..bbf708290 100644 --- a/src/components/TransactionSettingsBottomSheet.tsx +++ b/src/components/TransactionSettingsBottomSheet.tsx @@ -19,16 +19,14 @@ import { import { NetworkCongestion } from "config/types"; import { useAuthenticationStore } from "ducks/auth"; import { useSwapSettingsStore } from "ducks/swapSettings"; +import { useTransactionBuilderStore } from "ducks/transactionBuilder"; import { useTransactionSettingsStore } from "ducks/transactionSettings"; import { parseDisplayNumber, 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"; @@ -114,16 +112,14 @@ const TransactionSettingsBottomSheet: React.FC< const selectedBalance = balanceItems.find( (item) => item.id === (selectedTokenId || NATIVE_TOKEN_CODE), ); + + // Single source of truth for Soroban state from the transaction builder store + const { isSoroban: isSorobanTransaction } = useTransactionBuilderStore(); + 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 && diff --git a/src/components/screens/SendScreen/components/SendReviewBottomSheet.tsx b/src/components/screens/SendScreen/components/SendReviewBottomSheet.tsx index 5441d4bb4..24fd89100 100644 --- a/src/components/screens/SendScreen/components/SendReviewBottomSheet.tsx +++ b/src/components/screens/SendScreen/components/SendReviewBottomSheet.tsx @@ -22,7 +22,6 @@ 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"; @@ -103,6 +102,7 @@ const SendReviewBottomSheet: React.FC = ({ transactionXDR, isBuilding, error, + isSoroban, sorobanResourceFeeXlm, sorobanInclusionFeeXlm, } = useTransactionBuilderStore(); @@ -267,13 +267,15 @@ const SendReviewBottomSheet: React.FC = ({ /> ) : ( - - - + {isSoroban && ( + + + + )} {formatTokenForDisplay(totalFeeXlm, NATIVE_TOKEN_CODE)} @@ -306,6 +308,7 @@ const SendReviewBottomSheet: React.FC = ({ handleCopyXdr, handleOpenFeeBreakdown, isBuilding, + isSoroban, renderMemoTitle, renderXdrContent, t, @@ -400,10 +403,6 @@ const SendReviewBottomSheet: React.FC = ({ customContent={ feeBreakdownSheetRef.current?.dismiss()} - isSorobanTransaction={ - isSorobanTransaction(selectedBalance) || - type === SendType.Collectible - } /> } /> diff --git a/src/components/screens/SendScreen/screens/SendCollectibleReview.tsx b/src/components/screens/SendScreen/screens/SendCollectibleReview.tsx index 6bd6e0919..e237f2cef 100644 --- a/src/components/screens/SendScreen/screens/SendCollectibleReview.tsx +++ b/src/components/screens/SendScreen/screens/SendCollectibleReview.tsx @@ -3,6 +3,7 @@ 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"; @@ -131,6 +132,7 @@ const SendCollectibleReviewScreen: React.FC< const [isProcessing, setIsProcessing] = useState(false); const addMemoExplanationBottomSheetModalRef = useRef(null); const transactionSettingsBottomSheetModalRef = useRef(null); + const feeBreakdownBottomSheetModalRef = useRef(null); const muxedAddressInfoBottomSheetModalRef = useRef(null); const [transactionScanResult, setTransactionScanResult] = useState< Blockaid.StellarTransactionScanResponse | undefined @@ -347,6 +349,16 @@ const SendCollectibleReviewScreen: React.FC< prepareTransaction(false); }; + // Auto-simulate to populate the Soroban fee breakdown as soon as the + // collectible and recipient are available. Collectibles are always Soroban + // transactions, so fees include an inclusion + resource component. + useEffect(() => { + if (!isBuilding && recipientAddress && selectedCollectible) { + prepareTransaction(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [recipientAddress, selectedCollectible?.tokenId]); + const handleTransactionConfirmation = useCallback(() => { setIsProcessing(true); reviewBottomSheetModalRef.current?.dismiss(); @@ -694,6 +706,20 @@ const SendCollectibleReviewScreen: React.FC< onCancel={handleCancelTransactionSettings} onConfirm={handleConfirmTransactionSettings} onSettingsChange={handleSettingsChange} + onOpenFeeBreakdown={() => + feeBreakdownBottomSheetModalRef.current?.present() + } + /> + } + /> + + feeBreakdownBottomSheetModalRef.current?.dismiss() + } + customContent={ + feeBreakdownBottomSheetModalRef.current?.dismiss()} /> } /> diff --git a/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx b/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx index bb992bbc1..753724467 100644 --- a/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx +++ b/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx @@ -95,6 +95,7 @@ type TransactionAmountScreenProps = NativeStackScreenProps< const shouldSkipHighlighting = (rawInput: string | null): boolean => // Only skip if it's exactly "0" (no separator, no trailing zeros) or empty !rawInput || rawInput === "0" || rawInput === ""; + /** * TransactionAmountScreen Component * @@ -572,7 +573,8 @@ const TransactionAmountScreen: React.FC = ({ const hasRequiredParams = recipientAddress && selectedBalance && - numberEffectiveAmount.isGreaterThan(0); + (feeEstimationAmount !== undefined || + numberEffectiveAmount.isGreaterThan(0)); if (!hasRequiredParams) { return; } @@ -631,29 +633,19 @@ const TransactionAmountScreen: React.FC = ({ ); // Auto-simulate Soroban fee breakdown as soon as token + recipient are set. - // Soroban resource fees are per-token (not per-amount), so we use the token's - // own available balance as the estimation amount — it's already in the correct - // decimal precision and is guaranteed to pass balance validation. - // When balance is zero (e.g. the user is about to acquire the token), fall - // back to the smallest representable unit derived from the token's decimals. + // Uses /simulate-tx with amount 0 for early fee estimation so the user can + // see the resource fee breakdown before entering an amount. useEffect(() => { let timer: ReturnType | undefined; if ( + !isBuilding && isSorobanTransaction(selectedBalance, recipientAddress) && recipientAddress && selectedBalance ) { - const decimals = - "decimals" in selectedBalance - ? selectedBalance.decimals - : DEFAULT_DECIMALS; - const minUnit = new BigNumber(1).shiftedBy(-decimals).toString(); - const estimationAmount = selectedBalance.total.isGreaterThan(0) - ? selectedBalance.total.toString() - : minUnit; timer = setTimeout(() => { - prepareTransaction(false, estimationAmount); + prepareTransaction(false, "0"); }, 300); } @@ -664,26 +656,12 @@ const TransactionAmountScreen: React.FC = ({ }, [selectedBalance?.id, recipientAddress]); const handleSettingsChange = () => { - // For Soroban, fees are per-token so simulate with the available balance - // when the user hasn't entered an amount yet. Use the smallest - // representable unit (derived from the token's decimals) as a fallback - // when the balance is also zero. - const needsFallback = + if (isBuilding) return; + + const needsEstimation = isSorobanTransaction(selectedBalance, recipientAddress) && !new BigNumber(tokenAmount).isGreaterThan(0); - if (needsFallback && selectedBalance) { - const decimals = - "decimals" in selectedBalance - ? selectedBalance.decimals - : DEFAULT_DECIMALS; - const minUnit = new BigNumber(1).shiftedBy(-decimals).toString(); - const estimationAmount = selectedBalance.total.isGreaterThan(0) - ? selectedBalance.total.toString() - : minUnit; - prepareTransaction(false, estimationAmount); - } else { - prepareTransaction(false, undefined); - } + prepareTransaction(false, needsEstimation ? "0" : undefined); }; const handleTransactionConfirmation = useCallback(() => { @@ -1122,10 +1100,6 @@ const TransactionAmountScreen: React.FC = ({ customContent={ feeBreakdownBottomSheetModalRef.current?.dismiss()} - isSorobanTransaction={isSorobanTransaction( - selectedBalance, - recipientAddress, - )} /> } /> diff --git a/src/ducks/transactionBuilder.ts b/src/ducks/transactionBuilder.ts index 7b6724e61..d6953e946 100644 --- a/src/ducks/transactionBuilder.ts +++ b/src/ducks/transactionBuilder.ts @@ -1,3 +1,4 @@ +import BigNumber from "bignumber.js"; import { MIN_TRANSACTION_FEE, NETWORKS, @@ -6,7 +7,7 @@ import { import { logger } from "config/logger"; import { PricedBalance } from "config/types"; import { useDebugStore } from "ducks/debug"; -import { stroopToXlm, xlmToStroop } from "helpers/formatAmount"; +import { stroopToXlm } from "helpers/formatAmount"; import { isContractId } from "helpers/soroban"; import { isMuxedAccount } from "helpers/stellar"; import { t } from "i18next"; @@ -20,6 +21,24 @@ import { } from "services/transactionService"; import { create } from "zustand"; +/** + * Extracts a human-readable error message from any thrown value. + * Handles native Error instances, ApiError plain objects (from apiFactory), + * and arbitrary values. Prevents "[object Object]" from reaching the UI. + */ +const extractErrorMessage = (error: unknown): string => { + if (error instanceof Error) return error.message; + if ( + typeof error === "object" && + error !== null && + "message" in error && + typeof (error as { message: unknown }).message === "string" + ) { + return (error as { message: string }).message; + } + return String(error); +}; + /** * TransactionBuilderState Interface * @@ -34,6 +53,7 @@ interface TransactionBuilderState { transactionHash: string | null; error: string | null; requestId: string | null; + isSoroban: boolean; sorobanResourceFeeXlm: string | null; sorobanInclusionFeeXlm: string | null; @@ -99,6 +119,7 @@ const initialState: Omit< transactionHash: null, error: null, requestId: null, + isSoroban: false, sorobanResourceFeeXlm: null, sorobanInclusionFeeXlm: null, }; @@ -123,12 +144,23 @@ export const useTransactionBuilderStore = create( // Tag this build cycle const newRequestId = createRequestId(); + // Determine Soroban status early from params so the UI can show the + // correct fee label ("Inclusion Fee" vs "Transaction Fee") before + // the build/simulation completes. + const isSorobanTx = Boolean( + (params.selectedBalance && + "contractId" in params.selectedBalance && + params.selectedBalance.contractId) || + (params.recipientAddress && isContractId(params.recipientAddress)), + ); + // Mark new cycle and reset flags (clear stale Soroban fees so UI doesn't // show outdated data while the new build is in progress) set({ isBuilding: true, error: null, requestId: newRequestId, + isSoroban: isSorobanTx, sorobanResourceFeeXlm: null, sorobanInclusionFeeXlm: null, }); @@ -161,10 +193,6 @@ export const useTransactionBuilderStore = create( "contractId" in params.selectedBalance && params.selectedBalance.contractId; - // Use the final destination from buildPaymentTransaction if available (may be muxed) - const finalDestination = - builtTxResult.finalDestination || params.recipientAddress!; - // If sending to a contract OR using a custom token, prepare (simulate) the transaction // Custom tokens (SorobanBalance) always need simulation for proper fees and resources const shouldSimulate = @@ -172,37 +200,53 @@ export const useTransactionBuilderStore = create( if (shouldSimulate && params.network && params.senderAddress) { const networkDetails = mapNetworkToNetworkDetails(params.network); + const isFeeEstimation = Number(params.tokenAmount) === 0; + + let simulateResult; + + if (isFeeEstimation) { + // Fee estimation with amount 0: use /simulate-tx with the + // locally-built XDR. The /simulate-token-transfer endpoint + // rejects amount 0 because it rebuilds the tx server-side. + simulateResult = await simulateCollectibleTransfer({ + transactionXdr: builtTxResult.xdr, + networkDetails, + }); + } else { + // Real amount: use /simulate-token-transfer matching the + // extension's flow (backend builds + simulates the tx). + const finalDestination = + builtTxResult.finalDestination || params.recipientAddress!; + const isDestinationMuxed = isMuxedAccount(finalDestination); + const memoForSimulation = isDestinationMuxed + ? "" + : params.transactionMemo || ""; + + simulateResult = await simulateContractTransfer({ + transaction: builtTxResult.tx, + networkDetails, + memo: memoForSimulation, + params: { + publicKey: params.senderAddress, + destination: finalDestination, + amount: Number(builtTxResult.amountInBaseUnits ?? 0), + }, + contractAddress: builtTxResult.contractId!, + }); + } - const amountForSimulation = - isCustomToken && builtTxResult.amountInBaseUnits - ? builtTxResult.amountInBaseUnits - : xlmToStroop(params.tokenAmount).toString(); - - const isDestinationMuxed = isMuxedAccount(finalDestination); - const memoForSimulation = isDestinationMuxed - ? "" - : params.transactionMemo || ""; - - const simulateResult = await simulateContractTransfer({ - transaction: builtTxResult.tx, - networkDetails, - memo: memoForSimulation, - params: { - publicKey: params.senderAddress, - destination: finalDestination, - amount: amountForSimulation, - }, - contractAddress: builtTxResult.contractId!, - }); - + if (!simulateResult.preparedTransaction) { + throw new Error("Simulation returned no prepared transaction XDR"); + } finalXdr = simulateResult.preparedTransaction; if (simulateResult.minResourceFee) { - sorobanResourceFeeXlm = stroopToXlm( - simulateResult.minResourceFee, - ).toFixed(7); - sorobanInclusionFeeXlm = - params.transactionFee || MIN_TRANSACTION_FEE; + const resourceFeeBn = new BigNumber(simulateResult.minResourceFee); + if (!resourceFeeBn.isNaN()) { + sorobanResourceFeeXlm = stroopToXlm(resourceFeeBn).toFixed(7); + sorobanInclusionFeeXlm = + params.transactionFee || MIN_TRANSACTION_FEE; + } } } @@ -215,6 +259,7 @@ export const useTransactionBuilderStore = create( isBuilding: false, signedTransactionXDR: null, transactionHash: null, + isSoroban: Boolean(shouldSimulate), sorobanResourceFeeXlm, sorobanInclusionFeeXlm, }); @@ -222,8 +267,7 @@ export const useTransactionBuilderStore = create( return finalXdr; } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); + const errorMessage = extractErrorMessage(error); logger.error( "TransactionBuilderStore", @@ -295,8 +339,7 @@ export const useTransactionBuilderStore = create( return finalXdr; } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); + const errorMessage = extractErrorMessage(error); logger.error( "TransactionBuilderStore", "Failed to build swap transaction", @@ -326,10 +369,12 @@ export const useTransactionBuilderStore = create( // Mark new cycle and reset flags (clear stale Soroban fees so UI doesn't // show outdated data while the new build is in progress) + // Collectibles are always Soroban transactions. set({ isBuilding: true, error: null, requestId: newRequestId, + isSoroban: true, sorobanResourceFeeXlm: null, sorobanInclusionFeeXlm: null, }); @@ -360,13 +405,17 @@ export const useTransactionBuilderStore = create( networkDetails, }); + if (!simulateResult.preparedTransaction) { + throw new Error( + "Collectible simulation returned no prepared transaction XDR", + ); + } const finalXdr = simulateResult.preparedTransaction; const sorobanResourceFeeXlm = simulateResult.minResourceFee - ? stroopToXlm(simulateResult.minResourceFee).toFixed(7) - : null; - const sorobanInclusionFeeXlm = simulateResult.minResourceFee - ? params.transactionFee || MIN_TRANSACTION_FEE + ? stroopToXlm(new BigNumber(simulateResult.minResourceFee)).toFixed(7) : null; + const sorobanInclusionFeeXlm = + params.transactionFee || MIN_TRANSACTION_FEE; // Only update store if this build request is still the latest one. // This prevents race conditions where a slow async response from @@ -377,6 +426,7 @@ export const useTransactionBuilderStore = create( isBuilding: false, signedTransactionXDR: null, transactionHash: null, + isSoroban: true, sorobanResourceFeeXlm, sorobanInclusionFeeXlm, }); @@ -384,8 +434,7 @@ export const useTransactionBuilderStore = create( return finalXdr; } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); + const errorMessage = extractErrorMessage(error); logger.error( "TransactionBuilderStore", "Failed to build send collectible transaction", @@ -432,8 +481,7 @@ export const useTransactionBuilderStore = create( set({ signedTransactionXDR: signedXDR }); return signedXDR; } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); + const errorMessage = extractErrorMessage(error); logger.error( "TransactionBuilderStore", "Failed to sign transaction", @@ -501,8 +549,7 @@ export const useTransactionBuilderStore = create( return hash; } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); + const errorMessage = extractErrorMessage(error); logger.error( "TransactionBuilderStore", "Failed to submit transaction", diff --git a/src/services/backend.ts b/src/services/backend.ts index 421646db9..2fdbafbd7 100644 --- a/src/services/backend.ts +++ b/src/services/backend.ts @@ -714,7 +714,7 @@ export const handleContractLookup = async ( * @property {Object} params - Transfer parameters * @property {string} params.publicKey - Sender's public key * @property {string} params.destination - Recipient's address - * @property {string} params.amount - Amount to transfer + * @property {number} params.amount - Amount to transfer * @property {string} network_url - Network URL for simulation * @property {string} network_passphrase - Network passphrase */ @@ -726,7 +726,7 @@ export interface SimulateTokenTransferParams { params: { publicKey: string; destination: string; - amount: string; + amount: number; }; network_url: string; network_passphrase: string; diff --git a/src/services/transactionService.ts b/src/services/transactionService.ts index e9ef96194..2de82b26b 100644 --- a/src/services/transactionService.ts +++ b/src/services/transactionService.ts @@ -87,6 +87,7 @@ interface IValidateTransactionParams { destination: string; fee: string; timeout: number; + skipAmountValidation?: boolean; } /** @@ -97,8 +98,8 @@ export const validateTransactionParams = ( params: IValidateTransactionParams, ): string | null => { const { senderAddress, balance, amount, destination, fee, timeout } = params; - // Validate amount is positive - if (Number(amount) <= 0) { + // Validate amount is positive (skipped for Soroban fee estimation with amount 0) + if (!params.skipAmountValidation && Number(amount) <= 0) { return t("transaction.errors.amountRequired"); } @@ -347,6 +348,15 @@ export const buildPaymentTransaction = async ( throw new Error("Missing required parameters for building transaction"); } + // Soroban fee estimation can use amount 0 — resource fees are + // independent of the transfer amount. Skip only the amount check; + // all other validations (fee, timeout, address, balance) still run. + const isSorobanTransfer = + (selectedBalance && + "contractId" in selectedBalance && + Boolean(selectedBalance.contractId)) || + isContractId(recipientAddress); + const validationError = validateTransactionParams({ senderAddress, balance: selectedBalance, @@ -354,6 +364,7 @@ export const buildPaymentTransaction = async ( destination: recipientAddress, fee: transactionFee, timeout: transactionTimeout, + skipAmountValidation: isSorobanTransfer && Number(amount) === 0, }); if (validationError) { @@ -697,7 +708,7 @@ interface SimulateContractTransferParams { params: { publicKey: string; destination: string; - amount: string; + amount: number; }; contractAddress: string; } @@ -718,9 +729,9 @@ export const simulateContractTransfer = async ({ } try { - // Note: If destination is already muxed (from buildPaymentTransaction), - // it will be passed through here. The memo parameter is kept for backward compatibility - // but for CAP-0067, memo should be embedded in the muxed address. + // Follow the extension pattern: use /simulate-token-transfer which + // builds and simulates the transaction on the backend. + // Amount is passed as a number to match the extension's API contract. const result = await simulateTokenTransfer({ address: contractAddress, pub_key: transaction.source, From 5205d82d3b7e99a6f4750a2126beb28373a1eed1 Mon Sep 17 00:00:00 2001 From: leofelix077 Date: Fri, 20 Mar 2026 15:14:07 -0300 Subject: [PATCH 4/7] adjust logic to display and resimulate soroban fees --- __tests__/components/TransactionSettingsBottomSheet.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/__tests__/components/TransactionSettingsBottomSheet.test.tsx b/__tests__/components/TransactionSettingsBottomSheet.test.tsx index 9b19323c9..0b2f959b5 100644 --- a/__tests__/components/TransactionSettingsBottomSheet.test.tsx +++ b/__tests__/components/TransactionSettingsBottomSheet.test.tsx @@ -640,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); From cc32f031704195d53f7cc63e24bcb61df5a0469e Mon Sep 17 00:00:00 2001 From: leofelix077 Date: Fri, 20 Mar 2026 16:58:39 -0300 Subject: [PATCH 5/7] adjust comments from copilot --- __tests__/ducks/transactionBuilder.test.ts | 10 ++++++++++ .../TransactionSettingsBottomSheet.tsx | 11 +++++++---- .../screens/SendCollectibleReview.tsx | 18 +++++++++++++++--- .../screens/TransactionAmountScreen.tsx | 11 +++++++++-- src/ducks/transactionBuilder.ts | 5 ++++- src/services/backend.ts | 6 +++--- src/services/transactionService.ts | 2 +- 7 files changed, 49 insertions(+), 14 deletions(-) diff --git a/__tests__/ducks/transactionBuilder.test.ts b/__tests__/ducks/transactionBuilder.test.ts index df1c65724..6a9577d02 100644 --- a/__tests__/ducks/transactionBuilder.test.ts +++ b/__tests__/ducks/transactionBuilder.test.ts @@ -53,6 +53,8 @@ describe("transactionBuilder Duck", () => { isSubmitting: false, transactionHash: null, error: null, + requestId: null, + isSoroban: false, sorobanResourceFeeXlm: null, sorobanInclusionFeeXlm: null, }); @@ -297,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", }); }); @@ -311,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", () => { diff --git a/src/components/TransactionSettingsBottomSheet.tsx b/src/components/TransactionSettingsBottomSheet.tsx index bbf708290..240d5e638 100644 --- a/src/components/TransactionSettingsBottomSheet.tsx +++ b/src/components/TransactionSettingsBottomSheet.tsx @@ -19,7 +19,6 @@ import { import { NetworkCongestion } from "config/types"; import { useAuthenticationStore } from "ducks/auth"; import { useSwapSettingsStore } from "ducks/swapSettings"; -import { useTransactionBuilderStore } from "ducks/transactionBuilder"; import { useTransactionSettingsStore } from "ducks/transactionSettings"; import { parseDisplayNumber, @@ -113,9 +112,6 @@ const TransactionSettingsBottomSheet: React.FC< (item) => item.id === (selectedTokenId || NATIVE_TOKEN_CODE), ); - // Single source of truth for Soroban state from the transaction builder store - const { isSoroban: isSorobanTransaction } = useTransactionBuilderStore(); - const isCollectibleTransfer = Boolean(selectedCollectibleDetails?.collectionAddress) && Boolean(selectedCollectibleDetails?.tokenId); @@ -131,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) { diff --git a/src/components/screens/SendScreen/screens/SendCollectibleReview.tsx b/src/components/screens/SendScreen/screens/SendCollectibleReview.tsx index e237f2cef..4c021cee8 100644 --- a/src/components/screens/SendScreen/screens/SendCollectibleReview.tsx +++ b/src/components/screens/SendScreen/screens/SendCollectibleReview.tsx @@ -349,15 +349,27 @@ const SendCollectibleReviewScreen: React.FC< prepareTransaction(false); }; + // Tracks the (recipient, tokenId) pair for which auto-simulation has + // already been requested. Prevents re-triggering after isBuilding drops back + // to false at the end of the build itself. + const lastAutoSimulatedKey = useRef(null); + // Auto-simulate to populate the Soroban fee breakdown as soon as the // collectible and recipient are available. Collectibles are always Soroban // transactions, so fees include an inclusion + resource component. useEffect(() => { - if (!isBuilding && recipientAddress && selectedCollectible) { + const currentKey = `${recipientAddress}|${selectedCollectible?.tokenId}`; + + if ( + !isBuilding && + recipientAddress && + selectedCollectible && + lastAutoSimulatedKey.current !== currentKey + ) { + lastAutoSimulatedKey.current = currentKey; prepareTransaction(false); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [recipientAddress, selectedCollectible?.tokenId]); + }, [recipientAddress, selectedCollectible, isBuilding, prepareTransaction]); const handleTransactionConfirmation = useCallback(() => { setIsProcessing(true); diff --git a/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx b/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx index 753724467..34ec1dce4 100644 --- a/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx +++ b/src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx @@ -632,18 +632,26 @@ const TransactionAmountScreen: React.FC = ({ ], ); + // Tracks the (balanceId, recipient) pair for which auto-simulation has + // already been requested. Prevents re-triggering after isBuilding drops back + // to false at the end of the estimation build itself. + const lastAutoSimulatedKey = useRef(null); + // Auto-simulate Soroban fee breakdown as soon as token + recipient are set. // Uses /simulate-tx with amount 0 for early fee estimation so the user can // see the resource fee breakdown before entering an amount. useEffect(() => { let timer: ReturnType | undefined; + const currentKey = `${selectedBalance?.id}|${recipientAddress}`; if ( !isBuilding && + lastAutoSimulatedKey.current !== currentKey && isSorobanTransaction(selectedBalance, recipientAddress) && recipientAddress && selectedBalance ) { + lastAutoSimulatedKey.current = currentKey; timer = setTimeout(() => { prepareTransaction(false, "0"); }, 300); @@ -652,8 +660,7 @@ const TransactionAmountScreen: React.FC = ({ return () => { if (timer !== undefined) clearTimeout(timer); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedBalance?.id, recipientAddress]); + }, [selectedBalance, recipientAddress, isBuilding, prepareTransaction]); const handleSettingsChange = () => { if (isBuilding) return; diff --git a/src/ducks/transactionBuilder.ts b/src/ducks/transactionBuilder.ts index d6953e946..ce660bd4b 100644 --- a/src/ducks/transactionBuilder.ts +++ b/src/ducks/transactionBuilder.ts @@ -229,7 +229,10 @@ export const useTransactionBuilderStore = create( params: { publicKey: params.senderAddress, destination: finalDestination, - amount: Number(builtTxResult.amountInBaseUnits ?? 0), + amount: + builtTxResult.amountInBaseUnits !== undefined + ? String(builtTxResult.amountInBaseUnits) + : "0", }, contractAddress: builtTxResult.contractId!, }); diff --git a/src/services/backend.ts b/src/services/backend.ts index 2fdbafbd7..529315c2b 100644 --- a/src/services/backend.ts +++ b/src/services/backend.ts @@ -714,7 +714,7 @@ export const handleContractLookup = async ( * @property {Object} params - Transfer parameters * @property {string} params.publicKey - Sender's public key * @property {string} params.destination - Recipient's address - * @property {number} params.amount - Amount to transfer + * @property {string} params.amount - Amount to transfer in base units * @property {string} network_url - Network URL for simulation * @property {string} network_passphrase - Network passphrase */ @@ -726,7 +726,7 @@ export interface SimulateTokenTransferParams { params: { publicKey: string; destination: string; - amount: number; + amount: string; }; network_url: string; network_passphrase: string; @@ -735,7 +735,7 @@ export interface SimulateTokenTransferParams { /** * Response from token transfer simulation * @interface SimulateTransactionResponse - * @property {SorobanSimulationResponse} simulationResponse - Soroban simulation response from backend + * @property {SorobanSimulationResponse} simulationResponse - Soroban simulation response with fee data * @property {string} preparedTransaction - XDR-encoded prepared transaction */ export interface SimulateTransactionResponse { diff --git a/src/services/transactionService.ts b/src/services/transactionService.ts index 2de82b26b..ab44b31ac 100644 --- a/src/services/transactionService.ts +++ b/src/services/transactionService.ts @@ -708,7 +708,7 @@ interface SimulateContractTransferParams { params: { publicKey: string; destination: string; - amount: number; + amount: string; }; contractAddress: string; } From c874021111abaedef7e47ec83ff215c6a1627d27 Mon Sep 17 00:00:00 2001 From: leofelix077 Date: Tue, 24 Mar 2026 08:51:09 -0300 Subject: [PATCH 6/7] adjust button colors on unable to scan --- .../screens/SendScreen/components/SendReviewBottomSheet.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/screens/SendScreen/components/SendReviewBottomSheet.tsx b/src/components/screens/SendScreen/components/SendReviewBottomSheet.tsx index 24fd89100..d9d081a74 100644 --- a/src/components/screens/SendScreen/components/SendReviewBottomSheet.tsx +++ b/src/components/screens/SendScreen/components/SendReviewBottomSheet.tsx @@ -523,8 +523,8 @@ export const SendReviewFooter: React.FC = React.memo( const cancelButton = (