Skip to content

Commit c7dd040

Browse files
authored
feat: Derive fiat asset from feature flags before hardcoded fallback (MetaMask#8631)
## Explanation Currently `deriveFiatAssetForFiatPayment` resolves the fiat asset for a payment entirely from a hardcoded map (`FIAT_ASSET_ID_BY_TX_TYPE`). This makes it impossible to adjust asset mappings without a code release. This PR introduces a 3-tier resolution for fiat assets: 1. **Feature flag** - reads from `confirmations_pay_fiat.assetPerTransactionType[txType]` via `RemoteFeatureFlagController` 2. **Hardcoded map** - falls back to the existing `FIAT_ASSET_ID_BY_TX_TYPE` constant 3. **ETH on mainnet** - terminal fallback when neither source has an entry The function signature gains a `messenger` parameter (already available at the call site in `fiat-quotes.ts`), and the return type tightens from `TransactionPayFiatAsset | undefined` to `TransactionPayFiatAsset` since the ETH mainnet fallback guarantees a value. ## References - Feature flag key: `confirmations_pay_fiat` ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Medium risk because it changes fiat on-ramp asset resolution and CAIP asset identifiers used for quote fetching and order validation, which can affect MM Pay fiat quote/submit flows across transaction types. > > **Overview** > **Fiat-asset selection is now remotely configurable.** Fiat strategy resolves the source fiat asset per transaction type from the `confirmations_pay_fiat.assetPerTransactionType` remote feature flag, falls back to the existing hardcoded map, and finally defaults to *mainnet ETH*. > > **CAIP asset identifiers are now derived, not stored.** `TransactionPayFiatAsset` no longer carries `caipAssetId`/`decimals`; call sites now build CAIP-19 via new `buildCaipAssetType(chainId, address)` and fetch decimals via `getTokenInfo`, updating ramps token selection, quote requests, and fiat order validation accordingly, with tests updated to cover the new resolution and fallbacks. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 15f1c3d. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 374c0ed commit c7dd040

15 files changed

Lines changed: 470 additions & 69 deletions

packages/transaction-pay-controller/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
12+
- 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))
13+
1014
## [22.1.0]
1115

1216
### Fixed

