diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 96b301bed6f..c716ff78a85 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Use `EXACT_INPUT` Relay quotes when no transaction is embedded, expose optional `TransactionPayQuote.isInputBased` and `TransactionPayTotals.isInputBased` flags, and preserve `EXACT_OUTPUT` for embedded transactions such as Money Account calls ([#9954](https://github.com/MetaMask/core/pull/9954)) - Bump `@metamask/sentinel-api-service` from `^1.0.0` to `^1.0.1` ([#9972](https://github.com/MetaMask/core/pull/9972)) ## [27.0.0] diff --git a/packages/transaction-pay-controller/src/strategy/across/across-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/across/across-quotes.test.ts index 4af8a0553c8..33df6b264f6 100644 --- a/packages/transaction-pay-controller/src/strategy/across/across-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/across/across-quotes.test.ts @@ -321,13 +321,15 @@ describe('Across Quotes', () => { json: async () => QUOTE_MOCK, } as Response); - await getAcrossQuotes({ + const result = await getAcrossQuotes({ accountSupports7702: true, messenger, requests: [{ ...QUOTE_REQUEST_MOCK, isMaxAmount: true }], transaction: TRANSACTION_META_MOCK, }); + expect(result[0].isInputBased).toBe(true); + const [url] = successfulFetchMock.mock.calls[0]; const params = new URL(url as string).searchParams; diff --git a/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts b/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts index edb775d9913..a6ea9dc6cf1 100644 --- a/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/across/across-quotes.ts @@ -428,6 +428,7 @@ async function normalizeQuote( sourceNetwork, targetNetwork, }, + isInputBased: original.request.tradeType === 'exactInput', original: { ...original, metamask, diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd.test.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd.test.ts index 785b82cf095..2181285c0ad 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd.test.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd.test.ts @@ -183,6 +183,7 @@ describe('fiat-direct-musd', () => { }, targetNetwork: { fiat: '0', usd: '0' }, }), + isInputBased: false, original: { rampsQuote: RAMPS_QUOTE_MOCK, relayQuote: undefined }, request: expect.objectContaining({ from: MONEY_ACCOUNT_ADDRESS_MOCK, diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd.ts index e1efce2806e..9140f0470f9 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd.ts @@ -205,6 +205,7 @@ function combineDirectMusdFiatQuote({ }, targetNetwork: { fiat: '0', usd: '0' }, }, + isInputBased: false, original: { rampsQuote: fiatQuote, relayQuote: undefined, diff --git a/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.ts b/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.ts index 7591200dbb2..3fa526df88b 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.ts @@ -132,7 +132,7 @@ export async function buildPolymarketDepositWalletSimulation( } export async function applyPolymarketDepositWalletOverrides( - body: RelayQuoteRequest, + body: Omit, request: QuoteRequest, messenger: TransactionPayControllerMessenger, ): Promise { diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts index 03487050b8e..55036405c19 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts @@ -290,13 +290,13 @@ describe('Relay Quotes Utils', () => { expect(body).toStrictEqual( expect.objectContaining({ - amount: QUOTE_REQUEST_MOCK.targetAmountMinimum, + amount: QUOTE_REQUEST_MOCK.sourceTokenAmount, destinationChainId: 2, destinationCurrency: QUOTE_REQUEST_MOCK.targetTokenAddress, originChainId: 1, originCurrency: QUOTE_REQUEST_MOCK.sourceTokenAddress, recipient: QUOTE_REQUEST_MOCK.from, - tradeType: 'EXPECTED_OUTPUT', + tradeType: 'EXACT_INPUT', user: QUOTE_REQUEST_MOCK.from, }), ); @@ -379,13 +379,84 @@ describe('Relay Quotes Utils', () => { json: async () => QUOTE_MOCK, } as never); - await getRelayQuotes({ + const result = await getRelayQuotes({ accountSupports7702: true, messenger, requests: [{ ...QUOTE_REQUEST_MOCK, isMaxAmount: true }], transaction: TRANSACTION_META_MOCK, }); + expect(result[0].isInputBased).toBe(true); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + + expect(body).toStrictEqual( + expect.objectContaining({ + amount: QUOTE_REQUEST_MOCK.sourceTokenAmount, + tradeType: 'EXACT_INPUT', + }), + ); + }); + + it('sends request with EXACT_INPUT trade type for token transfers', async () => { + const quoteMock = cloneDeep(QUOTE_MOCK); + quoteMock.details.currencyOut.minimumAmount = '100'; + + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => quoteMock, + } as never); + + const result = await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: { + ...TRANSACTION_META_MOCK, + txParams: { + data: TOKEN_TRANSFER_DATA_MOCK, + }, + type: TransactionType.simpleSend, + }, + }); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + + expect(body).toStrictEqual( + expect.objectContaining({ + amount: QUOTE_REQUEST_MOCK.sourceTokenAmount, + tradeType: 'EXACT_INPUT', + }), + ); + expect(result[0].dust).toStrictEqual({ fiat: '0', usd: '0' }); + }); + + it('sends request with EXACT_INPUT trade type for nested token transfers', async () => { + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => QUOTE_MOCK, + } as never); + + await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [QUOTE_REQUEST_MOCK], + transaction: { + ...TRANSACTION_META_MOCK, + nestedTransactions: [ + { + data: TOKEN_TRANSFER_DATA_MOCK, + type: TransactionType.simpleSend, + }, + ], + type: TransactionType.batch, + }, + }); + const body = JSON.parse( successfulFetchMock.mock.calls[0][1]?.body as string, ); @@ -440,6 +511,7 @@ describe('Relay Quotes Utils', () => { expect(body).toStrictEqual( expect.objectContaining({ + amount: QUOTE_REQUEST_MOCK.targetAmountMinimum, authorizationList: [ { chainId: 1, @@ -540,14 +612,14 @@ describe('Relay Quotes Utils', () => { }); expect(result[0].original.request).toStrictEqual({ - amount: QUOTE_REQUEST_MOCK.targetAmountMinimum, + amount: QUOTE_REQUEST_MOCK.sourceTokenAmount, destinationChainId: 2, destinationCurrency: QUOTE_REQUEST_MOCK.targetTokenAddress, originChainId: 1, originCurrency: QUOTE_REQUEST_MOCK.sourceTokenAddress, recipient: QUOTE_REQUEST_MOCK.from, slippageTolerance: '50', - tradeType: 'EXPECTED_OUTPUT', + tradeType: 'EXACT_INPUT', user: QUOTE_REQUEST_MOCK.from, }); }); @@ -3878,7 +3950,7 @@ describe('Relay Quotes Utils', () => { ).rejects.toThrow(`Source token fiat rate not found`); }); - it('updates request if Arbitrum deposit to Hyperliquid', async () => { + it('requests exact input for Hyperliquid deposits without embedded transactions', async () => { const arbitrumToHyperliquidRequest: QuoteRequest = { ...QUOTE_REQUEST_MOCK, targetChainId: CHAIN_ID_ARBITRUM, @@ -3906,17 +3978,15 @@ describe('Relay Quotes Utils', () => { expect(body).toStrictEqual( expect.objectContaining({ - amount: '12300', + amount: QUOTE_REQUEST_MOCK.sourceTokenAmount, destinationChainId: 1337, destinationCurrency: '0x00000000000000000000000000000000', + tradeType: 'EXACT_INPUT', }), ); }); - // A HyperCore deposit funds an order that needs the whole target as margin. - // EXPECTED_OUTPUT only guarantees `target * (1 - slippage)`, which leaves the - // follow-on order short and it fails on insufficient margin. - it('requests an exact output for Hyperliquid deposits so the full margin is guaranteed', async () => { + it('requests exact input for Hyperliquid deposit-and-order flows without embedded transactions', async () => { const arbitrumToHyperliquidRequest: QuoteRequest = { ...QUOTE_REQUEST_MOCK, targetChainId: CHAIN_ID_ARBITRUM, @@ -3942,10 +4012,15 @@ describe('Relay Quotes Utils', () => { successfulFetchMock.mock.calls[0][1]?.body as string, ); - expect(body.tradeType).toBe('EXACT_OUTPUT'); + expect(body).toStrictEqual( + expect.objectContaining({ + amount: QUOTE_REQUEST_MOCK.sourceTokenAmount, + tradeType: 'EXACT_INPUT', + }), + ); }); - it('still requests an expected output for non-Hyperliquid targets', async () => { + it('requests exact input for non-Hyperliquid targets without embedded transactions', async () => { successfulFetchMock.mockResolvedValue({ ok: true, json: async () => QUOTE_MOCK, @@ -3962,7 +4037,12 @@ describe('Relay Quotes Utils', () => { successfulFetchMock.mock.calls[0][1]?.body as string, ); - expect(body.tradeType).toBe('EXPECTED_OUTPUT'); + expect(body).toStrictEqual( + expect.objectContaining({ + amount: QUOTE_REQUEST_MOCK.sourceTokenAmount, + tradeType: 'EXACT_INPUT', + }), + ); }); it('does not convert to Hyperliquid deposit when parent transaction is not a Perps deposit', async () => { 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 01e684698ac..10748d73df3 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -82,6 +82,7 @@ const POST_QUOTE_GAS_BUFFER = 1.1; const PAYMENT_OVERRIDE_GAS = 75_000; const ZERO_AMOUNT = { fiat: '0', human: '0', raw: '0', usd: '0' }; +type RelayQuoteRequestDraft = Omit; type RelayStepData = RelayTransactionStep['items'][0]['data']; type RelayGasResult = { @@ -283,7 +284,6 @@ async function getSingleQuote( const { from, - isMaxAmount, sourceChainId, sourceTokenAddress, sourceTokenAmount, @@ -303,19 +303,6 @@ async function getSingleQuote( ); try { - // For post-quote or max amount flows, use EXACT_INPUT - user specifies how much to send, - // and we show them how much they'll receive after fees. - // For regular flows with a target amount, use EXPECTED_OUTPUT, except - // HyperCore deposits, which need a guaranteed amount (see below). - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const useExactInput = isMaxAmount || request.isPostQuote; - - // HyperCore perps deposits fund an order that requires the full target as - // margin, so the delivered amount must be guaranteed rather than expected. - // EXPECTED_OUTPUT only guarantees `target * (1 - slippage)`, which leaves - // the follow-on order short and it fails on insufficient margin. - const useExactOutput = !useExactInput && isHypercoreDeposit(request); - const useExecute = supports7702 && isRelayExecuteEnabled(messenger) && @@ -331,8 +318,7 @@ async function getSingleQuote( ? { ...request, recipient: nonAtomicRecipient } : request; - const body: RelayQuoteRequest = { - amount: useExactInput ? sourceTokenAmount : targetAmountMinimum, + const body: RelayQuoteRequestDraft = { destinationChainId: Number(targetChainId), destinationCurrency: targetTokenAddress, originChainId: Number(sourceChainId), @@ -345,7 +331,6 @@ async function getSingleQuote( : {}), recipient: effectiveRequest.recipient ?? from, slippageTolerance, - tradeType: getTradeType(useExactInput, useExactOutput), user: from, }; @@ -366,13 +351,15 @@ async function getSingleQuote( messenger, ); + let transactionAmount: string | undefined; + if ( !processedTransactions && isAtomic && effectiveRequest.isPostQuote && effectiveRequest.paymentOverride === PaymentOverride.MoneyAccount ) { - await processMoneyAccountPostQuote( + transactionAmount = await processMoneyAccountPostQuote( transaction, effectiveRequest, body, @@ -385,9 +372,18 @@ async function getSingleQuote( body.refundTo = effectiveRequest.refundTo; } - log('Request body', body); + const hasTransactions = Boolean(body.txs?.length); + const finalBody: RelayQuoteRequest = { + ...body, + amount: + transactionAmount ?? + (hasTransactions ? targetAmountMinimum : sourceTokenAmount), + tradeType: hasTransactions ? 'EXACT_OUTPUT' : 'EXACT_INPUT', + }; - const quote = await fetchRelayQuote(messenger, body, signal); + log('Request body', finalBody); + + const quote = await fetchRelayQuote(messenger, finalBody, signal); log('Fetched relay quote', quote); @@ -472,7 +468,7 @@ async function resolveNonAtomicRecipient( async function processTransactions( transaction: TransactionMeta, request: QuoteRequest, - requestBody: RelayQuoteRequest, + requestBody: RelayQuoteRequestDraft, messenger: TransactionPayControllerMessenger, ): Promise { // Skip when skipProcessTransactions (defaulting to isPostQuote) is set — the @@ -526,7 +522,6 @@ async function processTransactions( requestBody.authorizationList = normalizeAuthorizationList( delegation.authorizationList, ); - requestBody.tradeType = 'EXACT_OUTPUT'; const tokenTransferData = nestedTransactions?.find((nestedTx) => nestedTx.data?.startsWith(TOKEN_TRANSFER_FOUR_BYTE), @@ -563,9 +558,9 @@ async function processTransactions( async function processMoneyAccountPostQuote( transaction: TransactionMeta, request: QuoteRequest, - requestBody: RelayQuoteRequest, + requestBody: RelayQuoteRequestDraft, messenger: TransactionPayControllerMessenger, -): Promise { +): Promise { const { transactionData: transactionDataList } = messenger.call( 'TransactionPayController:getState', ); @@ -585,15 +580,13 @@ async function processMoneyAccountPostQuote( if (!overrideCalls.length) { log('No payment override calls for money account post-quote'); - return; + return undefined; } const fundingRecipient = recipient ?? request.from; const rawAmount = transactionData?.tokens?.[0]?.amountRaw ?? '0'; requestBody.authorizationList = normalizeAuthorizationList(authorizationList); - requestBody.tradeType = 'EXACT_OUTPUT'; - requestBody.amount = rawAmount; requestBody.txs = [ { to: request.targetTokenAddress, @@ -610,42 +603,8 @@ async function processMoneyAccountPostQuote( log('Added money account deposit calls to quote body', { callCount: overrideCalls.length, }); -} - -/** - * Whether the quote deposits into HyperCore USDC. - * - * `normalizeRequest` remaps Arbitrum-USDC perps deposits to HyperCore before - * the quote is built, so the check is against the normalized target. - * - * @param request - Normalized quote request. - * @returns True when the target is HyperCore USDC. - */ -function isHypercoreDeposit(request: QuoteRequest): boolean { - return ( - !request.isHyperliquidSource && - request.targetChainId === CHAIN_ID_HYPERCORE && - request.targetTokenAddress.toLowerCase() === - HYPERCORE_USDC_ADDRESS.toLowerCase() - ); -} - -/** - * Resolve the Relay trade type for a quote. - * - * @param useExactInput - Whether the user specified the amount to send. - * @param useExactOutput - Whether the delivered amount must be guaranteed. - * @returns The Relay trade type. - */ -function getTradeType( - useExactInput: boolean | undefined, - useExactOutput: boolean, -): RelayQuoteRequest['tradeType'] { - if (useExactInput) { - return 'EXACT_INPUT'; - } - return useExactOutput ? 'EXACT_OUTPUT' : 'EXPECTED_OUTPUT'; + return rawAmount; } /** @@ -806,6 +765,7 @@ async function normalizeQuote( sourceNetwork, targetNetwork, }, + isInputBased: quote.request.tradeType === 'EXACT_INPUT', original: { ...quote, metamask, @@ -831,8 +791,9 @@ function calculateDustUsd(quote: RelayQuote, request: QuoteRequest): BigNumber { const targetUsdRate = new BigNumber(amountUsd).dividedBy(amountFormatted); - const dustRaw = new BigNumber(minimumAmount).minus( - request.targetAmountMinimum, + const dustRaw = BigNumber.maximum( + new BigNumber(minimumAmount).minus(request.targetAmountMinimum), + 0, ); return dustRaw.shiftedBy(-targetDecimals).multipliedBy(targetUsdRate); diff --git a/packages/transaction-pay-controller/src/strategy/server/server-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/server/server-quotes.test.ts index bd28a89abd5..d2fb37a82ac 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-quotes.test.ts @@ -201,14 +201,15 @@ describe('server-quotes', () => { ); }); - it('maps max-amount transactions to EXACT_INPUT quote requests', async () => { - await getServerQuotes({ + it('maps max-amount transactions to input-based EXACT_INPUT quotes', async () => { + const result = await getServerQuotes({ accountSupports7702: true, messenger, requests: [{ ...QUOTE_REQUEST_MOCK, isMaxAmount: true }], transaction: TRANSACTION_META_MOCK, }); + expect(result[0].isInputBased).toBe(true); expect(fetchServerQuoteMock).toHaveBeenCalledWith( messenger, expect.objectContaining({ @@ -412,6 +413,7 @@ describe('server-quotes', () => { }, targetNetwork: { fiat: '0', usd: '0' }, }, + isInputBased: false, original: { client: { gasLimits: [], diff --git a/packages/transaction-pay-controller/src/strategy/server/server-quotes.ts b/packages/transaction-pay-controller/src/strategy/server/server-quotes.ts index 12a4fc649ab..9c14ad7a3c9 100644 --- a/packages/transaction-pay-controller/src/strategy/server/server-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/server/server-quotes.ts @@ -124,7 +124,12 @@ async function getQuotesForRequest( const normalized = await Promise.all( fulfilledResults.map((result) => - normalizeQuote(result, quoteRequest, messenger), + normalizeQuote( + result, + quoteRequest, + messenger, + body.tradeType === ServerTradeType.ExactInput, + ), ), ); @@ -322,6 +327,7 @@ async function normalizeQuote( result: FulfilledServerQuoteResult, quoteRequest: QuoteRequest, messenger: TransactionPayControllerMessenger, + isInputBased: boolean, ): Promise> { const { quote } = result; const { gasless } = quote; @@ -369,6 +375,7 @@ async function normalizeQuote( }, targetNetwork: ZERO_FIAT_VALUE, }, + isInputBased, original: { client: { gasLimits: sourceNetwork.gasLimits, diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index d940c0dbcdc..d029e1e85b7 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -673,6 +673,9 @@ export type TransactionPayQuote = { /** Fees associated with the transaction pay quote. */ fees: TransactionPayFees; + /** Whether fees are subtracted from the destination amount, meaning the input amount is static. */ + isInputBased?: boolean; + /** Raw quote data returned by the provider. */ original: OriginalQuote; @@ -838,6 +841,9 @@ export type TransactionPayTotals = { /** Total fees for the target transaction and all quotes. */ fees: TransactionPayFees; + /** Whether the selected quotes subtract fees from the destination amount, meaning the input amount is static. */ + isInputBased?: boolean; + /** Total amount of source token required. */ sourceAmount: Amount; diff --git a/packages/transaction-pay-controller/src/utils/no-op-quote.test.ts b/packages/transaction-pay-controller/src/utils/no-op-quote.test.ts index b955bcb3148..8363b8ede8d 100644 --- a/packages/transaction-pay-controller/src/utils/no-op-quote.test.ts +++ b/packages/transaction-pay-controller/src/utils/no-op-quote.test.ts @@ -20,6 +20,7 @@ describe('No-Op Quote Utils', () => { const quote = buildNoOpQuote(FROM_MOCK, PAYMENT_TOKEN_MOCK); expect(quote.strategy).toBe(TransactionPayStrategy.None); + expect(quote.isInputBased).toBe(false); }); it('builds quote with zero fees and amounts', () => { diff --git a/packages/transaction-pay-controller/src/utils/no-op-quote.ts b/packages/transaction-pay-controller/src/utils/no-op-quote.ts index a4bcec8d4fd..d42666e3fee 100644 --- a/packages/transaction-pay-controller/src/utils/no-op-quote.ts +++ b/packages/transaction-pay-controller/src/utils/no-op-quote.ts @@ -46,6 +46,7 @@ export function buildNoOpQuote( }, targetNetwork: zeroFiat(), }, + isInputBased: false, original: null, request: { from, diff --git a/packages/transaction-pay-controller/src/utils/quotes.test.ts b/packages/transaction-pay-controller/src/utils/quotes.test.ts index c0135333d94..ac3d246c11b 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.test.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.test.ts @@ -76,6 +76,7 @@ const QUOTE_MOCK = { } as TransactionPayQuote; const TOTALS_MOCK = { + isInputBased: false, fees: { provider: { fiat: '7.89', diff --git a/packages/transaction-pay-controller/src/utils/quotes.ts b/packages/transaction-pay-controller/src/utils/quotes.ts index 48cf9ff93f8..b12e45816ca 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.ts @@ -168,7 +168,6 @@ export async function updateQuotes( const totals = calculateTotals({ fiatPaymentAmount: fiatPayment?.amountFiat, - isMaxAmount, messenger, quotes: executableQuotes as TransactionPayQuote[], tokens, diff --git a/packages/transaction-pay-controller/src/utils/totals.test.ts b/packages/transaction-pay-controller/src/utils/totals.test.ts index 963acd8fb30..a3429114142 100644 --- a/packages/transaction-pay-controller/src/utils/totals.test.ts +++ b/packages/transaction-pay-controller/src/utils/totals.test.ts @@ -48,7 +48,8 @@ const QUOTE_1_MOCK: TransactionPayQuote = { usd: '6.66', }, }, - original: undefined, + isInputBased: false, + original: {}, request: {} as QuoteRequest, sourceAmount: { human: '7.77', @@ -108,7 +109,8 @@ const QUOTE_2_MOCK: TransactionPayQuote = { usd: '12.12', }, }, - original: undefined, + isInputBased: false, + original: {}, request: {} as QuoteRequest, sourceAmount: { human: '13.13', @@ -125,6 +127,13 @@ const QUOTE_2_MOCK: TransactionPayQuote = { const TRANSACTION_META_MOCK = {} as TransactionMeta; +function getQuote( + isInputBased: boolean, + quote: TransactionPayQuote = QUOTE_1_MOCK, +): TransactionPayQuote { + return { ...quote, isInputBased }; +} + describe('Totals Utils', () => { const calculateTransactionGasCostMock = jest.mocked( calculateTransactionGasCost, @@ -166,10 +175,9 @@ describe('Totals Utils', () => { expect(result.total.usd).toBe('52.08'); }); - it('returns adjusted total when isMaxAmount is true', () => { + it('returns adjusted total when all quotes are input-based', () => { const result = calculateTotals({ - isMaxAmount: true, - quotes: [QUOTE_1_MOCK, QUOTE_2_MOCK], + quotes: [getQuote(true), getQuote(true, QUOTE_2_MOCK)], tokens: [TOKEN_1_MOCK, TOKEN_2_MOCK], messenger: MESSENGER_MOCK, transaction: TRANSACTION_META_MOCK, @@ -179,6 +187,141 @@ describe('Totals Utils', () => { expect(result.total.usd).toBe('71.68'); }); + it('returns isInputBased true for an input-based quote', () => { + const result = calculateTotals({ + quotes: [getQuote(true)], + tokens: [], + messenger: MESSENGER_MOCK, + transaction: TRANSACTION_META_MOCK, + }); + + expect(result.isInputBased).toBe(true); + }); + + it('returns isInputBased false for an output-based quote', () => { + const result = calculateTotals({ + quotes: [getQuote(false)], + tokens: [], + messenger: MESSENGER_MOCK, + transaction: TRANSACTION_META_MOCK, + }); + + expect(result.isInputBased).toBe(false); + }); + + it('returns isInputBased true regardless of the strategy', () => { + const result = calculateTotals({ + quotes: [ + { + ...getQuote(true), + strategy: TransactionPayStrategy.Server, + }, + ], + tokens: [], + messenger: MESSENGER_MOCK, + transaction: TRANSACTION_META_MOCK, + }); + + expect(result.isInputBased).toBe(true); + }); + + it('returns isInputBased false when the property is omitted', () => { + const quoteWithoutInputBased = { ...QUOTE_1_MOCK }; + delete quoteWithoutInputBased.isInputBased; + + const result = calculateTotals({ + quotes: [quoteWithoutInputBased], + tokens: [], + messenger: MESSENGER_MOCK, + transaction: TRANSACTION_META_MOCK, + }); + + expect(result.isInputBased).toBe(false); + }); + + it('returns isInputBased false when there are no quotes', () => { + const result = calculateTotals({ + quotes: [], + tokens: [], + messenger: MESSENGER_MOCK, + transaction: TRANSACTION_META_MOCK, + }); + + expect(result.isInputBased).toBe(false); + }); + + it('returns isInputBased false for mixed input- and output-based quotes', () => { + const result = calculateTotals({ + quotes: [getQuote(true), QUOTE_2_MOCK], + tokens: [], + messenger: MESSENGER_MOCK, + transaction: TRANSACTION_META_MOCK, + }); + + expect(result.isInputBased).toBe(false); + }); + + it('returns isInputBased true when all aggregate quotes are input-based', () => { + const result = calculateTotals({ + quotes: [getQuote(true), getQuote(true, QUOTE_2_MOCK)], + tokens: [], + messenger: MESSENGER_MOCK, + transaction: TRANSACTION_META_MOCK, + }); + + expect(result.isInputBased).toBe(true); + }); + + it('does not add fees twice for input-based quotes', () => { + const quote = { + ...getQuote(true), + fees: { + ...QUOTE_1_MOCK.fees, + metaMask: { fiat: '0', usd: '0' }, + provider: { fiat: '1', usd: '1' }, + sourceNetwork: { + estimate: { + fiat: '0.5', + human: '0.5', + raw: '500000000000000000', + usd: '0.5', + }, + max: { + fiat: '0.6', + human: '0.6', + raw: '600000000000000000', + usd: '0.6', + }, + }, + targetNetwork: { fiat: '0', usd: '0' }, + }, + sourceAmount: { + fiat: '100', + human: '100', + raw: '100000000', + usd: '100', + }, + targetAmount: { fiat: '99', usd: '99' }, + }; + const token = { + ...TOKEN_1_MOCK, + amountFiat: '100', + amountUsd: '100', + }; + + const result = calculateTotals({ + quotes: [quote], + tokens: [token], + messenger: MESSENGER_MOCK, + transaction: TRANSACTION_META_MOCK, + }); + + expect(result.total).toStrictEqual({ + fiat: '100.5', + usd: '100.5', + }); + }); + it('returns total using fiatPaymentAmount when fiat strategy is present', () => { const fiatQuote: TransactionPayQuote = { ...QUOTE_1_MOCK, diff --git a/packages/transaction-pay-controller/src/utils/totals.ts b/packages/transaction-pay-controller/src/utils/totals.ts index 26d587de3e8..75b2ede52f4 100644 --- a/packages/transaction-pay-controller/src/utils/totals.ts +++ b/packages/transaction-pay-controller/src/utils/totals.ts @@ -17,7 +17,6 @@ import { calculateTransactionGasCost } from './gas.js'; * * @param request - Request parameters. * @param request.fiatPaymentAmount - The amount of the transaction in fiat. - * @param request.isMaxAmount - Whether the transaction is a maximum amount transaction. * @param request.quotes - List of bridge quotes. * @param request.messenger - Controller messenger. * @param request.tokens - List of required tokens. @@ -26,14 +25,12 @@ import { calculateTransactionGasCost } from './gas.js'; */ export function calculateTotals({ fiatPaymentAmount, - isMaxAmount, quotes, messenger, tokens, transaction, }: { fiatPaymentAmount?: string; - isMaxAmount?: boolean; quotes: TransactionPayQuote[]; messenger: TransactionPayControllerMessenger; tokens: TransactionPayRequiredToken[]; @@ -78,23 +75,25 @@ export function calculateTotals({ const amountFiat = sumProperty(quoteTokens, (token) => token.amountFiat); const amountUsd = sumProperty(quoteTokens, (token) => token.amountUsd); const hasQuotes = quotes.length > 0; + const isInputBased = + hasQuotes && quotes.every((quote) => quote.isInputBased === true); const sourceAmountFiat = getSourceAmount({ hasFiatStrategy, fiatPaymentAmount, - isMaxAmount, hasQuotes, targetAmount: targetAmount.fiat, tokenAmount: amountFiat, + useTargetAmount: isInputBased, }); const sourceAmountUsd = getSourceAmount({ hasFiatStrategy, fiatPaymentAmount, - isMaxAmount, hasQuotes, targetAmount: targetAmount.usd, tokenAmount: amountUsd, + useTargetAmount: isInputBased, }); const totalFiat = new BigNumber(providerFee.fiat) @@ -137,6 +136,7 @@ export function calculateTotals({ }, targetNetwork: targetNetworkFee, }, + isInputBased, sourceAmount, targetAmount, total: { @@ -152,32 +152,32 @@ export function calculateTotals({ * @param request - Request parameters. * @param request.hasFiatStrategy - Whether a fiat strategy quote is present. * @param request.fiatPaymentAmount - The fiat payment amount, if applicable. - * @param request.isMaxAmount - Whether the transaction is a maximum amount transaction. * @param request.hasQuotes - Whether any quotes are present. * @param request.targetAmount - The target amount from quotes. * @param request.tokenAmount - The summed token amount. + * @param request.useTargetAmount - Whether fees are already included in the source amount. * @returns The payment amount to include in totals. */ function getSourceAmount({ hasFiatStrategy, fiatPaymentAmount, - isMaxAmount, hasQuotes, targetAmount, tokenAmount, + useTargetAmount, }: { hasFiatStrategy: boolean; fiatPaymentAmount?: string; - isMaxAmount?: boolean; hasQuotes: boolean; targetAmount: string; tokenAmount: string; + useTargetAmount: boolean; }): string { if (hasFiatStrategy) { return fiatPaymentAmount ?? '0'; } - if (isMaxAmount && hasQuotes) { + if (useTargetAmount && hasQuotes) { return targetAmount; }