Skip to content

Display better fee breakdown for soroban transactions#774

Open
leofelix077 wants to merge 13 commits intomainfrom
lf-improve-fee-display-on-send
Open

Display better fee breakdown for soroban transactions#774
leofelix077 wants to merge 13 commits intomainfrom
lf-improve-fee-display-on-send

Conversation

@leofelix077
Copy link
Copy Markdown
Collaborator

@leofelix077 leofelix077 commented Mar 18, 2026

Already verified UI with @sdfcharles on build 1.13.25 (1774353202)

Screenshot 2026-03-24 at 12 15 13 Screenshot 2026-03-24 at 11 50 24 Screenshot 2026-03-24 at 11 50 29 Screenshot 2026-03-24 at 11 50 35 Screenshot 2026-03-24 at 11 50 44

Checklist

PR structure

  • This PR does not mix refactoring changes with feature changes (break it down into smaller PRs if not).
  • This PR has reasonably narrow scope (break it down into smaller PRs if not).
  • This PR includes relevant before and after screenshots/videos highlighting these changes.
  • I took the time to review my own PR.

Testing

  • These changes have been tested and confirmed to work as intended on Android.
  • These changes have been tested and confirmed to work as intended on iOS.
  • These changes have been tested and confirmed to work as intended on small iOS screens.
  • These changes have been tested and confirmed to work as intended on small Android screens.
  • I have tried to break these changes while extensively testing them.
  • This PR adds tests for the new functionality or fixes.

Release

  • This is not a breaking change.
  • This PR updates existing JSDocs when applicable.
  • This PR adds JSDocs to new functionalities.
  • I've checked with the product team if we should add metrics to these changes.
  • I've shared relevant before and after screenshots/videos highlighting these changes with the design team and they've approved the changes.

@leofelix077 leofelix077 self-assigned this Mar 18, 2026
Copilot AI review requested due to automatic review settings March 18, 2026 21:40
@leofelix077 leofelix077 changed the title add initial version of better resource and inclusion fees display [WIP] add initial version of better resource and inclusion fees display Mar 18, 2026
@leofelix077 leofelix077 added wip work in progress don't review yet Work in Progress / Draft PR / Code Review adjustments being worked on labels Mar 18, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR introduces an initial UI/UX for displaying a clearer fee breakdown (inclusion vs resource fees) for Soroban transactions, by plumbing Soroban simulation fee data from the backend through transaction building and into new bottom sheet UI.

Changes:

  • Extend Soroban simulation plumbing to surface minResourceFee alongside prepared transaction XDR.
  • Add a new FeeBreakdownBottomSheet and hook it up from send review/settings flows.
  • Update transaction builder state + related tests/translations to support inclusion/resource/total fee display.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
src/services/transactionService.ts Returns { preparedTransaction, minResourceFee } from Soroban simulation wrappers.
src/services/backend.ts Types simulationResponse and introduces SorobanSimulationResponse with minResourceFee.
src/ducks/transactionBuilder.ts Stores computed Soroban inclusion/resource fees (XLM) in Zustand state.
src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx Auto-builds for fee estimation and wires up fee breakdown bottom sheet.
src/components/screens/SendScreen/components/SendReviewBottomSheet.tsx Displays total fee (classic vs Soroban total) and opens breakdown sheet.
src/components/TransactionSettingsBottomSheet.tsx Updates fee label/info behavior to support “inclusion fee” and fee breakdown entry point.
src/components/FeeBreakdownBottomSheet.tsx New bottom sheet UI showing fee rows + contextual description.
src/i18n/locales/en/translations.json Adds fee breakdown and fee row labels (EN).
src/i18n/locales/pt/translations.json Adds fee breakdown and fee row labels (PT).
tests/services/transactionService.test.ts Updates simulate collectible test for new return shape.
tests/ducks/transactionBuilder.test.ts Updates mocks for new simulate return shape.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds an initial “better fees” UI by separating Soroban inclusion vs resource fees and plumbing minResourceFee from simulation responses through the transaction build flow.

