Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions __tests__/components/TransactionSettingsBottomSheet.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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(),
}));
Expand Down Expand Up @@ -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<typeof useAuthenticationStore>;

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);
Expand Down
27 changes: 25 additions & 2 deletions __tests__/ducks/transactionBuilder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ describe("transactionBuilder Duck", () => {
isSubmitting: false,
transactionHash: null,
error: null,
requestId: null,
isSoroban: false,
sorobanResourceFeeXlm: null,
sorobanInclusionFeeXlm: null,
});
});

Expand All @@ -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,
);
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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",
});
});

Expand All @@ -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", () => {
Expand All @@ -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 () => {
Expand Down
23 changes: 22 additions & 1 deletion __tests__/services/transactionService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,14 +291,35 @@ 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,
network_passphrase: mockNetworkDetails.networkPassphrase,
});
});

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,
Expand Down
150 changes: 150 additions & 0 deletions src/components/FeeBreakdownBottomSheet.tsx
Original file line number Diff line number Diff line change
@@ -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<FeeBreakdownBottomSheetProps> = ({
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 (
<View className="flex-1">
{/* Header — lilac icon + close button */}
<View className="relative flex-row items-center mb-8">
<View className="bg-lilac-3 p-2 rounded-[8px]">
<Icon.Route color={themeColors.lilac[9]} size={28} />
</View>
<TouchableOpacity onPress={onClose} className="absolute right-0">
<Icon.X
color={themeColors.foreground.secondary}
size={24}
circle
circleBackground={themeColors.background.tertiary}
/>
</TouchableOpacity>
</View>

{/* Title */}
<Text xl medium primary textAlign="left">
{t("feeBreakdown.title")}
</Text>

{/* Fee rows card */}
<View className="mt-[16px] rounded-[12px] overflow-hidden bg-background-tertiary">
{isSoroban && (
<View className="flex-row justify-between items-center px-[16px] py-[12px] border-b border-gray-6">
<Text md secondary>
{t("transactionAmountScreen.details.inclusionFee")}
</Text>
{isBuilding || !sorobanInclusionFeeXlm ? (
<ActivityIndicator
size="small"
color={themeColors.text.secondary}
/>
) : (
<Text md primary>
{formatTokenForDisplay(
sorobanInclusionFeeXlm,
NATIVE_TOKEN_CODE,
)}
</Text>
)}
</View>
)}
{isSoroban && (
<View className="flex-row justify-between items-center px-[16px] py-[12px] border-b border-gray-6">
<Text md secondary>
{t("transactionAmountScreen.details.resourceFee")}
</Text>
{isBuilding || !sorobanResourceFeeXlm ? (
Comment on lines +76 to +103
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isSorobanContext is documented as controlling row visibility independently of the builder store, but the component actually gates the Inclusion/Resource fee rows (and totalFeeXlm) on isSoroban from useTransactionBuilderStore(). This can hide Soroban rows when opening the sheet before a build has set isSoroban (or if the store is stale). Consider using isSorobanContext to control row visibility (and possibly the total-fee calculation), and use the store values only for the amounts/spinner states.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator Author

@leofelix077 leofelix077 Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is intended due to UI. the fees themselves require an address to be shown, so it needs to be a "full" soroban context, but the UI shell is decided by either a C token or address, even if the data is incomplete

is in soroban context? -> is all data available? -> is soroban -> display all data for the fees
(step 1 ) -----------------------------------------(step 2 )

<ActivityIndicator
size="small"
color={themeColors.text.secondary}
/>
) : (
<Text md primary>
{formatTokenForDisplay(
sorobanResourceFeeXlm,
NATIVE_TOKEN_CODE,
)}
</Text>
)}
</View>
)}
{/* Total Fee — always shown, accented in lilac */}
<View className="flex-row justify-between items-center px-[16px] py-[12px]">
<Text md medium color={themeColors.lilac[11]}>
{t("transactionAmountScreen.details.totalFee")}
</Text>
{isBuilding ? (
<ActivityIndicator
size="small"
color={themeColors.text.secondary}
/>
) : (
<Text md medium color={themeColors.lilac[11]}>
{formatTokenForDisplay(totalFeeXlm, NATIVE_TOKEN_CODE)}
</Text>
)}
</View>
</View>

{/* Contextual description */}
<View className="mt-[24px] pr-8">
<Text md regular secondary textAlign="left">
{t(
isSorobanContext
? "feeBreakdown.descriptionSoroban"
: "feeBreakdown.descriptionClassic",
)}
</Text>
</View>
</View>
);
};

export default FeeBreakdownBottomSheet;
Loading
Loading