packages/transaction-pay-controller/src/TransactionPayController.test.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -710,9 +710,7 @@ describe('TransactionPayController', () => {
710710
const CAIP_ASSET_ID_MOCK = 'eip155:137/slip44:966';
711711
const FIAT_ASSET_MOCK = {
712712
address: '0x0000000000000000000000000000000000001010' as Hex,
713-
caipAssetId: CAIP_ASSET_ID_MOCK,
714713
chainId: '0x89' as Hex,
715-
decimals: 18,
716714
};
717715

718716
let setSelectedTokenMock: jest.Mock;
@@ -788,7 +786,7 @@ describe('TransactionPayController', () => {
788786

789787
it('does not call setSelectedToken when fiat asset cannot be derived', () => {
790788
getTransactionMock.mockReturnValue(TRANSACTION_META_MOCK);
791-
deriveFiatAssetForFiatPaymentMock.mockReturnValue(undefined);
789+
deriveFiatAssetForFiatPaymentMock.mockReturnValue(undefined as never);
792790

793791
const updateTransactionData = getUpdateTransactionData();
794792

packages/transaction-pay-controller/src/TransactionPayController.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import type {
2626
import { getStrategyOrder } from './utils/feature-flags';
2727
import { updateQuotes } from './utils/quotes';
2828
import { updateSourceAmounts } from './utils/source-amounts';
29+
import { buildCaipAssetType } from './utils/token';
2930
import {
3031
getTransaction,
3132
subscribeAssetChanges,
@@ -294,12 +295,15 @@ export class TransactionPayController extends BaseController<
294295
transactionId,
295296
this.messenger,
296297
) as TransactionMeta;
297-
const fiatAsset = deriveFiatAssetForFiatPayment(transaction);
298+
const fiatAsset = deriveFiatAssetForFiatPayment(
299+
transaction,
300+
this.messenger,
301+
);
298302
if (fiatAsset) {
299303
try {
300304
this.messenger.call(
301305
'RampsController:setSelectedToken',
302-
fiatAsset.caipAssetId,
306+
buildCaipAssetType(fiatAsset.chainId, fiatAsset.address),
303307
);
304308
} catch {
305309
// Intentionally no-op — tokens may not be loaded in RampsController yet.

packages/transaction-pay-controller/src/constants.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Hex } from '@metamask/utils';
22

33
export const CONTROLLER_NAME = 'TransactionPayController';
44
export const CHAIN_ID_ARBITRUM = '0xa4b1' as Hex;
5+
export const CHAIN_ID_MAINNET = '0x1' as Hex;
56
export const CHAIN_ID_POLYGON = '0x89' as Hex;
67
export const CHAIN_ID_HYPERCORE = '0x539' as Hex;
78

@@ -19,6 +20,10 @@ export const HYPERCORE_USDC_ADDRESS = '0x00000000000000000000000000000000';
1920
export const HYPERCORE_USDC_DECIMALS = 8;
2021
export const USDC_DECIMALS = 6;
2122

23+
export const SLIP44_COIN_TYPE_BY_CHAIN: Record<Hex, number> = {
24+
[CHAIN_ID_POLYGON]: 966, // POL
25+
};
26+
2227
export const STABLECOINS: Record<Hex, Hex[]> = {
2328
// Mainnet
2429
'0x1': [

packages/transaction-pay-controller/src/strategy/fiat/constants.ts

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { Hex } from '@metamask/utils';
33

44
import {
55
CHAIN_ID_ARBITRUM,
6+
CHAIN_ID_MAINNET,
67
CHAIN_ID_POLYGON,
78
NATIVE_TOKEN_ADDRESS,
89
} from '../../constants';
@@ -11,26 +12,24 @@ export const DEFAULT_FIAT_CURRENCY = 'USD';
1112

1213
export type TransactionPayFiatAsset = {
1314
address: Hex;
14-
caipAssetId: string;
1515
chainId: Hex;
16-
decimals: number;
1716
};
1817

1918
const POLYGON_POL_FIAT_ASSET: TransactionPayFiatAsset = {
2019
address: '0x0000000000000000000000000000000000001010',
21-
caipAssetId: 'eip155:137/slip44:966',
2220
chainId: CHAIN_ID_POLYGON,
23-
decimals: 18,
2421
};
2522

2623
const ARBITRUM_ETH_FIAT_ASSET: TransactionPayFiatAsset = {
2724
address: NATIVE_TOKEN_ADDRESS,
28-
caipAssetId: 'eip155:42161/slip44:60',
2925
chainId: CHAIN_ID_ARBITRUM,
30-
decimals: 18,
3126
};
3227

33-
// We might use feature flags to determine these later.
28+
export const ETH_MAINNET_FIAT_ASSET: TransactionPayFiatAsset = {
29+
address: NATIVE_TOKEN_ADDRESS,
30+
chainId: CHAIN_ID_MAINNET,
31+
};
32+
3433
export const FIAT_ASSET_ID_BY_TX_TYPE: Partial<
3534
Record<TransactionType, TransactionPayFiatAsset>
3635
> = {

packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.test.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,12 @@ import type {
1313
TransactionPayQuote,
1414
TransactionPayRequiredToken,
1515
} from '../../types';
16-
import { computeRawFromFiatAmount, getTokenFiatRate } from '../../utils/token';
16+
import {
17+
buildCaipAssetType,
18+
computeRawFromFiatAmount,
19+
getTokenFiatRate,
20+
getTokenInfo,
21+
} from '../../utils/token';
1722
import { getRelayQuotes } from '../relay/relay-quotes';
1823
import type { RelayQuote } from '../relay/types';
1924
import type { TransactionPayFiatAsset } from './constants';
@@ -52,9 +57,7 @@ const REQUIRED_TOKEN_MOCK: TransactionPayRequiredToken = {
5257

5358
const FIAT_ASSET_MOCK: TransactionPayFiatAsset = {
5459
address: '0x0000000000000000000000000000000000001010',
55-
caipAssetId: 'eip155:137/slip44:966',
5660
chainId: '0x89',
57-
decimals: 18,
5861
};
5962

6063
const FIAT_QUOTE_MOCK: RampsQuote = {
@@ -199,9 +202,13 @@ function getRequest({
199202
};
200203
}
201204

205+
const FIAT_ASSET_CAIP_ID_MOCK = 'eip155:137/slip44:966';
206+
202207
describe('getFiatQuotes', () => {
208+
const buildCaipAssetTypeMock = jest.mocked(buildCaipAssetType);
203209
const getRelayQuotesMock = jest.mocked(getRelayQuotes);
204210
const getTokenFiatRateMock = jest.mocked(getTokenFiatRate);
211+
const getTokenInfoMock = jest.mocked(getTokenInfo);
205212
const computeRawFromFiatAmountMock = jest.mocked(computeRawFromFiatAmount);
206213
const deriveFiatAssetForFiatPaymentMock = jest.mocked(
207214
deriveFiatAssetForFiatPayment,
@@ -210,11 +217,13 @@ describe('getFiatQuotes', () => {
210217
beforeEach(() => {
211218
jest.resetAllMocks();
212219

220+
buildCaipAssetTypeMock.mockReturnValue(FIAT_ASSET_CAIP_ID_MOCK);
213221
deriveFiatAssetForFiatPaymentMock.mockReturnValue(FIAT_ASSET_MOCK);
214222
getTokenFiatRateMock.mockReturnValue({
215223
fiatRate: '2',
216224
usdRate: '2',
217225
});
226+
getTokenInfoMock.mockReturnValue({ decimals: 18, symbol: 'POL' });
218227
computeRawFromFiatAmountMock.mockReturnValue('5000000000000000000');
219228
getRelayQuotesMock.mockResolvedValue([getRelayQuoteMock()]);
220229
});
@@ -242,7 +251,7 @@ describe('getFiatQuotes', () => {
242251
'RampsController:getQuotes',
243252
expect.objectContaining({
244253
amount: 20,
245-
assetId: FIAT_ASSET_MOCK.caipAssetId,
254+
assetId: FIAT_ASSET_CAIP_ID_MOCK,
246255
fiat: 'USD',
247256
paymentMethods: ['/payments/debit-credit-card'],
248257
providers: [SELECTED_PROVIDER_ID],
@@ -349,8 +358,8 @@ describe('getFiatQuotes', () => {
349358
expect(getRelayQuotesMock).not.toHaveBeenCalled();
350359
});
351360

352-
it('returns empty array if fiat asset mapping is missing', async () => {
353-
deriveFiatAssetForFiatPaymentMock.mockReturnValue(undefined);
361+
it('returns empty array if source token fiat rate is missing', async () => {
362+
getTokenFiatRateMock.mockReturnValue(undefined);
354363
const { request } = getRequest();
355364

356365
const result = await getFiatQuotes(request);
@@ -359,8 +368,8 @@ describe('getFiatQuotes', () => {
359368
expect(getRelayQuotesMock).not.toHaveBeenCalled();
360369
});
361370

362-
it('returns empty array if source token fiat rate is missing', async () => {
363-
getTokenFiatRateMock.mockReturnValue(undefined);
371+
it('returns empty array if token info is unavailable', async () => {
372+
getTokenInfoMock.mockReturnValue(undefined);
364373
const { request } = getRequest();
365374

366375
const result = await getFiatQuotes(request);

packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.ts

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,12 @@ import type {
1111
TransactionPayRequiredToken,
1212
TransactionPayQuote,
1313
} from '../../types';
14-
import { computeRawFromFiatAmount, getTokenFiatRate } from '../../utils/token';
14+
import {
15+
buildCaipAssetType,
16+
computeRawFromFiatAmount,
17+
getTokenFiatRate,
18+
getTokenInfo,
19+
} from '../../utils/token';
1520
import { getRelayQuotes } from '../relay/relay-quotes';
1621
import type { RelayQuote } from '../relay/types';
1722
import { DEFAULT_FIAT_CURRENCY } from './constants';
@@ -46,14 +51,9 @@ export async function getFiatQuotes(
4651
const amountFiat = transactionData?.fiatPayment?.amountFiat;
4752
const walletAddress = transaction.txParams.from as Hex;
4853
const requiredTokens = getRequiredTokens(transactionData?.tokens);
49-
const fiatAsset = deriveFiatAssetForFiatPayment(transaction);
50-
51-
if (
52-
!amountFiat ||
53-
!fiatPaymentMethod ||
54-
!requiredTokens.length ||
55-
!fiatAsset
56-
) {
54+
const fiatAsset = deriveFiatAssetForFiatPayment(transaction, messenger);
55+
56+
if (!amountFiat || !fiatPaymentMethod || !requiredTokens.length) {
5757
return [];
5858
}
5959

@@ -171,7 +171,7 @@ async function getRampsQuote({
171171

172172
const quotes = await messenger.call('RampsController:getQuotes', {
173173
amount: adjustedAmount,
174-
assetId: fiatAsset.caipAssetId,
174+
assetId: buildCaipAssetType(fiatAsset.chainId, fiatAsset.address),
175175
fiat: DEFAULT_FIAT_CURRENCY,
176176
paymentMethods: [fiatPaymentMethod],
177177
providers: selectedProviderId ? [selectedProviderId] : undefined,
@@ -203,7 +203,6 @@ function buildRelayRequestFromAmountFiat({
203203
fiatAsset: {
204204
address: Hex;
205205
chainId: Hex;
206-
decimals: number;
207206
};
208207
messenger: PayStrategyGetQuotesRequest['messenger'];
209208
requiredToken: TransactionPayRequiredToken;
@@ -219,9 +218,19 @@ function buildRelayRequestFromAmountFiat({
219218
return undefined;
220219
}
221220

221+
const tokenInfo = getTokenInfo(
222+
messenger,
223+
fiatAsset.address,
224+
fiatAsset.chainId,
225+
);
226+
227+
if (!tokenInfo) {
228+
return undefined;
229+
}
230+
222231
const sourceAmountRaw = computeRawFromFiatAmount(
223232
amountFiat,
224-
fiatAsset.decimals,
233+
tokenInfo.decimals,
225234
sourceFiatRate.usdRate,
226235
);
227236

packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
QuoteRequest,
1515
TransactionPayQuote,
1616
} from '../../types';
17+
import { buildCaipAssetType, getTokenInfo } from '../../utils/token';
1718
import { getRelayQuotes } from '../relay/relay-quotes';
1819
import { submitRelayQuotes } from '../relay/relay-submit';
1920
import type { RelayQuote } from '../relay/types';
@@ -23,6 +24,7 @@ import type { FiatQuote } from './types';
2324
import { deriveFiatAssetForFiatPayment } from './utils';
2425

2526
jest.mock('./utils');
27+
jest.mock('../../utils/token');
2628
jest.mock('../relay/relay-quotes');
2729
jest.mock('../relay/relay-submit');
2830

@@ -40,9 +42,7 @@ const TRANSACTION_MOCK = {
4042

4143
const FIAT_ASSET_MOCK: TransactionPayFiatAsset = {
4244
address: '0x0000000000000000000000000000000000001010',
43-
caipAssetId: 'eip155:137/slip44:966',
4445
chainId: '0x89',
45-
decimals: 18,
4646
};
4747

4848
const RAMPS_QUOTE_MOCK: RampsQuote = {
@@ -227,7 +227,11 @@ function getRequest({
227227
};
228228
}
229229

230+
const FIAT_ASSET_CAIP_ID_MOCK = 'eip155:137/slip44:966';
231+
230232
describe('submitFiatQuotes', () => {
233+
const buildCaipAssetTypeMock = jest.mocked(buildCaipAssetType);
234+
const getTokenInfoMock = jest.mocked(getTokenInfo);
231235
const deriveFiatAssetForFiatPaymentMock = jest.mocked(
232236
deriveFiatAssetForFiatPayment,
233237
);
@@ -238,6 +242,8 @@ describe('submitFiatQuotes', () => {
238242
jest.resetAllMocks();
239243
jest.useRealTimers();
240244

245+
buildCaipAssetTypeMock.mockReturnValue(FIAT_ASSET_CAIP_ID_MOCK);
246+
getTokenInfoMock.mockReturnValue({ decimals: 18, symbol: 'POL' });
241247
deriveFiatAssetForFiatPaymentMock.mockReturnValue(FIAT_ASSET_MOCK);
242248
getRelayQuotesMock.mockResolvedValue([RELAY_QUOTE_RESULT_MOCK]);
243249
submitRelayQuotesMock.mockResolvedValue({
@@ -249,7 +255,7 @@ describe('submitFiatQuotes', () => {
249255
const order = getFiatOrderMock({
250256
cryptoAmount: '1.2345',
251257
cryptoCurrency: {
252-
assetId: FIAT_ASSET_MOCK.caipAssetId,
258+
assetId: FIAT_ASSET_CAIP_ID_MOCK,
253259
chainId: 'eip155:137',
254260
symbol: 'POL',
255261
},
@@ -463,12 +469,12 @@ describe('submitFiatQuotes', () => {
463469
dateNowSpy.mockRestore();
464470
});
465471

466-
it('throws if fiat asset mapping is missing', async () => {
467-
deriveFiatAssetForFiatPaymentMock.mockReturnValue(undefined);
472+
it('throws if token info is unavailable for the fiat asset', async () => {
473+
getTokenInfoMock.mockReturnValue(undefined);
468474
const { request } = getRequest();
469475

470476
await expect(submitFiatQuotes(request)).rejects.toThrow(
471-
'Missing fiat asset mapping for transaction type: predictDeposit',
477+
`Unable to resolve token info for fiat asset ${FIAT_ASSET_MOCK.address} on chain ${FIAT_ASSET_MOCK.chainId}`,
472478
);
473479
});
474480

@@ -483,7 +489,7 @@ describe('submitFiatQuotes', () => {
483489
});
484490

485491
await expect(submitFiatQuotes(request)).rejects.toThrow(
486-
`Fiat order asset mismatch for transaction ${TRANSACTION_ID_MOCK}: expected ${FIAT_ASSET_MOCK.caipAssetId}, got eip155:137/slip44:60`,
492+
`Fiat order asset mismatch for transaction ${TRANSACTION_ID_MOCK}: expected ${FIAT_ASSET_CAIP_ID_MOCK.toLowerCase()}, got eip155:137/slip44:60`,
487493
);
488494
});
489495

0 commit comments

Comments
 (0)