Skip to content

Commit cdf56ce

Browse files
authored
refactor: extract various business logic into separate utilities (#26678)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** Extract various logic into separate pure functions and hooks. <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/SWAPS-4188 ## **Manual testing steps** ```gherkin This PR introduce no change to business logic. Ensure that no regressions got introduced. ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Mostly refactoring with broad surface-area touch in Bridge quoting/fee display and validation filtering; moderate risk of UI regressions around fee formatting and when quote sections render/clear on expiry. > > **Overview** > **Refactors Bridge quote presentation logic into reusable utilities/hooks.** Network-fee formatting is extracted to `formatNetworkFee` + `useFormattedNetworkFee`, and gas-sponsorship logic is moved into `useIsNetworkGasSponsored` + `useShouldRenderGasSponsoredBanner`, with gasless detection centralized in `isGaslessQuote`. > > **Hardens quote-driven UI and data selection.** `BridgeView` now avoids rendering the bottom action section when there’s no `activeQuote` (fixing an expiry/redirect edge case), and `useBridgeQuoteData` adds `validQuotes` by filtering sorted quotes to those matching the selected destination token and non-expired state. > > **Consolidates fiat formatting.** The existing `useFiatFormatter` hook is simplified to delegate to a new shared `util/formatFiat` helper, with extensive new unit tests added across the new hooks/utilities. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 9096d32. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent e7a93ce commit cdf56ce

18 files changed

Lines changed: 2414 additions & 148 deletions

File tree

app/components/UI/Bridge/Views/BridgeView/index.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,14 @@ const BridgeView = () => {
377377
);
378378
}
379379

380+
// Prevent bottom section from rendering when no active
381+
// quotes exist and none are being fetching.
382+
// This resolves edge cases when users are redirected back from
383+
// Select Quote page due to quotes expiry.
384+
if (!activeQuote) {
385+
return null;
386+
}
387+
380388
// TODO: remove this once controller types are updated
381389
// @ts-expect-error: controller types are not up to date yet
382390
const quoteBpsFee = activeQuote?.quote?.feeData?.metabridge?.quoteBpsFee;

app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx

Lines changed: 7 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,12 @@ import AddRewardsAccount from '../../../Rewards/components/AddRewardsAccount/Add
3939
import QuoteCountdownTimer from '../QuoteCountdownTimer';
4040
import QuoteDetailsRecipientKeyValueRow from '../QuoteDetailsRecipientKeyValueRow/QuoteDetailsRecipientKeyValueRow';
4141
import { toSentenceCase } from '../../../../../util/string';
42-
import { getGasFeesSponsoredNetworkEnabled } from '../../../../../selectors/featureFlagController/gasFeesSponsored';
43-
import { QuoteDetailsCardProps } from './QuoteDetailsCard.types';
4442
import TagColored, {
4543
TagColor,
4644
} from '../../../../../component-library/components-temp/TagColored';
45+
import { useShouldRenderGasSponsoredBanner } from '../../hooks/useShouldRenderGasSponsoredBanner';
46+
import { isGaslessQuote } from '../../utils/isGaslessQuote';
47+
import { QuoteDetailsCardProps } from './QuoteDetailsCard.types';
4748

