diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index b4e6644d379..2d9e1a88065 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -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)) diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index b5216dc7e2f..5c1b5108140 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -40,6 +40,7 @@ export { export type { ChainConfiguration, L1GasFees, + NonEvmBalanceError, NonEvmFees, GasMultiplierByChainId, FeatureFlagResponse, diff --git a/packages/bridge-controller/src/types.ts b/packages/bridge-controller/src/types.ts index a6e1d83542a..5450bdbebbb 100644 --- a/packages/bridge-controller/src/types.ts +++ b/packages/bridge-controller/src/types.ts @@ -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'; diff --git a/packages/bridge-controller/src/utils/quote-fees.test.ts b/packages/bridge-controller/src/utils/quote-fees.test.ts new file mode 100644 index 00000000000..d805f1b3a44 --- /dev/null +++ b/packages/bridge-controller/src/utils/quote-fees.test.ts @@ -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>> => { + 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, + ); + }); +}); diff --git a/packages/bridge-controller/src/utils/quote-fees.ts b/packages/bridge-controller/src/utils/quote-fees.ts index 6c15b6363a9..669f7014d3e 100644 --- a/packages/bridge-controller/src/utils/quote-fees.ts +++ b/packages/bridge-controller/src/utils/quote-fees.ts @@ -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'; @@ -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 * @@ -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( diff --git a/packages/bridge-controller/src/utils/quote-metadata/types.ts b/packages/bridge-controller/src/utils/quote-metadata/types.ts index c70c4f14eb1..a0a2e1bb15d 100644 --- a/packages/bridge-controller/src/utils/quote-metadata/types.ts +++ b/packages/bridge-controller/src/utils/quote-metadata/types.ts @@ -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 @@ -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.