From ced71aac48f8a8ed78d33d49f0be33256da480c3 Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Tue, 15 Sep 2026 03:05:44 -0700 Subject: [PATCH 1/4] refactor: reconcile native Transak fees in RampsController Follow-up to #9317 addressing review comments. - Add RampsController:getQuoteWithFees, which returns the best on-ramp quote with fees reconciled to the resolved provider. When the provider is Transak Native it fetches the native buy quote via the stateless TransakService:getBuyQuote (so it does not touch Unified Buy's shared buy-quote state) and folds the native total into the quote fee fields, keeping the aggregator networkFee on the network line and the remainder in the provider fee so the breakdown survives and the total is unchanged. - TransactionPayController consumes the single action instead of owning the provider check and native lookup; drop the now-unused TransakService:getBuyQuote delegation from its messenger. - Fall back to the entered fiat amount (not the crypto amountOut) for the direct mUSD fiat target when amountOutInFiat is missing. --- packages/ramps-controller/package.json | 1 + .../RampsController-method-action-types.ts | 22 ++ .../src/RampsController.test.ts | 295 ++++++++++++++++++ .../ramps-controller/src/RampsController.ts | 187 +++++++++++ packages/ramps-controller/src/index.ts | 1 + .../strategy/fiat/fiat-direct-musd.test.ts | 211 +++++-------- .../src/strategy/fiat/fiat-direct-musd.ts | 56 ++-- .../src/strategy/fiat/fiat-quotes.test.ts | 142 +++------ .../src/strategy/fiat/fiat-quotes.ts | 22 +- .../src/strategy/fiat/utils.ts | 104 +----- .../transaction-pay-controller/src/types.ts | 8 +- yarn.lock | 1 + 12 files changed, 666 insertions(+), 384 deletions(-) diff --git a/packages/ramps-controller/package.json b/packages/ramps-controller/package.json index ee8149e464d..2ca529ca08b 100644 --- a/packages/ramps-controller/package.json +++ b/packages/ramps-controller/package.json @@ -56,6 +56,7 @@ "@metamask/messenger": "^3.0.0", "@metamask/profile-sync-controller": "^32.1.1", "@metamask/remote-feature-flag-controller": "^7.0.0", + "bignumber.js": "^9.1.2", "fast-deep-equal": "^3.1.3" }, "devDependencies": { diff --git a/packages/ramps-controller/src/RampsController-method-action-types.ts b/packages/ramps-controller/src/RampsController-method-action-types.ts index 0af6093dc66..30318fd7b50 100644 --- a/packages/ramps-controller/src/RampsController-method-action-types.ts +++ b/packages/ramps-controller/src/RampsController-method-action-types.ts @@ -301,6 +301,27 @@ export type RampsControllerGetQuotesAction = { handler: RampsController['getQuotes']; }; +/** + * Fetches the best on-ramp quote and reconciles its fees so they match what the + * resolved provider actually charges. When the resolved provider is Transak + * Native, the returned quote's `providerFee`/`networkFee`/`totalFees` reflect + * the native buy quote's total fee (the aggregator `networkFee` is kept on the + * network line and the remainder placed in the provider fee). A non-native + * provider, a failed native lookup, or an unusable native fee returns the + * aggregator quote unchanged. Callers consume the fee fields directly and do + * not need to know about the native lookup. + * + * @param options - Quote options; see {@link RampsControllerGetQuotesAction}, + * plus `isFeeExcludedFromFiat` to mirror the eventual checkout fee mode + * (defaults to `true`, fee-on-top). + * @returns The best quote with reconciled fees, or `undefined` when none is + * available. + */ +export type RampsControllerGetQuoteWithFeesAction = { + type: `RampsController:getQuoteWithFees`; + handler: RampsController['getQuoteWithFees']; +}; + /** * Adds or updates a V2 order in controller state. * If an order with the same internal order code already exists, the incoming @@ -848,6 +869,7 @@ export type RampsControllerMethodActions = | RampsControllerGetPaymentMethodsForContextAction | RampsControllerSetSelectedPaymentMethodAction | RampsControllerGetQuotesAction + | RampsControllerGetQuoteWithFeesAction | RampsControllerAddOrderAction | RampsControllerRemoveOrderAction | RampsControllerSyncOrdersWithUserStorageAction diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 4f9f56b0939..3e3a75735a9 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -441,6 +441,301 @@ describe('RampsController', () => { }); }); + describe('getQuoteWithFees', () => { + const GQF_ASSET_ID = 'eip155:143/erc20:0xaca92e438df0b2401ff60da7e4337b'; + const GQF_NETWORK = 'eip155:143'; + const GQF_PAYMENT_METHOD = '/payments/debit-credit-card'; + const GQF_WALLET = '0x1234567890abcdef1234567890abcdef12345678'; + + /** + * Builds a single-quote `QuotesResponse` for the given provider and fees. + * + * @param provider - Provider id for the quote. + * @param fees - Optional provider/network fee overrides. + * @param fees.providerFee - Provider fee on the quote. + * @param fees.networkFee - Network fee on the quote. + * @returns A quotes response with a single success quote. + */ + function buildQuotesResponse( + provider: string, + fees: { providerFee?: number; networkFee?: number } = {}, + ): QuotesResponse { + return { + success: [ + { + provider, + quote: { + amountIn: 15, + amountOut: 14.25, + amountOutInFiat: 14.3, + networkFee: fees.networkFee ?? 0.2, + paymentMethod: GQF_PAYMENT_METHOD, + providerFee: fees.providerFee ?? 0.5, + }, + }, + ], + sorted: [], + error: [], + customActions: [], + }; + } + + /** + * Calls `RampsController:getQuoteWithFees` with default MM Pay-style options. + * + * @param messenger - The restricted controller messenger. + * @param overrides - Option overrides. + * @param overrides.isFeeExcludedFromFiat - Fee mode override. + * @param overrides.providers - Explicit provider ids override. + * @returns The reconciled quote, or undefined. + */ + async function callGetQuoteWithFees( + messenger: RampsControllerMessenger, + overrides: { isFeeExcludedFromFiat?: boolean; providers?: string[] } = {}, + ): Promise { + return messenger.call('RampsController:getQuoteWithFees', { + amount: 15, + assetId: GQF_ASSET_ID, + fiat: 'USD', + paymentMethods: [GQF_PAYMENT_METHOD], + providers: ['/providers/transak-native'], + region: 'US', + walletAddress: GQF_WALLET, + ...overrides, + }); + } + + it('reconciles a Transak Native quote to the native total fee and keeps the network split', async () => { + await withController(async ({ messenger, rootMessenger }) => { + rootMessenger.registerActionHandler('RampsService:getQuotes', async () => + buildQuotesResponse('/providers/transak-native'), + ); + rootMessenger.registerActionHandler( + 'TransakService:getBuyQuote', + async () => ({ totalFee: 0.9 }) as never, + ); + + const quote = await callGetQuoteWithFees(messenger); + + // Native total 0.9: aggregator network fee (0.2) stays on the network + // line, the remainder (0.7) goes to the provider fee, and the total is + // the native total. + expect(quote?.quote.providerFee).toBe('0.7'); + expect(quote?.quote.networkFee).toBe('0.2'); + expect(quote?.quote.totalFees).toBe('0.9'); + }); + }); + + it('clamps the network split when the native total is below the aggregator network fee', async () => { + await withController(async ({ messenger, rootMessenger }) => { + rootMessenger.registerActionHandler('RampsService:getQuotes', async () => + buildQuotesResponse('/providers/transak-native', { networkFee: 1 }), + ); + rootMessenger.registerActionHandler( + 'TransakService:getBuyQuote', + async () => ({ totalFee: 0.3 }) as never, + ); + + const quote = await callGetQuoteWithFees(messenger); + + expect(quote?.quote.networkFee).toBe('0.3'); + expect(quote?.quote.providerFee).toBe('0'); + expect(quote?.quote.totalFees).toBe('0.3'); + }); + }); + + it('requests the native quote in fee-on-top mode by default', async () => { + const getBuyQuote = jest.fn().mockResolvedValue({ totalFee: 0.9 }); + + await withController(async ({ messenger, rootMessenger }) => { + rootMessenger.registerActionHandler('RampsService:getQuotes', async () => + buildQuotesResponse('/providers/transak-native'), + ); + rootMessenger.registerActionHandler( + 'TransakService:getBuyQuote', + getBuyQuote, + ); + + await callGetQuoteWithFees(messenger); + + expect(getBuyQuote).toHaveBeenCalledWith( + 'USD', + GQF_ASSET_ID, + GQF_NETWORK, + GQF_PAYMENT_METHOD, + '15', + true, + ); + }); + }); + + it('forwards a fee-inclusive request when isFeeExcludedFromFiat is false', async () => { + const getBuyQuote = jest.fn().mockResolvedValue({ totalFee: 0.9 }); + + await withController(async ({ messenger, rootMessenger }) => { + rootMessenger.registerActionHandler('RampsService:getQuotes', async () => + buildQuotesResponse('/providers/transak-native'), + ); + rootMessenger.registerActionHandler( + 'TransakService:getBuyQuote', + getBuyQuote, + ); + + await callGetQuoteWithFees(messenger, { isFeeExcludedFromFiat: false }); + + expect(getBuyQuote).toHaveBeenCalledWith( + 'USD', + GQF_ASSET_ID, + GQF_NETWORK, + GQF_PAYMENT_METHOD, + '15', + false, + ); + }); + }); + + it('leaves a non-native quote unchanged and does not fetch a native quote', async () => { + const getBuyQuote = jest.fn().mockResolvedValue({ totalFee: 0.9 }); + + await withController(async ({ messenger, rootMessenger }) => { + rootMessenger.registerActionHandler('RampsService:getQuotes', async () => + buildQuotesResponse('/providers/moonpay'), + ); + rootMessenger.registerActionHandler( + 'TransakService:getBuyQuote', + getBuyQuote, + ); + + const quote = await callGetQuoteWithFees(messenger, { + providers: ['/providers/moonpay'], + }); + + expect(getBuyQuote).not.toHaveBeenCalled(); + expect(quote?.quote.providerFee).toBe(0.5); + expect(quote?.quote.networkFee).toBe(0.2); + }); + }); + + it('falls back to the aggregator quote when the native lookup fails', async () => { + await withController(async ({ messenger, rootMessenger }) => { + rootMessenger.registerActionHandler('RampsService:getQuotes', async () => + buildQuotesResponse('/providers/transak-native'), + ); + rootMessenger.registerActionHandler( + 'TransakService:getBuyQuote', + async () => { + throw new Error('native lookup failed'); + }, + ); + + const quote = await callGetQuoteWithFees(messenger); + + expect(quote?.quote.providerFee).toBe(0.5); + expect(quote?.quote.networkFee).toBe(0.2); + }); + }); + + it('falls back to the aggregator quote when the native fee is unusable', async () => { + await withController(async ({ messenger, rootMessenger }) => { + rootMessenger.registerActionHandler('RampsService:getQuotes', async () => + buildQuotesResponse('/providers/transak-native'), + ); + rootMessenger.registerActionHandler( + 'TransakService:getBuyQuote', + async () => ({ totalFee: -1 }) as never, + ); + + const quote = await callGetQuoteWithFees(messenger); + + expect(quote?.quote.providerFee).toBe(0.5); + expect(quote?.quote.networkFee).toBe(0.2); + }); + }); + + it('returns undefined when no quote is available', async () => { + await withController(async ({ messenger, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async () => ({ + success: [], + sorted: [], + error: [], + customActions: [], + }), + ); + + const quote = await callGetQuoteWithFees(messenger); + + expect(quote).toBeUndefined(); + }); + }); + + it('does not write the shared Unified Buy native buy-quote state', async () => { + await withController(async ({ controller, messenger, rootMessenger }) => { + rootMessenger.registerActionHandler('RampsService:getQuotes', async () => + buildQuotesResponse('/providers/transak-native'), + ); + rootMessenger.registerActionHandler( + 'TransakService:getBuyQuote', + async () => ({ totalFee: 0.9 }) as never, + ); + + const before = JSON.parse( + JSON.stringify(controller.state.nativeProviders.transak.buyQuote), + ); + + await callGetQuoteWithFees(messenger); + + // The native lookup must use the stateless `TransakService:getBuyQuote`, + // not the stateful `transakGetBuyQuote`, so Unified Buy's shared + // buy-quote resource is left untouched. + expect(controller.state.nativeProviders.transak.buyQuote).toStrictEqual( + before, + ); + }); + }); + + it('does not treat the aggregator Transak provider as native', async () => { + const getBuyQuote = jest.fn().mockResolvedValue({ totalFee: 0.9 }); + + await withController(async ({ messenger, rootMessenger }) => { + rootMessenger.registerActionHandler('RampsService:getQuotes', async () => + buildQuotesResponse('/providers/transak'), + ); + rootMessenger.registerActionHandler( + 'TransakService:getBuyQuote', + getBuyQuote, + ); + + const quote = await callGetQuoteWithFees(messenger, { + providers: ['/providers/transak'], + }); + + expect(getBuyQuote).not.toHaveBeenCalled(); + expect(quote?.quote.providerFee).toBe(0.5); + expect(quote?.quote.networkFee).toBe(0.2); + }); + }); + + it('puts the whole native total on the network line when it equals the aggregator network fee', async () => { + await withController(async ({ messenger, rootMessenger }) => { + rootMessenger.registerActionHandler('RampsService:getQuotes', async () => + buildQuotesResponse('/providers/transak-native', { networkFee: 0.2 }), + ); + rootMessenger.registerActionHandler( + 'TransakService:getBuyQuote', + async () => ({ totalFee: 0.2 }) as never, + ); + + const quote = await callGetQuoteWithFees(messenger); + + expect(quote?.quote.networkFee).toBe('0.2'); + expect(quote?.quote.providerFee).toBe('0'); + expect(quote?.quote.totalFees).toBe('0.2'); + }); + }); + }); + describe('getQuotes all-providers widening', () => { const SCOPE_ASSET_ID = 'eip155:1/slip44:60'; const SCOPE_PAYMENT_METHOD = '/payments/debit-credit-card'; diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index e3a64c33fe6..a1bddc3138f 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -13,6 +13,7 @@ import type { } from '@metamask/profile-sync-controller'; import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; import type { Json } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; import type { Draft } from 'immer'; import type { @@ -1017,6 +1018,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'getPaymentMethodsForContext', 'setSelectedPaymentMethod', 'getQuotes', + 'getQuoteWithFees', 'addOrder', 'removeOrder', 'addAutoramp', @@ -1105,6 +1107,29 @@ function contextStillMatches( ); } +/** + * Provider codes that identify Transak's native (non-aggregator) integration, + * in the bare form produced by {@link normalizeHeadlessProviderId}. + */ +const NATIVE_TRANSAK_PROVIDER_CODES = [ + 'transak-native', + 'transak-native-staging', +]; + +/** + * Coerces a quote fee value to a non-negative BigNumber, treating a missing or + * invalid value as zero. + * + * @param value - Raw fee value from a quote. + * @returns The fee as a non-negative BigNumber. + */ +function getSafeRampsFee(value: number | string | undefined): BigNumber { + const fee = new BigNumber(value ?? 0); + return fee.isFinite() && fee.isGreaterThanOrEqualTo(0) + ? fee + : new BigNumber(0); +} + export class RampsController extends BaseController< typeof controllerName, RampsControllerState, @@ -2632,6 +2657,168 @@ export class RampsController extends BaseController< }; } + /** + * Fetches the best on-ramp quote for a request and, when the resolved + * provider is Transak Native, reconciles its fees to match what Transak + * Native actually charges. + * + * The aggregator `/quotes` estimate of Transak's fee does not match the + * native integration. When the resolved provider is Transak Native this + * fetches the native buy quote (an unauthenticated, API-key-only lookup, so + * it is safe at estimate time) and rewrites the returned quote's fee fields + * to its `totalFee`, keeping the aggregator's `networkFee` on the network + * line and placing the remainder in the provider fee so the breakdown + * survives and `providerFee + networkFee` still equals the native total. A + * non-native provider, a failed lookup, or an unusable native fee returns the + * aggregator quote unchanged. + * + * Consumers (e.g. `TransactionPayController`) call this instead of owning the + * provider check, asset-id parsing, and second native quote themselves. + * + * @param options - Quote options; see {@link getQuotes}, plus the fee mode. + * @param options.amount - Fiat amount for the quote. + * @param options.assetId - CAIP-19 asset id being bought. + * @param options.fiat - Optional fiat currency; defaults like {@link getQuotes}. + * @param options.paymentMethods - Optional payment method ids. + * @param options.walletAddress - Wallet address receiving the on-ramped asset. + * @param options.isFeeExcludedFromFiat - Whether Transak adds its fee on top + * of the fiat amount (`true`, fee-on-top) or carves it out (`false`). Must + * mirror the eventual checkout mode so the estimate equals the charge. + * Defaults to `true`. + * @param options.providers - See {@link getQuotes}. + * @param options.autoSelectProvider - See {@link getQuotes}. + * @param options.restrictToKnownOrNativeProviders - See {@link getQuotes}. + * @param options.preferredProviderIds - See {@link getQuotes}. + * @param options.region - See {@link getQuotes}. + * @param options.redirectUrl - See {@link getQuotes}. + * @param options.action - See {@link getQuotes}. + * @param options.forceRefresh - See {@link getQuotes}. + * @param options.ttl - See {@link getQuotes}. + * @returns The best quote with native-reconciled fees, or `undefined` when + * no quote is available. + */ + async getQuoteWithFees(options: { + amount: number; + assetId: string; + fiat?: string; + paymentMethods?: string[]; + walletAddress: string; + isFeeExcludedFromFiat?: boolean; + providers?: string[]; + autoSelectProvider?: boolean; + restrictToKnownOrNativeProviders?: boolean; + preferredProviderIds?: string[]; + region?: string; + redirectUrl?: string; + action?: RampAction; + forceRefresh?: boolean; + ttl?: number; + }): Promise { + const { isFeeExcludedFromFiat = true, ...quoteOptions } = options; + + const response = await this.getQuotes(quoteOptions); + const quote = response.success?.[0]; + + if (!quote) { + return undefined; + } + + return this.#reconcileNativeTransakFee(quote, { + amount: options.amount, + assetId: options.assetId, + fiat: options.fiat, + paymentMethod: options.paymentMethods?.[0], + isFeeExcludedFromFiat, + }); + } + + /** + * Rewrites a quote's fees to Transak Native's own total when the resolved + * provider is Transak Native, so an estimate matches the native charge. + * Returns the quote unchanged for a non-native provider, a failed native + * lookup, or an unusable native fee. + * + * @param quote - The resolved aggregator quote. + * @param context - Native lookup inputs. + * @param context.amount - Fiat amount for the native quote. + * @param context.assetId - CAIP-19 asset id being bought. + * @param context.fiat - Fiat currency for the native quote. + * @param context.paymentMethod - Payment method id for the native quote. + * @param context.isFeeExcludedFromFiat - Fee mode for the native quote. + * @returns The quote with reconciled fees, or the original quote. + */ + async #reconcileNativeTransakFee( + quote: Quote, + { + amount, + assetId, + fiat, + paymentMethod, + isFeeExcludedFromFiat, + }: { + amount: number; + assetId: string; + fiat?: string; + paymentMethod?: string; + isFeeExcludedFromFiat: boolean; + }, + ): Promise { + // `normalizeHeadlessProviderId` strips the `/providers/` prefix and + // lowercases, so `/providers/transak-native` and `transak-native` both match + // the native codes below (and the aggregator `transak` does not). + const providerCode = normalizeHeadlessProviderId(quote.provider); + + if (!NATIVE_TRANSAK_PROVIDER_CODES.includes(providerCode)) { + return quote; + } + + const fiatCurrency = fiat ?? this.state.userRegion?.country?.currency; + + if (!fiatCurrency || !paymentMethod) { + return quote; + } + + try { + const network = assetId.split('/')[0]; + + const nativeQuote = await this.messenger.call( + 'TransakService:getBuyQuote', + fiatCurrency, + assetId, + network, + paymentMethod, + String(amount), + isFeeExcludedFromFiat, + ); + + const nativeTotalFee = new BigNumber(nativeQuote.totalFee ?? NaN); + + if (!nativeTotalFee.isFinite() || nativeTotalFee.isLessThan(0)) { + return quote; + } + + // Transak Native returns a single total fee, so keep the aggregator's + // network fee on the network line (clamped to the native total) and put + // the remainder in the provider fee. The breakdown survives and + // `providerFee + networkFee` still equals the native total. + const aggregatorNetworkFee = getSafeRampsFee(quote.quote.networkFee); + const networkFee = BigNumber.min(aggregatorNetworkFee, nativeTotalFee); + const providerFee = nativeTotalFee.minus(networkFee); + + return { + ...quote, + quote: { + ...quote.quote, + providerFee: providerFee.toString(10), + networkFee: networkFee.toString(10), + totalFees: nativeTotalFee.toString(10), + }, + }; + } catch { + return quote; + } + } + /** * Selects the best quote from a widened multi-provider response. * diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index 6aeecbcb6cd..e1756d75f52 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -32,6 +32,7 @@ export type { RampsControllerGetPaymentMethodsForContextAction, RampsControllerSetSelectedPaymentMethodAction, RampsControllerGetQuotesAction, + RampsControllerGetQuoteWithFeesAction, RampsControllerAddOrderAction, RampsControllerRemoveOrderAction, RampsControllerAddAutorampAction, 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 6ba54b4cbb1..964e5c0037b 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 @@ -67,42 +67,28 @@ const TRANSACTION_MOCK = { } as unknown as TransactionMeta; function getQuotesMessenger({ - quotes = [RAMPS_QUOTE_MOCK], + quote = RAMPS_QUOTE_MOCK, quoteError, - transakBuyQuote, - transakError, + returnsQuote = true, }: { - quotes?: RampsQuote[]; + quote?: RampsQuote; quoteError?: Error; - transakBuyQuote?: unknown; - transakError?: Error; + returnsQuote?: boolean; } = {}): { callMock: jest.Mock; messenger: PayStrategyGetQuotesRequest['messenger']; } { const callMock = jest.fn( (action: string, request?: Record) => { - if (action === 'RampsController:getQuotes') { + // `getRampsQuote` calls `RampsController:getQuoteWithFees`, which returns + // the single best quote with fees already reconciled to the resolved + // provider (the native lookup and split now live in `RampsController`). + if (action === 'RampsController:getQuoteWithFees') { if (quoteError) { throw quoteError; } - return { - customActions: [], - error: [], - sorted: [], - success: quotes, - }; - } - - if (action === 'TransakService:getBuyQuote') { - if (transakError) { - throw transakError; - } - - if (transakBuyQuote !== undefined) { - return transakBuyQuote; - } + return returnsQuote ? quote : undefined; } if (action === 'TransactionPayController:updateFiatPayment') { @@ -173,15 +159,20 @@ describe('fiat-direct-musd', () => { transactionId: TRANSACTION_ID_MOCK, }); - expect(callMock).toHaveBeenNthCalledWith(1, 'RampsController:getQuotes', { - amount: 10, - assetId: MUSD_CAIP_ASSET_ID_MOCK, - autoSelectProvider: true, - fiat: DEFAULT_FIAT_CURRENCY, - paymentMethods: ['/payments/debit-credit-card'], - restrictToKnownOrNativeProviders: true, - walletAddress: MONEY_ACCOUNT_ADDRESS_MOCK, - }); + expect(callMock).toHaveBeenNthCalledWith( + 1, + 'RampsController:getQuoteWithFees', + { + amount: 10, + assetId: MUSD_CAIP_ASSET_ID_MOCK, + autoSelectProvider: true, + fiat: DEFAULT_FIAT_CURRENCY, + isFeeExcludedFromFiat: true, + paymentMethods: ['/payments/debit-credit-card'], + restrictToKnownOrNativeProviders: true, + walletAddress: MONEY_ACCOUNT_ADDRESS_MOCK, + }, + ); expect(callMock).toHaveBeenCalledWith( 'TransactionPayController:updateFiatPayment', expect.objectContaining({ transactionId: TRANSACTION_ID_MOCK }), @@ -212,54 +203,27 @@ describe('fiat-direct-musd', () => { }), sourceAmount: { fiat: '10', human: '5', raw: '5000000', usd: '10' }, strategy: TransactionPayStrategy.Fiat, - targetAmount: { fiat: '5', usd: '5' }, + // No `amountOutInFiat` on the mock quote, so the fiat target falls + // back to the entered amount (not the crypto `amountOut`). + targetAmount: { fiat: '10', usd: '10' }, }), ); }); - it('uses the native Transak fee and collapses the network split', async () => { - const { callMock, messenger } = getQuotesMessenger({ - transakBuyQuote: { totalFee: 0.9 }, - }); - - const result = await getDirectMusdFiatQuote({ - amountFiat: '10', - fiatPaymentMethod: '/payments/debit-credit-card', - messenger, - moneyAccountAddress: MONEY_ACCOUNT_ADDRESS_MOCK, - requiredToken: REQUIRED_TOKEN_MOCK, - transactionId: TRANSACTION_ID_MOCK, - }); - - // Direct mUSD is fee-on-top: isFeeExcludedFromFiat = true. - expect(callMock).toHaveBeenCalledWith( - 'TransakService:getBuyQuote', - DEFAULT_FIAT_CURRENCY, - MUSD_CAIP_ASSET_ID_MOCK, - 'eip155:143', - '/payments/debit-credit-card', - '10', - true, - ); - expect(result).toStrictEqual( - expect.objectContaining({ - fees: expect.objectContaining({ - // native total fee, all in the provider bucket - provider: { fiat: '0.9', usd: '0.9' }, - providerFiat: { fiat: '0.9', usd: '0.9' }, - // network split collapses to zero for native - sourceNetwork: { - estimate: { fiat: '0', human: '0', raw: '0', usd: '0' }, - max: { fiat: '0', human: '0', raw: '0', usd: '0' }, - }, - }), - }), - ); - }); - - it('falls back to the aggregator fee when the native quote fetch fails', async () => { + it('maps the reconciled provider and network fees into the fee buckets', async () => { + // `getQuoteWithFees` reconciles a Transak Native quote into a split that + // keeps the network line populated (e.g. native total 0.9 split into a + // 0.85 provider fee and a 0.05 network fee). Direct mUSD maps both buckets + // straight through. const { messenger } = getQuotesMessenger({ - transakError: new Error('native lookup failed'), + quote: { + ...RAMPS_QUOTE_MOCK, + quote: { + ...RAMPS_QUOTE_MOCK.quote, + networkFee: 0.05, + providerFee: 0.85, + }, + }, }); const result = await getDirectMusdFiatQuote({ @@ -274,59 +238,27 @@ describe('fiat-direct-musd', () => { expect(result).toStrictEqual( expect.objectContaining({ fees: expect.objectContaining({ - provider: { fiat: '0.5', usd: '0.5' }, - providerFiat: { fiat: '0.7', usd: '0.7' }, + provider: { fiat: '0.85', usd: '0.85' }, + providerFiat: { fiat: '0.9', usd: '0.9' }, sourceNetwork: { - estimate: { fiat: '0.2', human: '0', raw: '0', usd: '0.2' }, - max: { fiat: '0.2', human: '0', raw: '0', usd: '0.2' }, + estimate: { fiat: '0.05', human: '0', raw: '0', usd: '0.05' }, + max: { fiat: '0.05', human: '0', raw: '0', usd: '0.05' }, }, }), }), ); }); - it('does not fetch a native quote for a non-native provider', async () => { - const { callMock, messenger } = getQuotesMessenger({ - quotes: [{ ...RAMPS_QUOTE_MOCK, provider: '/providers/moonpay' }], - transakBuyQuote: { totalFee: 0.9 }, - }); - - const result = await getDirectMusdFiatQuote({ - amountFiat: '10', - fiatPaymentMethod: '/payments/debit-credit-card', - messenger, - moneyAccountAddress: MONEY_ACCOUNT_ADDRESS_MOCK, - requiredToken: REQUIRED_TOKEN_MOCK, - transactionId: TRANSACTION_ID_MOCK, - }); - - expect( - callMock.mock.calls.filter( - (call) => call[0] === 'TransakService:getBuyQuote', - ), - ).toHaveLength(0); - expect(result).toStrictEqual( - expect.objectContaining({ - fees: expect.objectContaining({ - providerFiat: { fiat: '0.7', usd: '0.7' }, - }), - }), - ); - }); - - it('treats unusable aggregator fees as zero for a non-native provider', async () => { + it('treats unusable ramps fees as zero', async () => { const { messenger } = getQuotesMessenger({ - quotes: [ - { - ...RAMPS_QUOTE_MOCK, - provider: '/providers/moonpay', - quote: { - ...RAMPS_QUOTE_MOCK.quote, - networkFee: -1, - providerFee: -1, - }, + quote: { + ...RAMPS_QUOTE_MOCK, + quote: { + ...RAMPS_QUOTE_MOCK.quote, + networkFee: -1, + providerFee: -1, }, - ], + }, }); const result = await getDirectMusdFiatQuote({ @@ -358,7 +290,7 @@ describe('fiat-direct-musd', () => { amountOutInFiat: 14.3, }, }; - const { messenger } = getQuotesMessenger({ quotes: [fiatQuote] }); + const { messenger } = getQuotesMessenger({ quote: fiatQuote }); const result = await getDirectMusdFiatQuote({ amountFiat: '15', @@ -376,7 +308,9 @@ describe('fiat-direct-musd', () => { }); }); - it('uses stablecoin amountOut when amountOutInFiat is unavailable', async () => { + it('uses the entered fiat amount when amountOutInFiat is unavailable', async () => { + // `amountOut` is a crypto amount, so it must not be used as the fiat + // target. The entered fiat amount is the correct fallback. const fiatQuote: RampsQuote = { ...RAMPS_QUOTE_MOCK, quote: { @@ -385,7 +319,7 @@ describe('fiat-direct-musd', () => { amountOut: 14.3, }, }; - const { messenger } = getQuotesMessenger({ quotes: [fiatQuote] }); + const { messenger } = getQuotesMessenger({ quote: fiatQuote }); const result = await getDirectMusdFiatQuote({ amountFiat: '15', @@ -397,8 +331,8 @@ describe('fiat-direct-musd', () => { }); expect(result?.targetAmount).toStrictEqual({ - fiat: '14.3', - usd: '14.3', + fiat: '15', + usd: '15', }); }); @@ -414,19 +348,26 @@ describe('fiat-direct-musd', () => { transactionId: TRANSACTION_ID_MOCK, }); - expect(callMock).toHaveBeenNthCalledWith(1, 'RampsController:getQuotes', { - amount: 15, - assetId: MUSD_CAIP_ASSET_ID_MOCK, - autoSelectProvider: true, - fiat: DEFAULT_FIAT_CURRENCY, - paymentMethods: ['/payments/apple-pay'], - restrictToKnownOrNativeProviders: true, - walletAddress: MONEY_ACCOUNT_ADDRESS_MOCK, - }); + expect(callMock).toHaveBeenNthCalledWith( + 1, + 'RampsController:getQuoteWithFees', + { + amount: 15, + assetId: MUSD_CAIP_ASSET_ID_MOCK, + autoSelectProvider: true, + fiat: DEFAULT_FIAT_CURRENCY, + isFeeExcludedFromFiat: true, + paymentMethods: ['/payments/apple-pay'], + restrictToKnownOrNativeProviders: true, + walletAddress: MONEY_ACCOUNT_ADDRESS_MOCK, + }, + ); }); it('returns undefined when ramps returns no mUSD provider', async () => { - const { callMock, messenger } = getQuotesMessenger({ quotes: [] }); + const { callMock, messenger } = getQuotesMessenger({ + returnsQuote: false, + }); const result = await getDirectMusdFiatQuote({ amountFiat: '10', @@ -485,7 +426,7 @@ describe('fiat-direct-musd', () => { paymentMethod: '/payments/debit-credit-card', }, }; - const { messenger } = getQuotesMessenger({ quotes: [quoteWithoutFees] }); + const { messenger } = getQuotesMessenger({ quote: quoteWithoutFees }); const result = await getDirectMusdFiatQuote({ amountFiat: '10', 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 54a8916df59..92a98c27c56 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 @@ -22,7 +22,6 @@ import { buildCaipAssetType, getTokenInfo } from '../../utils/token.js'; import { MUSD_MONAD_FIAT_ASSET } from './constants.js'; import type { FiatQuote } from './types.js'; import { - getNativeTransakRampsFee, getRampsQuote, getRawSourceAmountFromOrderCryptoAmount, resolveSourceAmountRaw, @@ -66,6 +65,9 @@ export async function getDirectMusdFiatQuote({ throw new Error('Invalid fiat amount for direct mUSD quote'); } + // `getRampsQuote` requests `getQuoteWithFees`, so a Transak Native quote + // already carries the native fee (fee-on-top for direct mUSD) in its + // provider/network fee fields. const fiatQuote = await getRampsQuote({ adjustedAmount, errorMessage: 'No matching ramps quote found for direct mUSD provider', @@ -75,19 +77,6 @@ export async function getDirectMusdFiatQuote({ walletAddress: moneyAccountAddress, }); - // When Transak Native is the resolved provider, prefer its own quote's fee - // so the estimate matches what Transak Native will charge. Direct mUSD is - // fee-on-top (the fee is added to the entered amount), so request the native - // quote in that mode. - const nativeRampsFee = await getNativeTransakRampsFee({ - adjustedAmount, - fiatAsset: MUSD_MONAD_FIAT_ASSET, - fiatPaymentMethod, - fiatQuote, - isFeeExcludedFromFiat: true, - messenger, - }); - messenger.call('TransactionPayController:updateFiatPayment', { callback: (fiatPayment: TransactionFiatPayment) => { fiatPayment.rampsQuote = fiatQuote; @@ -110,7 +99,6 @@ export async function getDirectMusdFiatQuote({ fiatQuote, messenger, moneyAccountAddress, - nativeRampsFee, requiredToken, }); } catch (error) { @@ -180,14 +168,12 @@ function combineDirectMusdFiatQuote({ fiatQuote, messenger, moneyAccountAddress, - nativeRampsFee, requiredToken, }: { amountFiat: string; fiatQuote: RampsQuote; messenger: PayStrategyGetQuotesRequest['messenger']; moneyAccountAddress: Hex; - nativeRampsFee: BigNumber | null; requiredToken: TransactionPayRequiredToken; }): TransactionPayQuote { const tokenInfo = getTokenInfo( @@ -204,20 +190,16 @@ function combineDirectMusdFiatQuote({ cryptoAmount: fiatQuote.quote.amountOut, decimals: tokenInfo.decimals, }); - // Transak Native returns a single total fee, so the aggregator's separate - // provider/network split collapses: the whole native fee sits in the provider - // bucket and the source-network fee is zero. The `providerFiat` total stays - // correct either way. - const rampsProviderFee = ( - nativeRampsFee ?? getSafeFee(fiatQuote.quote.providerFee) - ).toString(10); - const rampsNetworkFee = nativeRampsFee - ? '0' - : getSafeFee(fiatQuote.quote.networkFee).toString(10); + // `getQuoteWithFees` already reconciled the fee split to the resolved + // provider (for Transak Native, the native total split across provider and + // network), so the provider/network fields can be mapped straight into the + // fee buckets. + const rampsProviderFee = getSafeFee(fiatQuote.quote.providerFee).toString(10); + const rampsNetworkFee = getSafeFee(fiatQuote.quote.networkFee).toString(10); const rampsTotalFee = new BigNumber(rampsProviderFee) .plus(rampsNetworkFee) .toString(10); - const targetAmountFiat = getDirectMusdTargetAmountFiat(fiatQuote); + const targetAmountFiat = getDirectMusdTargetAmountFiat(fiatQuote, amountFiat); const sourceAmountHuman = new BigNumber(sourceAmountRaw) .shiftedBy(-tokenInfo.decimals) .toString(10); @@ -276,13 +258,27 @@ function combineDirectMusdFiatQuote({ }; } -function getDirectMusdTargetAmountFiat(fiatQuote: RampsQuote): string { +/** + * Resolves the fiat value of the received mUSD for a direct deposit quote. + * + * Prefers the quote's `amountOutInFiat`. When that is missing, it falls back to + * the entered fiat amount rather than `amountOut`, which is a crypto amount and + * would otherwise put crypto units into a fiat field. + * + * @param fiatQuote - The resolved ramps quote. + * @param amountFiat - The entered fiat amount, used as the fallback. + * @returns The target amount in fiat. + */ +function getDirectMusdTargetAmountFiat( + fiatQuote: RampsQuote, + amountFiat: string, +): string { const amountOutInFiat = new BigNumber(fiatQuote.quote.amountOutInFiat ?? NaN); if (amountOutInFiat.isFinite() && amountOutInFiat.isGreaterThanOrEqualTo(0)) { return amountOutInFiat.toString(10); } - return new BigNumber(fiatQuote.quote.amountOut).toString(10); + return amountFiat; } function getSafeFee(value: BigNumber.Value | undefined): BigNumber { diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.test.ts index b50dbb3ffc8..ad3a5749abf 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.test.ts @@ -146,16 +146,12 @@ function getRequest({ rampsQuotes = FIAT_QUOTES_RESPONSE_MOCK, tokens = [REQUIRED_TOKEN_MOCK], throwsOnRampsQuotes, - transakBuyQuote, - throwsOnTransakBuyQuote, }: { amountFiat?: string; fiatPaymentMethod?: string; rampsQuotes?: RampsQuotesResponse; tokens?: TransactionPayRequiredToken[]; throwsOnRampsQuotes?: Error; - transakBuyQuote?: unknown; - throwsOnTransakBuyQuote?: Error; } = {}): { callMock: jest.Mock; request: PayStrategyGetQuotesRequest; @@ -176,22 +172,15 @@ function getRequest({ }; } - if (action === 'RampsController:getQuotes') { + // `getRampsQuote` calls `RampsController:getQuoteWithFees`, which returns + // the single best quote with fees already reconciled to the resolved + // provider. + if (action === 'RampsController:getQuoteWithFees') { if (throwsOnRampsQuotes) { throw throwsOnRampsQuotes; } - return rampsQuotes; - } - - if (action === 'TransakService:getBuyQuote') { - if (throwsOnTransakBuyQuote) { - throw throwsOnTransakBuyQuote; - } - - if (transakBuyQuote !== undefined) { - return transakBuyQuote; - } + return rampsQuotes.success?.[0]; } if (action === 'TransactionPayController:updateFiatPayment') { @@ -283,12 +272,13 @@ describe('getFiatQuotes', () => { ]); expect(callMock).toHaveBeenCalledWith( - 'RampsController:getQuotes', + 'RampsController:getQuoteWithFees', expect.objectContaining({ amount: 18, assetId: FIAT_ASSET_CAIP_ID_MOCK, autoSelectProvider: true, fiat: 'USD', + isFeeExcludedFromFiat: true, paymentMethods: ['/payments/debit-credit-card'], restrictToKnownOrNativeProviders: true, walletAddress: WALLET_ADDRESS, @@ -325,92 +315,41 @@ describe('getFiatQuotes', () => { }); }); - it('uses the native Transak fee when the provider is Transak Native', async () => { - const { callMock, request } = getRequest({ - transakBuyQuote: { totalFee: 3.3 }, + it('sums the reconciled ramps provider and network fees into the fee buckets', async () => { + // `getQuoteWithFees` reconciles the fee to the resolved provider (e.g. a + // Transak Native total of 3.3 split into a 3.1 provider fee and a 0.2 + // network fee). The relay path just adds the ramps provider fee on top of + // the relay provider fee. + const { request } = getRequest({ + rampsQuotes: { + ...FIAT_QUOTES_RESPONSE_MOCK, + success: [ + { + ...FIAT_QUOTE_MOCK, + quote: { + ...FIAT_QUOTE_MOCK.quote, + networkFee: 0.2, + providerFee: 3.1, + }, + }, + ], + }, }); const result = await getFiatQuotes(request); - // Relay path stays fee-on-top, so the native quote is requested with - // isFeeExcludedFromFiat = true, in USD, for the adjusted amount. - expect(callMock).toHaveBeenCalledWith( - 'TransakService:getBuyQuote', - 'USD', - FIAT_ASSET_CAIP_ID_MOCK, - 'eip155:137', - '/payments/debit-credit-card', - '18', - true, - ); - - // provider = relay(1) + native(3.3) = 4.3 + // provider = relay(1) + ramps(3.1 + 0.2) = 4.3 expect(result[0].fees.provider).toStrictEqual({ fiat: '4.3', usd: '4.3', }); - // providerFiat = native fee only + // providerFiat = ramps only (3.1 + 0.2 = 3.3) expect(result[0].fees.providerFiat).toStrictEqual({ fiat: '3.3', usd: '3.3', }); }); - it('falls back to the aggregator fee when the native quote fetch fails', async () => { - const { request } = getRequest({ - throwsOnTransakBuyQuote: new Error('native lookup failed'), - }); - - const result = await getFiatQuotes(request); - - // providerFiat = aggregator ramps (0.5 + 0.2 = 0.7) - expect(result[0].fees.providerFiat).toStrictEqual({ - fiat: '0.7', - usd: '0.7', - }); - expect(result[0].fees.provider).toStrictEqual({ - fiat: '1.7', - usd: '1.7', - }); - }); - - it('does not fetch a native quote for a non-native provider', async () => { - const { callMock, request } = getRequest({ - rampsQuotes: { - ...FIAT_QUOTES_RESPONSE_MOCK, - success: [{ ...FIAT_QUOTE_MOCK, provider: '/providers/moonpay' }], - }, - transakBuyQuote: { totalFee: 3.3 }, - }); - - const result = await getFiatQuotes(request); - - expect( - callMock.mock.calls.filter( - (call) => call[0] === 'TransakService:getBuyQuote', - ), - ).toHaveLength(0); - // aggregator fee retained - expect(result[0].fees.providerFiat).toStrictEqual({ - fiat: '0.7', - usd: '0.7', - }); - }); - - it('falls back to the aggregator fee when the native quote returns an unusable fee', async () => { - const { request } = getRequest({ - transakBuyQuote: { totalFee: -1 }, - }); - - const result = await getFiatQuotes(request); - - // negative native fee is rejected; aggregator fee (0.7) is used - expect(result[0].fees.providerFiat).toStrictEqual({ - fiat: '0.7', - usd: '0.7', - }); - }); - it('includes sourceNetwork gas in adjusted amount when source is native token', async () => { const nativeFiatAsset: TransactionPayFiatAsset = { address: NATIVE_TOKEN_ADDRESS, @@ -560,7 +499,7 @@ describe('getFiatQuotes', () => { expect(result).toStrictEqual([]); expect(callMock).not.toHaveBeenCalledWith( - 'RampsController:getQuotes', + 'RampsController:getQuoteWithFees', expect.anything(), ); }); @@ -580,7 +519,7 @@ describe('getFiatQuotes', () => { expect(result).toStrictEqual([]); expect(callMock).not.toHaveBeenCalledWith( - 'RampsController:getQuotes', + 'RampsController:getQuoteWithFees', expect.anything(), ); }); @@ -600,7 +539,7 @@ describe('getFiatQuotes', () => { expect(result).toStrictEqual([]); expect(callMock).not.toHaveBeenCalledWith( - 'RampsController:getQuotes', + 'RampsController:getQuoteWithFees', expect.anything(), ); }); @@ -650,8 +589,8 @@ describe('getFiatQuotes', () => { }; } - if (action === 'RampsController:getQuotes') { - return FIAT_QUOTES_RESPONSE_MOCK; + if (action === 'RampsController:getQuoteWithFees') { + return FIAT_QUOTES_RESPONSE_MOCK.success?.[0]; } if (action === 'TransactionPayController:updateFiatPayment') { @@ -713,7 +652,7 @@ describe('getFiatQuotes', () => { }; } - if (action === 'RampsController:getQuotes') { + if (action === 'RampsController:getQuoteWithFees') { throw new Error('ramps failed'); } @@ -855,12 +794,12 @@ describe('getFiatQuotes', () => { }; } - if (action === 'RampsController:getQuotes') { + if (action === 'RampsController:getQuoteWithFees') { if (throwsOnRampsQuotes) { throw throwsOnRampsQuotes; } - return rampsQuotes; + return rampsQuotes.success?.[0]; } if (action === 'TransactionPayController:updateFiatPayment') { @@ -917,11 +856,12 @@ describe('getFiatQuotes', () => { await getFiatQuotes(request); - expect(callMock).toHaveBeenCalledWith('RampsController:getQuotes', { + expect(callMock).toHaveBeenCalledWith('RampsController:getQuoteWithFees', { amount: 10, assetId: MUSD_CAIP_ID_MOCK, autoSelectProvider: true, fiat: DEFAULT_FIAT_CURRENCY, + isFeeExcludedFromFiat: true, paymentMethods: ['/payments/debit-credit-card'], restrictToKnownOrNativeProviders: true, walletAddress: MONEY_ACCOUNT_ADDRESS, @@ -993,7 +933,7 @@ describe('getFiatQuotes', () => { await getFiatQuotes(request); const rampsCalls = callMock.mock.calls.filter( - ([action]: [string]) => action === 'RampsController:getQuotes', + ([action]: [string]) => action === 'RampsController:getQuoteWithFees', ); expect(rampsCalls[0]?.[1]).toStrictEqual( expect.objectContaining({ @@ -1046,8 +986,8 @@ describe('getFiatQuotes', () => { }, }; } - if (action === 'RampsController:getQuotes') { - return PROBE_SUCCESS_RESPONSE; + if (action === 'RampsController:getQuoteWithFees') { + return PROBE_SUCCESS_RESPONSE.success?.[0]; } if (action === 'RemoteFeatureFlagController:getState') { return { diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.ts index e79fd65e84a..2ead2e3f49d 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.ts @@ -29,7 +29,6 @@ import { getDirectMusdFiatQuote } from './fiat-direct-musd.js'; import type { FiatQuote } from './types.js'; import { deriveFiatAssetForFiatPayment, - getNativeTransakRampsFee, getRampsQuote, isMoneyAccountDepositTransaction, } from './utils.js'; @@ -189,18 +188,6 @@ async function executeFiatQuotePipeline( walletAddress: rampsWalletAddress, }); - // When Transak Native is the resolved provider, prefer its own quote's fee - // so the estimate matches what Transak Native will charge. The relay path - // stays fee-on-top, so request the native quote in the same mode. - const nativeRampsFee = await getNativeTransakRampsFee({ - adjustedAmount, - fiatAsset, - fiatPaymentMethod, - fiatQuote, - isFeeExcludedFromFiat: true, - messenger, - }); - messenger.call('TransactionPayController:updateFiatPayment', { callback: (fiatPayment: TransactionFiatPayment) => { fiatPayment.rampsQuote = fiatQuote; @@ -217,7 +204,6 @@ async function executeFiatQuotePipeline( adjustedAmountFiat: adjustedAmountFiat.toString(10), amountFiat, fiatQuote, - nativeRampsFee, relayQuote, }), ]; @@ -303,8 +289,8 @@ function buildRelayRequestFromAmountFiat({ * @param params - Combined quote inputs. * @param params.adjustedAmountFiat - Fiat amount sent to ramps after adding relay fee estimate. * @param params.amountFiat - User-entered fiat amount. - * @param params.fiatQuote - Selected ramps quote. - * @param params.nativeRampsFee - Native Transak fee to use in place of the aggregator ramps fee, or null. + * @param params.fiatQuote - Selected ramps quote, with fees already reconciled + * to the resolved provider by `RampsController:getQuoteWithFees`. * @param params.relayQuote - Estimated relay quote. * @returns A single fiat strategy quote with split fee buckets. * @remarks @@ -323,16 +309,14 @@ function combineQuotes({ adjustedAmountFiat, amountFiat, fiatQuote, - nativeRampsFee, relayQuote, }: { adjustedAmountFiat: string; amountFiat: string; fiatQuote: RampsQuote; - nativeRampsFee: BigNumber | null; relayQuote: TransactionPayQuote; }): TransactionPayQuote { - const rampsProviderFee = nativeRampsFee ?? getRampsProviderFee(fiatQuote); + const rampsProviderFee = getRampsProviderFee(fiatQuote); const totalProviderFee = new BigNumber(relayQuote.fees.provider.usd) .plus(rampsProviderFee) .toString(10); diff --git a/packages/transaction-pay-controller/src/strategy/fiat/utils.ts b/packages/transaction-pay-controller/src/strategy/fiat/utils.ts index 7f884f9d3c3..4658bab9fff 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/utils.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/utils.ts @@ -81,7 +81,13 @@ export function isMoneyAccountDepositTransaction( } /** - * Fetches the first matching Ramps quote for a fiat asset and payment method. + * Fetches the best matching Ramps quote for a fiat asset and payment method, + * with provider fees reconciled to what the resolved provider actually charges. + * + * The `RampsController:getQuoteWithFees` action owns the provider check and the + * native buy-quote lookup, so a Transak Native quote already carries the native + * fee in its `providerFee`/`networkFee`/`totalFees` fields. MM Pay deposits are + * fee-on-top, so the quote is requested with `isFeeExcludedFromFiat: true`. * * @param options - Quote options. * @param options.adjustedAmount - Fiat amount sent to Ramps. @@ -90,7 +96,7 @@ export function isMoneyAccountDepositTransaction( * @param options.fiatPaymentMethod - Selected fiat payment method. * @param options.messenger - Controller messenger. * @param options.walletAddress - Wallet address that receives the on-ramped asset. - * @returns The first matching Ramps quote. + * @returns The best matching Ramps quote with reconciled fees. */ export async function getRampsQuote({ adjustedAmount, @@ -107,21 +113,18 @@ export async function getRampsQuote({ messenger: TransactionPayControllerMessenger; walletAddress: string; }): Promise { - const quotes = await messenger.call('RampsController:getQuotes', { + const quote = await messenger.call('RampsController:getQuoteWithFees', { amount: adjustedAmount, assetId: buildCaipAssetType(fiatAsset.chainId, fiatAsset.address), autoSelectProvider: true, fiat: DEFAULT_FIAT_CURRENCY, + isFeeExcludedFromFiat: true, paymentMethods: [fiatPaymentMethod], restrictToKnownOrNativeProviders: true, walletAddress, }); - log('Fetched ramps quotes', { - quotesCount: quotes.success?.length ?? 0, - }); - - const quote = quotes.success?.[0]; + log('Fetched ramps quote', { hasQuote: Boolean(quote) }); if (!quote) { throw new Error(errorMessage); @@ -390,88 +393,3 @@ export function extractProviderCode( return parts.length === 1 ? parts[0] : null; } - -/** - * Provider codes that identify Transak's native (non-aggregator) integration. - * Matches the codes emitted by `TransakService` for production and staging. - */ -const NATIVE_TRANSAK_PROVIDER_CODES = [ - 'transak-native', - 'transak-native-staging', -]; - -/** - * When the resolved ramps provider is Transak Native, fetches the native - * Transak buy quote and returns its total fee so the estimate matches what - * Transak Native actually charges. - * - * The native lookup (`/api/v2/lookup/quotes`) is an unauthenticated, API-key - * only call, so it is safe to run at estimate time before the user signs in to - * Transak. Returns `null` when the provider is not Transak Native, or when the - * native lookup fails or returns an unusable fee, so the caller falls back to - * the aggregator quote's fee. - * - * @param options - Native fee options. - * @param options.adjustedAmount - Fiat amount (USD) sent to ramps. - * @param options.fiatAsset - Fiat asset being bought. - * @param options.fiatPaymentMethod - Selected fiat payment method. - * @param options.fiatQuote - The resolved aggregator quote (used to detect native). - * @param options.isFeeExcludedFromFiat - Whether Transak adds its fee on top of - * the fiat amount (`true`) or carves it out of the amount (`false`). Must mirror - * the eventual checkout mode so the estimate equals the charge. - * @param options.messenger - Controller messenger. - * @returns The native total fee as a BigNumber, or `null` to use the aggregator fee. - */ -export async function getNativeTransakRampsFee({ - adjustedAmount, - fiatAsset, - fiatPaymentMethod, - fiatQuote, - isFeeExcludedFromFiat, - messenger, -}: { - adjustedAmount: number; - fiatAsset: TransactionPayFiatAsset; - fiatPaymentMethod: string; - fiatQuote: RampsQuote; - isFeeExcludedFromFiat: boolean; - messenger: TransactionPayControllerMessenger; -}): Promise { - const providerCode = extractProviderCode(fiatQuote.provider); - - if (!providerCode || !NATIVE_TRANSAK_PROVIDER_CODES.includes(providerCode)) { - return null; - } - - try { - const assetId = buildCaipAssetType(fiatAsset.chainId, fiatAsset.address); - const network = assetId.split('/')[0]; - - const nativeQuote = await messenger.call( - 'TransakService:getBuyQuote', - DEFAULT_FIAT_CURRENCY, - assetId, - network, - fiatPaymentMethod, - String(adjustedAmount), - isFeeExcludedFromFiat, - ); - - const fee = new BigNumber(nativeQuote.totalFee ?? NaN); - - if (!fee.isFinite() || fee.isLessThan(0)) { - log( - 'Native Transak quote returned an unusable fee; using aggregator fee', - { - totalFee: nativeQuote.totalFee, - }, - ); - return null; - } - - return fee; - } catch (error) { - log('Native Transak fee fetch failed; using aggregator fee', { error }); - return null; - } -} diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index 364e376c993..2991745122c 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -30,9 +30,7 @@ import type { NetworkControllerGetNetworkConfigurationByChainIdAction } from '@m import type { Quote as RampsQuote } from '@metamask/ramps-controller'; import type { RampsControllerGetOrderAction, - RampsControllerGetQuotesAction, - RampsControllerTransakGetBuyQuoteAction, - TransakServiceGetBuyQuoteAction, + RampsControllerGetQuoteWithFeesAction, } from '@metamask/ramps-controller'; import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; import type { SentinelApiServiceActions } from '@metamask/sentinel-api-service'; @@ -74,9 +72,7 @@ export type AllowedActions = | NetworkControllerGetNetworkClientByIdAction | NetworkControllerGetNetworkConfigurationByChainIdAction | RampsControllerGetOrderAction - | RampsControllerGetQuotesAction - | RampsControllerTransakGetBuyQuoteAction - | TransakServiceGetBuyQuoteAction + | RampsControllerGetQuoteWithFeesAction | RemoteFeatureFlagControllerGetStateAction | TokenBalancesControllerGetStateAction | TokenRatesControllerGetStateAction diff --git a/yarn.lock b/yarn.lock index 8d9d2143ad2..84ef99eb2a1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8723,6 +8723,7 @@ __metadata: "@metamask/remote-feature-flag-controller": "npm:^7.0.0" "@types/jest": "npm:^30.0.0" "@typescript/native": "npm:typescript@^7.0.2" + bignumber.js: "npm:^9.1.2" deepmerge: "npm:^4.2.2" fast-deep-equal: "npm:^3.1.3" jest: "npm:^30.4.2" From b7ffa92bfb8448c85692dc93e543c744ba7e5f81 Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Tue, 15 Sep 2026 03:07:39 -0700 Subject: [PATCH 2/4] docs: changelog entries for #10238 --- packages/ramps-controller/CHANGELOG.md | 7 +++++++ packages/transaction-pay-controller/CHANGELOG.md | 3 ++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index f39ff829978..ccf4898ecd7 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -7,9 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `RampsController:getQuoteWithFees`, which returns the best on-ramp quote with its fees reconciled to the resolved provider ([#10238](https://github.com/MetaMask/core/pull/10238)) + - When the resolved provider is Transak Native, the returned quote's `providerFee`/`networkFee`/`totalFees` reflect the native buy quote's total fee: the aggregator `networkFee` stays on the network line and the remainder goes to the provider fee, so the breakdown survives and the total is unchanged. A non-native provider, a failed native lookup, or an unusable native fee returns the aggregator quote unchanged. + - The native lookup uses the stateless `TransakService:getBuyQuote`, so it does not write the shared native buy-quote state used by Unified Buy. + ### Changed - Add an optional fee-exclusion argument to native Transak buy quotes while preserving fee exclusion as the default. ([#9317](https://github.com/MetaMask/core/pull/9317)) +- Add `bignumber.js` as a dependency, used by `getQuoteWithFees` for fee reconciliation ([#10238](https://github.com/MetaMask/core/pull/10238)) - Bump `@metamask/profile-sync-controller` from `^32.0.0` to `^32.1.1` ([#10184](https://github.com/MetaMask/core/pull/10184), [#10220](https://github.com/MetaMask/core/pull/10220)) ## [22.0.0] diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 5473162cd79..73cc6bf87dd 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -10,12 +10,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Use the native Transak buy quote's fee for the MM Pay fiat estimate when Transak Native is the resolved provider, falling back to the aggregator quote's fee when native is unavailable or the lookup fails ([#9317](https://github.com/MetaMask/core/pull/9317)) - - The fee is read via the stateless `TransakService:getBuyQuote` action so the estimate does not write the shared native buy-quote state used by Unified Buy; clients must delegate `TransakService:getBuyQuote` to the `TransactionPayController` messenger. + - The native lookup and fee reconciliation are owned by `RampsController:getQuoteWithFees`; clients must delegate `RampsController:getQuoteWithFees` to the `TransactionPayController` messenger (the previously required `TransakService:getBuyQuote` delegation is no longer needed) ([#10238](https://github.com/MetaMask/core/pull/10238)). - Charge direct Monad mUSD on-ramp fees on top of the entered amount (fee-on-top), so the total is the entered amount plus fees ([#9317](https://github.com/MetaMask/core/pull/9317)) - Bump `@metamask/utils` from `^11.12.0` to `^12.0.0` ([#10192](https://github.com/MetaMask/core/pull/10192)) ### Fixed +- Fall back to the entered fiat amount, rather than the crypto output amount, when a direct mUSD quote is missing `amountOutInFiat`, so a crypto value is not placed in the fiat target field ([#10238](https://github.com/MetaMask/core/pull/10238)) - Detect nested `perpsDepositAndOrder` and `predictDepositAndOrder` transactions when selecting `EXACT_OUTPUT` Relay quotes ([#10222](https://github.com/MetaMask/core/pull/10222)) ## [28.0.2] From 37756a578a9841e22662111e7b2109fcecb9e75b Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Tue, 15 Sep 2026 03:18:34 -0700 Subject: [PATCH 3/4] fix: regenerate action types and apply oxfmt formatting --- .../RampsController-method-action-types.ts | 52 +++++++++++++----- .../src/RampsController.test.ts | 54 ++++++++++++------- .../src/strategy/fiat/fiat-quotes.test.ts | 23 ++++---- 3 files changed, 85 insertions(+), 44 deletions(-) diff --git a/packages/ramps-controller/src/RampsController-method-action-types.ts b/packages/ramps-controller/src/RampsController-method-action-types.ts index 30318fd7b50..7ad966b6a95 100644 --- a/packages/ramps-controller/src/RampsController-method-action-types.ts +++ b/packages/ramps-controller/src/RampsController-method-action-types.ts @@ -302,20 +302,44 @@ export type RampsControllerGetQuotesAction = { }; /** - * Fetches the best on-ramp quote and reconciles its fees so they match what the - * resolved provider actually charges. When the resolved provider is Transak - * Native, the returned quote's `providerFee`/`networkFee`/`totalFees` reflect - * the native buy quote's total fee (the aggregator `networkFee` is kept on the - * network line and the remainder placed in the provider fee). A non-native - * provider, a failed native lookup, or an unusable native fee returns the - * aggregator quote unchanged. Callers consume the fee fields directly and do - * not need to know about the native lookup. - * - * @param options - Quote options; see {@link RampsControllerGetQuotesAction}, - * plus `isFeeExcludedFromFiat` to mirror the eventual checkout fee mode - * (defaults to `true`, fee-on-top). - * @returns The best quote with reconciled fees, or `undefined` when none is - * available. + * Fetches the best on-ramp quote for a request and, when the resolved + * provider is Transak Native, reconciles its fees to match what Transak + * Native actually charges. + * + * The aggregator `/quotes` estimate of Transak's fee does not match the + * native integration. When the resolved provider is Transak Native this + * fetches the native buy quote (an unauthenticated, API-key-only lookup, so + * it is safe at estimate time) and rewrites the returned quote's fee fields + * to its `totalFee`, keeping the aggregator's `networkFee` on the network + * line and placing the remainder in the provider fee so the breakdown + * survives and `providerFee + networkFee` still equals the native total. A + * non-native provider, a failed lookup, or an unusable native fee returns the + * aggregator quote unchanged. + * + * Consumers (e.g. `TransactionPayController`) call this instead of owning the + * provider check, asset-id parsing, and second native quote themselves. + * + * @param options - Quote options; see {@link getQuotes}, plus the fee mode. + * @param options.amount - Fiat amount for the quote. + * @param options.assetId - CAIP-19 asset id being bought. + * @param options.fiat - Optional fiat currency; defaults like {@link getQuotes}. + * @param options.paymentMethods - Optional payment method ids. + * @param options.walletAddress - Wallet address receiving the on-ramped asset. + * @param options.isFeeExcludedFromFiat - Whether Transak adds its fee on top + * of the fiat amount (`true`, fee-on-top) or carves it out (`false`). Must + * mirror the eventual checkout mode so the estimate equals the charge. + * Defaults to `true`. + * @param options.providers - See {@link getQuotes}. + * @param options.autoSelectProvider - See {@link getQuotes}. + * @param options.restrictToKnownOrNativeProviders - See {@link getQuotes}. + * @param options.preferredProviderIds - See {@link getQuotes}. + * @param options.region - See {@link getQuotes}. + * @param options.redirectUrl - See {@link getQuotes}. + * @param options.action - See {@link getQuotes}. + * @param options.forceRefresh - See {@link getQuotes}. + * @param options.ttl - See {@link getQuotes}. + * @returns The best quote with native-reconciled fees, or `undefined` when + * no quote is available. */ export type RampsControllerGetQuoteWithFeesAction = { type: `RampsController:getQuoteWithFees`; diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 3e3a75735a9..23a1222d0c9 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -507,8 +507,9 @@ describe('RampsController', () => { it('reconciles a Transak Native quote to the native total fee and keeps the network split', async () => { await withController(async ({ messenger, rootMessenger }) => { - rootMessenger.registerActionHandler('RampsService:getQuotes', async () => - buildQuotesResponse('/providers/transak-native'), + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async () => buildQuotesResponse('/providers/transak-native'), ); rootMessenger.registerActionHandler( 'TransakService:getBuyQuote', @@ -528,8 +529,10 @@ describe('RampsController', () => { it('clamps the network split when the native total is below the aggregator network fee', async () => { await withController(async ({ messenger, rootMessenger }) => { - rootMessenger.registerActionHandler('RampsService:getQuotes', async () => - buildQuotesResponse('/providers/transak-native', { networkFee: 1 }), + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async () => + buildQuotesResponse('/providers/transak-native', { networkFee: 1 }), ); rootMessenger.registerActionHandler( 'TransakService:getBuyQuote', @@ -548,8 +551,9 @@ describe('RampsController', () => { const getBuyQuote = jest.fn().mockResolvedValue({ totalFee: 0.9 }); await withController(async ({ messenger, rootMessenger }) => { - rootMessenger.registerActionHandler('RampsService:getQuotes', async () => - buildQuotesResponse('/providers/transak-native'), + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async () => buildQuotesResponse('/providers/transak-native'), ); rootMessenger.registerActionHandler( 'TransakService:getBuyQuote', @@ -573,8 +577,9 @@ describe('RampsController', () => { const getBuyQuote = jest.fn().mockResolvedValue({ totalFee: 0.9 }); await withController(async ({ messenger, rootMessenger }) => { - rootMessenger.registerActionHandler('RampsService:getQuotes', async () => - buildQuotesResponse('/providers/transak-native'), + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async () => buildQuotesResponse('/providers/transak-native'), ); rootMessenger.registerActionHandler( 'TransakService:getBuyQuote', @@ -598,8 +603,9 @@ describe('RampsController', () => { const getBuyQuote = jest.fn().mockResolvedValue({ totalFee: 0.9 }); await withController(async ({ messenger, rootMessenger }) => { - rootMessenger.registerActionHandler('RampsService:getQuotes', async () => - buildQuotesResponse('/providers/moonpay'), + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async () => buildQuotesResponse('/providers/moonpay'), ); rootMessenger.registerActionHandler( 'TransakService:getBuyQuote', @@ -618,8 +624,9 @@ describe('RampsController', () => { it('falls back to the aggregator quote when the native lookup fails', async () => { await withController(async ({ messenger, rootMessenger }) => { - rootMessenger.registerActionHandler('RampsService:getQuotes', async () => - buildQuotesResponse('/providers/transak-native'), + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async () => buildQuotesResponse('/providers/transak-native'), ); rootMessenger.registerActionHandler( 'TransakService:getBuyQuote', @@ -637,8 +644,9 @@ describe('RampsController', () => { it('falls back to the aggregator quote when the native fee is unusable', async () => { await withController(async ({ messenger, rootMessenger }) => { - rootMessenger.registerActionHandler('RampsService:getQuotes', async () => - buildQuotesResponse('/providers/transak-native'), + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async () => buildQuotesResponse('/providers/transak-native'), ); rootMessenger.registerActionHandler( 'TransakService:getBuyQuote', @@ -672,8 +680,9 @@ describe('RampsController', () => { it('does not write the shared Unified Buy native buy-quote state', async () => { await withController(async ({ controller, messenger, rootMessenger }) => { - rootMessenger.registerActionHandler('RampsService:getQuotes', async () => - buildQuotesResponse('/providers/transak-native'), + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async () => buildQuotesResponse('/providers/transak-native'), ); rootMessenger.registerActionHandler( 'TransakService:getBuyQuote', @@ -699,8 +708,9 @@ describe('RampsController', () => { const getBuyQuote = jest.fn().mockResolvedValue({ totalFee: 0.9 }); await withController(async ({ messenger, rootMessenger }) => { - rootMessenger.registerActionHandler('RampsService:getQuotes', async () => - buildQuotesResponse('/providers/transak'), + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async () => buildQuotesResponse('/providers/transak'), ); rootMessenger.registerActionHandler( 'TransakService:getBuyQuote', @@ -719,8 +729,12 @@ describe('RampsController', () => { it('puts the whole native total on the network line when it equals the aggregator network fee', async () => { await withController(async ({ messenger, rootMessenger }) => { - rootMessenger.registerActionHandler('RampsService:getQuotes', async () => - buildQuotesResponse('/providers/transak-native', { networkFee: 0.2 }), + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async () => + buildQuotesResponse('/providers/transak-native', { + networkFee: 0.2, + }), ); rootMessenger.registerActionHandler( 'TransakService:getBuyQuote', diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.test.ts index ad3a5749abf..8d7c63ab4b0 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-quotes.test.ts @@ -856,16 +856,19 @@ describe('getFiatQuotes', () => { await getFiatQuotes(request); - expect(callMock).toHaveBeenCalledWith('RampsController:getQuoteWithFees', { - amount: 10, - assetId: MUSD_CAIP_ID_MOCK, - autoSelectProvider: true, - fiat: DEFAULT_FIAT_CURRENCY, - isFeeExcludedFromFiat: true, - paymentMethods: ['/payments/debit-credit-card'], - restrictToKnownOrNativeProviders: true, - walletAddress: MONEY_ACCOUNT_ADDRESS, - }); + expect(callMock).toHaveBeenCalledWith( + 'RampsController:getQuoteWithFees', + { + amount: 10, + assetId: MUSD_CAIP_ID_MOCK, + autoSelectProvider: true, + fiat: DEFAULT_FIAT_CURRENCY, + isFeeExcludedFromFiat: true, + paymentMethods: ['/payments/debit-credit-card'], + restrictToKnownOrNativeProviders: true, + walletAddress: MONEY_ACCOUNT_ADDRESS, + }, + ); }); it('builds a direct pure-fiat quote without calling Relay', async () => { From 56ced5c22386af26f10aef348f9a7cb8e498d229 Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Tue, 15 Sep 2026 03:43:03 -0700 Subject: [PATCH 4/4] fix: use the resolved quote's payment method for the native fee lookup The native Transak lookup in getQuoteWithFees used paymentMethods[0] from the request, but the aggregator may price a different method (or the caller may omit the list). Use the resolved quote's own paymentMethod so the native lookup matches the quote being reconciled. --- .../src/RampsController.test.ts | 30 +++++++++++++++++++ .../ramps-controller/src/RampsController.ts | 6 +++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 23a1222d0c9..87be2734b00 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -599,6 +599,36 @@ describe('RampsController', () => { }); }); + it('uses the resolved quote payment method for the native lookup, not the request list', async () => { + const getBuyQuote = jest.fn().mockResolvedValue({ totalFee: 0.9 }); + + await withController(async ({ messenger, rootMessenger }) => { + const quotesResponse = buildQuotesResponse('/providers/transak-native'); + // The aggregator priced a method other than the caller's list head. + quotesResponse.success[0].quote.paymentMethod = + '/payments/sepa-bank-transfer'; + rootMessenger.registerActionHandler( + 'RampsService:getQuotes', + async () => quotesResponse, + ); + rootMessenger.registerActionHandler( + 'TransakService:getBuyQuote', + getBuyQuote, + ); + + await callGetQuoteWithFees(messenger); + + expect(getBuyQuote).toHaveBeenCalledWith( + 'USD', + GQF_ASSET_ID, + GQF_NETWORK, + '/payments/sepa-bank-transfer', + '15', + true, + ); + }); + }); + it('leaves a non-native quote unchanged and does not fetch a native quote', async () => { const getBuyQuote = jest.fn().mockResolvedValue({ totalFee: 0.9 }); diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index a1bddc3138f..9040dfa0861 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -2727,7 +2727,11 @@ export class RampsController extends BaseController< amount: options.amount, assetId: options.assetId, fiat: options.fiat, - paymentMethod: options.paymentMethods?.[0], + // Use the resolved quote's own payment method, not the request list: the + // aggregator may price a method other than `paymentMethods[0]` (or the + // caller may omit the list), and the native lookup must match the quote + // being reconciled. + paymentMethod: quote.quote.paymentMethod, isFeeExcludedFromFiat, }); }