diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index ad2778123f..b6a3faf7dc 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 - Add Gas Station support for Across source transactions when native balance is insufficient ([#8588](https://github.com/MetaMask/core/pull/8588)) +### Fixed + +- Pass explicit `assetId`, `providers`, and `fiat` to `RampsController:getQuotes` and persist the selected ramps quote on `TransactionFiatPayment` ([#8628](https://github.com/MetaMask/core/pull/8628)) + ## [20.2.0] ### Changed diff --git a/packages/transaction-pay-controller/src/strategy/fiat/constants.ts b/packages/transaction-pay-controller/src/strategy/fiat/constants.ts index 720be2ff02..5289ebcb82 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/constants.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/constants.ts @@ -7,6 +7,8 @@ import { NATIVE_TOKEN_ADDRESS, } from '../../constants'; +export const DEFAULT_FIAT_CURRENCY = 'USD'; + export type TransactionPayFiatAsset = { address: Hex; caipAssetId: string; 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 e874cba533..0bc9d291fd 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 @@ -9,6 +9,7 @@ import type { Hex } from '@metamask/utils'; import { TransactionPayStrategy } from '../../constants'; import type { PayStrategyGetQuotesRequest, + TransactionFiatPayment, TransactionPayQuote, TransactionPayRequiredToken, } from '../../types'; @@ -17,7 +18,7 @@ import { getRelayQuotes } from '../relay/relay-quotes'; import type { RelayQuote } from '../relay/types'; import type { TransactionPayFiatAsset } from './constants'; import { getFiatQuotes } from './fiat-quotes'; -import { deriveFiatAssetForFiatPayment, pickBestFiatQuote } from './utils'; +import { deriveFiatAssetForFiatPayment } from './utils'; jest.mock('../relay/relay-quotes'); jest.mock('../../utils/token'); @@ -67,6 +68,8 @@ const FIAT_QUOTE_MOCK: RampsQuote = { }, }; +const SELECTED_PROVIDER_ID = '/providers/transak-native-staging'; + const FIAT_QUOTES_RESPONSE_MOCK: RampsQuotesResponse = { customActions: [], error: [], @@ -123,47 +126,69 @@ function getRequest({ amountFiat = '10', fiatPaymentMethod = '/payments/debit-credit-card', rampsQuotes = FIAT_QUOTES_RESPONSE_MOCK, + selectedProviderId = SELECTED_PROVIDER_ID as string | null, tokens = [REQUIRED_TOKEN_MOCK], throwsOnRampsQuotes, }: { amountFiat?: string; fiatPaymentMethod?: string; rampsQuotes?: RampsQuotesResponse; + selectedProviderId?: string | null; 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, + const callMock = jest.fn( + (action: string, requestArg?: Record) => { + if (action === 'TransactionPayController:getState') { + return { + transactionData: { + [TRANSACTION_ID]: { + fiatPayment: { + amountFiat, + }, + isLoading: false, + tokens, }, - isLoading: false, - tokens, }, - }, - }; - } + }; + } - if (action === 'RampsController:getQuotes') { - if (throwsOnRampsQuotes) { - throw throwsOnRampsQuotes; + if (action === 'RampsController:getState') { + return { + providers: { + selected: selectedProviderId ? { id: selectedProviderId } : null, + }, + }; } - return rampsQuotes; - } + if (action === 'RampsController:getQuotes') { + if (throwsOnRampsQuotes) { + throw throwsOnRampsQuotes; + } - throw new Error(`Unexpected action: ${action}`); - }); + return rampsQuotes; + } + + if (action === 'TransactionPayController:updateFiatPayment') { + const { callback } = requestArg as unknown as { + callback: (fiatPayment: TransactionFiatPayment) => void; + }; + const fiatPayment: TransactionFiatPayment = {}; + callback(fiatPayment); + return undefined; + } + + throw new Error(`Unexpected action: ${action}`); + }, + ); return { callMock, request: { + accountSupports7702: false, fiatPaymentMethod, messenger: { call: callMock, @@ -181,7 +206,6 @@ describe('getFiatQuotes', () => { const deriveFiatAssetForFiatPaymentMock = jest.mocked( deriveFiatAssetForFiatPayment, ); - const pickBestFiatQuoteMock = jest.mocked(pickBestFiatQuote); beforeEach(() => { jest.resetAllMocks(); @@ -193,7 +217,6 @@ describe('getFiatQuotes', () => { }); computeRawFromFiatAmountMock.mockReturnValue('5000000000000000000'); getRelayQuotesMock.mockResolvedValue([getRelayQuoteMock()]); - pickBestFiatQuoteMock.mockReturnValue(FIAT_QUOTE_MOCK); }); it('returns combined fiat quote and calls ramps with adjusted amount', async () => { @@ -219,11 +242,22 @@ describe('getFiatQuotes', () => { 'RampsController:getQuotes', expect.objectContaining({ amount: 20, + assetId: FIAT_ASSET_MOCK.caipAssetId, + fiat: 'USD', paymentMethods: ['/payments/debit-credit-card'], + providers: [SELECTED_PROVIDER_ID], walletAddress: WALLET_ADDRESS, }), ); + expect(callMock).toHaveBeenCalledWith( + 'TransactionPayController:updateFiatPayment', + expect.objectContaining({ + callback: expect.any(Function), + transactionId: TRANSACTION_ID, + }), + ); + expect(result).toHaveLength(1); expect(result[0].strategy).toBe(TransactionPayStrategy.Fiat); // provider = relay(1) + ramps(0.7) = 1.7 @@ -301,16 +335,15 @@ describe('getFiatQuotes', () => { throw new Error(`Unexpected action: ${action}`); }); - const request: PayStrategyGetQuotesRequest = { + const result = await getFiatQuotes({ + accountSupports7702: false, fiatPaymentMethod: '/payments/debit-credit-card', messenger: { call: callMock, } as unknown as PayStrategyGetQuotesRequest['messenger'], requests: [], transaction: TRANSACTION_MOCK, - }; - - const result = await getFiatQuotes(request); + }); expect(result).toStrictEqual([]); expect(getRelayQuotesMock).not.toHaveBeenCalled(); @@ -425,9 +458,15 @@ describe('getFiatQuotes', () => { ); }); - it('returns empty array if preferred fiat quote is missing', async () => { - pickBestFiatQuoteMock.mockReturnValue(undefined); - const { request } = getRequest(); + it('returns empty array if no quotes in success array', async () => { + const { request } = getRequest({ + rampsQuotes: { + customActions: [], + error: [], + sorted: [], + success: [], + }, + }); const result = await getFiatQuotes(request); @@ -435,7 +474,6 @@ describe('getFiatQuotes', () => { }); it('handles ramps response without success property', async () => { - pickBestFiatQuoteMock.mockReturnValue(undefined); const { request } = getRequest({ rampsQuotes: { customActions: [], @@ -449,6 +487,72 @@ describe('getFiatQuotes', () => { expect(result).toStrictEqual([]); }); + it('passes undefined providers when no provider is selected', async () => { + const { callMock, request } = getRequest({ + selectedProviderId: null, + }); + + await getFiatQuotes(request); + + expect(callMock).toHaveBeenCalledWith( + 'RampsController:getQuotes', + expect.objectContaining({ + providers: undefined, + }), + ); + }); + + it('stores rampsQuote on fiat payment state via updateFiatPayment', async () => { + const fiatPaymentState: TransactionFiatPayment = {}; + const callMock = jest.fn( + (action: string, requestArg?: Record) => { + if (action === 'TransactionPayController:getState') { + return { + transactionData: { + [TRANSACTION_ID]: { + fiatPayment: { amountFiat: '10' }, + isLoading: false, + tokens: [REQUIRED_TOKEN_MOCK], + }, + }, + }; + } + + if (action === 'RampsController:getState') { + return { + providers: { selected: { id: SELECTED_PROVIDER_ID } }, + }; + } + + if (action === 'RampsController:getQuotes') { + return FIAT_QUOTES_RESPONSE_MOCK; + } + + if (action === 'TransactionPayController:updateFiatPayment') { + const { callback } = requestArg as unknown as { + callback: (fp: TransactionFiatPayment) => void; + }; + callback(fiatPaymentState); + return undefined; + } + + throw new Error(`Unexpected action: ${action}`); + }, + ); + + await getFiatQuotes({ + accountSupports7702: false, + fiatPaymentMethod: '/payments/debit-credit-card', + messenger: { + call: callMock, + } as unknown as PayStrategyGetQuotesRequest['messenger'], + requests: [], + transaction: TRANSACTION_MOCK, + }); + + expect(fiatPaymentState.rampsQuote).toStrictEqual(FIAT_QUOTE_MOCK); + }); + it('returns empty array if ramps quotes fetch throws', async () => { const { request } = getRequest({ throwsOnRampsQuotes: new Error('ramps failed'), @@ -474,15 +578,22 @@ describe('getFiatQuotes', () => { }); it('sets providerFiat fee to zero when ramps provider/network fees are missing', async () => { - pickBestFiatQuoteMock.mockReturnValue({ + const quoteWithoutFees: RampsQuote = { provider: '/providers/transak-native-staging', quote: { amountIn: 20, amountOut: 5, paymentMethod: '/payments/debit-credit-card', }, - } as RampsQuote); - const { request } = getRequest(); + }; + const { request } = getRequest({ + rampsQuotes: { + customActions: [], + error: [], + sorted: [], + success: [quoteWithoutFees], + }, + }); 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 959631c601..a4184af3d8 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.ts @@ -14,8 +14,10 @@ import type { import { computeRawFromFiatAmount, getTokenFiatRate } from '../../utils/token'; import { getRelayQuotes } from '../relay/relay-quotes'; import type { RelayQuote } from '../relay/types'; +import { DEFAULT_FIAT_CURRENCY } from './constants'; +import type { TransactionPayFiatAsset } from './constants'; import type { FiatQuote } from './types'; -import { deriveFiatAssetForFiatPayment, pickBestFiatQuote } from './utils'; +import { deriveFiatAssetForFiatPayment } from './utils'; const log = createModuleLogger(projectLogger, 'fiat-strategy'); @@ -115,23 +117,21 @@ export async function getFiatQuotes( transactionId, }); - const quotes = await messenger.call('RampsController:getQuotes', { - amount: adjustedAmount, - paymentMethods: [fiatPaymentMethod], + const fiatQuote = await getRampsQuote({ + adjustedAmount, + fiatAsset, + fiatPaymentMethod, + messenger, walletAddress, }); - log('Fetched ramps quotes', { - rampsQuotesCount: quotes.success?.length ?? 0, + messenger.call('TransactionPayController:updateFiatPayment', { + callback: (fiatPayment) => { + fiatPayment.rampsQuote = fiatQuote; + }, transactionId, }); - const fiatQuote = pickBestFiatQuote(quotes); - - if (!fiatQuote) { - throw new Error('No matching ramps quote found for selected provider'); - } - return [ combineQuotes({ adjustedAmountFiat: adjustedAmountFiat.toString(10), @@ -153,6 +153,45 @@ function getRequiredTokens( return tokens?.filter((token) => !token.skipIfBalance) ?? []; } +async function getRampsQuote({ + adjustedAmount, + fiatAsset, + fiatPaymentMethod, + messenger, + walletAddress, +}: { + adjustedAmount: number; + fiatAsset: TransactionPayFiatAsset; + fiatPaymentMethod: string; + messenger: PayStrategyGetQuotesRequest['messenger']; + walletAddress: string; +}): Promise { + const rampsState = messenger.call('RampsController:getState'); + const selectedProviderId = rampsState.providers.selected?.id; + + const quotes = await messenger.call('RampsController:getQuotes', { + amount: adjustedAmount, + assetId: fiatAsset.caipAssetId, + fiat: DEFAULT_FIAT_CURRENCY, + paymentMethods: [fiatPaymentMethod], + providers: selectedProviderId ? [selectedProviderId] : undefined, + walletAddress, + }); + + log('Fetched ramps quotes', { + quotesCount: quotes.success?.length ?? 0, + selectedProviderId, + }); + + const quote = quotes.success?.[0]; + + if (!quote) { + throw new Error('No matching ramps quote found for selected provider'); + } + + return quote; +} + function buildRelayRequestFromAmountFiat({ amountFiat, fiatAsset, diff --git a/packages/transaction-pay-controller/src/strategy/fiat/types.ts b/packages/transaction-pay-controller/src/strategy/fiat/types.ts index d009c27f69..8e5b53ef6b 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/types.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/types.ts @@ -1,4 +1,4 @@ -import type { Quote, QuotesResponse } from '@metamask/ramps-controller'; +import type { Quote } from '@metamask/ramps-controller'; import type { RelayQuote } from '../relay/types'; @@ -6,5 +6,3 @@ export type FiatQuote = { rampsQuote: Quote; relayQuote: RelayQuote; }; - -export type FiatQuotesResponse = QuotesResponse; 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 b1f997afda..1175bccd56 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/utils.test.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/utils.test.ts @@ -2,8 +2,7 @@ import type { TransactionMeta } from '@metamask/transaction-controller'; import { TransactionType } from '@metamask/transaction-controller'; import { FIAT_ASSET_ID_BY_TX_TYPE } from './constants'; -import type { FiatQuotesResponse } from './types'; -import { deriveFiatAssetForFiatPayment, pickBestFiatQuote } from './utils'; +import { deriveFiatAssetForFiatPayment } from './utils'; describe('Fiat Utils', () => { describe('deriveFiatAssetForFiatPayment', () => { @@ -42,46 +41,4 @@ describe('Fiat Utils', () => { 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/strategy/fiat/utils.ts b/packages/transaction-pay-controller/src/strategy/fiat/utils.ts index 73f2017e15..6894127b13 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/utils.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/utils.ts @@ -1,7 +1,3 @@ -import type { - Quote as RampsQuote, - QuotesResponse as RampsQuotesResponse, -} from '@metamask/ramps-controller'; import { TransactionMeta, TransactionType, @@ -23,12 +19,3 @@ export function deriveFiatAssetForFiatPayment( return 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/types.ts b/packages/transaction-pay-controller/src/types.ts index 41cdc70b8c..c8000178e2 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -20,7 +20,11 @@ import type { 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 { Quote as RampsQuote } from '@metamask/ramps-controller'; +import type { + RampsControllerGetQuotesAction, + RampsControllerGetStateAction, +} from '@metamask/ramps-controller'; import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; import type { AuthorizationList, @@ -56,6 +60,7 @@ export type AllowedActions = | NetworkControllerFindNetworkClientIdByChainIdAction | NetworkControllerGetNetworkClientByIdAction | RampsControllerGetQuotesAction + | RampsControllerGetStateAction | RemoteFeatureFlagControllerGetStateAction | TokenBalancesControllerGetStateAction | TokenRatesControllerGetStateAction @@ -239,6 +244,9 @@ export type TransactionFiatPayment = { /** Entered fiat amount for the selected payment method. */ amountFiat?: string; + /** The ramps quote received from the ramps provider. */ + rampsQuote?: RampsQuote; + /** Selected fiat payment method ID. */ selectedPaymentMethodId?: string; };