diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 4706b0528b..a7c279a63f 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add optional `getBalance` callback to `TransactionPayControllerOptions` to override the source balance used for max-amount source-amount calculation ([#9802](https://github.com/MetaMask/core/pull/9802)) + ## [26.4.0] ### Changed @@ -29,6 +33,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Read the `stableTokens` remote feature flag in `getStablecoins` instead of `stable-tokens` ([#9885](https://github.com/MetaMask/core/pull/9885)) +### Removed + +- **BREAKING:** Remove `resolveSourceAmount` constructor option from `TransactionPayController` and the associated `ResolveSourceAmountCallback`, `ResolveSourceAmountRequest`, and `ResolveSourceAmountResponse` types ([#9802](https://github.com/MetaMask/core/pull/9802)) + - `resolveSourceAmount` is replaced by the more capable `getBalance` callback, which receives the full transaction and transaction data and returns `{ balanceHuman, balanceRaw }`. Migrate by replacing `resolveSourceAmount: ({ isMaxAmount, paymentOverride }) => ({ sourceAmountRaw })` with `getBalance: ({ transaction, transactionData }) => ({ balanceHuman, balanceRaw })`. + ## [26.3.0] ### Added diff --git a/packages/transaction-pay-controller/src/TransactionPayController.test.ts b/packages/transaction-pay-controller/src/TransactionPayController.test.ts index 467f6406ab..62af4b1630 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.test.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.test.ts @@ -938,9 +938,11 @@ describe('TransactionPayController', () => { }); }); - it('forwards the resolveSourceAmount option to updateSourceAmounts', () => { - const resolveSourceAmount = jest.fn(); - const controller = createController({ resolveSourceAmount }); + it('forwards getBalance callback to updateSourceAmounts', () => { + const getBalance = jest + .fn() + .mockReturnValue({ balanceHuman: '9.9', balanceRaw: '9900000' }); + const controller = createController({ getBalance }); controller.updatePaymentToken({ transactionId: TRANSACTION_ID_MOCK, @@ -951,16 +953,14 @@ describe('TransactionPayController', () => { const { updateTransactionData } = updatePaymentTokenMock.mock.calls[0][1]; updateTransactionData(TRANSACTION_ID_MOCK, (data) => { - data.sourceAmounts = [ - { sourceAmountHuman: '1.23' } as TransactionPaySourceAmount, - ]; + data.isMaxAmount = true; }); expect(updateSourceAmountsMock).toHaveBeenCalledWith( TRANSACTION_ID_MOCK, expect.any(Object), messenger, - resolveSourceAmount, + getBalance, ); }); }); diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index 0f35951e68..7cdde0e6b4 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -14,10 +14,10 @@ import { import { QuoteRefresher } from './helpers/QuoteRefresher.js'; import type { GetAmountDataCallback, + GetBalanceCallback, GetDelegationTransactionCallback, GetPaymentOverrideDataCallback, PolymarketCallbacks, - ResolveSourceAmountCallback, TransactionConfig, TransactionConfigCallback, TransactionData, @@ -69,6 +69,8 @@ export class TransactionPayController extends BaseController< > { readonly #getAmountData?: GetAmountDataCallback; + readonly #getBalance?: GetBalanceCallback; + readonly #getDelegationTransaction: GetDelegationTransactionCallback; readonly #fiatOptions?: TransactionPayFiatOptions; @@ -85,18 +87,16 @@ export class TransactionPayController extends BaseController< readonly #polymarket?: PolymarketCallbacks; - readonly #resolveSourceAmount?: ResolveSourceAmountCallback; - constructor({ fiatOptions, getAmountData, + getBalance, getDelegationTransaction, getPaymentOverrideData, getStrategy, getStrategies, messenger, polymarket, - resolveSourceAmount, state, }: TransactionPayControllerOptions) { super({ @@ -107,13 +107,13 @@ export class TransactionPayController extends BaseController< }); this.#getAmountData = getAmountData; + this.#getBalance = getBalance; this.#getDelegationTransaction = getDelegationTransaction; this.#fiatOptions = fiatOptions; this.#getPaymentOverrideData = getPaymentOverrideData; this.#getStrategy = getStrategy; this.#getStrategies = getStrategies; this.#polymarket = polymarket; - this.#resolveSourceAmount = resolveSourceAmount; this.messenger.registerMethodActionHandlers( this, @@ -378,7 +378,7 @@ export class TransactionPayController extends BaseController< transactionId, current as never, this.messenger, - this.#resolveSourceAmount, + this.#getBalance, ); shouldUpdateQuotes = true; diff --git a/packages/transaction-pay-controller/src/index.ts b/packages/transaction-pay-controller/src/index.ts index 1d52593f72..f89d3983ca 100644 --- a/packages/transaction-pay-controller/src/index.ts +++ b/packages/transaction-pay-controller/src/index.ts @@ -2,6 +2,9 @@ export type { GetAmountDataCallback, GetAmountDataRequest, GetAmountDataResponse, + GetBalanceCallback, + GetBalanceRequest, + GetBalanceResponse, GetPaymentOverrideDataRequest, GetPaymentOverrideDataResponse, TransactionConfig, @@ -19,9 +22,6 @@ export type { PolymarketCallbacks, QuoteErrorInfo, QuoteErrorReason, - ResolveSourceAmountCallback, - ResolveSourceAmountRequest, - ResolveSourceAmountResponse, TransactionPayControllerStateChangeEvent, TransactionPaymentToken, TransactionPayQuote, diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index b8ee97a399..b8951cd283 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -215,36 +215,34 @@ export type GetAmountDataCallback = ( request: GetAmountDataRequest, ) => Promise; -/** Request passed to {@link ResolveSourceAmountCallback}. */ -export type ResolveSourceAmountRequest = { - /** Whether the user selected the maximum amount. */ - isMaxAmount: boolean; - - /** Optional payment source override for the transaction. */ - paymentOverride?: PaymentOverride; +/** Request passed to {@link GetBalanceCallback}. */ +export type GetBalanceRequest = { + /** Metadata of the transaction whose source balance is being resolved. */ + transaction: TransactionMeta; + /** Pay-controller state for the transaction. */ + transactionData: TransactionData; }; -/** Response returned by {@link ResolveSourceAmountCallback}. */ -export type ResolveSourceAmountResponse = { - /** - * Exact source token amount in atomic (raw) units. Used verbatim as the - * quote's source amount, bypassing the default fiat-derived calculation. - */ - sourceAmountRaw: string; +/** Balance override returned by {@link GetBalanceCallback}. */ +export type GetBalanceResponse = { + /** Balance in human-readable format factoring token decimals. */ + balanceHuman: string; + /** Balance in atomic format without factoring token decimals. */ + balanceRaw: string; }; /** - * Optional callback that lets the client supply an exact atomic source amount - * for a required token, bypassing the default fiat-derived source calculation. - * - * Returns `undefined` to fall back to the default calculation. Must be - * synchronous: it is consumed during synchronous source-amount computation, so - * the client should read from already-available (cached) state rather than - * performing async lookups. + * Optional client-supplied callback that overrides the built-in + * pay-token / required-token balance lookup used for `isMaxAmount` + * source-amount calculation. Enables alternate balance sources + * (perps, predict, money-account, post-quote, etc.) without adding + * conditional branches inside the controller. MUST be synchronous: + * it runs inside the controller state-update block. + * Return `undefined` to fall back to the built-in token balance. */ -export type ResolveSourceAmountCallback = ( - request: ResolveSourceAmountRequest, -) => ResolveSourceAmountResponse | undefined; +export type GetBalanceCallback = ( + request: GetBalanceRequest, +) => GetBalanceResponse | undefined; /** Callback to update fiat payment state. */ export type TransactionFiatPaymentCallback = ( @@ -285,6 +283,9 @@ export type TransactionPayControllerOptions = { /** Optional callback to re-encode nested transaction calldata for a given amount. */ getAmountData?: GetAmountDataCallback; + /** Optional callback to override the source balance used for max-amount calculation. */ + getBalance?: GetBalanceCallback; + /** Callback to convert a transaction into a redeem delegation. */ getDelegationTransaction: GetDelegationTransactionCallback; @@ -309,12 +310,6 @@ export type TransactionPayControllerOptions = { /** Callbacks for the Polymarket relayer; required only for the Polymarket deposit-wallet flow. */ polymarket?: PolymarketCallbacks; - /** - * Optional callback to supply an exact atomic source amount for a required - * token, bypassing the default fiat-derived source calculation. - */ - resolveSourceAmount?: ResolveSourceAmountCallback; - /** Initial state of the controller. */ state?: Partial; }; diff --git a/packages/transaction-pay-controller/src/utils/source-amounts.test.ts b/packages/transaction-pay-controller/src/utils/source-amounts.test.ts index c4f0365e31..6bd9f3f8f4 100644 --- a/packages/transaction-pay-controller/src/utils/source-amounts.test.ts +++ b/packages/transaction-pay-controller/src/utils/source-amounts.test.ts @@ -268,143 +268,221 @@ describe('Source Amounts Utils', () => { ]); }); - it('uses fiat-derived source amount for MoneyAccount max instead of payment token balance', () => { - // Money account withdrawable (mUSD + vmUSD) is reflected in the typed - // required token amount. The pay token's on-chain balance is only the - // un-vaulted mUSD portion and must not collapse Max. + it('uses getBalance override for isMaxAmount standard flow', () => { + const getBalance = jest.fn().mockReturnValue({ + balanceRaw: '9900000', + }); + const transactionData: TransactionData = { isLoading: false, isMaxAmount: true, - paymentOverride: PaymentOverride.MoneyAccount, - paymentToken: { - ...PAYMENT_TOKEN_MOCK, - // Bare on-chain mUSD — much smaller than the typed max. - balanceHuman: '0.62', - balanceRaw: '620000', - balanceUsd: '0.62', - }, - tokens: [ - { - ...TRANSACTION_TOKEN_MOCK, - // Full withdrawable max typed by the client ($6.00 USD). - amountUsd: '6.0', - }, - ], + paymentToken: PAYMENT_TOKEN_MOCK, + tokens: [TRANSACTION_TOKEN_MOCK], }; - updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, messenger); + updateSourceAmounts( + TRANSACTION_ID_MOCK, + transactionData, + messenger, + getBalance, + ); - // usdRate mock is 3.0 → source human = 6 / 3 = 2, raw = 2 * 10^6. expect(transactionData.sourceAmounts).toStrictEqual([ { - sourceAmountHuman: '2', - sourceAmountRaw: '2000000', + sourceAmountHuman: PAYMENT_TOKEN_MOCK.balanceHuman, + sourceAmountRaw: '9900000', targetTokenAddress: TRANSACTION_TOKEN_MOCK.address, }, ]); }); - it('uses resolveSourceAmount result verbatim when provided', () => { - const resolveSourceAmount = jest - .fn() - .mockReturnValue({ sourceAmountRaw: '15019083' }); + it('falls back to payment token balance when getBalance returns undefined', () => { + const getBalance = jest.fn().mockReturnValue(undefined); const transactionData: TransactionData = { isLoading: false, isMaxAmount: true, - paymentOverride: PaymentOverride.MoneyAccount, paymentToken: PAYMENT_TOKEN_MOCK, - tokens: [{ ...TRANSACTION_TOKEN_MOCK, amountUsd: '6.0' }], + tokens: [TRANSACTION_TOKEN_MOCK], }; updateSourceAmounts( TRANSACTION_ID_MOCK, transactionData, messenger, - resolveSourceAmount, + getBalance, ); - expect(resolveSourceAmount).toHaveBeenCalledWith({ - isMaxAmount: true, - paymentOverride: PaymentOverride.MoneyAccount, + expect(transactionData.sourceAmounts).toStrictEqual([ + { + sourceAmountHuman: PAYMENT_TOKEN_MOCK.balanceHuman, + sourceAmountRaw: PAYMENT_TOKEN_MOCK.balanceRaw, + targetTokenAddress: TRANSACTION_TOKEN_MOCK.address, + }, + ]); + }); + + it('ignores getBalance when isMaxAmount is false', () => { + const getBalance = jest.fn().mockReturnValue({ + balanceRaw: '9900000', }); - // Raw used verbatim; human derived by shifting down payment decimals (6). + const transactionData: TransactionData = { + isLoading: false, + paymentToken: PAYMENT_TOKEN_MOCK, + tokens: [TRANSACTION_TOKEN_MOCK], + }; + + updateSourceAmounts( + TRANSACTION_ID_MOCK, + transactionData, + messenger, + getBalance, + ); + + // isMaxAmount is false, so fiat-derived amounts should be used (not the override) expect(transactionData.sourceAmounts).toStrictEqual([ { - sourceAmountHuman: '15.019083', - sourceAmountRaw: '15019083', + sourceAmountHuman: '2', + sourceAmountRaw: '2000000', targetTokenAddress: TRANSACTION_TOKEN_MOCK.address, }, ]); }); - it('falls back to fiat-derived amount when resolveSourceAmount returns undefined', () => { - const resolveSourceAmount = jest.fn().mockReturnValue(undefined); + it('does not call getBalance when transaction is not found', () => { + // First call (top of updateSourceAmounts) returns undefined; subsequent + // calls (getStrategyContext) return the normal mock so no crash. + getTransactionMock.mockReturnValueOnce(undefined); + + const getBalance = jest.fn().mockReturnValue({ + balanceRaw: '9900000', + }); const transactionData: TransactionData = { isLoading: false, isMaxAmount: true, - paymentOverride: PaymentOverride.MoneyAccount, paymentToken: PAYMENT_TOKEN_MOCK, - tokens: [{ ...TRANSACTION_TOKEN_MOCK, amountUsd: '6.0' }], + tokens: [TRANSACTION_TOKEN_MOCK], }; updateSourceAmounts( TRANSACTION_ID_MOCK, transactionData, messenger, - resolveSourceAmount, + getBalance, ); - expect(resolveSourceAmount).toHaveBeenCalledTimes(1); - // usdRate mock is 3.0 → source human = 6 / 3 = 2, raw = 2 * 10^6. + expect(getBalance).not.toHaveBeenCalled(); expect(transactionData.sourceAmounts).toStrictEqual([ { - sourceAmountHuman: '2', - sourceAmountRaw: '2000000', + sourceAmountHuman: PAYMENT_TOKEN_MOCK.balanceHuman, + sourceAmountRaw: PAYMENT_TOKEN_MOCK.balanceRaw, targetTokenAddress: TRANSACTION_TOKEN_MOCK.address, }, ]); }); - it('does not invoke resolveSourceAmount when the zero-amount guard skips the token', () => { - const resolveSourceAmount = jest - .fn() - .mockReturnValue({ sourceAmountRaw: '15019083' }); + it('uses getBalance override for MoneyAccount max when getBalance is provided', () => { + const getBalance = jest.fn().mockReturnValue({ + balanceRaw: '9900000', + }); const transactionData: TransactionData = { isLoading: false, isMaxAmount: true, paymentOverride: PaymentOverride.MoneyAccount, - paymentToken: PAYMENT_TOKEN_MOCK, - tokens: [{ ...TRANSACTION_TOKEN_MOCK, amountRaw: '0' }], + paymentToken: { + ...PAYMENT_TOKEN_MOCK, + balanceHuman: '0.62', + balanceRaw: '620000', + balanceUsd: '0.62', + }, + tokens: [ + { + ...TRANSACTION_TOKEN_MOCK, + amountUsd: '6.0', + }, + ], }; updateSourceAmounts( TRANSACTION_ID_MOCK, transactionData, messenger, - resolveSourceAmount, + getBalance, ); - expect(resolveSourceAmount).not.toHaveBeenCalled(); - expect(transactionData.sourceAmounts).toStrictEqual([]); + // getBalance is provided, so its raw override is applied even for + // MoneyAccount. sourceAmountHuman is unread and falls back to the pay + // token balance. + expect(transactionData.sourceAmounts).toStrictEqual([ + { + sourceAmountHuman: '0.62', + sourceAmountRaw: '9900000', + targetTokenAddress: TRANSACTION_TOKEN_MOCK.address, + }, + ]); + }); + + it('uses the payment token balance on max when getBalance is not provided (payment-override agnostic)', () => { + const transactionData: TransactionData = { + isLoading: false, + isMaxAmount: true, + paymentOverride: PaymentOverride.MoneyAccount, + paymentToken: { + ...PAYMENT_TOKEN_MOCK, + balanceHuman: '0.62', + balanceRaw: '620000', + balanceUsd: '0.62', + }, + tokens: [ + { + ...TRANSACTION_TOKEN_MOCK, + amountUsd: '6.0', + }, + ], + }; + + updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, messenger); + + // No getBalance callback: max uses the pay token's on-chain balance, + // regardless of paymentOverride. All balance complexity now lives in the + // client getBalance callback. + expect(transactionData.sourceAmounts).toStrictEqual([ + { + sourceAmountHuman: '0.62', + sourceAmountRaw: '620000', + targetTokenAddress: TRANSACTION_TOKEN_MOCK.address, + }, + ]); }); - it('does not invoke resolveSourceAmount when the same-token guard skips the token', () => { - const resolveSourceAmount = jest - .fn() - .mockReturnValue({ sourceAmountRaw: '15019083' }); + it('uses getBalance override for isMaxAmount post-quote flow', () => { + const DESTINATION_TOKEN = { + address: '0xdef' as const, + balanceFiat: '100.00', + balanceHuman: '1.00', + balanceRaw: '1000000000000000000', + balanceUsd: '100.00', + chainId: '0x38' as const, + decimals: 18, + symbol: 'BNB', + }; + + const getBalance = jest.fn().mockReturnValue({ + balanceRaw: '5500000', + }); const transactionData: TransactionData = { isLoading: false, - paymentToken: PAYMENT_TOKEN_MOCK, + isMaxAmount: true, + isPostQuote: true, + paymentToken: DESTINATION_TOKEN, tokens: [ { ...TRANSACTION_TOKEN_MOCK, - address: PAYMENT_TOKEN_MOCK.address, - chainId: PAYMENT_TOKEN_MOCK.chainId, + skipIfBalance: false, }, ], }; @@ -413,11 +491,60 @@ describe('Source Amounts Utils', () => { TRANSACTION_ID_MOCK, transactionData, messenger, - resolveSourceAmount, + getBalance, ); - expect(resolveSourceAmount).not.toHaveBeenCalled(); - expect(transactionData.sourceAmounts).toStrictEqual([]); + expect(transactionData.sourceAmounts).toStrictEqual([ + { + sourceAmountHuman: TRANSACTION_TOKEN_MOCK.balanceHuman, + sourceAmountRaw: '5500000', + sourceBalanceRaw: '5500000', + sourceChainId: TRANSACTION_TOKEN_MOCK.chainId, + sourceTokenAddress: TRANSACTION_TOKEN_MOCK.address, + targetTokenAddress: DESTINATION_TOKEN.address, + }, + ]); + }); + + it('falls back to the payment token balance when getBalance returns undefined for MoneyAccount max', () => { + // A getBalance callback that returns undefined is a deliberate signal to + // use the pay token's on-chain balance — not the legacy fiat-derived + // amount. The callback owns all balance complexity. + const getBalance = jest.fn().mockReturnValue(undefined); + + const transactionData: TransactionData = { + isLoading: false, + isMaxAmount: true, + paymentOverride: PaymentOverride.MoneyAccount, + paymentToken: { + ...PAYMENT_TOKEN_MOCK, + balanceHuman: '0.62', + balanceRaw: '620000', + balanceUsd: '0.62', + }, + tokens: [ + { + ...TRANSACTION_TOKEN_MOCK, + amountUsd: '6.0', + }, + ], + }; + + updateSourceAmounts( + TRANSACTION_ID_MOCK, + transactionData, + messenger, + getBalance, + ); + + expect(getBalance).toHaveBeenCalled(); + expect(transactionData.sourceAmounts).toStrictEqual([ + { + sourceAmountHuman: '0.62', + sourceAmountRaw: '620000', + targetTokenAddress: TRANSACTION_TOKEN_MOCK.address, + }, + ]); }); it('does nothing if no payment token', () => { diff --git a/packages/transaction-pay-controller/src/utils/source-amounts.ts b/packages/transaction-pay-controller/src/utils/source-amounts.ts index 0674a2aff1..25332164d6 100644 --- a/packages/transaction-pay-controller/src/utils/source-amounts.ts +++ b/packages/transaction-pay-controller/src/utils/source-amounts.ts @@ -8,7 +8,6 @@ import { BigNumber } from 'bignumber.js'; import { ARBITRUM_USDC_ADDRESS, CHAIN_ID_ARBITRUM, - PaymentOverride, PERPS_DEPOSIT_TYPES, } from '../constants.js'; import type { @@ -18,10 +17,11 @@ import type { import { TransactionPayStrategy } from '../index.js'; import { projectLogger } from '../logger.js'; import type { + GetBalanceCallback, + GetBalanceResponse, TransactionPaySourceAmount, TransactionData, TransactionPayRequiredToken, - ResolveSourceAmountCallback, } from '../types.js'; import { getTokenFiatRate, isSameToken } from './token.js'; import { getTransaction } from './transaction.js'; @@ -34,25 +34,35 @@ const log = createModuleLogger(projectLogger, 'source-amounts'); * @param transactionId - ID of the transaction to update. * @param transactionData - Existing transaction data. * @param messenger - Controller messenger. - * @param resolveSourceAmount - Optional callback supplying an exact atomic source amount. + * @param getBalance - Optional callback to override the source balance used for max-amount + * calculation. Called only when `isMaxAmount` is true. Return `undefined` to fall back to + * the built-in token balance. */ export function updateSourceAmounts( transactionId: string, transactionData: TransactionData | undefined, messenger: TransactionPayControllerMessenger, - resolveSourceAmount?: ResolveSourceAmountCallback, + getBalance?: GetBalanceCallback, ): void { if (!transactionData) { return; } - const { isMaxAmount, isPostQuote, paymentOverride, paymentToken, tokens } = - transactionData; + const { isMaxAmount, isPostQuote, paymentToken, tokens } = transactionData; if (!tokens.length || !paymentToken) { return; } + const transaction = + getBalance && isMaxAmount + ? getTransaction(transactionId, messenger) + : undefined; + const balanceOverride = + getBalance && transaction + ? getBalance({ transaction, transactionData }) + : undefined; + // For post-quote flows, source amounts are calculated differently // The source is the transaction's required token, not the selected token if (isPostQuote) { @@ -63,6 +73,7 @@ export function updateSourceAmounts( isMaxAmount ?? false, isHyperliquidSource, isPolymarketDepositWallet, + balanceOverride, ); log('Updated post-quote source amounts', { transactionId, sourceAmounts }); transactionData.sourceAmounts = sourceAmounts; @@ -80,8 +91,7 @@ export function updateSourceAmounts( transactionId, isMaxAmount ?? false, isQuoteRequired, - paymentOverride, - resolveSourceAmount, + balanceOverride, ), ) .filter(Boolean) as TransactionPaySourceAmount[]; @@ -101,6 +111,7 @@ export function updateSourceAmounts( * @param isMaxAmount - Whether the transaction is a maximum amount transaction. * @param isHyperliquidSource - Whether the source is HyperLiquid (perps withdrawal). * @param isPolymarketDepositWallet - Whether the source is a Polymarket deposit wallet. + * @param balanceOverride - Optional balance override from the `getBalance` callback. * @returns Array of source amounts. */ function calculatePostQuoteSourceAmounts( @@ -109,6 +120,7 @@ function calculatePostQuoteSourceAmounts( isMaxAmount: boolean, isHyperliquidSource?: boolean, isPolymarketDepositWallet?: boolean, + balanceOverride?: GetBalanceResponse, ): TransactionPaySourceAmount[] { return tokens .filter((token) => { @@ -138,8 +150,10 @@ function calculatePostQuoteSourceAmounts( }) .map((token) => ({ sourceAmountHuman: isMaxAmount ? token.balanceHuman : token.amountHuman, - sourceAmountRaw: isMaxAmount ? token.balanceRaw : token.amountRaw, - sourceBalanceRaw: token.balanceRaw, + sourceAmountRaw: isMaxAmount + ? (balanceOverride?.balanceRaw ?? token.balanceRaw) + : token.amountRaw, + sourceBalanceRaw: balanceOverride?.balanceRaw ?? token.balanceRaw, sourceChainId: token.chainId, sourceTokenAddress: token.address, targetTokenAddress: paymentToken.address, @@ -155,8 +169,7 @@ function calculatePostQuoteSourceAmounts( * @param transactionId - ID of the transaction. * @param isMaxAmount - Whether the transaction is a maximum amount transaction. * @param isQuoteRequired - When true, a quote is always fetched even when source and target tokens are identical. - * @param paymentOverride - Optional payment source override for the transaction. - * @param resolveSourceAmount - Optional callback supplying an exact atomic source amount. + * @param balanceOverride - Optional balance override from the `getBalance` callback. * @returns The source amount or undefined if calculation failed. */ function calculateSourceAmount( @@ -166,8 +179,7 @@ function calculateSourceAmount( transactionId: string, isMaxAmount: boolean, isQuoteRequired?: boolean, - paymentOverride?: PaymentOverride, - resolveSourceAmount?: ResolveSourceAmountCallback, + balanceOverride?: GetBalanceResponse, ): TransactionPaySourceAmount | undefined { const paymentTokenFiatRate = getTokenFiatRate( messenger, @@ -209,29 +221,6 @@ function calculateSourceAmount( return undefined; } - const resolvedSourceAmount = resolveSourceAmount?.({ - isMaxAmount, - paymentOverride, - }); - - if (resolvedSourceAmount) { - const { sourceAmountRaw } = resolvedSourceAmount; - const sourceAmountHuman = new BigNumber(sourceAmountRaw) - .shiftedBy(-paymentToken.decimals) - .toString(10); - - log('Resolved source amount from callback', { - tokenAddress: token.address, - sourceAmountRaw, - }); - - return { - sourceAmountHuman, - sourceAmountRaw, - targetTokenAddress: token.address, - }; - } - const sourceAmountHumanValue = new BigNumber(token.amountUsd).div( paymentTokenFiatRate.usdRate, ); @@ -242,15 +231,15 @@ function calculateSourceAmount( .shiftedBy(paymentToken.decimals) .toFixed(0); - // Money account Max must not use the pay token's on-chain balance. That - // balance is only un-vaulted mUSD, while the typed required amount already - // reflects the full withdrawable total (mUSD + vmUSD). Using the typed - // fiat-derived source keeps isMaxAmount=true (EXACT_INPUT) correct for - // deposits funded from the money account (e.g. Send to Perps). - if (isMaxAmount && paymentOverride !== PaymentOverride.MoneyAccount) { + // On Max, use the exact source balance. The client `getBalance` callback is + // authoritative and owns all balance complexity (perps, predict, money + // account, payment overrides): when it returns a `balanceOverride`, use it; + // when it returns `undefined`, that is a deliberate signal to use the pay + // token's on-chain balance. This path is payment-override agnostic. + if (isMaxAmount) { return { sourceAmountHuman: paymentToken.balanceHuman, - sourceAmountRaw: paymentToken.balanceRaw, + sourceAmountRaw: balanceOverride?.balanceRaw ?? paymentToken.balanceRaw, targetTokenAddress: token.address, }; }