4849
if (
4950
Platform.OS === 'android' &&
@@ -80,34 +81,17 @@ const QuoteDetailsCard: React.FC<QuoteDetailsCardProps> = ({
8081
isQuoteLoading,
8182
});
8283

83-
const gasFeesSponsoredNetworkEnabled = useSelector(
84-
getGasFeesSponsoredNetworkEnabled,
85-
);
86-
8784
const nativeTokenName = useMemo(() => {
8885
const chainId = sourceToken?.chainId;
8986
if (!chainId) return undefined;
9087
const native = getNativeSourceToken(chainId);
9188
return native?.symbol ?? sourceToken?.symbol ?? '';
9289
}, [sourceToken?.chainId, sourceToken?.symbol]);
9390

94-
const isCurrentNetworkGasSponsored = useMemo(() => {
95-
if (!sourceToken?.chainId || !gasFeesSponsoredNetworkEnabled) {
96-
return false;
97-
}
98-
return gasFeesSponsoredNetworkEnabled(sourceToken.chainId);
99-
}, [sourceToken?.chainId, gasFeesSponsoredNetworkEnabled]);
100-
101-
const shouldShowGasSponsored = useMemo(() => {
102-
const gasSponsored = activeQuote?.quote?.gasSponsored ?? false;
103-
return (
104-
gasSponsored || (hasInsufficientBalance && isCurrentNetworkGasSponsored)
105-
);
106-
}, [
107-
activeQuote?.quote?.gasSponsored,
91+
const shouldShowGasSponsored = useShouldRenderGasSponsoredBanner({
92+
quoteGasSponsored: activeQuote?.quote?.gasSponsored ?? false,
10893
hasInsufficientBalance,
109-
isCurrentNetworkGasSponsored,
110-
]);
94+
});
11195

11296
const handleSlippagePress = () => {
11397
navigation.navigate(Routes.BRIDGE.MODALS.ROOT, {
@@ -131,9 +115,7 @@ const QuoteDetailsCard: React.FC<QuoteDetailsCardProps> = ({
131115

132116
const { networkFee, rate, priceImpact, slippage } = formattedQuoteData;
133117

134-
const gasIncluded = !!activeQuote?.quote.gasIncluded;
135-
const gasIncluded7702 = !!activeQuote?.quote.gasIncluded7702;
136-
const isGasless = gasIncluded7702 || gasIncluded;
118+
const isGasless = isGaslessQuote(activeQuote?.quote);
137119

138120
const formattedMinToTokenAmount = formatMinimumReceived(
139121
activeQuote?.minToTokenAmount?.amount || '0',

app/components/UI/Bridge/hooks/useBridgeQuoteData/index.ts

Lines changed: 47 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -14,23 +14,18 @@ import {
1414
import { RequestStatus, isNonEvmChainId } from '@metamask/bridge-controller';
1515
import { areAddressesEqual } from '../../../../../util/address';
1616
import { useCallback, useMemo, useEffect, useState, useRef } from 'react';
17-
import {
18-
fromTokenMinimalUnit,
19-
isNumberValue,
20-
} from '../../../../../util/number';
17+
import { fromTokenMinimalUnit } from '../../../../../util/number';
2118
import {
2219
isQuoteExpired,
2320
getQuoteRefreshRate,
2421
shouldRefreshQuote,
2522
} from '../../utils/quoteUtils';
26-
27-
import { BigNumber } from 'bignumber.js';
2823
import I18n from '../../../../../../locales/i18n';
29-
import useFiatFormatter from '../../../SimulationDetails/FiatDisplay/useFiatFormatter';
3024
import useIsInsufficientBalance from '../useInsufficientBalance';
3125
import { BigNumber as EthersBigNumber } from 'ethers';
3226
import useValidateBridgeTx from '../../../../../util/bridge/hooks/useValidateBridgeTx';
3327
import { getIntlNumberFormatter } from '../../../../../util/intl';
28+
import { useFormattedNetworkFee } from '../useFormattedNetworkFee';
3429

3530
interface UseBridgeQuoteDataParams {
3631
latestSourceAtomicBalance?: EthersBigNumber;
@@ -49,7 +44,6 @@ export const useBridgeQuoteData = ({
4944
const slippage = useSelector(selectSlippage);
5045
const isSubmittingTx = useSelector(selectIsSubmittingTx);
5146
const locale = I18n.locale;
52-
const fiatFormatter = useFiatFormatter();
5347
const quotes = useSelector(selectBridgeQuotes);
5448
const bridgeFeatureFlags = useSelector(selectBridgeFeatureFlags);
5549
const isSolanaSwap = useSelector(selectIsSolanaSwap);
@@ -85,6 +79,10 @@ export const useBridgeQuoteData = ({
8579
const isExpired = isQuoteExpired(willRefresh, refreshRate, quotesLastFetched);
8680

8781
const bestQuote = quotes?.recommendedQuote;
82+
const allQuotes = useMemo(
83+
() => quotes?.sortedQuotes ?? [],
84+
[quotes?.sortedQuotes],
85+
);
8886

8987
const activeQuote =
9088
isExpired && !willRefresh && !isSubmittingTx ? undefined : bestQuote;
@@ -104,25 +102,45 @@ export const useBridgeQuoteData = ({
104102
return areAddressesEqual(quoteSourceAddress, selectedSourceAddress);
105103
}, [activeQuote, sourceToken]);
106104

107-
// Validate that the quote's destination asset matches the selected destination token
105+
// Helper to validate that a quote's destination asset matches the selected destination token
108106
// This prevents showing stale quote data (with wrong decimals) when user changes destination token
109-
const isQuoteDestTokenMatch = useMemo(() => {
110-
if (!activeQuote || !destToken) return false;
111-
112-
const { destAsset } = activeQuote.quote;
113-
114-
// For non-EVM chains (e.g., Solana), destAsset.address is in raw format (e.g., "EPj...")
115-
// or zero address for native tokens, while destToken.address uses CAIP format
116-
// (e.g., "solana:.../token:EPj...").
117-
// Use destAsset.assetId (CAIP format) for comparison.
118-
// For EVM chains, use the original address comparison.
119-
const quoteDestAddress = isNonEvmChainId(destToken.chainId)
120-
? (destAsset.assetId ?? destAsset.address)
121-
: destAsset.address;
107+
const isQuoteDestTokenMatchForQuote = useCallback(
108+
(quote: (typeof allQuotes)[number] | undefined | null): boolean => {
109+
if (!quote || !destToken) return false;
110+
111+
const { destAsset } = quote.quote;
112+
113+
// For non-EVM chains (e.g., Solana), destAsset.address is in raw format (e.g., "EPj...")
114+
// or zero address for native tokens, while destToken.address uses CAIP format
115+
// (e.g., "solana:.../token:EPj...").
116+
// Use destAsset.assetId (CAIP format) for comparison.
117+
// For EVM chains, use the original address comparison.
118+
const quoteDestAddress = isNonEvmChainId(destToken.chainId)
119+
? (destAsset.assetId ?? destAsset.address)
120+
: destAsset.address;
121+
122+
const selectedDestAddress = destToken.address;
123+
return areAddressesEqual(quoteDestAddress, selectedDestAddress);
124+
},
125+
[destToken],
126+
);
122127

123-
const selectedDestAddress = destToken.address;
124-
return areAddressesEqual(quoteDestAddress, selectedDestAddress);
125-
}, [activeQuote, destToken]);
128+
const isQuoteDestTokenMatch = isQuoteDestTokenMatchForQuote(activeQuote);
129+
130+
// Filter all quotes to only include valid ones (not expired and matching dest token)
131+
const validQuotes = useMemo(
132+
() =>
133+
isExpired && !willRefresh && !isSubmittingTx
134+
? []
135+
: allQuotes.filter((quote) => isQuoteDestTokenMatchForQuote(quote)),
136+
[
137+
isExpired,
138+
willRefresh,
139+
isSubmittingTx,
140+
allQuotes,
141+
isQuoteDestTokenMatchForQuote,
142+
],
143+
);
126144

127145
const destTokenAmount =
128146
activeQuote && destToken && isQuoteSourceTokenMatch && isQuoteDestTokenMatch
@@ -137,28 +155,7 @@ export const useBridgeQuoteData = ({
137155
? undefined
138156
: Number(destTokenAmount) / Number(sourceAmount);
139157

140-
const getNetworkFee = useCallback(() => {
141-
if (!activeQuote?.totalNetworkFee) return '-';
142-
143-
const { totalNetworkFee } = activeQuote;
144-
145-
const { amount, valueInCurrency } = totalNetworkFee;
146-
147-
if (
148-
amount == null ||
149-
valueInCurrency == null ||
150-
!isNumberValue(amount) ||
151-
!isNumberValue(valueInCurrency)
152-
) {
153-
return '-';
154-
}
155-
156-
const formattedValueInCurrency = fiatFormatter(
157-
new BigNumber(valueInCurrency),
158-
);
159-
160-
return formattedValueInCurrency;
161-
}, [activeQuote, fiatFormatter]);
158+
const networkFee = useFormattedNetworkFee(activeQuote);
162159

163160
const formattedQuoteData = useMemo(() => {
164161
if (!activeQuote) return undefined;
@@ -186,7 +183,7 @@ export const useBridgeQuoteData = ({
186183
: '--';
187184

188185
return {
189-
networkFee: getNetworkFee(),
186+
networkFee,
190187
estimatedTime:
191188
estimatedProcessingTimeInSeconds >= 60
192189
? `${Math.ceil(estimatedProcessingTimeInSeconds / 60)} min`
@@ -204,9 +201,9 @@ export const useBridgeQuoteData = ({
204201
quoteRate,
205202
sourceToken?.symbol,
206203
destToken?.symbol,
207-
getNetworkFee,
208204
slippage,
209205
locale,
206+
networkFee,
210207
]);
211208

212209
const isLoading = quotesLoadingStatus === RequestStatus.LOADING;
@@ -303,5 +300,6 @@ export const useBridgeQuoteData = ({
303300
isExpired,
304301
blockaidError,
305302
shouldShowPriceImpactWarning,
303+
validQuotes,
306304
};
307305
};

0 commit comments

Comments
 (0)