Changes:

  • Plumbs Soroban minResourceFee through backend/service simulation calls and stores it in the transaction builder state.
  • Updates Send flows to pre-simulate Soroban transactions (including “amount=0” fee estimation) and adds a Fee Breakdown bottom sheet.
  • Updates fee-related labels/translations and adjusts tests for the new simulation return shape.

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
src/services/transactionService.ts Adds optional amount validation skip for Soroban fee estimation; returns { preparedTransaction, minResourceFee } from simulation helpers
src/services/backend.ts Updates simulation response typing and changes params.amount type to number
src/ducks/transactionBuilder.ts Stores Soroban fee breakdown fields and uses simulation results to compute/display fees
src/components/screens/SendScreen/screens/TransactionAmountScreen.tsx Adds auto fee-estimation simulation for Soroban and wires Fee Breakdown sheet
src/components/screens/SendScreen/screens/SendCollectibleReview.tsx Auto-simulates to populate fee breakdown; wires Fee Breakdown sheet
src/components/screens/SendScreen/components/SendReviewBottomSheet.tsx Shows total fee (classic or inclusion+resource) and opens Fee Breakdown sheet
src/components/TransactionSettingsBottomSheet.tsx Renames fee label to “Inclusion fee” for Soroban and routes info icon to Fee Breakdown
src/components/FeeBreakdownBottomSheet.tsx New UI component for inclusion/resource/total fee breakdown
src/i18n/locales/en/translations.json Adds strings for inclusion/resource/total fee breakdown
src/i18n/locales/pt/translations.json Adds Portuguese strings for inclusion/resource/total fee breakdown
tests/services/transactionService.test.ts Updates simulation tests for new return shape and minResourceFee plumbing
tests/ducks/transactionBuilder.test.ts Updates mocks/assertions for new simulation return shape and fee fields
tests/components/TransactionSettingsBottomSheet.test.tsx Mocks transaction builder Soroban state for updated component logic

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +655 to +656
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedBalance?.id, recipientAddress]);
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

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

This effect gates on !isBuilding, but isBuilding is not in the dependency array. If the effect runs while a build is in progress, the fee-estimation simulation will never be scheduled when isBuilding flips back to false. Include isBuilding (and prepareTransaction) in the dependencies or restructure so the fee-estimation trigger re-evaluates when building completes.

Suggested change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedBalance?.id, recipientAddress]);
}, [selectedBalance?.id, recipientAddress, isBuilding, prepareTransaction]);

Copilot uses AI. Check for mistakes.
Comment on lines +116 to +118
// Single source of truth for Soroban state from the transaction builder store
const { isSoroban: isSorobanTransaction } = useTransactionBuilderStore();

Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

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

TransactionSettingsBottomSheet now treats useTransactionBuilderStore().isSoroban as the single source of truth. That value can be stale in contexts that don’t rebuild via buildTransaction (e.g., swap settings) or before any build has started, leading to incorrect fee labeling and memo/contractId logic. Consider deriving Soroban-ness from the current settings/context (selected token, recipient, collectible) here, or ensure the builder store is always reset/updated for every context that can open this sheet.

Copilot uses AI. Check for mistakes.
@leofelix077 leofelix077 added don't review yet Work in Progress / Draft PR / Code Review adjustments being worked on and removed wip work in progress don't review yet Work in Progress / Draft PR / Code Review adjustments being worked on labels Mar 24, 2026
@leofelix077 leofelix077 changed the title [WIP] add initial version of better resource and inclusion fees display Display better fee breakdown for soroban transactions Mar 24, 2026
@leofelix077 leofelix077 added enhancement New feature or request and removed don't review yet Work in Progress / Draft PR / Code Review adjustments being worked on labels Mar 24, 2026
@leofelix077 leofelix077 requested a review from Copilot March 24, 2026 16:48
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated 5 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 534 to 537
<Button
tertiary={(isSuspicious && !isUnableToScan) || isExpectedToFail}
destructive={isMalicious && !shouldUseRowLayout}
tertiary={isSuspicious || isUnableToScan || isExpectedToFail}
destructive={isMalicious}
secondary={shouldUseRowLayout}
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.

