diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 2eec0ca8899..fabe83ffbb7 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -421,10 +421,54 @@ export type RampsControllerGetStateAction = ControllerGetStateAction< RampsControllerState >; +/** + * Sets the selected token by asset ID. + */ +export type RampsControllerSetSelectedTokenAction = { + type: `${typeof controllerName}:setSelectedToken`; + handler: (assetId?: string) => void; +}; + +/** + * Fetches buy quotes using the current controller context and provided inputs. + */ +export type RampsControllerGetQuotesAction = { + type: `${typeof controllerName}:getQuotes`; + handler: (options: { + region?: string; + fiat?: string; + assetId?: string; + amount: number; + walletAddress: string; + paymentMethods?: string[]; + providers?: string[]; + redirectUrl?: string; + action?: RampAction; + forceRefresh?: boolean; + ttl?: number; + }) => Promise; +}; + +/** + * Fetches a specific order from the unified V2 API endpoint. + */ +export type RampsControllerGetOrderAction = { + type: `${typeof controllerName}:getOrder`; + handler: ( + providerCode: string, + orderCode: string, + wallet: string, + ) => Promise; +}; + /** * Actions that {@link RampsControllerMessenger} exposes to other consumers. */ -export type RampsControllerActions = RampsControllerGetStateAction; +export type RampsControllerActions = + | RampsControllerGetStateAction + | RampsControllerGetOrderAction + | RampsControllerGetQuotesAction + | RampsControllerSetSelectedTokenAction; /** * Actions from other messengers that {@link RampsController} calls. @@ -661,6 +705,21 @@ export class RampsController extends BaseController< this.#requestCacheTTL = requestCacheTTL; this.#requestCacheMaxSize = requestCacheMaxSize; + + this.messenger.registerActionHandler( + 'RampsController:setSelectedToken', + this.setSelectedToken.bind(this), + ); + + this.messenger.registerActionHandler( + 'RampsController:getQuotes', + this.getQuotes.bind(this), + ); + + this.messenger.registerActionHandler( + 'RampsController:getOrder', + this.getOrder.bind(this), + ); } /** diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index 364a57ce811..7dc882ced58 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -1,8 +1,11 @@ export type { RampsControllerActions, RampsControllerEvents, + RampsControllerGetOrderAction, RampsControllerGetStateAction, + RampsControllerGetQuotesAction, RampsControllerMessenger, + RampsControllerSetSelectedTokenAction, RampsControllerState, RampsControllerStateChangeEvent, RampsControllerOptions, diff --git a/packages/transaction-pay-controller/package.json b/packages/transaction-pay-controller/package.json index 306c3b81c4b..d23eab7ad9d 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.0.0", "@metamask/remote-feature-flag-controller": "^4.1.0", "@metamask/transaction-controller": "^62.19.0", "@metamask/utils": "^11.9.0", diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index 7b8b2f20ed5..17860609f11 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -4,6 +4,7 @@ import type { TransactionMeta } from '@metamask/transaction-controller'; import type { Draft } from 'immer'; import { noop } from 'lodash'; +import { updateFiatPayment } from './actions/update-fiat-payment'; import { updatePaymentToken } from './actions/update-payment-token'; import { CONTROLLER_NAME, @@ -18,12 +19,14 @@ import type { TransactionPayControllerMessenger, TransactionPayControllerOptions, TransactionPayControllerState, + UpdateFiatPaymentRequest, UpdatePaymentTokenRequest, } from './types'; import { getStrategyOrder } from './utils/feature-flags'; +import { deriveFiatAssetIdForFiatPayment } from './utils/fiat'; import { updateQuotes } from './utils/quotes'; import { updateSourceAmounts } from './utils/source-amounts'; -import { pollTransactionChanges } from './utils/transaction'; +import { getTransaction, pollTransactionChanges } from './utils/transaction'; const stateMetadata: StateMetadata = { transactionData: { @@ -47,10 +50,12 @@ export class TransactionPayController extends BaseController< readonly #getStrategy?: ( transaction: TransactionMeta, + transactionData?: TransactionData, ) => TransactionPayStrategy; readonly #getStrategies?: ( transaction: TransactionMeta, + transactionData?: TransactionData, ) => TransactionPayStrategy[]; constructor({ @@ -111,6 +116,13 @@ export class TransactionPayController extends BaseController< }); } + updateFiatPayment(request: UpdateFiatPaymentRequest): void { + updateFiatPayment(request, { + messenger: this.messenger, + updateTransactionData: this.#updateTransactionData.bind(this), + }); + } + #removeTransactionData(transactionId: string): void { this.update((state) => { delete state.transactionData[transactionId]; @@ -122,6 +134,7 @@ export class TransactionPayController extends BaseController< fn: (transactionData: Draft) => void, ): void { let shouldUpdateQuotes = false; + let fiatAssetId: string | undefined; this.update((state) => { const { transactionData } = state; @@ -130,13 +143,26 @@ export class TransactionPayController extends BaseController< const originalTokens = current?.tokens; const originalIsMaxAmount = current?.isMaxAmount; const originalIsPostQuote = current?.isPostQuote; + const originalFiatPaymentAmount = current?.fiatPayment?.amount; + const originalFiatPaymentMethodId = + current?.fiatPayment?.selectedPaymentMethodId; if (!current) { transactionData[transactionId] = { + fiatPayment: { + amount: null, + quickBuyOrderId: null, + selectedPaymentMethodId: null, + }, isLoading: false, tokens: [], }; + const transaction = getTransaction(transactionId, this.messenger); + fiatAssetId = deriveFiatAssetIdForFiatPayment( + transaction as unknown as TransactionMeta, + ); + current = transactionData[transactionId]; } @@ -150,6 +176,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?.amount !== originalFiatPaymentAmount; + const isFiatPaymentMethodUpdated = + current.fiatPayment?.selectedPaymentMethodId !== + originalFiatPaymentMethodId; if ( isPaymentTokenUpdated || @@ -161,6 +192,14 @@ export class TransactionPayController extends BaseController< shouldUpdateQuotes = true; } + + if (isFiatAmountUpdated || isFiatPaymentMethodUpdated) { + shouldUpdateQuotes = true; + } + + if (fiatAssetId) { + this.messenger.call('RampsController:setSelectedToken', fiatAssetId); + } }); if (shouldUpdateQuotes) { @@ -195,14 +234,25 @@ export class TransactionPayController extends BaseController< 'TransactionPayController:updatePaymentToken', this.updatePaymentToken.bind(this), ); + + this.messenger.registerActionHandler( + 'TransactionPayController:updateFiatPayment', + this.updateFiatPayment.bind(this), + ); } #getStrategiesWithFallback( transaction: TransactionMeta, ): TransactionPayStrategy[] { + const transactionData = transaction.id + ? this.state.transactionData[transaction.id] + : undefined; + const strategyCandidates: unknown[] = - this.#getStrategies?.(transaction) ?? - (this.#getStrategy ? [this.#getStrategy(transaction)] : []); + this.#getStrategies?.(transaction, transactionData) ?? + (this.#getStrategy + ? [this.#getStrategy(transaction, transactionData)] + : []); const validStrategies = strategyCandidates.filter( (strategy): strategy is TransactionPayStrategy => diff --git a/packages/transaction-pay-controller/src/actions/update-fiat-payment.ts b/packages/transaction-pay-controller/src/actions/update-fiat-payment.ts new file mode 100644 index 00000000000..2ff487e3dda --- /dev/null +++ b/packages/transaction-pay-controller/src/actions/update-fiat-payment.ts @@ -0,0 +1,69 @@ +import { createModuleLogger } from '@metamask/utils'; +import { pickBy } from 'lodash'; + +import type { TransactionPayControllerMessenger } from '..'; +import { projectLogger } from '../logger'; +import type { + UpdateFiatPaymentRequest, + UpdateTransactionDataCallback, +} from '../types'; +import { getTransaction } from '../utils/transaction'; + +const log = createModuleLogger(projectLogger, 'update-fiat-payment'); + +export type UpdateFiatPaymentOptions = { + messenger: TransactionPayControllerMessenger; + updateTransactionData: UpdateTransactionDataCallback; +}; + +/** + * Update fiat payment state for a specific transaction. + * + * @param request - Request parameters. + * @param options - Options bag. + */ +export function updateFiatPayment( + request: UpdateFiatPaymentRequest, + options: UpdateFiatPaymentOptions, +): void { + const { transactionId, selectedPaymentMethodId, amount, quickBuyOrderId } = + request; + const { messenger, updateTransactionData } = options; + + const transaction = getTransaction(transactionId, messenger); + + if (!transaction) { + throw new Error('Transaction not found'); + } + + log('Updated fiat payment', { + transactionId, + selectedPaymentMethodId, + amount, + quickBuyOrderId, + }); + + updateTransactionData(transactionId, (data) => { + const currentFiatPayment = data.fiatPayment ?? { + amount: null, + quickBuyOrderId: null, + selectedPaymentMethodId: null, + }; + + const patch = pickBy( + { + amount, + quickBuyOrderId, + selectedPaymentMethodId, + }, + (value) => value !== undefined, + ) as Partial; + + data.fiatPayment = { + ...currentFiatPayment, + ...patch, + }; + + // We may need to update the payment token here later + }); +} diff --git a/packages/transaction-pay-controller/src/actions/update-payment-token.ts b/packages/transaction-pay-controller/src/actions/update-payment-token.ts index bd28290fe7f..bd27be33654 100644 --- a/packages/transaction-pay-controller/src/actions/update-payment-token.ts +++ b/packages/transaction-pay-controller/src/actions/update-payment-token.ts @@ -57,6 +57,11 @@ export function updatePaymentToken( updateTransactionData(transactionId, (data) => { data.paymentToken = paymentToken; + data.fiatPayment = { + amount: null, + quickBuyOrderId: null, + selectedPaymentMethodId: null, + }; }); } diff --git a/packages/transaction-pay-controller/src/constants.ts b/packages/transaction-pay-controller/src/constants.ts index 73dd4540621..3a98a28d6b5 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': [ @@ -35,6 +66,7 @@ export const STABLECOINS: Record = { export enum TransactionPayStrategy { Bridge = 'bridge', Relay = 'relay', + Fiat = 'fiat', Test = 'test', } diff --git a/packages/transaction-pay-controller/src/index.ts b/packages/transaction-pay-controller/src/index.ts index 52426a80c7a..05add796e9c 100644 --- a/packages/transaction-pay-controller/src/index.ts +++ b/packages/transaction-pay-controller/src/index.ts @@ -8,7 +8,9 @@ export type { TransactionPayControllerGetStrategyAction, TransactionPayControllerMessenger, TransactionPayControllerOptions, + TransactionPayControllerUpdateFiatPaymentAction, TransactionPayControllerSetTransactionConfigAction, + TransactionFiatPayment, TransactionPayControllerState, TransactionPayControllerStateChangeEvent, TransactionPayControllerUpdatePaymentTokenAction, @@ -17,6 +19,7 @@ export type { TransactionPayRequiredToken, TransactionPaySourceAmount, TransactionPayTotals, + UpdateFiatPaymentRequest, UpdatePaymentTokenRequest, } from './types'; export { TransactionPayStrategy } from './constants'; 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.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.ts new file mode 100644 index 00000000000..d7bba72827e --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.ts @@ -0,0 +1,175 @@ +import type { Hex } from '@metamask/utils'; +import { createModuleLogger } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; + +import type { FiatOriginalQuote, FiatQuote, FiatQuotesResponse } 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 { getRelayQuotes } from '../relay/relay-quotes'; +import type { RelayQuote } from '../relay/types'; + +const log = createModuleLogger(projectLogger, 'fiat-strategy'); + +/** + * Fetch Fiat quotes. + * + * @param request - Strategy quotes request. + * @returns Fiat strategy quotes. + */ +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 as string; + const amountString = transactionData?.fiatPayment?.amount; + const walletAddress = transaction.txParams.from as Hex; + const amount = Number(amountString); + + if (!Number.isFinite(amount)) { + return []; + } + + try { + const quotes = await messenger.call('RampsController:getQuotes', { + amount, + paymentMethods: [selectedPaymentMethodId], + walletAddress, + }); + + log('Fetched fiat quotes', { + amount, + paymentMethods: [selectedPaymentMethodId], + quotes, + transactionId, + walletAddress, + }); + + const fiatQuote = pickBestFiatQuote(quotes as FiatQuotesResponse); + const requiredToken = getFirstRequiredToken(transactionData?.tokens); + const fiatAsset = deriveFiatAssetForFiatPayment(transaction); + + if (!fiatQuote || !requiredToken || !fiatAsset) { + return []; + } + + const relayRequest = buildRelayRequest({ + fiatAsset, + fiatQuote, + requiredToken, + walletAddress, + }); + + if (!relayRequest) { + return []; + } + + const relayQuotes = await getRelayQuotes({ + messenger, + requests: [relayRequest], + transaction, + }); + + const relayQuote = relayQuotes[0]; + + if (!relayQuote) { + return []; + } + + return [combineQuotes({ 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 buildRelayRequest({ + fiatAsset, + fiatQuote, + requiredToken, + walletAddress, +}: { + fiatAsset: { + address: Hex; + chainId: Hex; + decimals: number; + }; + fiatQuote: FiatQuote; + requiredToken: TransactionPayRequiredToken; + walletAddress: Hex; +}): QuoteRequest | undefined { + const sourceAmountRaw = new BigNumber(fiatQuote.quote.amountOut) + .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, + }; +} + +function combineQuotes({ + fiatQuote, + relayQuote, +}: { + fiatQuote: FiatQuote; + relayQuote: TransactionPayQuote; +}): TransactionPayQuote { + const rampsProviderFee = getRampsProviderFee(fiatQuote).toString(10); + + return { + ...relayQuote, + fees: { + ...relayQuote.fees, + provider: relayQuote.fees.provider, + fiatProvider: { + fiat: rampsProviderFee, + usd: rampsProviderFee, + }, + }, + original: { + fiatQuote, + relayQuote: relayQuote.original, + }, + strategy: TransactionPayStrategy.Fiat, + }; +} + +function getRampsProviderFee(fiatQuote: FiatQuote): BigNumber { + return new BigNumber(fiatQuote.quote.providerFee ?? 0) + .plus(fiatQuote.quote.networkFee ?? 0) + .plus(fiatQuote.quote.extraFee ?? 0); +} 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..74d25fe77f6 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts @@ -0,0 +1,272 @@ +import { createModuleLogger } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; + +import type { FiatOriginalQuote } from './types'; +import { projectLogger } from '../../logger'; +import type { + PayStrategy, + PayStrategyExecuteRequest, + QuoteRequest, +} from '../../types'; +import { deriveFiatAssetForFiatPayment } from '../../utils/fiat'; +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; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function parseQuickBuyOrderId( + quickBuyOrderId: string, +): { orderCode: string; providerCode: string } | null { + const parts = quickBuyOrderId.split('/').filter(Boolean); + + if (parts.length < 4 || parts[0] !== 'providers' || parts[2] !== 'orders') { + return null; + } + + const providerCode = parts[1]; + const orderCode = parts[3]; + + if (!providerCode || !orderCode) { + return null; + } + + return { + orderCode, + providerCode, + }; +} + +type FiatOrder = { + status?: string; + cryptoAmount?: number | string; + cryptoCurrency?: { + assetId?: string; + chainId?: string; + decimals?: number; + }; +}; + +function getRawSourceAmountFromOrder({ + cryptoAmount, + decimals, +}: { + cryptoAmount: string | number | undefined; + 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; +} + +function validateOrderAsset({ + fiatAssetCaipAssetId, + order, + transactionId, +}: { + fiatAssetCaipAssetId: string; + order: FiatOrder; + transactionId: string; +}): void { + const orderAssetId = order.cryptoCurrency?.assetId?.toLowerCase(); + const expectedAssetId = fiatAssetCaipAssetId.toLowerCase(); + const expectedChainId = expectedAssetId.split('/')[0]; + const orderChainId = order.cryptoCurrency?.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}`, + ); + } +} + +async function submitRelayAfterFiatCompletion({ + request, + order, +}: { + request: PayStrategyExecuteRequest; + order: FiatOrder; +}): Promise<{ transactionHash?: Hex }> { + const { transaction, messenger, quotes } = 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: ${transaction.type}`, + ); + } + + validateOrderAsset({ + fiatAssetCaipAssetId: fiatAsset.caipAssetId, + order, + 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'); + } + + log('Received relay quotes for completed fiat order', { + relayQuotes: relayQuotes.map((quote) => ({ + request: quote.request, + sourceAmount: quote.sourceAmount, + strategy: quote.strategy, + targetAmount: quote.targetAmount, + })), + transactionId, + }); + + const relaySubmitRequest: PayStrategyExecuteRequest = { + isSmartTransaction: request.isSmartTransaction, + messenger, + quotes: relayQuotes, + transaction, + }; + + const relaySubmitResult = await submitRelayQuotes(relaySubmitRequest); + + log('Relay submission completed after fiat completion', { + relaySubmitResult, + transactionId, + }); + + return relaySubmitResult; +} + +/** + * Submit Fiat quotes. + * + * @param request - Strategy execute request. + * @returns Empty transaction hash until fiat implementation is added. + */ +export async function submitFiatQuotes( + request: PayStrategyExecuteRequest, +): ReturnType['execute']> { + 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 quickBuyOrderId = + state.transactionData[transactionId]?.fiatPayment?.quickBuyOrderId; + + if (!quickBuyOrderId) { + throw new Error('Missing quick buy order ID for fiat submission'); + } + + const parsedOrderId = parseQuickBuyOrderId(quickBuyOrderId); + + if (!parsedOrderId) { + throw new Error(`Invalid quick buy order ID format: ${quickBuyOrderId}`); + } + + const timeoutAt = Date.now() + ORDER_POLL_TIMEOUT_MS; + + while (Date.now() < timeoutAt) { + const order = (await messenger.call( + 'RampsController:getOrder', + parsedOrderId.providerCode, + parsedOrderId.orderCode, + walletAddress, + )) as FiatOrder; + + if (order?.status === 'COMPLETED') { + log('Fiat order completed', { + order, + quickBuyOrderId, + transactionId, + }); + + return await submitRelayAfterFiatCompletion({ + request, + order, + }); + } + + if (order?.status === 'FAILED' || order?.status === 'CANCELLED') { + log('Fiat order failed', { + order, + quickBuyOrderId, + transactionId, + }); + throw new Error(`Fiat order ${order.status.toLowerCase()}`); + } + + await sleep(ORDER_POLL_INTERVAL_MS); + } + + log('Fiat order polling timed out', { + quickBuyOrderId, + transactionId, + }); + + throw new Error('Timed out waiting for fiat order completion'); +} 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..699d994ad5c --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/fiat/types.ts @@ -0,0 +1,26 @@ +import type { RelayQuote } from '../relay/types'; + +export type FiatQuote = { + provider: string; + quote: { + amountOut: number | string; + providerFee?: number | string; + networkFee?: number | string; + extraFee?: number | string; + crypto?: { + assetId?: string; + chainId?: string; + decimals?: number; + }; + cryptoId?: string; + } & Record; +} & Record; + +export type FiatQuotesResponse = { + success?: FiatQuote[]; +} & Record; + +export type FiatOriginalQuote = { + fiatQuote: FiatQuote; + relayQuote: RelayQuote; +}; 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 b0476843ee3..f8983196a7c 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -63,11 +63,25 @@ export async function getRelayQuotes( 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, - ) + // and exact-input max requests that specify a positive source amount. + .filter((singleRequest) => { + const hasTargetMinimum = singleRequest.targetAmountMinimum !== '0'; + const isPostQuote = Boolean(singleRequest.isPostQuote); + const isExactInputRequest = + Boolean(singleRequest.isMaxAmount) && + new BigNumber(singleRequest.sourceTokenAmount).gt(0); + + const shouldKeep = + hasTargetMinimum || isPostQuote || isExactInputRequest; + + if (!shouldKeep) { + log('Skipping relay request as empty amount request', { + singleRequest, + }); + } + + return shouldKeep; + }) .map((singleRequest) => normalizeRequest(singleRequest)); log('Normalized requests', normalizedRequests); @@ -124,6 +138,16 @@ async function getSingleQuote( // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const useExactInput = isMaxAmount || request.isPostQuote; + log('Determined Relay trade type', { + isMaxAmount, + isPostQuote: request.isPostQuote, + sourceTokenAmount, + targetAmountMinimum, + tradeType: useExactInput ? 'EXACT_INPUT' : 'EXPECTED_OUTPUT', + transactionId: transaction.id, + useExactInput, + }); + const body: RelayQuoteRequest = { amount: useExactInput ? sourceTokenAmount : targetAmountMinimum, destinationChainId: Number(targetChainId), diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts index c5f208d1543..880118c2742 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -1,3 +1,4 @@ +import { Interface } from '@ethersproject/abi'; import { ORIGIN_METAMASK, successfulFetch, @@ -17,6 +18,7 @@ import { RELAY_DEPOSIT_TYPES, RELAY_POLLING_INTERVAL, RELAY_STATUS_URL, + TOKEN_TRANSFER_FOUR_BYTE, } from './constants'; import type { RelayQuote, RelayStatusResponse } from './types'; import { projectLogger } from '../../logger'; @@ -35,6 +37,9 @@ import { } from '../../utils/transaction'; const FALLBACK_HASH = '0x0' as Hex; +const TOKEN_TRANSFER_INTERFACE = new Interface([ + 'function transfer(address to, uint256 amount)', +]); const log = createModuleLogger(projectLogger, 'relay-strategy'); @@ -253,23 +258,16 @@ async function submitTransactions( normalizeParams(singleParams, messenger), ); - // For post-quote flows, prepend the original transaction so it gets - // included in the batch alongside the relay deposit(s). - // This always results in multiple params, so it takes the batch path. + // For post-quote flows, append the original transaction so relay steps + // execute first and fund the final transfer/deposit. const { isPostQuote } = quote.request; + const originalParams = isPostQuote + ? buildPostQuoteOriginalParams(transaction, quote) + : undefined; - const allParams = - isPostQuote && transaction.txParams.to - ? [ - { - data: transaction.txParams.data as Hex | undefined, - from: transaction.txParams.from, - to: transaction.txParams.to, - value: transaction.txParams.value as Hex | undefined, - } as TransactionParams, - ...normalizedParams, - ] - : normalizedParams; + const allParams = originalParams + ? [...normalizedParams, originalParams] + : normalizedParams; const transactionIds: string[] = []; const { from, sourceChainId, sourceTokenAddress } = quote.request; @@ -374,6 +372,18 @@ async function submitTransactions( }; }); + log('Submitting relay transaction batch', { + gasLimit7702, + isPostQuote, + transactionPlan: transactions.map((singleTransaction, index) => ({ + index, + to: singleTransaction.params.to, + type: singleTransaction.type, + value: singleTransaction.params.value, + })), + transactionId: transaction.id, + }); + await messenger.call('TransactionController:addTransactionBatch', { from, disable7702: !gasLimit7702, @@ -399,7 +409,21 @@ async function submitTransactions( } await Promise.all( - transactionIds.map((txId) => waitForTransactionConfirmed(txId, messenger)), + transactionIds.map(async (txId) => { + try { + await waitForTransactionConfirmed(txId, messenger); + } catch (error) { + const failedTx = getTransaction(txId, messenger); + + log('Failed waiting for transaction confirmation', { + error, + failedTx, + txId, + }); + + throw error; + } + }), ); log('All transactions confirmed', transactionIds); @@ -409,13 +433,111 @@ async function submitTransactions( return hash as Hex; } +function buildPostQuoteOriginalParams( + transaction: TransactionMeta, + quote: TransactionPayQuote, +): TransactionParams | undefined { + const originalParams = getOriginalTransferParams(transaction); + + if (!originalParams) { + return undefined; + } + + const originalData = originalParams.data; + if (!originalData?.startsWith(TOKEN_TRANSFER_FOUR_BYTE)) { + return originalParams; + } + + const decodedTransfer = decodeTransferData(originalData); + if (!decodedTransfer) { + return originalParams; + } + + const currentAmountRaw = new BigNumber(decodedTransfer.amountRaw); + if (!currentAmountRaw.isFinite() || currentAmountRaw.gt(0)) { + return originalParams; + } + + const minimumAmountRaw = new BigNumber( + quote.original.details.currencyOut.minimumAmount, + ) + .decimalPlaces(0, BigNumber.ROUND_DOWN) + .toFixed(0); + + if (!new BigNumber(minimumAmountRaw).gt(0)) { + throw new Error('Invalid relay minimum output for post-quote transfer'); + } + + const patchedData = TOKEN_TRANSFER_INTERFACE.encodeFunctionData('transfer', [ + decodedTransfer.recipient, + minimumAmountRaw, + ]) as Hex; + + log('Patched post-quote transfer amount from relay minimum output', { + minimumAmountRaw, + originalAmountRaw: currentAmountRaw.toString(10), + transactionId: transaction.id, + }); + + return { + ...originalParams, + data: patchedData, + }; +} + +function getOriginalTransferParams( + transaction: TransactionMeta, +): TransactionParams | undefined { + const nestedTransfer = transaction.nestedTransactions?.find( + (nestedTx) => + nestedTx.data?.startsWith(TOKEN_TRANSFER_FOUR_BYTE) && nestedTx.to, + ); + + if (nestedTransfer?.to) { + return { + data: nestedTransfer.data, + from: transaction.txParams.from, + to: nestedTransfer.to, + value: nestedTransfer.value, + }; + } + + if (!transaction.txParams.to) { + return undefined; + } + + return { + data: transaction.txParams.data as Hex | undefined, + from: transaction.txParams.from, + to: transaction.txParams.to, + value: transaction.txParams.value as Hex | undefined, + } as TransactionParams; +} + +function decodeTransferData( + data: string, +): { amountRaw: string; recipient: Hex } | undefined { + try { + const decoded = TOKEN_TRANSFER_INTERFACE.decodeFunctionData( + 'transfer', + data, + ); + return { + amountRaw: decoded.amount.toString(), + recipient: decoded.to as Hex, + }; + } catch { + return undefined; + } +} + /** * Determine the transaction type for a given index in the batch. * * @param isPostQuote - Whether this is a post-quote flow. * @param index - Index of the transaction in the batch. - * @param originalType - Type of the original transaction (used for post-quote index 0). - * @param relayParamCount - Number of relay-only params (excludes prepended original tx). + * @param originalType - Type of the original transaction. + * @param relayParamCount - Number of relay-only params (excludes appended original tx). * @returns The transaction type. */ function getTransactionType( @@ -424,13 +546,12 @@ function getTransactionType( originalType: TransactionMeta['type'], relayParamCount: number, ): TransactionMeta['type'] { - // Post-quote index 0 is the original transaction - if (isPostQuote && index === 0) { + // Post-quote appends original transaction after relay steps. + if (isPostQuote && index === relayParamCount) { return originalType; } - // Adjust index for post-quote flows where original tx is prepended - const relayIndex = isPostQuote ? index - 1 : index; + const relayIndex = index; const depositType = getRelayDepositType(originalType); diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index 7ea554b7073..a27113f88e9 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -36,6 +36,49 @@ import type { Draft } from 'immer'; import type { CONTROLLER_NAME, TransactionPayStrategy } from './constants'; +type RampsControllerSetSelectedTokenAction = { + type: 'RampsController:setSelectedToken'; + handler: (assetId?: string) => void; +}; + +type RampsControllerGetOrderAction = { + type: 'RampsController:getOrder'; + handler: ( + providerCode: string, + orderCode: string, + wallet: string, + ) => Promise; +}; + +type RampsOrder = { + status?: string; + [key: string]: unknown; +}; + +type RampsQuotesResponse = { + success: unknown[]; + sorted: unknown[]; + error: unknown[]; + customActions?: unknown[]; +}; + +type RampsControllerGetQuotesAction = { + type: 'RampsController:getQuotes'; + handler: (options: { + region?: string; + fiat?: string; + assetId?: string; + amount: number; + walletAddress: string; + paymentMethods?: string[]; + providers?: string[]; + redirectUrl?: string; + action?: 'buy' | 'sell'; + forceRefresh?: boolean; + ttl?: number; + }) => Promise; +}; + export type AllowedActions = | AccountTrackerControllerGetStateAction | BridgeControllerActions @@ -54,7 +97,10 @@ export type AllowedActions = | TransactionControllerEstimateGasBatchAction | TransactionControllerGetGasFeeTokensAction | TransactionControllerGetStateAction - | TransactionControllerUpdateTransactionAction; + | TransactionControllerUpdateTransactionAction + | RampsControllerGetQuotesAction + | RampsControllerSetSelectedTokenAction + | RampsControllerGetOrderAction; export type AllowedEvents = | BridgeStatusControllerStateChangeEvent @@ -89,6 +135,12 @@ export type TransactionPayControllerSetTransactionConfigAction = { handler: (transactionId: string, callback: TransactionConfigCallback) => void; }; +/** Action to update fiat payment state for a transaction. */ +export type TransactionPayControllerUpdateFiatPaymentAction = { + type: `${typeof CONTROLLER_NAME}:updateFiatPayment`; + handler: (request: UpdateFiatPaymentRequest) => void; +}; + /** Configurable properties of a transaction. */ export type TransactionConfig = { /** Whether the user has selected the maximum amount. */ @@ -115,6 +167,7 @@ export type TransactionPayControllerActions = | TransactionPayControllerGetDelegationTransactionAction | TransactionPayControllerGetStateAction | TransactionPayControllerGetStrategyAction + | TransactionPayControllerUpdateFiatPaymentAction | TransactionPayControllerSetTransactionConfigAction | TransactionPayControllerUpdatePaymentTokenAction; @@ -133,10 +186,16 @@ export type TransactionPayControllerOptions = { getDelegationTransaction: GetDelegationTransactionCallback; /** Callback to select the PayStrategy for a transaction. */ - getStrategy?: (transaction: TransactionMeta) => TransactionPayStrategy; + getStrategy?: ( + transaction: TransactionMeta, + transactionData?: TransactionData, + ) => TransactionPayStrategy; /** Callback to select ordered PayStrategies for a transaction. */ - getStrategies?: (transaction: TransactionMeta) => TransactionPayStrategy[]; + getStrategies?: ( + transaction: TransactionMeta, + transactionData?: TransactionData, + ) => TransactionPayStrategy[]; /** Controller messenger. */ messenger: TransactionPayControllerMessenger; @@ -175,6 +234,9 @@ export type TransactionData = { */ paymentToken?: TransactionPaymentToken; + /** Fiat payment method state. */ + fiatPayment?: TransactionFiatPayment; + /** Quotes retrieved for the transaction. */ quotes?: TransactionPayQuote[]; @@ -191,6 +253,18 @@ export type TransactionData = { totals?: TransactionPayTotals; }; +/** Fiat payment state stored per transaction. */ +export type TransactionFiatPayment = { + /** Selected fiat payment method ID. */ + selectedPaymentMethodId: string | null; + + /** Entered fiat amount for the selected payment method. */ + amount: string | null; + + /** Quick-buy order ID in normalized format (/providers/{provider}/orders/{id}). */ + quickBuyOrderId: string | null; +}; + /** A token required by a transaction. */ export type TransactionPayRequiredToken = { /** Address of the required token. */ @@ -348,6 +422,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; @@ -501,6 +578,21 @@ export type UpdatePaymentTokenRequest = { chainId: Hex; }; +/** Request to update fiat payment state for a transaction. */ +export type UpdateFiatPaymentRequest = { + /** ID of the transaction to update. */ + transactionId: string; + + /** Selected fiat payment method ID. */ + selectedPaymentMethodId?: string | null; + + /** Entered fiat amount. */ + amount?: string | null; + + /** Quick-buy order ID in normalized format (/providers/{provider}/orders/{id}). */ + quickBuyOrderId?: string | null; +}; + /** Callback to convert a transaction to a redeem delegation. */ export type GetDelegationTransactionCallback = ({ transaction, 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..bcf4b8c6dd0 --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/fiat.ts @@ -0,0 +1,40 @@ +import { + TransactionType, + TransactionMeta, +} from '@metamask/transaction-controller'; + +import { + MMPAY_FIAT_ASSET_ID_BY_TX_TYPE, + TransactionPayFiatAsset, +} from '../constants'; +import { FiatQuotesResponse, FiatQuote } from '../strategy/fiat/types'; + +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 deriveFiatAssetIdForFiatPayment( + transaction: TransactionMeta, +): string | undefined { + return deriveFiatAssetForFiatPayment(transaction)?.caipAssetId; +} + +export function pickBestFiatQuote( + quotes: FiatQuotesResponse, +): FiatQuote | undefined { + return quotes.success?.find( + // Implement to find most reliable quote but return transak for now + (quote) => quote.provider === '/providers/transak-native-staging', + ); +} diff --git a/packages/transaction-pay-controller/src/utils/quotes.ts b/packages/transaction-pay-controller/src/utils/quotes.ts index 9c946ed0a2c..462e80eac81 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 { @@ -108,17 +109,22 @@ export async function updateQuotes( ); const totals = calculateTotals({ + fiatPaymentAmountUsd: transactionData.fiatPayment?.amount, isMaxAmount, messenger, quotes: quotes as TransactionPayQuote[], tokens, transaction, }); + const hasFiatQuote = quotes.some( + (quote) => quote.strategy === TransactionPayStrategy.Fiat, + ); log('Calculated totals', { transactionId, totals }); syncTransaction({ batchTransactions, + hasFiatQuote, isPostQuote, messenger: messenger as never, paymentToken, @@ -145,6 +151,7 @@ export async function updateQuotes( * * @param request - Request object. * @param request.batchTransactions - Batch transactions to sync. + * @param request.hasFiatQuote - Whether current quotes include fiat strategy. * @param request.isPostQuote - Whether this is a post-quote flow. * @param request.messenger - Messenger instance. * @param request.paymentToken - Payment token (source for standard flows, destination for post-quote). @@ -153,6 +160,7 @@ export async function updateQuotes( */ function syncTransaction({ batchTransactions, + hasFiatQuote, isPostQuote, messenger, paymentToken, @@ -160,6 +168,7 @@ function syncTransaction({ transactionId, }: { batchTransactions: BatchTransaction[]; + hasFiatQuote: boolean; isPostQuote?: boolean; messenger: TransactionPayControllerMessenger; paymentToken: TransactionPaymentToken | undefined; @@ -180,14 +189,25 @@ function syncTransaction({ tx.batchTransactions = batchTransactions; tx.batchTransactionsOptions = {}; + const legacyTotalFiat = hasFiatQuote + ? new BigNumber(totals.total.usd) + .plus(totals.fees.provider.usd) + .plus(totals.fees.sourceNetwork.estimate.usd) + .plus(totals.fees.targetNetwork.usd) + .plus(totals.fees.metaMask.usd) + .toString(10) + : totals.total.usd; + tx.metamaskPay = { - bridgeFeeFiat: totals.fees.provider.usd, + bridgeFeeFiat: new BigNumber(totals.fees.provider.usd) + .plus(totals.fees.fiatProvider?.usd ?? 0) + .toString(10), chainId: paymentToken.chainId, isPostQuote, networkFeeFiat: totals.fees.sourceNetwork.estimate.usd, targetFiat: totals.targetAmount.usd, tokenAddress: paymentToken.address, - totalFiat: totals.total.usd, + totalFiat: legacyTotalFiat, }; }, ); @@ -476,7 +496,11 @@ async function getQuotes( }, ); - if (!requests?.length) { + const hasFiatStrategy = strategies.some( + ({ name }) => name === TransactionPayStrategy.Fiat, + ); + + if (!requests?.length && !hasFiatStrategy) { return { batchTransactions: [], quotes: [], 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..43f030eaa52 100644 --- a/packages/transaction-pay-controller/src/utils/totals.ts +++ b/packages/transaction-pay-controller/src/utils/totals.ts @@ -2,6 +2,7 @@ import type { TransactionMeta } from '@metamask/transaction-controller'; import { BigNumber } from 'bignumber.js'; import { calculateTransactionGasCost } from './gas'; +import { TransactionPayStrategy } from '../constants'; import type { Amount, FiatValue, @@ -15,6 +16,7 @@ import type { * Calculate totals for a list of quotes and tokens. * * @param request - Request parameters. + * @param request.fiatPaymentAmountUsd - Entered fiat payment amount in USD. * @param request.isMaxAmount - Whether the transaction is a maximum amount transaction. * @param request.quotes - List of bridge quotes. * @param request.messenger - Controller messenger. @@ -23,12 +25,14 @@ import type { * @returns The calculated totals in USD and fiat currency. */ export function calculateTotals({ + fiatPaymentAmountUsd, isMaxAmount, quotes, messenger, tokens, transaction, }: { + fiatPaymentAmountUsd?: string | null; isMaxAmount?: boolean; quotes: TransactionPayQuote[]; messenger: TransactionPayControllerMessenger; @@ -37,6 +41,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), @@ -68,20 +75,36 @@ export function calculateTotals({ const amountFiat = sumProperty(quoteTokens, (token) => token.amountFiat); const amountUsd = sumProperty(quoteTokens, (token) => token.amountUsd); const hasQuotes = quotes.length > 0; - - const totalFiat = new BigNumber(providerFee.fiat) - .plus(metaMaskFee.fiat) - .plus(sourceNetworkFeeEstimate.fiat) - .plus(targetNetworkFee.fiat) - .plus(isMaxAmount && hasQuotes ? targetAmount.fiat : amountFiat) - .toString(10); - - const totalUsd = new BigNumber(providerFee.usd) - .plus(metaMaskFee.usd) - .plus(sourceNetworkFeeEstimate.usd) - .plus(targetNetworkFee.usd) - .plus(isMaxAmount && hasQuotes ? targetAmount.usd : amountUsd) - .toString(10); + const hasFiatQuote = quotes.some( + (quote) => quote.strategy === TransactionPayStrategy.Fiat, + ); + const defaultTotalAmountUsd = + isMaxAmount && hasQuotes ? targetAmount.usd : amountUsd; + + const totalAmountUsd = hasFiatQuote + ? new BigNumber(fiatPaymentAmountUsd ?? 0).toString(10) + : defaultTotalAmountUsd; + const totalFiat = hasFiatQuote + ? new BigNumber(fiatPaymentAmountUsd ?? 0) + .plus(fiatProviderFee.fiat) + .toString(10) + : new BigNumber(providerFee.fiat) + .plus(fiatProviderFee.fiat) + .plus(metaMaskFee.fiat) + .plus(sourceNetworkFeeEstimate.fiat) + .plus(targetNetworkFee.fiat) + .plus(isMaxAmount && hasQuotes ? targetAmount.fiat : amountFiat) + .toString(10); + + const totalUsd = hasFiatQuote + ? new BigNumber(totalAmountUsd).plus(fiatProviderFee.usd).toString(10) + : new BigNumber(providerFee.usd) + .plus(fiatProviderFee.usd) + .plus(metaMaskFee.usd) + .plus(sourceNetworkFeeEstimate.usd) + .plus(targetNetworkFee.usd) + .plus(totalAmountUsd) + .toString(10); const estimatedDuration = Number( sumProperty(quotes, (quote) => quote.estimatedDuration), @@ -100,6 +123,7 @@ export function calculateTotals({ fees: { isSourceGasFeeToken, isTargetGasFeeToken, + fiatProvider: fiatProviderFee, metaMask: metaMaskFee, provider: providerFee, sourceNetwork: { diff --git a/yarn.lock b/yarn.lock index 4ad12d57ba5..1940fd009b4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4571,7 +4571,7 @@ __metadata: languageName: node linkType: hard -"@metamask/ramps-controller@workspace:packages/ramps-controller": +"@metamask/ramps-controller@npm:^10.0.0, @metamask/ramps-controller@workspace:packages/ramps-controller": version: 0.0.0-use.local resolution: "@metamask/ramps-controller@workspace:packages/ramps-controller" dependencies: @@ -5113,6 +5113,7 @@ __metadata: "@metamask/messenger": "npm:^0.3.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" "@metamask/network-controller": "npm:^30.0.0" + "@metamask/ramps-controller": "npm:^10.0.0" "@metamask/remote-feature-flag-controller": "npm:^4.1.0" "@metamask/transaction-controller": "npm:^62.19.0" "@metamask/utils": "npm:^11.9.0"