diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 049d39f5d2f..8dbaa84d3d5 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 + +- Implement fiat strategy submit flow with order polling and relay execution ([#8347](https://github.com/MetaMask/core/pull/8347)) + ## [19.0.3] ### Changed diff --git a/packages/transaction-pay-controller/src/TransactionPayController.test.ts b/packages/transaction-pay-controller/src/TransactionPayController.test.ts index 7f669956e50..1c4938532d7 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.test.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.test.ts @@ -7,18 +7,21 @@ import { TransactionPayController } from '.'; import { updateFiatPayment } from './actions/update-fiat-payment'; import { updatePaymentToken } from './actions/update-payment-token'; import { TransactionPayStrategy } from './constants'; +import { deriveFiatAssetForFiatPayment } from './strategy/fiat/utils'; import { getMessengerMock } from './tests/messenger-mock'; import type { TransactionPayControllerMessenger, TransactionPaySourceAmount, + UpdateTransactionDataCallback, } from './types'; import { getStrategyOrder } from './utils/feature-flags'; import { updateQuotes } from './utils/quotes'; import { updateSourceAmounts } from './utils/source-amounts'; -import { pollTransactionChanges } from './utils/transaction'; +import { getTransaction, pollTransactionChanges } from './utils/transaction'; jest.mock('./actions/update-fiat-payment'); jest.mock('./actions/update-payment-token'); +jest.mock('./strategy/fiat/utils'); jest.mock('./utils/source-amounts'); jest.mock('./utils/quotes'); jest.mock('./utils/transaction'); @@ -32,6 +35,10 @@ const CHAIN_ID_MOCK = '0x1' as Hex; describe('TransactionPayController', () => { const updateFiatPaymentMock = jest.mocked(updateFiatPayment); const updatePaymentTokenMock = jest.mocked(updatePaymentToken); + const deriveFiatAssetForFiatPaymentMock = jest.mocked( + deriveFiatAssetForFiatPayment, + ); + const getTransactionMock = jest.mocked(getTransaction); const updateSourceAmountsMock = jest.mocked(updateSourceAmounts); const updateQuotesMock = jest.mocked(updateQuotes); const pollTransactionChangesMock = jest.mocked(pollTransactionChanges); @@ -503,4 +510,129 @@ describe('TransactionPayController', () => { ).toBeUndefined(); }); }); + + describe('fiat token selection', () => { + 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; + + beforeEach(() => { + setSelectedTokenMock = jest.fn(); + messenger.registerActionHandler( + 'RampsController:setSelectedToken' as never, + setSelectedTokenMock as never, + ); + }); + + function getUpdateTransactionData(): UpdateTransactionDataCallback { + const controller = createController(); + controller.updatePaymentToken({ + transactionId: TRANSACTION_ID_MOCK, + tokenAddress: TOKEN_ADDRESS_MOCK, + chainId: CHAIN_ID_MOCK, + }); + return updatePaymentTokenMock.mock.calls[0][1].updateTransactionData; + } + + it('does not call setSelectedToken when only fiat amount changes', () => { + getTransactionMock.mockReturnValue(TRANSACTION_META_MOCK); + deriveFiatAssetForFiatPaymentMock.mockReturnValue(FIAT_ASSET_MOCK); + + const updateTransactionData = getUpdateTransactionData(); + + updateTransactionData(TRANSACTION_ID_MOCK, (data) => { + data.fiatPayment = { amountFiat: '100' }; + }); + + expect(setSelectedTokenMock).not.toHaveBeenCalled(); + }); + + it('calls RampsController:setSelectedToken when payment method changes', () => { + getTransactionMock.mockReturnValue(TRANSACTION_META_MOCK); + deriveFiatAssetForFiatPaymentMock.mockReturnValue(FIAT_ASSET_MOCK); + + const updateTransactionData = getUpdateTransactionData(); + + updateTransactionData(TRANSACTION_ID_MOCK, (data) => { + data.fiatPayment = { selectedPaymentMethodId: 'card-123' }; + }); + + expect(setSelectedTokenMock).toHaveBeenCalledWith(CAIP_ASSET_ID_MOCK); + }); + + it('triggers quote update when fiat payment changes', () => { + getTransactionMock.mockReturnValue(TRANSACTION_META_MOCK); + deriveFiatAssetForFiatPaymentMock.mockReturnValue(FIAT_ASSET_MOCK); + + const updateTransactionData = getUpdateTransactionData(); + + updateTransactionData(TRANSACTION_ID_MOCK, (data) => { + data.fiatPayment = { amountFiat: '100' }; + }); + + expect(updateQuotesMock).toHaveBeenCalledTimes(1); + }); + + it('does not call setSelectedToken when transaction is not found', () => { + getTransactionMock.mockReturnValue(undefined); + + const updateTransactionData = getUpdateTransactionData(); + + updateTransactionData(TRANSACTION_ID_MOCK, (data) => { + data.fiatPayment = { selectedPaymentMethodId: 'card-123' }; + }); + + expect(setSelectedTokenMock).not.toHaveBeenCalled(); + }); + + it('does not call setSelectedToken when fiat asset cannot be derived', () => { + getTransactionMock.mockReturnValue(TRANSACTION_META_MOCK); + deriveFiatAssetForFiatPaymentMock.mockReturnValue(undefined); + + const updateTransactionData = getUpdateTransactionData(); + + updateTransactionData(TRANSACTION_ID_MOCK, (data) => { + data.fiatPayment = { selectedPaymentMethodId: 'card-123' }; + }); + + expect(setSelectedTokenMock).not.toHaveBeenCalled(); + }); + + it('does not call setSelectedToken when fiat payment does not change', () => { + getTransactionMock.mockReturnValue(TRANSACTION_META_MOCK); + deriveFiatAssetForFiatPaymentMock.mockReturnValue(FIAT_ASSET_MOCK); + + const updateTransactionData = getUpdateTransactionData(); + + updateTransactionData(TRANSACTION_ID_MOCK, (data) => { + data.sourceAmounts = [ + { sourceAmountHuman: '1.23' } as TransactionPaySourceAmount, + ]; + }); + + expect(setSelectedTokenMock).not.toHaveBeenCalled(); + }); + + it('does not throw when setSelectedToken throws', () => { + getTransactionMock.mockReturnValue(TRANSACTION_META_MOCK); + deriveFiatAssetForFiatPaymentMock.mockReturnValue(FIAT_ASSET_MOCK); + setSelectedTokenMock.mockImplementation(() => { + throw new Error('Tokens not loaded'); + }); + + const updateTransactionData = getUpdateTransactionData(); + + expect(() => { + updateTransactionData(TRANSACTION_ID_MOCK, (data) => { + data.fiatPayment = { selectedPaymentMethodId: 'card-123' }; + }); + }).not.toThrow(); + }); + }); }); diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index a1e48b3a2ca..9d045ff51ce 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -12,6 +12,7 @@ import { TransactionPayStrategy, } from './constants'; import { QuoteRefresher } from './helpers/QuoteRefresher'; +import { deriveFiatAssetForFiatPayment } from './strategy/fiat/utils'; import type { GetDelegationTransactionCallback, TransactionConfigCallback, @@ -25,7 +26,7 @@ import type { import { getStrategyOrder } from './utils/feature-flags'; import { updateQuotes } from './utils/quotes'; import { updateSourceAmounts } from './utils/source-amounts'; -import { pollTransactionChanges } from './utils/transaction'; +import { getTransaction, pollTransactionChanges } from './utils/transaction'; const MESSENGER_EXPOSED_METHODS = [ 'getDelegationTransaction', @@ -207,6 +208,7 @@ export class TransactionPayController extends BaseController< fn: (transactionData: Draft) => void, ): void { let shouldUpdateQuotes = false; + let shouldUpdateFiatToken = false; this.update((state) => { const { transactionData } = state; @@ -215,6 +217,9 @@ export class TransactionPayController extends BaseController< const originalTokens = current?.tokens; const originalIsMaxAmount = current?.isMaxAmount; const originalIsPostQuote = current?.isPostQuote; + const originalFiatPaymentAmount = current?.fiatPayment?.amountFiat; + const originalFiatPaymentMethodId = + current?.fiatPayment?.selectedPaymentMethodId; if (!current) { transactionData[transactionId] = { @@ -236,6 +241,11 @@ export class TransactionPayController extends BaseController< const isTokensUpdated = current.tokens !== originalTokens; const isIsMaxUpdated = current.isMaxAmount !== originalIsMaxAmount; const isPostQuoteUpdated = current.isPostQuote !== originalIsPostQuote; + const isFiatAmountUpdated = + current.fiatPayment?.amountFiat !== originalFiatPaymentAmount; + const isFiatPaymentMethodUpdated = + current.fiatPayment?.selectedPaymentMethodId !== + originalFiatPaymentMethodId; if ( isPaymentTokenUpdated || @@ -247,8 +257,34 @@ export class TransactionPayController extends BaseController< shouldUpdateQuotes = true; } + + if (isFiatAmountUpdated || isFiatPaymentMethodUpdated) { + shouldUpdateQuotes = true; + } + + if (isFiatPaymentMethodUpdated) { + shouldUpdateFiatToken = true; + } }); + if (shouldUpdateFiatToken) { + const transaction = getTransaction( + transactionId, + this.messenger, + ) as TransactionMeta; + const fiatAsset = deriveFiatAssetForFiatPayment(transaction); + if (fiatAsset) { + try { + this.messenger.call( + 'RampsController:setSelectedToken', + fiatAsset.caipAssetId, + ); + } catch { + // Intentionally no-op — tokens may not be loaded in RampsController yet. + } + } + } + if (shouldUpdateQuotes) { updateQuotes({ getStrategies: this.#getStrategiesWithFallback.bind(this), 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 b1e3e2ff83d..ab0615d1498 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 @@ -1,19 +1,594 @@ +import type { + Quote as RampsQuote, + RampsOrder, + RampsOrderCryptoCurrency, +} from '@metamask/ramps-controller'; +import { RampsOrderStatus } from '@metamask/ramps-controller'; import type { TransactionMeta } from '@metamask/transaction-controller'; +import { TransactionType } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; +import type { TransactionPayFiatAsset } from './constants'; import { submitFiatQuotes } from './fiat-submit'; import type { FiatQuote } from './types'; -import type { TransactionPayControllerMessenger } from '../..'; -import type { TransactionPayQuote } from '../../types'; +import { deriveFiatAssetForFiatPayment } from './utils'; +import { TransactionPayStrategy } from '../../constants'; +import type { + PayStrategyExecuteRequest, + QuoteRequest, + TransactionPayQuote, +} from '../../types'; +import { getRelayQuotes } from '../relay/relay-quotes'; +import { submitRelayQuotes } from '../relay/relay-submit'; +import type { RelayQuote } from '../relay/types'; + +jest.mock('./utils'); +jest.mock('../relay/relay-quotes'); +jest.mock('../relay/relay-submit'); + +const TRANSACTION_ID_MOCK = 'tx-id'; +const WALLET_ADDRESS_MOCK = '0x1111111111111111111111111111111111111111' as Hex; +const ORDER_ID_MOCK = '/providers/transak/orders/order-123'; + +const TRANSACTION_MOCK = { + id: TRANSACTION_ID_MOCK, + txParams: { + from: WALLET_ADDRESS_MOCK, + }, + type: TransactionType.predictDeposit, +} as TransactionMeta; + +const FIAT_ASSET_MOCK: TransactionPayFiatAsset = { + address: '0x0000000000000000000000000000000000001010', + caipAssetId: 'eip155:137/slip44:966', + chainId: '0x89', + decimals: 18, +}; + +const RAMPS_QUOTE_MOCK: RampsQuote = { + provider: '/providers/transak-native-staging', + quote: { + amountIn: 20, + amountOut: 5, + paymentMethod: '/payments/debit-credit-card', + }, +}; + +const BASE_QUOTE_REQUEST_MOCK: QuoteRequest = { + from: WALLET_ADDRESS_MOCK, + sourceBalanceRaw: '1000000000000000000', + sourceChainId: '0x89', + sourceTokenAddress: '0x0000000000000000000000000000000000001010', + sourceTokenAmount: '1000000000000000000', + targetAmountMinimum: '12000000', + targetChainId: '0x89', + targetTokenAddress: '0x2222222222222222222222222222222222222222', +}; + +const RELAY_QUOTE_RESULT_MOCK = { + dust: { fiat: '0', usd: '0' }, + estimatedDuration: 1, + fees: { + metaMask: { fiat: '0', usd: '0' }, + provider: { fiat: '0', usd: '0' }, + sourceNetwork: { + estimate: { + fiat: '0', + human: '0', + raw: '0', + usd: '0', + }, + max: { + fiat: '0', + human: '0', + raw: '0', + usd: '0', + }, + }, + targetNetwork: { + fiat: '0', + usd: '0', + }, + }, + original: { + details: { + currencyOut: { amount: '12000000' }, + }, + } as unknown as RelayQuote, + request: BASE_QUOTE_REQUEST_MOCK, + sourceAmount: { + fiat: '0', + human: '0', + raw: '0', + usd: '0', + }, + strategy: TransactionPayStrategy.Relay, + targetAmount: { + fiat: '0', + usd: '0', + }, +} as TransactionPayQuote; + +function getFiatOrderMock({ + cryptoAmount = '1', + cryptoCurrency, + status = RampsOrderStatus.Completed, +}: { + cryptoAmount?: RampsOrder['cryptoAmount']; + cryptoCurrency?: RampsOrderCryptoCurrency; + status?: RampsOrderStatus; +} = {}): RampsOrder { + return { + cryptoAmount, + cryptoCurrency, + status, + } as RampsOrder; +} + +function getFiatQuoteMock({ + request = BASE_QUOTE_REQUEST_MOCK, +}: { + request?: QuoteRequest; +} = {}): TransactionPayQuote { + return { + dust: { fiat: '0', usd: '0' }, + estimatedDuration: 1, + fees: { + metaMask: { fiat: '0', usd: '0' }, + provider: { fiat: '0', usd: '0' }, + sourceNetwork: { + estimate: { + fiat: '0', + human: '0', + raw: '0', + usd: '0', + }, + max: { + fiat: '0', + human: '0', + raw: '0', + usd: '0', + }, + }, + targetNetwork: { + fiat: '0', + usd: '0', + }, + }, + original: { + rampsQuote: RAMPS_QUOTE_MOCK, + relayQuote: { + details: { + currencyOut: { amount: '12000000' }, + }, + } as unknown as RelayQuote, + }, + request, + sourceAmount: { + fiat: '0', + human: '0', + raw: '0', + usd: '0', + }, + strategy: TransactionPayStrategy.Fiat, + targetAmount: { + fiat: '0', + usd: '0', + }, + }; +} + +function getRequest({ + orderId = ORDER_ID_MOCK, + order = getFiatOrderMock(), + quotes = [getFiatQuoteMock()], + transaction = TRANSACTION_MOCK, +}: { + orderId?: string; + order?: RampsOrder; + quotes?: TransactionPayQuote[]; + transaction?: TransactionMeta; +} = {}): { + callMock: jest.Mock; + request: PayStrategyExecuteRequest; +} { + const callMock = jest.fn((action: string) => { + if (action === 'TransactionPayController:getState') { + return { + transactionData: { + [transaction.id]: { + fiatPayment: { + orderId, + }, + isLoading: false, + tokens: [], + }, + }, + }; + } + + if (action === 'RampsController:getOrder') { + return order; + } + + throw new Error(`Unexpected action: ${action}`); + }); + + return { + callMock, + request: { + isSmartTransaction: () => false, + messenger: { + call: callMock, + } as unknown as PayStrategyExecuteRequest['messenger'], + quotes, + transaction, + }, + }; +} describe('submitFiatQuotes', () => { - it('returns empty transaction hash placeholder', async () => { - const result = await submitFiatQuotes({ + const deriveFiatAssetForFiatPaymentMock = jest.mocked( + deriveFiatAssetForFiatPayment, + ); + const getRelayQuotesMock = jest.mocked(getRelayQuotes); + const submitRelayQuotesMock = jest.mocked(submitRelayQuotes); + + beforeEach(() => { + jest.resetAllMocks(); + jest.useRealTimers(); + + deriveFiatAssetForFiatPaymentMock.mockReturnValue(FIAT_ASSET_MOCK); + getRelayQuotesMock.mockResolvedValue([RELAY_QUOTE_RESULT_MOCK]); + submitRelayQuotesMock.mockResolvedValue({ + transactionHash: '0x1234', + }); + }); + + it('polls completed fiat order then requotes and submits relay', async () => { + const order = getFiatOrderMock({ + cryptoAmount: '1.2345', + cryptoCurrency: { + assetId: FIAT_ASSET_MOCK.caipAssetId, + chainId: 'eip155:137', + symbol: 'POL', + }, + status: RampsOrderStatus.Completed, + }); + const { callMock, request } = getRequest({ order }); + + const result = await submitFiatQuotes(request); + + expect(callMock).toHaveBeenCalledWith( + 'RampsController:getOrder', + 'transak', + 'order-123', + WALLET_ADDRESS_MOCK, + ); + expect(getRelayQuotesMock).toHaveBeenCalledTimes(1); + expect(getRelayQuotesMock.mock.calls[0][0].requests).toStrictEqual([ + expect.objectContaining({ + isMaxAmount: true, + isPostQuote: false, + sourceBalanceRaw: '1234500000000000000', + sourceTokenAmount: '1234500000000000000', + }), + ]); + expect( + getRelayQuotesMock.mock.calls[0][0].transaction.txParams.data, + ).toBeUndefined(); + expect( + getRelayQuotesMock.mock.calls[0][0].transaction.nestedTransactions, + ).toBeUndefined(); + expect(submitRelayQuotesMock).toHaveBeenCalledWith( + expect.objectContaining({ + quotes: [RELAY_QUOTE_RESULT_MOCK], + }), + ); + expect(result).toStrictEqual({ transactionHash: '0x1234' }); + }); + + it('throws if wallet address is missing', async () => { + const { request } = getRequest({ + transaction: { + ...TRANSACTION_MOCK, + txParams: {}, + } as TransactionMeta, + }); + + await expect(submitFiatQuotes(request)).rejects.toThrow( + 'Missing wallet address for fiat submission', + ); + }); + + it('throws if order ID is missing', async () => { + const { request } = getRequest({ orderId: '' }); + + await expect(submitFiatQuotes(request)).rejects.toThrow( + 'Missing order ID for fiat submission', + ); + }); + + it('throws if order ID format is invalid', async () => { + const { request } = getRequest({ + orderId: '/providers/transak/oops', + }); + + await expect(submitFiatQuotes(request)).rejects.toThrow( + 'Invalid order ID format: /providers/transak/oops', + ); + }); + + it('throws if fiat order status is failed', async () => { + const { request } = getRequest({ + order: getFiatOrderMock({ status: RampsOrderStatus.Failed }), + }); + + await expect(submitFiatQuotes(request)).rejects.toThrow( + 'Fiat order failed', + ); + }); + + it('throws if fiat order status is cancelled', async () => { + const { request } = getRequest({ + order: getFiatOrderMock({ status: RampsOrderStatus.Cancelled }), + }); + + await expect(submitFiatQuotes(request)).rejects.toThrow( + 'Fiat order cancelled', + ); + }); + + it('throws if fiat order status is id_expired', async () => { + const { request } = getRequest({ + order: getFiatOrderMock({ status: RampsOrderStatus.IdExpired }), + }); + + await expect(submitFiatQuotes(request)).rejects.toThrow( + 'Fiat order id_expired', + ); + }); + + it('polls pending orders until completed', async () => { + jest.useFakeTimers(); + + const pendingOrder = getFiatOrderMock({ status: RampsOrderStatus.Pending }); + const completedOrder = getFiatOrderMock({ + cryptoAmount: '1', + status: RampsOrderStatus.Completed, + }); + + let getOrderCallCount = 0; + const callMock = jest.fn((action: string) => { + if (action === 'TransactionPayController:getState') { + return { + transactionData: { + [TRANSACTION_ID_MOCK]: { + fiatPayment: { orderId: ORDER_ID_MOCK }, + isLoading: false, + tokens: [], + }, + }, + }; + } + + if (action === 'RampsController:getOrder') { + getOrderCallCount += 1; + return getOrderCallCount === 1 ? pendingOrder : completedOrder; + } + + throw new Error(`Unexpected action: ${action}`); + }); + + const request: PayStrategyExecuteRequest = { + isSmartTransaction: () => false, + messenger: { + call: callMock, + } as unknown as PayStrategyExecuteRequest['messenger'], + quotes: [getFiatQuoteMock()], + transaction: TRANSACTION_MOCK, + }; + + const promise = submitFiatQuotes(request); + await jest.advanceTimersByTimeAsync(1000); + const result = await promise; + + expect(result).toStrictEqual({ transactionHash: '0x1234' }); + expect(getOrderCallCount).toBe(2); + }); + + it('continues polling after transient getOrder error', async () => { + jest.useFakeTimers(); + + const completedOrder = getFiatOrderMock({ + cryptoAmount: '1', + status: RampsOrderStatus.Completed, + }); + + let getOrderCallCount = 0; + const callMock = jest.fn((action: string) => { + if (action === 'TransactionPayController:getState') { + return { + transactionData: { + [TRANSACTION_ID_MOCK]: { + fiatPayment: { orderId: ORDER_ID_MOCK }, + isLoading: false, + tokens: [], + }, + }, + }; + } + + if (action === 'RampsController:getOrder') { + getOrderCallCount += 1; + if (getOrderCallCount === 1) { + throw new Error('Network error'); + } + return completedOrder; + } + + throw new Error(`Unexpected action: ${action}`); + }); + + const request: PayStrategyExecuteRequest = { isSmartTransaction: () => false, - quotes: [] as TransactionPayQuote[], - messenger: {} as TransactionPayControllerMessenger, - transaction: {} as TransactionMeta, + messenger: { + call: callMock, + } as unknown as PayStrategyExecuteRequest['messenger'], + quotes: [getFiatQuoteMock()], + transaction: TRANSACTION_MOCK, + }; + + const promise = submitFiatQuotes(request); + await jest.advanceTimersByTimeAsync(1000); + const result = await promise; + + expect(result).toStrictEqual({ transactionHash: '0x1234' }); + expect(getOrderCallCount).toBe(2); + }); + + it('throws if fiat order polling times out and includes last status', async () => { + const dateNowSpy = jest + .spyOn(Date, 'now') + .mockReturnValueOnce(0) + .mockReturnValue(Number.MAX_SAFE_INTEGER); + + const pendingOrder = getFiatOrderMock({ status: RampsOrderStatus.Pending }); + const { request } = getRequest({ order: pendingOrder }); + + await expect(submitFiatQuotes(request)).rejects.toThrow( + 'Fiat order polling timed out (last status: PENDING)', + ); + + dateNowSpy.mockRestore(); + }); + + it('throws if fiat asset mapping is missing', async () => { + deriveFiatAssetForFiatPaymentMock.mockReturnValue(undefined); + const { request } = getRequest(); + + await expect(submitFiatQuotes(request)).rejects.toThrow( + 'Missing fiat asset mapping for transaction type: predictDeposit', + ); + }); + + it('throws if order asset id mismatches expected fiat asset', async () => { + const { request } = getRequest({ + order: getFiatOrderMock({ + cryptoCurrency: { + assetId: 'eip155:137/slip44:60', + symbol: 'ETH', + }, + }), + }); + + 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`, + ); + }); + + it('throws if order chain mismatches expected fiat asset chain', async () => { + const { request } = getRequest({ + order: getFiatOrderMock({ + cryptoCurrency: { + chainId: 'eip155:1', + symbol: 'POL', + }, + }), + }); + + await expect(submitFiatQuotes(request)).rejects.toThrow( + `Fiat order chain mismatch for transaction ${TRANSACTION_ID_MOCK}: expected eip155:137, got eip155:1`, + ); + }); + + it.each([ + ['0', 'Invalid fiat order crypto amount: 0'], + ['-1', 'Invalid fiat order crypto amount: -1'], + ['NaN', 'Invalid fiat order crypto amount: NaN'], + ])( + 'throws if order crypto amount is invalid (%s)', + async (cryptoAmount, expectedError) => { + const { request } = getRequest({ + order: getFiatOrderMock({ cryptoAmount }), + }); + + await expect(submitFiatQuotes(request)).rejects.toThrow(expectedError); + }, + ); + + it('throws if request has no fiat quotes', async () => { + const { request } = getRequest(); + request.quotes = []; + + await expect(submitFiatQuotes(request)).rejects.toThrow( + 'Missing fiat quote for relay submission', + ); + }); + + it('throws if request has multiple fiat quotes', async () => { + const { request } = getRequest(); + request.quotes = [getFiatQuoteMock(), getFiatQuoteMock()]; + + await expect(submitFiatQuotes(request)).rejects.toThrow( + 'Multiple fiat quotes are not supported for submission', + ); + }); + + it('throws if crypto amount rounds to zero after decimal shift', async () => { + const { request } = getRequest({ + order: getFiatOrderMock({ cryptoAmount: '0.0000000000000000001' }), }); - expect(result).toStrictEqual({ transactionHash: undefined }); + await expect(submitFiatQuotes(request)).rejects.toThrow( + 'Computed fiat order source amount is not positive', + ); + }); + + it('skips slippage check when original relay target amount is zero', async () => { + const { request } = getRequest(); + request.quotes[0].original.relayQuote = { + details: { currencyOut: { amount: '0' } }, + } as unknown as RelayQuote; + + const result = await submitFiatQuotes(request); + + expect(result).toStrictEqual({ transactionHash: '0x1234' }); + }); + + it('throws if relay re-quote slippage exceeds threshold', async () => { + getRelayQuotesMock.mockResolvedValue([ + { + ...RELAY_QUOTE_RESULT_MOCK, + original: { + details: { + currencyOut: { amount: '10000000' }, + }, + } as unknown as RelayQuote, + }, + ]); + const { request } = getRequest(); + + await expect(submitFiatQuotes(request)).rejects.toThrow( + /Relay re-quote slippage too high/u, + ); + }); + + it('throws if relay re-quote returns no quotes', async () => { + getRelayQuotesMock.mockResolvedValue([]); + const { request } = getRequest(); + + await expect(submitFiatQuotes(request)).rejects.toThrow( + 'No relay quotes returned for completed fiat order', + ); + }); + + it('throws if relay submit fails', async () => { + submitRelayQuotesMock.mockRejectedValue(new Error('Relay submit failed')); + const { request } = getRequest(); + + await expect(submitFiatQuotes(request)).rejects.toThrow( + 'Relay submit failed', + ); }); }); 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 6fb436b654b..8ca4e08c388 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts @@ -1,14 +1,395 @@ +import type { + RampsOrder, + RampsOrderCryptoCurrency, +} from '@metamask/ramps-controller'; +import { RampsOrderStatus } from '@metamask/ramps-controller'; +import type { Hex } from '@metamask/utils'; +import { createModuleLogger } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; + +import type { TransactionPayFiatAsset } from './constants'; import type { FiatQuote } from './types'; -import type { PayStrategy, PayStrategyExecuteRequest } from '../../types'; +import { deriveFiatAssetForFiatPayment } from './utils'; +import { projectLogger } from '../../logger'; +import type { + PayStrategy, + PayStrategyExecuteRequest, + QuoteRequest, + TransactionPayControllerMessenger, +} from '../../types'; +import { getRelayQuotes } from '../relay/relay-quotes'; +import { submitRelayQuotes } from '../relay/relay-submit'; +import type { RelayQuote } from '../relay/types'; + +const log = createModuleLogger(projectLogger, 'fiat-submit'); + +const ORDER_POLL_INTERVAL_MS = 1000; +const ORDER_POLL_TIMEOUT_MS = 10 * 60 * 1000; +const MAX_SLIPPAGE_PERCENT = 5; + +const TERMINAL_FAILURE_STATUSES: RampsOrderStatus[] = [ + RampsOrderStatus.Cancelled, + RampsOrderStatus.Failed, + RampsOrderStatus.IdExpired, +]; /** - * Submit Fiat quotes. + * Submits fiat strategy quotes by polling the on-ramp order until completion, + * then re-quoting and submitting the relay leg with the settled crypto amount. * - * @param _request - Strategy execute request. - * @returns Empty transaction hash until fiat submit implementation is added. + * @param request - Strategy execute request containing fiat quotes, messenger, and transaction metadata. + * @param request.messenger - Controller messenger for cross-controller calls. + * @param request.quotes - Fiat quotes to execute (exactly one expected). + * @param request.transaction - Original transaction metadata. + * @param request.isSmartTransaction - Callback to check smart transaction eligibility. + * @returns An object containing the relay transaction hash if available. */ export async function submitFiatQuotes( - _request: PayStrategyExecuteRequest, + request: PayStrategyExecuteRequest, ): ReturnType['execute']> { - return { transactionHash: undefined }; + const { messenger, transaction } = request; + const transactionId = transaction.id; + const walletAddress = transaction.txParams.from as Hex | undefined; + + if (!walletAddress) { + throw new Error('Missing wallet address for fiat submission'); + } + + const state = messenger.call('TransactionPayController:getState'); + const orderId = state.transactionData[transactionId]?.fiatPayment?.orderId; + + if (!orderId) { + throw new Error('Missing order ID for fiat submission'); + } + + const parsedOrder = parseOrderId(orderId); + + if (!parsedOrder) { + throw new Error(`Invalid order ID format: ${orderId}`); + } + + log('Starting fiat order polling', { + orderId, + providerCode: parsedOrder.providerCode, + transactionId, + }); + + const order = await waitForOrderCompletion({ + messenger, + orderCode: parsedOrder.orderCode, + providerCode: parsedOrder.providerCode, + transactionId, + walletAddress, + }); + + log('Fiat order completed', { + cryptoAmount: order.cryptoAmount, + orderId, + transactionId, + }); + + return await submitRelayAfterFiatCompletion({ order, request }); +} + +/** + * Parses a normalized order ID string into its provider and order components. + * + * @param orderId - Order ID in `/providers/{providerCode}/orders/{orderCode}` format. + * @returns The parsed provider and order codes, or `null` if the format is invalid. + */ +function parseOrderId( + orderId: string, +): { orderCode: string; providerCode: string } | null { + const parts = orderId.split('/').filter(Boolean); + + if (parts.length < 4 || parts[0] !== 'providers' || parts[2] !== 'orders') { + return null; + } + + return { orderCode: parts[3], providerCode: parts[1] }; +} + +/** + * Converts the order's human-readable crypto amount to a raw token amount. + * + * @param options - The conversion options. + * @param options.cryptoAmount - Human-readable crypto amount from the completed order. + * @param options.decimals - Token decimals for the fiat asset. + * @returns The raw token amount as a string. + */ +function getRawSourceAmountFromOrder({ + cryptoAmount, + decimals, +}: { + cryptoAmount: RampsOrder['cryptoAmount']; + decimals: number; +}): string { + const normalizedAmount = new BigNumber(String(cryptoAmount)); + + if (!normalizedAmount.isFinite() || normalizedAmount.lte(0)) { + throw new Error( + `Invalid fiat order crypto amount: ${String(cryptoAmount)}`, + ); + } + + const rawAmount = normalizedAmount + .shiftedBy(decimals) + .decimalPlaces(0, BigNumber.ROUND_DOWN) + .toFixed(0); + + if (!new BigNumber(rawAmount).gt(0)) { + throw new Error('Computed fiat order source amount is not positive'); + } + + return rawAmount; +} + +/** + * Validates that the completed order's crypto asset matches the expected fiat asset. + * + * @param options - The validation options. + * @param options.expectedAsset - The expected fiat asset derived from the transaction type. + * @param options.orderCrypto - The crypto currency information from the completed order. + * @param options.transactionId - Transaction ID for error reporting. + */ +function validateOrderAsset({ + expectedAsset, + orderCrypto, + transactionId, +}: { + expectedAsset: TransactionPayFiatAsset; + orderCrypto: RampsOrderCryptoCurrency | undefined; + transactionId: string; +}): void { + const orderAssetId = orderCrypto?.assetId?.toLowerCase(); + const expectedAssetId = expectedAsset.caipAssetId.toLowerCase(); + const expectedChainId = expectedAssetId.split('/')[0]; + const orderChainId = orderCrypto?.chainId?.toLowerCase(); + + if (orderAssetId && orderAssetId !== expectedAssetId) { + throw new Error( + `Fiat order asset mismatch for transaction ${transactionId}: ` + + `expected ${expectedAssetId}, got ${orderAssetId}`, + ); + } + + if (orderChainId && orderChainId !== expectedChainId) { + throw new Error( + `Fiat order chain mismatch for transaction ${transactionId}: ` + + `expected ${expectedChainId}, got ${orderChainId}`, + ); + } +} + +/** + * Validates that the re-quoted relay target output hasn't drifted beyond the + * acceptable slippage threshold compared to the original quote shown to the user. + * + * @param options - The validation options. + * @param options.originalTargetRaw - Raw target amount from the original relay quote. + * @param options.reQuotedTargetRaw - Raw target amount from the re-quoted relay. + * @param options.transactionId - Transaction ID for error reporting. + */ +function validateRelaySlippage({ + originalTargetRaw, + reQuotedTargetRaw, + transactionId, +}: { + originalTargetRaw: string; + reQuotedTargetRaw: string; + transactionId: string; +}): void { + const original = new BigNumber(originalTargetRaw); + const reQuoted = new BigNumber(reQuotedTargetRaw); + + if (!original.gt(0) || !reQuoted.gt(0)) { + return; + } + + const slippagePercent = original + .minus(reQuoted) + .dividedBy(original) + .multipliedBy(100); + + log('Relay slippage check', { + originalTargetRaw, + reQuotedTargetRaw, + slippagePercent: slippagePercent.toFixed(2), + transactionId, + }); + + if (slippagePercent.gt(MAX_SLIPPAGE_PERCENT)) { + throw new Error( + `Relay re-quote slippage too high for transaction ${transactionId}: ` + + `${slippagePercent.toFixed(2)}% exceeds ${MAX_SLIPPAGE_PERCENT}% max`, + ); + } +} + +/** + * Polls the on-ramp order until it reaches a terminal status. + * + * @param options - The polling options. + * @param options.messenger - Controller messenger for calling `RampsController:getOrder`. + * @param options.orderCode - The order identifier within the provider. + * @param options.providerCode - The on-ramp provider code (e.g. "transak"). + * @param options.transactionId - Transaction ID for logging. + * @param options.walletAddress - Wallet address associated with the order. + * @returns The completed order data. + */ +async function waitForOrderCompletion({ + messenger, + orderCode, + providerCode, + transactionId, + walletAddress, +}: { + messenger: TransactionPayControllerMessenger; + orderCode: string; + providerCode: string; + transactionId: string; + walletAddress: string; +}): Promise { + const startTime = Date.now(); + let lastStatus: string | undefined; + + while (true) { + let order: RampsOrder | undefined; + + try { + order = await messenger.call( + 'RampsController:getOrder', + providerCode, + orderCode, + walletAddress, + ); + } catch (error) { + log('Order polling network error', error); + } + + if (order) { + lastStatus = order.status; + + log('Polled fiat order', { + orderStatus: order.status, + providerCode, + transactionId, + }); + + if (order.status === RampsOrderStatus.Completed) { + return order; + } + + if (TERMINAL_FAILURE_STATUSES.includes(order.status)) { + throw new Error(`Fiat order ${order.status.toLowerCase()}`); + } + } + + if (Date.now() - startTime >= ORDER_POLL_TIMEOUT_MS) { + throw new Error( + `Fiat order polling timed out (last status: ${lastStatus})`, + ); + } + + await new Promise((resolve) => setTimeout(resolve, ORDER_POLL_INTERVAL_MS)); + } +} + +/** + * Re-quotes and submits the relay leg using the settled amount from a completed fiat order. + * + * @param options - The submission options. + * @param options.order - The completed on-ramp order containing the settled crypto amount. + * @param options.request - The original fiat strategy execute request. + * @returns An object containing the relay transaction hash if available. + */ +async function submitRelayAfterFiatCompletion({ + order, + request, +}: { + order: RampsOrder; + request: PayStrategyExecuteRequest; +}): Promise<{ transactionHash?: Hex }> { + const { messenger, quotes, transaction } = request; + const transactionId = transaction.id; + + if (!quotes.length) { + throw new Error('Missing fiat quote for relay submission'); + } + + if (quotes.length > 1) { + 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)}`, + ); + } + + validateOrderAsset({ + expectedAsset: fiatAsset, + orderCrypto: order.cryptoCurrency, + transactionId, + }); + + const sourceAmountRaw = getRawSourceAmountFromOrder({ + cryptoAmount: order.cryptoAmount, + decimals: fiatAsset.decimals, + }); + + const baseRequest = quotes[0].request; + const relayRequest: QuoteRequest = { + ...baseRequest, + isMaxAmount: true, + isPostQuote: false, + sourceBalanceRaw: sourceAmountRaw, + sourceTokenAmount: sourceAmountRaw, + }; + + log('Re-quoting relay from completed fiat order', { + completedOrderAmount: order.cryptoAmount, + relayRequest, + sourceAmountRaw, + transactionId, + }); + + const relayQuotes = await getRelayQuotes({ + messenger, + requests: [relayRequest], + transaction, + }); + + if (!relayQuotes.length) { + throw new Error('No relay quotes returned for completed fiat order'); + } + + const originalRelayQuote = quotes[0].original.relayQuote; + validateRelaySlippage({ + originalTargetRaw: originalRelayQuote.details.currencyOut.amount, + reQuotedTargetRaw: relayQuotes[0].original.details.currencyOut.amount, + transactionId, + }); + + log('Received relay quotes for completed fiat order', { + relayQuoteCount: relayQuotes.length, + transactionId, + }); + + const relaySubmitRequest: PayStrategyExecuteRequest = { + isSmartTransaction: request.isSmartTransaction, + messenger, + quotes: relayQuotes, + transaction, + }; + + const relayResult = await submitRelayQuotes(relaySubmitRequest); + + log('Relay submission completed after fiat order', { + relayResult, + transactionId, + }); + + return relayResult; } diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts index 8ad148cbf13..82a3dfda9c0 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -78,13 +78,15 @@ export async function getRelayQuotes( try { const normalizedRequests = requests - // Ignore gas fee token requests (which have both target=0 and source=0) - // but keep post-quote requests (identified by isPostQuote flag) - .filter( - (singleRequest) => - singleRequest.targetAmountMinimum !== '0' || - singleRequest.isPostQuote, - ) + .filter((singleRequest) => { + const hasTargetMinimum = singleRequest.targetAmountMinimum !== '0'; + const isPostQuote = Boolean(singleRequest.isPostQuote); + const isExactInputRequest = + Boolean(singleRequest.isMaxAmount) && + new BigNumber(singleRequest.sourceTokenAmount).gt(0); + + return hasTargetMinimum || isPostQuote || isExactInputRequest; + }) .map((singleRequest) => normalizeRequest(singleRequest)); log('Normalized requests', normalizedRequests); diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index 320f07327dc..9ac78d943e5 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -16,7 +16,11 @@ import type { KeyringControllerSignTypedMessageAction } from '@metamask/keyring- 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 { + RampsControllerGetOrderAction, + RampsControllerGetQuotesAction, + RampsControllerSetSelectedTokenAction, +} from '@metamask/ramps-controller'; import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; import type { AuthorizationList, @@ -50,7 +54,9 @@ export type AllowedActions = | KeyringControllerSignTypedMessageAction | NetworkControllerFindNetworkClientIdByChainIdAction | NetworkControllerGetNetworkClientByIdAction + | RampsControllerGetOrderAction | RampsControllerGetQuotesAction + | RampsControllerSetSelectedTokenAction | RemoteFeatureFlagControllerGetStateAction | TokenBalancesControllerGetStateAction | TokenRatesControllerGetStateAction @@ -211,6 +217,9 @@ export type TransactionFiatPayment = { /** Entered fiat amount for the selected payment method. */ amountFiat?: string; + /** Order identifier in normalized format (/providers/{provider}/orders/{id}). */ + orderId?: string; + /** Selected fiat payment method ID. */ selectedPaymentMethodId?: string; };