diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index cd14cdf303..9ca4c43d21 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Implement fiat strategy submit flow with order polling and relay execution ([#8347](https://github.com/MetaMask/core/pull/8347)) +### Changed + +- Resolve fiat asset per transaction type from `confirmations_pay_fiat` remote feature flag, falling back to hardcoded map then ETH on mainnet ([#8631](https://github.com/MetaMask/core/pull/8631)) + ## [21.0.0] ### Added diff --git a/packages/transaction-pay-controller/src/TransactionPayController.test.ts b/packages/transaction-pay-controller/src/TransactionPayController.test.ts index bd8e5a4cfe..9e9b8283e2 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.test.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.test.ts @@ -646,9 +646,7 @@ describe('TransactionPayController', () => { const CAIP_ASSET_ID_MOCK = 'eip155:137/slip44:966'; const FIAT_ASSET_MOCK = { address: '0x0000000000000000000000000000000000001010' as Hex, - caipAssetId: CAIP_ASSET_ID_MOCK, chainId: '0x89' as Hex, - decimals: 18, }; let setSelectedTokenMock: jest.Mock; @@ -724,7 +722,7 @@ describe('TransactionPayController', () => { it('does not call setSelectedToken when fiat asset cannot be derived', () => { getTransactionMock.mockReturnValue(TRANSACTION_META_MOCK); - deriveFiatAssetForFiatPaymentMock.mockReturnValue(undefined); + deriveFiatAssetForFiatPaymentMock.mockReturnValue(undefined as never); const updateTransactionData = getUpdateTransactionData(); diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index e1b7670379..b192ec91bd 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -26,6 +26,7 @@ import type { import { getStrategyOrder } from './utils/feature-flags'; import { updateQuotes } from './utils/quotes'; import { updateSourceAmounts } from './utils/source-amounts'; +import { buildCaipAssetType } from './utils/token'; import { getTransaction, pollTransactionChanges } from './utils/transaction'; const MESSENGER_EXPOSED_METHODS = [ @@ -284,12 +285,15 @@ export class TransactionPayController extends BaseController< transactionId, this.messenger, ) as TransactionMeta; - const fiatAsset = deriveFiatAssetForFiatPayment(transaction); + const fiatAsset = deriveFiatAssetForFiatPayment( + transaction, + this.messenger, + ); if (fiatAsset) { try { this.messenger.call( 'RampsController:setSelectedToken', - fiatAsset.caipAssetId, + buildCaipAssetType(fiatAsset.chainId, fiatAsset.address), ); } catch { // Intentionally no-op — tokens may not be loaded in RampsController yet. diff --git a/packages/transaction-pay-controller/src/constants.ts b/packages/transaction-pay-controller/src/constants.ts index 5a097ee85d..c740bdbd6d 100644 --- a/packages/transaction-pay-controller/src/constants.ts +++ b/packages/transaction-pay-controller/src/constants.ts @@ -2,6 +2,7 @@ import type { Hex } from '@metamask/utils'; export const CONTROLLER_NAME = 'TransactionPayController'; export const CHAIN_ID_ARBITRUM = '0xa4b1' as Hex; +export const CHAIN_ID_MAINNET = '0x1' as Hex; export const CHAIN_ID_POLYGON = '0x89' as Hex; export const CHAIN_ID_HYPERCORE = '0x539' as Hex; @@ -19,6 +20,10 @@ export const HYPERCORE_USDC_ADDRESS = '0x00000000000000000000000000000000'; export const HYPERCORE_USDC_DECIMALS = 8; export const USDC_DECIMALS = 6; +export const SLIP44_COIN_TYPE_BY_CHAIN: Record = { + [CHAIN_ID_POLYGON]: 966, // POL +}; + export const STABLECOINS: Record = { // Mainnet '0x1': [ diff --git a/packages/transaction-pay-controller/src/strategy/fiat/constants.ts b/packages/transaction-pay-controller/src/strategy/fiat/constants.ts index 5289ebcb82..83d85afa3d 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/constants.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/constants.ts @@ -3,6 +3,7 @@ import type { Hex } from '@metamask/utils'; import { CHAIN_ID_ARBITRUM, + CHAIN_ID_MAINNET, CHAIN_ID_POLYGON, NATIVE_TOKEN_ADDRESS, } from '../../constants'; @@ -11,26 +12,24 @@ export const DEFAULT_FIAT_CURRENCY = 'USD'; export type TransactionPayFiatAsset = { address: Hex; - caipAssetId: string; chainId: Hex; - decimals: number; }; const POLYGON_POL_FIAT_ASSET: TransactionPayFiatAsset = { address: '0x0000000000000000000000000000000000001010', - caipAssetId: 'eip155:137/slip44:966', chainId: CHAIN_ID_POLYGON, - decimals: 18, }; const ARBITRUM_ETH_FIAT_ASSET: TransactionPayFiatAsset = { address: NATIVE_TOKEN_ADDRESS, - caipAssetId: 'eip155:42161/slip44:60', chainId: CHAIN_ID_ARBITRUM, - decimals: 18, }; -// We might use feature flags to determine these later. +export const ETH_MAINNET_FIAT_ASSET: TransactionPayFiatAsset = { + address: NATIVE_TOKEN_ADDRESS, + chainId: CHAIN_ID_MAINNET, +}; + export const FIAT_ASSET_ID_BY_TX_TYPE: Partial< Record > = { diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.test.ts index 0bc9d291fd..5a5766b3af 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.test.ts @@ -13,7 +13,12 @@ import type { TransactionPayQuote, TransactionPayRequiredToken, } from '../../types'; -import { computeRawFromFiatAmount, getTokenFiatRate } from '../../utils/token'; +import { + buildCaipAssetType, + computeRawFromFiatAmount, + getTokenFiatRate, + getTokenInfo, +} from '../../utils/token'; import { getRelayQuotes } from '../relay/relay-quotes'; import type { RelayQuote } from '../relay/types'; import type { TransactionPayFiatAsset } from './constants'; @@ -52,9 +57,7 @@ const REQUIRED_TOKEN_MOCK: TransactionPayRequiredToken = { const FIAT_ASSET_MOCK: TransactionPayFiatAsset = { address: '0x0000000000000000000000000000000000001010', - caipAssetId: 'eip155:137/slip44:966', chainId: '0x89', - decimals: 18, }; const FIAT_QUOTE_MOCK: RampsQuote = { @@ -199,9 +202,13 @@ function getRequest({ }; } +const FIAT_ASSET_CAIP_ID_MOCK = 'eip155:137/slip44:966'; + describe('getFiatQuotes', () => { + const buildCaipAssetTypeMock = jest.mocked(buildCaipAssetType); const getRelayQuotesMock = jest.mocked(getRelayQuotes); const getTokenFiatRateMock = jest.mocked(getTokenFiatRate); + const getTokenInfoMock = jest.mocked(getTokenInfo); const computeRawFromFiatAmountMock = jest.mocked(computeRawFromFiatAmount); const deriveFiatAssetForFiatPaymentMock = jest.mocked( deriveFiatAssetForFiatPayment, @@ -210,11 +217,13 @@ describe('getFiatQuotes', () => { beforeEach(() => { jest.resetAllMocks(); + buildCaipAssetTypeMock.mockReturnValue(FIAT_ASSET_CAIP_ID_MOCK); deriveFiatAssetForFiatPaymentMock.mockReturnValue(FIAT_ASSET_MOCK); getTokenFiatRateMock.mockReturnValue({ fiatRate: '2', usdRate: '2', }); + getTokenInfoMock.mockReturnValue({ decimals: 18, symbol: 'POL' }); computeRawFromFiatAmountMock.mockReturnValue('5000000000000000000'); getRelayQuotesMock.mockResolvedValue([getRelayQuoteMock()]); }); @@ -242,7 +251,7 @@ describe('getFiatQuotes', () => { 'RampsController:getQuotes', expect.objectContaining({ amount: 20, - assetId: FIAT_ASSET_MOCK.caipAssetId, + assetId: FIAT_ASSET_CAIP_ID_MOCK, fiat: 'USD', paymentMethods: ['/payments/debit-credit-card'], providers: [SELECTED_PROVIDER_ID], @@ -349,8 +358,8 @@ describe('getFiatQuotes', () => { expect(getRelayQuotesMock).not.toHaveBeenCalled(); }); - it('returns empty array if fiat asset mapping is missing', async () => { - deriveFiatAssetForFiatPaymentMock.mockReturnValue(undefined); + it('returns empty array if source token fiat rate is missing', async () => { + getTokenFiatRateMock.mockReturnValue(undefined); const { request } = getRequest(); const result = await getFiatQuotes(request); @@ -359,8 +368,8 @@ describe('getFiatQuotes', () => { expect(getRelayQuotesMock).not.toHaveBeenCalled(); }); - it('returns empty array if source token fiat rate is missing', async () => { - getTokenFiatRateMock.mockReturnValue(undefined); + it('returns empty array if token info is unavailable', async () => { + getTokenInfoMock.mockReturnValue(undefined); const { request } = getRequest(); const result = await getFiatQuotes(request); diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.ts index a4184af3d8..e401447f53 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.ts @@ -11,7 +11,12 @@ import type { TransactionPayRequiredToken, TransactionPayQuote, } from '../../types'; -import { computeRawFromFiatAmount, getTokenFiatRate } from '../../utils/token'; +import { + buildCaipAssetType, + computeRawFromFiatAmount, + getTokenFiatRate, + getTokenInfo, +} from '../../utils/token'; import { getRelayQuotes } from '../relay/relay-quotes'; import type { RelayQuote } from '../relay/types'; import { DEFAULT_FIAT_CURRENCY } from './constants'; @@ -46,14 +51,9 @@ export async function getFiatQuotes( const amountFiat = transactionData?.fiatPayment?.amountFiat; const walletAddress = transaction.txParams.from as Hex; const requiredTokens = getRequiredTokens(transactionData?.tokens); - const fiatAsset = deriveFiatAssetForFiatPayment(transaction); - - if ( - !amountFiat || - !fiatPaymentMethod || - !requiredTokens.length || - !fiatAsset - ) { + const fiatAsset = deriveFiatAssetForFiatPayment(transaction, messenger); + + if (!amountFiat || !fiatPaymentMethod || !requiredTokens.length) { return []; } @@ -171,7 +171,7 @@ async function getRampsQuote({ const quotes = await messenger.call('RampsController:getQuotes', { amount: adjustedAmount, - assetId: fiatAsset.caipAssetId, + assetId: buildCaipAssetType(fiatAsset.chainId, fiatAsset.address), fiat: DEFAULT_FIAT_CURRENCY, paymentMethods: [fiatPaymentMethod], providers: selectedProviderId ? [selectedProviderId] : undefined, @@ -203,7 +203,6 @@ function buildRelayRequestFromAmountFiat({ fiatAsset: { address: Hex; chainId: Hex; - decimals: number; }; messenger: PayStrategyGetQuotesRequest['messenger']; requiredToken: TransactionPayRequiredToken; @@ -219,9 +218,19 @@ function buildRelayRequestFromAmountFiat({ return undefined; } + const tokenInfo = getTokenInfo( + messenger, + fiatAsset.address, + fiatAsset.chainId, + ); + + if (!tokenInfo) { + return undefined; + } + const sourceAmountRaw = computeRawFromFiatAmount( amountFiat, - fiatAsset.decimals, + tokenInfo.decimals, sourceFiatRate.usdRate, ); diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts index 8b94b88f2f..3316f9c537 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts @@ -14,6 +14,7 @@ import type { QuoteRequest, TransactionPayQuote, } from '../../types'; +import { buildCaipAssetType, getTokenInfo } from '../../utils/token'; import { getRelayQuotes } from '../relay/relay-quotes'; import { submitRelayQuotes } from '../relay/relay-submit'; import type { RelayQuote } from '../relay/types'; @@ -23,6 +24,7 @@ import type { FiatQuote } from './types'; import { deriveFiatAssetForFiatPayment } from './utils'; jest.mock('./utils'); +jest.mock('../../utils/token'); jest.mock('../relay/relay-quotes'); jest.mock('../relay/relay-submit'); @@ -40,9 +42,7 @@ const TRANSACTION_MOCK = { const FIAT_ASSET_MOCK: TransactionPayFiatAsset = { address: '0x0000000000000000000000000000000000001010', - caipAssetId: 'eip155:137/slip44:966', chainId: '0x89', - decimals: 18, }; const RAMPS_QUOTE_MOCK: RampsQuote = { @@ -227,7 +227,11 @@ function getRequest({ }; } +const FIAT_ASSET_CAIP_ID_MOCK = 'eip155:137/slip44:966'; + describe('submitFiatQuotes', () => { + const buildCaipAssetTypeMock = jest.mocked(buildCaipAssetType); + const getTokenInfoMock = jest.mocked(getTokenInfo); const deriveFiatAssetForFiatPaymentMock = jest.mocked( deriveFiatAssetForFiatPayment, ); @@ -238,6 +242,8 @@ describe('submitFiatQuotes', () => { jest.resetAllMocks(); jest.useRealTimers(); + buildCaipAssetTypeMock.mockReturnValue(FIAT_ASSET_CAIP_ID_MOCK); + getTokenInfoMock.mockReturnValue({ decimals: 18, symbol: 'POL' }); deriveFiatAssetForFiatPaymentMock.mockReturnValue(FIAT_ASSET_MOCK); getRelayQuotesMock.mockResolvedValue([RELAY_QUOTE_RESULT_MOCK]); submitRelayQuotesMock.mockResolvedValue({ @@ -249,7 +255,7 @@ describe('submitFiatQuotes', () => { const order = getFiatOrderMock({ cryptoAmount: '1.2345', cryptoCurrency: { - assetId: FIAT_ASSET_MOCK.caipAssetId, + assetId: FIAT_ASSET_CAIP_ID_MOCK, chainId: 'eip155:137', symbol: 'POL', }, @@ -463,12 +469,12 @@ describe('submitFiatQuotes', () => { dateNowSpy.mockRestore(); }); - it('throws if fiat asset mapping is missing', async () => { - deriveFiatAssetForFiatPaymentMock.mockReturnValue(undefined); + it('throws if token info is unavailable for the fiat asset', async () => { + getTokenInfoMock.mockReturnValue(undefined); const { request } = getRequest(); await expect(submitFiatQuotes(request)).rejects.toThrow( - 'Missing fiat asset mapping for transaction type: predictDeposit', + `Unable to resolve token info for fiat asset ${FIAT_ASSET_MOCK.address} on chain ${FIAT_ASSET_MOCK.chainId}`, ); }); @@ -483,7 +489,7 @@ describe('submitFiatQuotes', () => { }); await expect(submitFiatQuotes(request)).rejects.toThrow( - `Fiat order asset mismatch for transaction ${TRANSACTION_ID_MOCK}: expected ${FIAT_ASSET_MOCK.caipAssetId}, got eip155:137/slip44:60`, + `Fiat order asset mismatch for transaction ${TRANSACTION_ID_MOCK}: expected ${FIAT_ASSET_CAIP_ID_MOCK.toLowerCase()}, got eip155:137/slip44:60`, ); }); diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts index 94c2234428..f611e6cf19 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts @@ -14,6 +14,7 @@ import type { QuoteRequest, TransactionPayControllerMessenger, } from '../../types'; +import { buildCaipAssetType, getTokenInfo } from '../../utils/token'; import { getRelayQuotes } from '../relay/relay-quotes'; import { submitRelayQuotes } from '../relay/relay-submit'; import type { RelayQuote } from '../relay/types'; @@ -162,7 +163,10 @@ function validateOrderAsset({ transactionId: string; }): void { const orderAssetId = orderCrypto?.assetId?.toLowerCase(); - const expectedAssetId = expectedAsset.caipAssetId.toLowerCase(); + const expectedAssetId = buildCaipAssetType( + expectedAsset.chainId, + expectedAsset.address, + ).toLowerCase(); const expectedChainId = expectedAssetId.split('/')[0]; const orderChainId = orderCrypto?.chainId?.toLowerCase(); @@ -321,12 +325,7 @@ async function submitRelayAfterFiatCompletion({ throw new Error('Multiple fiat quotes are not supported for submission'); } - const fiatAsset = deriveFiatAssetForFiatPayment(transaction); - if (!fiatAsset) { - throw new Error( - `Missing fiat asset mapping for transaction type: ${String(transaction.type)}`, - ); - } + const fiatAsset = deriveFiatAssetForFiatPayment(transaction, messenger); validateOrderAsset({ expectedAsset: fiatAsset, @@ -334,9 +333,21 @@ async function submitRelayAfterFiatCompletion({ transactionId, }); + const tokenInfo = getTokenInfo( + messenger, + fiatAsset.address, + fiatAsset.chainId, + ); + + if (!tokenInfo) { + throw new Error( + `Unable to resolve token info for fiat asset ${fiatAsset.address} on chain ${fiatAsset.chainId}`, + ); + } + const sourceAmountRaw = getRawSourceAmountFromOrder({ cryptoAmount: order.cryptoAmount, - decimals: fiatAsset.decimals, + decimals: tokenInfo.decimals, }); const baseRequest = quotes[0].request; diff --git a/packages/transaction-pay-controller/src/strategy/fiat/utils.test.ts b/packages/transaction-pay-controller/src/strategy/fiat/utils.test.ts index 1175bccd56..5f91a94114 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/utils.test.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/utils.test.ts @@ -1,44 +1,169 @@ import type { TransactionMeta } from '@metamask/transaction-controller'; import { TransactionType } from '@metamask/transaction-controller'; -import { FIAT_ASSET_ID_BY_TX_TYPE } from './constants'; +import { getDefaultRemoteFeatureFlagControllerState } from '../../../../remote-feature-flag-controller/src/remote-feature-flag-controller'; +import { getMessengerMock } from '../../tests/messenger-mock'; +import { ETH_MAINNET_FIAT_ASSET, FIAT_ASSET_ID_BY_TX_TYPE } from './constants'; +import type { TransactionPayFiatAsset } from './constants'; import { deriveFiatAssetForFiatPayment } from './utils'; +const FEATURE_FLAG_ASSET_MOCK: TransactionPayFiatAsset = { + address: '0x0000000000000000000000000000000000000abc', + chainId: '0xa', +}; + describe('Fiat Utils', () => { + const { messenger, getRemoteFeatureFlagControllerStateMock } = + getMessengerMock(); + + beforeEach(() => { + jest.resetAllMocks(); + + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + }); + }); + describe('deriveFiatAssetForFiatPayment', () => { - it('returns mapped fiat asset for direct transaction type', () => { + it('returns asset from feature flag when present', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay_fiat: { + assetPerTransactionType: { + [TransactionType.predictDeposit]: FEATURE_FLAG_ASSET_MOCK, + }, + }, + }, + }); + + const transaction = { + type: TransactionType.predictDeposit, + } as TransactionMeta; + + const result = deriveFiatAssetForFiatPayment(transaction, messenger); + + expect(result).toStrictEqual(FEATURE_FLAG_ASSET_MOCK); + }); + + it('returns feature flag asset over hardcoded asset when both exist', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay_fiat: { + assetPerTransactionType: { + [TransactionType.predictDeposit]: FEATURE_FLAG_ASSET_MOCK, + }, + }, + }, + }); + const transaction = { type: TransactionType.predictDeposit, } as TransactionMeta; - const result = deriveFiatAssetForFiatPayment(transaction); + const result = deriveFiatAssetForFiatPayment(transaction, messenger); + + expect(result).toStrictEqual(FEATURE_FLAG_ASSET_MOCK); + expect(result).not.toStrictEqual( + FIAT_ASSET_ID_BY_TX_TYPE[TransactionType.predictDeposit], + ); + }); + + it('returns hardcoded asset when feature flag has no entry for the type', () => { + const transaction = { + type: TransactionType.predictDeposit, + } as TransactionMeta; + + const result = deriveFiatAssetForFiatPayment(transaction, messenger); expect(result).toStrictEqual( FIAT_ASSET_ID_BY_TX_TYPE[TransactionType.predictDeposit], ); }); - it('returns mapped fiat asset for first nested transaction in batch', () => { + it('returns hardcoded asset for direct transaction type', () => { + const transaction = { + type: TransactionType.perpsDeposit, + } as TransactionMeta; + + const result = deriveFiatAssetForFiatPayment(transaction, messenger); + + expect(result).toStrictEqual( + FIAT_ASSET_ID_BY_TX_TYPE[TransactionType.perpsDeposit], + ); + }); + + it('returns hardcoded asset for supported nested transaction in batch', () => { const transaction = { nestedTransactions: [{ type: TransactionType.perpsDeposit }], type: TransactionType.batch, } as TransactionMeta; - const result = deriveFiatAssetForFiatPayment(transaction); + const result = deriveFiatAssetForFiatPayment(transaction, messenger); expect(result).toStrictEqual( FIAT_ASSET_ID_BY_TX_TYPE[TransactionType.perpsDeposit], ); }); - it('returns undefined for unsupported type', () => { + it('skips unsupported nested types and finds supported one in batch', () => { + const transaction = { + nestedTransactions: [ + { type: TransactionType.tokenMethodApprove }, + { type: TransactionType.perpsDeposit }, + ], + type: TransactionType.batch, + } as TransactionMeta; + + const result = deriveFiatAssetForFiatPayment(transaction, messenger); + + expect(result).toStrictEqual( + FIAT_ASSET_ID_BY_TX_TYPE[TransactionType.perpsDeposit], + ); + }); + + it('returns feature flag asset for supported nested transaction in batch', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay_fiat: { + assetPerTransactionType: { + [TransactionType.perpsDeposit]: FEATURE_FLAG_ASSET_MOCK, + }, + }, + }, + }); + + const transaction = { + nestedTransactions: [{ type: TransactionType.perpsDeposit }], + type: TransactionType.batch, + } as TransactionMeta; + + const result = deriveFiatAssetForFiatPayment(transaction, messenger); + + expect(result).toStrictEqual(FEATURE_FLAG_ASSET_MOCK); + }); + + it('returns ETH mainnet fallback for unsupported type', () => { const transaction = { type: TransactionType.contractInteraction, } as TransactionMeta; - const result = deriveFiatAssetForFiatPayment(transaction); + const result = deriveFiatAssetForFiatPayment(transaction, messenger); + + expect(result).toStrictEqual(ETH_MAINNET_FIAT_ASSET); + }); + + it('returns ETH mainnet fallback for batch with no nested transactions', () => { + const transaction = { + nestedTransactions: [], + type: TransactionType.batch, + } as unknown as TransactionMeta; + + const result = deriveFiatAssetForFiatPayment(transaction, messenger); - expect(result).toBeUndefined(); + expect(result).toStrictEqual(ETH_MAINNET_FIAT_ASSET); }); }); }); diff --git a/packages/transaction-pay-controller/src/strategy/fiat/utils.ts b/packages/transaction-pay-controller/src/strategy/fiat/utils.ts index 6894127b13..8759473531 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/utils.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/utils.ts @@ -1,21 +1,28 @@ -import { - TransactionMeta, - TransactionType, -} from '@metamask/transaction-controller'; +import type { TransactionMeta } from '@metamask/transaction-controller'; +import { TransactionType } from '@metamask/transaction-controller'; -import { FIAT_ASSET_ID_BY_TX_TYPE, TransactionPayFiatAsset } from './constants'; +import type { TransactionPayControllerMessenger } from '../../types'; +import { getFiatAssetPerTransactionType } from '../../utils/feature-flags'; +import type { TransactionPayFiatAsset } from './constants'; +import { FIAT_ASSET_ID_BY_TX_TYPE } from './constants'; export function deriveFiatAssetForFiatPayment( transaction: TransactionMeta, -): TransactionPayFiatAsset | undefined { - const transactionType = transaction?.type; + messenger: TransactionPayControllerMessenger, +): TransactionPayFiatAsset { + const txType = resolveTransactionType(transaction); - if (transactionType === TransactionType.batch) { - const firstMatchingType = transaction.nestedTransactions?.[0]?.type; - if (firstMatchingType) { - return FIAT_ASSET_ID_BY_TX_TYPE[firstMatchingType]; - } + return getFiatAssetPerTransactionType(messenger, txType); +} + +function resolveTransactionType( + transaction: TransactionMeta, +): TransactionType | undefined { + if (transaction.type !== TransactionType.batch) { + return transaction.type; } - return FIAT_ASSET_ID_BY_TX_TYPE[transactionType as TransactionType]; + return transaction.nestedTransactions?.find( + (tx) => tx.type && FIAT_ASSET_ID_BY_TX_TYPE[tx.type] !== undefined, + )?.type; } diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.test.ts b/packages/transaction-pay-controller/src/utils/feature-flags.test.ts index 6e16c174cb..c3f51b2e3e 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.test.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.test.ts @@ -1,7 +1,9 @@ +import { TransactionType } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { getDefaultRemoteFeatureFlagControllerState } from '../../../remote-feature-flag-controller/src/remote-feature-flag-controller'; import { TransactionPayStrategy } from '../constants'; +import type { TransactionPayFiatAsset } from '../strategy/fiat/constants'; import { getMessengerMock } from '../tests/messenger-mock'; import { DEFAULT_ACROSS_API_BASE, @@ -13,6 +15,7 @@ import { DEFAULT_SLIPPAGE, getAssetsUnifyStateFeature, getFallbackGas, + getFiatAssetPerTransactionType, DEFAULT_RELAY_EXECUTE_URL, getRelayOriginGasOverhead, getRelayPollingInterval, @@ -1168,4 +1171,105 @@ describe('Feature Flags Utils', () => { expect(getStrategy(messenger)).toBeUndefined(); }); }); + + describe('getFiatAssetPerTransactionType', () => { + const FIAT_ASSET_MOCK: TransactionPayFiatAsset = { + address: '0x0000000000000000000000000000000000001010', + chainId: '0x89', + }; + + it('returns ETH mainnet fallback when confirmations_pay_fiat flag is absent', () => { + const result = getFiatAssetPerTransactionType( + messenger, + TransactionType.contractInteraction, + ); + + expect(result).toStrictEqual({ + address: '0x0000000000000000000000000000000000000000', + chainId: '0x1', + }); + }); + + it('returns hardcoded asset when flag exists but has no entry for the transaction type', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay_fiat: { + assetPerTransactionType: { + [TransactionType.perpsDeposit]: FIAT_ASSET_MOCK, + }, + }, + }, + }); + + const result = getFiatAssetPerTransactionType( + messenger, + TransactionType.predictDeposit, + ); + + expect(result).toStrictEqual({ + address: '0x0000000000000000000000000000000000001010', + chainId: '0x89', + }); + }); + + it('returns feature flag asset when entry matches the transaction type', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay_fiat: { + assetPerTransactionType: { + [TransactionType.predictDeposit]: FIAT_ASSET_MOCK, + }, + }, + }, + }); + + const result = getFiatAssetPerTransactionType( + messenger, + TransactionType.predictDeposit, + ); + + expect(result).toStrictEqual(FIAT_ASSET_MOCK); + }); + + it('returns ETH mainnet fallback when assetPerTransactionType is not defined', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay_fiat: {}, + }, + }); + + const result = getFiatAssetPerTransactionType( + messenger, + TransactionType.contractInteraction, + ); + + expect(result).toStrictEqual({ + address: '0x0000000000000000000000000000000000000000', + chainId: '0x1', + }); + }); + + it('prefers feature flag over hardcoded asset', () => { + getRemoteFeatureFlagControllerStateMock.mockReturnValue({ + ...getDefaultRemoteFeatureFlagControllerState(), + remoteFeatureFlags: { + confirmations_pay_fiat: { + assetPerTransactionType: { + [TransactionType.predictDeposit]: FIAT_ASSET_MOCK, + }, + }, + }, + }); + + const result = getFiatAssetPerTransactionType( + messenger, + TransactionType.predictDeposit, + ); + + expect(result).toStrictEqual(FIAT_ASSET_MOCK); + }); + }); }); diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.ts b/packages/transaction-pay-controller/src/utils/feature-flags.ts index 911c5bc605..571722a860 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.ts @@ -1,9 +1,15 @@ +import type { TransactionType } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; import { uniq } from 'lodash'; import { isTransactionPayStrategy, TransactionPayStrategy } from '../constants'; import { projectLogger } from '../logger'; +import type { TransactionPayFiatAsset } from '../strategy/fiat/constants'; +import { + ETH_MAINNET_FIAT_ASSET, + FIAT_ASSET_ID_BY_TX_TYPE, +} from '../strategy/fiat/constants'; import { RELAY_EXECUTE_URL, RELAY_POLLING_INTERVAL, @@ -75,6 +81,12 @@ type StrategyOverrides = { transactionTypes: Record; }; +type FiatFlags = { + assetPerTransactionType?: Partial< + Record + >; +}; + type StrategyRoutingConfig = { payStrategies: { across: { @@ -661,6 +673,38 @@ function getCaseInsensitive( return entry?.[1]; } +/** + * Get the fiat asset for a specific transaction type. + * + * Resolution order: + * 1. Feature flag override (`confirmations_pay_fiat.assetPerTransactionType`) + * 2. Hardcoded constant (`FIAT_ASSET_ID_BY_TX_TYPE`) + * 3. ETH mainnet fallback + * + * @param messenger - Controller messenger. + * @param transactionType - Transaction type to look up. + * @returns The fiat asset for the given transaction type. + */ +export function getFiatAssetPerTransactionType( + messenger: TransactionPayControllerMessenger, + transactionType?: TransactionType, +): TransactionPayFiatAsset { + if (!transactionType) { + return ETH_MAINNET_FIAT_ASSET; + } + + const state = messenger.call('RemoteFeatureFlagController:getState'); + const fiatFlags = state.remoteFeatureFlags?.confirmations_pay_fiat as + | FiatFlags + | undefined; + + return ( + fiatFlags?.assetPerTransactionType?.[transactionType] ?? + FIAT_ASSET_ID_BY_TX_TYPE[transactionType] ?? + ETH_MAINNET_FIAT_ASSET + ); +} + /** * Checks if a chain supports EIP-7702. * diff --git a/packages/transaction-pay-controller/src/utils/token.test.ts b/packages/transaction-pay-controller/src/utils/token.test.ts index 0e5cdc9343..bb129d43b8 100644 --- a/packages/transaction-pay-controller/src/utils/token.test.ts +++ b/packages/transaction-pay-controller/src/utils/token.test.ts @@ -13,6 +13,7 @@ import { } from '../constants'; import { getMessengerMock } from '../tests/messenger-mock'; import { + buildCaipAssetType, computeRawFromFiatAmount, computeTokenAmounts, getTokenBalance, @@ -838,4 +839,42 @@ describe('Token Utils', () => { expect(isSameToken(token1, token2)).toBe(false); }); }); + + describe('buildCaipAssetType', () => { + it('returns slip44 asset type for native token on mainnet', () => { + expect(buildCaipAssetType('0x1' as Hex, NATIVE_TOKEN_ADDRESS)).toBe( + 'eip155:1/slip44:60', + ); + }); + + it('returns slip44 asset type for Polygon native token with auto-mapped coin type', () => { + const polygonNative = '0x0000000000000000000000000000000000001010' as Hex; + + expect(buildCaipAssetType('0x89' as Hex, polygonNative)).toBe( + 'eip155:137/slip44:966', + ); + }); + + it('returns slip44 asset type with explicit coin type override', () => { + const polygonNative = '0x0000000000000000000000000000000000001010' as Hex; + + expect(buildCaipAssetType('0x89' as Hex, polygonNative, 966)).toBe( + 'eip155:137/slip44:966', + ); + }); + + it('returns erc20 asset type for ERC-20 token', () => { + const usdcAddress = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Hex; + + expect(buildCaipAssetType('0x1' as Hex, usdcAddress)).toBe( + `eip155:1/erc20:${usdcAddress}`, + ); + }); + + it('defaults slip44CoinType to 60 for native tokens', () => { + expect(buildCaipAssetType('0xa4b1' as Hex, NATIVE_TOKEN_ADDRESS)).toBe( + 'eip155:42161/slip44:60', + ); + }); + }); }); diff --git a/packages/transaction-pay-controller/src/utils/token.ts b/packages/transaction-pay-controller/src/utils/token.ts index a0a2c40b96..b7d4fde67c 100644 --- a/packages/transaction-pay-controller/src/utils/token.ts +++ b/packages/transaction-pay-controller/src/utils/token.ts @@ -3,12 +3,14 @@ import { Web3Provider } from '@ethersproject/providers'; import { TokensControllerState } from '@metamask/assets-controllers'; import { toChecksumHexAddress } from '@metamask/controller-utils'; import { abiERC20 } from '@metamask/metamask-eth-abis'; -import type { Hex } from '@metamask/utils'; +import type { CaipAssetType, Hex } from '@metamask/utils'; +import { hexToBigInt, toCaipAssetType } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; import { CHAIN_ID_POLYGON, NATIVE_TOKEN_ADDRESS, + SLIP44_COIN_TYPE_BY_CHAIN, STABLECOINS, } from '../constants'; import type { FiatRates, TransactionPayControllerMessenger } from '../types'; @@ -340,6 +342,41 @@ export async function getLiveTokenBalance( return balance.toString(); } +/** + * Build a CAIP-19 asset type identifier for an EVM token. + * + * For native tokens the SLIP-44 coin type is resolved automatically from + * a built-in chain→coin-type map, falling back to 60 (ETH). Callers can + * override via the optional third parameter. + * + * @param chainId - Hex chain ID (e.g. `0x1`). + * @param tokenAddress - Token contract address, or the native token address. + * @param slip44CoinType - Optional SLIP-44 coin type override for native tokens. + * @returns CAIP-19 asset type string. + */ +export function buildCaipAssetType( + chainId: Hex, + tokenAddress: Hex, + slip44CoinType?: number, +): CaipAssetType { + const chainReference = String(hexToBigInt(chainId)); + const isNative = + tokenAddress.toLowerCase() === getNativeToken(chainId).toLowerCase(); + + if (isNative) { + const coinType = slip44CoinType ?? SLIP44_COIN_TYPE_BY_CHAIN[chainId] ?? 60; + + return toCaipAssetType( + 'eip155', + chainReference, + 'slip44', + String(coinType), + ); + } + + return toCaipAssetType('eip155', chainReference, 'erc20', tokenAddress); +} + function getTicker( chainId: Hex, messenger: TransactionPayControllerMessenger,