diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index 9bba57d98d8..fad8d2c5ada 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -8,6 +8,7 @@ import { updatePaymentToken } from './actions/update-payment-token'; import { CONTROLLER_NAME, TransactionPayStrategy } from './constants'; import { QuoteRefresher } from './helpers/QuoteRefresher'; import type { + FiatPaymentData, GetDelegationTransactionCallback, TransactionData, TransactionPayControllerMessenger, @@ -41,6 +42,7 @@ export class TransactionPayController extends BaseController< readonly #getStrategy?: ( transaction: TransactionMeta, + transactionData?: TransactionData, ) => TransactionPayStrategy; constructor({ @@ -80,6 +82,26 @@ export class TransactionPayController extends BaseController< }); } + setFiatPayment( + transactionId: string, + fiatPayment: FiatPaymentData | undefined, + ): void { + this.#updateTransactionData(transactionId, (transactionData) => { + transactionData.fiatPayment = fiatPayment; + + // When setting fiat payment, clear token-based fields + if (fiatPayment !== undefined) { + transactionData.paymentToken = undefined; + transactionData.sourceAmounts = undefined; + transactionData.quotes = undefined; + transactionData.totals = undefined; + transactionData.isMaxAmount = undefined; + transactionData.isLoading = false; + transactionData.quotesLastUpdated = undefined; + } + }); + } + updatePaymentToken(request: UpdatePaymentTokenRequest): void { updatePaymentToken(request, { messenger: this.messenger, @@ -148,8 +170,13 @@ export class TransactionPayController extends BaseController< this.messenger.registerActionHandler( 'TransactionPayController:getStrategy', - this.#getStrategy ?? - ((): TransactionPayStrategy => TransactionPayStrategy.Relay), + (transaction: TransactionMeta): TransactionPayStrategy => { + const transactionData = this.state.transactionData?.[transaction.id]; + return ( + this.#getStrategy?.(transaction, transactionData) ?? + TransactionPayStrategy.Relay + ); + }, ); this.messenger.registerActionHandler( @@ -157,6 +184,11 @@ export class TransactionPayController extends BaseController< this.setIsMaxAmount.bind(this), ); + this.messenger.registerActionHandler( + 'TransactionPayController:setFiatPayment', + this.setFiatPayment.bind(this), + ); + this.messenger.registerActionHandler( 'TransactionPayController:updatePaymentToken', this.updatePaymentToken.bind(this), diff --git a/packages/transaction-pay-controller/src/constants.ts b/packages/transaction-pay-controller/src/constants.ts index 0b6d75ff06e..7d070aeeacc 100644 --- a/packages/transaction-pay-controller/src/constants.ts +++ b/packages/transaction-pay-controller/src/constants.ts @@ -17,4 +17,5 @@ export enum TransactionPayStrategy { Bridge = 'bridge', Relay = 'relay', Test = 'test', + Fiat = 'fiat', } diff --git a/packages/transaction-pay-controller/src/index.ts b/packages/transaction-pay-controller/src/index.ts index d3cb83c35f3..0bf6c68b92f 100644 --- a/packages/transaction-pay-controller/src/index.ts +++ b/packages/transaction-pay-controller/src/index.ts @@ -1,4 +1,7 @@ export type { + FiatPaymentData, + FiatPaymentQuote, + TransactionData, TransactionPayControllerActions, TransactionPayControllerEvents, TransactionPayControllerGetDelegationTransactionAction, @@ -6,6 +9,7 @@ export type { TransactionPayControllerGetStrategyAction, TransactionPayControllerMessenger, TransactionPayControllerOptions, + TransactionPayControllerSetFiatPaymentAction, TransactionPayControllerSetIsMaxAmountAction, TransactionPayControllerState, TransactionPayControllerStateChangeEvent, 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..0cd8a0ae52b --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/fiat/FiatStrategy.ts @@ -0,0 +1,112 @@ +import type { Hex } from '@metamask/utils'; +import { createModuleLogger } from '@metamask/utils'; + +import { TransactionPayStrategy } from '../..'; +import { projectLogger } from '../../logger'; +import type { + PayStrategy, + PayStrategyExecuteRequest, + PayStrategyGetQuotesRequest, + TransactionPayQuote, +} from '../../types'; + +const log = createModuleLogger(projectLogger, 'fiat-strategy'); + +const MOCK_TRANSACTION_HASH = + '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex; + +const POC_DELAY_MS = 5000; // 5 seconds + +export class FiatStrategy implements PayStrategy { + async getQuotes( + request: PayStrategyGetQuotesRequest, + ): Promise[]> { + const { messenger, requests, transaction } = request; + + log('Getting fiat quotes (PoC mock)', { transactionId: transaction.id }); + + // Get required tokens from controller state to compute fee + const state = messenger.call('TransactionPayController:getState'); + const transactionData = state.transactionData?.[transaction.id]; + const tokens = transactionData?.tokens ?? []; + + // Sum up the USD amounts of required tokens (excluding skipIfBalance) + const requiredTokens = tokens.filter((token) => !token.skipIfBalance); + const baseAmountUsd = requiredTokens.reduce( + (sum, token) => sum + parseFloat(token.amountUsd || '0'), + 0, + ); + + // Compute 2% provider fee + const providerFeeUsd = baseAmountUsd * 0.02; + + // Use the dummy request passed in (from buildFiatQuoteRequests) + const dummyRequest = requests[0]; + + // PoC: Return a single mocked quote with computed fee + return [ + { + dust: { fiat: '0', usd: '0' }, + estimatedDuration: POC_DELAY_MS / 1000, + fees: { + provider: { + fiat: providerFeeUsd.toFixed(2), + usd: providerFeeUsd.toFixed(2), + }, + sourceNetwork: { + estimate: { + human: '0', + fiat: '0', + usd: '0', + raw: '0', + }, + max: { + human: '0', + fiat: '0', + usd: '0', + raw: '0', + }, + }, + targetNetwork: { + fiat: '0', + usd: '0', + }, + }, + original: undefined, + request: dummyRequest, + sourceAmount: { + human: baseAmountUsd.toFixed(2), + fiat: baseAmountUsd.toFixed(2), + raw: baseAmountUsd.toFixed(2), + usd: baseAmountUsd.toFixed(2), + }, + targetAmount: { + human: baseAmountUsd.toFixed(2), + fiat: baseAmountUsd.toFixed(2), + raw: baseAmountUsd.toFixed(2), + usd: baseAmountUsd.toFixed(2), + }, + strategy: TransactionPayStrategy.Fiat, + }, + ]; + } + + async execute( + request: PayStrategyExecuteRequest, + ): ReturnType['execute']> { + const { quotes } = request; + + log('Executing fiat strategy (PoC mock)', quotes); + log(`Waiting ${POC_DELAY_MS / 1000} seconds...`); + + await this.#timeout(POC_DELAY_MS); + + log('Returning mock transaction hash:', MOCK_TRANSACTION_HASH); + + return { transactionHash: MOCK_TRANSACTION_HASH }; + } + + #timeout(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index 8539a6e15ff..bbeb84bcf8c 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -37,6 +37,34 @@ import type { Draft } from 'immer'; import type { CONTROLLER_NAME, TransactionPayStrategy } from './constants'; +/** Quote data for a fiat payment. */ +export type FiatPaymentQuote = { + /** Provider fee in fiat currency (e.g. "2.50"). */ + providerFeeFiat: string; + /** Provider fee in USD (e.g. "2.50"). */ + providerFeeUsd: string; + /** Total amount in fiat currency (amountFiat + providerFeeFiat). */ + totalFiat: string; + /** Total amount in USD. */ + totalUsd: string; + /** Estimated duration in seconds. */ + estimatedDurationSeconds: number; +}; + +/** Data for a fiat payment selection. */ +export type FiatPaymentData = { + /** Provider ID (PoC: 'transak'). */ + providerId: string; + /** Provider method to use for payment. (PoC: 'credit_debit_card' | 'apple_pay') */ + method: string; + /** Provider method name to use for payment. (PoC: 'Credit Debit Card' | 'Apple Pay') */ + methodName: string; + /** User-entered amount in fiat (e.g. "50"). */ + amountFiat?: string; + /** Fiat currency (PoC: 'usd'). */ + fiatCurrency?: 'usd'; +}; + export type AllowedActions = | AccountTrackerControllerGetStateAction | BridgeControllerActions @@ -76,7 +104,10 @@ export type TransactionPayControllerGetDelegationTransactionAction = { /** Action to get the pay strategy type used for a transaction. */ export type TransactionPayControllerGetStrategyAction = { type: `${typeof CONTROLLER_NAME}:getStrategy`; - handler: (transaction: TransactionMeta) => TransactionPayStrategy; + handler: ( + transaction: TransactionMeta, + transactionData?: TransactionData, + ) => TransactionPayStrategy; }; /** Action to update the payment token for a transaction. */ @@ -91,6 +122,15 @@ export type TransactionPayControllerSetIsMaxAmountAction = { handler: (transactionId: string, isMaxAmount: boolean) => void; }; +/** Action to set the fiat payment data for a transaction. */ +export type TransactionPayControllerSetFiatPaymentAction = { + type: `${typeof CONTROLLER_NAME}:setFiatPayment`; + handler: ( + transactionId: string, + fiatPayment: FiatPaymentData | undefined, + ) => void; +}; + export type TransactionPayControllerStateChangeEvent = ControllerStateChangeEvent< typeof CONTROLLER_NAME, @@ -101,6 +141,7 @@ export type TransactionPayControllerActions = | TransactionPayControllerGetDelegationTransactionAction | TransactionPayControllerGetStateAction | TransactionPayControllerGetStrategyAction + | TransactionPayControllerSetFiatPaymentAction | TransactionPayControllerSetIsMaxAmountAction | TransactionPayControllerUpdatePaymentTokenAction; @@ -119,7 +160,10 @@ export type TransactionPayControllerOptions = { getDelegationTransaction: GetDelegationTransactionCallback; /** Callback to select the PayStrategy for a transaction. */ - getStrategy?: (transaction: TransactionMeta) => TransactionPayStrategy; + getStrategy?: ( + transaction: TransactionMeta, + transactionData?: TransactionData, + ) => TransactionPayStrategy; /** Controller messenger. */ messenger: TransactionPayControllerMessenger; @@ -159,6 +203,9 @@ export type TransactionData = { /** Calculated totals for the transaction. */ totals?: TransactionPayTotals; + + /** Fiat payment data if user selected fiat payment method. */ + fiatPayment?: FiatPaymentData; }; /** A token required by a transaction. */ diff --git a/packages/transaction-pay-controller/src/utils/quotes.ts b/packages/transaction-pay-controller/src/utils/quotes.ts index a242607fbf7..f09c71bafd2 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.ts @@ -53,18 +53,24 @@ export async function updateQuotes( return false; } + const isFiatSelected = Boolean(transactionData.fiatPayment); + log('Updating quotes', { transactionId }); const { isMaxAmount, paymentToken, sourceAmounts, tokens } = transactionData; - const requests = buildQuoteRequests({ - from: transaction.txParams.from as Hex, - isMaxAmount: isMaxAmount ?? false, - paymentToken, - sourceAmounts, - tokens, - transactionId, - }); + const requests = isFiatSelected + ? buildFiatQuoteRequests({ + tokens, + }) + : buildQuoteRequests({ + from: transaction.txParams.from as Hex, + isMaxAmount: isMaxAmount ?? false, + paymentToken, + sourceAmounts, + tokens, + transactionId, + }); updateTransactionData(transactionId, (data) => { data.isLoading = true; @@ -77,13 +83,15 @@ export async function updateQuotes( messenger, ); - const totals = calculateTotals({ - isMaxAmount, - messenger, - quotes: quotes as TransactionPayQuote[], - tokens, - transaction, - }); + const totals = isFiatSelected + ? (undefined as unknown as TransactionPayTotals) + : calculateTotals({ + isMaxAmount, + messenger, + quotes: quotes as TransactionPayQuote[], + tokens, + transaction, + }); log('Calculated totals', { transactionId, totals }); @@ -263,6 +271,29 @@ function buildQuoteRequests({ return requests; } +function buildFiatQuoteRequests({ + tokens, +}: { + tokens: TransactionPayRequiredToken[]; + // TODO: fix type + // eslint-disable-next-line @typescript-eslint/no-explicit-any +}): any[] { + const primary = tokens[0]; + if (!primary) { + return []; + } + + return [ + { + sourceChainId: primary.chainId, + sourceTokenAddress: primary.address, + targetAmountMinimum: primary.allowUnderMinimum ? '0' : primary.amountRaw, + targetChainId: primary.chainId, + targetTokenAddress: primary.address, + }, + ]; +} + /** * Retrieve quotes for a transaction. * diff --git a/packages/transaction-pay-controller/src/utils/source-amounts.ts b/packages/transaction-pay-controller/src/utils/source-amounts.ts index f98cd422c37..a6f1d52498c 100644 --- a/packages/transaction-pay-controller/src/utils/source-amounts.ts +++ b/packages/transaction-pay-controller/src/utils/source-amounts.ts @@ -31,7 +31,7 @@ export function updateSourceAmounts( transactionData: TransactionData | undefined, messenger: TransactionPayControllerMessenger, ): void { - if (!transactionData) { + if (!transactionData || transactionData.fiatPayment) { return; } diff --git a/packages/transaction-pay-controller/src/utils/strategy.ts b/packages/transaction-pay-controller/src/utils/strategy.ts index dc505431f4e..ae9faca66b4 100644 --- a/packages/transaction-pay-controller/src/utils/strategy.ts +++ b/packages/transaction-pay-controller/src/utils/strategy.ts @@ -2,6 +2,7 @@ import type { TransactionMeta } from '@metamask/transaction-controller'; 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, TransactionPayControllerMessenger } from '../types'; @@ -44,6 +45,9 @@ export function getStrategyByName( case TransactionPayStrategy.Test: return new TestStrategy() as never; + case TransactionPayStrategy.Fiat: + return new FiatStrategy() as never; + default: throw new Error(`Unknown strategy: ${strategyName as string}`); }