From f377b4a226095d235d9cf6169ada8be0e8fabb22 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Fri, 7 Aug 2026 14:37:56 +0100 Subject: [PATCH 1/6] feat: add optional getBalance callback to TransactionPayController --- .../transaction-pay-controller/CHANGELOG.md | 4 + .../src/TransactionPayController.test.ts | 12 +- .../src/TransactionPayController.ts | 17 +-- .../transaction-pay-controller/src/index.ts | 6 +- .../transaction-pay-controller/src/types.ts | 55 ++++----- .../src/utils/source-amounts.test.ts | 114 ++++++++++++++++++ .../src/utils/source-amounts.ts | 69 ++++++----- 7 files changed, 191 insertions(+), 86 deletions(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 4706b0528b..77476876e6 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 + ## [26.4.0] ### Changed diff --git a/packages/transaction-pay-controller/src/TransactionPayController.test.ts b/packages/transaction-pay-controller/src/TransactionPayController.test.ts index 467f6406ab..7b536e4959 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.test.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.test.ts @@ -938,9 +938,9 @@ 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 +951,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..a1dd2f80a0 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, @@ -374,12 +374,7 @@ export class TransactionPayController extends BaseController< isPostQuoteUpdated || isAccountOverrideUpdated ) { - updateSourceAmounts( - transactionId, - current as never, - this.messenger, - this.#resolveSourceAmount, - ); + updateSourceAmounts(transactionId, current as never, this.messenger, 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..60f6ee2ab9 100644 --- a/packages/transaction-pay-controller/src/utils/source-amounts.test.ts +++ b/packages/transaction-pay-controller/src/utils/source-amounts.test.ts @@ -268,6 +268,120 @@ describe('Source Amounts Utils', () => { ]); }); + + it('uses getBalance override for isMaxAmount standard flow', () => { + const getBalance = jest.fn().mockReturnValue({ + balanceHuman: '9.9', + balanceRaw: '9900000', + }); + + const transactionData: TransactionData = { + isLoading: false, + isMaxAmount: true, + paymentToken: PAYMENT_TOKEN_MOCK, + tokens: [TRANSACTION_TOKEN_MOCK], + }; + + updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, messenger, getBalance); + + expect(transactionData.sourceAmounts).toStrictEqual([ + { + sourceAmountHuman: '9.9', + sourceAmountRaw: '9900000', + targetTokenAddress: TRANSACTION_TOKEN_MOCK.address, + }, + ]); + }); + + it('falls back to payment token balance when getBalance returns undefined', () => { + const getBalance = jest.fn().mockReturnValue(undefined); + + const transactionData: TransactionData = { + isLoading: false, + isMaxAmount: true, + paymentToken: PAYMENT_TOKEN_MOCK, + tokens: [TRANSACTION_TOKEN_MOCK], + }; + + updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, messenger, getBalance); + + 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({ + balanceHuman: '9.9', + balanceRaw: '9900000', + }); + + 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: '2', + sourceAmountRaw: '2000000', + targetTokenAddress: TRANSACTION_TOKEN_MOCK.address, + }, + ]); + }); + + 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({ + balanceHuman: '5.5', + balanceRaw: '5500000', + }); + + const transactionData: TransactionData = { + isLoading: false, + isMaxAmount: true, + isPostQuote: true, + paymentToken: DESTINATION_TOKEN, + tokens: [ + { + ...TRANSACTION_TOKEN_MOCK, + skipIfBalance: false, + }, + ], + }; + + updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, messenger, getBalance); + + expect(transactionData.sourceAmounts).toStrictEqual([ + { + sourceAmountHuman: '5.5', + sourceAmountRaw: '5500000', + sourceBalanceRaw: '5500000', + sourceChainId: TRANSACTION_TOKEN_MOCK.chainId, + sourceTokenAddress: TRANSACTION_TOKEN_MOCK.address, + targetTokenAddress: DESTINATION_TOKEN.address, + }, + ]); + }); + 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 diff --git a/packages/transaction-pay-controller/src/utils/source-amounts.ts b/packages/transaction-pay-controller/src/utils/source-amounts.ts index 0674a2aff1..030620732c 100644 --- a/packages/transaction-pay-controller/src/utils/source-amounts.ts +++ b/packages/transaction-pay-controller/src/utils/source-amounts.ts @@ -18,10 +18,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,13 +35,15 @@ 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; @@ -53,6 +56,15 @@ export function updateSourceAmounts( return; } + const transaction = + getBalance && isMaxAmount + ? getTransaction(transactionId, messenger) + : undefined; + const balanceOverride = + getBalance && transaction + ? getBalance({ transaction: transaction as TransactionMeta, 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 +75,7 @@ export function updateSourceAmounts( isMaxAmount ?? false, isHyperliquidSource, isPolymarketDepositWallet, + balanceOverride, ); log('Updated post-quote source amounts', { transactionId, sourceAmounts }); transactionData.sourceAmounts = sourceAmounts; @@ -81,7 +94,7 @@ export function updateSourceAmounts( isMaxAmount ?? false, isQuoteRequired, paymentOverride, - resolveSourceAmount, + balanceOverride, ), ) .filter(Boolean) as TransactionPaySourceAmount[]; @@ -109,6 +122,7 @@ function calculatePostQuoteSourceAmounts( isMaxAmount: boolean, isHyperliquidSource?: boolean, isPolymarketDepositWallet?: boolean, + balanceOverride?: GetBalanceResponse, ): TransactionPaySourceAmount[] { return tokens .filter((token) => { @@ -137,9 +151,9 @@ function calculatePostQuoteSourceAmounts( return true; }) .map((token) => ({ - sourceAmountHuman: isMaxAmount ? token.balanceHuman : token.amountHuman, - sourceAmountRaw: isMaxAmount ? token.balanceRaw : token.amountRaw, - sourceBalanceRaw: token.balanceRaw, + sourceAmountHuman: isMaxAmount ? (balanceOverride?.balanceHuman ?? token.balanceHuman) : token.amountHuman, + sourceAmountRaw: isMaxAmount ? (balanceOverride?.balanceRaw ?? token.balanceRaw) : token.amountRaw, + sourceBalanceRaw: balanceOverride?.balanceRaw ?? token.balanceRaw, sourceChainId: token.chainId, sourceTokenAddress: token.address, targetTokenAddress: paymentToken.address, @@ -156,7 +170,7 @@ function calculatePostQuoteSourceAmounts( * @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( @@ -167,7 +181,7 @@ function calculateSourceAmount( isMaxAmount: boolean, isQuoteRequired?: boolean, paymentOverride?: PaymentOverride, - resolveSourceAmount?: ResolveSourceAmountCallback, + balanceOverride?: GetBalanceResponse, ): TransactionPaySourceAmount | undefined { const paymentTokenFiatRate = getTokenFiatRate( messenger, @@ -209,29 +223,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, ); @@ -247,10 +238,18 @@ function calculateSourceAmount( // 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) { + // Exception: when a getBalance callback is provided (balanceOverride is + // defined), the callback is authoritative and bypasses the MoneyAccount + // guard — all balance complexity lives in the callback. + if ( + isMaxAmount && + (balanceOverride !== undefined || + paymentOverride !== PaymentOverride.MoneyAccount) + ) { return { - sourceAmountHuman: paymentToken.balanceHuman, - sourceAmountRaw: paymentToken.balanceRaw, + sourceAmountHuman: + balanceOverride?.balanceHuman ?? paymentToken.balanceHuman, + sourceAmountRaw: balanceOverride?.balanceRaw ?? paymentToken.balanceRaw, targetTokenAddress: token.address, }; } From 80304b54f0189e6698c88cb1072cd335b55244c3 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Fri, 7 Aug 2026 14:46:27 +0100 Subject: [PATCH 2/6] =?UTF-8?q?fix:=20address=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20prettier,=20getBalance=20guard,=20missing=20tests,?= =?UTF-8?q?=20changelog=20link?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../transaction-pay-controller/CHANGELOG.md | 2 +- .../src/TransactionPayController.test.ts | 4 +- .../src/TransactionPayController.ts | 7 +- .../src/utils/source-amounts.test.ts | 105 +++++++++++++++++- .../src/utils/source-amounts.ts | 22 ++-- 5 files changed, 124 insertions(+), 16 deletions(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 77476876e6..a0d973bcf1 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add optional `getBalance` callback to `TransactionPayControllerOptions` to override the source balance used for max-amount source-amount calculation +- 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] diff --git a/packages/transaction-pay-controller/src/TransactionPayController.test.ts b/packages/transaction-pay-controller/src/TransactionPayController.test.ts index 7b536e4959..62af4b1630 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.test.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.test.ts @@ -939,7 +939,9 @@ describe('TransactionPayController', () => { }); it('forwards getBalance callback to updateSourceAmounts', () => { - const getBalance = jest.fn().mockReturnValue({ balanceHuman: '9.9', balanceRaw: '9900000' }); + const getBalance = jest + .fn() + .mockReturnValue({ balanceHuman: '9.9', balanceRaw: '9900000' }); const controller = createController({ getBalance }); controller.updatePaymentToken({ diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index a1dd2f80a0..7cdde0e6b4 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -374,7 +374,12 @@ export class TransactionPayController extends BaseController< isPostQuoteUpdated || isAccountOverrideUpdated ) { - updateSourceAmounts(transactionId, current as never, this.messenger, this.#getBalance); + updateSourceAmounts( + transactionId, + current as never, + this.messenger, + this.#getBalance, + ); shouldUpdateQuotes = true; } 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 60f6ee2ab9..b7b9676df9 100644 --- a/packages/transaction-pay-controller/src/utils/source-amounts.test.ts +++ b/packages/transaction-pay-controller/src/utils/source-amounts.test.ts @@ -268,7 +268,6 @@ describe('Source Amounts Utils', () => { ]); }); - it('uses getBalance override for isMaxAmount standard flow', () => { const getBalance = jest.fn().mockReturnValue({ balanceHuman: '9.9', @@ -282,7 +281,12 @@ describe('Source Amounts Utils', () => { tokens: [TRANSACTION_TOKEN_MOCK], }; - updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, messenger, getBalance); + updateSourceAmounts( + TRANSACTION_ID_MOCK, + transactionData, + messenger, + getBalance, + ); expect(transactionData.sourceAmounts).toStrictEqual([ { @@ -303,7 +307,12 @@ describe('Source Amounts Utils', () => { tokens: [TRANSACTION_TOKEN_MOCK], }; - updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, messenger, getBalance); + updateSourceAmounts( + TRANSACTION_ID_MOCK, + transactionData, + messenger, + getBalance, + ); expect(transactionData.sourceAmounts).toStrictEqual([ { @@ -326,7 +335,12 @@ describe('Source Amounts Utils', () => { tokens: [TRANSACTION_TOKEN_MOCK], }; - updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, messenger, getBalance); + updateSourceAmounts( + TRANSACTION_ID_MOCK, + transactionData, + messenger, + getBalance, + ); // isMaxAmount is false, so fiat-derived amounts should be used (not the override) expect(transactionData.sourceAmounts).toStrictEqual([ @@ -338,6 +352,82 @@ describe('Source Amounts Utils', () => { ]); }); + 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({ + balanceHuman: '9.9', + balanceRaw: '9900000', + }); + + const transactionData: TransactionData = { + isLoading: false, + isMaxAmount: true, + paymentToken: PAYMENT_TOKEN_MOCK, + tokens: [TRANSACTION_TOKEN_MOCK], + }; + + updateSourceAmounts( + TRANSACTION_ID_MOCK, + transactionData, + messenger, + getBalance, + ); + + expect(getBalance).not.toHaveBeenCalled(); + expect(transactionData.sourceAmounts).toStrictEqual([ + { + sourceAmountHuman: PAYMENT_TOKEN_MOCK.balanceHuman, + sourceAmountRaw: PAYMENT_TOKEN_MOCK.balanceRaw, + targetTokenAddress: TRANSACTION_TOKEN_MOCK.address, + }, + ]); + }); + + it('ignores getBalance override when paymentOverride is MoneyAccount and isMaxAmount is true', () => { + const getBalance = jest.fn().mockReturnValue({ + balanceHuman: '9.9', + balanceRaw: '9900000', + }); + + 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, + ); + + // MoneyAccount branch uses fiat-derived amounts, not the getBalance override. + // usdRate mock is 3.0 -> source human = 6 / 3 = 2, raw = 2 * 10^6. + expect(transactionData.sourceAmounts).toStrictEqual([ + { + sourceAmountHuman: '2', + sourceAmountRaw: '2000000', + targetTokenAddress: TRANSACTION_TOKEN_MOCK.address, + }, + ]); + }); + it('uses getBalance override for isMaxAmount post-quote flow', () => { const DESTINATION_TOKEN = { address: '0xdef' as const, @@ -368,7 +458,12 @@ describe('Source Amounts Utils', () => { ], }; - updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, messenger, getBalance); + updateSourceAmounts( + TRANSACTION_ID_MOCK, + transactionData, + messenger, + getBalance, + ); expect(transactionData.sourceAmounts).toStrictEqual([ { diff --git a/packages/transaction-pay-controller/src/utils/source-amounts.ts b/packages/transaction-pay-controller/src/utils/source-amounts.ts index 030620732c..e6217168d8 100644 --- a/packages/transaction-pay-controller/src/utils/source-amounts.ts +++ b/packages/transaction-pay-controller/src/utils/source-amounts.ts @@ -56,13 +56,15 @@ export function updateSourceAmounts( return; } - const transaction = - getBalance && isMaxAmount - ? getTransaction(transactionId, messenger) - : undefined; + const transaction = getBalance + ? getTransaction(transactionId, messenger) + : undefined; const balanceOverride = - getBalance && transaction - ? getBalance({ transaction: transaction as TransactionMeta, transactionData }) + getBalance && transaction && isMaxAmount + ? getBalance({ + transaction: transaction as TransactionMeta, + transactionData, + }) : undefined; // For post-quote flows, source amounts are calculated differently @@ -151,8 +153,12 @@ function calculatePostQuoteSourceAmounts( return true; }) .map((token) => ({ - sourceAmountHuman: isMaxAmount ? (balanceOverride?.balanceHuman ?? token.balanceHuman) : token.amountHuman, - sourceAmountRaw: isMaxAmount ? (balanceOverride?.balanceRaw ?? token.balanceRaw) : token.amountRaw, + sourceAmountHuman: isMaxAmount + ? (balanceOverride?.balanceHuman ?? token.balanceHuman) + : token.amountHuman, + sourceAmountRaw: isMaxAmount + ? (balanceOverride?.balanceRaw ?? token.balanceRaw) + : token.amountRaw, sourceBalanceRaw: balanceOverride?.balanceRaw ?? token.balanceRaw, sourceChainId: token.chainId, sourceTokenAddress: token.address, From 34a06e63130c47f02ad07afe9c8191104ca37b03 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Fri, 7 Aug 2026 14:52:05 +0100 Subject: [PATCH 3/6] fix: add missing JSDoc params and remove unnecessary type assertion in source-amounts --- .../src/utils/source-amounts.ts | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/packages/transaction-pay-controller/src/utils/source-amounts.ts b/packages/transaction-pay-controller/src/utils/source-amounts.ts index e6217168d8..1c5f9cd8c5 100644 --- a/packages/transaction-pay-controller/src/utils/source-amounts.ts +++ b/packages/transaction-pay-controller/src/utils/source-amounts.ts @@ -56,15 +56,13 @@ export function updateSourceAmounts( return; } - const transaction = getBalance - ? getTransaction(transactionId, messenger) - : undefined; + const transaction = + getBalance && isMaxAmount + ? getTransaction(transactionId, messenger) + : undefined; const balanceOverride = - getBalance && transaction && isMaxAmount - ? getBalance({ - transaction: transaction as TransactionMeta, - transactionData, - }) + getBalance && transaction + ? getBalance({ transaction, transactionData }) : undefined; // For post-quote flows, source amounts are calculated differently @@ -116,6 +114,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( From ba118ea359a670392d301c81d21d1f7ed7b7ba2c Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Fri, 7 Aug 2026 16:20:51 +0100 Subject: [PATCH 4/6] feat: allow getBalance callback to override MoneyAccount max branch --- .../src/utils/source-amounts.test.ts | 35 +++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) 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 b7b9676df9..9245fa38bd 100644 --- a/packages/transaction-pay-controller/src/utils/source-amounts.test.ts +++ b/packages/transaction-pay-controller/src/utils/source-amounts.test.ts @@ -386,7 +386,7 @@ describe('Source Amounts Utils', () => { ]); }); - it('ignores getBalance override when paymentOverride is MoneyAccount and isMaxAmount is true', () => { + it('uses getBalance override for MoneyAccount max when getBalance is provided', () => { const getBalance = jest.fn().mockReturnValue({ balanceHuman: '9.9', balanceRaw: '9900000', @@ -417,7 +417,38 @@ describe('Source Amounts Utils', () => { getBalance, ); - // MoneyAccount branch uses fiat-derived amounts, not the getBalance override. + // getBalance is provided, so the override is applied even for MoneyAccount. + expect(transactionData.sourceAmounts).toStrictEqual([ + { + sourceAmountHuman: '9.9', + sourceAmountRaw: '9900000', + targetTokenAddress: TRANSACTION_TOKEN_MOCK.address, + }, + ]); + }); + + it('preserves MoneyAccount max guard when getBalance is not provided', () => { + 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: MoneyAccount guard applies, fiat-derived amounts used. // usdRate mock is 3.0 -> source human = 6 / 3 = 2, raw = 2 * 10^6. expect(transactionData.sourceAmounts).toStrictEqual([ { From c6f4d6564df733a905d92a4028a7326a31882687 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Thu, 13 Aug 2026 14:13:42 +0100 Subject: [PATCH 5/6] feat: replace resolveSourceAmount with getBalance in changelog and remove stale tests --- .../transaction-pay-controller/CHANGELOG.md | 5 + .../src/utils/source-amounts.test.ts | 116 ------------------ 2 files changed, 5 insertions(+), 116 deletions(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index a0d973bcf1..a7c279a63f 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -33,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/utils/source-amounts.test.ts b/packages/transaction-pay-controller/src/utils/source-amounts.test.ts index 9245fa38bd..00e7b68204 100644 --- a/packages/transaction-pay-controller/src/utils/source-amounts.test.ts +++ b/packages/transaction-pay-controller/src/utils/source-amounts.test.ts @@ -544,122 +544,6 @@ describe('Source Amounts Utils', () => { ]); }); - it('uses resolveSourceAmount result verbatim when provided', () => { - const resolveSourceAmount = jest - .fn() - .mockReturnValue({ sourceAmountRaw: '15019083' }); - - const transactionData: TransactionData = { - isLoading: false, - isMaxAmount: true, - paymentOverride: PaymentOverride.MoneyAccount, - paymentToken: PAYMENT_TOKEN_MOCK, - tokens: [{ ...TRANSACTION_TOKEN_MOCK, amountUsd: '6.0' }], - }; - - updateSourceAmounts( - TRANSACTION_ID_MOCK, - transactionData, - messenger, - resolveSourceAmount, - ); - - expect(resolveSourceAmount).toHaveBeenCalledWith({ - isMaxAmount: true, - paymentOverride: PaymentOverride.MoneyAccount, - }); - - // Raw used verbatim; human derived by shifting down payment decimals (6). - expect(transactionData.sourceAmounts).toStrictEqual([ - { - sourceAmountHuman: '15.019083', - sourceAmountRaw: '15019083', - targetTokenAddress: TRANSACTION_TOKEN_MOCK.address, - }, - ]); - }); - - it('falls back to fiat-derived amount when resolveSourceAmount returns undefined', () => { - const resolveSourceAmount = 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' }], - }; - - updateSourceAmounts( - TRANSACTION_ID_MOCK, - transactionData, - messenger, - resolveSourceAmount, - ); - - expect(resolveSourceAmount).toHaveBeenCalledTimes(1); - // usdRate mock is 3.0 → source human = 6 / 3 = 2, raw = 2 * 10^6. - expect(transactionData.sourceAmounts).toStrictEqual([ - { - sourceAmountHuman: '2', - sourceAmountRaw: '2000000', - 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' }); - - const transactionData: TransactionData = { - isLoading: false, - isMaxAmount: true, - paymentOverride: PaymentOverride.MoneyAccount, - paymentToken: PAYMENT_TOKEN_MOCK, - tokens: [{ ...TRANSACTION_TOKEN_MOCK, amountRaw: '0' }], - }; - - updateSourceAmounts( - TRANSACTION_ID_MOCK, - transactionData, - messenger, - resolveSourceAmount, - ); - - expect(resolveSourceAmount).not.toHaveBeenCalled(); - expect(transactionData.sourceAmounts).toStrictEqual([]); - }); - - it('does not invoke resolveSourceAmount when the same-token guard skips the token', () => { - const resolveSourceAmount = jest - .fn() - .mockReturnValue({ sourceAmountRaw: '15019083' }); - - const transactionData: TransactionData = { - isLoading: false, - paymentToken: PAYMENT_TOKEN_MOCK, - tokens: [ - { - ...TRANSACTION_TOKEN_MOCK, - address: PAYMENT_TOKEN_MOCK.address, - chainId: PAYMENT_TOKEN_MOCK.chainId, - }, - ], - }; - - updateSourceAmounts( - TRANSACTION_ID_MOCK, - transactionData, - messenger, - resolveSourceAmount, - ); - - expect(resolveSourceAmount).not.toHaveBeenCalled(); - expect(transactionData.sourceAmounts).toStrictEqual([]); - }); - it('does nothing if no payment token', () => { const transactionData: TransactionData = { isLoading: false, From 69836be4e041b3ddc2c96c2e7ca480426d75bffd Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Thu, 20 Aug 2026 14:38:21 +0100 Subject: [PATCH 6/6] refactor: make Max source amount payment-override agnostic Remove the MoneyAccount-specific guard from calculateSourceAmount and calculatePostQuoteSourceAmounts. On Max, 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, fall back to the pay token's on-chain balance. Drop the now-unused paymentOverride parameter and PaymentOverride import. --- .../src/utils/source-amounts.test.ts | 51 ++++++++++--------- .../src/utils/source-amounts.ts | 33 ++++-------- 2 files changed, 36 insertions(+), 48 deletions(-) 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 00e7b68204..6bd9f3f8f4 100644 --- a/packages/transaction-pay-controller/src/utils/source-amounts.test.ts +++ b/packages/transaction-pay-controller/src/utils/source-amounts.test.ts @@ -270,7 +270,6 @@ describe('Source Amounts Utils', () => { it('uses getBalance override for isMaxAmount standard flow', () => { const getBalance = jest.fn().mockReturnValue({ - balanceHuman: '9.9', balanceRaw: '9900000', }); @@ -290,7 +289,7 @@ describe('Source Amounts Utils', () => { expect(transactionData.sourceAmounts).toStrictEqual([ { - sourceAmountHuman: '9.9', + sourceAmountHuman: PAYMENT_TOKEN_MOCK.balanceHuman, sourceAmountRaw: '9900000', targetTokenAddress: TRANSACTION_TOKEN_MOCK.address, }, @@ -325,7 +324,6 @@ describe('Source Amounts Utils', () => { it('ignores getBalance when isMaxAmount is false', () => { const getBalance = jest.fn().mockReturnValue({ - balanceHuman: '9.9', balanceRaw: '9900000', }); @@ -358,7 +356,6 @@ describe('Source Amounts Utils', () => { getTransactionMock.mockReturnValueOnce(undefined); const getBalance = jest.fn().mockReturnValue({ - balanceHuman: '9.9', balanceRaw: '9900000', }); @@ -388,7 +385,6 @@ describe('Source Amounts Utils', () => { it('uses getBalance override for MoneyAccount max when getBalance is provided', () => { const getBalance = jest.fn().mockReturnValue({ - balanceHuman: '9.9', balanceRaw: '9900000', }); @@ -417,17 +413,19 @@ describe('Source Amounts Utils', () => { getBalance, ); - // getBalance is provided, so the override is applied even for MoneyAccount. + // 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: '9.9', + sourceAmountHuman: '0.62', sourceAmountRaw: '9900000', targetTokenAddress: TRANSACTION_TOKEN_MOCK.address, }, ]); }); - it('preserves MoneyAccount max guard when getBalance is not provided', () => { + it('uses the payment token balance on max when getBalance is not provided (payment-override agnostic)', () => { const transactionData: TransactionData = { isLoading: false, isMaxAmount: true, @@ -448,12 +446,13 @@ describe('Source Amounts Utils', () => { updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, messenger); - // No getBalance callback: MoneyAccount guard applies, fiat-derived amounts used. - // usdRate mock is 3.0 -> source human = 6 / 3 = 2, raw = 2 * 10^6. + // 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: '2', - sourceAmountRaw: '2000000', + sourceAmountHuman: '0.62', + sourceAmountRaw: '620000', targetTokenAddress: TRANSACTION_TOKEN_MOCK.address, }, ]); @@ -472,7 +471,6 @@ describe('Source Amounts Utils', () => { }; const getBalance = jest.fn().mockReturnValue({ - balanceHuman: '5.5', balanceRaw: '5500000', }); @@ -498,7 +496,7 @@ describe('Source Amounts Utils', () => { expect(transactionData.sourceAmounts).toStrictEqual([ { - sourceAmountHuman: '5.5', + sourceAmountHuman: TRANSACTION_TOKEN_MOCK.balanceHuman, sourceAmountRaw: '5500000', sourceBalanceRaw: '5500000', sourceChainId: TRANSACTION_TOKEN_MOCK.chainId, @@ -508,17 +506,18 @@ 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('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, - // Bare on-chain mUSD — much smaller than the typed max. balanceHuman: '0.62', balanceRaw: '620000', balanceUsd: '0.62', @@ -526,19 +525,23 @@ describe('Source Amounts Utils', () => { tokens: [ { ...TRANSACTION_TOKEN_MOCK, - // Full withdrawable max typed by the client ($6.00 USD). amountUsd: '6.0', }, ], }; - 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(getBalance).toHaveBeenCalled(); expect(transactionData.sourceAmounts).toStrictEqual([ { - sourceAmountHuman: '2', - sourceAmountRaw: '2000000', + sourceAmountHuman: '0.62', + sourceAmountRaw: '620000', targetTokenAddress: TRANSACTION_TOKEN_MOCK.address, }, ]); diff --git a/packages/transaction-pay-controller/src/utils/source-amounts.ts b/packages/transaction-pay-controller/src/utils/source-amounts.ts index 1c5f9cd8c5..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 { @@ -49,8 +48,7 @@ export function updateSourceAmounts( return; } - const { isMaxAmount, isPostQuote, paymentOverride, paymentToken, tokens } = - transactionData; + const { isMaxAmount, isPostQuote, paymentToken, tokens } = transactionData; if (!tokens.length || !paymentToken) { return; @@ -93,7 +91,6 @@ export function updateSourceAmounts( transactionId, isMaxAmount ?? false, isQuoteRequired, - paymentOverride, balanceOverride, ), ) @@ -152,9 +149,7 @@ function calculatePostQuoteSourceAmounts( return true; }) .map((token) => ({ - sourceAmountHuman: isMaxAmount - ? (balanceOverride?.balanceHuman ?? token.balanceHuman) - : token.amountHuman, + sourceAmountHuman: isMaxAmount ? token.balanceHuman : token.amountHuman, sourceAmountRaw: isMaxAmount ? (balanceOverride?.balanceRaw ?? token.balanceRaw) : token.amountRaw, @@ -174,7 +169,6 @@ 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 balanceOverride - Optional balance override from the `getBalance` callback. * @returns The source amount or undefined if calculation failed. */ @@ -185,7 +179,6 @@ function calculateSourceAmount( transactionId: string, isMaxAmount: boolean, isQuoteRequired?: boolean, - paymentOverride?: PaymentOverride, balanceOverride?: GetBalanceResponse, ): TransactionPaySourceAmount | undefined { const paymentTokenFiatRate = getTokenFiatRate( @@ -238,22 +231,14 @@ 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). - // Exception: when a getBalance callback is provided (balanceOverride is - // defined), the callback is authoritative and bypasses the MoneyAccount - // guard — all balance complexity lives in the callback. - if ( - isMaxAmount && - (balanceOverride !== undefined || - 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: - balanceOverride?.balanceHuman ?? paymentToken.balanceHuman, + sourceAmountHuman: paymentToken.balanceHuman, sourceAmountRaw: balanceOverride?.balanceRaw ?? paymentToken.balanceRaw, targetTokenAddress: token.address, };