This changes the cancel button styling logic (tertiary/destructive) in a way that appears unrelated to the PR’s stated goal (fee breakdown for Soroban transactions). If this behavior change is intentional, it would be good to call it out in the PR description; otherwise consider reverting it to keep the PR focused.

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

Choose a reason for hiding this comment

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

intentional change

Screenshot 2026-03-24 at 08 51 06 Screenshot 2026-03-24 at 08 50 53

Comment on lines +76 to +103
{/* 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 ? (
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 )

});
const isFeeEstimation = Number(params.tokenAmount) === 0;

let simulateResult;
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.

let simulateResult; leaves simulateResult implicitly typed as any, which defeats type-safety and may violate TypeScript/ESLint settings. Prefer giving it an explicit union type (e.g., the shared shape { preparedTransaction: string; minResourceFee?: string }) or restructuring to avoid the untyped variable.

Suggested change
let simulateResult;
let simulateResult: { preparedTransaction: string; minResourceFee?: string };

Copilot uses AI. Check for mistakes.
Comment on lines +232 to +236
amount:
builtTxResult.amountInBaseUnits !== undefined
? String(builtTxResult.amountInBaseUnits)
: "0",
},
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.

When building the /simulate-token-transfer request, the amount falls back to the string "0" if builtTxResult.amountInBaseUnits is undefined. That would silently simulate the wrong transaction rather than failing fast. Since amount is required by the backend contract, consider throwing an explicit error (or deriving base units from the entered amount) instead of defaulting to "0".

Copilot uses AI. Check for mistakes.
Comment on lines +417 to +419
const sorobanResourceFeeXlm = simulateResult.minResourceFee
? stroopToXlm(new BigNumber(simulateResult.minResourceFee)).toFixed(7)
: null;
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.

sorobanResourceFeeXlm is derived from simulateResult.minResourceFee without validating that the value is numeric. If the backend ever returns a non-numeric string, this can propagate "NaN" into the UI. Consider mirroring the earlier isNaN() guard used in buildTransaction before converting/formatting the fee.

Suggested change
const sorobanResourceFeeXlm = simulateResult.minResourceFee
? stroopToXlm(new BigNumber(simulateResult.minResourceFee)).toFixed(7)
: null;
const sorobanResourceFeeXlm =
simulateResult.minResourceFee &&
!Number.isNaN(Number(simulateResult.minResourceFee))
? stroopToXlm(
new BigNumber(simulateResult.minResourceFee),
).toFixed(7)
: null;

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

PR Review: Display better fee breakdown for soroban transactions (Mobile)

Cross-reference: This review covers this PR alongside the extension counterpart at stellar/freighter#2649. A comparison section is included at the bottom.


Individual Review of Mobile PR

Overall: Well-structured feature with good separation of concerns. The FeeBreakdownBottomSheet component is clean, the Zustand store changes are logical, and the early fee estimation with amount "0" is a nice UX touch.

Things I Like

  • Auto-simulation with amount "0" for early fee estimation — lets users see the fee breakdown before entering an amount. This is a UX advantage over the extension.
  • extractErrorMessage helper — properly handles plain-object API errors that would otherwise render as [object Object].
  • Request ID race-condition guard — correctly prevents stale async responses from overwriting newer state.
  • lastAutoSimulatedKey ref pattern — clean way to prevent re-triggering simulation loops.
  • isSorobanContext prop on FeeBreakdownBottomSheet — ensures correct Soroban description text even before simulation completes (loading state), which is more correct than inferring from data.
  • Good test coverage for the new store fields and simulation response shape changes.

Issues & Suggestions

1. Potential fragility in TransactionAmountScreen auto-simulation effect ⚠️
The useEffect at ~line 640 depends on [selectedBalance, recipientAddress, isBuilding, prepareTransaction]. When prepareTransaction runs, it sets isBuilding: true → then false, which re-triggers the effect. The lastAutoSimulatedKey ref guards against this, but prepareTransaction is recreated on every render (it's a useCallback with many deps including tokenAmount). If tokenAmount changes, prepareTransaction identity changes, lastAutoSimulatedKey still matches so it won't re-fire — but it's fragile. Consider removing isBuilding from the dependency array since the ref already handles deduplication, or memoizing the key comparison more defensively.

2. totalFeeXlm is computed in two places 🔁
Both FeeBreakdownBottomSheet and SendReviewBottomSheet independently compute totalFeeXlm with the same BigNumber(inclusionFee).plus(resourceFee) logic. Consider computing and exposing totalFeeXlm directly from useTransactionBuilderStore (either as a derived getter or a stored field) to keep it DRY and avoid divergence.

3. isSoroban detection is duplicated across layers 🔁
The "is this a Soroban transaction?" check is done in:

  • transactionBuilder.ts (buildTransaction — checks contractId / isContractId)
  • TransactionSettingsBottomSheet (isCollectibleTransfer || isCustomToken || isSorobanRecipient)
  • SendReviewBottomSheet (isSorobanTransaction(selectedBalance, recipientAddress))
  • TransactionAmountScreen (same helper)

These use slightly different logic. The settings sheet no longer uses the isSorobanTransaction helper from helpers/soroban and instead inlines its own version — but the inlined version (isCollectibleTransfer || isCustomToken || isSorobanRecipient) may not exactly match isSorobanTransaction() in all edge cases. Worth verifying these are equivalent or consolidating.

4. simulateContractTransfer return type change is breaking 📝
simulateContractTransfer now returns { preparedTransaction, minResourceFee } instead of a raw XDR string. All internal callers are updated, but if there are any other consumers (e.g. in swap flows or other features), they would break silently. The extension PR avoids this by adding optional fields to an existing interface instead. Worth double-checking there are no other callers.

5. Fee estimation build skips Blockaid scan silently
In TransactionAmountScreen.prepareTransaction, when feeEstimationAmount is provided, the function returns early before the Blockaid scan. This is intentional (comment says "dummy amount"), but it means if the user opens the review without changing the amount, the scan result could be stale or missing. Worth confirming the scan always runs on the "real" build before review.

6. Cancel button logic change appears unrelated 📝

// Before
tertiary={(isSuspicious && !isUnableToScan) || isExpectedToFail}
destructive={isMalicious && !shouldUseRowLayout}

// After  
tertiary={isSuspicious || isUnableToScan || isExpectedToFail}
destructive={isMalicious}

This changes behavior: previously isUnableToScan excluded the tertiary style, now it enables it. And destructive no longer checks shouldUseRowLayout. Was this intentional? It seems unrelated to the fee breakdown feature — might be worth calling out in the PR description or splitting into a separate commit.

7. Minor: 300ms setTimeout for auto-simulation
The debounce in TransactionAmountScreen uses a raw setTimeout with 300ms. The cleanup function handles clearTimeout, so it's safe — just noting it's a manual debounce.


Cross-Platform Comparison: Mobile (#774) vs Extension (#2649)

Both PRs implement the same feature (Soroban fee breakdown: Inclusion Fee + Resource Fee + Total Fee) across the two Freighter platforms. Here's how they differ:

Aspect Mobile (#774) Extension (#2649)
State management Zustand store (useTransactionBuilderStore) with isSoroban, sorobanResourceFeeXlm, sorobanInclusionFeeXlm Redux + useRequest pattern (simulationState with RequestState enum)
Fee data location Stored directly in Zustand store; computed in components via BigNumber SimulateTxData interface (shared type) carries inclusionFee? and resourceFee?
Soroban detection (FeePane) Dual system: isSoroban from store + isSorobanContext prop from parent context Inferred from simulationState.data?.resourceFee presence
Fee breakdown component FeeBreakdownBottomSheet — NativeWind/Tailwind, uses @gorhom/bottom-sheet FeesPane — SCSS-styled, integrated into MultiPaneSlider
Auto-simulation trigger useEffect with lastAutoSimulatedKey ref; uses amount "0" for early estimation useEffect watching destination + asset with simulationDataRef for dedup
Fee estimation approach Simulates with amount "0" to get fees before user enters amount; skipAmountValidation flag Simulates with the real amount; auto-triggers when destination changes
Base fee inflation prevention Inclusion fee derived from params.transactionFee || MIN_TRANSACTION_FEE in the store lastInclusionFeeRef + explicit saveTransactionFee reset before re-simulation
Total fee computation Computed independently in both FeeBreakdownBottomSheet and SendReviewBottomSheet (duplicated) Computed in simulateTxrecommendedFee = baseFee + minResourceFee; single source of truth
EditSettings fee label "Inclusion fee" for Soroban, "Transaction fee" for classic; info button opens FeeBreakdownBottomSheet "Inclusion Fee" for Soroban, "Transaction Fee" for classic; info button opens FeesPane
Info icon on review screen Only shown for Soroban transactions Always shown (classic and Soroban)
Simulation return type Breaking change: simulateContractTransfer and simulateCollectibleTransfer now return { preparedTransaction, minResourceFee } instead of raw XDR string Unchanged — useSimulateTxData hook returns data via existing pattern
Error handling New extractErrorMessage helper for plain-object API errors Existing patterns
Test coverage Unit tests updated for store + service changes; no E2E tests 358-line E2E test suite (6 test cases including re-simulation)
i18n EN + PT translations EN + PT translations (matching keys/descriptions)
Loading state ActivityIndicator spinner in bottom sheet and fee row "Calculating..." text in FeesPane and fee display

Key Architectural Differences Worth Aligning

  1. Total fee computation: The extension computes the total fee in one place (recommendedFee in the simulation hook). The mobile computes it in two components independently. Consider centralizing this in the Zustand store — either as a computed getter or a stored totalFeeXlm field — to match the extension's DRY approach.

  2. Fee estimation with amount 0: The mobile introduces feeEstimationAmount and skipAmountValidation to simulate with amount 0 before the user enters a value. The extension doesn't do this — it only simulates when the destination changes (using the real amount). This gives mobile users earlier fee visibility, but adds complexity (modified prepareTransaction signature, skipAmountValidation in validation, skipping Blockaid scan). Worth evaluating whether the extension should adopt this too, or whether the added complexity outweighs the benefit.

  3. Soroban detection strategy: The mobile uses isSorobanContext (derived from send context) + isSoroban (from store) — two separate signals. The extension's FeesPane infers it from resourceFee presence, which is simpler but shows classic text during loading. The mobile approach is more correct during loading. Consider aligning on one strategy.

  4. Breaking vs non-breaking simulation API: The mobile changes simulateContractTransfer's return type from string to { preparedTransaction, minResourceFee } (breaking). The extension adds optional fields to SimulateTxData (non-breaking additive). The extension approach is safer — if there are any other consumers of simulateContractTransfer or simulateCollectibleTransfer, they would break silently with the mobile change.

  5. Review screen info icon visibility: The mobile only shows the fee info icon for Soroban transactions; the extension always shows it. Worth deciding on a consistent approach across platforms — showing it always (extension approach) is arguably better for feature discoverability.

  6. Base fee inflation guard: The extension's lastInclusionFeeRef + explicit Redux dispatch approach is more defensive than the mobile's reliance on params.transactionFee || MIN_TRANSACTION_FEE. The extension had to solve this explicitly because Redux stores the total after simulation — the mobile's Zustand store separates inclusion and resource fees, which naturally avoids this. Both work, but for different reasons.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Improve minResourceFee display in Send flow

3 participants