diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 5dba1cf5a1e..3aac669f2c5 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 + +- Calculate totals even if no quotes received ([#7042](https://github.com/MetaMask/core/pull/7042)) + ## [3.0.0] ### Changed diff --git a/packages/transaction-pay-controller/src/utils/quotes.test.ts b/packages/transaction-pay-controller/src/utils/quotes.test.ts index 7d9a43c9fb8..129a32f492f 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.test.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.test.ts @@ -187,7 +187,7 @@ describe('Quotes Utils', () => { }); }); - it('does nothing if no payment token', async () => { + it('clears state if no payment token', async () => { await run({ transactionData: { ...TRANSACTION_DATA_MOCK, @@ -195,7 +195,19 @@ describe('Quotes Utils', () => { }, }); - expect(updateTransactionDataMock).not.toHaveBeenCalled(); + const transactionDataMock = { + quotes: [QUOTE_MOCK], + quotesLastUpdated: undefined, + }; + + updateTransactionDataMock.mock.calls.map((call) => + call[1](transactionDataMock), + ); + + expect(transactionDataMock).toMatchObject({ + quotes: [], + quotesLastUpdated: expect.any(Number), + }); }); it('gets quotes from strategy', async () => { diff --git a/packages/transaction-pay-controller/src/utils/quotes.ts b/packages/transaction-pay-controller/src/utils/quotes.ts index ff713afc618..b784722de1d 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.ts @@ -13,6 +13,7 @@ import type { TransactionPayControllerMessenger, TransactionPayQuote, TransactionPayRequiredToken, + TransactionPaySourceAmount, TransactionPayTotals, TransactionPaymentToken, UpdateTransactionDataCallback, @@ -48,65 +49,31 @@ export async function updateQuotes(request: UpdateQuotesRequest) { const { paymentToken, sourceAmounts, tokens } = transactionData; - if (!paymentToken) { - return; - } - - const requests: QuoteRequest[] = (sourceAmounts ?? []).map((sourceAmount) => { - const token = tokens.find( - (t) => t.address === sourceAmount.targetTokenAddress, - ) as TransactionPayRequiredToken; - - return { - from: transaction.txParams.from as Hex, - sourceBalanceRaw: paymentToken.balanceRaw, - sourceTokenAmount: sourceAmount.sourceAmountRaw, - sourceChainId: paymentToken.chainId, - sourceTokenAddress: paymentToken.address, - targetAmountMinimum: token.allowUnderMinimum ? '0' : token.amountRaw, - targetChainId: token.chainId, - targetTokenAddress: token.address, - }; + const requests = buildQuoteRequests({ + from: transaction.txParams.from as Hex, + paymentToken, + sourceAmounts, + tokens, + transactionId, }); - if (!requests?.length) { - log('No quote requests', { transactionId }); - } - - let quotes: TransactionPayQuote[] | undefined = []; - updateTransactionData(transactionId, (data) => { data.isLoading = true; }); try { - const strategy = await getStrategy(messenger as never, transaction); - - try { - quotes = requests?.length - ? ((await strategy.getQuotes({ - messenger, - requests, - transaction, - })) as TransactionPayQuote[]) - : []; - } catch (error) { - log('Error fetching quotes', { error, transactionId }); - } - - log('Updated', { transactionId, quotes }); - - const batchTransactions = - quotes?.length && strategy.getBatchTransactions - ? await strategy.getBatchTransactions({ - messenger, - quotes, - }) - : []; - - log('Batch transactions', { transactionId, batchTransactions }); + const { batchTransactions, quotes } = await getQuotes( + transaction, + requests, + messenger, + ); - const totals = calculateTotals(quotes as never, tokens, messenger); + const totals = calculateTotals({ + quotes: quotes as TransactionPayQuote[], + messenger, + tokens, + transaction, + }); log('Calculated totals', { transactionId, totals }); @@ -149,10 +116,14 @@ function syncTransaction({ }: { batchTransactions: BatchTransaction[]; messenger: TransactionPayControllerMessenger; - paymentToken: TransactionPaymentToken; + paymentToken: TransactionPaymentToken | undefined; totals: TransactionPayTotals; transactionId: string; }) { + if (!paymentToken) { + return; + } + updateTransaction( { transactionId, @@ -226,3 +197,102 @@ export async function refreshQuotes( log('Refreshed quotes', { transactionId, strategy: strategyName }); } } + +/** + * Build quote requests required to retrieve quotes. + * + * @param request - Request parameters. + * @param request.from - Address from which the transaction is sent. + * @param request.paymentToken - Payment token used for the transaction. + * @param request.sourceAmounts - Source amounts for the transaction. + * @param request.tokens - Required tokens for the transaction. + * @param request.transactionId - ID of the transaction. + * @returns Array of quote requests. + */ +function buildQuoteRequests({ + from, + paymentToken, + sourceAmounts, + tokens, + transactionId, +}: { + from: Hex; + paymentToken: TransactionPaymentToken | undefined; + sourceAmounts: TransactionPaySourceAmount[] | undefined; + tokens: TransactionPayRequiredToken[]; + transactionId: string; +}): QuoteRequest[] { + if (!paymentToken) { + return []; + } + + const requests = (sourceAmounts ?? []).map((sourceAmount) => { + const token = tokens.find( + (t) => t.address === sourceAmount.targetTokenAddress, + ) as TransactionPayRequiredToken; + + return { + from, + sourceBalanceRaw: paymentToken.balanceRaw, + sourceTokenAmount: sourceAmount.sourceAmountRaw, + sourceChainId: paymentToken.chainId, + sourceTokenAddress: paymentToken.address, + targetAmountMinimum: token.allowUnderMinimum ? '0' : token.amountRaw, + targetChainId: token.chainId, + targetTokenAddress: token.address, + }; + }); + + if (!requests.length) { + log('No quote requests', { transactionId }); + } + + return requests; +} + +/** + * Retrieve quotes for a transaction. + * + * @param transaction - Transaction metadata. + * @param requests - Quote requests. + * @param messenger - Controller messenger. + * @returns An object containing batch transactions and quotes. + */ +async function getQuotes( + transaction: TransactionMeta, + requests: QuoteRequest[], + messenger: TransactionPayControllerMessenger, +) { + const { id: transactionId } = transaction; + const strategy = await getStrategy(messenger as never, transaction); + let quotes: TransactionPayQuote[] | undefined = []; + + try { + quotes = requests?.length + ? ((await strategy.getQuotes({ + messenger, + requests, + transaction, + })) as TransactionPayQuote[]) + : []; + } catch (error) { + log('Error fetching quotes', { error, transactionId }); + } + + log('Updated', { transactionId, quotes }); + + const batchTransactions = + quotes?.length && strategy.getBatchTransactions + ? await strategy.getBatchTransactions({ + messenger, + quotes, + }) + : []; + + log('Batch transactions', { transactionId, batchTransactions }); + + return { + batchTransactions, + quotes, + }; +} diff --git a/packages/transaction-pay-controller/src/utils/totals.test.ts b/packages/transaction-pay-controller/src/utils/totals.test.ts index 50adc9be304..734e0926e96 100644 --- a/packages/transaction-pay-controller/src/utils/totals.test.ts +++ b/packages/transaction-pay-controller/src/utils/totals.test.ts @@ -1,3 +1,6 @@ +import type { TransactionMeta } from '@metamask/transaction-controller'; + +import { calculateTransactionGasCost } from './gas'; import { calculateTotals } from './totals'; import { TransactionPayStrategy, @@ -9,6 +12,8 @@ import type { TransactionPayRequiredToken, } from '../types'; +jest.mock('./gas'); + const MESSENGER_MOCK = {} as TransactionPayControllerMessenger; const QUOTE_1_MOCK: TransactionPayQuote = { @@ -71,33 +76,50 @@ const QUOTE_2_MOCK: TransactionPayQuote = { strategy: TransactionPayStrategy.Test, }; +const TRANSACTION_META_MOCK = {} as TransactionMeta; + describe('Totals Utils', () => { + const calculateTransactionGasCostMock = jest.mocked( + calculateTransactionGasCost, + ); + + beforeEach(() => { + jest.resetAllMocks(); + + calculateTransactionGasCostMock.mockReturnValue({ + fiat: '1.23', + usd: '2.34', + }); + }); + describe('calculateTotals', () => { it('returns estimated duration', () => { - const result = calculateTotals( - [QUOTE_1_MOCK, QUOTE_2_MOCK], - [], - MESSENGER_MOCK, - ); + const result = calculateTotals({ + quotes: [QUOTE_1_MOCK, QUOTE_2_MOCK], + tokens: [], + messenger: MESSENGER_MOCK, + transaction: TRANSACTION_META_MOCK, + }); expect(result.estimatedDuration).toBe(357); }); it('returns total', () => { - const result = calculateTotals( - [QUOTE_1_MOCK, QUOTE_2_MOCK], - [TOKEN_1_MOCK, TOKEN_2_MOCK], - MESSENGER_MOCK, - ); + const result = calculateTotals({ + quotes: [QUOTE_1_MOCK, QUOTE_2_MOCK], + tokens: [TOKEN_1_MOCK, TOKEN_2_MOCK], + messenger: MESSENGER_MOCK, + transaction: TRANSACTION_META_MOCK, + }); expect(result.total.fiat).toBe('43.3'); expect(result.total.usd).toBe('51.08'); }); it('returns total excluding token amount not in quote', () => { - const result = calculateTotals( - [QUOTE_1_MOCK, QUOTE_2_MOCK], - [ + const result = calculateTotals({ + quotes: [QUOTE_1_MOCK, QUOTE_2_MOCK], + tokens: [ TOKEN_1_MOCK, { ...TOKEN_2_MOCK, @@ -106,44 +128,60 @@ describe('Totals Utils', () => { skipIfBalance: true, }, ], - MESSENGER_MOCK, - ); + messenger: MESSENGER_MOCK, + transaction: TRANSACTION_META_MOCK, + }); expect(result.total.fiat).toBe('39.97'); expect(result.total.usd).toBe('46.64'); }); it('returns provider fees', () => { - const result = calculateTotals( - [QUOTE_1_MOCK, QUOTE_2_MOCK], - [], - MESSENGER_MOCK, - ); + const result = calculateTotals({ + quotes: [QUOTE_1_MOCK, QUOTE_2_MOCK], + tokens: [], + messenger: MESSENGER_MOCK, + transaction: TRANSACTION_META_MOCK, + }); expect(result.fees.provider.fiat).toBe('8.88'); expect(result.fees.provider.usd).toBe('11.1'); }); it('returns source network fees', () => { - const result = calculateTotals( - [QUOTE_1_MOCK, QUOTE_2_MOCK], - [], - MESSENGER_MOCK, - ); + const result = calculateTotals({ + quotes: [QUOTE_1_MOCK, QUOTE_2_MOCK], + tokens: [], + messenger: MESSENGER_MOCK, + transaction: TRANSACTION_META_MOCK, + }); expect(result.fees.sourceNetwork.fiat).toBe('13.32'); expect(result.fees.sourceNetwork.usd).toBe('14.54'); }); it('returns target network fees', () => { - const result = calculateTotals( - [QUOTE_1_MOCK, QUOTE_2_MOCK], - [], - MESSENGER_MOCK, - ); + const result = calculateTotals({ + quotes: [QUOTE_1_MOCK, QUOTE_2_MOCK], + tokens: [], + messenger: MESSENGER_MOCK, + transaction: TRANSACTION_META_MOCK, + }); expect(result.fees.targetNetwork.fiat).toBe('16.66'); expect(result.fees.targetNetwork.usd).toBe('18.78'); }); + + it('returns target network fee as transaction fee if no quotes', () => { + const result = calculateTotals({ + quotes: [], + tokens: [], + messenger: MESSENGER_MOCK, + transaction: TRANSACTION_META_MOCK, + }); + + expect(result.fees.targetNetwork.fiat).toBe('1.23'); + expect(result.fees.targetNetwork.usd).toBe('2.34'); + }); }); }); diff --git a/packages/transaction-pay-controller/src/utils/totals.ts b/packages/transaction-pay-controller/src/utils/totals.ts index 103bb49c128..6102949df1e 100644 --- a/packages/transaction-pay-controller/src/utils/totals.ts +++ b/packages/transaction-pay-controller/src/utils/totals.ts @@ -1,5 +1,7 @@ +import type { TransactionMeta } from '@metamask/transaction-controller'; import { BigNumber } from 'bignumber.js'; +import { calculateTransactionGasCost } from './gas'; import type { TransactionPayControllerMessenger, TransactionPayQuote, @@ -10,16 +12,24 @@ import type { /** * Calculate totals for a list of quotes and tokens. * - * @param quotes - List of bridge quotes. - * @param tokens - List of required transaction tokens. - * @param _messenger - Controller messenger. + * @param request - Request parameters. + * @param request.quotes - List of bridge quotes. + * @param request.messenger - Controller messenger. + * @param request.tokens - List of required tokens. + * @param request.transaction - Transaction metadata. * @returns The calculated totals in USD and fiat currency. */ -export function calculateTotals( - quotes: TransactionPayQuote[], - tokens: TransactionPayRequiredToken[], - _messenger: TransactionPayControllerMessenger, -): TransactionPayTotals { +export function calculateTotals({ + quotes, + messenger, + tokens, + transaction, +}: { + quotes: TransactionPayQuote[]; + messenger: TransactionPayControllerMessenger; + tokens: TransactionPayRequiredToken[]; + transaction: TransactionMeta; +}): TransactionPayTotals { const providerFeeFiat = sumProperty( quotes, (quote) => quote.fees.provider.fiat, @@ -40,15 +50,18 @@ export function calculateTotals( (quote) => quote.fees.sourceNetwork.usd, ); - const targetNetworkFeeFiat = sumProperty( - quotes, - (quote) => quote.fees.targetNetwork.fiat, + const transactionNetworkFee = calculateTransactionGasCost( + transaction, + messenger, ); - const targetNetworkFeeUsd = sumProperty( - quotes, - (quote) => quote.fees.targetNetwork.usd, - ); + const targetNetworkFeeFiat = quotes?.length + ? sumProperty(quotes, (quote) => quote.fees.targetNetwork.fiat) + : transactionNetworkFee.fiat; + + const targetNetworkFeeUsd = quotes.length + ? sumProperty(quotes, (quote) => quote.fees.targetNetwork.usd) + : transactionNetworkFee.usd; const quoteTokens = tokens.filter( (t) =>