Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
254cad9
chore: use preview build
micaelae Nov 19, 2025
a5a7b73
feat: enable tx submission
micaelae Nov 19, 2025
bfefc6b
chore: use getQuotesReceivedProperties util
micaelae Nov 19, 2025
2cb4426
fix: lint errors
micaelae Nov 19, 2025
c61c167
fix: format
micaelae Nov 19, 2025
dd85cd6
Merge branch 'main' into swaps3024-submit-while-loading
micaelae Nov 20, 2025
b32e5e2
fix: useBridgeQuoteEvents unit test
micaelae Nov 20, 2025
86defd5
Merge branch 'main' into swaps3024-submit-while-loading
micaelae Nov 20, 2025
28214fa
Merge branch 'main' into swaps3024-submit-while-loading
micaelae Nov 20, 2025
2eb32f1
Merge branch 'main' into swaps3024-submit-while-loading
micaelae Nov 20, 2025
1a7dcd3
fix: useSubmitBridgeTx unit tests
micaelae Nov 20, 2025
464a5d9
fix: BridgeView tests
micaelae Nov 20, 2025
2f0e4c9
Merge branch 'main' into swaps3024-submit-while-loading
micaelae Nov 21, 2025
e0c73c5
Merge branch 'main' into swaps3024-submit-while-loading
micaelae Nov 21, 2025
a924831
Merge branch 'main' into swaps3024-submit-while-loading
micaelae Nov 21, 2025
6dc45ba
Merge branch 'main' into swaps3024-submit-while-loading
micaelae Nov 24, 2025
cc2a216
Merge branch 'main' into swaps3024-submit-while-loading
micaelae Nov 25, 2025
fc0ad59
chore: bump bridge controllers
micaelae Nov 25, 2025
a4c5251
Merge branch 'main' into swaps3024-submit-while-loading
micaelae Nov 26, 2025
444d246
chore: bump controllers
micaelae Nov 26, 2025
53fca2f
chore: update submitTx args
micaelae Nov 26, 2025
4d57ca5
Merge branch 'main' into swaps3024-submit-while-loading
micaelae Nov 26, 2025
14ee4b1
fix: yarn.lock
micaelae Nov 26, 2025
ad06a20
chore: dedupe
micaelae Nov 26, 2025
edad063
Merge branch 'main' into swaps3024-submit-while-loading
micaelae Nov 26, 2025
63bafb9
fix: unit tests
micaelae Nov 26, 2025
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
Original file line number Diff line number Diff line change
Expand Up @@ -1265,6 +1265,7 @@ describe('BridgeView', () => {
await waitFor(() => {
expect(mockSubmitBridgeTx).toHaveBeenCalledWith({
quoteResponse: mockQuote,
warnings: [],
});
expect(mockNavigate).toHaveBeenCalledWith(Routes.TRANSACTIONS_VIEW);
});
Expand Down Expand Up @@ -1331,6 +1332,7 @@ describe('BridgeView', () => {
await waitFor(() => {
expect(mockSubmitBridgeTx).toHaveBeenCalledWith({
quoteResponse: mockQuote,
warnings: [],
});
expect(mockNavigate).toHaveBeenCalledWith(Routes.TRANSACTIONS_VIEW);
});
Expand Down Expand Up @@ -1394,6 +1396,7 @@ describe('BridgeView', () => {
await waitFor(() => {
expect(mockSubmitBridgeTx).toHaveBeenCalledWith({
quoteResponse: mockQuote,
warnings: [],
});
expect(mockNavigate).toHaveBeenCalledWith(Routes.TRANSACTIONS_VIEW);
});
Expand Down Expand Up @@ -1454,6 +1457,7 @@ describe('BridgeView', () => {
await waitFor(() => {
expect(mockSubmitBridgeTx).toHaveBeenCalledWith({
quoteResponse: mockQuote,
warnings: [],
});
expect(mockNavigate).toHaveBeenCalledWith(Routes.TRANSACTIONS_VIEW);
});
Expand Down
31 changes: 23 additions & 8 deletions app/components/UI/Bridge/Views/BridgeView/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect, useState, useRef } from 'react';
import React, { useEffect, useState, useRef, useMemo } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import ScreenView from '../../../../Base/ScreenView';
import {
Expand Down Expand Up @@ -77,7 +77,7 @@
import { useRecipientInitialization } from '../../hooks/useRecipientInitialization';
import ApprovalTooltip from '../../components/ApprovalText';
import { RootState } from '../../../../../reducers/index.ts';
import { BRIDGE_MM_FEE_RATE } from '@metamask/bridge-controller';
import { BRIDGE_MM_FEE_RATE, QuoteWarning } from '@metamask/bridge-controller';
import { isNullOrUndefined } from '@metamask/utils';
import { useBridgeQuoteEvents } from '../../hooks/useBridgeQuoteEvents/index.ts';
import { SwapsKeypad } from '../../components/SwapsKeypad/index.tsx';
Expand Down Expand Up @@ -206,20 +206,34 @@
});

const isSubmitDisabled =
isLoading ||
hasInsufficientBalance ||
isSubmittingTx ||
(isHardwareAddress && isSolanaSourced) ||
!!blockaidError ||
!hasSufficientGas;

useBridgeQuoteEvents({
const warnings = useMemo(() => {
const latestWarnings: QuoteWarning[] = [];

isNoQuotesAvailable && latestWarnings.push('no_quotes');
!hasSufficientGas &&
latestWarnings.push('insufficient_gas_for_selected_quote');
hasInsufficientBalance && latestWarnings.push('insufficient_balance');
Boolean(blockaidError) && latestWarnings.push('tx_alert');
shouldShowPriceImpactWarning && latestWarnings.push('price_impact');

return latestWarnings;
}, [
isNoQuotesAvailable,
hasSufficientGas,
hasInsufficientBalance,
hasNoQuotesAvailable: isNoQuotesAvailable,
hasInsufficientGas: !hasSufficientGas,
hasTxAlert: Boolean(blockaidError),
blockaidError,
shouldShowPriceImpactWarning,
]);

useBridgeQuoteEvents({
warnings,
isSubmitDisabled,
isPriceImpactWarningVisible: shouldShowPriceImpactWarning,
});

// Compute error state directly from dependencies
Expand Down Expand Up @@ -322,6 +336,7 @@
dispatch(setIsSubmittingTx(true));
await submitBridgeTx({
quoteResponse: activeQuote,
warnings,
Copy link

Choose a reason for hiding this comment

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

Bug: Navigation occurs even when transaction submission fails

The navigation.navigate(Routes.TRANSACTIONS_VIEW) call in the finally block (line 347) executes regardless of whether the transaction was actually submitted. If a user clicks "Continue" while activeQuote is undefined (e.g., before quotes finish loading, which is now possible since isLoading was removed from isSubmitDisabled), the if (activeQuote) check fails silently and nothing is submitted. However, the finally block still navigates away, causing the user to be taken to an empty transactions view instead of remaining on the bridge screen.

Fix in Cursor Fix in Web

});
}
} catch (error) {
Expand Down Expand Up @@ -402,7 +417,7 @@
);
}

// TODO: remove this once controller types are updated

Check warning on line 420 in app/components/UI/Bridge/Views/BridgeView/index.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this "TODO" comment.

See more on https://sonarcloud.io/project/issues?id=metamask-mobile&issues=AZqjl1g6FhD_K5bipRLe&open=AZqjl1g6FhD_K5bipRLe&pullRequest=22905
// @ts-expect-error: controller types are not up to date yet
const quoteBpsFee = activeQuote?.quote?.feeData?.metabridge?.quoteBpsFee;
const feePercentage = !isNullOrUndefined(quoteBpsFee)
Expand Down Expand Up @@ -456,11 +471,11 @@
? strings('bridge.fee_disclaimer', {
feePercentage,
})
: !hasFee && isNoFeeDestinationAsset
? strings('bridge.no_mm_fee_disclaimer', {
destTokenSymbol: destToken?.symbol,
})
: ''}

Check warning on line 478 in app/components/UI/Bridge/Views/BridgeView/index.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=metamask-mobile&issues=AZqjl1g6FhD_K5bipRLf&open=AZqjl1g6FhD_K5bipRLf&pullRequest=22905
{approval
? ` ${strings('bridge.approval_needed', approval)}`
: ''}{' '}
Expand Down
56 changes: 10 additions & 46 deletions app/components/UI/Bridge/hooks/useBridgeQuoteEvents/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,77 +3,41 @@ import {
selectBridgeControllerState,
selectBridgeQuotes,
} from '../../../../../core/redux/slices/bridge';
import { useEffect, useMemo } from 'react';
import { useEffect } from 'react';
import Engine from '../../../../../core/Engine';
import {
formatProviderLabel,
getQuotesReceivedProperties,
type QuoteWarning,
UnifiedSwapBridgeEventName,
} from '@metamask/bridge-controller';

/**
* Hook for publishing the QuotesReceived event
*/
export const useBridgeQuoteEvents = ({
hasInsufficientBalance,
hasNoQuotesAvailable,
hasInsufficientGas,
hasTxAlert,
isSubmitDisabled,
isPriceImpactWarningVisible,
warnings,
}: {
hasInsufficientBalance: boolean;
hasNoQuotesAvailable: boolean;
hasInsufficientGas: boolean;
hasTxAlert: boolean;
isSubmitDisabled: boolean;
isPriceImpactWarningVisible: boolean;
warnings: QuoteWarning[];
}) => {
const { quoteFetchError, quotesRefreshCount } = useSelector(
selectBridgeControllerState,
);
const { activeQuote, recommendedQuote, isLoading } =
useSelector(selectBridgeQuotes);

const warnings = useMemo(() => {
const latestWarnings = [];

hasNoQuotesAvailable && latestWarnings.push('no_quotes');
hasInsufficientGas &&
latestWarnings.push('insufficient_gas_for_selected_quote');
hasInsufficientBalance && latestWarnings.push('insufficient_balance');
hasTxAlert && latestWarnings.push('tx_alert');
isPriceImpactWarningVisible && latestWarnings.push('price_impact_warning');

return latestWarnings;
}, [
hasNoQuotesAvailable,
hasInsufficientGas,
hasInsufficientBalance,
hasTxAlert,
isPriceImpactWarningVisible,
]);

// Emit QuotesReceived event each time quotes are fetched successfully
useEffect(() => {
if (!isLoading && quotesRefreshCount > 0 && !quoteFetchError) {
Engine.context.BridgeController.trackUnifiedSwapBridgeEvent(
UnifiedSwapBridgeEventName.QuotesReceived,
{
can_submit: !isSubmitDisabled,
gas_included: Boolean(activeQuote?.quote?.gasIncluded),
gas_included_7702: Boolean(activeQuote?.quote?.gasIncluded7702),
quoted_time_minutes: activeQuote?.estimatedProcessingTimeInSeconds
? activeQuote.estimatedProcessingTimeInSeconds / 60
: 0,
usd_quoted_gas: Number(activeQuote?.gasFee?.effective?.usd ?? 0),
usd_quoted_return: Number(activeQuote?.toTokenAmount?.usd ?? 0),
best_quote_provider: recommendedQuote
? formatProviderLabel(recommendedQuote.quote)
: undefined,
provider: activeQuote ? formatProviderLabel(activeQuote.quote) : '_',
getQuotesReceivedProperties(
activeQuote,
warnings,
price_impact: Number(activeQuote?.quote.priceData?.priceImpact ?? 0),
},
!isSubmitDisabled,
recommendedQuote,
),
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useBridgeQuoteEvents } from '.';
import Engine from '../../../../../core/Engine';
import { createBridgeTestState } from '../../testUtils';
import { mockQuoteWithMetadata } from '../../_mocks_/bridgeQuoteWithMetadata';
import { RequestStatus } from '@metamask/bridge-controller';
import { QuoteWarning, RequestStatus } from '@metamask/bridge-controller';

jest.mock('../../../../../core/Engine', () => ({
context: {
Expand Down Expand Up @@ -39,12 +39,8 @@ describe('useBridgeQuoteEvents', () => {
renderHookWithProvider(
() =>
useBridgeQuoteEvents({
hasNoQuotesAvailable: false,
hasInsufficientBalance: false,
hasInsufficientGas: false,
hasTxAlert: false,
isSubmitDisabled: false,
isPriceImpactWarningVisible: false,
warnings: [],
}),
{ state: testState },
);
Expand Down Expand Up @@ -80,12 +76,8 @@ describe('useBridgeQuoteEvents', () => {
renderHookWithProvider(
() =>
useBridgeQuoteEvents({
hasNoQuotesAvailable: false,
hasInsufficientBalance: false,
hasInsufficientGas: false,
hasTxAlert: false,
isSubmitDisabled: false,
isPriceImpactWarningVisible: false,
warnings: warnings as QuoteWarning[],
...hookArgs,
}),
{ state: testState },
Expand Down
30 changes: 30 additions & 0 deletions app/util/bridge/hooks/useSubmitBridgeTx.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ describe('useSubmitBridgeTx', () => {

const txResult = await result.current.submitBridgeTx({
quoteResponse: mockQuoteResponse as QuoteResponse & QuoteMetadata,
warnings: [],
});

expect(mockSubmitTx).toHaveBeenCalledWith(
Expand All @@ -161,6 +162,18 @@ describe('useSubmitBridgeTx', () => {
approval: undefined,
},
true,
{
best_quote_provider: 'lifi_across',
can_submit: true,
gas_included: false,
gas_included_7702: false,
price_impact: 0,
provider: 'lifi_across',
quoted_time_minutes: 0.03333333333333333,
usd_quoted_gas: 0.6491483498924696,
usd_quoted_return: 9.707614272223898,
warnings: [],
},
);
expect(txResult).toEqual({
chainId: '0x1',
Expand Down Expand Up @@ -197,6 +210,7 @@ describe('useSubmitBridgeTx', () => {

const txResult = await result.current.submitBridgeTx({
quoteResponse: mockQuoteResponse as QuoteResponse & QuoteMetadata,
warnings: [],
});

expect(mockSubmitTx).toHaveBeenCalledWith(
Expand All @@ -206,6 +220,18 @@ describe('useSubmitBridgeTx', () => {
approval: mockQuoteResponse.approval ?? undefined,
},
true,
{
best_quote_provider: 'lifi_across',
can_submit: true,
gas_included: false,
gas_included_7702: false,
price_impact: 0,
provider: 'lifi_across',
quoted_time_minutes: 0.3,
usd_quoted_gas: 0.6491483498924696,
usd_quoted_return: 9.707614272223898,
warnings: [],
},
);
expect(txResult).toEqual({
chainId: '0x1',
Expand Down Expand Up @@ -235,6 +261,7 @@ describe('useSubmitBridgeTx', () => {
await expect(
result.current.submitBridgeTx({
quoteResponse: mockQuoteResponse as QuoteResponse & QuoteMetadata,
warnings: [],
}),
).rejects.toThrow('Approval failed');
});
Expand All @@ -255,6 +282,7 @@ describe('useSubmitBridgeTx', () => {
await expect(
result.current.submitBridgeTx({
quoteResponse: mockQuoteResponse as QuoteResponse & QuoteMetadata,
warnings: [],
}),
).rejects.toThrow('Bridge transaction failed');
});
Expand All @@ -280,6 +308,7 @@ describe('useSubmitBridgeTx', () => {
await expect(
result.current.submitBridgeTx({
quoteResponse: invalidQuoteResponse as QuoteResponse & QuoteMetadata,
warnings: [],
}),
).rejects.toThrow('Serialization failed');
});
Expand All @@ -300,6 +329,7 @@ describe('useSubmitBridgeTx', () => {
await expect(
result.current.submitBridgeTx({
quoteResponse: mockQuoteResponse as QuoteResponse & QuoteMetadata,
warnings: [],
}),
).rejects.toThrow('Wallet address is not set');
});
Expand Down
10 changes: 9 additions & 1 deletion app/util/bridge/hooks/useSubmitBridgeTx.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import Engine from '../../../core/Engine';
import { QuoteMetadata, QuoteResponse } from '@metamask/bridge-controller';
import {
getQuotesReceivedProperties,
QuoteMetadata,
QuoteResponse,
QuoteWarning,
} from '@metamask/bridge-controller';
import { useSelector } from 'react-redux';
import { selectShouldUseSmartTransaction } from '../../../selectors/smartTransactionsController';
import { selectSourceWalletAddress } from '../../../selectors/bridge';
Expand All @@ -10,8 +15,10 @@ export default function useSubmitBridgeTx() {

const submitBridgeTx = async ({
quoteResponse,
warnings,
}: {
quoteResponse: QuoteResponse & QuoteMetadata;
warnings: QuoteWarning[];
}) => {
if (!walletAddress) {
throw new Error('Wallet address is not set');
Expand All @@ -23,6 +30,7 @@ export default function useSubmitBridgeTx() {
approval: quoteResponse.approval ?? undefined,
},
stxEnabled,
getQuotesReceivedProperties(quoteResponse, warnings, true),
);

return txResult;
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -201,8 +201,8 @@
"@metamask/assets-controllers": "patch:@metamask/assets-controllers@npm%3A89.0.1#~/.yarn/patches/@metamask-assets-controllers-npm-89.0.1-02fa7acd54.patch",
"@metamask/base-controller": "^9.0.0",
"@metamask/bitcoin-wallet-snap": "^1.7.0",
"@metamask/bridge-controller": "^61.0.0",
"@metamask/bridge-status-controller": "^61.0.0",
"@metamask/bridge-controller": "^63.2.0",
"@metamask/bridge-status-controller": "^63.1.0",
"@metamask/chain-agnostic-permission": "^1.2.2",
"@metamask/composable-controller": "^12.0.0",
"@metamask/controller-utils": "^11.11.0",
Expand Down
Loading
Loading