diff --git a/app/scripts/lib/transaction/delegation.test.ts b/app/scripts/lib/transaction/delegation.test.ts index 3197d98b6202..ce47bd1c0053 100644 --- a/app/scripts/lib/transaction/delegation.test.ts +++ b/app/scripts/lib/transaction/delegation.test.ts @@ -33,6 +33,7 @@ import { DelegationMessenger, getDelegationTransaction, normalizeCallData, + SUBSIDIZED_ORDER_ID_PLACEHOLDER, } from './delegation'; jest.mock('../../../../shared/lib/delegation', () => ({ @@ -61,6 +62,10 @@ const EXACT_EXECUTION_ENFORCER_MOCK = '0xExactExecutionEnforcer00000000000000000000' as Hex; const EXACT_EXECUTION_BATCH_ENFORCER_MOCK = '0xExactExecutionBatchEnforcer00000000000000' as Hex; +const ALLOWED_TARGETS_ENFORCER_MOCK = + '0xAllowedTargetsEnforcer0000000000000000000' as Hex; +const ALLOWED_CALLDATA_ENFORCER_MOCK = + '0xAllowedCalldataEnforcer000000000000000000' as Hex; const TERMS_LIMITED_MOCK = '0xterms-limited' as Hex; const TERMS_EXACT_MOCK = '0xterms-exact' as Hex; @@ -199,6 +204,8 @@ describe('delegation', () => { LimitedCallsEnforcer: LIMITED_CALLS_ENFORCER_MOCK, ExactExecutionEnforcer: EXACT_EXECUTION_ENFORCER_MOCK, ExactExecutionBatchEnforcer: EXACT_EXECUTION_BATCH_ENFORCER_MOCK, + AllowedTargetsEnforcer: ALLOWED_TARGETS_ENFORCER_MOCK, + AllowedCalldataEnforcer: ALLOWED_CALLDATA_ENFORCER_MOCK, }, } as never); @@ -826,6 +833,291 @@ describe('delegation', () => { }); }); + describe('subsidized Relay execute', () => { + const PLACEHOLDER_BODY = SUBSIDIZED_ORDER_ID_PLACEHOLDER.slice(2); + const SELF_TARGET = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd' as Hex; + const APPROVE_SELECTOR = '095ea7b3'; + const DEPOSIT_SELECTOR = 'f9e4bab4'; + const EXECUTE_SELECTOR = '1a2b3c4d'; + const APPROVE_DATA = `${APPROVE_SELECTOR}${'22'.repeat(28)}`; + const DEPOSIT_DATA = `${DEPOSIT_SELECTOR}${APPROVE_SELECTOR}${'33'.repeat( + 12, + )}${PLACEHOLDER_BODY}${APPROVE_SELECTOR}${'33'.repeat(12)}`; + + const buildBatchData = (occurrences = 1): Hex => { + const fill = (byte: string) => byte.repeat(16); + const header = `${EXECUTE_SELECTOR}${fill('11')}${APPROVE_DATA}${DEPOSIT_DATA}`; + const windows = Array.from( + { length: occurrences }, + (_, index) => `${PLACEHOLDER_BODY}${fill(index === 0 ? '44' : '55')}`, + ).join(''); + return `0x${header}${windows}${fill('cd')}` as Hex; + }; + + const buildSubsidizedTransaction = (data: Hex): TransactionMeta => + ({ + ...TRANSACTION_META_MOCK, + txParams: { + ...TRANSACTION_META_MOCK.txParams, + to: SELF_TARGET, + data, + value: '0x0', + }, + nestedTransactions: [ + { + data: `0x${APPROVE_DATA}` as Hex, + to: '0x1111111111111111111111111111111111111111' as Hex, + value: '0x0' as Hex, + }, + { + data: `0x${DEPOSIT_DATA}` as Hex, + to: '0x2222222222222222222222222222222222222222' as Hex, + value: '0x0' as Hex, + }, + ], + }) as TransactionMeta; + + const parseAllowedCalldata = (terms: string) => ({ + startIndex: parseInt(terms.slice(2, 2 + 64), 16), + value: terms.slice(2 + 64).toLowerCase(), + }); + + const getAllowedCalldataTerms = () => { + const { caveats } = signDelegationMock.mock.calls[0][0].delegation; + return caveats + .map((caveat) => caveat.terms) + .filter((terms) => terms.length > 2 + 64) + .map(parseAllowedCalldata); + }; + + it('redeems the batch as a single execution in single mode', async () => { + const data = buildBatchData(1); + + await convertTransactionToRedeemDelegations({ + transaction: buildSubsidizedTransaction(data), + messenger, + isSubsidized: true, + }); + + expect(createExactExecutionTermsMock).not.toHaveBeenCalled(); + expect(createExactExecutionBatchTermsMock).not.toHaveBeenCalled(); + expect(encodeRedeemDelegationsMock).toHaveBeenCalledWith( + expect.objectContaining({ + modes: [SINGLE_DEFAULT_MODE], + executions: [ + [ + { + target: SELF_TARGET, + value: 0n, + callData: data, + }, + ], + ], + }), + ); + }); + + it('does not append additionalExecutions on the subsidized path', async () => { + const data = buildBatchData(1); + + await convertTransactionToRedeemDelegations({ + transaction: buildSubsidizedTransaction(data), + messenger, + isSubsidized: true, + additionalExecutions: [ADDITIONAL_EXECUTION_MOCK], + }); + + expect(encodeRedeemDelegationsMock).toHaveBeenCalledWith( + expect.objectContaining({ + executions: [ + [ + { + target: SELF_TARGET, + value: 0n, + callData: data, + }, + ], + ], + }), + ); + }); + + it('signs allowedTargets and limitedCalls caveats', async () => { + const data = buildBatchData(1); + + await convertTransactionToRedeemDelegations({ + transaction: buildSubsidizedTransaction(data), + messenger, + isSubsidized: true, + }); + + const { caveats } = signDelegationMock.mock.calls[0][0].delegation; + + expect(caveats[0]).toStrictEqual({ + enforcer: ALLOWED_TARGETS_ENFORCER_MOCK, + terms: SELF_TARGET, + args: '0x', + }); + expect(caveats[1]).toStrictEqual({ + enforcer: LIMITED_CALLS_ENFORCER_MOCK, + terms: TERMS_LIMITED_MOCK, + args: '0x', + }); + expect( + caveats + .slice(2) + .every( + (caveat) => caveat.enforcer === ALLOWED_CALLDATA_ENFORCER_MOCK, + ), + ).toBe(true); + }); + + it('splits only after order-ID-bearing call selectors', async () => { + const data = buildBatchData(1); + const body = data.slice(2).toLowerCase(); + + await convertTransactionToRedeemDelegations({ + transaction: buildSubsidizedTransaction(data), + messenger, + isSubsidized: true, + }); + + const enforced = getAllowedCalldataTerms(); + const approveSplit = body.indexOf(APPROVE_DATA) / 2 + 4; + const depositSplit = body.indexOf(DEPOSIT_DATA) / 2 + 4; + const segmentEnds = enforced.map( + ({ startIndex, value }) => startIndex + value.length / 2, + ); + + expect(segmentEnds).toContain(depositSplit); + expect(segmentEnds).not.toContain(approveSplit); + + const depositStart = body.indexOf(DEPOSIT_DATA) / 2; + const innerApprove1 = depositStart + 4 + 4; + expect(segmentEnds).not.toContain(innerApprove1); + }); + + it('produces far fewer caveats than the per-selector split', async () => { + const data = buildBatchData(2); + + await convertTransactionToRedeemDelegations({ + transaction: buildSubsidizedTransaction(data), + messenger, + isSubsidized: true, + }); + + const { caveats } = signDelegationMock.mock.calls[0][0].delegation; + expect(caveats.length).toBeLessThanOrEqual(7); + }); + + it('leaves the order-ID placeholder window free and enforces the remainder', async () => { + const data = buildBatchData(1); + + await convertTransactionToRedeemDelegations({ + transaction: buildSubsidizedTransaction(data), + messenger, + isSubsidized: true, + }); + + const body = data.slice(2).toLowerCase(); + const enforced = getAllowedCalldataTerms(); + + for (const { value } of enforced) { + expect(value).not.toContain(PLACEHOLDER_BODY); + } + + const rebuilt = Array.from(body); + for (const { startIndex, value } of enforced) { + for (let i = 0; i < value.length; i++) { + rebuilt[startIndex * 2 + i] = value[i]; + } + } + expect(rebuilt.join('')).toBe(body); + + const placeholderStart = body.indexOf(PLACEHOLDER_BODY) / 2; + const covered = enforced.some( + ({ startIndex, value }) => + startIndex <= placeholderStart && + placeholderStart < startIndex + value.length / 2, + ); + expect(covered).toBe(false); + }); + + it('frees every occurrence when the placeholder appears multiple times', async () => { + const data = buildBatchData(2); + + await convertTransactionToRedeemDelegations({ + transaction: buildSubsidizedTransaction(data), + messenger, + isSubsidized: true, + }); + + const enforced = getAllowedCalldataTerms(); + for (const { value } of enforced) { + expect(value).not.toContain(PLACEHOLDER_BODY); + } + + const body = data.slice(2).toLowerCase(); + let searchIndex = body.indexOf(PLACEHOLDER_BODY); + const placeholderStarts: number[] = []; + while (searchIndex !== -1) { + placeholderStarts.push(searchIndex / 2); + searchIndex = body.indexOf(PLACEHOLDER_BODY, searchIndex + 1); + } + expect(placeholderStarts).toHaveLength(3); + + for (const start of placeholderStarts) { + const covered = enforced.some( + ({ startIndex, value }) => + startIndex <= start && start < startIndex + value.length / 2, + ); + expect(covered).toBe(false); + } + }); + + it('throws with the subsidized prefix when batch calldata is missing', async () => { + const transaction = { + ...TRANSACTION_META_MOCK, + txParams: { + ...TRANSACTION_META_MOCK.txParams, + to: SELF_TARGET, + data: undefined, + }, + } as unknown as TransactionMeta; + + await expect( + convertTransactionToRedeemDelegations({ + transaction, + messenger, + isSubsidized: true, + }), + ).rejects.toThrow('Subsidized Caveats: Missing batch target or calldata'); + }); + + it('forwards isSubsidized from getDelegationTransaction', async () => { + isAtomicBatchSupportedMock.mockResolvedValue([ + { + chainId: '0x1', + isSupported: true, + }, + ]); + + const data = buildBatchData(1); + + await getDelegationTransaction( + { messenger, isSubsidized: true }, + buildSubsidizedTransaction(data), + ); + + expect(createExactExecutionTermsMock).not.toHaveBeenCalled(); + expect(encodeRedeemDelegationsMock).toHaveBeenCalledWith( + expect.objectContaining({ + modes: [SINGLE_DEFAULT_MODE], + }), + ); + }); + }); + describe('getDelegationTransaction', () => { it('adds value 0x0 to converted delegation transaction', async () => { isAtomicBatchSupportedMock.mockResolvedValue([ diff --git a/app/scripts/lib/transaction/delegation.ts b/app/scripts/lib/transaction/delegation.ts index d402307790f4..3dcf34522bc4 100644 --- a/app/scripts/lib/transaction/delegation.ts +++ b/app/scripts/lib/transaction/delegation.ts @@ -36,6 +36,28 @@ const log = createProjectLogger('transaction-delegation'); export const PRIMARY_TYPE_DELEGATION = 'Delegation'; +/** + * Must match the placeholder used by the Intents / Relay execute API so + * subsidized quotes can inject the real request id after signing. + */ +export const SUBSIDIZED_ORDER_ID_PLACEHOLDER = + '0x07cece46d0aec658b12c9d194b3ac3cc74aadf102176005c76f96422b57328b2' as Hex; + +/** The number of bytes in a function selector. */ +const SELECTOR_BYTES = 4; + +/** A byte range within calldata: [start, end) in bytes, not hex characters. */ +type ByteRange = { + start: number; + end: number; +}; + +/** A run of calldata bytes to enforce, plus its byte start index. */ +type EnforcedSegment = { + startIndex: number; + value: Hex; +}; + type DelegationMessengerActions = | DelegationControllerSignDelegationAction | KeyringControllerSignEip7702AuthorizationAction @@ -96,6 +118,12 @@ type ConvertTransactionToRedeemDelegationsRequest = { * Omit to skip authorization list building entirely. */ authorization?: AuthorizationRequest; + + /** + * When true, build the Relay-execute subsidized shape: a single execution + * of the 7702 batch and caveats that leave the order-id placeholder free. + */ + isSubsidized?: boolean; }; type ConvertTransactionToRedeemDelegationsResult = { @@ -107,6 +135,7 @@ type ConvertTransactionToRedeemDelegationsResult = { type GetDelegationTransactionRequest = { messenger: DelegationMessenger; + isSubsidized?: boolean; }; type DelegationTransactionResult = { @@ -131,22 +160,37 @@ type DelegationTransactionResult = { export async function convertTransactionToRedeemDelegations( request: ConvertTransactionToRedeemDelegationsRequest, ): Promise { - const { transaction, messenger } = request; + const { transaction, messenger, isSubsidized = false } = request; const { chainId } = transaction; const environment = getDeleGatorEnvironment(parseInt(chainId, 16)); - const defaultExecutions = getDefaultTransactionExecutions(transaction); + // Subsidized caveats are built first so a missing batch target/calldata + // throws with the same prefixed error as mobile. + const subsidizedCaveats = + isSubsidized && !request.caveats + ? buildSubsidizedCaveats(environment, transaction) + : undefined; + + const defaultExecutions = isSubsidized + ? buildSubsidizedExecutions(transaction) + : getDefaultTransactionExecutions(transaction); - const additionalExecutions = request.additionalExecutions ?? []; + const additionalExecutions = isSubsidized + ? [] + : (request.additionalExecutions ?? []); const executions: ExecutionStruct[][] = [ [...defaultExecutions, ...additionalExecutions], ]; const caveats = - request.caveats ?? buildDefaultCaveats(environment, executions[0]); + request.caveats ?? + subsidizedCaveats ?? + buildDefaultCaveats(environment, executions[0]); const modes: ExecutionMode[] = [ - executions[0].length > 1 ? BATCH_DEFAULT_MODE : SINGLE_DEFAULT_MODE, + isSubsidized || executions[0].length <= 1 + ? SINGLE_DEFAULT_MODE + : BATCH_DEFAULT_MODE, ]; const delegations = await signAndWrapDelegation({ @@ -192,6 +236,7 @@ export async function getDelegationTransaction( transaction, messenger: request.messenger, authorization: {}, + isSubsidized: request.isSubsidized, }); return { @@ -293,6 +338,233 @@ function buildDefaultCaveats( return caveats; } +function buildSubsidizedExecutions( + transactionMeta: TransactionMeta, +): ExecutionStruct[] { + const { txParams } = transactionMeta; + const target = txParams.to as Hex | undefined; + const callData = txParams.data as Hex | undefined; + + if (!target || !callData) { + throw new Error('Missing batch target or calldata'); + } + + return [ + { + target, + value: BigInt(txParams.value ?? '0x0'), + callData: normalizeCallData(callData), + }, + ]; +} + +function buildSubsidizedCaveats( + environment: ReturnType, + transaction: TransactionMeta, +): Caveat[] { + try { + return buildSubsidizedCaveatsInternal(environment, transaction); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Subsidized Caveats: ${message}`, { cause: error }); + } +} + +function buildSubsidizedCaveatsInternal( + environment: ReturnType, + transaction: TransactionMeta, +): Caveat[] { + const { txParams } = transaction; + const target = txParams.to as Hex | undefined; + const calldata = txParams.data as Hex | undefined; + + if (!target || !calldata) { + throw new Error('Missing batch target or calldata'); + } + + const caveats: Caveat[] = [ + { + enforcer: environment.caveatEnforcers.AllowedTargetsEnforcer, + terms: concatHex([normalizeCallData(target)]), + args: '0x', + }, + { + enforcer: environment.caveatEnforcers.LimitedCallsEnforcer, + terms: createLimitedCallsTerms({ + limit: 1, + }), + args: '0x', + }, + ]; + + for (const { startIndex, value } of getEnforcedSegments( + normalizeCallData(calldata), + transaction.nestedTransactions ?? [], + )) { + caveats.push({ + enforcer: environment.caveatEnforcers.AllowedCalldataEnforcer, + terms: concatHex([toUint256Hex(startIndex), value]), + args: '0x', + }); + } + + return caveats; +} + +/** + * Enforces every calldata byte except the order-ID placeholder window(s). + * + * @param calldata - The 0x-prefixed batch calldata (txParams.data). + * @param nestedTransactions - The batch's nested calls, used to locate split points. + * @returns The segments to enforce, ordered by byte start index. + */ +function getEnforcedSegments( + calldata: Hex, + nestedTransactions: { data?: string }[], +): EnforcedSegment[] { + const freeRanges = findByteRanges(calldata, [ + SUBSIDIZED_ORDER_ID_PLACEHOLDER, + ]); + + const splitPoints = getSplitPoints(calldata, nestedTransactions); + + return getSegmentsBetweenFreeRanges(calldata, freeRanges, splitPoints); +} + +/** + * Byte offset after the selector of each order-ID-bearing nested call. + * + * @param calldata - The 0x-prefixed batch calldata. + * @param nestedTransactions - The nested calls to locate. + * @returns The post-selector byte offsets, sorted ascending, deduplicated. + */ +function getSplitPoints( + calldata: Hex, + nestedTransactions: { data?: string }[], +): number[] { + const placeholderBody = + SUBSIDIZED_ORDER_ID_PLACEHOLDER.slice(2).toLowerCase(); + + const nestedData = nestedTransactions + .map((tx) => tx.data) + // length >= 10 ensures at least a 0x-prefixed 4-byte selector. + .filter((data): data is string => data !== undefined && data.length >= 10) + .map((data) => data.toLowerCase() as Hex) + // Only order-ID-bearing calls need an isolated boundary. + .filter((data) => data.includes(placeholderBody)); + + const ranges = findByteRanges(calldata, nestedData); + + const points = ranges.map((range) => range.start + SELECTOR_BYTES); + + return [...new Set(points)].sort((a, b) => a - b); +} + +/** + * Every whole-byte-aligned occurrence of each needle in calldata. + * + * @param calldata - The 0x-prefixed calldata to search. + * @param needles - The 0x-prefixed values to locate. + * @returns The byte ranges [start, end) of every occurrence, unsorted. + */ +function findByteRanges(calldata: Hex, needles: Hex[]): ByteRange[] { + const haystack = calldata.slice(2).toLowerCase(); + + return needles.flatMap((needle) => { + const body = needle.slice(2).toLowerCase(); + const byteLength = body.length / 2; + const ranges: ByteRange[] = []; + + let charIndex = haystack.indexOf(body); + while (charIndex !== -1) { + // Only whole-byte boundaries are meaningful (each byte is two hex chars). + if (charIndex % 2 === 0) { + const start = charIndex / 2; + ranges.push({ start, end: start + byteLength }); + } + charIndex = haystack.indexOf(body, charIndex + 1); + } + + return ranges; + }); +} + +/** + * Enforces the bytes outside the free ranges, ending a segment at each split point. + * + * @param calldata - The 0x-prefixed calldata. + * @param freeRanges - Ranges to leave free (order-ID placeholder windows). + * @param splitPoints - Byte offsets at which to end a segment (post-selector). + * @returns The enforced segments ordered by byte start index. + */ +function getSegmentsBetweenFreeRanges( + calldata: Hex, + freeRanges: ByteRange[], + splitPoints: number[], +): EnforcedSegment[] { + const totalBytes = (calldata.length - 2) / 2; + const sliceValue = (start: number, end: number): Hex => + `0x${calldata.slice(2 + start * 2, 2 + end * 2)}` as Hex; + + const sortedFree = [...freeRanges].sort((a, b) => a.start - b.start); + const sortedSplitPoints = [...splitPoints].sort((a, b) => a - b); + + const segments: EnforcedSegment[] = []; + + // Walk the ranges between free windows, ending a segment at each split point. + let cursor = 0; + for (const free of [...sortedFree, { start: totalBytes, end: totalBytes }]) { + addSegments(cursor, free.start, sortedSplitPoints, segments, sliceValue); + cursor = Math.max(cursor, free.end); + } + + return segments; +} + +/** + * Enforces [start, end), ending a segment at each split point inside it; the preceding + * selector folds into that segment. + * + * @param start - The first byte of the range (inclusive). + * @param end - The end of the range (exclusive). + * @param sortedSplitPoints - Split points sorted ascending, spanning the whole calldata. + * @param segments - The accumulator to push enforced segments onto. + * @param sliceValue - Extracts the 0x-prefixed value for a byte range. + */ +function addSegments( + start: number, + end: number, + sortedSplitPoints: number[], + segments: EnforcedSegment[], + sliceValue: (from: number, to: number) => Hex, +): void { + const pushSegment = (from: number, to: number) => { + if (to > from) { + segments.push({ startIndex: from, value: sliceValue(from, to) }); + } + }; + + const pointsInRange = sortedSplitPoints.filter( + (point) => point > start && point < end, + ); + + let cursor = start; + for (const point of pointsInRange) { + pushSegment(cursor, point); + cursor = point; + } + + pushSegment(cursor, end); +} + +function concatHex(values: Hex[]): Hex { + return `0x${values.map((value) => value.slice(2).toLowerCase()).join('')}` as Hex; +} + +function toUint256Hex(value: number): Hex { + return `0x${value.toString(16).padStart(64, '0')}` as Hex; +} + async function signAndWrapDelegation({ transaction, caveats, diff --git a/app/scripts/messenger-client-init/transaction-pay-controller-init.test.ts b/app/scripts/messenger-client-init/transaction-pay-controller-init.test.ts index 2e2efea7a7cd..88a97e708dc7 100644 --- a/app/scripts/messenger-client-init/transaction-pay-controller-init.test.ts +++ b/app/scripts/messenger-client-init/transaction-pay-controller-init.test.ts @@ -3,6 +3,7 @@ import { TransactionPayControllerMessenger, } from '@metamask/transaction-pay-controller'; import { getRootMessenger } from '../lib/messenger'; +import { getDelegationTransaction } from '../lib/transaction/delegation'; import { MessengerClientInitRequest } from './types'; import { buildControllerInitRequestMock } from './test/utils'; import { @@ -13,6 +14,9 @@ import { import { TransactionPayControllerInit } from './transaction-pay-controller-init'; jest.mock('@metamask/transaction-pay-controller'); +jest.mock('../lib/transaction/delegation', () => ({ + getDelegationTransaction: jest.fn(), +})); function getInitRequestMock(): jest.Mocked< MessengerClientInitRequest< @@ -52,6 +56,30 @@ describe('TransactionPayControllerInit', () => { }); }); + it('forwards isSubsidized to getDelegationTransaction', async () => { + TransactionPayControllerInit(getInitRequestMock()); + + const controllerMock = jest.mocked(TransactionPayController); + const lastCall = + controllerMock.mock.calls[controllerMock.mock.calls.length - 1][0]; + const getDelegationTransactionCallback = + lastCall.getDelegationTransaction as (request: { + transaction: { id: string }; + isSubsidized?: boolean; + }) => Promise; + + const transaction = { id: 'tx-1' }; + await getDelegationTransactionCallback({ + transaction, + isSubsidized: true, + }); + + expect(jest.mocked(getDelegationTransaction)).toHaveBeenCalledWith( + expect.objectContaining({ isSubsidized: true }), + transaction, + ); + }); + describe('api.setTransactionPayPostQuote', () => { function initApi() { const { api, messengerClient } = @@ -116,6 +144,62 @@ describe('TransactionPayControllerInit', () => { }); }); + describe('api.setTransactionPayIsMaxAmount', () => { + function initApi() { + const { api, messengerClient } = + TransactionPayControllerInit(getInitRequestMock()); + if (!api) { + throw new Error('Expected init result to expose an api'); + } + const setTransactionConfigMock = jest.mocked( + messengerClient.setTransactionConfig, + ); + return { api, setTransactionConfigMock }; + } + + it('sets isMaxAmount without touching atomic by default', () => { + const { api, setTransactionConfigMock } = initApi(); + + api.setTransactionPayIsMaxAmount('tx-1', true); + + const updater = setTransactionConfigMock.mock.calls[0][1]; + const config: { isMaxAmount?: boolean; atomic?: boolean } = {}; + updater(config as never); + + expect(config).toEqual({ isMaxAmount: true }); + }); + + it('sets atomic false for a max money-account deposit', () => { + const { api, setTransactionConfigMock } = initApi(); + + api.setTransactionPayIsMaxAmount('tx-1', true, { + isMoneyAccountDeposit: true, + }); + + const updater = setTransactionConfigMock.mock.calls[0][1]; + const config: { isMaxAmount?: boolean; atomic?: boolean } = {}; + updater(config as never); + + expect(config).toEqual({ isMaxAmount: true, atomic: false }); + }); + + it('clears atomic when max is unset on a money-account deposit', () => { + const { api, setTransactionConfigMock } = initApi(); + + api.setTransactionPayIsMaxAmount('tx-1', false, { + isMoneyAccountDeposit: true, + }); + + const updater = setTransactionConfigMock.mock.calls[0][1]; + const config: { isMaxAmount?: boolean; atomic?: boolean } = { + atomic: false, + }; + updater(config as never); + + expect(config).toEqual({ isMaxAmount: false, atomic: undefined }); + }); + }); + describe('api.setTransactionPayAccountOverride', () => { function initApi() { const { api, messengerClient } = diff --git a/app/scripts/messenger-client-init/transaction-pay-controller-init.ts b/app/scripts/messenger-client-init/transaction-pay-controller-init.ts index 148eb101e6fc..a127c288282a 100644 --- a/app/scripts/messenger-client-init/transaction-pay-controller-init.ts +++ b/app/scripts/messenger-client-init/transaction-pay-controller-init.ts @@ -33,10 +33,15 @@ export const TransactionPayControllerInit: MessengerClientInitFunction< const getDelegationTransactionCallback: (request: { transaction: TransactionMeta; - }) => ReturnType = ({ transaction }) => + isSubsidized?: boolean; + }) => ReturnType = ({ + transaction, + isSubsidized, + }) => getDelegationTransaction( { messenger: initMessenger as DelegationMessenger, + isSubsidized, }, transaction, ); @@ -106,9 +111,16 @@ function getApi( setTransactionPayIsMaxAmount: ( transactionId: string, isMaxAmount: boolean, + options: { isMoneyAccountDeposit?: boolean } = {}, ) => { messengerClient.setTransactionConfig(transactionId, (config) => { config.isMaxAmount = isMaxAmount; + // Max money-account deposits run the vault deposit after Relay + // settles (EXACT_INPUT). Regular deposits stay atomic so the vault + // call is embedded in the Relay bundle (EXPECTED_OUTPUT). + if (options.isMoneyAccountDeposit) { + config.atomic = isMaxAmount ? false : undefined; + } }); }, setTransactionPayPostQuote: ( diff --git a/test/jest/console-baseline-unit.json b/test/jest/console-baseline-unit.json index b2691373747a..fadb1d5b0dba 100644 --- a/test/jest/console-baseline-unit.json +++ b/test/jest/console-baseline-unit.json @@ -760,7 +760,7 @@ "Reselect: Identity function warnings": 1 }, "ui/pages/confirmations/hooks/alerts/transactions/useInsufficientBalanceAlerts.test.ts": { - "MetaMask: Background connection not initialized": 68 + "MetaMask: Background connection not initialized": 72 }, "ui/pages/confirmations/hooks/alerts/useNetworkAndOriginSwitchingAlerts.test.ts": { "MetaMask: Background connection not initialized": 6 diff --git a/ui/pages/confirmations/components/rows/pay-with-row/pay-with-row.test.tsx b/ui/pages/confirmations/components/rows/pay-with-row/pay-with-row.test.tsx index 595e2ecef58b..a7579b1ba62d 100644 --- a/ui/pages/confirmations/components/rows/pay-with-row/pay-with-row.test.tsx +++ b/ui/pages/confirmations/components/rows/pay-with-row/pay-with-row.test.tsx @@ -7,6 +7,7 @@ import { PaymentOverride } from '@metamask/transaction-pay-controller'; import { renderWithProvider } from '../../../../../../test/lib/render-helpers-navigate'; import { useTransactionPayToken } from '../../../hooks/pay/useTransactionPayToken'; import { useTransactionPayRequiredTokens } from '../../../hooks/pay/useTransactionPayData'; +import { useTransactionPayAvailableTokens } from '../../../hooks/pay/useTransactionPayAvailableTokens'; import { useSendTokens } from '../../../hooks/send/useSendTokens'; import { useConfirmContext } from '../../../context/confirm'; import useAlerts from '../../../../../hooks/useAlerts'; @@ -18,6 +19,7 @@ import { PayWithRow, PayWithRowSkeleton } from './pay-with-row'; jest.mock('../../../hooks/pay/useTransactionPayToken'); jest.mock('../../../hooks/pay/useTransactionPayData'); +jest.mock('../../../hooks/pay/useTransactionPayAvailableTokens'); jest.mock('../../../selectors/feature-flags', () => ({ ...jest.requireActual('../../../selectors/feature-flags'), selectIsMoneyAccountTransactionEnabled: jest.fn(() => false), @@ -144,6 +146,9 @@ describe('PayWithRow', () => { const useTransactionPayRequiredTokensMock = jest.mocked( useTransactionPayRequiredTokens, ); + const useTransactionPayAvailableTokensMock = jest.mocked( + useTransactionPayAvailableTokens, + ); const useSendTokensMock = jest.mocked(useSendTokens); const useConfirmContextMock = jest.mocked(useConfirmContext); const useAlertsMock = jest.mocked(useAlerts); @@ -156,6 +161,7 @@ describe('PayWithRow', () => { jest.resetAllMocks(); useSendTokensMock.mockReturnValue([]); + useTransactionPayAvailableTokensMock.mockReturnValue([]); useTransactionPayRequiredTokensMock.mockReturnValue([]); getFieldAlertsMock.mockReturnValue([]); useAlertsMock.mockReturnValue({ @@ -225,7 +231,7 @@ describe('PayWithRow', () => { expect(screen.queryByTestId('pay-with-modal')).not.toBeInTheDocument(); }); - it('renders skeleton when no display token available', () => { + it('renders empty selection when no display token and no available tokens', () => { useTransactionPayTokenMock.mockReturnValue({ payToken: undefined, setPayToken: jest.fn(), @@ -236,6 +242,33 @@ describe('PayWithRow', () => { const store = mockStore(getMockState()); renderWithProvider(, store); + expect( + screen.queryByTestId('pay-with-row-skeleton'), + ).not.toBeInTheDocument(); + expect(screen.getByTestId('pay-with-row')).toBeInTheDocument(); + expect(screen.getByTestId('pay-with-symbol')).toHaveTextContent( + 'Select payment method', + ); + }); + + it('renders skeleton while available tokens exist and pay token is not set', () => { + useTransactionPayTokenMock.mockReturnValue({ + payToken: undefined, + setPayToken: jest.fn(), + isNative: false, + }); + useTransactionPayRequiredTokensMock.mockReturnValue([]); + useTransactionPayAvailableTokensMock.mockReturnValue([ + { + address: ADDRESS_MOCK, + chainId: CHAIN_ID_MOCK, + disabled: false, + }, + ] as never); + + const store = mockStore(getMockState()); + renderWithProvider(, store); + expect(screen.getByTestId('pay-with-row-skeleton')).toBeInTheDocument(); }); @@ -301,21 +334,12 @@ describe('PayWithRow', () => { const store = mockStore(getMockState()); renderWithProvider(, store); - if (transactionType === TransactionType.perpsWithdraw) { - // Post-quote withdraws show an empty Receive selector instead of an - // endless skeleton while the destination token is imported. - expect(screen.getByTestId('pay-with-row')).toBeInTheDocument(); - expect(screen.getByTestId('pay-with-symbol')).toHaveTextContent( - 'Select payment method', - ); - } else { - expect( - screen.getByTestId('pay-with-row-skeleton'), - ).toBeInTheDocument(); - expect( - screen.queryByTestId('pay-with-symbol'), - ).not.toBeInTheDocument(); - } + // No funding tokens yet: show the empty selector instead of an + // endless skeleton (same as mobile). + expect(screen.getByTestId('pay-with-row')).toBeInTheDocument(); + expect(screen.getByTestId('pay-with-symbol')).toHaveTextContent( + 'Select payment method', + ); }); it('renders the resolved payToken once it is set', () => { diff --git a/ui/pages/confirmations/components/rows/pay-with-row/pay-with-row.tsx b/ui/pages/confirmations/components/rows/pay-with-row/pay-with-row.tsx index ae8ae9136d8e..e30a33bf5667 100644 --- a/ui/pages/confirmations/components/rows/pay-with-row/pay-with-row.tsx +++ b/ui/pages/confirmations/components/rows/pay-with-row/pay-with-row.tsx @@ -143,6 +143,7 @@ export function PayWithRow({ ownerId, isPostQuoteWithdraw, isMoneyAccountSelected, + hasAvailableTokens, openModal, modal, } = usePayWithToken(); @@ -153,11 +154,15 @@ export function PayWithRow({ (alert) => alert.key === AlertsName.AccountNoFunds, ); - // When the selected account has no funding tokens, show an empty - // "Select payment method" placeholder instead of an endless skeleton. - // Post-quote withdraws also avoid an endless skeleton — destination tokens - // may still be importing/enriching; show an empty Receive selector instead. - if (!displayToken && !hasAccountNoFunds && !isPostQuoteWithdraw) { + // Same as mobile: skeleton only while funding tokens exist to auto-select + // from. Without tokens the skeleton never resolves — show the empty + // "Select payment method" placeholder instead. + if ( + !displayToken && + !hasAccountNoFunds && + !isPostQuoteWithdraw && + hasAvailableTokens + ) { return ; } diff --git a/ui/pages/confirmations/hooks/alerts/transactions/useAccountNoFundsAlert.test.tsx b/ui/pages/confirmations/hooks/alerts/transactions/useAccountNoFundsAlert.test.tsx index a42b761227f0..6bf5343ccb62 100644 --- a/ui/pages/confirmations/hooks/alerts/transactions/useAccountNoFundsAlert.test.tsx +++ b/ui/pages/confirmations/hooks/alerts/transactions/useAccountNoFundsAlert.test.tsx @@ -11,11 +11,13 @@ import { ConfirmContext } from '../../../context/confirm'; import { ACCOUNT_RESELECT_EMPTY_TIMEOUT_MS } from '../../pay/useAutomaticTransactionPayToken'; import { useTransactionPayAvailableTokens } from '../../pay/useTransactionPayAvailableTokens'; import { useIsTransactionPayLoading } from '../../pay/useTransactionPayData'; +import { useTransactionPayToken } from '../../pay/useTransactionPayToken'; import { useTransactionAccountOverride } from '../../transactions/useTransactionAccountOverride'; import { AlertsName } from '../constants'; import { useAccountNoFundsAlert } from './useAccountNoFundsAlert'; jest.mock('../../pay/useTransactionPayAvailableTokens'); +jest.mock('../../pay/useTransactionPayToken'); jest.mock('../../pay/useTransactionPayData', () => ({ ...jest.requireActual('../../pay/useTransactionPayData'), useIsTransactionPayLoading: jest.fn(), @@ -55,6 +57,7 @@ describe('useAccountNoFundsAlert', () => { const useTransactionAccountOverrideMock = jest.mocked( useTransactionAccountOverride, ); + const useTransactionPayTokenMock = jest.mocked(useTransactionPayToken); beforeEach(() => { jest.resetAllMocks(); @@ -65,6 +68,10 @@ describe('useAccountNoFundsAlert', () => { ] as ReturnType); useIsTransactionPayLoadingMock.mockReturnValue(false); useTransactionAccountOverrideMock.mockReturnValue(undefined); + useTransactionPayTokenMock.mockReturnValue({ + payToken: undefined, + setPayToken: jest.fn(), + }); }); it('returns alert for moneyAccountDeposit with no available tokens', () => { @@ -101,6 +108,21 @@ describe('useAccountNoFundsAlert', () => { expect(result.current[0].key).toBe(AlertsName.AccountNoFunds); }); + it('returns no alert when a pay token with a USD balance is selected', () => { + useTransactionPayAvailableTokensMock.mockReturnValue([]); + useTransactionPayTokenMock.mockReturnValue({ + payToken: { balanceUsd: '0.38' } as never, + setPayToken: jest.fn(), + }); + + const { result } = renderHookWithConfirmation({ + type: TransactionType.moneyAccountDeposit, + txParams: { from: '0xabc' }, + } as TransactionMeta); + + expect(result.current).toStrictEqual([]); + }); + it('returns no alert for moneyAccountDeposit with available tokens', () => { const { result } = renderHookWithConfirmation({ type: TransactionType.moneyAccountDeposit, diff --git a/ui/pages/confirmations/hooks/alerts/transactions/useAccountNoFundsAlert.ts b/ui/pages/confirmations/hooks/alerts/transactions/useAccountNoFundsAlert.ts index 76eaabdea022..11bbc1c64ffc 100644 --- a/ui/pages/confirmations/hooks/alerts/transactions/useAccountNoFundsAlert.ts +++ b/ui/pages/confirmations/hooks/alerts/transactions/useAccountNoFundsAlert.ts @@ -1,6 +1,7 @@ 'use no memo'; import { useEffect, useMemo, useState } from 'react'; +import { BigNumber } from 'bignumber.js'; import { TransactionMeta, TransactionType, @@ -14,6 +15,7 @@ import { useConfirmContext } from '../../../context/confirm'; import { useTransactionPayAvailableTokens } from '../../pay/useTransactionPayAvailableTokens'; import { ACCOUNT_RESELECT_EMPTY_TIMEOUT_MS } from '../../pay/useAutomaticTransactionPayToken'; import { useIsTransactionPayLoading } from '../../pay/useTransactionPayData'; +import { useTransactionPayToken } from '../../pay/useTransactionPayToken'; import { useTransactionAccountOverride } from '../../transactions/useTransactionAccountOverride'; import { AlertsName } from '../constants'; @@ -25,6 +27,7 @@ export function useAccountNoFundsAlert(): Alert[] { const t = useI18nContext(); const { currentConfirmation } = useConfirmContext(); const availableTokens = useTransactionPayAvailableTokens(); + const { payToken } = useTransactionPayToken(); const isLoading = useIsTransactionPayLoading(); const accountOverride = useTransactionAccountOverride(); const from = currentConfirmation?.txParams?.from; @@ -38,7 +41,9 @@ export function useAccountNoFundsAlert(): Alert[] { TransactionType.moneyAccountDeposit, ]); - const hasTokens = availableTokens.some((token) => !token.disabled); + const hasTokens = + availableTokens.some((token) => !token.disabled) || + new BigNumber(payToken?.balanceUsd ?? '0').gt(0); // Keep the wait flag in sync during render so an account override cannot // flash this alert for one frame before effects run. diff --git a/ui/pages/confirmations/hooks/alerts/transactions/useInsufficientBalanceAlerts.test.ts b/ui/pages/confirmations/hooks/alerts/transactions/useInsufficientBalanceAlerts.test.ts index c1617292cec3..2a05abba4ec9 100644 --- a/ui/pages/confirmations/hooks/alerts/transactions/useInsufficientBalanceAlerts.test.ts +++ b/ui/pages/confirmations/hooks/alerts/transactions/useInsufficientBalanceAlerts.test.ts @@ -384,6 +384,21 @@ describe('useInsufficientBalanceAlerts', () => { expect(alerts).toEqual(ALERT); }); + it('returns no alerts for money account deposits even when native balance is insufficient', () => { + const moneyAccountDeposit = { + ...TRANSACTION_MOCK, + nestedTransactions: [{ type: TransactionType.moneyAccountDeposit }], + } as TransactionMeta; + + const alerts = runHook({ + balance: 7, + currentConfirmation: moneyAccountDeposit, + transaction: moneyAccountDeposit, + }); + + expect(alerts).toEqual([]); + }); + describe('post-quote withdraws', () => { const WITHDRAW_TRANSACTION_MOCK = { ...TRANSACTION_MOCK, diff --git a/ui/pages/confirmations/hooks/alerts/transactions/useInsufficientBalanceAlerts.ts b/ui/pages/confirmations/hooks/alerts/transactions/useInsufficientBalanceAlerts.ts index b4f8a7114de4..0de186fba566 100644 --- a/ui/pages/confirmations/hooks/alerts/transactions/useInsufficientBalanceAlerts.ts +++ b/ui/pages/confirmations/hooks/alerts/transactions/useInsufficientBalanceAlerts.ts @@ -1,4 +1,7 @@ -import { TransactionMeta } from '@metamask/transaction-controller'; +import { + TransactionMeta, + TransactionType, +} from '@metamask/transaction-controller'; import { useMemo } from 'react'; import { useSelector } from 'react-redux'; import { @@ -9,6 +12,7 @@ import { Alert } from '../../../../../ducks/confirm-alerts/confirm-alerts'; import { Severity } from '../../../../../helpers/constants/design-system'; import { useI18nContext } from '../../../../../hooks/useI18nContext'; import { getUseTransactionSimulations } from '../../../../../selectors'; +import { hasTransactionType } from '../../../../../../shared/lib/transactions.utils'; import { useConfirmContext } from '../../../context/confirm'; import { useIsGaslessSupported } from '../../gas/useIsGaslessSupported'; import { useHasInsufficientBalance } from '../../useHasInsufficientBalance'; @@ -49,6 +53,12 @@ export function useInsufficientBalanceAlerts({ const isPayPendingInput = Boolean(payToken) && primaryRequiredToken?.amountRaw === '0'; + // Deposit batches execute from the money account, which has no native MON. + // Gas is sponsored, so the EOA native-balance check is wrong. + const isMoneyAccountDeposit = hasTransactionType(currentConfirmation, [ + TransactionType.moneyAccountDeposit, + ]); + const isGasFeeTokensEmpty = gasFeeTokens?.length === 0; // Check if gasless check has completed (regardless of result) @@ -89,7 +99,8 @@ export function useInsufficientBalanceAlerts({ hasNoGasFeeTokenSelected && shouldCheckGaslessConditions && !isSponsoredTransaction && - !isPostQuoteWithdraw; + !isPostQuoteWithdraw && + !isMoneyAccountDeposit; return useMemo(() => { if (!showAlert) { diff --git a/ui/pages/confirmations/hooks/alerts/transactions/useInsufficientPayTokenBalanceAlert.test.ts b/ui/pages/confirmations/hooks/alerts/transactions/useInsufficientPayTokenBalanceAlert.test.ts index c47bb4a43c0f..2a8cdcb749da 100644 --- a/ui/pages/confirmations/hooks/alerts/transactions/useInsufficientPayTokenBalanceAlert.test.ts +++ b/ui/pages/confirmations/hooks/alerts/transactions/useInsufficientPayTokenBalanceAlert.test.ts @@ -2,13 +2,14 @@ import { Hex } from '@metamask/utils'; import { TransactionMeta, TransactionType, - CHAIN_IDS, } from '@metamask/transaction-controller'; import { TransactionPayRequiredToken, TransactionPayTotals, TransactionPaymentToken, + PaymentOverride, } from '@metamask/transaction-pay-controller'; +import { CHAIN_IDS } from '../../../../../../shared/constants/network'; import { getMockConfirmStateForTransaction } from '../../../../../../test/data/confirmations/helper'; import { genUnapprovedContractInteractionConfirmation } from '../../../../../../test/data/confirmations/contract-interaction'; import { renderHookWithConfirmContextProvider } from '../../../../../../test/lib/confirmations/render-helpers'; @@ -19,6 +20,7 @@ import { useTransactionPayRequiredTokens, useTransactionPayTotals, } from '../../pay/useTransactionPayData'; +import { useSendTokens } from '../../send/useSendTokens'; import { useTokenWithBalance } from '../../tokens/useTokenWithBalance'; import { AlertsName } from '../constants'; import { RowAlertKey } from '../../../../../components/app/confirm/info/row/constants'; @@ -27,6 +29,7 @@ import { useInsufficientPayTokenBalanceAlert } from './useInsufficientPayTokenBa jest.mock('../../pay/useTransactionPayToken'); jest.mock('../../pay/useTransactionPayData'); +jest.mock('../../send/useSendTokens'); jest.mock('../../tokens/useTokenWithBalance'); const PAY_TOKEN_MOCK = { @@ -62,12 +65,25 @@ const NATIVE_TOKEN_MOCK = { function runHook( props: Parameters[0] = {}, + { + paymentOverride, + }: { + paymentOverride?: PaymentOverride; + } = {}, ) { const contractInteraction = genUnapprovedContractInteractionConfirmation({ chainId: CHAIN_IDS.MAINNET, }) as TransactionMeta; - const state = getMockConfirmStateForTransaction(contractInteraction); + const state = getMockConfirmStateForTransaction(contractInteraction, { + metamask: paymentOverride + ? { + transactionData: { + [contractInteraction.id]: { paymentOverride }, + }, + } + : {}, + }); return renderHookWithConfirmContextProvider( () => useInsufficientPayTokenBalanceAlert(props), @@ -120,10 +136,13 @@ describe('useInsufficientPayTokenBalanceAlert', () => { const useIsTransactionPayLoadingMock = jest.mocked( useIsTransactionPayLoading, ); + const useSendTokensMock = jest.mocked(useSendTokens); beforeEach(() => { jest.resetAllMocks(); + // Empty list so the alert falls back to the pay-token snapshot under test. + useSendTokensMock.mockReturnValue([]); useTransactionPayRequiredTokensMock.mockReturnValue([REQUIRED_TOKEN_MOCK]); useTransactionPayTotalsMock.mockReturnValue(TOTALS_MOCK); useTransactionPayIsMaxAmountMock.mockReturnValue(false); @@ -171,8 +190,8 @@ describe('useInsufficientPayTokenBalanceAlert', () => { key: AlertsName.InsufficientPayTokenBalance, field: RowAlertKey.EstimatedFee, isBlocking: true, - reason: 'Insufficient funds', - message: 'Insufficient funds', + reason: expect.stringContaining('Insufficient funds'), + message: expect.stringContaining('Insufficient funds'), severity: Severity.Danger, }, ]); @@ -214,6 +233,21 @@ describe('useInsufficientPayTokenBalanceAlert', () => { expect(result.current).toStrictEqual([]); }); + + it('returns no alert for a non-max amount when live USD is below the Pay-with snapshot', () => { + useSendTokensMock.mockReturnValue([ + { + address: PAY_TOKEN_MOCK.address, + chainId: PAY_TOKEN_MOCK.chainId, + decimals: 18, + rawBalance: '0x8ac7230489e80000' as Hex, + }, + ]); + + const { result } = runHook(); + + expect(result.current).toStrictEqual([]); + }); }); describe('for fees', () => { @@ -234,13 +268,31 @@ describe('useInsufficientPayTokenBalanceAlert', () => { key: AlertsName.InsufficientPayTokenFees, field: RowAlertKey.EstimatedFee, isBlocking: true, - reason: 'Insufficient funds', + reason: expect.stringContaining('Insufficient funds'), message: 'Add less or use a different token.', severity: Severity.Danger, }, ]); }); + it('returns no alert for fees when paying with Money Account', () => { + useTransactionPayTokenMock.mockReturnValue({ + payToken: { + ...PAY_TOKEN_MOCK, + balanceRaw: '4000000000000000000', + }, + isNative: false, + setPayToken: jest.fn(), + }); + + const { result } = runHook( + {}, + { paymentOverride: PaymentOverride.MoneyAccount }, + ); + + expect(result.current).toStrictEqual([]); + }); + it('returns no alert if pending amount is provided', () => { useTransactionPayTokenMock.mockReturnValue({ payToken: { @@ -277,7 +329,7 @@ describe('useInsufficientPayTokenBalanceAlert', () => { key: AlertsName.InsufficientPayTokenNative, field: RowAlertKey.EstimatedFee, isBlocking: true, - reason: 'Insufficient funds', + reason: expect.stringContaining('Insufficient funds'), message: expect.stringContaining('Not enough'), severity: Severity.Danger, }, @@ -352,6 +404,90 @@ describe('useInsufficientPayTokenBalanceAlert', () => { expect(result.current).toStrictEqual([]); }); + + it('returns no alert when source chain is Monad even if native balance is insufficient', () => { + useTransactionPayTokenMock.mockReturnValue({ + payToken: { + ...PAY_TOKEN_MOCK, + chainId: CHAIN_IDS.MONAD as Hex, + }, + isNative: false, + setPayToken: jest.fn(), + }); + + useTokenWithBalanceMock.mockReturnValue({ + address: NATIVE_TOKEN_MOCK.address, + chainId: CHAIN_IDS.MONAD, + symbol: 'MON', + decimals: 18, + balance: '0', + balanceRaw: '0', + balanceFiat: '$0.00', + tokenFiatAmount: 0, + }); + + const { result } = runHook(); + + expect(result.current).toStrictEqual([]); + }); + + it('returns no alert when paying with Money Account even if native balance is insufficient', () => { + useTokenWithBalanceMock.mockReturnValue({ + address: NATIVE_TOKEN_MOCK.address, + chainId: NATIVE_TOKEN_MOCK.chainId, + symbol: 'ETH', + decimals: 18, + balance: '0.0001', + balanceRaw: '100000000000000', + balanceFiat: '$0.00', + tokenFiatAmount: 0, + }); + + const { result } = runHook( + {}, + { paymentOverride: PaymentOverride.MoneyAccount }, + ); + + expect(result.current).toStrictEqual([]); + }); + }); + + it('does not treat a zero raw snapshot as insufficient when USD still covers the source amount', () => { + useTransactionPayTokenMock.mockReturnValue({ + payToken: { + ...PAY_TOKEN_MOCK, + balanceRaw: '0', + }, + isNative: false, + setPayToken: jest.fn(), + }); + + const { result } = runHook(); + + expect(result.current).toStrictEqual([]); + }); + + it('uses the live funding-account balance when the pay-token snapshot raw balance is empty', () => { + useTransactionPayTokenMock.mockReturnValue({ + payToken: { + ...PAY_TOKEN_MOCK, + balanceRaw: '0', + }, + isNative: false, + setPayToken: jest.fn(), + }); + useSendTokensMock.mockReturnValue([ + { + address: PAY_TOKEN_MOCK.address, + chainId: PAY_TOKEN_MOCK.chainId, + decimals: 18, + rawBalance: '0x8ac7230489e80000' as Hex, // 10e18 + }, + ]); + + const { result } = runHook(); + + expect(result.current).toStrictEqual([]); }); it('returns no alert if no pay token is selected', () => { @@ -383,6 +519,29 @@ describe('useInsufficientPayTokenBalanceAlert', () => { expect(result.current).toStrictEqual([]); }); + it('returns alert when Money Account total exceeds the money-account balance', () => { + useTransactionPayTotalsMock.mockReturnValue({ + ...TOTALS_MOCK, + total: { fiat: '11.00', usd: '11.00' }, + }); + + const { result } = runHook( + {}, + { paymentOverride: PaymentOverride.MoneyAccount }, + ); + + expect(result.current).toStrictEqual([ + { + key: AlertsName.InsufficientPayTokenBalance, + field: RowAlertKey.EstimatedFee, + isBlocking: true, + reason: expect.stringContaining('Insufficient funds'), + message: 'Add less or use a different token.', + severity: Severity.Danger, + }, + ]); + }); + // For Perps Withdraw (and other post-quote flows), `payToken` is the // *destination* token — its balance has nothing to do with whether the // user can fund the withdraw. Only the source-network gas check on the @@ -464,7 +623,7 @@ describe('useInsufficientPayTokenBalanceAlert', () => { key: AlertsName.InsufficientPayTokenNative, field: RowAlertKey.EstimatedFee, isBlocking: true, - reason: 'Insufficient funds', + reason: expect.stringContaining('Insufficient funds'), message: expect.stringContaining('Not enough'), severity: Severity.Danger, }, diff --git a/ui/pages/confirmations/hooks/alerts/transactions/useInsufficientPayTokenBalanceAlert.ts b/ui/pages/confirmations/hooks/alerts/transactions/useInsufficientPayTokenBalanceAlert.ts index f071aabf1af1..ae875e2d93f0 100644 --- a/ui/pages/confirmations/hooks/alerts/transactions/useInsufficientPayTokenBalanceAlert.ts +++ b/ui/pages/confirmations/hooks/alerts/transactions/useInsufficientPayTokenBalanceAlert.ts @@ -3,13 +3,20 @@ import { useSelector } from 'react-redux'; import { BigNumber } from 'bignumber.js'; import { getNativeTokenAddress } from '@metamask/assets-controllers'; import type { TransactionMeta } from '@metamask/transaction-controller'; +import { PaymentOverride } from '@metamask/transaction-pay-controller'; import type { Hex } from '@metamask/utils'; import { Alert } from '../../../../../ducks/confirm-alerts/confirm-alerts'; import { Severity } from '../../../../../helpers/constants/design-system'; import { RowAlertKey } from '../../../../../components/app/confirm/info/row/constants'; import { useI18nContext } from '../../../../../hooks/useI18nContext'; +import { + selectPaymentOverrideByTransactionId, + type TransactionPayState, +} from '../../../../../selectors/transactionPayController'; +import { CHAIN_IDS } from '../../../../../../shared/constants/network'; import { useConfirmContext } from '../../../context/confirm'; import { useTransactionPayToken } from '../../pay/useTransactionPayToken'; +import { usePayTokenAccountBalance } from '../../pay/usePayTokenAccountBalance'; import { useTransactionPayWithdraw } from '../../pay/useTransactionPayWithdraw'; import { useTokenWithBalance } from '../../tokens/useTokenWithBalance'; import { @@ -36,6 +43,12 @@ export function useInsufficientPayTokenBalanceAlert({ const isSourceGasFeeToken = totals?.fees.isSourceGasFeeToken ?? false; const isPendingAlert = Boolean(pendingAmountUsd !== undefined); const isMax = useTransactionPayIsMaxAmount(); + const transactionId = currentConfirmation?.id ?? ''; + const paymentOverride = useSelector((state: TransactionPayState) => + selectPaymentOverrideByTransactionId(state, transactionId), + ); + const isMoneyPaymentOverride = + paymentOverride === PaymentOverride.MoneyAccount; // Post-quote withdraws: `payToken` is the destination, not the source — // skip input/fees checks; gas check runs against the tx chain. Gate on the @@ -79,7 +92,16 @@ export function useInsufficientPayTokenBalanceAlert({ payToken.chainId === sourceChainId, ); - const { balanceUsd, balanceRaw } = payToken ?? {}; + // Live funding-account raw balance. USD still prefers the Pay-with + // snapshot when the live rate would understate it (see + // `usePayTokenAccountBalance`). + const { balanceUsd: liveBalanceUsd, balanceRaw } = + usePayTokenAccountBalance(); + const balanceUsd = useMemo(() => { + const snapshot = new BigNumber(payToken?.balanceUsd ?? '0'); + const live = new BigNumber(liveBalanceUsd ?? '0'); + return BigNumber.max(snapshot, live).toString(10); + }, [liveBalanceUsd, payToken?.balanceUsd]); const nativeBalanceRaw = nativeToken?.balanceRaw ?? '0'; const totalAmountUsd = useMemo(() => { @@ -124,31 +146,83 @@ export function useInsufficientPayTokenBalanceAlert({ [balanceUsd, isPostQuote, payToken, totalAmountUsd], ); - const isInsufficientForFees = useMemo( + const isInsufficientForFees = useMemo(() => { + if (isMoneyPaymentOverride || isPostQuote || isPendingAlert || !payToken) { + return false; + } + + // A 0 raw snapshot is common on money-account deposits (tx `from` is the + // money account). Do not treat that as "user has no tokens" — fall back + // to USD when we have no positive raw balance to compare. + const hasRawBalance = new BigNumber(balanceRaw ?? '0').gt(0); + if (hasRawBalance) { + return totalSourceAmountRaw.gt(balanceRaw ?? '0'); + } + + const sourceUsd = new BigNumber(totals?.sourceAmount?.usd ?? '0').plus( + isPayTokenNative || isSourceGasFeeToken + ? new BigNumber(totals?.fees?.sourceNetwork?.max?.usd ?? '0') + : '0', + ); + + return sourceUsd.gt(0) && sourceUsd.gt(balanceUsd ?? '0'); + }, [ + balanceRaw, + balanceUsd, + isMoneyPaymentOverride, + isPayTokenNative, + isPendingAlert, + isPostQuote, + isSourceGasFeeToken, + payToken, + totalSourceAmountRaw, + totals, + ]); + + // Pay-with Money Account: deposit amount and fees come from the same + // money-account balance. The input-only check can pass while amount+fees + // still exceed it. Skip for Max: atomic is cleared so the deposit amount + // is reduced to leave room for fees. + const isInsufficientForMoneyAccountTotal = useMemo( () => + isMoneyPaymentOverride && + !isMax && !isPostQuote && !isPendingAlert && - payToken && - totalSourceAmountRaw.gt(balanceRaw ?? '0'), - [balanceRaw, isPendingAlert, isPostQuote, payToken, totalSourceAmountRaw], + totals?.total?.usd !== undefined && + new BigNumber(totals.total.usd).gt(balanceUsd ?? '0'), + [ + balanceUsd, + isMax, + isMoneyPaymentOverride, + isPendingAlert, + isPostQuote, + totals, + ], ); // Post-quote can run before `payToken` is set (auto-selection skipped); // gas check is independent of `payToken`. + // Monad source gas is sponsored for money-account deposits, so a missing + // MON balance must not block paying with Monad mUSD. const isInsufficientForSourceNetwork = useMemo( () => + sourceChainId !== CHAIN_IDS.MONAD && + !isMoneyPaymentOverride && (payToken || isPostQuote) && !isPayTokenNative && !isPendingAlert && !isSourceGasFeeToken && totalSourceNetworkFeeRaw.gt(nativeBalanceRaw), [ + isMoneyPaymentOverride, isPayTokenNative, isPendingAlert, isPostQuote, isSourceGasFeeToken, nativeBalanceRaw, payToken, + sourceChainId, totalSourceNetworkFeeRaw, ], ); @@ -171,6 +245,17 @@ export function useInsufficientPayTokenBalanceAlert({ ]; } + if (isInsufficientForMoneyAccountTotal) { + return [ + { + ...baseAlert, + key: AlertsName.InsufficientPayTokenBalance, + reason: t('alertInsufficientPayTokenBalance'), + message: t('alertInsufficientPayTokenBalanceFeesNoTarget'), + }, + ]; + } + if (isInsufficientForFees) { return [ { @@ -196,6 +281,7 @@ export function useInsufficientPayTokenBalanceAlert({ return []; }, [ isInsufficientForInput, + isInsufficientForMoneyAccountTotal, isInsufficientForFees, isInsufficientForSourceNetwork, ticker, diff --git a/ui/pages/confirmations/hooks/pay/useAutomaticTransactionPayToken.test.tsx b/ui/pages/confirmations/hooks/pay/useAutomaticTransactionPayToken.test.tsx index a38840cb5654..19b5e3d02c70 100644 --- a/ui/pages/confirmations/hooks/pay/useAutomaticTransactionPayToken.test.tsx +++ b/ui/pages/confirmations/hooks/pay/useAutomaticTransactionPayToken.test.tsx @@ -614,44 +614,75 @@ describe('useAutomaticTransactionPayToken', () => { }); }); - it('selects the first token that meets the minimum required fiat balance', () => { - selectMinimumRequiredTokenBalanceMock.mockReturnValue(5); + it('falls back to the first available token when no preferred flag token meets the minimum', () => { + selectMinimumRequiredTokenBalanceMock.mockReturnValue(100); useTransactionPayAvailableTokensMock.mockReturnValue([ { - address: TOKEN_ADDRESS_2_MOCK, - chainId: CHAIN_ID_2_MOCK, - fiat: { balance: 1 }, + address: TOKEN_ADDRESS_1_MOCK, + chainId: CHAIN_ID_1_MOCK, + fiat: { balance: 5 }, }, { - address: TOKEN_ADDRESS_3_MOCK, + address: TOKEN_ADDRESS_2_MOCK, chainId: CHAIN_ID_2_MOCK, - fiat: { balance: 10 }, + fiat: { balance: 50 }, }, ] as Asset[]); - renderHookWithProvider(); + renderHookWithProvider({ + remoteFeatureFlags: { + confirmations_pay_tokens: { + preferredTokens: { + overrides: { + perpsDeposit: [ + { + address: TOKEN_ADDRESS_1_MOCK, + chainId: CHAIN_ID_1_MOCK, + }, + ], + }, + }, + }, + }, + }); expect(setPayTokenMock).toHaveBeenCalledWith({ - address: TOKEN_ADDRESS_3_MOCK, - chainId: CHAIN_ID_2_MOCK, + address: TOKEN_ADDRESS_1_MOCK, + chainId: CHAIN_ID_1_MOCK, }); }); - it('falls back to the required token when no funding token meets the minimum', () => { + it('selects a no-fee token that meets the minimum over the first available token', () => { selectMinimumRequiredTokenBalanceMock.mockReturnValue(5); useTransactionPayAvailableTokensMock.mockReturnValue([ + { + address: TOKEN_ADDRESS_1_MOCK, + chainId: CHAIN_ID_1_MOCK, + fiat: { balance: 20 }, + }, { address: TOKEN_ADDRESS_2_MOCK, chainId: CHAIN_ID_2_MOCK, - fiat: { balance: 1 }, + fiat: { balance: 10 }, }, ] as Asset[]); - renderHookWithProvider(); + renderHookWithProvider({ + remoteFeatureFlags: { + confirmations_relay_fixed_spread: { + chains: { src: CHAIN_ID_2_MOCK, dst: CHAIN_ID_1_MOCK }, + tokens: { + fee: TOKEN_ADDRESS_2_MOCK, + other: TOKEN_ADDRESS_1_MOCK, + }, + routes: [['src', 'fee', 'dst', 'other']], + }, + }, + }); expect(setPayTokenMock).toHaveBeenCalledWith({ - address: TOKEN_ADDRESS_1_MOCK, - chainId: CHAIN_ID_1_MOCK, + address: TOKEN_ADDRESS_2_MOCK, + chainId: CHAIN_ID_2_MOCK, }); }); diff --git a/ui/pages/confirmations/hooks/pay/useAutomaticTransactionPayToken.ts b/ui/pages/confirmations/hooks/pay/useAutomaticTransactionPayToken.ts index 8d0460479d44..f9449c3e3e33 100644 --- a/ui/pages/confirmations/hooks/pay/useAutomaticTransactionPayToken.ts +++ b/ui/pages/confirmations/hooks/pay/useAutomaticTransactionPayToken.ts @@ -13,8 +13,13 @@ import { useConfirmContext } from '../../context/confirm'; import { selectMinimumRequiredTokenBalance, selectPreferredPayTokens, + selectRelayFixedSpread, type PreferredPayToken, } from '../../selectors/feature-flags'; +import { + isSubsidizedSource, + type RelayFixedSpreadConfig, +} from '../../utils/relay-fixed-spread'; import { useTransactionAccountOverride } from '../transactions/useTransactionAccountOverride'; import { useImportPayToken } from './useImportPayToken'; import { useTransactionPayToken } from './useTransactionPayToken'; @@ -42,6 +47,7 @@ export function useAutomaticTransactionPayToken({ const minimumRequiredTokenBalance = useSelector( selectMinimumRequiredTokenBalance, ); + const relayFixedSpread = useSelector(selectRelayFixedSpread); const { currentConfirmation } = useConfirmContext(); const transactionId = currentConfirmation?.id; @@ -127,6 +133,7 @@ export function useAutomaticTransactionPayToken({ minimumRequiredTokenBalance, preferredToken, preferredTokensFromFlags, + relayFixedSpread, targetToken, tokens: tokensWithBalance, }), @@ -138,6 +145,7 @@ export function useAutomaticTransactionPayToken({ minimumRequiredTokenBalance, preferredToken, preferredTokensFromFlags, + relayFixedSpread, targetToken, tokensWithBalance, ], @@ -293,6 +301,7 @@ function getBestToken({ minimumRequiredTokenBalance, preferredToken, preferredTokensFromFlags, + relayFixedSpread, targetToken, tokens, }: { @@ -306,6 +315,7 @@ function getBestToken({ minimumRequiredTokenBalance: number; preferredToken?: SetPayTokenRequest; preferredTokensFromFlags: PreferredPayToken[]; + relayFixedSpread: RelayFixedSpreadConfig; targetToken?: { address: Hex; chainId: Hex }; tokens: Asset[]; }): { address: Hex; chainId: Hex } | undefined { @@ -362,23 +372,33 @@ function getBestToken({ return undefined; } - if (tokens?.length) { - const eligibleTokens = tokens.filter( - (token) => (token.fiat?.balance ?? 0) >= minimumRequiredTokenBalance, - ); - - if (eligibleTokens.length) { + // Same as mobile: prefer a no-fee (subsidized) source that meets the + // fiat minimum before falling through to the first funding token. + if (tokens?.length && !isPostQuoteWithdraw) { + const noFeeCandidates = tokens + .filter((token) => { + if (!token.chainId || !token.address) { + return false; + } + if ((token.fiat?.balance ?? 0) < minimumRequiredTokenBalance) { + return false; + } + return isSubsidizedSource(relayFixedSpread, { + chainId: String(token.chainId), + address: token.address, + }); + }) + .sort((a, b) => (b.fiat?.balance ?? 0) - (a.fiat?.balance ?? 0)); + + if (noFeeCandidates.length) { return { - address: eligibleTokens[0].address as Hex, - chainId: eligibleTokens[0].chainId as Hex, + address: noFeeCandidates[0].address as Hex, + chainId: noFeeCandidates[0].chainId as Hex, }; } + } - // Tokens exist but none meet the fiat minimum — use destination fallback. - if (minimumRequiredTokenBalance > 0) { - return targetTokenFallback; - } - + if (tokens?.length) { return { address: tokens[0].address as Hex, chainId: tokens[0].chainId as Hex, diff --git a/ui/pages/confirmations/hooks/pay/useIsPaidByMetaMask.test.ts b/ui/pages/confirmations/hooks/pay/useIsPaidByMetaMask.test.ts index 514fae498056..08355edfb9eb 100644 --- a/ui/pages/confirmations/hooks/pay/useIsPaidByMetaMask.test.ts +++ b/ui/pages/confirmations/hooks/pay/useIsPaidByMetaMask.test.ts @@ -7,6 +7,7 @@ import type { TransactionPayTotals } from '@metamask/transaction-pay-controller' import { useTransactionMetadataRequestOptional } from '../transactions/useTransactionMetadataRequest'; import { useTransactionPayHasPositiveRequiredAmount, + useTransactionPaySourceAmounts, useTransactionPayTotals, } from './useTransactionPayData'; import { useIsPaidByMetaMask } from './useIsPaidByMetaMask'; @@ -21,10 +22,17 @@ const useTransactionPayTotalsMock = jest.mocked(useTransactionPayTotals); const useTransactionPayHasPositiveRequiredAmountMock = jest.mocked( useTransactionPayHasPositiveRequiredAmount, ); +const useTransactionPaySourceAmountsMock = jest.mocked( + useTransactionPaySourceAmounts, +); -function mockConfirmation(type: TransactionType) { +function mockConfirmation( + type: TransactionType, + extras: Partial = {}, +) { useTransactionMetadataRequestOptionalMock.mockReturnValue({ type, + ...extras, } as TransactionMeta); } @@ -46,6 +54,7 @@ describe('useIsPaidByMetaMask', () => { mockConfirmation(TransactionType.musdConversion); mockTotals(); useTransactionPayHasPositiveRequiredAmountMock.mockReturnValue(true); + useTransactionPaySourceAmountsMock.mockReturnValue(undefined); }); it('returns true when all fees are zero for musdConversion', () => { @@ -89,13 +98,46 @@ describe('useIsPaidByMetaMask', () => { expect(result.current).toBe(false); }); - it('returns false for non-musdConversion transaction types', () => { + it('returns false for unsupported transaction types', () => { mockConfirmation(TransactionType.simpleSend); const { result } = renderHook(() => useIsPaidByMetaMask()); expect(result.current).toBe(false); }); + it('returns true when all fees are zero for moneyAccountDeposit', () => { + mockConfirmation(TransactionType.moneyAccountDeposit); + + const { result } = renderHook(() => useIsPaidByMetaMask()); + expect(result.current).toBe(true); + }); + + it('returns true when gas is sponsored and there are no source amounts yet', () => { + mockConfirmation(TransactionType.moneyAccountDeposit, { + isGasFeeSponsored: true, + }); + useTransactionPayTotalsMock.mockReturnValue(undefined); + useTransactionPaySourceAmountsMock.mockReturnValue([]); + + const { result } = renderHook(() => useIsPaidByMetaMask()); + expect(result.current).toBe(true); + }); + + it('does not short-circuit when sponsored but source amounts exist', () => { + mockConfirmation(TransactionType.moneyAccountDeposit, { + isGasFeeSponsored: true, + }); + useTransactionPaySourceAmountsMock.mockReturnValue([ + {}, + ] as unknown as ReturnType); + mockTotals({ + provider: { usd: '1.00' }, + } as TransactionPayTotals['fees']); + + const { result } = renderHook(() => useIsPaidByMetaMask()); + expect(result.current).toBe(false); + }); + it('returns false when totals are undefined', () => { useTransactionPayTotalsMock.mockReturnValue(undefined); diff --git a/ui/pages/confirmations/hooks/pay/useIsPaidByMetaMask.ts b/ui/pages/confirmations/hooks/pay/useIsPaidByMetaMask.ts index f79f7b0d2f28..2449020e7c0d 100644 --- a/ui/pages/confirmations/hooks/pay/useIsPaidByMetaMask.ts +++ b/ui/pages/confirmations/hooks/pay/useIsPaidByMetaMask.ts @@ -4,33 +4,44 @@ import { hasTransactionType } from '../../../../../shared/lib/transactions.utils import { useTransactionMetadataRequestOptional } from '../transactions/useTransactionMetadataRequest'; import { useTransactionPayHasPositiveRequiredAmount, + useTransactionPaySourceAmounts, useTransactionPayTotals, } from './useTransactionPayData'; -const SUPPORTED_TYPES: TransactionType[] = [TransactionType.musdConversion]; +const SUPPORTED_TYPES: TransactionType[] = [ + TransactionType.musdConversion, + TransactionType.moneyAccountDeposit, +]; /** * Determines whether the current transaction is fully sponsored by MetaMask * (zero gas, zero provider fee, zero MetaMask fee). * - * Scoped to musdConversion for now; broadening to other types is a product - * decision left for follow-up. + * Money-account deposits on Monad are gas-sponsored, and fixed-spread / same- + * token (Monad mUSD) routes have $0 provider fee, so they show as paid by + * MetaMask the same way mUSD conversion does. */ export function useIsPaidByMetaMask(): boolean { const transactionMeta = useTransactionMetadataRequestOptional(); const totals = useTransactionPayTotals(); + const sourceAmounts = useTransactionPaySourceAmounts(); const hasPositiveRequiredAmount = useTransactionPayHasPositiveRequiredAmount(); + if (!hasTransactionType(transactionMeta, SUPPORTED_TYPES)) { + return false; + } + + // Pre-quote gasless deposits: no conversion yet, gas is sponsored. + if (transactionMeta?.isGasFeeSponsored && !sourceAmounts?.length) { + return true; + } + // Every fee is zero before an amount is entered, which is indistinguishable // from genuine sponsorship. Requiring a positive amount stops the empty state // from claiming the transaction is "Paid by MetaMask" and then contradicting // itself with real fees once the user types. - if ( - !hasTransactionType(transactionMeta, SUPPORTED_TYPES) || - !totals?.fees || - !hasPositiveRequiredAmount - ) { + if (!totals?.fees || !hasPositiveRequiredAmount) { return false; } diff --git a/ui/pages/confirmations/hooks/pay/usePayTokenAccountBalance.test.ts b/ui/pages/confirmations/hooks/pay/usePayTokenAccountBalance.test.ts new file mode 100644 index 000000000000..0cf3b40898a7 --- /dev/null +++ b/ui/pages/confirmations/hooks/pay/usePayTokenAccountBalance.test.ts @@ -0,0 +1,241 @@ +import { Hex } from '@metamask/utils'; +import type { TransactionPaymentToken } from '@metamask/transaction-pay-controller'; +import { renderHook } from '@testing-library/react'; +import { useSendTokens } from '../send/useSendTokens'; +import { useTokenFiatRate } from '../tokens/useTokenFiatRates'; +import type { Asset } from '../../types/send'; +import { usePayTokenAccountBalance } from './usePayTokenAccountBalance'; +import { useTransactionPayToken } from './useTransactionPayToken'; + +jest.mock('./useTransactionPayToken'); +jest.mock('../send/useSendTokens'); +jest.mock('../tokens/useTokenFiatRates'); + +const PAY_TOKEN_MOCK = { + address: '0xabc' as Hex, + chainId: '0x1' as Hex, + balanceUsd: '5.00', + balanceRaw: '5000000000000000000', + decimals: 18, + symbol: 'ETH', +} as TransactionPaymentToken; + +const ACCOUNT_TOKEN_MOCK = { + address: '0xabc', + chainId: '0x1', + decimals: 18, + rawBalance: '0x1bc16d674ec80000' as Hex, // 2e18 + balance: '2', + symbol: 'ETH', + fiat: { balance: 3400 }, +} as Asset; + +describe('usePayTokenAccountBalance', () => { + const useTransactionPayTokenMock = jest.mocked(useTransactionPayToken); + const useSendTokensMock = jest.mocked(useSendTokens); + const useTokenFiatRateMock = jest.mocked(useTokenFiatRate); + + beforeEach(() => { + jest.resetAllMocks(); + + useTransactionPayTokenMock.mockReturnValue({ + payToken: PAY_TOKEN_MOCK, + setPayToken: jest.fn(), + }); + useSendTokensMock.mockReturnValue([ACCOUNT_TOKEN_MOCK]); + useTokenFiatRateMock.mockReturnValue(1700); + }); + + it('returns zero balances when no pay token is selected', () => { + useTransactionPayTokenMock.mockReturnValue({ + payToken: undefined, + setPayToken: jest.fn(), + }); + + const { result } = renderHook(() => usePayTokenAccountBalance()); + + expect(result.current).toStrictEqual({ + balanceUsd: '0', + balanceRaw: '0', + }); + }); + + it('computes balance from matching account token and USD rate', () => { + const { result } = renderHook(() => usePayTokenAccountBalance()); + + expect(result.current.balanceRaw).toBe('2000000000000000000'); + expect(result.current.balanceUsd).toBe('3400'); + }); + + it('falls back to controller snapshot when no matching account token', () => { + useSendTokensMock.mockReturnValue([]); + + const { result } = renderHook(() => usePayTokenAccountBalance()); + + expect(result.current).toStrictEqual({ + balanceUsd: PAY_TOKEN_MOCK.balanceUsd, + balanceRaw: PAY_TOKEN_MOCK.balanceRaw, + }); + }); + + it('falls back to controller snapshot when matching token has no rawBalance', () => { + useSendTokensMock.mockReturnValue([ + { ...ACCOUNT_TOKEN_MOCK, rawBalance: undefined }, + ]); + + const { result } = renderHook(() => usePayTokenAccountBalance()); + + expect(result.current).toStrictEqual({ + balanceUsd: PAY_TOKEN_MOCK.balanceUsd, + balanceRaw: PAY_TOKEN_MOCK.balanceRaw, + }); + }); + + it('matches a native account token to the native pay-token address', () => { + const nativeAddress = '0x0000000000000000000000000000000000000000' as Hex; + useTransactionPayTokenMock.mockReturnValue({ + payToken: { ...PAY_TOKEN_MOCK, address: nativeAddress }, + setPayToken: jest.fn(), + }); + useSendTokensMock.mockReturnValue([ + { + ...ACCOUNT_TOKEN_MOCK, + address: undefined, + isNative: true, + }, + ]); + + const { result } = renderHook(() => usePayTokenAccountBalance()); + + expect(result.current.balanceRaw).toBe('2000000000000000000'); + }); + + it('matches token by address case-insensitively', () => { + useTransactionPayTokenMock.mockReturnValue({ + payToken: { ...PAY_TOKEN_MOCK, address: '0xABC' as Hex }, + setPayToken: jest.fn(), + }); + + const { result } = renderHook(() => usePayTokenAccountBalance()); + + expect(result.current.balanceRaw).toBe('2000000000000000000'); + }); + + it('matches tokens whose chainId is CAIP-2', () => { + useSendTokensMock.mockReturnValue([ + { ...ACCOUNT_TOKEN_MOCK, chainId: 'eip155:1' }, + ]); + + const { result } = renderHook(() => usePayTokenAccountBalance()); + + expect(result.current.balanceRaw).toBe('2000000000000000000'); + }); + + it('does not match token with same address but different chainId', () => { + useSendTokensMock.mockReturnValue([ + { ...ACCOUNT_TOKEN_MOCK, chainId: '0x89' }, + ]); + + const { result } = renderHook(() => usePayTokenAccountBalance()); + + expect(result.current).toStrictEqual({ + balanceUsd: PAY_TOKEN_MOCK.balanceUsd, + balanceRaw: PAY_TOKEN_MOCK.balanceRaw, + }); + }); + + it('does not throw when the USD rate has more than 15 significant digits', () => { + useTokenFiatRateMock.mockReturnValue(1.0001734321076745); + + const { result } = renderHook(() => usePayTokenAccountBalance()); + + expect(result.current.balanceRaw).toBe('2000000000000000000'); + expect(result.current.balanceUsd).toBe('5'); + }); + + it('does not understate USD when the live rate is below the snapshot', () => { + useTokenFiatRateMock.mockReturnValue(1); + + const { result } = renderHook(() => usePayTokenAccountBalance()); + + expect(result.current.balanceRaw).toBe('2000000000000000000'); + expect(result.current.balanceUsd).toBe('5'); + }); + + it('falls back to controller balanceUsd when USD rate is unavailable', () => { + useTokenFiatRateMock.mockReturnValue(undefined); + + const { result } = renderHook(() => usePayTokenAccountBalance()); + + expect(result.current.balanceRaw).toBe('2000000000000000000'); + expect(result.current.balanceUsd).toBe('5'); + }); + + it('uses account token decimals over payToken decimals', () => { + useSendTokensMock.mockReturnValue([ + { + ...ACCOUNT_TOKEN_MOCK, + decimals: 6, + rawBalance: '0xf4240' as Hex, + }, + ]); + + const { result } = renderHook(() => usePayTokenAccountBalance()); + + expect(result.current.balanceUsd).toBe('1700'); + expect(result.current.balanceRaw).toBe('1000000'); + }); + + it('uses payToken decimals when account token has no decimals', () => { + useTransactionPayTokenMock.mockReturnValue({ + payToken: { ...PAY_TOKEN_MOCK, decimals: 8 }, + setPayToken: jest.fn(), + }); + useSendTokensMock.mockReturnValue([ + { + ...ACCOUNT_TOKEN_MOCK, + decimals: undefined, + rawBalance: '0x5f5e100' as Hex, + }, + ]); + + const { result } = renderHook(() => usePayTokenAccountBalance()); + + expect(result.current.balanceUsd).toBe('1700'); + }); + + it('handles zero rawBalance', () => { + useSendTokensMock.mockReturnValue([ + { + ...ACCOUNT_TOKEN_MOCK, + rawBalance: '0x0' as Hex, + }, + ]); + + const { result } = renderHook(() => usePayTokenAccountBalance()); + + expect(result.current).toStrictEqual({ + balanceUsd: '0', + balanceRaw: '0', + }); + }); + + it('falls back to controller defaults when payToken fields are undefined', () => { + useTransactionPayTokenMock.mockReturnValue({ + payToken: { + ...PAY_TOKEN_MOCK, + balanceUsd: undefined, + balanceRaw: undefined, + } as unknown as TransactionPaymentToken, + setPayToken: jest.fn(), + }); + useSendTokensMock.mockReturnValue([]); + + const { result } = renderHook(() => usePayTokenAccountBalance()); + + expect(result.current).toStrictEqual({ + balanceUsd: '0', + balanceRaw: '0', + }); + }); +}); diff --git a/ui/pages/confirmations/hooks/pay/usePayTokenAccountBalance.ts b/ui/pages/confirmations/hooks/pay/usePayTokenAccountBalance.ts new file mode 100644 index 000000000000..7dd342972383 --- /dev/null +++ b/ui/pages/confirmations/hooks/pay/usePayTokenAccountBalance.ts @@ -0,0 +1,122 @@ +'use no memo'; + +import { useMemo } from 'react'; +import { BigNumber } from 'bignumber.js'; +import { getNativeTokenAddress } from '@metamask/assets-controllers'; +import { + isCaipChainId, + isStrictHexString, + parseCaipChainId, + type Hex, +} from '@metamask/utils'; +import { useSendTokens } from '../send/useSendTokens'; +import { useTokenFiatRate } from '../tokens/useTokenFiatRates'; +import type { Asset } from '../../types/send'; +import { useTransactionPayToken } from './useTransactionPayToken'; + +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as Hex; + +function hexToDecimalString(hex: string): string { + return new BigNumber(hex.replace(/^0x/u, '') || '0', 16).toString(10); +} + +function toHexChainId( + chainId: string | number | undefined, +): string | undefined { + if (chainId === undefined || chainId === null) { + return undefined; + } + + const asString = String(chainId); + if (isStrictHexString(asString)) { + return asString.toLowerCase(); + } + + if (isCaipChainId(asString)) { + const { namespace, reference } = parseCaipChainId(asString); + if (namespace === 'eip155') { + return `0x${Number(reference).toString(16)}`; + } + } + + if (/^\d+$/u.test(asString)) { + return `0x${Number(asString).toString(16)}`; + } + + return asString.toLowerCase(); +} + +function tokenAddressForMatch(token: Asset, chainId: Hex): string | undefined { + if (token.isNative) { + try { + return getNativeTokenAddress(chainId); + } catch { + return token.address; + } + } + + return token.address; +} + +/** + * Live pay-token balance from the funding account (accountOverride), not the + * pay-controller snapshot. `paymentToken.balanceRaw` is taken once and can be + * 0 / stale on money-account deposits — the confirmation `from` is the money + * account, while funds come from the selected account. Comparing a real quote + * against that snapshot marks every amount as insufficient. + * + * Mirrors mobile `usePayTokenAccountBalance`. + */ +export function usePayTokenAccountBalance(): { + balanceUsd: string; + balanceRaw: string; +} { + const { payToken } = useTransactionPayToken(); + const accountTokens = useSendTokens({ includeNoBalance: true }); + const usdRate = useTokenFiatRate( + (payToken?.address ?? ZERO_ADDRESS) as Hex, + (payToken?.chainId ?? ZERO_ADDRESS) as Hex, + 'usd', + ); + + return useMemo(() => { + if (!payToken) { + return { balanceUsd: '0', balanceRaw: '0' }; + } + + const payTokenChainId = toHexChainId(payToken.chainId); + const matchingToken = accountTokens.find((token) => { + if (toHexChainId(token.chainId) !== payTokenChainId) { + return false; + } + + const tokenAddress = tokenAddressForMatch(token, payToken.chainId); + return tokenAddress?.toLowerCase() === payToken.address.toLowerCase(); + }); + + if (!matchingToken?.rawBalance) { + return { + balanceUsd: payToken.balanceUsd ?? '0', + balanceRaw: payToken.balanceRaw ?? '0', + }; + } + + const balanceRaw = hexToDecimalString(matchingToken.rawBalance); + if (new BigNumber(balanceRaw).isZero()) { + return { balanceUsd: '0', balanceRaw: '0' }; + } + + const decimals = matchingToken.decimals ?? payToken.decimals ?? 18; + const humanBalance = new BigNumber(balanceRaw).dividedBy(10 ** decimals); + const snapshotUsd = new BigNumber(payToken.balanceUsd ?? '0'); + const computedUsd = usdRate + ? humanBalance.times(String(usdRate)) + : new BigNumber(0); + // `useTokenFiatRate` can return a non-USD unit (e.g. 1 × native rate). + // Never report less USD than the Pay-with snapshot — that made every + // non-Max deposit look insufficient while Max skipped the USD check. + const balanceUsd = BigNumber.max(snapshotUsd, computedUsd).toString(10); + + return { balanceUsd, balanceRaw }; + }, [accountTokens, payToken, usdRate]); +} diff --git a/ui/pages/confirmations/hooks/pay/usePayWithNoFeeToken.test.tsx b/ui/pages/confirmations/hooks/pay/usePayWithNoFeeToken.test.tsx index a9ee91f75aa0..2a356f763b3a 100644 --- a/ui/pages/confirmations/hooks/pay/usePayWithNoFeeToken.test.tsx +++ b/ui/pages/confirmations/hooks/pay/usePayWithNoFeeToken.test.tsx @@ -3,6 +3,8 @@ import { renderHook } from '@testing-library/react'; import { Provider } from 'react-redux'; import configureMockStore from 'redux-mock-store'; import { type Asset } from '../../types/send'; +import { CHAIN_IDS } from '../../../../../shared/constants/network'; +import { MUSD_TOKEN_ADDRESS } from '../../constants/musd'; import { usePayWithNoFeeToken } from './usePayWithNoFeeToken'; const ETH_USDC = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; @@ -73,4 +75,25 @@ describe('usePayWithNoFeeToken', () => { expect(tagged).not.toBeNull(); expect(untagged).toBeNull(); }); + + it('tags Monad mUSD itself even when the flag omits the same-token route', () => { + const { result } = renderUsePayWithNoFeeToken({}); + + expect( + result.current.isNoFeeToken(MUSD_TOKEN_ADDRESS, CHAIN_IDS.MONAD), + ).toBe(true); + expect(result.current.isNoFeeToken(ETH_USDC, CHAIN_IDS.MONAD)).toBe(false); + }); + + it('renders a No fee tag for Monad mUSD', () => { + const { result } = renderUsePayWithNoFeeToken({}); + + const tagged = result.current.renderNoFeeTag({ + address: MUSD_TOKEN_ADDRESS, + chainId: CHAIN_IDS.MONAD, + symbol: 'mUSD', + } as Asset); + + expect(tagged).not.toBeNull(); + }); }); diff --git a/ui/pages/confirmations/hooks/pay/usePayWithNoFeeToken.tsx b/ui/pages/confirmations/hooks/pay/usePayWithNoFeeToken.tsx index 5613ec9f2d62..ef33a9bd9715 100644 --- a/ui/pages/confirmations/hooks/pay/usePayWithNoFeeToken.tsx +++ b/ui/pages/confirmations/hooks/pay/usePayWithNoFeeToken.tsx @@ -5,11 +5,24 @@ import { selectRelayFixedSpread } from '../../selectors/feature-flags'; import { NoFeeTag } from '../../components/UI/no-fee-tag'; import { type TokenTagRenderer } from '../../components/UI/asset'; import { type Asset } from '../../types/send'; +import { CHAIN_IDS } from '../../../../../shared/constants/network'; +import { MUSD_TOKEN_ADDRESS } from '../../constants/musd'; + +/** + * Monad mUSD → Monad mUSD needs no swap or bridge, so the fixed-spread flag + * omits that same-token route. Depositing it still incurs no Relay fee. + * @param address + * @param chainId + */ +const isMonadMusd = (address: string, chainId: string) => + chainId.toLowerCase() === CHAIN_IDS.MONAD.toLowerCase() && + address.toLowerCase() === MUSD_TOKEN_ADDRESS.toLowerCase(); /** * Identifies payment tokens that incur no Relay fixed-spread fee for * Money Account deposits. A token is no-fee when it is a subsidised source - * in the `confirmations_relay_fixed_spread` remote feature flag. + * in the `confirmations_relay_fixed_spread` remote feature flag, or when it + * is Monad mUSD itself. */ export function usePayWithNoFeeToken(): { isNoFeeToken: (address: string, chainId: string) => boolean; @@ -23,10 +36,13 @@ export function usePayWithNoFeeToken(): { return false; } - return isSubsidizedSource(relayFixedSpread, { - address, - chainId: String(chainId), - }); + return ( + isMonadMusd(address, chainId) || + isSubsidizedSource(relayFixedSpread, { + address, + chainId: String(chainId), + }) + ); }, [relayFixedSpread], ); diff --git a/ui/pages/confirmations/hooks/pay/usePayWithToken.test.tsx b/ui/pages/confirmations/hooks/pay/usePayWithToken.test.tsx index 0cf7355b7642..194f87490c59 100644 --- a/ui/pages/confirmations/hooks/pay/usePayWithToken.test.tsx +++ b/ui/pages/confirmations/hooks/pay/usePayWithToken.test.tsx @@ -10,9 +10,11 @@ import { } from '@metamask/transaction-controller'; import { useSelector } from 'react-redux'; import { useConfirmContext } from '../../context/confirm'; +import { getInternalAccountByAddress } from '../../../../selectors/accounts'; import { selectPaymentOverrideByTransactionId } from '../../../../selectors/transactionPayController'; import { useTransactionPayToken } from './useTransactionPayToken'; import { useTransactionPayRequiredTokens } from './useTransactionPayData'; +import { useTransactionPayAvailableTokens } from './useTransactionPayAvailableTokens'; import { MONEY_ACCOUNT_DUMMY_BALANCE_FIAT } from './sections/usePayWithMoneyAccountSection'; import { usePayWithToken } from './usePayWithToken'; @@ -29,6 +31,12 @@ jest.mock('./useTransactionPayToken', () => ({ jest.mock('./useTransactionPayData', () => ({ useTransactionPayRequiredTokens: jest.fn(), })); +jest.mock('./useTransactionPayAvailableTokens', () => ({ + useTransactionPayAvailableTokens: jest.fn(), +})); +jest.mock('../../../../selectors/accounts', () => ({ + getInternalAccountByAddress: jest.fn(), +})); jest.mock('../../../../selectors/transactionPayController', () => ({ selectPaymentOverrideByTransactionId: jest.fn(), })); @@ -75,6 +83,12 @@ describe('usePayWithToken', () => { const useTransactionPayRequiredTokensMock = jest.mocked( useTransactionPayRequiredTokens, ); + const useTransactionPayAvailableTokensMock = jest.mocked( + useTransactionPayAvailableTokens, + ); + const getInternalAccountByAddressMock = jest.mocked( + getInternalAccountByAddress, + ); const selectPaymentOverrideByTransactionIdMock = jest.mocked( selectPaymentOverrideByTransactionId, ); @@ -96,6 +110,8 @@ describe('usePayWithToken', () => { isNative: false, }); useTransactionPayRequiredTokensMock.mockReturnValue([]); + useTransactionPayAvailableTokensMock.mockReturnValue([]); + getInternalAccountByAddressMock.mockReturnValue(ACCOUNT as never); selectPaymentOverrideByTransactionIdMock.mockReturnValue(undefined); useSelectorMock.mockImplementation( @@ -180,4 +196,24 @@ describe('usePayWithToken', () => { expect(result.current.displayToken).toBeUndefined(); }); + + it('reports hasAvailableTokens when a selectable funding token exists', () => { + useTransactionPayAvailableTokensMock.mockReturnValue([ + { + address: PAY_TOKEN.address, + chainId: PAY_TOKEN.chainId, + disabled: false, + }, + ] as never); + + const { result } = renderHook(() => usePayWithToken()); + + expect(result.current.hasAvailableTokens).toBe(true); + }); + + it('reports no available tokens when the list is empty', () => { + const { result } = renderHook(() => usePayWithToken()); + + expect(result.current.hasAvailableTokens).toBe(false); + }); }); diff --git a/ui/pages/confirmations/hooks/pay/usePayWithToken.tsx b/ui/pages/confirmations/hooks/pay/usePayWithToken.tsx index 52ec6a0cb279..fa2a80c2cfc3 100644 --- a/ui/pages/confirmations/hooks/pay/usePayWithToken.tsx +++ b/ui/pages/confirmations/hooks/pay/usePayWithToken.tsx @@ -20,6 +20,7 @@ import { useConfirmContext } from '../../context/confirm'; import { PayWithModal } from '../../components/modals/pay-with-modal'; import { useTransactionPayToken } from './useTransactionPayToken'; import { useTransactionPayRequiredTokens } from './useTransactionPayData'; +import { useTransactionPayAvailableTokens } from './useTransactionPayAvailableTokens'; import { MONEY_ACCOUNT_DUMMY_BALANCE_FIAT } from './sections/usePayWithMoneyAccountSection'; export type PayWithDisplayToken = { @@ -37,6 +38,7 @@ type PayWithToken = { ownerId: string; isPostQuoteWithdraw: boolean; isMoneyAccountSelected: boolean; + hasAvailableTokens: boolean; openModal: () => void; modal: React.ReactNode; }; @@ -54,6 +56,7 @@ export function usePayWithToken(): PayWithToken { const [isModalOpen, setIsModalOpen] = useState(false); const { payToken } = useTransactionPayToken(); const requiredTokens = useTransactionPayRequiredTokens(); + const availableTokens = useTransactionPayAvailableTokens(); const fiatFormatter = useFiatFormatter({ overrideCurrency: 'usd' }); const { currentConfirmation } = useConfirmContext(); @@ -87,6 +90,11 @@ export function usePayWithToken(): PayWithToken { const resolvedToken = payToken ?? (shouldWaitForPayToken ? undefined : firstRequiredToken); + const hasAvailableTokens = useMemo( + () => (availableTokens ?? []).some((token) => !token.disabled), + [availableTokens], + ); + const balanceUsdFormatted = useMemo(() => { if (isMoneyAccountSelected) { return MONEY_ACCOUNT_DUMMY_BALANCE_FIAT; @@ -121,6 +129,7 @@ export function usePayWithToken(): PayWithToken { ownerId: currentConfirmation?.id ?? '', isPostQuoteWithdraw, isMoneyAccountSelected, + hasAvailableTokens, openModal, modal: isModalOpen ? ( diff --git a/ui/pages/confirmations/hooks/pay/useTransactionPayAvailableTokens.test.tsx b/ui/pages/confirmations/hooks/pay/useTransactionPayAvailableTokens.test.tsx index c5dd83dbfe18..f3e8cce0e53f 100644 --- a/ui/pages/confirmations/hooks/pay/useTransactionPayAvailableTokens.test.tsx +++ b/ui/pages/confirmations/hooks/pay/useTransactionPayAvailableTokens.test.tsx @@ -4,10 +4,12 @@ import * as transactionPayUtils from '../../utils/transaction-pay'; import { useSendTokens } from '../send/useSendTokens'; import { Asset, AssetStandard } from '../../types/send'; import { useTransactionPayBlockedTokens } from './useTransactionPayBlockedTokens'; +import { useTransactionPayToken } from './useTransactionPayToken'; import { useTransactionPayAvailableTokens } from './useTransactionPayAvailableTokens'; jest.mock('../send/useSendTokens'); jest.mock('./useTransactionPayBlockedTokens'); +jest.mock('./useTransactionPayToken'); jest.mock('../../utils/transaction-pay', () => ({ ...jest.requireActual('../../utils/transaction-pay'), getAvailableTokens: jest.fn(), @@ -41,6 +43,7 @@ describe('useTransactionPayAvailableTokens', () => { const getAvailableTokensMock = jest.mocked( transactionPayUtils.getAvailableTokens, ); + const useTransactionPayTokenMock = jest.mocked(useTransactionPayToken); const blockedTokensMock = { chainIds: ['0xa4b1'], @@ -51,6 +54,10 @@ describe('useTransactionPayAvailableTokens', () => { jest.resetAllMocks(); useSendTokensMock.mockReturnValue([SEND_TOKEN_MOCK]); useTransactionPayBlockedTokensMock.mockReturnValue(blockedTokensMock); + useTransactionPayTokenMock.mockReturnValue({ + payToken: undefined, + setPayToken: jest.fn(), + }); getAvailableTokensMock.mockReturnValue([TOKEN_MOCK]); }); @@ -72,6 +79,7 @@ describe('useTransactionPayAvailableTokens', () => { }), ]), blockedTokens: blockedTokensMock, + payToken: undefined, }); }); diff --git a/ui/pages/confirmations/hooks/pay/useTransactionPayAvailableTokens.ts b/ui/pages/confirmations/hooks/pay/useTransactionPayAvailableTokens.ts index 8fb1198c8df6..be335733bbc3 100644 --- a/ui/pages/confirmations/hooks/pay/useTransactionPayAvailableTokens.ts +++ b/ui/pages/confirmations/hooks/pay/useTransactionPayAvailableTokens.ts @@ -2,18 +2,21 @@ import { useMemo } from 'react'; import { getAvailableTokens } from '../../utils/transaction-pay'; import { useSendTokens } from '../send/useSendTokens'; import { useTransactionPayBlockedTokens } from './useTransactionPayBlockedTokens'; +import { useTransactionPayToken } from './useTransactionPayToken'; export function useTransactionPayAvailableTokens() { const tokens = useSendTokens(); const blockedTokens = useTransactionPayBlockedTokens(); + const { payToken } = useTransactionPayToken(); const availableTokens = useMemo( () => getAvailableTokens({ tokens, blockedTokens, + payToken, }), - [blockedTokens, tokens], + [blockedTokens, payToken, tokens], ); return availableTokens; diff --git a/ui/pages/confirmations/hooks/transactions/useDepositPrefillAmount.test.ts b/ui/pages/confirmations/hooks/transactions/useDepositPrefillAmount.test.ts index f468b90b998f..c5b94afd6098 100644 --- a/ui/pages/confirmations/hooks/transactions/useDepositPrefillAmount.test.ts +++ b/ui/pages/confirmations/hooks/transactions/useDepositPrefillAmount.test.ts @@ -12,6 +12,7 @@ import { selectRelayFixedSpread, } from '../../selectors/feature-flags'; import { isRouteToken } from '../../utils/relay-fixed-spread'; +import { usePayTokenAccountBalance } from '../pay/usePayTokenAccountBalance'; import { useTransactionPayToken } from '../pay/useTransactionPayToken'; import { useTransactionAccountOverride } from './useTransactionAccountOverride'; import { useTransactionMetadataRequest } from './useTransactionMetadataRequest'; @@ -27,6 +28,7 @@ jest.mock('../../utils/relay-fixed-spread', () => ({ isRouteToken: jest.fn(), })); +jest.mock('../pay/usePayTokenAccountBalance'); jest.mock('../pay/useTransactionPayToken'); jest.mock('./useTransactionMetadataRequest'); jest.mock('./useTransactionAccountOverride'); @@ -40,6 +42,7 @@ const TRANSACTION_ID_MOCK = 'test-tx-id'; const useTransactionMetadataRequestMock = jest.mocked( useTransactionMetadataRequest, ); +const usePayTokenAccountBalanceMock = jest.mocked(usePayTokenAccountBalance); const useTransactionPayTokenMock = jest.mocked(useTransactionPayToken); const useTransactionAccountOverrideMock = jest.mocked( useTransactionAccountOverride, @@ -100,6 +103,10 @@ function setupMocks( setPayToken: jest.fn(), isNative: false, } as ReturnType); + usePayTokenAccountBalanceMock.mockReturnValue({ + balanceUsd: resolvedPayToken?.balanceUsd ?? '0', + balanceRaw: resolvedPayToken?.balanceRaw ?? '0', + }); useTransactionAccountOverrideMock.mockReturnValue(overrides.accountOverride); useSelectorMock.mockImplementation((selector) => { @@ -207,14 +214,14 @@ describe('useDepositPrefillAmount', () => { expect(result.current.prefillAmount).toBeUndefined(); }); - it('returns undefined when balanceUsd is 0', () => { + it('returns 0.0 when balanceUsd is 0', () => { setupMocks({ payToken: makePayToken({ balanceUsd: '0' }), }); const { result } = runHook(); - expect(result.current.prefillAmount).toBeUndefined(); + expect(result.current.prefillAmount).toBe('0.0'); }); it('formats integer amounts without decimals', () => { @@ -303,13 +310,18 @@ describe('useDepositPrefillAmount', () => { setPayToken: jest.fn(), isNative: false, } as ReturnType); + usePayTokenAccountBalanceMock.mockReturnValue({ + balanceUsd: '0', + balanceRaw: '0', + }); await act(async () => { rerender(); }); - expect(result.current.hasPrefilled).toBe(false); - expect(result.current.isLoading).toBe(true); + expect(result.current.hasPrefilled).toBe(true); + expect(result.current.isLoading).toBe(false); + expect(result.current.prefillAmount).toBe('0.0'); }); it('recommits for a new confirmation rendered by the same mounted UI', async () => { @@ -358,6 +370,10 @@ describe('useDepositPrefillAmount', () => { setPayToken: jest.fn(), isNative: false, } as ReturnType); + usePayTokenAccountBalanceMock.mockReturnValue({ + balanceUsd: '800', + balanceRaw: '0', + }); await act(async () => { rerender(); @@ -369,12 +385,24 @@ describe('useDepositPrefillAmount', () => { }); describe('isLoading', () => { - it('true when enabled but not yet committed', () => { + it('false when enabled but no pay token is selected', () => { setupMocks({ payToken: null }); const { result } = runHook(); - expect(result.current.isLoading).toBe(true); + expect(result.current.isLoading).toBe(false); + }); + + it('false when the selected pay token has a zero balance', () => { + setupMocks({ + payToken: makePayToken({ balanceUsd: '0' }), + }); + + const { result } = runHook(); + + expect(result.current.isLoading).toBe(false); + expect(result.current.hasPrefilled).toBe(true); + expect(result.current.prefillAmount).toBe('0.0'); }); it('false when not enabled', () => { diff --git a/ui/pages/confirmations/hooks/transactions/useDepositPrefillAmount.ts b/ui/pages/confirmations/hooks/transactions/useDepositPrefillAmount.ts index bcb2f3a718a0..7e535a0f60eb 100644 --- a/ui/pages/confirmations/hooks/transactions/useDepositPrefillAmount.ts +++ b/ui/pages/confirmations/hooks/transactions/useDepositPrefillAmount.ts @@ -17,10 +17,13 @@ import { } from '../../selectors/feature-flags'; import { getDepositLimitForTransaction } from '../../utils/pay-deposit-limit'; import { isRouteToken } from '../../utils/relay-fixed-spread'; +import { usePayTokenAccountBalance } from '../pay/usePayTokenAccountBalance'; import { useTransactionPayToken } from '../pay/useTransactionPayToken'; import { useTransactionAccountOverride } from './useTransactionAccountOverride'; import { useTransactionMetadataRequest } from './useTransactionMetadataRequest'; +const ZERO_PREFILL_AMOUNT = '0.0'; + function formatFiatAmount(value: BigNumber): string { return value.isInteger() ? value.toString(10) : value.toFixed(2); } @@ -88,10 +91,14 @@ export function useDepositPrefillAmount(): DepositPrefillResult { ); const enabled = Boolean(prefilledAmountConfig.enabled); + const { balanceUsd: liveBalanceUsd } = usePayTokenAccountBalance(); + // Live funding-account USD, not the pay-controller snapshot. A $0 snapshot + // (common on deposits: tx `from` is the money account) left prefill + // uncommitted and the amount skeleton up forever. // Keep balance as a string so BigNumber.times never receives a JS number with // >15 significant digits (throws in this bignumber.js version). - const balanceUsd = String(payToken?.balanceUsd ?? 0); + const balanceUsd = String(liveBalanceUsd || payToken?.balanceUsd || 0); // The confirmation id is part of the key so a following deposit rendered by // the same mounted UI releases the commit and prefills again, instead of // inheriting the previous confirmation's amount. @@ -101,15 +108,14 @@ export function useDepositPrefillAmount(): DepositPrefillResult { const prefillAmount = useMemo(() => { const balanceUsdValue = new BigNumber(balanceUsd); - if ( - !enabled || - !payToken || - !balanceUsdValue.isFinite() || - balanceUsdValue.lte(0) - ) { + if (!enabled || !payToken) { return undefined; } + if (!balanceUsdValue.isFinite() || balanceUsdValue.lte(0)) { + return ZERO_PREFILL_AMOUNT; + } + const stable = isRouteToken(relayFixedSpread, { chainId: payToken.chainId, address: payToken.address, @@ -144,7 +150,9 @@ export function useDepositPrefillAmount(): DepositPrefillResult { }, [committedKey, enabled, prefillAmount, tokenKey]); const hasPrefilled = committedKey === tokenKey; - const isLoading = enabled && !hasPrefilled; + // No pay token means auto-select found nothing to prefill — do not keep + // the amount skeleton up forever waiting for a token that will not come. + const isLoading = enabled && Boolean(payToken) && !hasPrefilled; return { prefillAmount, isLoading, hasPrefilled, enabled }; } diff --git a/ui/pages/confirmations/hooks/transactions/useTransactionCustomAmount.test.ts b/ui/pages/confirmations/hooks/transactions/useTransactionCustomAmount.test.ts index ad886cbb9772..0ecfec157d0a 100644 --- a/ui/pages/confirmations/hooks/transactions/useTransactionCustomAmount.test.ts +++ b/ui/pages/confirmations/hooks/transactions/useTransactionCustomAmount.test.ts @@ -13,6 +13,8 @@ import * as useTokenFiatRatesModule from '../tokens/useTokenFiatRates'; import * as usePayWithNoFeeTokenModule from '../pay/usePayWithNoFeeToken'; import * as useTransactionPayDataModule from '../pay/useTransactionPayData'; import * as useTransactionPayTokenModule from '../pay/useTransactionPayToken'; +import * as usePayTokenAccountBalanceModule from '../pay/usePayTokenAccountBalance'; +import { MUSD_TOKEN_ADDRESS } from '../../constants/musd'; import { useTransactionCustomAmount, MAX_LENGTH, @@ -25,6 +27,7 @@ jest.mock('../tokens/useTokenFiatRates'); jest.mock('../pay/usePayWithNoFeeToken'); jest.mock('../pay/useTransactionPayData'); jest.mock('../pay/useTransactionPayToken'); +jest.mock('../pay/usePayTokenAccountBalance'); jest.mock('./useDepositPrefillAmount'); jest.mock('./useUpdateTokenAmount'); jest.mock('../../../../store/actions', () => ({ @@ -47,15 +50,17 @@ function runHook({ currency, disableUpdate = false, tokenFiatRate = 1, + tokenFiatRateMusd, payTokenBalanceUsd = 100, payTokenBalanceRaw, - payTokenDecimals, + payTokenDecimals = 6, payTokenAddress = '0xpaytoken', payTokenChainId = '0x1', balanceUsdOverride, isNoFeePayToken = true, isMaxAmount = false, requiredTokens = [], + totals, updateTokenAmountMock = jest.fn(), prefillMaxOnLoad = false, transactionMeta = MOCK_TRANSACTION_META, @@ -64,6 +69,7 @@ function runHook({ currency?: string; disableUpdate?: boolean; tokenFiatRate?: number; + tokenFiatRateMusd?: number; payTokenBalanceUsd?: number; payTokenBalanceRaw?: string; payTokenDecimals?: number; @@ -73,6 +79,7 @@ function runHook({ isNoFeePayToken?: boolean; isMaxAmount?: boolean; requiredTokens?: { amountUsd?: string; skipIfBalance?: boolean }[]; + totals?: { targetAmount?: { usd?: string } }; updateTokenAmountMock?: jest.Mock; prefillMaxOnLoad?: boolean; transactionMeta?: TransactionMeta; @@ -80,10 +87,25 @@ function runHook({ } = {}) { jest .mocked(useTokenFiatRatesModule.useTokenFiatRate) - .mockReturnValue(tokenFiatRate); + .mockImplementation((address) => { + if ( + tokenFiatRateMusd !== undefined && + String(address).toLowerCase() === MUSD_TOKEN_ADDRESS.toLowerCase() + ) { + return tokenFiatRateMusd; + } + return tokenFiatRate; + }); jest .mocked(useTransactionPayDataModule.useTransactionPayIsMaxAmount) .mockReturnValue(isMaxAmount); + jest + .mocked(useTransactionPayDataModule.useTransactionPayTotals) + .mockReturnValue( + totals as ReturnType< + typeof useTransactionPayDataModule.useTransactionPayTotals + >, + ); jest .mocked(useTransactionPayDataModule.useTransactionPayRequiredTokens) .mockReturnValue( @@ -117,6 +139,12 @@ function runHook({ isNoFeeToken: () => isNoFeePayToken, renderNoFeeTag: () => null, }); + jest + .mocked(usePayTokenAccountBalanceModule.usePayTokenAccountBalance) + .mockReturnValue({ + balanceUsd: String(payTokenBalanceUsd), + balanceRaw: payTokenBalanceRaw ?? '0', + }); jest.mocked(useUpdateTokenAmountModule.useUpdateTokenAmount).mockReturnValue({ updateTokenAmount: updateTokenAmountMock, isUpdating: false, @@ -160,7 +188,7 @@ describe('useTransactionCustomAmount', () => { it('returns target amount USD when isMaxAmount is true and target amount exists', () => { const { result } = runHook({ isMaxAmount: true, - requiredTokens: [{ amountUsd: '123.456', skipIfBalance: false }], + totals: { targetAmount: { usd: '123.456' } }, }); expect(result.current.amountFiat).toBe('123.46'); @@ -181,13 +209,30 @@ describe('useTransactionCustomAmount', () => { const { result } = runHook({ tokenFiatRate: 2, isMaxAmount: true, - requiredTokens: [{ amountUsd: '100', skipIfBalance: false }], + totals: { targetAmount: { usd: '100' } }, }); // amountFiat = 100, tokenFiatRate = 2, so amountHuman = 100 / 2 = 50 expect(result.current.amountHuman).toBe('50'); }); + it('converts deposit USD with the mUSD rate instead of the Monad vault rate', () => { + const { result } = runHook({ + transactionMeta: { + ...MOCK_TRANSACTION_META, + type: TransactionType.moneyAccountDeposit, + } as TransactionMeta, + tokenFiatRate: 0.04, + tokenFiatRateMusd: 1, + }); + + act(() => { + result.current.updatePendingAmount('0.34'); + }); + + expect(result.current.amountHuman).toBe('0.34'); + }); + it('returns "0" when amountFiat is "0"', () => { const { result } = runHook({ tokenFiatRate: 2, @@ -288,6 +333,7 @@ describe('useTransactionCustomAmount', () => { expect(setIsMaxAmountMock).toHaveBeenCalledWith( MOCK_TRANSACTION_META.id, false, + { isMoneyAccountDeposit: false }, ); }); }); @@ -318,6 +364,7 @@ describe('useTransactionCustomAmount', () => { expect(setIsMaxAmountMock).toHaveBeenCalledWith( MOCK_TRANSACTION_META.id, true, + { isMoneyAccountDeposit: false }, ); }); @@ -335,6 +382,7 @@ describe('useTransactionCustomAmount', () => { expect(setIsMaxAmountMock).toHaveBeenCalledWith( MOCK_TRANSACTION_META.id, false, + { isMoneyAccountDeposit: false }, ); }); @@ -534,6 +582,45 @@ describe('useTransactionCustomAmount', () => { expect(updateTokenAmountMock).toHaveBeenCalledWith('50'); }); + it('does not call updateTokenAmount for a zero amount after debounce', () => { + const updateTokenAmountMock = jest.fn(); + runHook({ + disableUpdate: false, + updateTokenAmountMock, + }); + + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(updateTokenAmountMock).not.toHaveBeenCalled(); + }); + + it('does not call updateTokenAmount when the amount is cleared to zero', () => { + const updateTokenAmountMock = jest.fn(); + const { result } = runHook({ + disableUpdate: false, + updateTokenAmountMock, + }); + + act(() => { + result.current.updatePendingAmount('50'); + }); + act(() => { + jest.advanceTimersByTime(500); + }); + updateTokenAmountMock.mockClear(); + + act(() => { + result.current.updatePendingAmount('0'); + }); + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(updateTokenAmountMock).not.toHaveBeenCalled(); + }); + it('does not call updateTokenAmount when disableUpdate is true and percentage button is clicked', () => { const updateTokenAmountMock = jest.fn(); const { result } = runHook({ @@ -619,6 +706,25 @@ describe('useTransactionCustomAmount', () => { ); }); + it('does not dispatch mm_pay_quote_requested after debounce for a zero amount', () => { + runHook(); + + jest.mocked(upsertTransactionUIMetricsFragment).mockClear(); + + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(upsertTransactionUIMetricsFragment).not.toHaveBeenCalledWith( + MOCK_TRANSACTION_META.id, + expect.objectContaining({ + properties: expect.objectContaining({ + mm_pay_quote_requested: expect.anything(), + }), + }), + ); + }); + it('does not dispatch mm_pay_quote_requested after debounce when disableUpdate is true', () => { const { result } = runHook({ disableUpdate: true }); @@ -697,6 +803,7 @@ describe('useTransactionCustomAmount', () => { expect(setIsMaxAmountMock).toHaveBeenCalledWith( MOCK_TRANSACTION_META.id, true, + { isMoneyAccountDeposit: false }, ); }); @@ -796,6 +903,49 @@ describe('useTransactionCustomAmount', () => { ); }); + it('shows 0.0 when the pay token has no balance', () => { + const updateTokenAmountMock = jest.fn(); + const { result } = runHook({ + transactionMeta: moneyAccountDepositMeta, + payTokenBalanceUsd: 0, + updateTokenAmountMock, + depositPrefill: { + enabled: true, + hasPrefilled: true, + isLoading: false, + prefillAmount: '0.0', + }, + }); + + expect(result.current.amountFiat).toBe('0.0'); + expect(updateTokenAmountMock).not.toHaveBeenCalled(); + }); + + it('does not request a quote for a zero deposit prefill', () => { + const updateTokenAmountMock = jest.fn(); + runHook({ + transactionMeta: moneyAccountDepositMeta, + payTokenBalanceUsd: 1000, + updateTokenAmountMock, + depositPrefill: { + enabled: true, + hasPrefilled: true, + isLoading: false, + prefillAmount: '0.0', + }, + }); + + expect(updateTokenAmountMock).not.toHaveBeenCalled(); + expect(upsertTransactionUIMetricsFragment).not.toHaveBeenCalledWith( + moneyAccountDepositMeta.id, + expect.objectContaining({ + properties: expect.objectContaining({ + mm_pay_quote_requested: expect.anything(), + }), + }), + ); + }); + it('does not apply deposit prefill for non-deposit transactions', () => { const { result } = runHook({ payTokenBalanceUsd: 1000, @@ -951,7 +1101,7 @@ describe('useTransactionCustomAmount', () => { transactionMeta: moneyAccountDepositMeta, }; - it('submits the raw token balance for Max without setting isMaxAmount', () => { + it('sets isMaxAmount with isMoneyAccountDeposit so Max deposits run non-atomic', () => { const updateTokenAmountMock = jest.fn(); const { result } = runHook({ ...depositMaxPayToken, @@ -965,9 +1115,10 @@ describe('useTransactionCustomAmount', () => { // 1123456 × 10^-6 = 1.123456, not the fiat roundtrip 2.24 ÷ 2 = 1.12 expect(updateTokenAmountMock).toHaveBeenCalledWith('1.123456'); expect(result.current.amountFiat).toBe('2.24'); - expect(setIsMaxAmountMock).not.toHaveBeenCalledWith( + expect(setIsMaxAmountMock).toHaveBeenCalledWith( moneyAccountDepositMeta.id, true, + { isMoneyAccountDeposit: true }, ); }); @@ -1046,6 +1197,7 @@ describe('useTransactionCustomAmount', () => { expect(setIsMaxAmountMock).toHaveBeenCalledWith( MOCK_TRANSACTION_META.id, true, + { isMoneyAccountDeposit: false }, ); }); @@ -1097,9 +1249,10 @@ describe('useTransactionCustomAmount', () => { }); expect(updateTokenAmountMock).toHaveBeenCalledWith('1.12'); - expect(setIsMaxAmountMock).not.toHaveBeenCalledWith( + expect(setIsMaxAmountMock).toHaveBeenCalledWith( moneyAccountDepositMeta.id, true, + { isMoneyAccountDeposit: true }, ); }); }); diff --git a/ui/pages/confirmations/hooks/transactions/useTransactionCustomAmount.ts b/ui/pages/confirmations/hooks/transactions/useTransactionCustomAmount.ts index 7a66ea610a03..86bc64d406ed 100644 --- a/ui/pages/confirmations/hooks/transactions/useTransactionCustomAmount.ts +++ b/ui/pages/confirmations/hooks/transactions/useTransactionCustomAmount.ts @@ -13,11 +13,17 @@ import { useTokenFiatRate } from '../tokens/useTokenFiatRates'; import { useConfirmContext } from '../../context/confirm'; import { usePayWithNoFeeToken } from '../pay/usePayWithNoFeeToken'; import { useTransactionPayToken } from '../pay/useTransactionPayToken'; +import { usePayTokenAccountBalance } from '../pay/usePayTokenAccountBalance'; import { useTransactionPayIsMaxAmount, useTransactionPayPrimaryRequiredToken, + useTransactionPayTotals, } from '../pay/useTransactionPayData'; import { getTokenAddress } from '../../utils/transaction-pay'; +import { + MUSD_CONVERSION_DEFAULT_CHAIN_ID, + MUSD_TOKEN_ADDRESS, +} from '../../constants/musd'; import { useDepositPrefillAmount } from './useDepositPrefillAmount'; import { useUpdateTokenAmount } from './useUpdateTokenAmount'; @@ -55,12 +61,35 @@ export function useTransactionCustomAmount({ const { chainId, id: transactionId } = transactionMeta ?? {}; const isMaxAmount = useTransactionPayIsMaxAmount(); + const isMoneyAccountDeposit = hasTransactionType(transactionMeta, [ + TransactionType.moneyAccountDeposit, + ]); + const isMoneyAccountWithdraw = hasTransactionType(transactionMeta, [ + TransactionType.moneyAccountWithdraw, + ]); const tokenAddress = getTokenAddress(transactionMeta); + const payTokenFiatRate = useTokenFiatRate( + tokenAddress, + chainId as Hex, + currency, + ); + const musdFiatRate = + useTokenFiatRate( + MUSD_TOKEN_ADDRESS, + MUSD_CONVERSION_DEFAULT_CHAIN_ID, + currency, + ) ?? 1; + // Deposit/withdraw amounts are human mUSD and the input is USD. The + // confirmation `to` is the vault on Monad, so `payTokenFiatRate` is the + // MON rate and inflates every non-Max amount past the pay-token balance. const tokenFiatRate = - useTokenFiatRate(tokenAddress, chainId as Hex, currency) ?? 1; + isMoneyAccountDeposit || isMoneyAccountWithdraw + ? musdFiatRate + : (payTokenFiatRate ?? 1); const hasBalanceUsdOverride = balanceUsdOverride !== undefined; + const balanceUsd = useTokenBalance(balanceUsdOverride); + const { payToken } = useTransactionPayToken(); - const balanceUsd = getTokenBalanceUsd(balanceUsdOverride, payToken); const { isNoFeeToken } = usePayWithNoFeeToken(); const isNoFeePayToken = Boolean( payToken && isNoFeeToken(payToken.address, String(payToken.chainId)), @@ -68,6 +97,7 @@ export function useTransactionCustomAmount({ const { updateTokenAmount: updateTokenAmountCallback } = useUpdateTokenAmount(); + const totals = useTransactionPayTotals(); const debounceRef = useRef void> | null>( null, @@ -88,9 +118,6 @@ export function useTransactionCustomAmount({ >(undefined); const hasUserEditedAmount = editedTransactionId !== undefined && editedTransactionId === transactionId; - const isMoneyAccountDeposit = hasTransactionType(transactionMeta, [ - TransactionType.moneyAccountDeposit, - ]); const depositPrefill = useDepositPrefillAmount(); const shouldUseDepositPrefill = isMoneyAccountDeposit && depositPrefill.enabled; @@ -104,18 +131,23 @@ export function useTransactionCustomAmount({ // Create new debounced function const debouncedFn = debounce((value: string) => { setAmountHumanDebounced(value); - if (!disableUpdate) { - updateTokenAmountCallback(value); - // Emitted only after the debounce actually triggers a quote refresh - // via updateEditableParams -> TransactionPayController:stateChange. - if (transactionId) { - upsertTransactionUIMetricsFragment(transactionId, { - properties: { - // eslint-disable-next-line @typescript-eslint/naming-convention - mm_pay_quote_requested: true, - }, - }); - } + // Same as mobile: a zero transfer must not update tx data or request + // quotes. TransactionPayController skips zero required amounts, but + // writing 0 still kicks the quote pipeline. + if (disableUpdate || isZeroHumanAmount(value)) { + return; + } + + updateTokenAmountCallback(value); + // Emitted only after the debounce actually triggers a quote refresh + // via updateEditableParams -> TransactionPayController:stateChange. + if (transactionId) { + upsertTransactionUIMetricsFragment(transactionId, { + properties: { + // eslint-disable-next-line @typescript-eslint/naming-convention + mm_pay_quote_requested: true, + }, + }); } }, DEBOUNCE_DELAY); @@ -137,7 +169,9 @@ export function useTransactionCustomAmount({ ); const amountFiat = useMemo(() => { - const targetAmountUsd = primaryRequiredToken?.amountUsd; + // Quote target USD is the amount that will actually land after fees — + // use it for Max display so the field matches the submitted total. + const targetAmountUsd = totals?.targetAmount?.usd; if (isMaxAmount && targetAmountUsd && targetAmountUsd !== '0') { return new BigNumber(targetAmountUsd) @@ -146,7 +180,7 @@ export function useTransactionCustomAmount({ } return amountFiatState; - }, [amountFiatState, isMaxAmount, primaryRequiredToken?.amountUsd]); + }, [amountFiatState, isMaxAmount, totals?.targetAmount?.usd]); const amountHuman = useMemo( () => @@ -201,10 +235,10 @@ export function useTransactionCustomAmount({ const setIsMax = useCallback( (value: boolean) => { if (transactionId) { - setIsMaxAmount(transactionId, value); + setIsMaxAmount(transactionId, value, { isMoneyAccountDeposit }); } }, - [transactionId], + [isMoneyAccountDeposit, transactionId], ); const updatePendingAmount = useCallback( @@ -267,11 +301,10 @@ export function useTransactionCustomAmount({ const newAmountFiatValue = new BigNumber(percentage) .dividedBy(100) .times(balanceUsdValue); - // Money-account deposits never set isMaxAmount (matches mobile keypad - // Max for this flow). TPC would otherwise substitute token.balanceRaw - // and the displayed fiat would jump to the quote target. + // Max deposits also set isMaxAmount, with isMoneyAccountDeposit so TPC + // runs them non-atomic instead of substituting token.balanceRaw. const shouldSetMaxAmountMode = - percentage === 100 && !hasBalanceUsdOverride && !isMoneyAccountDeposit; + percentage === 100 && !hasBalanceUsdOverride; // Keep the displayed fiat rounded except for balanceUsdOverride Max // (Perps withdraw), which must preserve the full typed balance. const newAmountFiat = ( @@ -331,7 +364,7 @@ export function useTransactionCustomAmount({ // debounced typing update that would otherwise overwrite them. debounceRef.current?.cancel(); setAmountHumanDebounced(newAmountHuman); - if (!disableUpdate) { + if (!disableUpdate && !isZeroHumanAmount(newAmountHuman)) { updateTokenAmountCallback(newAmountHuman); } }, @@ -363,22 +396,44 @@ export function useTransactionCustomAmount({ const balanceUsdValue = new BigNumber(String(balanceUsd ?? 0)); const prefillFiat = new BigNumber(fiatAmount); - if ( - !balanceUsdValue.isFinite() || - balanceUsdValue.lte(0) || - !prefillFiat.isFinite() - ) { + if (!balanceUsdValue.isFinite() || !prefillFiat.isFinite()) { return; } - // Money-account deposits keep isMaxAmount false (matches mobile) so the - // typed fiat amount is what gets submitted, not the raw token balance. + // $0 pay token: show 0.0 so the field is usable. Do not request a quote. + if (balanceUsdValue.lte(0)) { + if (isMaxAmount) { + setIsMax(false); + } + depositMaxHumanRef.current = null; + setAmountFiat(fiatAmount); + debounceRef.current?.cancel(); + setAmountHumanDebounced('0'); + return; + } + + // Limit-capped / literal prefills are not a true Max. Uncapped 100% + // prefills go through `updatePendingAmountPercentage` instead. if (isMaxAmount) { setIsMax(false); } depositMaxHumanRef.current = null; + setAmountFiat(fiatAmount); + + const newAmountHuman = getAmountHumanFromFiat( + fiatAmount, + tokenFiatRate, + hasBalanceUsdOverride, + ); + + debounceRef.current?.cancel(); + setAmountHumanDebounced(newAmountHuman); + if (disableUpdate || isZeroHumanAmount(newAmountHuman)) { + return; + } + if (transactionId) { upsertTransactionUIMetricsFragment(transactionId, { properties: { @@ -392,19 +447,7 @@ export function useTransactionCustomAmount({ }); } - setAmountFiat(fiatAmount); - - const newAmountHuman = getAmountHumanFromFiat( - fiatAmount, - tokenFiatRate, - hasBalanceUsdOverride, - ); - - debounceRef.current?.cancel(); - setAmountHumanDebounced(newAmountHuman); - if (!disableUpdate) { - updateTokenAmountCallback(newAmountHuman); - } + updateTokenAmountCallback(newAmountHuman); }, [ balanceUsd, @@ -438,7 +481,7 @@ export function useTransactionCustomAmount({ if (depositPrefill.hasPrefilled) { applyDepositPrefillAmount(depositPrefill.prefillAmount ?? '0'); } else if (prevDepositHasPrefilledRef.current) { - setAmountFiat('0'); + setAmountFiat('0.0'); } prevDepositHasPrefilledRef.current = depositPrefill.hasPrefilled; @@ -490,15 +533,14 @@ export function useTransactionCustomAmount({ }; } -function getTokenBalanceUsd( - balanceUsdOverride: number | undefined, - payToken: ReturnType['payToken'], -) { +function useTokenBalance(balanceUsdOverride?: number) { + const { balanceUsd } = usePayTokenAccountBalance(); + if (balanceUsdOverride !== undefined) { return balanceUsdOverride; } - return new BigNumber(payToken?.balanceUsd ?? 0).toNumber(); + return new BigNumber(balanceUsd ?? 0).toNumber(); } function getHumanAmountFromBalanceRaw( @@ -520,6 +562,11 @@ function getHumanAmountFromBalanceRaw( return humanAmount.toString(10); } +function isZeroHumanAmount(value: string): boolean { + const amount = new BigNumber(value || 0); + return !amount.isFinite() || amount.lte(0); +} + function getAmountHumanFromFiat( amountFiat: string, tokenFiatRate: number, diff --git a/ui/pages/confirmations/hooks/transactions/useUpdateTokenAmount.test.ts b/ui/pages/confirmations/hooks/transactions/useUpdateTokenAmount.test.ts index 84b41522cd3d..1c372697284a 100644 --- a/ui/pages/confirmations/hooks/transactions/useUpdateTokenAmount.test.ts +++ b/ui/pages/confirmations/hooks/transactions/useUpdateTokenAmount.test.ts @@ -5,6 +5,10 @@ import { getMockConfirmStateForTransaction } from '../../../../../test/data/conf import { renderHookWithConfirmContextProvider } from '../../../../../test/lib/confirmations/render-helpers'; import { updateEditableParams } from '../../../../store/actions'; import { updateAtomicBatchData } from '../../../../store/controller-actions/transaction-controller'; +import { + updateMoneyAccountDepositAmount, + updateMoneyAccountWithdrawAmount, +} from '../../../../store/controller-actions/transaction-pay-controller'; import * as useTransactionPayDataModule from '../pay/useTransactionPayData'; import * as transactionPayUtils from '../../utils/transaction-pay'; import { useUpdateTokenAmount } from './useUpdateTokenAmount'; @@ -24,6 +28,17 @@ jest.mock( }), ); +jest.mock( + '../../../../store/controller-actions/transaction-pay-controller', + () => ({ + ...jest.requireActual( + '../../../../store/controller-actions/transaction-pay-controller', + ), + updateMoneyAccountDepositAmount: jest.fn(), + updateMoneyAccountWithdrawAmount: jest.fn(), + }), +); + jest.mock('../pay/useTransactionPayData'); jest.mock('../../utils/transaction-pay'); @@ -104,6 +119,76 @@ describe('useUpdateTokenAmount', () => { }); describe('updateTokenAmount', () => { + it('dispatches the money deposit commit path for a money account deposit batch', async () => { + const updateMoneyAmountMock = jest + .mocked(updateMoneyAccountDepositAmount) + .mockResolvedValue(true); + + const transactionMeta = createMockTransactionMeta({ + nestedTransactions: [ + { to: MOCK_TOKEN_ADDRESS, type: 'tokenMethodApprove' }, + { to: MOCK_RECIPIENT, type: 'moneyAccountDeposit' }, + ], + } as unknown as Partial); + + const { result } = runHook({ + transactionMeta, + // The placeholder batch has no transfer calldata to parse. + tokenTransferData: { + data: undefined, + to: undefined, + index: undefined, + }, + }); + + await act(async () => { + result.current.updateTokenAmount('1.5'); + }); + + expect(updateMoneyAmountMock).toHaveBeenCalledWith( + transactionMeta.id, + '1.5', + ); + expect(updateAtomicBatchDataMock).not.toHaveBeenCalled(); + expect(updateEditableParamsMock).not.toHaveBeenCalled(); + }); + + it('dispatches the withdrawal commit path for a money account withdrawal batch', async () => { + const updateWithdrawAmountMock = jest + .mocked(updateMoneyAccountWithdrawAmount) + .mockResolvedValue({ + didCommit: true, + recipient: MOCK_RECIPIENT, + }); + + const transactionMeta = createMockTransactionMeta({ + nestedTransactions: [ + { to: MOCK_TOKEN_ADDRESS, type: 'moneyAccountWithdraw' }, + { to: MOCK_RECIPIENT, type: 'transfer' }, + ], + } as unknown as Partial); + + const { result } = runHook({ + transactionMeta, + tokenTransferData: { + data: undefined, + to: undefined, + index: undefined, + }, + }); + + await act(async () => { + result.current.updateTokenAmount('2'); + }); + + expect(updateWithdrawAmountMock).toHaveBeenCalledWith( + transactionMeta.id, + '2', + ); + expect(updateAtomicBatchDataMock).not.toHaveBeenCalled(); + expect(updateEditableParamsMock).not.toHaveBeenCalled(); + }); + it('does nothing when data is undefined', () => { const { result } = runHook({ tokenTransferData: { diff --git a/ui/pages/confirmations/hooks/transactions/useUpdateTokenAmount.ts b/ui/pages/confirmations/hooks/transactions/useUpdateTokenAmount.ts index 1a96a9dfbb20..7c17af3197d1 100644 --- a/ui/pages/confirmations/hooks/transactions/useUpdateTokenAmount.ts +++ b/ui/pages/confirmations/hooks/transactions/useUpdateTokenAmount.ts @@ -5,9 +5,17 @@ import { BigNumber } from 'bignumber.js'; import { Interface } from '@ethersproject/abi'; import { useConfirmContext } from '../../context/confirm'; import { parseStandardTokenTransactionData } from '../../../../../shared/lib/transaction.utils'; +import { + getMoneyAccountFlow, + MoneyAccountFlow, +} from '../../../../../shared/lib/money/money-account-flow'; import { getTokenTransferData } from '../../utils/transaction-pay'; import { updateEditableParams } from '../../../../store/actions'; import { updateAtomicBatchData } from '../../../../store/controller-actions/transaction-controller'; +import { + updateMoneyAccountDepositAmount, + updateMoneyAccountWithdrawAmount, +} from '../../../../store/controller-actions/transaction-pay-controller'; import { useTransactionPayPrimaryRequiredToken } from '../pay/useTransactionPayData'; import { useDispatch } from '../../../../store/hooks'; @@ -73,8 +81,43 @@ export function useUpdateTokenAmount() { } }, [isUpdating, transactionId]); + const moneyAccountFlow = useMemo( + () => getMoneyAccountFlow(transactionMeta), + [transactionMeta], + ); + const updateTokenAmount = useCallback( (amountHuman: string) => { + // Money deposits are a placeholder approve + deposit batch with no + // transfer calldata to parse. The background commit re-encodes both + // calls and writes requiredAssets so TransactionPayController can + // fetch quotes. Without this, typed amounts stay in local UI state. + if (moneyAccountFlow === MoneyAccountFlow.Deposit) { + updateMoneyAccountDepositAmount(transactionId, amountHuman).catch( + (error) => { + console.error( + 'Failed to update money account deposit amount', + error, + ); + }, + ); + return; + } + + // Same shape as deposits: the placeholder withdraw + transfer batch + // has no transfer calldata to parse on the parent. + if (moneyAccountFlow === MoneyAccountFlow.Withdraw) { + updateMoneyAccountWithdrawAmount(transactionId, amountHuman).catch( + (error) => { + console.error( + 'Failed to update money account withdrawal amount', + error, + ); + }, + ); + return; + } + if (!data || !to || decimals === undefined) { return; } @@ -120,7 +163,16 @@ export function useUpdateTokenAmount() { }), ); }, - [amountRaw, data, decimals, dispatch, nestedCallIndex, to, transactionId], + [ + amountRaw, + data, + decimals, + dispatch, + moneyAccountFlow, + nestedCallIndex, + to, + transactionId, + ], ); return { diff --git a/ui/pages/confirmations/selectors/feature-flags.ts b/ui/pages/confirmations/selectors/feature-flags.ts index f68eec54ccea..722cf8c17f64 100644 --- a/ui/pages/confirmations/selectors/feature-flags.ts +++ b/ui/pages/confirmations/selectors/feature-flags.ts @@ -234,8 +234,9 @@ export const selectBlockedPayTokens = createSelector( ); /** - * Minimum fiat balance required when auto-selecting a pay token, from the - * `confirmations_pay_tokens` remote feature flag. + * Minimum `token.fiat.balance` required when auto-selecting preferred or + * no-fee pay tokens, from `confirmations_pay_tokens`. Same unit as + * `fiat.balance` (user preferred currency), not token units. Matches mobile. */ export const selectMinimumRequiredTokenBalance = createSelector( selectPayTokensFlag, diff --git a/ui/store/controller-actions/transaction-pay-controller.test.ts b/ui/store/controller-actions/transaction-pay-controller.test.ts index 40a9eea67a8d..9fc4fb48306e 100644 --- a/ui/store/controller-actions/transaction-pay-controller.test.ts +++ b/ui/store/controller-actions/transaction-pay-controller.test.ts @@ -60,7 +60,7 @@ describe('transaction-pay-controller actions', () => { expect(mockSubmitRequestToBackground).toHaveBeenCalledTimes(1); expect(mockSubmitRequestToBackground).toHaveBeenCalledWith( 'setTransactionPayIsMaxAmount', - [transactionId, true], + [transactionId, true, {}], ); }); @@ -72,16 +72,17 @@ describe('transaction-pay-controller actions', () => { expect(mockSubmitRequestToBackground).toHaveBeenCalledTimes(1); expect(mockSubmitRequestToBackground).toHaveBeenCalledWith( 'setTransactionPayIsMaxAmount', - [transactionId, false], + [transactionId, false, {}], ); }); - it('returns the result from submitRequestToBackground', async () => { - mockSubmitRequestToBackground.mockResolvedValue(undefined); + it('forwards isMoneyAccountDeposit so Max deposits run non-atomic', async () => { + await setIsMaxAmount('tx-ma', true, { isMoneyAccountDeposit: true }); - const result = await setIsMaxAmount('tx-123', true); - - expect(result).toBeUndefined(); + expect(mockSubmitRequestToBackground).toHaveBeenCalledWith( + 'setTransactionPayIsMaxAmount', + ['tx-ma', true, { isMoneyAccountDeposit: true }], + ); }); }); diff --git a/ui/store/controller-actions/transaction-pay-controller.ts b/ui/store/controller-actions/transaction-pay-controller.ts index 9f1359e79b68..ea87c8ef98bd 100644 --- a/ui/store/controller-actions/transaction-pay-controller.ts +++ b/ui/store/controller-actions/transaction-pay-controller.ts @@ -40,10 +40,12 @@ export async function setPaymentOverride( export async function setIsMaxAmount( transactionId: string, isMaxAmount: boolean, + options: { isMoneyAccountDeposit?: boolean } = {}, ): Promise { return await submitRequestToBackground('setTransactionPayIsMaxAmount', [ transactionId, isMaxAmount, + options, ]); }