Skip to content
Draft
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
4 changes: 4 additions & 0 deletions packages/bridge-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Return structured Stellar balance errors in `QuoteMetadata.nonEvmBalanceError` when fee computation fails due to insufficient funds ([#9659](https://github.com/MetaMask/core/pull/9659))

### Changed

- Bump `@metamask/assets-controller` from `^11.2.0` to `^11.2.1` ([#9648](https://github.com/MetaMask/core/pull/9648))
Expand Down
1 change: 1 addition & 0 deletions packages/bridge-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export {
export type {
ChainConfiguration,
L1GasFees,
NonEvmBalanceError,
NonEvmFees,
GasMultiplierByChainId,
FeatureFlagResponse,
Expand Down
14 changes: 14 additions & 0 deletions packages/bridge-controller/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,25 @@ export type L1GasFees = {
l1GasFeesInHexWei?: Hex; // l1 fees for approval and trade in hex wei, appended by BridgeController.#appendL1GasFees
};

export type NonEvmBalanceError = {
code: 'InsufficientBalance' | 'InsufficientBalanceToCoverFee';
assetId: string;
availableAmount: string;
requiredAmount: string;
/**
* Minimum native balance the account must keep on-chain (e.g. the Stellar
* base reserve), in display units. Enables clients to surface reserve
* warnings without chain-specific lookups.
*/
reserveAmount?: string;
};

/**
* @deprecated Avoid introducing new usages and use the QuoteResponseV2 feeData.network value instead
*/
export type NonEvmFees = {
nonEvmFeesInNative?: string; // Non-EVM chain fees in native units (SOL for Solana, BTC for Bitcoin)
nonEvmBalanceError?: NonEvmBalanceError;
};

export type InputPrimaryDenomination = 'token_amount' | 'fiat_value';
Expand Down
172 changes: 172 additions & 0 deletions packages/bridge-controller/src/utils/quote-fees.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import type { InternalAccount } from '@metamask/keyring-internal-api';

import { mockBridgeQuotesSolErc20V1 } from '../../tests/mock-quotes-sol-erc20.js';
import { ChainId } from '../types.js';
import type {
BridgeControllerMessenger,
NonEvmBalanceError,
} from '../types.js';
import type { QuoteResponseV1 } from '../validators/quote-response-v1.js';
import { appendFeesToQuotes } from './quote-fees.js';

const balanceError: NonEvmBalanceError = {
code: 'InsufficientBalance',
assetId: 'stellar:pubnet/slip44:148',
availableAmount: '0',
requiredAmount: '1.25',
};

const balanceFeeError: NonEvmBalanceError = {
...balanceError,
code: 'InsufficientBalanceToCoverFee',
};

const balanceErrorWithReserve: NonEvmBalanceError = {
...balanceError,
reserveAmount: '1.5',
};

const stellarQuote: QuoteResponseV1 = {
...mockBridgeQuotesSolErc20V1[0],
quote: {
...mockBridgeQuotesSolErc20V1[0].quote,
srcChainId: ChainId.STELLAR,
},
};

const selectedAccount = {
id: 'stellar-account',
metadata: {
snap: {
id: 'npm:@metamask/stellar-wallet-snap',
},
},
} as InternalAccount;

const getQuotesWithFeeError = async (
error: unknown,
): Promise<Awaited<ReturnType<typeof appendFeesToQuotes>>> => {
const messenger = {
call: jest.fn().mockRejectedValue(error),
} as unknown as BridgeControllerMessenger;

return await appendFeesToQuotes(
[stellarQuote],
messenger,
jest.fn(),
selectedAccount,
);
};

describe('appendFeesToQuotes', () => {
afterEach(() => {
jest.restoreAllMocks();
});

it.each([
[
'directly on the error data',
{
data: balanceError,
},
balanceError,
],
[
'on nested cause data',
{
data: {
cause: {
data: balanceFeeError,
},
},
},
balanceFeeError,
],
[
'with a reserve amount',
{
data: balanceErrorWithReserve,
},
balanceErrorWithReserve,
],
])(
'returns a balance error found %s',
async (_description, error, expectedBalanceError) => {
const quotes = await getQuotesWithFeeError(error);

expect(quotes).toStrictEqual([
{
...stellarQuote,
nonEvmFeesInNative: undefined,
nonEvmBalanceError: expectedBalanceError,
},
]);
},
);

it.each([
['a non-object error', new Error('Failed to compute fees')],
['missing data', {}],
['non-object data', { data: null }],
['missing nested cause', { data: {} }],
['a non-object nested cause', { data: { cause: null } }],
['missing nested cause data', { data: { cause: {} } }],
['non-object nested cause data', { data: { cause: { data: null } } }],
[
'an unsupported code',
{ data: { ...balanceError, code: 'UnknownError' } },
],
['a missing asset ID', { data: { ...balanceError, assetId: undefined } }],
['a non-string asset ID', { data: { ...balanceError, assetId: 1 } }],
['an empty asset ID', { data: { ...balanceError, assetId: '' } }],
[
'a missing available amount',
{ data: { ...balanceError, availableAmount: undefined } },
],
[
'a non-string available amount',
{ data: { ...balanceError, availableAmount: 0 } },
],
[
'an invalid available amount',
{ data: { ...balanceError, availableAmount: '01' } },
],
[
'a missing required amount',
{ data: { ...balanceError, requiredAmount: undefined } },
],
[
'a non-string required amount',
{ data: { ...balanceError, requiredAmount: 1 } },
],
[
'an invalid required amount',
{ data: { ...balanceError, requiredAmount: '-1' } },
],
[
'a non-string reserve amount',
{ data: { ...balanceError, reserveAmount: 1 } },
],
[
'an invalid reserve amount',
{ data: { ...balanceError, reserveAmount: 'abc' } },
],
])('ignores %s', async (_description, error) => {
const consoleErrorSpy = jest
.spyOn(console, 'error')
.mockImplementation(jest.fn());

const quotes = await getQuotesWithFeeError(error);

expect(quotes).toStrictEqual([
{
...stellarQuote,
nonEvmFeesInNative: undefined,
},
]);
expect(consoleErrorSpy).toHaveBeenCalledWith(
`Failed to compute non-EVM fees for quote ${stellarQuote.quote.requestId}:`,
error,
);
});
});
66 changes: 64 additions & 2 deletions packages/bridge-controller/src/utils/quote-fees.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import type { InternalAccount } from '@metamask/keyring-internal-api';
import type { TransactionController } from '@metamask/transaction-controller';
import { numberToHex } from '@metamask/utils';
import { hasProperty, isObject, numberToHex } from '@metamask/utils';

import { CHAIN_IDS } from '../constants/chains.js';
import type {
L1GasFees,
NonEvmBalanceError,
NonEvmFees,
BridgeControllerMessenger,
} from '../types.js';
import type { QuoteResponseV1 } from '../validators/quote-response-v1.js';
import { isTronTrade } from '../validators/trade.js';
import type { TxData } from '../validators/trade.js';
import { isNonEvmChainId, sumHexes } from './bridge.js';
import { isNonEvmChainId, isStellarChainId, sumHexes } from './bridge.js';
import { formatChainIdToCaip } from './caip-formatters.js';
import { computeFeeRequest } from './snaps.js';
import { extractTradeData } from './trade-utils.js';
Expand Down Expand Up @@ -88,6 +89,55 @@ const appendL1GasFees = async (
return quotesWithL1GasFees;
};

const isAmountString = (value: unknown): value is string =>
typeof value === 'string' && /^(?:0|[1-9]\d*)(?:\.\d+)?$/u.test(value);

const isNonEvmBalanceError = (value: unknown): value is NonEvmBalanceError => {
if (!isObject(value)) {
return false;
}

return (
hasProperty(value, 'code') &&
(value.code === 'InsufficientBalance' ||
value.code === 'InsufficientBalanceToCoverFee') &&
hasProperty(value, 'assetId') &&
typeof value.assetId === 'string' &&
value.assetId.length > 0 &&
hasProperty(value, 'availableAmount') &&
isAmountString(value.availableAmount) &&
hasProperty(value, 'requiredAmount') &&
isAmountString(value.requiredAmount) &&
(!hasProperty(value, 'reserveAmount') ||
isAmountString(value.reserveAmount))
);
};

const getNonEvmBalanceError = (
error: unknown,
): NonEvmBalanceError | undefined => {
if (!isObject(error) || !hasProperty(error, 'data')) {
return undefined;
}

if (isNonEvmBalanceError(error.data)) {
return error.data;
}

if (
!isObject(error.data) ||
!hasProperty(error.data, 'cause') ||
!isObject(error.data.cause) ||
!hasProperty(error.data.cause, 'data')
) {
return undefined;
}

return isNonEvmBalanceError(error.data.cause.data)
? error.data.cause.data
: undefined;
};

/**
* Appends transaction fees for non-EVM chains to quotes
*
Expand Down Expand Up @@ -161,6 +211,18 @@ const appendNonEvmFees = async (
nonEvmFeesInNative: feeInNative,
};
} catch (error) {
const nonEvmBalanceError = isStellarChainId(quote.srcChainId)
? getNonEvmBalanceError(error)
: undefined;

if (nonEvmBalanceError) {
return {
...quoteResponse,
nonEvmFeesInNative: undefined,
nonEvmBalanceError,
};
}

// Return quote with undefined fee if snap fails (e.g., insufficient UTXO funds)
// Client can render special UI or skip the quote card row for quotes with missing fee data
console.error(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { DeepPartial } from '../../types.js';
import type { DeepPartial, NonEvmBalanceError } from '../../types.js';

/**
* The types of values for the token amount and its values when converted to the user's selected currency and USD
Expand Down Expand Up @@ -30,6 +30,10 @@ export type TokenAmountValues = {
* @deprecated Avoid introducing new usages and use the QuoteResponse V2 type instead
*/
type QuoteMetadataV1 = {
/**
* A structured balance error returned while computing fees for a non-EVM quote.
*/
nonEvmBalanceError?: NonEvmBalanceError;
/**
* If gas is included, this is the value of the src or dest token that was used to pay for the gas.
* Show this value to indicate transaction fees for gasless quotes.
Expand Down