From 23e8baba6ce49eed1c5d7491a9bd72ad798d5340 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Thu, 5 Mar 2026 15:53:38 +0100 Subject: [PATCH] Implement FiatStrategy and getQuotes functionality --- .../transaction-pay-controller/CHANGELOG.md | 4 + .../transaction-pay-controller/package.json | 1 + .../src/constants.ts | 32 ++ .../src/strategy/fiat/FiatStrategy.test.ts | 53 +++ .../src/strategy/fiat/FiatStrategy.ts | 23 + .../src/strategy/fiat/fiat-quotes.test.ts | 409 ++++++++++++++++++ .../src/strategy/fiat/fiat-quotes.ts | 294 +++++++++++++ .../src/strategy/fiat/fiat-submit.test.ts | 19 + .../src/strategy/fiat/fiat-submit.ts | 14 + .../src/strategy/fiat/types.ts | 8 + .../transaction-pay-controller/src/types.ts | 20 + .../src/utils/fiat.test.ts | 87 ++++ .../src/utils/fiat.ts | 37 ++ .../src/utils/quotes.test.ts | 29 ++ .../src/utils/quotes.ts | 7 +- .../src/utils/strategy.test.ts | 11 +- .../src/utils/strategy.ts | 4 + .../src/utils/totals.ts | 6 + .../tsconfig.build.json | 3 + .../transaction-pay-controller/tsconfig.json | 3 + 20 files changed, 1062 insertions(+), 2 deletions(-) create mode 100644 packages/transaction-pay-controller/src/strategy/fiat/FiatStrategy.test.ts create mode 100644 packages/transaction-pay-controller/src/strategy/fiat/FiatStrategy.ts create mode 100644 packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.test.ts create mode 100644 packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.ts create mode 100644 packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts create mode 100644 packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts create mode 100644 packages/transaction-pay-controller/src/strategy/fiat/types.ts create mode 100644 packages/transaction-pay-controller/src/utils/fiat.test.ts create mode 100644 packages/transaction-pay-controller/src/utils/fiat.ts diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 6ad60fd01fc..7577af2b1d9 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add MMPay `FiatStrategy` quotes flow ([#8121](https://github.com/MetaMask/core/pull/8121)) + ## [16.3.0] ### Added diff --git a/packages/transaction-pay-controller/package.json b/packages/transaction-pay-controller/package.json index 213233a53c9..c3d027d1e14 100644 --- a/packages/transaction-pay-controller/package.json +++ b/packages/transaction-pay-controller/package.json @@ -60,6 +60,7 @@ "@metamask/messenger": "^0.3.0", "@metamask/metamask-eth-abis": "^3.1.1", "@metamask/network-controller": "^30.0.0", + "@metamask/ramps-controller": "^10.2.0", "@metamask/remote-feature-flag-controller": "^4.1.0", "@metamask/transaction-controller": "^62.20.0", "@metamask/utils": "^11.9.0", diff --git a/packages/transaction-pay-controller/src/constants.ts b/packages/transaction-pay-controller/src/constants.ts index 73dd4540621..6628f375679 100644 --- a/packages/transaction-pay-controller/src/constants.ts +++ b/packages/transaction-pay-controller/src/constants.ts @@ -1,3 +1,4 @@ +import { TransactionType } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; export const CONTROLLER_NAME = 'TransactionPayController'; @@ -14,6 +15,36 @@ export const ARBITRUM_USDC_ADDRESS = export const POLYGON_USDCE_ADDRESS = '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174' as Hex; +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 MMPAY_FIAT_ASSET_ID_BY_TX_TYPE: Partial< + Record +> = { + [TransactionType.predictDeposit]: POLYGON_POL_FIAT_ASSET, + [TransactionType.perpsDeposit]: ARBITRUM_ETH_FIAT_ASSET, + [TransactionType.perpsDepositAndOrder]: ARBITRUM_ETH_FIAT_ASSET, +}; + export const STABLECOINS: Record = { // Mainnet '0x1': [ @@ -34,6 +65,7 @@ export const STABLECOINS: Record = { export enum TransactionPayStrategy { Bridge = 'bridge', + Fiat = 'fiat', Relay = 'relay', Test = 'test', } diff --git a/packages/transaction-pay-controller/src/strategy/fiat/FiatStrategy.test.ts b/packages/transaction-pay-controller/src/strategy/fiat/FiatStrategy.test.ts new file mode 100644 index 00000000000..aa6577f7aac --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/fiat/FiatStrategy.test.ts @@ -0,0 +1,53 @@ +import type { TransactionMeta } from '@metamask/transaction-controller'; + +import { getFiatQuotes } from './fiat-quotes'; +import { submitFiatQuotes } from './fiat-submit'; +import { FiatStrategy } from './FiatStrategy'; +import type { FiatOriginalQuote } from './types'; +import type { TransactionPayControllerMessenger } from '../..'; +import type { TransactionPayQuote } from '../../types'; + +jest.mock('./fiat-quotes'); +jest.mock('./fiat-submit'); + +const QUOTE_MOCK = { + estimatedDuration: 5, +} as TransactionPayQuote; + +describe('FiatStrategy', () => { + const getFiatQuotesMock = jest.mocked(getFiatQuotes); + const submitFiatQuotesMock = jest.mocked(submitFiatQuotes); + + beforeEach(() => { + jest.resetAllMocks(); + getFiatQuotesMock.mockResolvedValue([QUOTE_MOCK]); + }); + + describe('getQuotes', () => { + it('returns result from util', async () => { + const result = new FiatStrategy().getQuotes({ + messenger: {} as TransactionPayControllerMessenger, + requests: [], + transaction: {} as TransactionMeta, + }); + + expect(await result).toStrictEqual([QUOTE_MOCK]); + }); + }); + + describe('execute', () => { + it('calls util', async () => { + await new FiatStrategy().execute({ + isSmartTransaction: () => false, + quotes: [QUOTE_MOCK], + messenger: {} as TransactionPayControllerMessenger, + transaction: { txParams: { from: '0x1' } } as TransactionMeta, + }); + + expect(submitFiatQuotesMock).toHaveBeenCalledTimes(1); + expect( + submitFiatQuotesMock.mock.calls[0][0].transaction.txParams.from, + ).toBe('0x1'); + }); + }); +}); diff --git a/packages/transaction-pay-controller/src/strategy/fiat/FiatStrategy.ts b/packages/transaction-pay-controller/src/strategy/fiat/FiatStrategy.ts new file mode 100644 index 00000000000..a77c555656c --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/fiat/FiatStrategy.ts @@ -0,0 +1,23 @@ +import { getFiatQuotes } from './fiat-quotes'; +import { submitFiatQuotes } from './fiat-submit'; +import type { FiatOriginalQuote } from './types'; +import type { + PayStrategy, + PayStrategyExecuteRequest, + PayStrategyGetQuotesRequest, + TransactionPayQuote, +} from '../../types'; + +export class FiatStrategy implements PayStrategy { + async getQuotes( + request: PayStrategyGetQuotesRequest, + ): Promise[]> { + return getFiatQuotes(request); + } + + async execute( + request: PayStrategyExecuteRequest, + ): ReturnType['execute']> { + return await submitFiatQuotes(request); + } +} 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 new file mode 100644 index 00000000000..ef11d4b459a --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.test.ts @@ -0,0 +1,409 @@ +import type { + Quote as RampsQuote, + QuotesResponse as RampsQuotesResponse, +} from '@metamask/ramps-controller'; +import type { TransactionMeta } from '@metamask/transaction-controller'; +import { TransactionType } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; + +import { getFiatQuotes } from './fiat-quotes'; +import type { TransactionPayFiatAsset } from '../../constants'; +import { TransactionPayStrategy } from '../../constants'; +import type { + PayStrategyGetQuotesRequest, + TransactionPayQuote, + TransactionPayRequiredToken, +} from '../../types'; +import { + deriveFiatAssetForFiatPayment, + pickBestFiatQuote, +} from '../../utils/fiat'; +import { getTokenFiatRate } from '../../utils/token'; +import { getRelayQuotes } from '../relay/relay-quotes'; +import type { RelayQuote } from '../relay/types'; + +jest.mock('../relay/relay-quotes'); +jest.mock('../../utils/token'); +jest.mock('../../utils/fiat'); + +const TRANSACTION_ID = 'tx-id'; +const WALLET_ADDRESS = '0x1111111111111111111111111111111111111111' as Hex; + +const TRANSACTION_MOCK = { + id: TRANSACTION_ID, + txParams: { from: WALLET_ADDRESS }, + type: TransactionType.predictDeposit, +} as TransactionMeta; + +const REQUIRED_TOKEN_MOCK: TransactionPayRequiredToken = { + address: '0x2222222222222222222222222222222222222222' as Hex, + allowUnderMinimum: false, + amountFiat: '12', + amountHuman: '12', + amountRaw: '12000000', + amountUsd: '12', + balanceFiat: '0', + balanceHuman: '0', + balanceRaw: '0', + balanceUsd: '0', + chainId: '0x89', + decimals: 6, + skipIfBalance: false, + symbol: 'USDC', +}; + +const FIAT_ASSET_MOCK: TransactionPayFiatAsset = { + address: '0x0000000000000000000000000000000000001010', + caipAssetId: 'eip155:137/slip44:966', + chainId: '0x89', + decimals: 18, +}; + +const FIAT_QUOTE_MOCK: RampsQuote = { + provider: '/providers/transak-native-staging', + quote: { + amountIn: 20, + amountOut: 5, + networkFee: 0.2, + paymentMethod: '/payments/debit-credit-card', + providerFee: 0.5, + }, +}; + +const FIAT_QUOTES_RESPONSE_MOCK: RampsQuotesResponse = { + customActions: [], + error: [], + sorted: [], + success: [FIAT_QUOTE_MOCK], +}; + +const AMOUNT_MOCK = { + fiat: '0', + human: '0', + raw: '0', + usd: '0', +}; + +function getRelayQuoteMock({ + metaMaskUsd = '4', + providerUsd = '1', + sourceNetworkUsd = '2', + targetNetworkUsd = '3', +}: { + metaMaskUsd?: string; + providerUsd?: string; + sourceNetworkUsd?: string; + targetNetworkUsd?: string; +} = {}): TransactionPayQuote { + return { + dust: { fiat: '0', usd: '0' }, + estimatedDuration: 1, + fees: { + isSourceGasFeeToken: false, + isTargetGasFeeToken: false, + metaMask: { fiat: metaMaskUsd, usd: metaMaskUsd }, + provider: { fiat: providerUsd, usd: providerUsd }, + sourceNetwork: { + estimate: { + fiat: sourceNetworkUsd, + human: '0', + raw: '0', + usd: sourceNetworkUsd, + }, + max: AMOUNT_MOCK, + }, + targetNetwork: { fiat: targetNetworkUsd, usd: targetNetworkUsd }, + }, + original: {} as RelayQuote, + request: {} as never, + sourceAmount: AMOUNT_MOCK, + strategy: TransactionPayStrategy.Relay, + targetAmount: { fiat: '0', usd: '0' }, + }; +} + +function getRequest({ + amountFiat = '10', + rampsQuotes = FIAT_QUOTES_RESPONSE_MOCK, + selectedPaymentMethodId = '/payments/debit-credit-card', + tokens = [REQUIRED_TOKEN_MOCK], + throwsOnRampsQuotes, +}: { + amountFiat?: string; + rampsQuotes?: RampsQuotesResponse; + selectedPaymentMethodId?: string; + tokens?: TransactionPayRequiredToken[]; + throwsOnRampsQuotes?: Error; +} = {}): { + callMock: jest.Mock; + request: PayStrategyGetQuotesRequest; +} { + const callMock = jest.fn((action: string) => { + if (action === 'TransactionPayController:getState') { + return { + transactionData: { + [TRANSACTION_ID]: { + fiatPayment: { + amountFiat, + selectedPaymentMethodId, + }, + isLoading: false, + tokens, + }, + }, + }; + } + + if (action === 'RampsController:getQuotes') { + if (throwsOnRampsQuotes) { + throw throwsOnRampsQuotes; + } + + return rampsQuotes; + } + + throw new Error(`Unexpected action: ${action}`); + }); + + return { + callMock, + request: { + messenger: { + call: callMock, + } as unknown as PayStrategyGetQuotesRequest['messenger'], + requests: [], + transaction: TRANSACTION_MOCK, + }, + }; +} + +describe('getFiatQuotes', () => { + const getRelayQuotesMock = jest.mocked(getRelayQuotes); + const getTokenFiatRateMock = jest.mocked(getTokenFiatRate); + const deriveFiatAssetForFiatPaymentMock = jest.mocked( + deriveFiatAssetForFiatPayment, + ); + const pickBestFiatQuoteMock = jest.mocked(pickBestFiatQuote); + + beforeEach(() => { + jest.resetAllMocks(); + + deriveFiatAssetForFiatPaymentMock.mockReturnValue(FIAT_ASSET_MOCK); + getTokenFiatRateMock.mockReturnValue({ + fiatRate: '2', + usdRate: '2', + }); + getRelayQuotesMock.mockResolvedValue([getRelayQuoteMock()]); + pickBestFiatQuoteMock.mockReturnValue(FIAT_QUOTE_MOCK); + }); + + it('returns combined fiat quote and calls ramps with adjusted amount', async () => { + const { callMock, request } = getRequest(); + + const result = await getFiatQuotes(request); + + expect(getRelayQuotesMock).toHaveBeenCalledTimes(1); + expect(getRelayQuotesMock.mock.calls[0][0].requests).toStrictEqual([ + expect.objectContaining({ + from: WALLET_ADDRESS, + isPostQuote: true, + sourceChainId: FIAT_ASSET_MOCK.chainId, + sourceTokenAddress: FIAT_ASSET_MOCK.address, + sourceTokenAmount: '5000000000000000000', + targetAmountMinimum: REQUIRED_TOKEN_MOCK.amountRaw, + targetChainId: REQUIRED_TOKEN_MOCK.chainId, + targetTokenAddress: REQUIRED_TOKEN_MOCK.address, + }), + ]); + + expect(callMock).toHaveBeenCalledWith( + 'RampsController:getQuotes', + expect.objectContaining({ + amount: 20, + paymentMethods: ['/payments/debit-credit-card'], + walletAddress: WALLET_ADDRESS, + }), + ); + + expect(result).toHaveLength(1); + expect(result[0].strategy).toBe(TransactionPayStrategy.Fiat); + expect(result[0].fees.provider).toStrictEqual({ fiat: '1', usd: '1' }); + expect(result[0].fees.fiatProvider).toStrictEqual({ + fiat: '0.7', + usd: '0.7', + }); + expect(result[0].fees.metaMask).toStrictEqual({ + fiat: '0.3', + usd: '0.3', + }); + expect(result[0].original).toStrictEqual({ + fiatQuote: FIAT_QUOTE_MOCK, + relayQuote: {}, + }); + }); + + it('returns empty array if amountFiat is missing', async () => { + const { request } = getRequest({ amountFiat: '' }); + + const result = await getFiatQuotes(request); + + expect(result).toStrictEqual([]); + expect(getRelayQuotesMock).not.toHaveBeenCalled(); + }); + + it('returns empty array if payment method is missing', async () => { + const { request } = getRequest({ selectedPaymentMethodId: '' }); + + const result = await getFiatQuotes(request); + + expect(result).toStrictEqual([]); + expect(getRelayQuotesMock).not.toHaveBeenCalled(); + }); + + it('returns empty array if no required token is available', async () => { + const { request } = getRequest({ + tokens: [{ ...REQUIRED_TOKEN_MOCK, skipIfBalance: true }], + }); + + const result = await getFiatQuotes(request); + + expect(result).toStrictEqual([]); + expect(getRelayQuotesMock).not.toHaveBeenCalled(); + }); + + it('returns empty array if fiat asset mapping is missing', async () => { + deriveFiatAssetForFiatPaymentMock.mockReturnValue(undefined); + const { request } = getRequest(); + + const result = await getFiatQuotes(request); + + expect(result).toStrictEqual([]); + expect(getRelayQuotesMock).not.toHaveBeenCalled(); + }); + + it('returns empty array if source token fiat rate is missing', async () => { + getTokenFiatRateMock.mockReturnValue(undefined); + const { request } = getRequest(); + + const result = await getFiatQuotes(request); + + expect(result).toStrictEqual([]); + expect(getRelayQuotesMock).not.toHaveBeenCalled(); + }); + + it('returns empty array if source token usd rate is not positive', async () => { + getTokenFiatRateMock.mockReturnValue({ + fiatRate: '0', + usdRate: '0', + }); + const { request } = getRequest(); + + const result = await getFiatQuotes(request); + + expect(result).toStrictEqual([]); + expect(getRelayQuotesMock).not.toHaveBeenCalled(); + }); + + it('returns empty array if source amount resolves to zero', async () => { + const { request } = getRequest({ amountFiat: '0' }); + + const result = await getFiatQuotes(request); + + expect(result).toStrictEqual([]); + expect(getRelayQuotesMock).not.toHaveBeenCalled(); + }); + + it('returns empty array if source amount rounds down to zero raw', async () => { + getTokenFiatRateMock.mockReturnValue({ + fiatRate: '1', + usdRate: '1', + }); + const { request } = getRequest({ amountFiat: '0.0000000000000000001' }); + + const result = await getFiatQuotes(request); + + expect(result).toStrictEqual([]); + expect(getRelayQuotesMock).not.toHaveBeenCalled(); + }); + + it('returns empty array if relay quotes are unavailable', async () => { + getRelayQuotesMock.mockResolvedValue([]); + const { request } = getRequest(); + + const result = await getFiatQuotes(request); + + expect(result).toStrictEqual([]); + }); + + it('returns empty array if adjusted amount is non-positive', async () => { + getRelayQuotesMock.mockResolvedValue([ + getRelayQuoteMock({ + metaMaskUsd: '0', + providerUsd: '-20', + sourceNetworkUsd: '0', + targetNetworkUsd: '0', + }), + ]); + const { callMock, request } = getRequest(); + + const result = await getFiatQuotes(request); + + expect(result).toStrictEqual([]); + expect(callMock).not.toHaveBeenCalledWith( + 'RampsController:getQuotes', + expect.anything(), + ); + }); + + it('returns empty array if adjusted amount cannot be represented as finite number', async () => { + getTokenFiatRateMock.mockReturnValue({ + fiatRate: '1e300', + usdRate: '1e300', + }); + const { callMock, request } = getRequest({ amountFiat: '1e309' }); + + const result = await getFiatQuotes(request); + + expect(result).toStrictEqual([]); + expect(callMock).not.toHaveBeenCalledWith( + 'RampsController:getQuotes', + expect.anything(), + ); + }); + + it('returns empty array if preferred fiat quote is missing', async () => { + pickBestFiatQuoteMock.mockReturnValue(undefined); + const { request } = getRequest(); + + const result = await getFiatQuotes(request); + + expect(result).toStrictEqual([]); + }); + + it('returns empty array if ramps quotes fetch throws', async () => { + const { request } = getRequest({ + throwsOnRampsQuotes: new Error('ramps failed'), + }); + + const result = await getFiatQuotes(request); + + expect(result).toStrictEqual([]); + }); + + it('sets fiat provider fee to zero when provider/network fees are missing', async () => { + pickBestFiatQuoteMock.mockReturnValue({ + provider: '/providers/transak-native-staging', + quote: { + amountIn: 20, + amountOut: 5, + paymentMethod: '/payments/debit-credit-card', + }, + } as RampsQuote); + const { request } = getRequest(); + + const result = await getFiatQuotes(request); + + expect(result).toHaveLength(1); + expect(result[0].fees.fiatProvider).toStrictEqual({ fiat: '0', usd: '0' }); + }); +}); diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.ts new file mode 100644 index 00000000000..a3d298b50b3 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.ts @@ -0,0 +1,294 @@ +import type { Quote as RampsQuote } from '@metamask/ramps-controller'; +import type { Hex } from '@metamask/utils'; +import { createModuleLogger } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; + +import type { FiatOriginalQuote } from './types'; +import { TransactionPayStrategy } from '../../constants'; +import { projectLogger } from '../../logger'; +import type { + PayStrategyGetQuotesRequest, + QuoteRequest, + TransactionPayRequiredToken, + TransactionPayQuote, +} from '../../types'; +import { + deriveFiatAssetForFiatPayment, + pickBestFiatQuote, +} from '../../utils/fiat'; +import { getTokenFiatRate } from '../../utils/token'; +import { getRelayQuotes } from '../relay/relay-quotes'; +import type { RelayQuote } from '../relay/types'; + +const log = createModuleLogger(projectLogger, 'fiat-strategy'); + +/** + * Fetches MM Pay fiat strategy quotes using a relay-first estimation flow. + * + * @param request - Strategy quotes request. + * @returns A single combined fiat strategy quote, or an empty array when inputs/quotes are unavailable. + * @remarks + * Flow summary: + * 1. Read `amountFiat` and selected payment method from transaction pay state. + * 2. Build a synthetic relay request from `amountFiat` using source token USD rate. + * 3. Fetch relay quote and compute total relay fee (`provider + source network + target network + MetaMask`). + * 4. Call ramps quotes with `adjustedAmountFiat = amountFiat + relayTotalFeeUsd`. + * 5. Pick the configured ramps provider quote and combine it with relay quote into one fiat strategy quote. + */ +export async function getFiatQuotes( + request: PayStrategyGetQuotesRequest, +): Promise[]> { + const { messenger, transaction } = request; + const transactionId = transaction.id; + + const state = messenger.call('TransactionPayController:getState'); + const transactionData = state.transactionData[transactionId]; + const selectedPaymentMethodId = + transactionData?.fiatPayment?.selectedPaymentMethodId; + const amountFiat = transactionData?.fiatPayment?.amountFiat; + const walletAddress = transaction.txParams.from as Hex; + const requiredToken = getFirstRequiredToken(transactionData?.tokens); + const fiatAsset = deriveFiatAssetForFiatPayment(transaction); + + if (!amountFiat || !selectedPaymentMethodId || !requiredToken || !fiatAsset) { + return []; + } + + try { + const relayRequest = buildRelayRequestFromAmountFiat({ + amountFiat, + fiatAsset, + messenger, + requiredToken, + walletAddress, + }); + + if (!relayRequest) { + return []; + } + + const relayQuotes = await getRelayQuotes({ + messenger, + requests: [relayRequest], + transaction, + }); + + const relayQuote = relayQuotes[0]; + if (!relayQuote) { + return []; + } + + const relayTotalFeeUsd = getRelayTotalFeeUsd(relayQuote); + const adjustedAmountFiat = new BigNumber(amountFiat).plus(relayTotalFeeUsd); + + if ( + !adjustedAmountFiat.isFinite() || + !adjustedAmountFiat.gt(0) || + !relayTotalFeeUsd.isFinite() || + !relayTotalFeeUsd.gte(0) + ) { + return []; + } + + const adjustedAmount = adjustedAmountFiat.toNumber(); + + if (!Number.isFinite(adjustedAmount) || adjustedAmount <= 0) { + return []; + } + + log('Using relay-first fiat estimate', { + adjustedAmountFiat: adjustedAmountFiat.toString(10), + amountFiat, + relayTotalFeeUsd: relayTotalFeeUsd.toString(10), + sourceAmountRaw: relayRequest.sourceTokenAmount, + transactionId, + }); + + const quotes = await messenger.call('RampsController:getQuotes', { + amount: adjustedAmount, + paymentMethods: [selectedPaymentMethodId], + walletAddress, + }); + + log('Fetched fiat quotes', { + adjustedAmountFiat: adjustedAmountFiat.toString(10), + amountFiat, + paymentMethods: [selectedPaymentMethodId], + relayTotalFeeUsd: relayTotalFeeUsd.toString(10), + transactionId, + walletAddress, + }); + + const fiatQuote = pickBestFiatQuote(quotes); + + if (!fiatQuote) { + return []; + } + + return [ + combineQuotes({ + adjustedAmountFiat: adjustedAmountFiat.toString(10), + amountFiat, + fiatQuote, + relayQuote, + }), + ]; + } catch (error) { + log('Failed to fetch fiat quotes', { error, transactionId }); + } + + return []; +} + +function getFirstRequiredToken( + tokens?: TransactionPayRequiredToken[], +): TransactionPayRequiredToken | undefined { + return tokens?.find((token) => !token.skipIfBalance); +} + +function buildRelayRequestFromAmountFiat({ + amountFiat, + fiatAsset, + messenger, + requiredToken, + walletAddress, +}: { + amountFiat: string; + fiatAsset: { + address: Hex; + chainId: Hex; + decimals: number; + }; + messenger: PayStrategyGetQuotesRequest['messenger']; + requiredToken: TransactionPayRequiredToken; + walletAddress: Hex; +}): QuoteRequest | undefined { + const sourceFiatRate = getTokenFiatRate( + messenger, + fiatAsset.address, + fiatAsset.chainId, + ); + + if (!sourceFiatRate) { + return undefined; + } + + const usdRate = new BigNumber(sourceFiatRate.usdRate); + if (!usdRate.isFinite() || !usdRate.gt(0)) { + return undefined; + } + + const sourceAmountHuman = new BigNumber(amountFiat).dividedBy(usdRate); + if (!sourceAmountHuman.isFinite() || !sourceAmountHuman.gt(0)) { + return undefined; + } + + const sourceAmountRaw = sourceAmountHuman + .shiftedBy(fiatAsset.decimals) + .decimalPlaces(0, BigNumber.ROUND_DOWN) + .toFixed(0); + + if (!new BigNumber(sourceAmountRaw).gt(0)) { + return undefined; + } + + return { + from: walletAddress, + isPostQuote: true, + sourceBalanceRaw: sourceAmountRaw, + sourceChainId: fiatAsset.chainId, + sourceTokenAddress: fiatAsset.address, + sourceTokenAmount: sourceAmountRaw, + targetAmountMinimum: requiredToken.amountRaw, + targetChainId: requiredToken.chainId, + targetTokenAddress: requiredToken.address, + }; +} + +/** + * Combines fiat and relay legs into a single MM Pay fiat strategy quote. + * + * @param params - Combined quote inputs. + * @param params.adjustedAmountFiat - Fiat amount sent to ramps after adding relay fee estimate. + * @param params.amountFiat - User-entered fiat amount. + * @param params.fiatQuote - Selected ramps quote. + * @param params.relayQuote - Estimated relay quote. + * @returns A single fiat strategy quote with split fee buckets. + * @remarks + * Fee mapping contract for MM Pay Fiat strategy: + * - `fees.provider`: Relay provider/swap fee only. + * Consumed by UI transaction fee row and tooltip provider fee (with `fees.fiatProvider`). + * - `fees.fiatProvider`: Fiat on-ramp provider fees only (`providerFee + networkFee` from ramps quote). + * Consumed by UI transaction fee row and tooltip provider fee (with `fees.provider`). + * - `fees.sourceNetwork` / `fees.targetNetwork`: Relay settlement network fees. + * Consumed by UI transaction fee row and tooltip network fee. + * - `fees.metaMask`: MM Pay fee (currently 100 bps over `amountFiat + adjustedAmountFiat`). + * Consumed by UI transaction fee row and tooltip MetaMask fee. + * - `totals.total` should represent Amount + Transaction Fee using the totals pipeline. + */ +function combineQuotes({ + adjustedAmountFiat, + amountFiat, + fiatQuote, + relayQuote, +}: { + adjustedAmountFiat: string; + amountFiat: string; + fiatQuote: RampsQuote; + relayQuote: TransactionPayQuote; +}): TransactionPayQuote { + const rampsProviderFee = getRampsProviderFee(fiatQuote).toString(10); + const metaMaskFee = getMetaMaskFee({ + adjustedAmountFiat, + amountFiat, + }).toString(10); + + return { + ...relayQuote, + fees: { + ...relayQuote.fees, + metaMask: { + fiat: metaMaskFee, + usd: metaMaskFee, + }, + provider: relayQuote.fees.provider, + fiatProvider: { + fiat: rampsProviderFee, + usd: rampsProviderFee, + }, + }, + original: { + fiatQuote, + relayQuote: relayQuote.original, + }, + strategy: TransactionPayStrategy.Fiat, + }; +} + +function getRampsProviderFee(fiatQuote: RampsQuote): BigNumber { + return new BigNumber(fiatQuote.quote.providerFee ?? 0).plus( + fiatQuote.quote.networkFee ?? 0, + ); +} + +function getRelayTotalFeeUsd( + relayQuote: TransactionPayQuote, +): BigNumber { + return new BigNumber(relayQuote.fees.provider.usd) + .plus(relayQuote.fees.sourceNetwork.estimate.usd) + .plus(relayQuote.fees.targetNetwork.usd) + .plus(relayQuote.fees.metaMask.usd); +} + +function getMetaMaskFee({ + adjustedAmountFiat, + amountFiat, +}: { + adjustedAmountFiat: BigNumber.Value; + amountFiat: BigNumber.Value; +}): BigNumber { + return new BigNumber(amountFiat) + .plus(adjustedAmountFiat) + .multipliedBy(100) + .dividedBy(10_000); +} 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 new file mode 100644 index 00000000000..49e9705c136 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts @@ -0,0 +1,19 @@ +import type { TransactionMeta } from '@metamask/transaction-controller'; + +import { submitFiatQuotes } from './fiat-submit'; +import type { FiatOriginalQuote } from './types'; +import type { TransactionPayControllerMessenger } from '../..'; +import type { TransactionPayQuote } from '../../types'; + +describe('submitFiatQuotes', () => { + it('returns empty transaction hash placeholder', async () => { + const result = await submitFiatQuotes({ + isSmartTransaction: () => false, + quotes: [] as TransactionPayQuote[], + messenger: {} as TransactionPayControllerMessenger, + transaction: {} as TransactionMeta, + }); + + expect(result).toStrictEqual({ transactionHash: undefined }); + }); +}); diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts new file mode 100644 index 00000000000..b34d78c324a --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts @@ -0,0 +1,14 @@ +import type { FiatOriginalQuote } from './types'; +import type { PayStrategy, PayStrategyExecuteRequest } from '../../types'; + +/** + * Submit Fiat quotes. + * + * @param _request - Strategy execute request. + * @returns Empty transaction hash until fiat submit implementation is added. + */ +export async function submitFiatQuotes( + _request: PayStrategyExecuteRequest, +): ReturnType['execute']> { + return { transactionHash: undefined }; +} diff --git a/packages/transaction-pay-controller/src/strategy/fiat/types.ts b/packages/transaction-pay-controller/src/strategy/fiat/types.ts new file mode 100644 index 00000000000..f7acc817793 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/fiat/types.ts @@ -0,0 +1,8 @@ +import type { Quote } from '@metamask/ramps-controller'; + +import type { RelayQuote } from '../relay/types'; + +export type FiatOriginalQuote = { + fiatQuote: Quote; + relayQuote: RelayQuote; +}; diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index 1687473aa0f..1a807af1700 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -14,6 +14,7 @@ import type { GasFeeControllerActions } from '@metamask/gas-fee-controller'; import type { Messenger } from '@metamask/messenger'; import type { NetworkControllerFindNetworkClientIdByChainIdAction } from '@metamask/network-controller'; import type { NetworkControllerGetNetworkClientByIdAction } from '@metamask/network-controller'; +import type { RampsControllerGetQuotesAction } from '@metamask/ramps-controller'; import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; import type { AuthorizationList, @@ -44,6 +45,7 @@ export type AllowedActions = | GasFeeControllerActions | NetworkControllerFindNetworkClientIdByChainIdAction | NetworkControllerGetNetworkClientByIdAction + | RampsControllerGetQuotesAction | RemoteFeatureFlagControllerGetStateAction | TokenBalancesControllerGetStateAction | TokenRatesControllerGetStateAction @@ -162,6 +164,9 @@ export type TransactionPayControllerState = { /** State relating to a single transaction. */ export type TransactionData = { + /** Fiat payment method state. */ + fiatPayment?: TransactionFiatPayment; + /** Whether quotes are currently being retrieved. */ isLoading: boolean; @@ -207,6 +212,18 @@ export type TransactionData = { totals?: TransactionPayTotals; }; +/** Fiat payment state stored per transaction. */ +export type TransactionFiatPayment = { + /** Entered fiat amount for the selected payment method. */ + amountFiat?: string; + + /** Selected fiat payment method ID. */ + selectedPaymentMethodId?: string; + + /** Quick-buy order ID in normalized format (/providers/{provider}/orders/{id}). */ + quickBuyOrderId?: string; +}; + /** A token required by a transaction. */ export type TransactionPayRequiredToken = { /** Address of the required token. */ @@ -371,6 +388,9 @@ export type TransactionPayFees = { /** Fee charged by the quote provider. */ provider: FiatValue; + /** Fee charged by fiat on-ramp provider. */ + fiatProvider?: FiatValue; + /** Network fee for transactions on the source network. */ sourceNetwork: { estimate: Amount; diff --git a/packages/transaction-pay-controller/src/utils/fiat.test.ts b/packages/transaction-pay-controller/src/utils/fiat.test.ts new file mode 100644 index 00000000000..8f72fa96eab --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/fiat.test.ts @@ -0,0 +1,87 @@ +import type { TransactionMeta } from '@metamask/transaction-controller'; +import { TransactionType } from '@metamask/transaction-controller'; + +import { deriveFiatAssetForFiatPayment, pickBestFiatQuote } from './fiat'; +import { MMPAY_FIAT_ASSET_ID_BY_TX_TYPE } from '../constants'; +import type { FiatQuotesResponse } from '../strategy/fiat/types'; + +describe('Fiat Utils', () => { + describe('deriveFiatAssetForFiatPayment', () => { + it('returns mapped fiat asset for direct transaction type', () => { + const transaction = { + type: TransactionType.predictDeposit, + } as TransactionMeta; + + const result = deriveFiatAssetForFiatPayment(transaction); + + expect(result).toStrictEqual( + MMPAY_FIAT_ASSET_ID_BY_TX_TYPE[TransactionType.predictDeposit], + ); + }); + + it('returns mapped fiat asset for first nested transaction in batch', () => { + const transaction = { + nestedTransactions: [{ type: TransactionType.perpsDeposit }], + type: TransactionType.batch, + } as TransactionMeta; + + const result = deriveFiatAssetForFiatPayment(transaction); + + expect(result).toStrictEqual( + MMPAY_FIAT_ASSET_ID_BY_TX_TYPE[TransactionType.perpsDeposit], + ); + }); + + it('returns undefined for unsupported type', () => { + const transaction = { + type: TransactionType.contractInteraction, + } as TransactionMeta; + + const result = deriveFiatAssetForFiatPayment(transaction); + + expect(result).toBeUndefined(); + }); + }); + + describe('pickBestFiatQuote', () => { + it('returns transak-native-staging quote when present', () => { + const quotes = { + customActions: [], + error: [], + sorted: [], + success: [ + { + provider: '/providers/moonpay', + quote: { amountIn: 10, amountOut: 20, paymentMethod: 'card' }, + }, + { + provider: '/providers/transak-native-staging', + quote: { amountIn: 11, amountOut: 22, paymentMethod: 'card' }, + }, + ], + } as FiatQuotesResponse; + + const result = pickBestFiatQuote(quotes); + + expect(result).toStrictEqual(quotes.success[1]); + }); + + it('returns undefined when transak-native-staging quote is missing', () => { + const quotes = { + customActions: [], + error: [], + sorted: [], + success: [ + { + provider: '/providers/moonpay', + quote: { amountIn: 10, amountOut: 20, paymentMethod: 'card' }, + }, + ], + } as FiatQuotesResponse; + + const result = pickBestFiatQuote(quotes); + + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/packages/transaction-pay-controller/src/utils/fiat.ts b/packages/transaction-pay-controller/src/utils/fiat.ts new file mode 100644 index 00000000000..86bbb2d110d --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/fiat.ts @@ -0,0 +1,37 @@ +import type { + Quote as RampsQuote, + QuotesResponse as RampsQuotesResponse, +} from '@metamask/ramps-controller'; +import { + TransactionMeta, + TransactionType, +} from '@metamask/transaction-controller'; + +import { + MMPAY_FIAT_ASSET_ID_BY_TX_TYPE, + TransactionPayFiatAsset, +} from '../constants'; + +export function deriveFiatAssetForFiatPayment( + transaction: TransactionMeta, +): TransactionPayFiatAsset | undefined { + const transactionType = transaction?.type; + + if (transactionType === TransactionType.batch) { + const firstMatchingType = transaction.nestedTransactions?.[0]?.type; + if (firstMatchingType) { + return MMPAY_FIAT_ASSET_ID_BY_TX_TYPE[firstMatchingType]; + } + } + + return MMPAY_FIAT_ASSET_ID_BY_TX_TYPE[transactionType as TransactionType]; +} + +export function pickBestFiatQuote( + quotes: RampsQuotesResponse, +): RampsQuote | undefined { + return quotes.success?.find( + // TODO: Implement provider selection logic; force Transak staging for now. + (quote) => quote.provider === '/providers/transak-native-staging', + ); +} diff --git a/packages/transaction-pay-controller/src/utils/quotes.test.ts b/packages/transaction-pay-controller/src/utils/quotes.test.ts index f70caee68bd..aaf85f30146 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.test.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.test.ts @@ -578,6 +578,35 @@ describe('Quotes Utils', () => { }); }); + it('updates metrics in metadata with fiat provider fee', async () => { + calculateTotalsMock.mockReturnValue({ + ...TOTALS_MOCK, + fees: { + ...TOTALS_MOCK.fees, + fiatProvider: { + fiat: '0.11', + usd: '0.22', + }, + }, + }); + + await run(); + + const transactionMetaMock = {} as TransactionMeta; + updateTransactionMock.mock.calls[0][1](transactionMetaMock); + + expect(transactionMetaMock).toMatchObject({ + metamaskPay: { + bridgeFeeFiat: '9.12', + chainId: TRANSACTION_DATA_MOCK.paymentToken?.chainId, + networkFeeFiat: TOTALS_MOCK.fees.sourceNetwork.estimate.usd, + targetFiat: TOTALS_MOCK.targetAmount.usd, + tokenAddress: TRANSACTION_DATA_MOCK.paymentToken?.address, + totalFiat: TOTALS_MOCK.total.usd, + }, + }); + }); + it('does nothing if transaction is not unapproved', async () => { getTransactionMock.mockReturnValue({ ...TRANSACTION_META_MOCK, diff --git a/packages/transaction-pay-controller/src/utils/quotes.ts b/packages/transaction-pay-controller/src/utils/quotes.ts index 1e75f2d9a29..21f56c78981 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.ts @@ -3,6 +3,7 @@ import type { BatchTransaction } from '@metamask/transaction-controller'; import type { TransactionMeta } from '@metamask/transaction-controller'; import type { Hex, Json } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; import { getStrategiesByName, getStrategyByName } from './strategy'; import { @@ -183,7 +184,11 @@ function syncTransaction({ tx.batchTransactionsOptions = {}; tx.metamaskPay = { - bridgeFeeFiat: totals.fees.provider.usd, + bridgeFeeFiat: totals.fees.fiatProvider + ? new BigNumber(totals.fees.provider.usd) + .plus(totals.fees.fiatProvider.usd) + .toString(10) + : totals.fees.provider.usd, chainId: paymentToken.chainId, isPostQuote, networkFeeFiat: totals.fees.sourceNetwork.estimate.usd, diff --git a/packages/transaction-pay-controller/src/utils/strategy.test.ts b/packages/transaction-pay-controller/src/utils/strategy.test.ts index 8e781f203dc..839596a0212 100644 --- a/packages/transaction-pay-controller/src/utils/strategy.test.ts +++ b/packages/transaction-pay-controller/src/utils/strategy.test.ts @@ -1,6 +1,7 @@ import { getStrategiesByName, getStrategyByName } from './strategy'; import { TransactionPayStrategy } from '../constants'; import { BridgeStrategy } from '../strategy/bridge/BridgeStrategy'; +import { FiatStrategy } from '../strategy/fiat/FiatStrategy'; import { RelayStrategy } from '../strategy/relay/RelayStrategy'; import { TestStrategy } from '../strategy/test/TestStrategy'; @@ -21,6 +22,11 @@ describe('Strategy Utils', () => { expect(strategy).toBeInstanceOf(RelayStrategy); }); + it('returns FiatStrategy if strategy name is Fiat', () => { + const strategy = getStrategyByName(TransactionPayStrategy.Fiat); + expect(strategy).toBeInstanceOf(FiatStrategy); + }); + it('throws if strategy name is unknown', () => { expect(() => getStrategyByName('UnknownStrategy' as never)).toThrow( 'Unknown strategy: UnknownStrategy', @@ -34,15 +40,18 @@ describe('Strategy Utils', () => { TransactionPayStrategy.Test, TransactionPayStrategy.Bridge, TransactionPayStrategy.Relay, + TransactionPayStrategy.Fiat, ]); - expect(strategies).toHaveLength(3); + expect(strategies).toHaveLength(4); expect(strategies[0].name).toBe(TransactionPayStrategy.Test); expect(strategies[1].name).toBe(TransactionPayStrategy.Bridge); expect(strategies[2].name).toBe(TransactionPayStrategy.Relay); + expect(strategies[3].name).toBe(TransactionPayStrategy.Fiat); expect(strategies[0].strategy).toBeInstanceOf(TestStrategy); expect(strategies[1].strategy).toBeInstanceOf(BridgeStrategy); expect(strategies[2].strategy).toBeInstanceOf(RelayStrategy); + expect(strategies[3].strategy).toBeInstanceOf(FiatStrategy); }); it('skips unknown strategies and calls callback', () => { diff --git a/packages/transaction-pay-controller/src/utils/strategy.ts b/packages/transaction-pay-controller/src/utils/strategy.ts index 22c2cf7c9dc..2a8e476bfa9 100644 --- a/packages/transaction-pay-controller/src/utils/strategy.ts +++ b/packages/transaction-pay-controller/src/utils/strategy.ts @@ -1,5 +1,6 @@ import { TransactionPayStrategy } from '../constants'; import { BridgeStrategy } from '../strategy/bridge/BridgeStrategy'; +import { FiatStrategy } from '../strategy/fiat/FiatStrategy'; import { RelayStrategy } from '../strategy/relay/RelayStrategy'; import { TestStrategy } from '../strategy/test/TestStrategy'; import type { PayStrategy } from '../types'; @@ -25,6 +26,9 @@ export function getStrategyByName( case TransactionPayStrategy.Relay: return new RelayStrategy() as never; + case TransactionPayStrategy.Fiat: + return new FiatStrategy() as never; + case TransactionPayStrategy.Test: return new TestStrategy() as never; diff --git a/packages/transaction-pay-controller/src/utils/totals.ts b/packages/transaction-pay-controller/src/utils/totals.ts index a5421a39a57..e9230a0c21f 100644 --- a/packages/transaction-pay-controller/src/utils/totals.ts +++ b/packages/transaction-pay-controller/src/utils/totals.ts @@ -37,6 +37,9 @@ export function calculateTotals({ }): TransactionPayTotals { const metaMaskFee = sumFiat(quotes.map((quote) => quote.fees.metaMask)); const providerFee = sumFiat(quotes.map((quote) => quote.fees.provider)); + const fiatProviderFee = sumFiat( + quotes.map((quote) => quote.fees.fiatProvider ?? { fiat: '0', usd: '0' }), + ); const sourceNetworkFeeMax = sumAmounts( quotes.map((quote) => quote.fees.sourceNetwork.max), @@ -70,6 +73,7 @@ export function calculateTotals({ const hasQuotes = quotes.length > 0; const totalFiat = new BigNumber(providerFee.fiat) + .plus(fiatProviderFee.fiat) .plus(metaMaskFee.fiat) .plus(sourceNetworkFeeEstimate.fiat) .plus(targetNetworkFee.fiat) @@ -77,6 +81,7 @@ export function calculateTotals({ .toString(10); const totalUsd = new BigNumber(providerFee.usd) + .plus(fiatProviderFee.usd) .plus(metaMaskFee.usd) .plus(sourceNetworkFeeEstimate.usd) .plus(targetNetworkFee.usd) @@ -100,6 +105,7 @@ export function calculateTotals({ fees: { isSourceGasFeeToken, isTargetGasFeeToken, + fiatProvider: fiatProviderFee, metaMask: metaMaskFee, provider: providerFee, sourceNetwork: { diff --git a/packages/transaction-pay-controller/tsconfig.build.json b/packages/transaction-pay-controller/tsconfig.build.json index aa8dd9cb92e..1b630c5f78f 100644 --- a/packages/transaction-pay-controller/tsconfig.build.json +++ b/packages/transaction-pay-controller/tsconfig.build.json @@ -27,6 +27,9 @@ { "path": "../network-controller/tsconfig.build.json" }, + { + "path": "../ramps-controller/tsconfig.build.json" + }, { "path": "../remote-feature-flag-controller/tsconfig.build.json" }, diff --git a/packages/transaction-pay-controller/tsconfig.json b/packages/transaction-pay-controller/tsconfig.json index 0452cae3d20..db9fa88b1ec 100644 --- a/packages/transaction-pay-controller/tsconfig.json +++ b/packages/transaction-pay-controller/tsconfig.json @@ -25,6 +25,9 @@ { "path": "../network-controller" }, + { + "path": "../ramps-controller" + }, { "path": "../remote-feature-flag-controller" },