diff --git a/app/scripts/lib/account-supports-7702.test.ts b/app/scripts/lib/account-supports-7702.test.ts new file mode 100644 index 000000000000..4f81ef7c6f25 --- /dev/null +++ b/app/scripts/lib/account-supports-7702.test.ts @@ -0,0 +1,65 @@ +import { accountSupports7702 } from './account-supports-7702'; + +const ADDRESS_MOCK = '0x1234567890123456789012345678901234567890'; + +function keyringControllerWithType(type: string) { + return { + getKeyringForAccount: jest.fn().mockResolvedValue({ type }), + }; +} + +describe('accountSupports7702', () => { + it('returns true for HD keyring accounts', async () => { + expect( + await accountSupports7702( + ADDRESS_MOCK, + keyringControllerWithType('HD Key Tree'), + ), + ).toBe(true); + }); + + it('returns true for simple keyring accounts', async () => { + expect( + await accountSupports7702( + ADDRESS_MOCK, + keyringControllerWithType('Simple Key Pair'), + ), + ).toBe(true); + }); + + it('returns true for money keyring accounts', async () => { + // Sponsored Money Account transactions (e.g. Monad withdrawals) are + // externally signed and must publish via the EIP-7702 relay; treating the + // money keyring as unsupported skipped the relay hook and raw-sent an + // unsigned payload. + expect( + await accountSupports7702( + ADDRESS_MOCK, + keyringControllerWithType('Money Keyring'), + ), + ).toBe(true); + }); + + it('returns false for hardware keyring accounts', async () => { + expect( + await accountSupports7702( + ADDRESS_MOCK, + keyringControllerWithType('Ledger Hardware'), + ), + ).toBe(false); + }); + + it('returns true when the address is missing', async () => { + expect( + await accountSupports7702(undefined, keyringControllerWithType('any')), + ).toBe(true); + }); + + it('returns true when the keyring lookup fails', async () => { + expect( + await accountSupports7702(ADDRESS_MOCK, { + getKeyringForAccount: jest.fn().mockRejectedValue(new Error('nope')), + }), + ).toBe(true); + }); +}); diff --git a/app/scripts/lib/account-supports-7702.ts b/app/scripts/lib/account-supports-7702.ts index 45c94f398c70..cb109dc57128 100644 --- a/app/scripts/lib/account-supports-7702.ts +++ b/app/scripts/lib/account-supports-7702.ts @@ -1,5 +1,5 @@ import { KeyringControllerGetKeyringForAccountAction } from '@metamask/keyring-controller'; -import { KEYRING_TYPES_SUPPORTING_7702 } from '../../../shared/constants/keyring'; +import { KEYRING_TYPES_SUPPORTING_7702_RELAY } from '../../../shared/constants/keyring'; import { RootMessenger } from './messenger'; /** Minimal shape; KeyringController.getKeyringForAccount is typed as Promise. */ @@ -49,7 +49,7 @@ export async function accountSupports7702( typeof (keyring as { type: unknown }).type === 'string' ? (keyring as { type: string }).type : ''; - return KEYRING_TYPES_SUPPORTING_7702.includes(keyringType as never); + return KEYRING_TYPES_SUPPORTING_7702_RELAY.includes(keyringType as never); } catch { return true; } diff --git a/app/scripts/lib/money/pay/account-override.ts b/app/scripts/lib/money/pay/account-override.ts index ad11e13abd15..09d664e8dc5e 100644 --- a/app/scripts/lib/money/pay/account-override.ts +++ b/app/scripts/lib/money/pay/account-override.ts @@ -19,14 +19,13 @@ import { getSelectedAccount, type MoneyPayMessenger } from './pay-context'; * funding account to quote against. `isQuoteRequired` is set for deposits so * Pay always fetches a quote even when the source and target tokens match. * - * Skips non-EVM selected accounts, matching mobile. Two mobile branches are - * deliberately not ported: the Card-link approve discriminator (the extension - * has no Card product), and the nested-transaction address replacement for - * withdrawals (the extension's placeholder batches carry no calldata, and the - * withdraw commit path re-resolves the recipient from the selected account on - * every amount commit). Mobile's eager balance refresh is also skipped: the - * override is the selected account, whose balances the extension already - * polls while the UI is open. + * Skips non-EVM selected accounts, matching mobile. The Card-link approve + * discriminator is not ported (the extension has no Card product). Nested + * address replacement on add is skipped because the extension placeholder + * has no calldata — `FromAccountRow` rewrites encoded nested data when the + * user later changes account, matching mobile `PayAccountSelector`. Mobile's + * eager balance refresh is also skipped: the override is the selected + * account, whose balances the extension already polls while the UI is open. * * @param controller - The Pay controller to seed. * @param messenger - The messenger to resolve the selected account through. diff --git a/app/scripts/lib/money/pay/update-withdraw-amount.test.ts b/app/scripts/lib/money/pay/update-withdraw-amount.test.ts index ba0b02e1801f..f2bab39e6132 100644 --- a/app/scripts/lib/money/pay/update-withdraw-amount.test.ts +++ b/app/scripts/lib/money/pay/update-withdraw-amount.test.ts @@ -95,8 +95,12 @@ describe('updateMoneyAccountWithdrawAmount', () => { AMOUNT_HUMAN, ); - expect(result).toBe(true); const meta = committed.meta as TransactionMeta; + expect(result).toEqual({ + withdrawData: meta.nestedTransactions?.[0].data, + transferData: meta.nestedTransactions?.[1].data, + transactionData: meta.txParams.data, + }); const withdraw = TELLER_INTERFACE.decodeFunctionData( 'withdraw', @@ -105,7 +109,6 @@ describe('updateMoneyAccountWithdrawAmount', () => { expect(withdraw.withdrawAsset.toLowerCase()).toBe( MUSD_ADDRESS.toLowerCase(), ); - // Shares are ceil(amount * ONE_SHARE / rate) at the mocked vault rate. expect(withdraw.shareAmount.toBigInt()).toBe( (AMOUNT_ROUNDED_DOWN * 1_000_000n + VAULT_RATE_MOCK - 1n) / VAULT_RATE_MOCK, @@ -119,6 +122,7 @@ describe('updateMoneyAccountWithdrawAmount', () => { expect(transfer.recipient.toLowerCase()).toBe(RECIPIENT_MOCK); expect(transfer.amount.toBigInt()).toBe(AMOUNT_ROUNDED_DOWN); + expect(meta.type).toBe(TransactionType.moneyAccountWithdraw); expect(meta.txParams.gas).toBeUndefined(); expect(meta.simulationData).toBeUndefined(); }); @@ -152,6 +156,22 @@ describe('updateMoneyAccountWithdrawAmount', () => { ); }); + it('encodes when nested slots exist without money-account types', async () => { + const transaction = buildTemplateTransaction(); + delete transaction.nestedTransactions?.[0].type; + delete transaction.nestedTransactions?.[1].type; + const { messenger, committed } = setup(transaction); + + const result = await updateMoneyAccountWithdrawAmount( + messenger, + transaction.id, + AMOUNT_HUMAN, + ); + + expect(result).not.toBe(false); + expect(committed.meta?.nestedTransactions?.[1].data).toBeDefined(); + }); + it('rejects when no recipient account resolves', async () => { const transaction = buildTemplateTransaction(); const { messenger } = setup(transaction, { @@ -233,6 +253,63 @@ describe('updateMoneyAccountWithdrawAmount', () => { ); expect(second).toBe(first); - await expect(first).resolves.toBe(true); + await expect(first).resolves.toEqual( + expect.objectContaining({ + transferData: expect.any(String), + withdrawData: expect.any(String), + }), + ); + }); + + it('forwards mUSD to the recipient override instead of the selected account', async () => { + const transaction = buildTemplateTransaction(); + const { messenger, committed } = setup(transaction); + const recipientOverride = + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; + + await updateMoneyAccountWithdrawAmount( + messenger, + transaction.id, + AMOUNT_HUMAN, + recipientOverride, + ); + + const meta = committed.meta as TransactionMeta; + const transfer = ERC20_INTERFACE.decodeFunctionData( + 'transfer', + meta.nestedTransactions?.[1].data as string, + ); + expect(transfer.recipient.toLowerCase()).toBe( + recipientOverride.toLowerCase(), + ); + }); + + it('does not share the promise when the recipient override differs', async () => { + const transaction = buildTemplateTransaction(); + const { messenger } = setup(transaction); + const firstRecipient = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; + const secondRecipient = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex; + + const first = updateMoneyAccountWithdrawAmount( + messenger, + transaction.id, + AMOUNT_HUMAN, + firstRecipient, + ); + const second = updateMoneyAccountWithdrawAmount( + messenger, + transaction.id, + AMOUNT_HUMAN, + secondRecipient, + ); + + expect(second).not.toBe(first); + await expect(first).resolves.toBe(false); + await expect(second).resolves.toEqual( + expect.objectContaining({ + transferData: expect.any(String), + withdrawData: expect.any(String), + }), + ); }); }); diff --git a/app/scripts/lib/money/pay/update-withdraw-amount.ts b/app/scripts/lib/money/pay/update-withdraw-amount.ts index 569d0141c40b..8dfc6d210f81 100644 --- a/app/scripts/lib/money/pay/update-withdraw-amount.ts +++ b/app/scripts/lib/money/pay/update-withdraw-amount.ts @@ -24,7 +24,16 @@ import { const UPDATE_ERROR_PREFIX = 'Update Amount: Money Account Withdrawal: '; -const amountUpdates = new Map>(); +export type MoneyAccountWithdrawAmountUpdate = { + transactionData?: Hex; + transferData: Hex; + withdrawData: Hex; +}; + +const amountUpdates = new Map< + string, + AmountUpdateIntent +>(); function failUpdate(message: string): never { throw new Error(`${UPDATE_ERROR_PREFIX}${message}`); @@ -37,11 +46,12 @@ function failUpdate(message: string): never { * @param transaction - The transaction to validate. */ function validateTransactionTemplate(transaction: TransactionMeta): void { + // Require the two nested slots only. `addTransactionBatch` can drop nested + // types on the unapproved parent, and a type check here made every encode + // throw — Send then no-op'd because confirm swallowed that error. if ( - transaction.nestedTransactions?.[0]?.type !== - TransactionType.moneyAccountWithdraw || - transaction.nestedTransactions[1]?.type !== - TransactionType.tokenMethodTransfer + !transaction.nestedTransactions?.[0] || + !transaction.nestedTransactions[1] ) { failUpdate('missing withdraw/transfer transaction template'); } @@ -52,7 +62,8 @@ async function updateMoneyAccountWithdrawAmountInternal( transaction: TransactionMeta, amountHuman: string, isCurrentIntent: () => boolean, -): Promise { + recipientOverride?: Hex, +): Promise { validateTransactionTemplate(transaction); const chainId = transaction.chainId as Hex; @@ -79,19 +90,26 @@ async function updateMoneyAccountWithdrawAmountInternal( return false; } - // The redeemed mUSD is forwarded to the user's currently selected account, - // resolved at commit time — the same rule as mobile's `selectEvmAddress` - // default. Validated rather than cast: the selected account is global - // state the user can change mid-flow, including to a non-EVM account whose - // address would otherwise reach the ABI encoder as a bogus recipient. - const selectedAccount = getSelectedAccount(messenger); - if (!selectedAccount) { - failUpdate('missing recipient account'); - } - if (!isEvmAccountType(selectedAccount.type)) { - failUpdate('selected account is not an EVM account'); + // Mobile: `recipientOverride ?? selectEvmAddress`. The From-row override is + // the user's EVM account; `txParams.from` is the money account and must not + // receive the redeemed mUSD. Fall back to the selected account when no + // override is set, resolved at commit time. + // + // The fallback is validated rather than cast: the selected account is + // global state the user can change mid-flow, including to a non-EVM + // account whose address would otherwise reach the ABI encoder as a bogus + // recipient. + let recipient: string | undefined = recipientOverride; + if (!recipient) { + const selectedAccount = getSelectedAccount(messenger); + if (!selectedAccount) { + failUpdate('missing recipient account'); + } + if (!isEvmAccountType(selectedAccount.type)) { + failUpdate('selected account is not an EVM account'); + } + recipient = selectedAccount.address; } - const recipient = selectedAccount.address; if (!isStrictHexString(recipient)) { failUpdate('invalid recipient address'); } @@ -116,6 +134,7 @@ async function updateMoneyAccountWithdrawAmountInternal( failUpdate('incomplete withdraw/transfer updates'); } + let transactionData: Hex | undefined; messenger.call('TransactionController:updateTransactionMetadata', { transactionId: transaction.id, skipResimulate: true, @@ -133,7 +152,7 @@ async function updateMoneyAccountWithdrawAmountInternal( failUpdate('transaction chain changed during preparation'); } - const { nestedTransactions, transactionData } = updateEIP7702BatchData({ + const updated = updateEIP7702BatchData({ from: transactionMeta.txParams.from as Hex, transactions: transactionMeta.nestedTransactions ?? [], updates: [ @@ -141,37 +160,49 @@ async function updateMoneyAccountWithdrawAmountInternal( { transactionIndex: 1, transactionData: transferData }, ], }); + transactionData = updated.transactionData; - transactionMeta.nestedTransactions = nestedTransactions; - transactionMeta.txParams.data = transactionData; + transactionMeta.type = TransactionType.moneyAccountWithdraw; + transactionMeta.nestedTransactions = updated.nestedTransactions; + transactionMeta.txParams.data = updated.transactionData; resetTransactionEstimates(transactionMeta); }, }); - return true; + const committed = findTransaction( + messenger, + ({ id }) => id === transaction.id, + ); + if (!committed) { + failUpdate('transaction missing after commit'); + } + + // Return the hexes, not the full TransactionMeta. The UI background bridge + // often strips nested calldata, which made Send treat a successful encode + // as "not funded" and no-op. + return { withdrawData, transferData, transactionData }; } /** * Prepares and atomically commits a Money Account withdrawal amount: - * re-encodes the withdraw + transfer calldata for the new amount (which needs - * the vault rate for the share conversion) and writes it into the transaction - * in one `updateTransactionMetadata` call. - * - * The concurrency contract mirrors `updateMoneyAccountDepositAmount`: - * identical in-flight intents share a promise, and a newer intent for the - * same transaction prevents an older preparation from committing stale - * calldata — the superseded call resolves `false`. + * re-encodes the withdraw + transfer calldata and writes both nested calls + * in one `updateTransactionMetadata` so confirm can approve the returned + * transaction instead of the empty placeholder. * * @param messenger - The messenger to resolve and commit through. * @param transactionId - Id of the Money Account withdrawal transaction. * @param amountHuman - Exact human-readable amount. - * @returns Whether this intent committed transaction metadata. + * @param recipientOverride - Optional EVM address to receive the redeemed mUSD. + * When omitted, defaults to the currently selected account. + * @returns The encoded nested calldata, or `false` if this intent did not + * commit (zero amount or superseded). */ export function updateMoneyAccountWithdrawAmount( messenger: MoneyPayMessenger, transactionId: string, amountHuman: string, -): Promise { + recipientOverride?: Hex, +): Promise { const transaction = findTransaction( messenger, ({ id }) => id === transactionId, @@ -180,7 +211,11 @@ export function updateMoneyAccountWithdrawAmount( failUpdate('transaction not found'); } - const intentKey = JSON.stringify({ amountHuman, transactionId }); + const intentKey = JSON.stringify({ + amountHuman, + recipientOverride: recipientOverride?.toLowerCase() ?? null, + transactionId, + }); return runSingleFlightAmountUpdate( amountUpdates, @@ -192,6 +227,7 @@ export function updateMoneyAccountWithdrawAmount( transaction, amountHuman, isCurrentIntent, + recipientOverride, ), ); } diff --git a/app/scripts/lib/transaction/delegation.test.ts b/app/scripts/lib/transaction/delegation.test.ts index ce47bd1c0053..67d39b55defd 100644 --- a/app/scripts/lib/transaction/delegation.test.ts +++ b/app/scripts/lib/transaction/delegation.test.ts @@ -291,6 +291,56 @@ describe('delegation', () => { ); }); + it('uses the parent txParams execution when useParentExecution is set, even with nestedTransactions', async () => { + // Mirrors the mobile publish hook: sponsored Money Account withdrawals + // must relay the parent `execute()` as a single execution — redeeming + // the nested calls directly mined on Monad without moving funds. + const transaction = { + ...TRANSACTION_META_MOCK, + nestedTransactions: [ + { + to: '0x1111111111111111111111111111111111111111', + value: '0x2', + data: '0xaaaa', + }, + { + to: '0x2222222222222222222222222222222222222222', + value: '0x3', + data: '0xbbbb', + }, + ], + } as unknown as TransactionMeta; + + await convertTransactionToRedeemDelegations({ + transaction, + messenger, + useParentExecution: true, + }); + + expect(createExactExecutionTermsMock).toHaveBeenCalledWith({ + execution: { + target: TRANSACTION_META_MOCK.txParams.to, + value: 256n, + callData: '0xdeadbeef', + }, + }); + expect(createExactExecutionBatchTermsMock).not.toHaveBeenCalled(); + + expect(encodeRedeemDelegationsMock).toHaveBeenCalledWith( + expect.objectContaining({ + executions: [ + [ + { + target: TRANSACTION_META_MOCK.txParams.to, + value: 256n, + callData: '0xdeadbeef', + }, + ], + ], + }), + ); + }); + it('normalizes nestedTransactions callData', async () => { const transaction = { ...TRANSACTION_META_MOCK, diff --git a/app/scripts/lib/transaction/delegation.ts b/app/scripts/lib/transaction/delegation.ts index 3dcf34522bc4..cf04197eb800 100644 --- a/app/scripts/lib/transaction/delegation.ts +++ b/app/scripts/lib/transaction/delegation.ts @@ -124,6 +124,16 @@ type ConvertTransactionToRedeemDelegationsRequest = { * of the 7702 batch and caveats that leave the order-id placeholder free. */ isSubsidized?: boolean; + + /** + * When true, build a single execution from the parent `txParams` (`to` / + * `data`) even when `nestedTransactions` exist. Matches the mobile publish + * hook: for 7702 batches the parent `execute()` calldata is the canonical + * payload — redeeming the nested calls directly is a shape mobile never + * publishes and it does not move funds on-chain (e.g. sponsored Money + * Account withdrawals on Monad). + */ + useParentExecution?: boolean; }; type ConvertTransactionToRedeemDelegationsResult = { @@ -173,7 +183,7 @@ export async function convertTransactionToRedeemDelegations( const defaultExecutions = isSubsidized ? buildSubsidizedExecutions(transaction) - : getDefaultTransactionExecutions(transaction); + : getDefaultTransactionExecutions(transaction, request.useParentExecution); const additionalExecutions = isSubsidized ? [] @@ -278,10 +288,12 @@ function hasExecutableNestedTransactions( function getDefaultTransactionExecutions( transactionMeta: TransactionMeta, + useParentExecution = false, ): ExecutionStruct[] { const { nestedTransactions, txParams } = transactionMeta; if ( + !useParentExecution && nestedTransactions?.length && hasExecutableNestedTransactions(transactionMeta) ) { diff --git a/app/scripts/lib/transaction/hooks/delegation-7702-publish.test.ts b/app/scripts/lib/transaction/hooks/delegation-7702-publish.test.ts index 1ec127a16c44..e2152602bce5 100644 --- a/app/scripts/lib/transaction/hooks/delegation-7702-publish.test.ts +++ b/app/scripts/lib/transaction/hooks/delegation-7702-publish.test.ts @@ -222,6 +222,31 @@ describe('Delegation 7702 Publish Hook', () => { }); }); + it('throws for a sponsored transaction when EIP-7702 is not supported', async () => { + // Sponsored transactions skip local signing; a silent skip here would + // raw-send an unsigned payload ("Transaction decoding error"). + const upgradedElsewhere = '0x12345678901234567890123456789012345678ff'; + isAtomicBatchSupportedMock.mockResolvedValueOnce([ + { + chainId: TRANSACTION_META_MOCK.chainId, + delegationAddress: upgradedElsewhere as never, + isSupported: false, + }, + ]); + + await expect( + hookClass.getHook()( + { + ...TRANSACTION_META_MOCK, + isGasFeeSponsored: true, + }, + SIGNED_TX_MOCK, + ), + ).rejects.toThrow( + 'Chain must support EIP-7702 for sponsored or gas included transaction', + ); + }); + it('no gas fee tokens', async () => { isAtomicBatchSupportedMock.mockResolvedValueOnce([ { @@ -507,6 +532,59 @@ describe('Delegation 7702 Publish Hook', () => { expect(signArgs.delegation.caveats).toHaveLength(2); }); + it('relays the parent execute as a single execution for sponsored batches with nested calls', async () => { + // Money Account withdrawals are `[withdraw, transfer]` batches sponsored + // on Monad. Same as mobile: the delegation must enforce the parent + // `execute()` (single ExactExecution), not a batch of the nested calls — + // the batch shape mined on-chain without moving funds. + isAtomicBatchSupportedMock.mockResolvedValueOnce([ + { + chainId: TRANSACTION_META_MOCK.chainId, + delegationAddress: UPGRADE_CONTRACT_ADDRESS_MOCK, + isSupported: true, + upgradeContractAddress: UPGRADE_CONTRACT_ADDRESS_MOCK, + }, + ]); + + await hookClass.getHook()( + { + ...TRANSACTION_META_MOCK, + type: TransactionType.batch, + isGasFeeSponsored: true, + txParams: { + ...TRANSACTION_META_MOCK.txParams, + data: '0xdeadbeef', + }, + nestedTransactions: [ + { + to: '0x1111111111111111111111111111111111111111', + data: '0xaaaa', + }, + { + to: '0x2222222222222222222222222222222222222222', + data: '0xbbbb', + }, + ], + } as unknown as TransactionMeta, + SIGNED_TX_MOCK, + ); + + expect(submitRelayTransactionMock).toHaveBeenCalledTimes(1); + expect(signDelegationControllerMock).toHaveBeenCalledTimes(1); + + const { caveatEnforcers } = getDeleGatorEnvironment( + parseInt(TRANSACTION_META_MOCK.chainId, 16), + ); + const signArgs = signDelegationControllerMock.mock.calls[0][0]; + const enforcers = signArgs.delegation.caveats.map( + (caveat: { enforcer: string }) => caveat.enforcer, + ); + expect(enforcers).toContain(caveatEnforcers.ExactExecutionEnforcer); + expect(enforcers).not.toContain( + caveatEnforcers.ExactExecutionBatchEnforcer, + ); + }); + it('signs delegation for gasless 7702 swap without gas fee tokens', async () => { isAtomicBatchSupportedMock.mockResolvedValueOnce([ { diff --git a/app/scripts/lib/transaction/hooks/delegation-7702-publish.ts b/app/scripts/lib/transaction/hooks/delegation-7702-publish.ts index 459dc317fad5..0c2e856cc425 100644 --- a/app/scripts/lib/transaction/hooks/delegation-7702-publish.ts +++ b/app/scripts/lib/transaction/hooks/delegation-7702-publish.ts @@ -99,14 +99,28 @@ export class Delegation7702PublishHook { const { isSupported, delegationAddress, upgradeContractAddress } = checkEip7702Support(atomicBatchChainSupport); + const isGaslessSwap = transactionMeta.isGasFeeIncluded; + + const isSponsored = Boolean(transactionMeta.isGasFeeSponsored); + if (!isSupported) { log('Skipping as EIP-7702 is not supported', { from, chainId }); - return EMPTY_RESULT; - } - const isGaslessSwap = transactionMeta.isGasFeeIncluded; + if (isGaslessSwap || isSponsored) { + // Same as mobile: sponsored and gas-included transactions skip local + // signing, so falling through to the default publish would raw-send + // an unsigned payload ("Transaction decoding error"). Fail loudly. + throw new Error( + `Chain must support EIP-7702 for sponsored or gas included transaction. chainId: ${chainId}, delegationAddress: ${ + atomicBatchChainSupport?.delegationAddress ?? 'none' + }, upgradeContractAddress: ${ + atomicBatchChainSupport?.upgradeContractAddress ?? 'none' + }, entryFound: ${Boolean(atomicBatchChainSupport)}`, + ); + } - const isSponsored = Boolean(transactionMeta.isGasFeeSponsored); + return EMPTY_RESULT; + } if ( (!selectedGasFeeToken || !gasFeeTokens?.length) && @@ -163,6 +177,11 @@ export class Delegation7702PublishHook { upgradeContractAddress: (upgradeContractAddress as Hex) ?? undefined, }, + // Same as mobile's publish hook: relay the parent `execute()` as a + // single execution. Expanding `nestedTransactions` into a batch + // redeem is a shape mobile never publishes — on Monad it mined + // without moving funds for Money Account withdrawals. + useParentExecution: true, }); const relayRequest: RelaySubmitRequest = { 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 113b12af7041..ab3df4674f76 100644 --- a/app/scripts/messenger-client-init/transaction-pay-controller-init.ts +++ b/app/scripts/messenger-client-init/transaction-pay-controller-init.ts @@ -90,11 +90,13 @@ function getApi( updateMoneyAccountWithdrawAmount: ( transactionId: string, amountHuman: string, + recipientOverride?: Hex, ) => updateMoneyAccountWithdrawAmount( moneyPayMessenger, transactionId, amountHuman, + recipientOverride, ), updateMoneyAccountDepositAmount: ( transactionId: string, diff --git a/shared/constants/keyring.ts b/shared/constants/keyring.ts index ff54f05a9397..a5055bd26626 100644 --- a/shared/constants/keyring.ts +++ b/shared/constants/keyring.ts @@ -37,3 +37,19 @@ export const KEYRING_TYPES_SUPPORTING_7702 = [ KeyringTypes.hd, KeyringTypes.simple, ]; + +/** + * Keyring types whose transactions can publish via the EIP-7702 relay + * (sentinel). Extends the Smart Account list with the Money keyring: sponsored + * Money Account transactions (e.g. withdrawals on Monad) are marked + * externally-signed and must publish through the relay — without the money + * keyring here the relay hook is skipped and an unsigned payload reaches + * `eth_sendRawTransaction` ("Transaction decoding error"). Mirrors mobile's + * `KEYRING_TYPES_SUPPORTING_7702`, which includes `ExtendedKeyringTypes.money` + * for its transaction publish gate only. + */ +export const KEYRING_TYPES_SUPPORTING_7702_RELAY = [ + KeyringTypes.hd, + KeyringTypes.simple, + KeyringTypes.money, +]; diff --git a/ui/pages/confirmations/components/activity/transaction-details-hero/transaction-details-hero.test.tsx b/ui/pages/confirmations/components/activity/transaction-details-hero/transaction-details-hero.test.tsx index b14a3ad77abc..39fadee6d8e8 100644 --- a/ui/pages/confirmations/components/activity/transaction-details-hero/transaction-details-hero.test.tsx +++ b/ui/pages/confirmations/components/activity/transaction-details-hero/transaction-details-hero.test.tsx @@ -1,6 +1,9 @@ import React from 'react'; import configureMockStore from 'redux-mock-store'; -import { TransactionStatus } from '@metamask/transaction-controller'; +import { + TransactionStatus, + TransactionType, +} from '@metamask/transaction-controller'; import { renderWithProvider } from '../../../../../../test/lib/render-helpers-navigate'; import { TransactionDetailsProvider } from '../transaction-details-context'; import { TransactionDetailsHero } from './transaction-details-hero'; @@ -16,24 +19,48 @@ const mockState = { }, }; -function createMockTransactionMeta(targetFiat?: string) { +function createMockTransactionMeta({ + targetFiat, + type, + nestedTransactions, +}: { + targetFiat?: string; + type?: TransactionType; + nestedTransactions?: { data?: string; type?: TransactionType }[]; +} = {}) { return { id: 'test-id', chainId: '0x1', status: TransactionStatus.confirmed, time: Date.now(), + type, txParams: { from: '0x123', to: '0x456', }, + nestedTransactions, metamaskPay: targetFiat ? { targetFiat } : undefined, }; } -function render(targetFiat?: string) { +function render({ + targetFiat, + type, + nestedTransactions, +}: { + targetFiat?: string; + type?: TransactionType; + nestedTransactions?: { data?: string; type?: TransactionType }[]; +} = {}) { return renderWithProvider( , @@ -43,7 +70,7 @@ function render(targetFiat?: string) { describe('TransactionDetailsHero', () => { it('renders formatted fiat amount when targetFiat is provided', () => { - const { getByTestId, getByText } = render('100.50'); + const { getByTestId, getByText } = render({ targetFiat: '100.50' }); expect(getByTestId('transaction-details-hero')).toBeInTheDocument(); // metamaskPay fiat values are USD; override currency so BRL preference does not show R$ expect(getByText(/\$100[.,]50/u)).toBeInTheDocument(); @@ -55,7 +82,38 @@ describe('TransactionDetailsHero', () => { }); it('returns null when targetFiat is zero', () => { - const { container } = render('0'); + const { container } = render({ targetFiat: '0' }); + expect(container.firstChild).toBeNull(); + }); + + it('renders the nested transfer amount for a money account withdraw when targetFiat is missing', () => { + const { getByTestId, getByText } = render({ + type: TransactionType.moneyAccountWithdraw, + nestedTransactions: [ + { type: TransactionType.moneyAccountWithdraw, data: '0xwithdraw' }, + { + type: TransactionType.tokenMethodTransfer, + data: '0xa9059cbb0000000000000000000000002222222222222222222222222222222222222222000000000000000000000000000000000000000000000000000000000000c350', + }, + ], + }); + + expect(getByTestId('transaction-details-hero')).toBeInTheDocument(); + expect(getByText('0.05 mUSD')).toBeInTheDocument(); + }); + + it('returns null for a money account withdraw whose nested transfer amount is zero', () => { + const { container } = render({ + type: TransactionType.moneyAccountWithdraw, + nestedTransactions: [ + { type: TransactionType.moneyAccountWithdraw, data: '0xwithdraw' }, + { + type: TransactionType.tokenMethodTransfer, + data: '0xa9059cbb00000000000000000000000022222222222222222222222222222222222222220000000000000000000000000000000000000000000000000000000000000000', + }, + ], + }); + expect(container.firstChild).toBeNull(); }); }); diff --git a/ui/pages/confirmations/components/activity/transaction-details-hero/transaction-details-hero.tsx b/ui/pages/confirmations/components/activity/transaction-details-hero/transaction-details-hero.tsx index a382a7098ec3..98d45a45f6db 100644 --- a/ui/pages/confirmations/components/activity/transaction-details-hero/transaction-details-hero.tsx +++ b/ui/pages/confirmations/components/activity/transaction-details-hero/transaction-details-hero.tsx @@ -1,4 +1,7 @@ import React, { useMemo } from 'react'; +import { TransactionType } from '@metamask/transaction-controller'; +import { MUSD_DECIMALS, MUSD_TOKEN } from '@metamask/money-account-utils'; +import { BigNumber } from 'bignumber.js'; import { Text, Box } from '../../../../../components/component-library'; import { Display, @@ -6,21 +9,54 @@ import { TextVariant, } from '../../../../../helpers/constants/design-system'; import { useFiatFormatter } from '../../../../../hooks/useFiatFormatter'; +import { parseStandardTokenTransactionData } from '../../../../../../shared/lib/transaction.utils'; +import { hasTransactionType } from '../../../../../../shared/lib/transactions.utils'; import { useTransactionDetails } from '../transaction-details-context'; +function getWithdrawTransferAmountHuman(transactionMeta: { + nestedTransactions?: { data?: string; type?: string }[]; +}): string | undefined { + const transfer = transactionMeta.nestedTransactions?.find( + (nested) => nested.type === TransactionType.tokenMethodTransfer, + ); + if (!transfer?.data) { + return undefined; + } + const parsed = parseStandardTokenTransactionData(transfer.data); + const value = parsed?.args?._value ?? parsed?.args?.value; + if (value === undefined || value === null) { + return undefined; + } + const amount = new BigNumber(value.toString()).dividedBy( + new BigNumber(10).pow(MUSD_DECIMALS), + ); + if (amount.isZero()) { + return undefined; + } + return `${amount.toFixed()} ${MUSD_TOKEN.symbol}`; +} + export function TransactionDetailsHero() { const { transactionMeta } = useTransactionDetails(); const fiatFormatter = useFiatFormatter({ overrideCurrency: 'usd' }); const { metamaskPay } = transactionMeta; const { targetFiat } = metamaskPay || {}; + const isMoneyAccountWithdraw = hasTransactionType(transactionMeta, [ + TransactionType.moneyAccountWithdraw, + ]); const formattedAmount = useMemo(() => { - if (!targetFiat || targetFiat === '0') { - return null; + if (targetFiat && targetFiat !== '0') { + return fiatFormatter(Number(targetFiat)); } - return fiatFormatter(Number(targetFiat)); - }, [fiatFormatter, targetFiat]); + // Direct withdraws have no quotes, so targetFiat stays 0. Show the + // nested transfer amount instead of an empty / $0 hero. + if (isMoneyAccountWithdraw) { + return getWithdrawTransferAmountHuman(transactionMeta); + } + return null; + }, [fiatFormatter, isMoneyAccountWithdraw, targetFiat, transactionMeta]); if (!formattedAmount) { return null; diff --git a/ui/pages/confirmations/components/confirm/footer/footer.test.tsx b/ui/pages/confirmations/components/confirm/footer/footer.test.tsx index 609fe934f30b..f561780b3bb7 100644 --- a/ui/pages/confirmations/components/confirm/footer/footer.test.tsx +++ b/ui/pages/confirmations/components/confirm/footer/footer.test.tsx @@ -77,6 +77,7 @@ jest.mock('../../../hooks/pay/useTransactionPayData', () => ({ useTransactionPayPrimaryRequiredToken: jest.fn(() => undefined), useTransactionPayQuotes: jest.fn(() => undefined), useTransactionPayRequiredTokens: jest.fn(() => []), + useTransactionPayTotals: jest.fn(() => undefined), })); jest.mock( '../../../../../components/app/product-safety/scam-questionnaire/useScamQuestionnaireMetrics', diff --git a/ui/pages/confirmations/components/confirm/footer/single-action-footer.test.tsx b/ui/pages/confirmations/components/confirm/footer/single-action-footer.test.tsx index 3d112e8a153c..34be77ff2adf 100644 --- a/ui/pages/confirmations/components/confirm/footer/single-action-footer.test.tsx +++ b/ui/pages/confirmations/components/confirm/footer/single-action-footer.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { fireEvent } from '@testing-library/react'; +import { act, fireEvent } from '@testing-library/react'; import { TransactionType } from '@metamask/transaction-controller'; import { getMockConfirmStateForTransaction } from '../../../../../../test/data/confirmations/helper'; import { genUnapprovedContractInteractionConfirmation } from '../../../../../../test/data/confirmations/contract-interaction'; @@ -11,11 +11,14 @@ import { useIsTransactionPayQuotePending, useTransactionPayHasExecutableQuote, useTransactionPayPrimaryRequiredToken, + useTransactionPayTotals, } from '../../../hooks/pay/useTransactionPayData'; import * as confirmContext from '../../../context/confirm'; +import { useLastMoneyAccountWithdrawAmount } from '../../../hooks/transactions/useLastMoneyAccountWithdrawAmount'; import { SingleActionFooter } from './single-action-footer'; jest.mock('../../../hooks/pay/useTransactionPayData'); +jest.mock('../../../hooks/transactions/useLastMoneyAccountWithdrawAmount'); jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), @@ -112,6 +115,8 @@ describe('', () => { jest.resetAllMocks(); jest.mocked(useIsTransactionPayQuotePending).mockReturnValue(false); jest.mocked(useTransactionPayHasExecutableQuote).mockReturnValue(true); + jest.mocked(useTransactionPayTotals).mockReturnValue(undefined); + jest.mocked(useLastMoneyAccountWithdrawAmount).mockReturnValue(undefined); jest.mocked(useTransactionPayPrimaryRequiredToken).mockReturnValue({ amountUsd: '10.00', skipIfBalance: false, @@ -124,10 +129,12 @@ describe('', () => { expect(getByTestId('confirm-footer-button')).toBeInTheDocument(); }); - it('calls onSubmit when button is clicked', () => { + it('calls onSubmit when button is clicked', async () => { const { getByTestId } = render(); - fireEvent.click(getByTestId('confirm-footer-button')); + await act(async () => { + fireEvent.click(getByTestId('confirm-footer-button')); + }); expect(MOCK_ON_SUBMIT).toHaveBeenCalledTimes(1); }); @@ -138,6 +145,22 @@ describe('', () => { expect(getByTestId('confirm-footer-button')).toBeDisabled(); }); + it('does not keep Send loading for a withdraw when gasless tokens never arrive', () => { + jest.mocked(useLastMoneyAccountWithdrawAmount).mockReturnValue('0.05'); + jest + .mocked(useTransactionPayPrimaryRequiredToken) + .mockReturnValue(undefined); + + const { getByTestId } = render({ + confirmation: genMoneyAccountWithdraw(), + isGaslessLoading: true, + }); + + const button = getByTestId('confirm-footer-button'); + expect(button).toBeEnabled(); + expect(button).not.toHaveAttribute('aria-busy', 'true'); + }); + it('disables the button when pay token data is loading', () => { jest.mocked(useIsTransactionPayQuotePending).mockReturnValue(true); @@ -230,6 +253,49 @@ describe('', () => { expect(getByTestId('confirm-footer-button')).toBeDisabled(); }); + it('enables button when amountUsd is zero but amountHuman is committed', () => { + jest.mocked(useTransactionPayPrimaryRequiredToken).mockReturnValue({ + amountUsd: '0', + amountHuman: '0.05', + skipIfBalance: false, + } as ReturnType); + + const { getByTestId } = render(); + + expect(getByTestId('confirm-footer-button')).toBeEnabled(); + }); + + it('disables button when amount is zero even if totals exist at zero', () => { + jest.mocked(useTransactionPayPrimaryRequiredToken).mockReturnValue({ + amountUsd: '0', + amountHuman: '0', + amountRaw: '0', + skipIfBalance: false, + } as ReturnType); + jest.mocked(useTransactionPayTotals).mockReturnValue({ + targetAmount: { usd: '0' }, + sourceAmount: { usd: '0' }, + } as ReturnType); + + const { getByTestId } = render(); + + expect(getByTestId('confirm-footer-button')).toBeDisabled(); + }); + + it('enables button when amountUsd is zero but quote totals are positive', () => { + jest.mocked(useTransactionPayPrimaryRequiredToken).mockReturnValue({ + amountUsd: '0', + skipIfBalance: false, + } as ReturnType); + jest.mocked(useTransactionPayTotals).mockReturnValue({ + targetAmount: { usd: '0.05' }, + } as ReturnType); + + const { getByTestId } = render(); + + expect(getByTestId('confirm-footer-button')).toBeEnabled(); + }); + it('shows disabled loading when primary required token is not yet resolved', () => { jest .mocked(useTransactionPayPrimaryRequiredToken) @@ -310,10 +376,15 @@ describe('', () => { expect(getByTestId('confirm-footer-button')).toBeDisabled(); }); - it('submits perps withdrawal when an executable quote is ready', () => { + it('submits perps withdrawal when an executable quote is ready', async () => { const { getByTestId } = render({ confirmation: genPerpsWithdraw() }); - fireEvent.click(getByTestId('confirm-footer-button')); + // `handleSubmit` is async: it flips `isSubmitting` back in a `finally` + // that lands a microtask after the click, so the click must be awaited + // inside `act` for that update to be covered. + await act(async () => { + fireEvent.click(getByTestId('confirm-footer-button')); + }); expect(MOCK_ON_SUBMIT).toHaveBeenCalledTimes(1); }); @@ -326,6 +397,37 @@ describe('', () => { ); }); + it('enables Send for a withdraw when a positive amount was typed', () => { + jest.mocked(useLastMoneyAccountWithdrawAmount).mockReturnValue('0.05'); + jest.mocked(useTransactionPayPrimaryRequiredToken).mockReturnValue({ + amountUsd: '0', + skipIfBalance: false, + } as ReturnType); + + const { getByTestId } = render({ + confirmation: genMoneyAccountWithdraw(), + }); + + expect(getByTestId('confirm-footer-button')).toBeEnabled(); + expect(getByTestId('confirm-footer-button')).toHaveTextContent( + messages.perpsWithdraw.message, + ); + }); + + it('disables Send for a withdraw when the typed amount is zero', () => { + jest.mocked(useLastMoneyAccountWithdrawAmount).mockReturnValue('0'); + jest.mocked(useTransactionPayPrimaryRequiredToken).mockReturnValue({ + amountUsd: '0', + skipIfBalance: false, + } as ReturnType); + + const { getByTestId } = render({ + confirmation: genMoneyAccountWithdraw(), + }); + + expect(getByTestId('confirm-footer-button')).toBeDisabled(); + }); + it('disables the button while a money account amount commit is pending', () => { const confirmation = genMoneyAccountDeposit(); jest.spyOn(confirmContext, 'useConfirmContext').mockReturnValue({ @@ -338,6 +440,21 @@ describe('', () => { expect(getByTestId('confirm-footer-button')).toBeDisabled(); }); + it('enables Send for a withdraw without a required token once an amount is typed', () => { + jest.mocked(useLastMoneyAccountWithdrawAmount).mockReturnValue('0.05'); + jest + .mocked(useTransactionPayPrimaryRequiredToken) + .mockReturnValue(undefined); + + const { getByTestId } = render({ + confirmation: genMoneyAccountWithdraw(), + }); + + const button = getByTestId('confirm-footer-button'); + expect(button).toBeEnabled(); + expect(button).not.toHaveAttribute('aria-busy', 'true'); + }); + it('re-enables the button once the money account amount commit resolves', () => { const confirmation = genMoneyAccountDeposit(); jest.spyOn(confirmContext, 'useConfirmContext').mockReturnValue({ diff --git a/ui/pages/confirmations/components/confirm/footer/single-action-footer.tsx b/ui/pages/confirmations/components/confirm/footer/single-action-footer.tsx index 43bddaac112b..e34298b59991 100644 --- a/ui/pages/confirmations/components/confirm/footer/single-action-footer.tsx +++ b/ui/pages/confirmations/components/confirm/footer/single-action-footer.tsx @@ -1,6 +1,6 @@ import type { TransactionMeta } from '@metamask/transaction-controller'; import { TransactionType } from '@metamask/transaction-controller'; -import React, { useMemo } from 'react'; +import React, { useMemo, useState } from 'react'; import { BigNumber } from 'bignumber.js'; import { Button, ButtonSize } from '@metamask/design-system-react'; import { isPerpsWithdrawTransaction } from '../../../../../../shared/lib/transactions.utils'; @@ -13,7 +13,9 @@ import { useIsTransactionPayQuotePending, useTransactionPayHasExecutableQuote, useTransactionPayPrimaryRequiredToken, + useTransactionPayTotals, } from '../../../hooks/pay/useTransactionPayData'; +import { useLastMoneyAccountWithdrawAmount } from '../../../hooks/transactions/useLastMoneyAccountWithdrawAmount'; import { FlexDirection } from '../../../../../helpers/constants/design-system'; type ButtonState = { @@ -43,6 +45,10 @@ function useSingleActionButtonState(isGaslessLoading: boolean): ButtonState { const primaryRequiredToken = useTransactionPayPrimaryRequiredToken(); const isPayReady = !isPerpsWithdrawTransaction(currentConfirmation) || hasExecutableQuote; + const totals = useTransactionPayTotals(); + const lastWithdrawAmount = useLastMoneyAccountWithdrawAmount(transactionId); + const isMoneyAccountWithdraw = + transactionType === TransactionType.moneyAccountWithdraw; const blockingAlerts = useMemo( () => alerts.filter((a) => a.isBlocking), @@ -54,16 +60,21 @@ function useSingleActionButtonState(isGaslessLoading: boolean): ButtonState { (transactionType && BUTTON_TEXT_BY_TYPE[transactionType]) ?? 'confirm'; const defaultButtonText = t(i18nKey); - const isAwaitingRequiredToken = !primaryRequiredToken; + // Direct withdraws set `disablePay` and have no `requiredAssets`, so TPC + // never resolves a primary required token. Do not treat that as loading. + const isAwaitingRequiredToken = + !isMoneyAccountWithdraw && !primaryRequiredToken; const hasBlockingAlerts = blockingAlerts.length > 0; const firstAlert = blockingAlerts[0]; const alertText = firstAlert?.reason ?? (firstAlert?.message as string | undefined); - const hasAmount = primaryRequiredToken - ? new BigNumber(primaryRequiredToken.amountUsd ?? 0).gt(0) - : false; + // Withdrawals have no `requiredAssets` and often no quote totals (same- + // token mUSD). Enable from the last typed amount; $0 stays disabled. + const hasAmount = isMoneyAccountWithdraw + ? isPositiveAmount(lastWithdrawAmount) + : hasCommittedPayAmount(primaryRequiredToken, totals); const buttonText = !isAwaitingRequiredToken && hasBlockingAlerts && alertText @@ -77,24 +88,67 @@ function useSingleActionButtonState(isGaslessLoading: boolean): ButtonState { !isPayReady || isMoneyAccountAmountCommitPending; + // Direct withdraws do not fetch quotes and skip initial gas estimate. + // Stuck pay/gasless loading flags would keep Send spinning after the + // amount is already typed. const isLoading = - isAwaitingRequiredToken || isGaslessLoading || isPayLoading; + isAwaitingRequiredToken || + (isGaslessLoading && !isMoneyAccountWithdraw) || + (isPayLoading && !(isMoneyAccountWithdraw && !primaryRequiredToken)); return { buttonText, isDisabled, isLoading }; }, [ blockingAlerts, isGaslessLoading, isMoneyAccountAmountCommitPending, + isMoneyAccountWithdraw, isPayReady, isPayLoading, + lastWithdrawAmount, primaryRequiredToken, + totals, transactionType, t, ]); } +function isPositiveAmount(value: string | undefined): boolean { + if (!value) { + return false; + } + return new BigNumber(value).gt(0); +} + +function hasCommittedPayAmount( + primaryRequiredToken: ReturnType< + typeof useTransactionPayPrimaryRequiredToken + >, + totals: ReturnType, +): boolean { + if (!primaryRequiredToken) { + return false; + } + + if (isPositiveAmount(primaryRequiredToken.amountUsd)) { + return true; + } + + if (isPositiveAmount(primaryRequiredToken.amountHuman)) { + return true; + } + + if (isPositiveAmount(primaryRequiredToken.amountRaw)) { + return true; + } + + return ( + isPositiveAmount(totals?.targetAmount?.usd) || + isPositiveAmount(totals?.sourceAmount?.usd) + ); +} + type SingleActionFooterProps = { - onSubmit: () => void; + onSubmit: () => void | Promise; isGaslessLoading: boolean; }; @@ -104,6 +158,23 @@ export const SingleActionFooter = ({ }: SingleActionFooterProps) => { const { buttonText, isDisabled, isLoading } = useSingleActionButtonState(isGaslessLoading); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleSubmit = async () => { + if (isDisabled || isLoading || isSubmitting) { + return; + } + + setIsSubmitting(true); + try { + await onSubmit(); + } catch (error) { + console.error('Confirmation submit failed', error); + throw error; + } finally { + setIsSubmitting(false); + } + }; return ( {buttonText} diff --git a/ui/pages/confirmations/components/developer/money-account-withdraw-button/messenger.ts b/ui/pages/confirmations/components/developer/money-account-withdraw-button/messenger.ts new file mode 100644 index 000000000000..c50ef561d228 --- /dev/null +++ b/ui/pages/confirmations/components/developer/money-account-withdraw-button/messenger.ts @@ -0,0 +1,13 @@ +import { defineAllowedRouteCapabilities } from '../../../../../helpers/route-messenger-helpers'; +import type { RouteMessengerFromCapabilities } from '../../../../../messengers/route-messenger'; + +export const MONEY_ACCOUNT_WITHDRAW_BUTTON_ALLOWED_CAPABILITIES = + defineAllowedRouteCapabilities({ + actions: ['MoneyAccountAvailabilityService:getAvailability'], + events: [], + }); + +export type MoneyAccountWithdrawButtonMessenger = + RouteMessengerFromCapabilities< + typeof MONEY_ACCOUNT_WITHDRAW_BUTTON_ALLOWED_CAPABILITIES + >; diff --git a/ui/pages/confirmations/components/developer/money-account-withdraw-button/money-account-withdraw-button.test.tsx b/ui/pages/confirmations/components/developer/money-account-withdraw-button/money-account-withdraw-button.test.tsx index 535f12618913..36845706ddea 100644 --- a/ui/pages/confirmations/components/developer/money-account-withdraw-button/money-account-withdraw-button.test.tsx +++ b/ui/pages/confirmations/components/developer/money-account-withdraw-button/money-account-withdraw-button.test.tsx @@ -1,63 +1,78 @@ +import { fireEvent, screen } from '@testing-library/react'; import React from 'react'; -import { fireEvent, render, screen } from '@testing-library/react'; -import { TransactionType } from '@metamask/transaction-controller'; -import { CHAIN_IDS } from '../../../../../../shared/constants/network'; -import { MUSD_TOKEN, MUSD_TOKEN_ADDRESS } from '../../../constants/musd'; -import { useDeveloperTransferTransaction } from '../utils'; +import configureMockStore from 'redux-mock-store'; +import mockState from '../../../../../../test/data/mock-state.json'; +import { renderWithProvider } from '../../../../../../test/lib/render-helpers-navigate'; +import { useMoneyAccountInfo } from '../../../../../hooks/money/useMoneyAccountInfo'; +import { useMoneyAccountWithdrawal } from '../../../../../hooks/money/useMoneyAccountWithdrawal'; import { MoneyAccountWithdrawButton } from './money-account-withdraw-button'; -jest.mock('../utils', () => ({ - useDeveloperTransferTransaction: jest.fn(), +const render = () => + renderWithProvider( + , + configureMockStore()(mockState), + ); + +jest.mock('../../../../../hooks/money/useMoneyAccountWithdrawal', () => ({ + useMoneyAccountWithdrawal: jest.fn(), +})); + +jest.mock('../../../../../hooks/money/useMoneyAccountInfo', () => ({ + useMoneyAccountInfo: jest.fn(), })); -const useDeveloperTransferTransactionMock = jest.mocked( - useDeveloperTransferTransaction, -); +const useMoneyAccountWithdrawalMock = jest.mocked(useMoneyAccountWithdrawal); +const useMoneyAccountInfoMock = jest.mocked(useMoneyAccountInfo); describe('MoneyAccountWithdrawButton', () => { - const handleTriggerMock = jest.fn(); + const initiateWithdrawalMock = jest.fn(); beforeEach(() => { jest.clearAllMocks(); - useDeveloperTransferTransactionMock.mockReturnValue({ + initiateWithdrawalMock.mockResolvedValue(undefined); + useMoneyAccountWithdrawalMock.mockReturnValue({ + initiateWithdrawal: initiateWithdrawalMock, isLoading: false, - handleTrigger: handleTriggerMock, }); + useMoneyAccountInfoMock.mockReturnValue({ + isMoneyAccountFeatureEnabled: true, + hasMoneyAccount: true, + primaryMoneyAccount: { address: '0xd5fe' }, + } as unknown as ReturnType); }); - it('configures the transfer hook for a Monad mUSD money account withdraw', () => { - render(); - - expect(useDeveloperTransferTransactionMock).toHaveBeenCalledWith({ - chainId: CHAIN_IDS.MONAD, - tokenAddress: MUSD_TOKEN_ADDRESS, - decimals: MUSD_TOKEN.decimals, - type: TransactionType.moneyAccountWithdraw, - errorMessage: 'Failed to create money account withdraw transaction', - }); - }); - - it('renders the developer button and triggers the transaction on click', () => { - render(); + it('initiates the withdrawal on click', () => { + render(); const button = screen.getByRole('button', { name: 'Money Account Withdraw', }); - expect(button).toBeInTheDocument(); expect(button).not.toBeDisabled(); fireEvent.click(button); - expect(handleTriggerMock).toHaveBeenCalledTimes(1); + expect(initiateWithdrawalMock).toHaveBeenCalledTimes(1); + }); + + it('renders nothing at all when the money account is unavailable', () => { + useMoneyAccountInfoMock.mockReturnValue({ + isMoneyAccountFeatureEnabled: false, + hasMoneyAccount: false, + primaryMoneyAccount: undefined, + } as unknown as ReturnType); + + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); }); - it('disables the button while loading', () => { - useDeveloperTransferTransactionMock.mockReturnValue({ + it('disables the button while initiating', () => { + useMoneyAccountWithdrawalMock.mockReturnValue({ + initiateWithdrawal: initiateWithdrawalMock, isLoading: true, - handleTrigger: handleTriggerMock, }); - render(); + render(); expect( screen.getByRole('button', { name: 'Money Account Withdraw' }), diff --git a/ui/pages/confirmations/components/developer/money-account-withdraw-button/money-account-withdraw-button.tsx b/ui/pages/confirmations/components/developer/money-account-withdraw-button/money-account-withdraw-button.tsx index 51b099b27c1f..f971782bc7f2 100644 --- a/ui/pages/confirmations/components/developer/money-account-withdraw-button/money-account-withdraw-button.tsx +++ b/ui/pages/confirmations/components/developer/money-account-withdraw-button/money-account-withdraw-button.tsx @@ -1,25 +1,49 @@ import React from 'react'; -import { TransactionType } from '@metamask/transaction-controller'; -import { CHAIN_IDS } from '../../../../../../shared/constants/network'; +import { useMoneyAccountInfo } from '../../../../../hooks/money/useMoneyAccountInfo'; +import { useMoneyAccountWithdrawal } from '../../../../../hooks/money/useMoneyAccountWithdrawal'; +import { RouteWithMessenger } from '../../../../../layouts/route-with-messenger'; import { DeveloperButton } from '../developer-button'; -import { MUSD_TOKEN, MUSD_TOKEN_ADDRESS } from '../../../constants/musd'; -import { useDeveloperTransferTransaction } from '../utils'; +import { MONEY_ACCOUNT_WITHDRAW_BUTTON_ALLOWED_CAPABILITIES } from './messenger'; -export const MoneyAccountWithdrawButton = () => { - const { isLoading, handleTrigger } = useDeveloperTransferTransaction({ - chainId: CHAIN_IDS.MONAD, - tokenAddress: MUSD_TOKEN_ADDRESS, - decimals: MUSD_TOKEN.decimals, - type: TransactionType.moneyAccountWithdraw, - errorMessage: 'Failed to create money account withdraw transaction', - }); +/** + * Developer trigger for the real Money Account withdraw flow: the placeholder + * withdraw + transfer batch from the money account, re-encoded once an amount + * is chosen. Hidden entirely — not disabled — when the money account is + * unavailable, the same rule every production entry point follows. + */ +const MoneyAccountWithdrawButtonContent = () => { + const { hasMoneyAccount } = useMoneyAccountInfo(); + const { initiateWithdrawal, isLoading } = useMoneyAccountWithdrawal(); + + if (!hasMoneyAccount) { + return null; + } return ( + initiateWithdrawal().catch((error) => + console.error('Failed to initiate money account withdrawal', error), + ) + } disabled={isLoading} /> ); }; + +/** + * {@link MoneyAccountWithdrawButtonContent}, wrapped in the route messenger it + * needs to call `MoneyAccountAvailabilityService:getAvailability` via + * `useMoneyAccountInfo`. This settings panel isn't behind a router route with + * its own messenger, so it carries its own. + */ +export const MoneyAccountWithdrawButton = () => ( + + + +); diff --git a/ui/pages/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx b/ui/pages/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx index 1100bc81f1b7..ffa5694e7473 100644 --- a/ui/pages/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx +++ b/ui/pages/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx @@ -83,6 +83,16 @@ jest.mock('../../rows/total-row/total-row', () => ({ const MOCK_TRANSACTION_META = genUnapprovedContractInteractionConfirmation() as TransactionMeta; +// Withdraws are batches, so the money-account type sits on a nested transaction. +const MOCK_MONEY_ACCOUNT_WITHDRAW_TRANSACTION_META = { + ...MOCK_TRANSACTION_META, + type: TransactionType.batch, + nestedTransactions: [ + { type: TransactionType.moneyAccountWithdraw }, + { type: TransactionType.tokenMethodTransfer }, + ], +} as TransactionMeta; + const mockStore = configureMockStore([]); const DEFAULT_CUSTOM_AMOUNT_HOOK_RETURN = { @@ -471,6 +481,19 @@ describe('CustomAmountInfo', () => { queryByTestId('custom-amount-info-skeleton'), ).not.toBeInTheDocument(); }); + + it('does not render the skeleton for a money-account withdraw without a required token', () => { + const { getByTestId, queryByTestId } = render({ + disablePay: false, + primaryRequiredToken: undefined, + transactionMeta: MOCK_MONEY_ACCOUNT_WITHDRAW_TRANSACTION_META, + }); + + expect(getByTestId('custom-amount-info')).toBeInTheDocument(); + expect( + queryByTestId('custom-amount-info-skeleton'), + ).not.toBeInTheDocument(); + }); }); it('renders the pay with selector when tokens available and disablePay is false', () => { @@ -614,6 +637,17 @@ describe('CustomAmountInfo', () => { expect(queryByTestId('bridge-fee-row')).not.toBeInTheDocument(); }); + it('renders the total without a fee row for disablePay withdraws', () => { + const { getByTestId, queryByTestId } = render({ + disablePay: true, + hasQuotes: false, + isQuotesLoading: false, + }); + + expect(getByTestId('total-row')).toBeInTheDocument(); + expect(queryByTestId('bridge-fee-row')).not.toBeInTheDocument(); + }); + it('does not render result rows before an amount is entered', () => { const { queryByTestId } = render({ customAmountHookReturn: { diff --git a/ui/pages/confirmations/components/info/custom-amount-info/custom-amount-info.tsx b/ui/pages/confirmations/components/info/custom-amount-info/custom-amount-info.tsx index ff65c7bb56cb..052ed7dfcd97 100644 --- a/ui/pages/confirmations/components/info/custom-amount-info/custom-amount-info.tsx +++ b/ui/pages/confirmations/components/info/custom-amount-info/custom-amount-info.tsx @@ -1,5 +1,8 @@ import React, { ReactNode, useCallback } from 'react'; -import type { TransactionMeta } from '@metamask/transaction-controller'; +import { + TransactionType, + type TransactionMeta, +} from '@metamask/transaction-controller'; import { Box, Text } from '../../../../../components/component-library'; import { Display, @@ -29,7 +32,10 @@ import { PercentageButtons, PercentageButtonsSkeleton, } from '../../percentage-buttons'; -import { isPerpsWithdrawTransaction } from '../../../../../../shared/lib/transactions.utils'; +import { + hasTransactionType, + isPerpsWithdrawTransaction, +} from '../../../../../../shared/lib/transactions.utils'; import { useTransactionCustomAmount } from '../../../hooks/transactions/useTransactionCustomAmount'; import { useTransactionCustomAmountAlerts } from '../../../hooks/transactions/useTransactionCustomAmountAlerts'; import { useAutomaticTransactionPayToken } from '../../../hooks/pay/useAutomaticTransactionPayToken'; @@ -138,7 +144,14 @@ export const CustomAmountInfo = React.memo( const { isWithdraw } = useTransactionPayWithdraw(); const hasTokens = availableTokens.length > 0 || isWithdraw; const primaryRequiredToken = useTransactionPayPrimaryRequiredToken(); - const isAwaitingRequiredToken = !disablePay && !primaryRequiredToken; + // Direct and post-quote money-account withdraws have no `requiredAssets`, + // so TPC never resolves a primary required token. Waiting on it keeps the + // amount skeleton up forever after the placeholder batch is created. + const isMoneyAccountWithdraw = hasTransactionType(currentConfirmation, [ + TransactionType.moneyAccountWithdraw, + ]); + const isAwaitingRequiredToken = + !disablePay && !isMoneyAccountWithdraw && !primaryRequiredToken; const { disableUpdate } = useTransactionCustomAmountAlerts(); @@ -352,7 +365,7 @@ function BottomContainer({ hasAmount: boolean; }) { const t = useI18nContext(); - const isResultReady = useIsResultReady(hasAmount); + const isResultReady = useIsResultReady(hasAmount, disablePay); const { hideResults } = useTransactionCustomAmountAlerts(); const { currentConfirmation } = useConfirmContext(); @@ -376,14 +389,18 @@ function BottomContainer({ {disablePay !== true && } {isResultReady && !hideResults && ( <> - - - {canSelectWithdrawToken ? ( + {disablePay !== true && ( + <> + + + + )} + {canSelectWithdrawToken && disablePay !== true ? ( (); const quotes = useTransactionPayQuotes(); const isQuotePending = useIsTransactionPayQuotePending(); @@ -417,6 +436,8 @@ function useIsResultReady(hasAmount: boolean) { const hasPositiveRequiredAmount = useTransactionPayHasPositiveRequiredAmount(); + // Selecting a destination token still stores a no-op quote and gas totals. + // A $0 withdraw must not show those as a real quote. if (!hasAmount) { return false; } @@ -425,7 +446,9 @@ function useIsResultReady(hasAmount: boolean) { return hasPositiveRequiredAmount && (isQuotePending || hasExecutableQuote); } - return isQuotePending || Boolean(quotes?.length); + // Direct withdraws never fetch quotes. Show the total once an amount is + // typed; do not wait on a quote that will never arrive. + return Boolean(disablePay) || isQuotePending || Boolean(quotes?.length); } function AlertMessage() { diff --git a/ui/pages/confirmations/components/modals/pay-with-modal/pay-with-modal.test.tsx b/ui/pages/confirmations/components/modals/pay-with-modal/pay-with-modal.test.tsx index f9a523099b8c..495d2f5a2351 100644 --- a/ui/pages/confirmations/components/modals/pay-with-modal/pay-with-modal.test.tsx +++ b/ui/pages/confirmations/components/modals/pay-with-modal/pay-with-modal.test.tsx @@ -252,6 +252,18 @@ describe('PayWithModal', () => { expect(screen.getByTestId('has-tag-renderers')).toHaveTextContent('true'); }); + it('passes no-fee tag renderers for money account withdraws', () => { + useConfirmContextMock.mockReturnValue({ + currentConfirmation: { + type: TransactionType.moneyAccountWithdraw, + }, + } as ReturnType); + + renderModal({ isOpen: true, onClose: onCloseMock }); + + expect(screen.getByTestId('has-tag-renderers')).toHaveTextContent('true'); + }); + it('calls onClose when close button is clicked', () => { renderModal({ isOpen: true, onClose: onCloseMock }); diff --git a/ui/pages/confirmations/components/modals/pay-with-modal/pay-with-modal.tsx b/ui/pages/confirmations/components/modals/pay-with-modal/pay-with-modal.tsx index b52d8dd8870e..3588db3cd4c0 100644 --- a/ui/pages/confirmations/components/modals/pay-with-modal/pay-with-modal.tsx +++ b/ui/pages/confirmations/components/modals/pay-with-modal/pay-with-modal.tsx @@ -78,8 +78,11 @@ export const PayWithModal = ({ isOpen, onClose }: PayWithModalProps) => { confirmationType === TransactionType.moneyAccountDeposit; const { renderNoFeeTag } = usePayWithNoFeeToken(); const tagRenderers = useMemo( - () => (isMoneyAccountDeposit ? [renderNoFeeTag] : undefined), - [isMoneyAccountDeposit, renderNoFeeTag], + () => + isMoneyAccountDeposit || isPostQuoteWithdraw + ? [renderNoFeeTag] + : undefined, + [isMoneyAccountDeposit, isPostQuoteWithdraw, renderNoFeeTag], ); const handleClose = useCallback(() => { diff --git a/ui/pages/confirmations/components/rows/from-account-row/from-account-row.test.tsx b/ui/pages/confirmations/components/rows/from-account-row/from-account-row.test.tsx index 5b2a3b3d7948..1dc1f8f2ffb4 100644 --- a/ui/pages/confirmations/components/rows/from-account-row/from-account-row.test.tsx +++ b/ui/pages/confirmations/components/rows/from-account-row/from-account-row.test.tsx @@ -6,6 +6,7 @@ import { renderWithProvider } from '../../../../../../test/lib/render-helpers-na import { useConfirmContext } from '../../../context/confirm'; import { useDisplayName } from '../../../../../hooks/useDisplayName'; import { setAccountOverride } from '../../../../../store/controller-actions/transaction-pay-controller'; +import { replaceAccountInNestedTransactions } from '../../../utils/transaction-pay'; import { FromAccountRow } from './from-account-row'; jest.mock('../../../context/confirm'); @@ -16,6 +17,9 @@ jest.mock( setAccountOverride: jest.fn(), }), ); +jest.mock('../../../utils/transaction-pay', () => ({ + replaceAccountInNestedTransactions: jest.fn(), +})); jest.mock('../../account-select-modal', () => ({ AccountSelectModal: ({ @@ -105,6 +109,9 @@ describe('FromAccountRow', () => { const useConfirmContextMock = jest.mocked(useConfirmContext); const useDisplayNameMock = jest.mocked(useDisplayName); const setAccountOverrideMock = jest.mocked(setAccountOverride); + const replaceAccountInNestedTransactionsMock = jest.mocked( + replaceAccountInNestedTransactions, + ); beforeEach(() => { jest.resetAllMocks(); @@ -196,12 +203,47 @@ describe('FromAccountRow', () => { fireEvent.click(screen.getByTestId('from-account-pill')); fireEvent.click(screen.getByTestId('select-other')); + expect(replaceAccountInNestedTransactionsMock).toHaveBeenCalledWith({ + transactionId: TX_ID_MOCK, + nestedTransactions: undefined, + oldAddress: FROM_ADDRESS_MOCK, + newAddress: OTHER_ADDRESS_MOCK, + }); expect(setAccountOverrideMock).toHaveBeenCalledWith( TX_ID_MOCK, OTHER_ADDRESS_MOCK, ); }); + it('rewrites nested calldata using the previous override as the old address', () => { + const nestedTransactions = [{ data: '0xabc', to: '0x1' }]; + useConfirmContextMock.mockReturnValue({ + currentConfirmation: { + id: TX_ID_MOCK, + chainId: CHAIN_ID_MOCK, + txParams: { from: FROM_ADDRESS_MOCK }, + nestedTransactions, + }, + } as never); + + const store = createStore({ accountOverride: OTHER_ADDRESS_MOCK }); + renderWithProvider(, store); + + fireEvent.click(screen.getByTestId('from-account-pill')); + fireEvent.click(screen.getByTestId('select-same')); + + expect(replaceAccountInNestedTransactionsMock).toHaveBeenCalledWith({ + transactionId: TX_ID_MOCK, + nestedTransactions, + oldAddress: OTHER_ADDRESS_MOCK, + newAddress: FROM_ADDRESS_MOCK, + }); + expect(setAccountOverrideMock).toHaveBeenCalledWith( + TX_ID_MOCK, + FROM_ADDRESS_MOCK, + ); + }); + it('does not set the account override when the current account is chosen', () => { const store = createStore(); renderWithProvider(, store); @@ -209,6 +251,7 @@ describe('FromAccountRow', () => { fireEvent.click(screen.getByTestId('from-account-pill')); fireEvent.click(screen.getByTestId('select-same')); + expect(replaceAccountInNestedTransactionsMock).not.toHaveBeenCalled(); expect(setAccountOverrideMock).not.toHaveBeenCalled(); }); @@ -219,6 +262,7 @@ describe('FromAccountRow', () => { fireEvent.click(screen.getByTestId('from-account-pill')); fireEvent.click(screen.getByTestId('select-override')); + expect(replaceAccountInNestedTransactionsMock).not.toHaveBeenCalled(); expect(setAccountOverrideMock).not.toHaveBeenCalled(); }); diff --git a/ui/pages/confirmations/components/rows/from-account-row/from-account-row.tsx b/ui/pages/confirmations/components/rows/from-account-row/from-account-row.tsx index edc12da98b90..300e7134853b 100644 --- a/ui/pages/confirmations/components/rows/from-account-row/from-account-row.tsx +++ b/ui/pages/confirmations/components/rows/from-account-row/from-account-row.tsx @@ -27,6 +27,7 @@ import { import { useI18nContext } from '../../../../../hooks/useI18nContext'; import { useDisplayName } from '../../../../../hooks/useDisplayName'; import { useConfirmContext } from '../../../context/confirm'; +import { replaceAccountInNestedTransactions } from '../../../utils/transaction-pay'; import { AccountSelectModal } from '../../account-select-modal'; export { ConfirmInfoRowSize }; @@ -91,6 +92,16 @@ export function FromAccountRow({ currentConfirmation?.id && address.toLowerCase() !== from.toLowerCase() ) { + // Mobile PayAccountSelector rewrites encoded nested calldata first so + // the withdraw transfer recipient (or any other encoded account word) + // matches the new selection, then seeds accountOverride. + replaceAccountInNestedTransactions({ + transactionId: currentConfirmation.id, + nestedTransactions: currentConfirmation.nestedTransactions, + oldAddress: accountOverride ?? txFrom, + newAddress: address, + }); + // The TransactionPayController resolves the funding account (and gas) // from `accountOverride ?? txParams.from`, so keep it in sync with the // newly selected account. @@ -101,7 +112,7 @@ export function FromAccountRow({ ); } }, - [closeModal, currentConfirmation?.id, from], + [accountOverride, closeModal, currentConfirmation, from, txFrom], ); if (!currentConfirmation || !from) { diff --git a/ui/pages/confirmations/hooks/alerts/constants.ts b/ui/pages/confirmations/hooks/alerts/constants.ts index 2820aba41e2a..42b7c1f2894a 100644 --- a/ui/pages/confirmations/hooks/alerts/constants.ts +++ b/ui/pages/confirmations/hooks/alerts/constants.ts @@ -11,6 +11,7 @@ export enum AlertsName { InsufficientPayTokenBalance = 'insufficientPayTokenBalance', InsufficientPayTokenNative = 'insufficientPayTokenNative', InsufficientPayTokenFees = 'insufficientPayTokenFees', + InsufficientMoneyAccountBalance = 'insufficientMoneyAccountBalance', NetworkBusy = 'networkBusy', NoGasPrice = 'noGasPrice', NoPayTokenQuotes = 'noPayTokenQuotes', diff --git a/ui/pages/confirmations/hooks/alerts/transactions/useInsufficientBalanceAlerts.test.ts b/ui/pages/confirmations/hooks/alerts/transactions/useInsufficientBalanceAlerts.test.ts index 2a05abba4ec9..cd91df133514 100644 --- a/ui/pages/confirmations/hooks/alerts/transactions/useInsufficientBalanceAlerts.test.ts +++ b/ui/pages/confirmations/hooks/alerts/transactions/useInsufficientBalanceAlerts.test.ts @@ -424,7 +424,7 @@ describe('useInsufficientBalanceAlerts', () => { expect(alerts).toEqual([]); }); - it('returns alert when post-quote is disabled for the type, since the direct transfer spends native balance', () => { + it('returns no alerts for a direct money-account withdraw, which is sponsored from the money account', () => { const alerts = runHook({ balance: 7, currentConfirmation: WITHDRAW_TRANSACTION_MOCK, @@ -432,7 +432,7 @@ describe('useInsufficientBalanceAlerts', () => { remoteFeatureFlags: buildPostQuoteFlags(false), }); - expect(alerts).toEqual(ALERT); + expect(alerts).toEqual([]); }); }); diff --git a/ui/pages/confirmations/hooks/alerts/transactions/useInsufficientBalanceAlerts.ts b/ui/pages/confirmations/hooks/alerts/transactions/useInsufficientBalanceAlerts.ts index 0de186fba566..2cb3dfd13a6e 100644 --- a/ui/pages/confirmations/hooks/alerts/transactions/useInsufficientBalanceAlerts.ts +++ b/ui/pages/confirmations/hooks/alerts/transactions/useInsufficientBalanceAlerts.ts @@ -53,10 +53,13 @@ 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, [ + // Money-account batches execute from the money account, which has no native + // MON. Gas is sponsored, so the EOA native-balance check is wrong. Direct + // withdraws also skip initial gas estimate, so this alert otherwise blocks + // Send after the user types an amount. + const isMoneyAccountTransaction = hasTransactionType(currentConfirmation, [ TransactionType.moneyAccountDeposit, + TransactionType.moneyAccountWithdraw, ]); const isGasFeeTokensEmpty = gasFeeTokens?.length === 0; @@ -100,7 +103,7 @@ export function useInsufficientBalanceAlerts({ shouldCheckGaslessConditions && !isSponsoredTransaction && !isPostQuoteWithdraw && - !isMoneyAccountDeposit; + !isMoneyAccountTransaction; return useMemo(() => { if (!showAlert) { diff --git a/ui/pages/confirmations/hooks/alerts/transactions/useInsufficientMoneyAccountBalanceAlert.test.ts b/ui/pages/confirmations/hooks/alerts/transactions/useInsufficientMoneyAccountBalanceAlert.test.ts new file mode 100644 index 000000000000..5c13f7a312b8 --- /dev/null +++ b/ui/pages/confirmations/hooks/alerts/transactions/useInsufficientMoneyAccountBalanceAlert.test.ts @@ -0,0 +1,205 @@ +import { BigNumber } from 'bignumber.js'; +import { useQuery } from '@metamask/react-data-query'; +import { + TransactionMeta, + TransactionType, +} from '@metamask/transaction-controller'; +import type { CanonicalMoneyAccountBalanceResponse } from '@metamask/money-account-balance-service'; +import { DATA_SERVICES } from '../../../../../../shared/constants/data-services'; +import { MoneyAccountBalanceServiceQueryKeys } from '../../../../../../shared/lib/money/query-keys'; +import { getMockConfirmStateForTransaction } from '../../../../../../test/data/confirmations/helper'; +import { genUnapprovedContractInteractionConfirmation } from '../../../../../../test/data/confirmations/contract-interaction'; +import { renderHookWithConfirmContextProvider } from '../../../../../../test/lib/confirmations/render-helpers'; +import { useTransactionPayPrimaryRequiredToken } from '../../pay/useTransactionPayData'; +import { AlertsName } from '../constants'; +import { RowAlertKey } from '../../../../../components/app/confirm/info/row/constants'; +import { Severity } from '../../../../../helpers/constants/design-system'; +import { useInsufficientMoneyAccountBalanceAlert } from './useInsufficientMoneyAccountBalanceAlert'; + +jest.mock('@metamask/react-data-query', () => ({ + useQuery: jest.fn(), +})); +jest.mock('../../pay/useTransactionPayData'); + +const useQueryMock = jest.mocked(useQuery); +const usePrimaryRequiredTokenMock = jest.mocked( + useTransactionPayPrimaryRequiredToken, +); + +const EXPECTED_ALERT = { + field: RowAlertKey.Amount, + isBlocking: true, + key: AlertsName.InsufficientMoneyAccountBalance, + message: 'Insufficient funds', + reason: 'Insufficient funds', + severity: Severity.Danger, +}; + +const MONEY_ACCOUNT_ADDRESS = '0xabc0000000000000000000000000000000000001'; + +function musdUnits(human: string): string { + return new BigNumber(human).times(1e6).toFixed(0); +} + +function mockBalance({ + vmusdHuman, + isLoading = false, + isError = false, +}: { + vmusdHuman?: string; + isLoading?: boolean; + isError?: boolean; +}) { + useQueryMock.mockReturnValue({ + data: + vmusdHuman === undefined + ? undefined + : ({ + vmusdValueInMusd: musdUnits(vmusdHuman), + } as CanonicalMoneyAccountBalanceResponse), + isLoading, + isError, + } as ReturnType); +} + +function runHook({ + pendingAmount, + type = TransactionType.moneyAccountWithdraw, +}: { + pendingAmount?: string; + type?: TransactionType; +} = {}) { + const transaction = { + ...genUnapprovedContractInteractionConfirmation(), + type, + txParams: { + from: MONEY_ACCOUNT_ADDRESS, + }, + } as TransactionMeta; + + return renderHookWithConfirmContextProvider( + () => useInsufficientMoneyAccountBalanceAlert({ pendingAmount }), + getMockConfirmStateForTransaction(transaction), + ); +} + +describe('useInsufficientMoneyAccountBalanceAlert', () => { + beforeEach(() => { + jest.resetAllMocks(); + mockBalance({ vmusdHuman: '100' }); + usePrimaryRequiredTokenMock.mockReturnValue( + undefined as unknown as ReturnType< + typeof useTransactionPayPrimaryRequiredToken + >, + ); + }); + + it('returns alert when pending amount exceeds available balance', () => { + const { result } = runHook({ pendingAmount: '150' }); + + expect(result.current).toEqual([EXPECTED_ALERT]); + }); + + it('returns no alert when pending amount equals available balance', () => { + const { result } = runHook({ pendingAmount: '100' }); + + expect(result.current).toStrictEqual([]); + }); + + it('returns no alert when pending amount is less than available balance', () => { + const { result } = runHook({ pendingAmount: '50' }); + + expect(result.current).toStrictEqual([]); + }); + + it('returns no alert when withdrawableMusd is undefined', () => { + mockBalance({}); + + const { result } = runHook({ pendingAmount: '150' }); + + expect(result.current).toStrictEqual([]); + }); + + it('returns no alert while the balance is loading', () => { + mockBalance({ isLoading: true }); + + const { result } = runHook({ pendingAmount: '150' }); + + expect(result.current).toStrictEqual([]); + }); + + it('returns no alert when the balance query failed', () => { + mockBalance({ isError: true }); + + const { result } = runHook({ pendingAmount: '150' }); + + expect(result.current).toStrictEqual([]); + }); + + it('returns no alert when transaction type is not moneyAccountWithdraw', () => { + const { result } = runHook({ + pendingAmount: '150', + type: TransactionType.simpleSend, + }); + + expect(result.current).toStrictEqual([]); + }); + + it('queries the money account balance service for a withdraw', () => { + runHook({ pendingAmount: '150' }); + + expect(useQueryMock).toHaveBeenCalledWith( + expect.objectContaining({ + queryKey: [ + MoneyAccountBalanceServiceQueryKeys.FETCH_BALANCE_WITH_FALLBACK, + MONEY_ACCOUNT_ADDRESS, + ], + }), + ); + }); + + it('does not use a data service query key for a non-withdraw confirmation', () => { + // This hook runs for every confirmation via `useConfirmationAlerts`. The + // query client opens a background `messengerSubscribe` on the first + // observer of any data service key, so a money-account key here would + // fire that subscription for unrelated confirmations. + runHook({ pendingAmount: '150', type: TransactionType.simpleSend }); + + const queryKey = useQueryMock.mock.calls[0][0].queryKey ?? []; + + expect(queryKey).not.toContain( + MoneyAccountBalanceServiceQueryKeys.FETCH_BALANCE_WITH_FALLBACK, + ); + expect( + DATA_SERVICES.some((service) => + String(queryKey[0]).startsWith(`${service}:`), + ), + ).toBe(false); + }); + + it('returns no alert when no pendingAmount is provided and defaults to zero', () => { + const { result } = runHook(); + + expect(result.current).toStrictEqual([]); + }); + + it('returns alert using required token amount when no pendingAmount provided', () => { + usePrimaryRequiredTokenMock.mockReturnValue({ + amountHuman: '150', + } as ReturnType); + + const { result } = runHook(); + + expect(result.current).toEqual([EXPECTED_ALERT]); + }); + + it('returns no alert when required token amount is within balance', () => { + usePrimaryRequiredTokenMock.mockReturnValue({ + amountHuman: '50', + } as ReturnType); + + const { result } = runHook(); + + expect(result.current).toStrictEqual([]); + }); +}); diff --git a/ui/pages/confirmations/hooks/alerts/transactions/useInsufficientMoneyAccountBalanceAlert.ts b/ui/pages/confirmations/hooks/alerts/transactions/useInsufficientMoneyAccountBalanceAlert.ts new file mode 100644 index 000000000000..69f71ed62ef9 --- /dev/null +++ b/ui/pages/confirmations/hooks/alerts/transactions/useInsufficientMoneyAccountBalanceAlert.ts @@ -0,0 +1,117 @@ +'use no memo'; + +import { useMemo } from 'react'; +import { BigNumber } from 'bignumber.js'; +import { + TransactionMeta, + TransactionType, +} from '@metamask/transaction-controller'; +import { MUSD_DECIMALS } from '@metamask/money-account-utils'; +import { useQuery } from '@metamask/react-data-query'; +import type { CanonicalMoneyAccountBalanceResponse } from '@metamask/money-account-balance-service'; +import type { 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 { hasTransactionType } from '../../../../../../shared/lib/transactions.utils'; +import { MoneyAccountBalanceServiceQueryKeys } from '../../../../../../shared/lib/money/query-keys'; +import { useConfirmContext } from '../../../context/confirm'; +import { useTransactionPayPrimaryRequiredToken } from '../../pay/useTransactionPayData'; +import { AlertsName } from '../constants'; + +const MUSD_UNIT = 10 ** MUSD_DECIMALS; + +/** + * Inert query key used when the confirmation is not a money-account withdraw. + * Deliberately not a `DATA_SERVICES` name so the query client does not open a + * background messenger subscription for it. + */ +const NON_WITHDRAW_QUERY_KEY = 'money-account-withdraw-alert:disabled'; + +/** + * Blocking alert when a money-account withdraw exceeds withdrawable vmUSD. + * + * Reads the same react-query cache `useMoneyAccountBalance` writes (from the + * withdraw info messenger) so this can run in `useConfirmationAlerts` without + * requiring a money-account route messenger. + * + * Mirrors mobile `useInsufficientMoneyAccountBalanceAlert`. + * + * @param options + * @param options.pendingAmount - Optional in-progress human mUSD amount. + */ +export function useInsufficientMoneyAccountBalanceAlert({ + pendingAmount, +}: { + pendingAmount?: string; +} = {}): Alert[] { + const t = useI18nContext(); + const { currentConfirmation } = useConfirmContext(); + const primaryRequiredToken = useTransactionPayPrimaryRequiredToken(); + + const isMoneyAccountWithdraw = hasTransactionType(currentConfirmation, [ + TransactionType.moneyAccountWithdraw, + ]); + const moneyAccountAddress = currentConfirmation?.txParams?.from; + + // This hook runs from `useConfirmationAlerts` for every confirmation, but + // only withdrawals can produce this alert. `useQuery` registers a cache + // observer even when `enabled` is false, and the query client subscribes to + // the owning data service on the first observer — so a money-account key + // here would open a `messengerSubscribe` for unrelated confirmations. Use a + // key outside `DATA_SERVICES` unless this really is a withdraw, which leaves + // the query inert and skips the subscription entirely. + const moneyBalanceQuery = useQuery({ + queryKey: isMoneyAccountWithdraw + ? [ + MoneyAccountBalanceServiceQueryKeys.FETCH_BALANCE_WITH_FALLBACK, + moneyAccountAddress ?? '', + ] + : [NON_WITHDRAW_QUERY_KEY], + enabled: false, + }); + + const amountHuman = pendingAmount ?? primaryRequiredToken?.amountHuman ?? '0'; + + const withdrawableMusd = useMemo(() => { + if ( + !isMoneyAccountWithdraw || + moneyBalanceQuery.isLoading || + moneyBalanceQuery.isError || + !moneyBalanceQuery.data + ) { + return undefined; + } + + return new BigNumber( + moneyBalanceQuery.data.vmusdValueInMusd ?? 0, + ).dividedBy(MUSD_UNIT); + }, [ + isMoneyAccountWithdraw, + moneyBalanceQuery.data, + moneyBalanceQuery.isError, + moneyBalanceQuery.isLoading, + ]); + + const isInsufficient = + isMoneyAccountWithdraw && + withdrawableMusd !== undefined && + withdrawableMusd.lt(amountHuman); + + return useMemo(() => { + if (!isInsufficient) { + return []; + } + + return [ + { + field: RowAlertKey.Amount, + isBlocking: true, + key: AlertsName.InsufficientMoneyAccountBalance, + message: t('alertInsufficientPayTokenBalance'), + reason: t('alertInsufficientPayTokenBalance'), + severity: Severity.Danger, + }, + ]; + }, [isInsufficient, t]); +} diff --git a/ui/pages/confirmations/hooks/gas/useIsGaslessLoading.test.ts b/ui/pages/confirmations/hooks/gas/useIsGaslessLoading.test.ts index 64ebdbb5242f..d5fcb119d9ae 100644 --- a/ui/pages/confirmations/hooks/gas/useIsGaslessLoading.test.ts +++ b/ui/pages/confirmations/hooks/gas/useIsGaslessLoading.test.ts @@ -1,4 +1,4 @@ -import { GasFeeToken } from '@metamask/transaction-controller'; +import { GasFeeToken, TransactionType } from '@metamask/transaction-controller'; import { Hex } from '@metamask/utils'; import { renderHookWithConfirmContextProvider } from '../../../../../test/lib/confirmations/render-helpers'; @@ -22,6 +22,7 @@ async function runHook({ gasFeeTokens, selectedGasFeeToken, excludeNativeTokenForFee, + type, }: { simulationEnabled: boolean; gaslessSupported: boolean; @@ -30,6 +31,7 @@ async function runHook({ gasFeeTokens?: GasFeeToken[]; selectedGasFeeToken?: Hex; excludeNativeTokenForFee?: boolean; + type?: TransactionType; }) { mockedUseIsGaslessSupported.mockReturnValue({ isSupported: gaslessSupported, @@ -45,11 +47,14 @@ async function runHook({ const { result } = renderHookWithConfirmContextProvider( useIsGaslessLoading, getMockConfirmStateForTransaction( - genUnapprovedContractInteractionConfirmation({ - gasFeeTokens, - selectedGasFeeToken, - excludeNativeTokenForFee, - }), + { + ...genUnapprovedContractInteractionConfirmation({ + gasFeeTokens, + selectedGasFeeToken, + excludeNativeTokenForFee, + }), + ...(type ? { type } : {}), + }, { metamask: { useTransactionSimulations: simulationEnabled } }, ), ); @@ -103,6 +108,18 @@ describe('useIsGaslessLoading', () => { expect(result.isGaslessLoading).toBe(false); }); + it('returns false for a money account withdraw even when gas fee tokens are still loading', async () => { + const result = await runHook({ + simulationEnabled: true, + gaslessSupported: true, + insufficientBalance: true, + gasFeeTokens: undefined, + type: TransactionType.moneyAccountWithdraw, + }); + + expect(result.isGaslessLoading).toBe(false); + }); + it('returns true if gas fee tokens are undefined (still loading)', async () => { const result = await runHook({ simulationEnabled: true, diff --git a/ui/pages/confirmations/hooks/gas/useIsGaslessLoading.ts b/ui/pages/confirmations/hooks/gas/useIsGaslessLoading.ts index b98b79ea3d5d..1d872f787d6e 100644 --- a/ui/pages/confirmations/hooks/gas/useIsGaslessLoading.ts +++ b/ui/pages/confirmations/hooks/gas/useIsGaslessLoading.ts @@ -1,6 +1,11 @@ import { useSelector } from 'react-redux'; -import { GasFeeToken, TransactionMeta } from '@metamask/transaction-controller'; +import { + GasFeeToken, + TransactionMeta, + TransactionType, +} from '@metamask/transaction-controller'; import { Hex } from '@metamask/utils'; +import { hasTransactionType } from '../../../../../shared/lib/transactions.utils'; import { useConfirmContext } from '../../context/confirm'; import { getUseTransactionSimulations } from '../../../../selectors'; import { useHasInsufficientBalance } from '../useHasInsufficientBalance'; @@ -49,7 +54,15 @@ export function useIsGaslessLoading() { const hasNoNativeTokenAvailable = excludeNativeTokenForFee || hasInsufficientBalance; + // Money-account batches skip initial gas estimate and sponsor Monad gas. + // Waiting on `gasFeeTokens` keeps Send spinning forever. + const isMoneyAccountTransaction = hasTransactionType(transactionMeta, [ + TransactionType.moneyAccountDeposit, + TransactionType.moneyAccountWithdraw, + ]); + const isGaslessLoading = Boolean( + !isMoneyAccountTransaction && isSimulationEnabled && hasNoNativeTokenAvailable && (isGaslessSupportedPending || isGaslessSupportedFinished) && diff --git a/ui/pages/confirmations/hooks/pay/useIsPaidByMetaMask.test.ts b/ui/pages/confirmations/hooks/pay/useIsPaidByMetaMask.test.ts index 08355edfb9eb..3c35137b8db6 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, + useTransactionPayQuotes, useTransactionPaySourceAmounts, useTransactionPayTotals, } from './useTransactionPayData'; @@ -22,6 +23,7 @@ const useTransactionPayTotalsMock = jest.mocked(useTransactionPayTotals); const useTransactionPayHasPositiveRequiredAmountMock = jest.mocked( useTransactionPayHasPositiveRequiredAmount, ); +const useTransactionPayQuotesMock = jest.mocked(useTransactionPayQuotes); const useTransactionPaySourceAmountsMock = jest.mocked( useTransactionPaySourceAmounts, ); @@ -54,6 +56,7 @@ describe('useIsPaidByMetaMask', () => { mockConfirmation(TransactionType.musdConversion); mockTotals(); useTransactionPayHasPositiveRequiredAmountMock.mockReturnValue(true); + useTransactionPayQuotesMock.mockReturnValue([{}] as never); useTransactionPaySourceAmountsMock.mockReturnValue(undefined); }); @@ -112,6 +115,25 @@ describe('useIsPaidByMetaMask', () => { expect(result.current).toBe(true); }); + it('returns true when all fees are zero for moneyAccountWithdraw', () => { + mockConfirmation(TransactionType.moneyAccountWithdraw); + + const { result } = renderHook(() => useIsPaidByMetaMask()); + expect(result.current).toBe(true); + }); + + it('returns false for a sponsored withdraw with no quotes', () => { + mockConfirmation(TransactionType.moneyAccountWithdraw, { + isGasFeeSponsored: true, + }); + useTransactionPayQuotesMock.mockReturnValue([]); + useTransactionPayTotalsMock.mockReturnValue(undefined); + useTransactionPaySourceAmountsMock.mockReturnValue([]); + + const { result } = renderHook(() => useIsPaidByMetaMask()); + expect(result.current).toBe(false); + }); + it('returns true when gas is sponsored and there are no source amounts yet', () => { mockConfirmation(TransactionType.moneyAccountDeposit, { isGasFeeSponsored: true, diff --git a/ui/pages/confirmations/hooks/pay/useIsPaidByMetaMask.ts b/ui/pages/confirmations/hooks/pay/useIsPaidByMetaMask.ts index 2449020e7c0d..ed7ca1bc723d 100644 --- a/ui/pages/confirmations/hooks/pay/useIsPaidByMetaMask.ts +++ b/ui/pages/confirmations/hooks/pay/useIsPaidByMetaMask.ts @@ -4,6 +4,7 @@ import { hasTransactionType } from '../../../../../shared/lib/transactions.utils import { useTransactionMetadataRequestOptional } from '../transactions/useTransactionMetadataRequest'; import { useTransactionPayHasPositiveRequiredAmount, + useTransactionPayQuotes, useTransactionPaySourceAmounts, useTransactionPayTotals, } from './useTransactionPayData'; @@ -11,19 +12,22 @@ import { const SUPPORTED_TYPES: TransactionType[] = [ TransactionType.musdConversion, TransactionType.moneyAccountDeposit, + TransactionType.moneyAccountWithdraw, ]; /** * Determines whether the current transaction is fully sponsored by MetaMask * (zero gas, zero provider fee, zero MetaMask fee). * - * 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. + * The pre-quote sponsored short-circuit is deposit-only. Direct withdrawals + * set `isGasFeeSponsored` on Monad and never have source amounts, so that + * check would always show "Paid by MetaMask". Withdrawals only qualify once + * a quote reports $0 fees. */ export function useIsPaidByMetaMask(): boolean { const transactionMeta = useTransactionMetadataRequestOptional(); const totals = useTransactionPayTotals(); + const quotes = useTransactionPayQuotes(); const sourceAmounts = useTransactionPaySourceAmounts(); const hasPositiveRequiredAmount = useTransactionPayHasPositiveRequiredAmount(); @@ -32,8 +36,16 @@ export function useIsPaidByMetaMask(): boolean { return false; } + const isMoneyAccountWithdraw = hasTransactionType(transactionMeta, [ + TransactionType.moneyAccountWithdraw, + ]); + // Pre-quote gasless deposits: no conversion yet, gas is sponsored. - if (transactionMeta?.isGasFeeSponsored && !sourceAmounts?.length) { + if ( + !isMoneyAccountWithdraw && + transactionMeta?.isGasFeeSponsored && + !sourceAmounts?.length + ) { return true; } @@ -41,7 +53,7 @@ export function useIsPaidByMetaMask(): boolean { // 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 (!totals?.fees || !hasPositiveRequiredAmount) { + if (!quotes?.length || !totals?.fees || !hasPositiveRequiredAmount) { return false; } diff --git a/ui/pages/confirmations/hooks/pay/usePayWithNoFeeToken.test.tsx b/ui/pages/confirmations/hooks/pay/usePayWithNoFeeToken.test.tsx index 2a356f763b3a..924edf5e8082 100644 --- a/ui/pages/confirmations/hooks/pay/usePayWithNoFeeToken.test.tsx +++ b/ui/pages/confirmations/hooks/pay/usePayWithNoFeeToken.test.tsx @@ -2,15 +2,25 @@ import React from 'react'; import { renderHook } from '@testing-library/react'; import { Provider } from 'react-redux'; import configureMockStore from 'redux-mock-store'; +import { + TransactionMeta, + TransactionType, +} from '@metamask/transaction-controller'; import { type Asset } from '../../types/send'; import { CHAIN_IDS } from '../../../../../shared/constants/network'; import { MUSD_TOKEN_ADDRESS } from '../../constants/musd'; +import { useTransactionMetadataRequestOptional } from '../transactions/useTransactionMetadataRequest'; import { usePayWithNoFeeToken } from './usePayWithNoFeeToken'; +jest.mock('../transactions/useTransactionMetadataRequest'); + const ETH_USDC = '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48'; const ETH_MUSD = '0xaca92e438df0b2401ff60da7e4337b687a2435da'; const mockStore = configureMockStore(); +const useTransactionMetadataRequestOptionalMock = jest.mocked( + useTransactionMetadataRequestOptional, +); function renderUsePayWithNoFeeToken( remoteFeatureFlags: Record, @@ -29,6 +39,10 @@ function renderUsePayWithNoFeeToken( } describe('usePayWithNoFeeToken', () => { + beforeEach(() => { + useTransactionMetadataRequestOptionalMock.mockReturnValue(undefined); + }); + it('returns false when the relay fixed-spread flag is empty', () => { const { result } = renderUsePayWithNoFeeToken({}); @@ -96,4 +110,50 @@ describe('usePayWithNoFeeToken', () => { expect(tagged).not.toBeNull(); }); + + describe('Money Account withdrawal — directional no-fee', () => { + const ethDest = '0xdddddddddddddddddddddddddddddddddddddddd'; + + beforeEach(() => { + useTransactionMetadataRequestOptionalMock.mockReturnValue({ + type: TransactionType.moneyAccountWithdraw, + } as TransactionMeta); + }); + + it('tags a token that is the destination of a Monad mUSD route', () => { + const { result } = renderUsePayWithNoFeeToken({ + /* eslint-disable @typescript-eslint/naming-convention */ + confirmations_relay_fixed_spread: { + chains: { eth: '0x1', monad: CHAIN_IDS.MONAD }, + tokens: { eth_dest: ethDest, musd: MUSD_TOKEN_ADDRESS }, + routes: [['monad', 'musd', 'eth', 'eth_dest']], + }, + /* eslint-enable @typescript-eslint/naming-convention */ + }); + + expect(result.current.isNoFeeToken(ethDest, '0x1')).toBe(true); + }); + + it('does not tag a deposit-only subsidised source', () => { + const { result } = renderUsePayWithNoFeeToken({ + /* eslint-disable @typescript-eslint/naming-convention */ + confirmations_relay_fixed_spread: { + chains: { eth: '0x1', monad: CHAIN_IDS.MONAD }, + tokens: { eth_usdc: ETH_USDC, musd: MUSD_TOKEN_ADDRESS }, + routes: [['eth', 'eth_usdc', 'monad', 'musd']], + }, + /* eslint-enable @typescript-eslint/naming-convention */ + }); + + expect(result.current.isNoFeeToken(ETH_USDC, '0x1')).toBe(false); + }); + + it('tags Monad mUSD itself even though the flag omits the same-token route', () => { + const { result } = renderUsePayWithNoFeeToken({}); + + expect( + result.current.isNoFeeToken(MUSD_TOKEN_ADDRESS, CHAIN_IDS.MONAD), + ).toBe(true); + }); + }); }); diff --git a/ui/pages/confirmations/hooks/pay/usePayWithNoFeeToken.tsx b/ui/pages/confirmations/hooks/pay/usePayWithNoFeeToken.tsx index ef33a9bd9715..cc87e4f97458 100644 --- a/ui/pages/confirmations/hooks/pay/usePayWithNoFeeToken.tsx +++ b/ui/pages/confirmations/hooks/pay/usePayWithNoFeeToken.tsx @@ -1,16 +1,30 @@ import React, { useCallback } from 'react'; import { useSelector } from 'react-redux'; -import { isSubsidizedSource } from '../../utils/relay-fixed-spread'; +import { TransactionType } from '@metamask/transaction-controller'; +import { + isSubsidizedRoute, + isSubsidizedSource, +} from '../../utils/relay-fixed-spread'; 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 { hasTransactionType } from '../../../../../shared/lib/transactions.utils'; import { MUSD_TOKEN_ADDRESS } from '../../constants/musd'; +import { useTransactionMetadataRequestOptional } from '../transactions/useTransactionMetadataRequest'; + +/** The Money Account vault token; withdrawals always convert FROM this. */ +const MONAD_MUSD_SOURCE = { + address: MUSD_TOKEN_ADDRESS, + chainId: CHAIN_IDS.MONAD, +}; /** * 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. + * omits that same-token route. Depositing or withdrawing it still incurs no + * Relay fee. + * * @param address * @param chainId */ @@ -19,16 +33,23 @@ const isMonadMusd = (address: string, chainId: string) => 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, or when it - * is Monad mUSD itself. + * Identifies tokens that incur no Relay fixed-spread fee. + * + * For deposits the picker token is the source, so a token is no-fee when it + * is a subsidised source (or Monad mUSD itself). For a Money Account + * withdrawal the picker token is the destination and the source is always + * Monad mUSD, so the match is directional: a subsidised route FROM Monad + * mUSD INTO the token, or Monad mUSD itself. */ export function usePayWithNoFeeToken(): { isNoFeeToken: (address: string, chainId: string) => boolean; renderNoFeeTag: TokenTagRenderer; } { const relayFixedSpread = useSelector(selectRelayFixedSpread); + const transactionMeta = useTransactionMetadataRequestOptional(); + const isMoneyWithdraw = hasTransactionType(transactionMeta, [ + TransactionType.moneyAccountWithdraw, + ]); const isNoFeeToken = useCallback( (address: string, chainId: string): boolean => { @@ -36,6 +57,16 @@ export function usePayWithNoFeeToken(): { return false; } + if (isMoneyWithdraw) { + return ( + isMonadMusd(address, chainId) || + isSubsidizedRoute(relayFixedSpread, MONAD_MUSD_SOURCE, { + address, + chainId: String(chainId), + }) + ); + } + return ( isMonadMusd(address, chainId) || isSubsidizedSource(relayFixedSpread, { @@ -44,7 +75,7 @@ export function usePayWithNoFeeToken(): { }) ); }, - [relayFixedSpread], + [isMoneyWithdraw, relayFixedSpread], ); const renderNoFeeTag: TokenTagRenderer = useCallback( diff --git a/ui/pages/confirmations/hooks/transactions/useLastMoneyAccountWithdrawAmount.test.ts b/ui/pages/confirmations/hooks/transactions/useLastMoneyAccountWithdrawAmount.test.ts new file mode 100644 index 000000000000..a30ad4db388f --- /dev/null +++ b/ui/pages/confirmations/hooks/transactions/useLastMoneyAccountWithdrawAmount.test.ts @@ -0,0 +1,46 @@ +import { renderHook, act } from '@testing-library/react'; +import { + getLastMoneyAccountWithdrawAmount, + setLastMoneyAccountWithdrawAmount, + updateMoneyAccountWithdrawAmount, +} from '../../../../store/controller-actions/transaction-pay-controller'; +import { useLastMoneyAccountWithdrawAmount } from './useLastMoneyAccountWithdrawAmount'; + +jest.mock('../../../../store/background-connection', () => ({ + submitRequestToBackground: jest.fn(() => Promise.resolve(false)), +})); + +describe('useLastMoneyAccountWithdrawAmount', () => { + it('returns undefined before an amount is dispatched', () => { + const { result } = renderHook(() => + useLastMoneyAccountWithdrawAmount('tx-none'), + ); + + expect(result.current).toBeUndefined(); + expect(getLastMoneyAccountWithdrawAmount('tx-none')).toBeUndefined(); + }); + + it('updates when the typed amount is recorded', () => { + const { result } = renderHook(() => + useLastMoneyAccountWithdrawAmount('tx-typed-amount'), + ); + + act(() => { + setLastMoneyAccountWithdrawAmount('tx-typed-amount', '0.05'); + }); + + expect(result.current).toBe('0.05'); + }); + + it('updates when a withdraw amount is dispatched', async () => { + const { result } = renderHook(() => + useLastMoneyAccountWithdrawAmount('tx-withdraw-amount'), + ); + + await act(async () => { + await updateMoneyAccountWithdrawAmount('tx-withdraw-amount', '0.05'); + }); + + expect(result.current).toBe('0.05'); + }); +}); diff --git a/ui/pages/confirmations/hooks/transactions/useLastMoneyAccountWithdrawAmount.ts b/ui/pages/confirmations/hooks/transactions/useLastMoneyAccountWithdrawAmount.ts new file mode 100644 index 000000000000..67de4682eeee --- /dev/null +++ b/ui/pages/confirmations/hooks/transactions/useLastMoneyAccountWithdrawAmount.ts @@ -0,0 +1,21 @@ +import { useSyncExternalStore } from 'react'; +import { + getLastMoneyAccountWithdrawAmount, + subscribeLastMoneyAccountWithdrawAmount, +} from '../../../../store/controller-actions/transaction-pay-controller'; + +/** + * Last human-readable withdraw amount dispatched for this confirmation. + * Reactive so the footer can enable Send without waiting on TPC required + * tokens or quote totals. + * + * @param transactionId - Id of the Money Account withdrawal transaction. + * @returns The last amount, if any update has been dispatched. + */ +export function useLastMoneyAccountWithdrawAmount( + transactionId: string, +): string | undefined { + return useSyncExternalStore(subscribeLastMoneyAccountWithdrawAmount, () => + getLastMoneyAccountWithdrawAmount(transactionId), + ); +} diff --git a/ui/pages/confirmations/hooks/transactions/useTransactionConfirm.test.ts b/ui/pages/confirmations/hooks/transactions/useTransactionConfirm.test.ts index 94cec746476f..be11810d46f0 100644 --- a/ui/pages/confirmations/hooks/transactions/useTransactionConfirm.test.ts +++ b/ui/pages/confirmations/hooks/transactions/useTransactionConfirm.test.ts @@ -1,6 +1,7 @@ import { QuoteResponseV1, TxData } from '@metamask/bridge-controller'; import { GasFeeToken, + generateEIP7702BatchTransaction, TransactionMeta, TransactionType, } from '@metamask/transaction-controller'; @@ -18,6 +19,7 @@ import { ENVIRONMENT_TYPE_POPUP, ENVIRONMENT_TYPE_SIDEPANEL, } from '../../../../../shared/constants/app'; +import { submitRequestToBackground } from '../../../../store/background-connection'; import { attemptCloseNotificationPopup, updateAndApproveTx, @@ -29,6 +31,10 @@ import * as DappSwapContext from '../../context/dapp-swap'; import { useGaslessSupportedSmartTransactions } from '../gas/useGaslessSupportedSmartTransactions'; import { useIsGaslessSupported } from '../gas/useIsGaslessSupported'; import { useGasSponsorshipPreference } from '../gas/useGasSponsorshipPreference'; +import { + getLastMoneyAccountWithdrawAmount, + updateMoneyAccountWithdrawAmount, +} from '../../../../store/controller-actions/transaction-pay-controller'; import * as DappSwapActions from './dapp-swap-comparison/useDappSwapActions'; import { useTransactionConfirm } from './useTransactionConfirm'; @@ -74,6 +80,17 @@ jest.mock('../../../../store/actions', () => ({ updateAndApproveTx: jest.fn(), })); +jest.mock( + '../../../../store/controller-actions/transaction-pay-controller', + () => ({ + ...jest.requireActual( + '../../../../store/controller-actions/transaction-pay-controller', + ), + getLastMoneyAccountWithdrawAmount: jest.fn(), + updateMoneyAccountWithdrawAmount: jest.fn(), + }), +); + const mockUseNavigate = jest.fn(); jest.mock('react-router-dom', () => { return { @@ -117,6 +134,8 @@ function runHook({ isExternalSign, selectedGasFeeToken, type, + nestedTransactions, + txParamsData, }: { customNonceValue?: string; gasFeeTokens?: GasFeeToken[]; @@ -124,16 +143,24 @@ function runHook({ isExternalSign?: boolean; selectedGasFeeToken?: Hex; type?: TransactionType; + nestedTransactions?: TransactionMeta['nestedTransactions']; + txParamsData?: string; } = {}) { const confirmation = genUnapprovedContractInteractionConfirmation({ gasFeeTokens, isGasFeeSponsored, isExternalSign, selectedGasFeeToken, - }); + }) as TransactionMeta; if (type) { confirmation.type = type; } + if (nestedTransactions) { + confirmation.nestedTransactions = nestedTransactions; + } + if (txParamsData) { + confirmation.txParams.data = txParamsData; + } const { result } = renderHookWithConfirmContextProvider( useTransactionConfirm, @@ -145,7 +172,7 @@ function runHook({ }), ); - return result.current; + return { ...result.current, confirmation }; } describe('useTransactionConfirm', () => { @@ -163,6 +190,13 @@ describe('useTransactionConfirm', () => { const useGasSponsorshipPreferenceMock = jest.mocked( useGasSponsorshipPreference, ); + const getLastMoneyAccountWithdrawAmountMock = jest.mocked( + getLastMoneyAccountWithdrawAmount, + ); + const updateMoneyAccountWithdrawAmountMock = jest.mocked( + updateMoneyAccountWithdrawAmount, + ); + const submitRequestToBackgroundMock = jest.mocked(submitRequestToBackground); beforeEach(() => { jest.resetAllMocks(); consoleWarnSpy = jest @@ -216,6 +250,8 @@ describe('useTransactionConfirm', () => { }); mockIsHardwareWalletError.mockReturnValue(false); mockIsUserRejectedHardwareWalletError.mockReturnValue(false); + getLastMoneyAccountWithdrawAmountMock.mockReturnValue(undefined); + updateMoneyAccountWithdrawAmountMock.mockResolvedValue(false); useHardwareWalletErrorMock.mockReturnValue({ showErrorModal: jest.fn(), dismissErrorModal: jest.fn(), @@ -897,4 +933,209 @@ describe('useTransactionConfirm', () => { await expect(onTransactionConfirm()).rejects.toThrow('Network error'); }); + + describe('money account withdraw', () => { + const PLACEHOLDER_DATA = '0xemptyexecute'; + const WITHDRAW_DATA = '0x1234567890abcdef1234567890abcdef12345678'; + const TELLER_ADDRESS = '0x1111111111111111111111111111111111111111'; + const MUSD_ADDRESS = '0x3333333333333333333333333333333333333333'; + const FUNDED_TRANSFER_DATA = + '0xa9059cbb0000000000000000000000002222222222222222222222222222222222222222000000000000000000000000000000000000000000000000000000000000c350'; + const ZERO_TRANSFER_DATA = + '0xa9059cbb00000000000000000000000022222222222222222222222222222222222222220000000000000000000000000000000000000000000000000000000000000000'; + const PLACEHOLDER_NESTED = [ + { + to: TELLER_ADDRESS, + data: '0x', + type: TransactionType.moneyAccountWithdraw, + }, + { + to: MUSD_ADDRESS, + data: '0x', + type: TransactionType.tokenMethodTransfer, + }, + ] as TransactionMeta['nestedTransactions']; + const FUNDED_NESTED = [ + { + to: TELLER_ADDRESS, + data: WITHDRAW_DATA, + type: TransactionType.moneyAccountWithdraw, + }, + { + to: MUSD_ADDRESS, + data: FUNDED_TRANSFER_DATA, + type: TransactionType.tokenMethodTransfer, + }, + ] as TransactionMeta['nestedTransactions']; + const FUNDED_UPDATE = { + withdrawData: WITHDRAW_DATA as Hex, + transferData: FUNDED_TRANSFER_DATA as Hex, + transactionData: PLACEHOLDER_DATA as Hex, + }; + + function expectedBatchData(from: string) { + return generateEIP7702BatchTransaction(from as Hex, FUNDED_NESTED ?? []) + .data; + } + + it('rebuilds parent execute from funded nested calls instead of approving stale parent data', async () => { + const { confirmation, onTransactionConfirm } = runHook({ + type: TransactionType.moneyAccountWithdraw, + txParamsData: PLACEHOLDER_DATA, + nestedTransactions: FUNDED_NESTED, + }); + + await onTransactionConfirm(); + + expect(updateMoneyAccountWithdrawAmountMock).not.toHaveBeenCalled(); + const approved = updateAndApproveTxMock.mock.calls[0][0]; + expect(approved.type).toBe(TransactionType.moneyAccountWithdraw); + expect(approved.txParams.to).toBe(confirmation.txParams.from); + expect(approved.txParams.data).toBe( + expectedBatchData(confirmation.txParams.from as string), + ); + expect(approved.txParams.data).not.toBe(PLACEHOLDER_DATA); + expect(submitRequestToBackgroundMock).toHaveBeenCalledWith( + 'updateTransaction', + [expect.objectContaining({ id: confirmation.id })], + ); + }); + + it('approves the encoded withdraw returned by the amount updater', async () => { + const { confirmation, onTransactionConfirm } = runHook({ + type: TransactionType.moneyAccountWithdraw, + nestedTransactions: PLACEHOLDER_NESTED, + }); + + getLastMoneyAccountWithdrawAmountMock.mockReturnValue('0.05'); + updateMoneyAccountWithdrawAmountMock.mockResolvedValue(FUNDED_UPDATE); + + await onTransactionConfirm(); + + expect(updateMoneyAccountWithdrawAmountMock).toHaveBeenCalledWith( + confirmation.id, + '0.05', + undefined, + ); + const approved = updateAndApproveTxMock.mock.calls[0][0]; + expect(approved.type).toBe(TransactionType.moneyAccountWithdraw); + expect(approved.nestedTransactions?.[0].data).toBe(WITHDRAW_DATA); + expect(approved.nestedTransactions?.[1].data).toBe(FUNDED_TRANSFER_DATA); + expect(approved.txParams.to).toBe(confirmation.txParams.from); + expect(approved.txParams.data).toBe( + expectedBatchData(confirmation.txParams.from as string), + ); + }); + + it('keeps gas sponsorship on money account withdraw when gasless support is unknown', async () => { + useIsGaslessSupportedMock.mockReturnValue({ + isSupported: false, + isSmartTransaction: false, + pending: false, + }); + + const { onTransactionConfirm } = runHook({ + type: TransactionType.moneyAccountWithdraw, + isGasFeeSponsored: true, + nestedTransactions: FUNDED_NESTED, + }); + + await onTransactionConfirm(); + + expect(updateAndApproveTxMock.mock.calls[0][0].isGasFeeSponsored).toBe( + true, + ); + }); + + it('does not approve when no withdraw amount has been committed', async () => { + const { onTransactionConfirm } = runHook({ + type: TransactionType.moneyAccountWithdraw, + }); + + const result = await onTransactionConfirm(); + + expect(result).toBe(false); + expect(updateMoneyAccountWithdrawAmountMock).not.toHaveBeenCalled(); + expect(updateAndApproveTxMock).not.toHaveBeenCalled(); + }); + + it('rethrows when the amount updater fails', async () => { + const { onTransactionConfirm } = runHook({ + type: TransactionType.moneyAccountWithdraw, + nestedTransactions: PLACEHOLDER_NESTED, + }); + + getLastMoneyAccountWithdrawAmountMock.mockReturnValue('0.05'); + updateMoneyAccountWithdrawAmountMock.mockRejectedValue( + new Error('Update Amount: Money Account Withdrawal: missing vault'), + ); + + await expect(onTransactionConfirm()).rejects.toThrow( + 'Update Amount: Money Account Withdrawal: missing vault', + ); + expect(updateAndApproveTxMock).not.toHaveBeenCalled(); + }); + + it('does not approve the unencoded placeholder when the amount updater does not commit', async () => { + const { onTransactionConfirm } = runHook({ + type: TransactionType.moneyAccountWithdraw, + }); + + getLastMoneyAccountWithdrawAmountMock.mockReturnValue('0.05'); + updateMoneyAccountWithdrawAmountMock.mockResolvedValue(false); + + const result = await onTransactionConfirm(); + + expect(result).toBe(false); + expect(updateAndApproveTxMock).not.toHaveBeenCalled(); + }); + + it('does not approve a zero-amount transfer even when the nested calldata is encoded', async () => { + const { confirmation, onTransactionConfirm } = runHook({ + type: TransactionType.moneyAccountWithdraw, + nestedTransactions: [ + { + to: TELLER_ADDRESS, + data: WITHDRAW_DATA, + type: TransactionType.moneyAccountWithdraw, + }, + { + to: MUSD_ADDRESS, + data: ZERO_TRANSFER_DATA, + type: TransactionType.tokenMethodTransfer, + }, + ], + }); + + getLastMoneyAccountWithdrawAmountMock.mockReturnValue('0.05'); + updateMoneyAccountWithdrawAmountMock.mockResolvedValue({ + withdrawData: WITHDRAW_DATA, + transferData: ZERO_TRANSFER_DATA, + }); + + const result = await onTransactionConfirm(); + + expect(result).toBe(false); + expect(updateMoneyAccountWithdrawAmountMock).toHaveBeenCalledTimes(1); + expect(updateAndApproveTxMock).not.toHaveBeenCalled(); + }); + + it('does not approve a returned update that is still missing encoded nested calldata', async () => { + const { onTransactionConfirm } = runHook({ + type: TransactionType.moneyAccountWithdraw, + nestedTransactions: PLACEHOLDER_NESTED, + }); + + getLastMoneyAccountWithdrawAmountMock.mockReturnValue('0.05'); + updateMoneyAccountWithdrawAmountMock.mockResolvedValue({ + withdrawData: '0x', + transferData: '0x', + }); + + const result = await onTransactionConfirm(); + + expect(result).toBe(false); + expect(updateAndApproveTxMock).not.toHaveBeenCalled(); + }); + }); }); diff --git a/ui/pages/confirmations/hooks/transactions/useTransactionConfirm.ts b/ui/pages/confirmations/hooks/transactions/useTransactionConfirm.ts index f1563e526e23..8899611497d1 100644 --- a/ui/pages/confirmations/hooks/transactions/useTransactionConfirm.ts +++ b/ui/pages/confirmations/hooks/transactions/useTransactionConfirm.ts @@ -1,15 +1,30 @@ import { + generateEIP7702BatchTransaction, TransactionMeta, TransactionType, } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; import { cloneDeep } from 'lodash'; -import { useCallback, useMemo } from 'react'; +import { useCallback } from 'react'; import { useSelector } from 'react-redux'; +import { hasTransactionType } from '../../../../../shared/lib/transactions.utils'; import { getCustomNonceValue } from '../../../../selectors'; +import { + selectTransactionById, + type TransactionState, +} from '../../../../selectors/transactionController'; import { useConfirmContext } from '../../context/confirm'; import { useSelectedGasFeeToken } from '../../components/confirm/info/hooks/useGasFeeToken'; import { updateAndApproveTx } from '../../../../store/actions'; +import { submitRequestToBackground } from '../../../../store/background-connection'; +import { + getLastMoneyAccountWithdrawAmount, + updateMoneyAccountWithdrawAmount, + type MoneyAccountWithdrawAmountUpdate, +} from '../../../../store/controller-actions/transaction-pay-controller'; +import type { MetaMaskReduxDispatch } from '../../../../store/types'; import { useIsGaslessSupported } from '../gas/useIsGaslessSupported'; import { useGaslessSupportedSmartTransactions } from '../gas/useGaslessSupportedSmartTransactions'; import { useGasSponsorshipPreference } from '../gas/useGasSponsorshipPreference'; @@ -22,6 +37,244 @@ import { useSendBundleHwNavigation } from '../../../../hooks/hardware-wallets/us import { useDispatch } from '../../../../store/hooks'; import { useShieldConfirm } from './useShieldConfirm'; import { useDappSwapActions } from './dapp-swap-comparison/useDappSwapActions'; +import { useTransactionAccountOverride } from './useTransactionAccountOverride'; + +const TRANSFER_SELECTOR = '0xa9059cbb'; + +function isEncodedCalldata(data: string | undefined): boolean { + return Boolean(data && data !== '0x' && data !== '0x00' && data.length > 10); +} + +function getTransferAmountRawFromData( + data: string | undefined, +): string | undefined { + if (!data) { + return undefined; + } + const lower = data.toLowerCase(); + if (!lower.startsWith(TRANSFER_SELECTOR) || lower.length < 138) { + return undefined; + } + try { + return BigInt(`0x${lower.slice(-64)}`).toString(); + } catch { + return undefined; + } +} + +/** + * Placeholder and zero-amount `transfer(recipient, 0)` calldata both look + * "encoded" (they have a selector). The Monadscan withdraw that transferred + * nothing was approved because we treated that zero transfer as ready. + * + * @param transaction - Transaction whose nested calls to inspect. + * @returns Whether nested withdraw + transfer encode a non-zero amount. + */ +function hasFundedWithdrawCalldata( + transaction: TransactionMeta | undefined, +): boolean { + if (!isEncodedCalldata(transaction?.nestedTransactions?.[0]?.data)) { + return false; + } + const amountRaw = getTransferAmountRawFromData( + transaction?.nestedTransactions?.[1]?.data, + ); + return Boolean(amountRaw && amountRaw !== '0'); +} + +function asFundedWithdrawUpdate( + value: unknown, +): MoneyAccountWithdrawAmountUpdate | undefined { + if (!value || typeof value !== 'object') { + return undefined; + } + if (!('withdrawData' in value) || !('transferData' in value)) { + return undefined; + } + const { withdrawData, transferData } = + value as MoneyAccountWithdrawAmountUpdate; + if (!isEncodedCalldata(withdrawData)) { + return undefined; + } + const amountRaw = getTransferAmountRawFromData(transferData); + if (!amountRaw || amountRaw === '0') { + return undefined; + } + const transactionData = + 'transactionData' in value + ? (value as MoneyAccountWithdrawAmountUpdate).transactionData + : undefined; + return { withdrawData, transferData, transactionData }; +} + +function applyWithdrawCalldata( + transaction: TransactionMeta | undefined, + update: MoneyAccountWithdrawAmountUpdate, +): TransactionMeta | null { + if (!transaction) { + return null; + } + const next = asWithdrawTransactionToApprove(transaction); + const nested = [...(next.nestedTransactions ?? [])]; + if (!nested[0] || !nested[1]) { + return null; + } + nested[0] = { + ...nested[0], + data: update.withdrawData, + }; + nested[1] = { + ...nested[1], + data: update.transferData, + }; + next.nestedTransactions = nested; + return withFundedBatchCalldata(next); +} + +/** + * Nested withdraw + transfer can look funded while the parent still carries + * the placeholder `execute([])` (or `execute` of empty calls). That parent + * mines successfully and moves no funds. Rebuild `to` + `data` from the + * nested calls so publish signs the real batch. + * + * @param transaction - Transaction with funded nested withdraw + transfer. + * @returns The same transaction with parent EIP-7702 execute calldata, or + * `null` when the nested calls are not funded. + */ +function withFundedBatchCalldata( + transaction: TransactionMeta | undefined, +): TransactionMeta | null { + if (!hasFundedWithdrawCalldata(transaction) || !transaction) { + return null; + } + const next = asWithdrawTransactionToApprove(transaction); + const from = next.txParams.from as Hex; + const batch = generateEIP7702BatchTransaction( + from, + next.nestedTransactions ?? [], + ); + next.txParams = { + ...next.txParams, + to: batch.to ?? from, + data: batch.data, + }; + return next; +} + +async function persistWithdrawTransaction( + transaction: TransactionMeta, +): Promise { + await submitRequestToBackground('updateTransaction', [transaction]); +} + +function readTransactionFromStore( + dispatch: MetaMaskReduxDispatch, + transactionId: string, +): TransactionMeta | undefined { + return dispatch((_, getState) => + selectTransactionById(getState() as TransactionState, transactionId), + ); +} + +function asWithdrawTransactionToApprove( + transaction: TransactionMeta, +): TransactionMeta { + const next = cloneDeep(transaction); + // `addTransactionBatch` stores the parent as `batch`. Persist the withdraw + // type on approve so the activity list does not fall through to + // "Contract interaction". + next.type = TransactionType.moneyAccountWithdraw; + return next; +} + +/** + * Withdraw placeholders have no calldata. Await the amount commit and approve + * only a funded withdraw (nested transfer amount > 0). Never approve the + * empty placeholder or `transfer(recipient, 0)` — both mine successfully + * and move no funds. + * + * Nested calls can look funded while the parent still carries the empty + * `execute()`. Rebuild `to` + `data` from those nested calls and persist + * before approve so publish signs the real batch. + * + * The background encoder returns the two nested data hexes. The UI bridge + * often strips a full TransactionMeta, so confirm patches those hexes onto + * the confirmation clone instead of requiring the IPC object to be funded. + * + * @param transactionMeta - The current confirmation. + * @param accountOverride - Destination EVM account, when set. + * @param dispatch - Redux dispatch used to read current transaction state. + * @returns The encoded transaction to approve, or `null` if it is not encoded. + */ +async function prepareMoneyAccountWithdrawTransaction( + transactionMeta: TransactionMeta, + accountOverride: Hex | undefined, + dispatch: MetaMaskReduxDispatch, +): Promise { + const fromStore = readTransactionFromStore(dispatch, transactionMeta.id); + const ready = + withFundedBatchCalldata(fromStore) ?? + withFundedBatchCalldata(transactionMeta); + + if (ready) { + await persistWithdrawTransaction(ready); + return ready; + } + + const amountHuman = getLastMoneyAccountWithdrawAmount(transactionMeta.id); + const hasAmount = Boolean(amountHuman && new BigNumber(amountHuman).gt(0)); + + if (!hasAmount) { + console.error( + 'Money Account withdraw: no committed amount, refusing to approve placeholder', + { transactionId: transactionMeta.id, amountHuman }, + ); + return null; + } + + const rawUpdate = await updateMoneyAccountWithdrawAmount( + transactionMeta.id, + amountHuman as string, + accountOverride, + ); + const update = asFundedWithdrawUpdate(rawUpdate); + if (update) { + const applied = + applyWithdrawCalldata(fromStore, update) ?? + applyWithdrawCalldata(transactionMeta, update); + if (applied) { + await persistWithdrawTransaction(applied); + return applied; + } + } + + const fromStoreAfterEncode = withFundedBatchCalldata( + readTransactionFromStore(dispatch, transactionMeta.id), + ); + if (fromStoreAfterEncode) { + await persistWithdrawTransaction(fromStoreAfterEncode); + return fromStoreAfterEncode; + } + + console.error( + 'Money Account withdraw: amount encode did not produce a funded batch, refusing to approve placeholder', + { + transactionId: transactionMeta.id, + amountHuman, + encodeCommitted: rawUpdate !== false, + encodeFunded: Boolean(update), + hasStoreTransaction: Boolean(fromStore), + hasStoreNestedCalls: Boolean( + fromStore?.nestedTransactions?.[0] && fromStore?.nestedTransactions[1], + ), + hasConfirmationNestedCalls: Boolean( + transactionMeta.nestedTransactions?.[0] && + transactionMeta.nestedTransactions[1], + ), + }, + ); + return null; +} export function useTransactionConfirm() { const dispatch = useDispatch(); @@ -30,6 +283,10 @@ export function useTransactionConfirm() { const selectedGasFeeToken = useSelectedGasFeeToken(); const { currentConfirmation: transactionMeta } = useConfirmContext(); + const accountOverride = useTransactionAccountOverride(); + const isMoneyAccountWithdraw = hasTransactionType(transactionMeta, [ + TransactionType.moneyAccountWithdraw, + ]); const { isSupported: isGaslessSupportedSTX } = useGaslessSupportedSmartTransactions(); @@ -42,33 +299,31 @@ export function useTransactionConfirm() { const { shouldRedirectToHwSigningPage, redirectToHwSigningPage } = useSendBundleHwNavigation({ transactionMeta }); - const newTransactionMeta = useMemo( - () => cloneDeep(transactionMeta), - [transactionMeta], - ); - - const handleSmartTransaction = useCallback(() => { - if (!selectedGasFeeToken) { - return; - } + const applySmartTransaction = useCallback( + (tx: TransactionMeta) => { + if (!selectedGasFeeToken) { + return; + } - newTransactionMeta.batchTransactions = [ - { - ...selectedGasFeeToken.transferTransaction, - type: TransactionType.gasPayment, - }, - ]; + tx.batchTransactions = [ + { + ...selectedGasFeeToken.transferTransaction, + type: TransactionType.gasPayment, + }, + ]; - newTransactionMeta.txParams.gas = selectedGasFeeToken.gas; - newTransactionMeta.txParams.maxFeePerGas = selectedGasFeeToken.maxFeePerGas; + tx.txParams.gas = selectedGasFeeToken.gas; + tx.txParams.maxFeePerGas = selectedGasFeeToken.maxFeePerGas; - newTransactionMeta.txParams.maxPriorityFeePerGas = - selectedGasFeeToken.maxPriorityFeePerGas; - }, [newTransactionMeta, selectedGasFeeToken]); + tx.txParams.maxPriorityFeePerGas = + selectedGasFeeToken.maxPriorityFeePerGas; + }, + [selectedGasFeeToken], + ); - const handleGasless7702 = useCallback(() => { - newTransactionMeta.isExternalSign = true; - }, [newTransactionMeta]); + const applyGasless7702 = useCallback((tx: TransactionMeta) => { + tx.isExternalSign = true; + }, []); const { handleShieldSubscriptionApprovalTransactionAfterConfirm, @@ -76,9 +331,23 @@ export function useTransactionConfirm() { } = useShieldConfirm(); const onTransactionConfirm = useCallback(async (): Promise => { - newTransactionMeta.customNonceValue = customNonceValue; + let txToApprove = cloneDeep(transactionMeta); + + if (isMoneyAccountWithdraw) { + const committed = await prepareMoneyAccountWithdrawTransaction( + transactionMeta, + accountOverride, + dispatch, + ); + if (!committed) { + return false; + } + txToApprove = committed; + } - updateSwapWithQuoteDetailsIfRequired(newTransactionMeta); + txToApprove.customNonceValue = customNonceValue; + + updateSwapWithQuoteDetailsIfRequired(txToApprove); // If the gasless flow is not supported (e.g. stx is disabled by the user, // or 7702 is not supported in the chain), or the user has opted out of @@ -88,10 +357,17 @@ export function useTransactionConfirm() { // limitation on the activity list will be that pre-populated transactions // on fresh installs will not show as sponsored even if they were because // this is not easily observable onchain for all cases. - newTransactionMeta.isGasFeeSponsored = - isGaslessSupported && - transactionMeta.isGasFeeSponsored && - !isSponsorshipOptedOut; + // + // Money Account withdrawals are sponsored on Monad by design (the money + // account has no native MON). `useIsGaslessSupported` can disagree with + // the 7702 publish hook; clearing the flag here made the hook skip and + // published the parent `execute()` instead — which mines and moves + // nothing when that parent is still the empty placeholder. + txToApprove.isGasFeeSponsored = isMoneyAccountWithdraw + ? Boolean(transactionMeta.isGasFeeSponsored) && !isSponsorshipOptedOut + : isGaslessSupported && + transactionMeta.isGasFeeSponsored && + !isSponsorshipOptedOut; // Revert the controller's `isExternalSign` flag when this account cannot // use an external relay — i.e. gasless is unsupported for the account/chain @@ -110,31 +386,29 @@ export function useTransactionConfirm() { isSponsorshipOptedOut || shouldRedirectToHwSigningPage); if (shouldClearExternalSign) { - newTransactionMeta.isExternalSign = false; + txToApprove.isExternalSign = false; } if (isGaslessSupportedSTX) { - handleSmartTransaction(); + applySmartTransaction(txToApprove); } else if (selectedGasFeeToken) { - handleGasless7702(); + applyGasless7702(txToApprove); } if (shouldRedirectToHwSigningPage) { - redirectToHwSigningPage(newTransactionMeta); + redirectToHwSigningPage(txToApprove); return false; } // transaction confirmation screen is a full screen modal that appear over the app and will be dismissed after transaction approved // navigate to shield settings page first before approving transaction to wait for subscription creation there - handleShieldSubscriptionApprovalTransactionAfterConfirm(newTransactionMeta); + handleShieldSubscriptionApprovalTransactionAfterConfirm(txToApprove); try { - await dispatch(updateAndApproveTx(newTransactionMeta, true, '')); + await dispatch(updateAndApproveTx(txToApprove, true, '')); onDappSwapCompleted(); return true; } catch (error) { - handleShieldSubscriptionApprovalTransactionAfterConfirmErr( - newTransactionMeta, - ); + handleShieldSubscriptionApprovalTransactionAfterConfirmErr(txToApprove); if (!isHardwareWalletError(error)) { // Non-hardware wallet errors - just rethrow @@ -148,22 +422,23 @@ export function useTransactionConfirm() { return false; } }, [ - newTransactionMeta, + accountOverride, + applyGasless7702, + applySmartTransaction, customNonceValue, + dispatch, + handleShieldSubscriptionApprovalTransactionAfterConfirm, + handleShieldSubscriptionApprovalTransactionAfterConfirmErr, isGaslessSupported, isGaslessSupportedSTX, + isMoneyAccountWithdraw, isSponsorshipOptedOut, - dispatch, - showErrorModal, - handleSmartTransaction, - handleGasless7702, + onDappSwapCompleted, + redirectToHwSigningPage, selectedGasFeeToken, - transactionMeta, shouldRedirectToHwSigningPage, - redirectToHwSigningPage, - handleShieldSubscriptionApprovalTransactionAfterConfirm, - handleShieldSubscriptionApprovalTransactionAfterConfirmErr, - onDappSwapCompleted, + showErrorModal, + transactionMeta, updateSwapWithQuoteDetailsIfRequired, ]); diff --git a/ui/pages/confirmations/hooks/transactions/useTransactionCustomAmount.test.ts b/ui/pages/confirmations/hooks/transactions/useTransactionCustomAmount.test.ts index f21b6bae5e44..1a7bb263d972 100644 --- a/ui/pages/confirmations/hooks/transactions/useTransactionCustomAmount.test.ts +++ b/ui/pages/confirmations/hooks/transactions/useTransactionCustomAmount.test.ts @@ -197,6 +197,20 @@ describe('useTransactionCustomAmount', () => { expect(result.current.amountFiat).toBe('123.46'); }); + it('does not use target amount USD for money account withdraw Max', () => { + const { result } = runHook({ + transactionMeta: { + ...MOCK_TRANSACTION_META, + type: TransactionType.moneyAccountWithdraw, + } as TransactionMeta, + isMaxAmount: true, + totals: { targetAmount: { usd: '123.456' } }, + requiredTokens: [{ amountUsd: '10', skipIfBalance: false }], + }); + + expect(result.current.amountFiat).toBe('10'); + }); + it('pre-populates from transaction data when user has not typed yet', () => { const { result } = runHook({ isMaxAmount: false, @@ -414,6 +428,43 @@ describe('useTransactionCustomAmount', () => { expect(result.current.amountFiat).toBe('33'); }); + it('does not set isMaxAmount for money account withdraw Max', () => { + const { result } = runHook({ + transactionMeta: { + ...MOCK_TRANSACTION_META, + type: TransactionType.moneyAccountWithdraw, + } as TransactionMeta, + payTokenBalanceUsd: 100, + }); + + act(() => { + result.current.updatePendingAmountPercentage(100); + }); + + expect(setIsMaxAmountMock).not.toHaveBeenCalled(); + }); + + it('clears isMaxAmount for money account withdraw when Max was previously set', () => { + const { result } = runHook({ + transactionMeta: { + ...MOCK_TRANSACTION_META, + type: TransactionType.moneyAccountWithdraw, + } as TransactionMeta, + payTokenBalanceUsd: 100, + isMaxAmount: true, + }); + + act(() => { + result.current.updatePendingAmountPercentage(100); + }); + + expect(setIsMaxAmountMock).toHaveBeenCalledWith( + MOCK_TRANSACTION_META.id, + false, + { isMoneyAccountDeposit: false }, + ); + }); + it('does not inflate max amount with token fiat rate when balanceUsdOverride is provided', () => { const updateTokenAmountMock = jest.fn(); const { result } = runHook({ diff --git a/ui/pages/confirmations/hooks/transactions/useTransactionCustomAmount.ts b/ui/pages/confirmations/hooks/transactions/useTransactionCustomAmount.ts index 5cd33dc5ba66..7fc6c0562f27 100644 --- a/ui/pages/confirmations/hooks/transactions/useTransactionCustomAmount.ts +++ b/ui/pages/confirmations/hooks/transactions/useTransactionCustomAmount.ts @@ -6,7 +6,10 @@ import { type TransactionMeta, } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; -import { setIsMaxAmount } from '../../../../store/controller-actions/transaction-pay-controller'; +import { + setIsMaxAmount, + setLastMoneyAccountWithdrawAmount, +} from '../../../../store/controller-actions/transaction-pay-controller'; import { upsertTransactionUIMetricsFragment } from '../../../../store/actions'; import { hasTransactionType } from '../../../../../shared/lib/transactions.utils'; import { useTokenFiatRate } from '../tokens/useTokenFiatRates'; @@ -203,16 +206,28 @@ export function useTransactionCustomAmount({ const amountFiat = useMemo(() => { // Quote target USD is the amount that will actually land after fees — // use it for Max display so the field matches the submitted total. + // Withdrawals: target USD is destination-received value after bridge + // fees, not the mUSD being withdrawn — keep the typed amount. const targetAmountUsd = totals?.targetAmount?.usd; - if (isMaxAmount && targetAmountUsd && targetAmountUsd !== '0') { + if ( + !isMoneyAccountWithdraw && + isMaxAmount && + targetAmountUsd && + targetAmountUsd !== '0' + ) { return new BigNumber(targetAmountUsd) .round(2, BigNumber.ROUND_HALF_UP) .toString(10); } return amountFiatState; - }, [amountFiatState, isMaxAmount, totals?.targetAmount?.usd]); + }, [ + amountFiatState, + isMaxAmount, + isMoneyAccountWithdraw, + totals?.targetAmount?.usd, + ]); const amountHuman = useMemo( () => @@ -238,6 +253,12 @@ export function useTransactionCustomAmount({ }, [payToken?.address, payToken?.chainId]); useEffect(() => { + // Record immediately so Send is enabled and confirm can encode without + // waiting for the 500ms debounce that writes calldata. + if (isMoneyAccountWithdraw && transactionId) { + setLastMoneyAccountWithdrawAmount(transactionId, amountHuman); + } + // When isMaxAmount is true, amountHuman is driven by quote-controller updates // (primaryRequiredToken.amountUsd). Re-feeding it into updateTokenAmount // changes txParams.data, which restarts the quote cycle (infinite loop). @@ -266,8 +287,10 @@ export function useTransactionCustomAmount({ amountHuman, disableUpdate, isMaxAmount, + isMoneyAccountWithdraw, payToken?.address, payToken?.chainId, + transactionId, ]); useEffect(() => { @@ -351,8 +374,11 @@ export function useTransactionCustomAmount({ .times(balanceUsdValue); // Max deposits also set isMaxAmount, with isMoneyAccountDeposit so TPC // runs them non-atomic instead of substituting token.balanceRaw. + // Do not set isMaxAmount for money-account withdraw. TPC would + // substitute on-chain mUSD `token.balanceRaw` instead of the typed + // withdrawable (mUSD + vmUSD) total. Matches mobile. const shouldSetMaxAmountMode = - percentage === 100 && !hasBalanceUsdOverride; + percentage === 100 && !hasBalanceUsdOverride && !isMoneyAccountWithdraw; // Keep the displayed fiat rounded except for balanceUsdOverride Max // (Perps withdraw), which must preserve the full typed balance. const newAmountFiat = ( @@ -424,6 +450,7 @@ export function useTransactionCustomAmount({ hasBalanceUsdOverride, isMaxAmount, isMoneyAccountDeposit, + isMoneyAccountWithdraw, isNoFeePayToken, payToken?.balanceRaw, payToken?.decimals, diff --git a/ui/pages/confirmations/hooks/transactions/useTransactionCustomAmountAlerts.test.ts b/ui/pages/confirmations/hooks/transactions/useTransactionCustomAmountAlerts.test.ts index af3b8bc206f2..ac7415407fb5 100644 --- a/ui/pages/confirmations/hooks/transactions/useTransactionCustomAmountAlerts.test.ts +++ b/ui/pages/confirmations/hooks/transactions/useTransactionCustomAmountAlerts.test.ts @@ -114,6 +114,31 @@ describe('useTransactionCustomAmountAlerts', () => { }); }); + it('sets hideResults to true when InsufficientMoneyAccountBalance alert exists', () => { + useAlertsMock.mockReturnValue( + createMockUseAlertsReturnValue({ + alerts: [ + createMockAlert({ + key: AlertsName.InsufficientMoneyAccountBalance, + message: 'Insufficient funds', + isBlocking: true, + severity: Severity.Danger, + }), + ], + hasDangerAlerts: true, + hasAlerts: true, + hasUnconfirmedDangerAlerts: true, + }), + ); + + const { result } = runHook(); + + expect(result.current).toStrictEqual({ + disableUpdate: false, + hideResults: true, + }); + }); + it('sets hideResults to true when InsufficientPayTokenBalance alert exists', () => { useAlertsMock.mockReturnValue( createMockUseAlertsReturnValue({ diff --git a/ui/pages/confirmations/hooks/transactions/useTransactionCustomAmountAlerts.ts b/ui/pages/confirmations/hooks/transactions/useTransactionCustomAmountAlerts.ts index 2c048ce8fda2..aa5a09d76182 100644 --- a/ui/pages/confirmations/hooks/transactions/useTransactionCustomAmountAlerts.ts +++ b/ui/pages/confirmations/hooks/transactions/useTransactionCustomAmountAlerts.ts @@ -8,6 +8,7 @@ const ALERTS_HIDE_RESULTS: string[] = [ AlertsName.AccountNoFunds, AlertsName.DepositLimit, AlertsName.InsufficientPayTokenBalance, + AlertsName.InsufficientMoneyAccountBalance, AlertsName.PayHardwareAccount, AlertsName.PerpsWithdrawBalanceUnavailable, AlertsName.SigningOrSubmitting, diff --git a/ui/pages/confirmations/hooks/transactions/useUpdateTokenAmount.test.ts b/ui/pages/confirmations/hooks/transactions/useUpdateTokenAmount.test.ts index 018a42480705..6c96fd877b29 100644 --- a/ui/pages/confirmations/hooks/transactions/useUpdateTokenAmount.test.ts +++ b/ui/pages/confirmations/hooks/transactions/useUpdateTokenAmount.test.ts @@ -8,9 +8,11 @@ import { updateAtomicBatchData } from '../../../../store/controller-actions/tran import { updateMoneyAccountDepositAmount, updateMoneyAccountWithdrawAmount, + type MoneyAccountWithdrawAmountUpdate, } from '../../../../store/controller-actions/transaction-pay-controller'; import * as useTransactionPayDataModule from '../pay/useTransactionPayData'; import * as transactionPayUtils from '../../utils/transaction-pay'; +import { useTransactionAccountOverride } from './useTransactionAccountOverride'; import { useUpdateTokenAmount } from './useUpdateTokenAmount'; jest.mock('../../../../store/actions', () => ({ @@ -41,6 +43,7 @@ jest.mock( jest.mock('../pay/useTransactionPayData'); jest.mock('../../utils/transaction-pay'); +jest.mock('./useTransactionAccountOverride'); const MOCK_RECIPIENT = '0x1234567890123456789012345678901234567890'; const MOCK_TOKEN_ADDRESS = '0xabcdef0123456789abcdef0123456789abcdef01'; @@ -110,12 +113,16 @@ function runHook({ describe('useUpdateTokenAmount', () => { const updateEditableParamsMock = jest.mocked(updateEditableParams); const updateAtomicBatchDataMock = jest.mocked(updateAtomicBatchData); + const useTransactionAccountOverrideMock = jest.mocked( + useTransactionAccountOverride, + ); beforeEach(() => { jest.resetAllMocks(); updateAtomicBatchDataMock.mockResolvedValue(undefined); updateEditableParamsMock.mockReturnValue((() => Promise.resolve()) as never); + useTransactionAccountOverrideMock.mockReturnValue(undefined); }); describe('updateTokenAmount', () => { @@ -158,7 +165,7 @@ describe('useUpdateTokenAmount', () => { it('dispatches the withdrawal commit path for a money account withdrawal batch', async () => { const updateWithdrawAmountMock = jest .mocked(updateMoneyAccountWithdrawAmount) - .mockResolvedValue(true); + .mockResolvedValue(false); const transactionMeta = createMockTransactionMeta({ nestedTransactions: [ @@ -185,10 +192,49 @@ describe('useUpdateTokenAmount', () => { expect(updateWithdrawAmountMock).toHaveBeenCalledWith( transactionMeta.id, '2', + undefined, ); expect(updateAtomicBatchDataMock).not.toHaveBeenCalled(); }); + it('passes the account override as the withdraw recipient', async () => { + const accountOverride = + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as const; + useTransactionAccountOverrideMock.mockReturnValue(accountOverride); + + const updateWithdrawAmountMock = jest + .mocked(updateMoneyAccountWithdrawAmount) + .mockResolvedValue(false); + + 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, + }, + }); + + // The commit promise settles in a microtask, and its `finally` clears the + // pending flag, so the state update must be flushed inside `act`. + await act(async () => { + result.current.updateTokenAmount('2'); + }); + + expect(updateWithdrawAmountMock).toHaveBeenCalledWith( + transactionMeta.id, + '2', + accountOverride, + ); + }); + it('does nothing when data is undefined', () => { const { result } = runHook({ tokenTransferData: { @@ -371,7 +417,9 @@ describe('useUpdateTokenAmount', () => { }); it('is true while a money withdrawal amount commit is in flight, and false once it resolves', async () => { - let resolveCommit: (value: boolean) => void = () => undefined; + let resolveCommit: ( + value: false | MoneyAccountWithdrawAmountUpdate, + ) => void = () => undefined; jest.mocked(updateMoneyAccountWithdrawAmount).mockReturnValue( new Promise((resolve) => { resolveCommit = resolve; @@ -401,7 +449,10 @@ describe('useUpdateTokenAmount', () => { await waitFor(() => expect(result.current.isUpdating).toBe(true)); await act(async () => { - resolveCommit(true); + resolveCommit({ + withdrawData: '0x', + transferData: '0x', + }); }); await waitFor(() => expect(result.current.isUpdating).toBe(false)); diff --git a/ui/pages/confirmations/hooks/transactions/useUpdateTokenAmount.ts b/ui/pages/confirmations/hooks/transactions/useUpdateTokenAmount.ts index 3364b88076e9..91900133d7e3 100644 --- a/ui/pages/confirmations/hooks/transactions/useUpdateTokenAmount.ts +++ b/ui/pages/confirmations/hooks/transactions/useUpdateTokenAmount.ts @@ -18,6 +18,7 @@ import { } from '../../../../store/controller-actions/transaction-pay-controller'; import { useTransactionPayPrimaryRequiredToken } from '../pay/useTransactionPayData'; import { useDispatch } from '../../../../store/hooks'; +import { useTransactionAccountOverride } from './useTransactionAccountOverride'; const ERC20_ABI = ['function transfer(address to, uint256 amount)']; let erc20Interface: Interface | null = null; @@ -61,6 +62,7 @@ export function useUpdateTokenAmount() { ); const primaryRequiredToken = useTransactionPayPrimaryRequiredToken(); + const accountOverride = useTransactionAccountOverride(); const decimals = primaryRequiredToken?.decimals; @@ -138,12 +140,16 @@ export function useUpdateTokenAmount() { return; } - // Same shape as deposits: the placeholder withdraw + transfer batch has - // no calldata to parse, and the background commit path resolves the - // recipient (the selected account) and the vault rate. + // Placeholder withdraw + transfer batch has no calldata to parse. The + // background commit re-encodes both calls (vault rate + recipient). + // Confirm patches the returned hexes onto the approval clone. if (moneyAccountFlow === MoneyAccountFlow.Withdraw) { setMoneyAccountDisplayedAmount(amountHuman, transactionId); - updateMoneyAccountWithdrawAmount(transactionId, amountHuman) + updateMoneyAccountWithdrawAmount( + transactionId, + amountHuman, + accountOverride, + ) .then((didCommit) => { if (didCommit) { setMoneyAccountCommittedAmount(amountHuman, transactionId); @@ -205,6 +211,7 @@ export function useUpdateTokenAmount() { ); }, [ + accountOverride, amountRaw, data, decimals, diff --git a/ui/pages/confirmations/hooks/useConfirmationAlerts.test.ts b/ui/pages/confirmations/hooks/useConfirmationAlerts.test.ts index caf81e73ac63..167d6c079f37 100644 --- a/ui/pages/confirmations/hooks/useConfirmationAlerts.test.ts +++ b/ui/pages/confirmations/hooks/useConfirmationAlerts.test.ts @@ -2,6 +2,10 @@ import { renderHookWithConfirmContextProvider } from '../../../../test/lib/confi import mockState from '../../../../test/data/mock-state.json'; import useConfirmationAlerts from './useConfirmationAlerts'; +jest.mock('@metamask/react-data-query', () => ({ + useQuery: () => ({ data: undefined, isLoading: false, isError: false }), +})); + const mockUseNavigate = jest.fn(); jest.mock('react-router-dom', () => { return { diff --git a/ui/pages/confirmations/hooks/useConfirmationAlerts.ts b/ui/pages/confirmations/hooks/useConfirmationAlerts.ts index ef8e749821b1..7701fae2f6e0 100644 --- a/ui/pages/confirmations/hooks/useConfirmationAlerts.ts +++ b/ui/pages/confirmations/hooks/useConfirmationAlerts.ts @@ -13,6 +13,7 @@ import { useSuggestedGasFeeHighAlert } from './alerts/transactions/useSuggestedG import { useInsufficientBalanceAlerts } from './alerts/transactions/useInsufficientBalanceAlerts'; import { useAccountNoFundsAlert } from './alerts/transactions/useAccountNoFundsAlert'; import { useInsufficientPayTokenBalanceAlert } from './alerts/transactions/useInsufficientPayTokenBalanceAlert'; +import { useInsufficientMoneyAccountBalanceAlert } from './alerts/transactions/useInsufficientMoneyAccountBalanceAlert'; import { usePerpsWithdrawInsufficientBalanceAlert } from './alerts/transactions/usePerpsWithdrawInsufficientBalanceAlert'; import { useTransactionDepositLimitAlert } from './alerts/transactions/useTransactionDepositLimitAlert'; import { useMultipleApprovalsAlerts } from './alerts/transactions/useMultipleApprovalsAlerts'; @@ -59,6 +60,8 @@ function useTransactionAlerts(): Alert[] { const insufficientBalanceAlerts = useInsufficientBalanceAlerts(); const insufficientPayTokenBalanceAlerts = useInsufficientPayTokenBalanceAlert(); + const insufficientMoneyAccountBalanceAlerts = + useInsufficientMoneyAccountBalanceAlert(); const perpsWithdrawInsufficientBalanceAlerts = usePerpsWithdrawInsufficientBalanceAlert(); const multipleApprovalAlerts = useMultipleApprovalsAlerts(); @@ -87,6 +90,7 @@ function useTransactionAlerts(): Alert[] { ...gasTooLowAlerts, ...insufficientBalanceAlerts, ...insufficientPayTokenBalanceAlerts, + ...insufficientMoneyAccountBalanceAlerts, ...perpsWithdrawInsufficientBalanceAlerts, ...multipleApprovalAlerts, ...noGasPriceAlerts, @@ -113,6 +117,7 @@ function useTransactionAlerts(): Alert[] { gasTooLowAlerts, insufficientBalanceAlerts, insufficientPayTokenBalanceAlerts, + insufficientMoneyAccountBalanceAlerts, perpsWithdrawInsufficientBalanceAlerts, multipleApprovalAlerts, noGasPriceAlerts, diff --git a/ui/pages/confirmations/utils/transaction-pay.test.ts b/ui/pages/confirmations/utils/transaction-pay.test.ts index 68a2db04f1b6..c43230bb9249 100644 --- a/ui/pages/confirmations/utils/transaction-pay.test.ts +++ b/ui/pages/confirmations/utils/transaction-pay.test.ts @@ -5,14 +5,20 @@ import type { TransactionPaymentToken, } from '@metamask/transaction-pay-controller'; import { CHAIN_IDS } from '../../../../shared/constants/network'; +import { updateAtomicBatchData } from '../../../store/controller-actions/transaction-controller'; import { Asset, AssetStandard } from '../types/send'; import { getTokenTransferData, getTokenAddress, getAvailableTokens, isTokenBlocked, + replaceAccountInNestedTransactions, } from './transaction-pay'; +jest.mock('../../../store/controller-actions/transaction-controller', () => ({ + updateAtomicBatchData: jest.fn(), +})); + const CHAIN_ID_MOCK = '0x1' as Hex; const TOKEN_ADDRESS_MOCK = '0x1234567890abcdef1234567890abcdef12345678' as Hex; const TOKEN_ADDRESS_2_MOCK = @@ -429,4 +435,166 @@ describe('transaction-pay utils', () => { ).toBe(false); }); }); + + describe('replaceAccountInNestedTransactions', () => { + const TRANSACTION_ID = 'tx-1'; + const OLD_ADDRESS = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + const NEW_ADDRESS = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + const OLD_WORD = + '000000000000000000000000aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; + const NEW_WORD = + '000000000000000000000000bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + const SELECTOR = '0x23b872dd'; + + const updateAtomicBatchDataMock = jest.mocked(updateAtomicBatchData); + + beforeEach(() => { + updateAtomicBatchDataMock.mockReset(); + updateAtomicBatchDataMock.mockResolvedValue(undefined); + }); + + it('replaces the old address word in nested transaction data', () => { + replaceAccountInNestedTransactions({ + transactionId: TRANSACTION_ID, + nestedTransactions: [{ data: `${SELECTOR}${OLD_WORD}` as Hex }], + oldAddress: OLD_ADDRESS, + newAddress: NEW_ADDRESS, + }); + + expect(updateAtomicBatchDataMock).toHaveBeenCalledTimes(1); + expect(updateAtomicBatchDataMock).toHaveBeenCalledWith({ + transactionId: TRANSACTION_ID, + transactionIndex: 0, + transactionData: `${SELECTOR}${NEW_WORD}`, + }); + }); + + it('replaces every occurrence within the same nested transaction data', () => { + replaceAccountInNestedTransactions({ + transactionId: TRANSACTION_ID, + nestedTransactions: [ + { data: `${SELECTOR}${OLD_WORD}${OLD_WORD}` as Hex }, + ], + oldAddress: OLD_ADDRESS, + newAddress: NEW_ADDRESS, + }); + + expect(updateAtomicBatchDataMock).toHaveBeenCalledWith({ + transactionId: TRANSACTION_ID, + transactionIndex: 0, + transactionData: `${SELECTOR}${NEW_WORD}${NEW_WORD}`, + }); + }); + + it('matches address case-insensitively and writes lowercase output', () => { + replaceAccountInNestedTransactions({ + transactionId: TRANSACTION_ID, + nestedTransactions: [ + { data: `${SELECTOR}${OLD_WORD.toUpperCase()}` as Hex }, + ], + oldAddress: OLD_ADDRESS.toUpperCase(), + newAddress: NEW_ADDRESS, + }); + + expect(updateAtomicBatchDataMock).toHaveBeenCalledWith({ + transactionId: TRANSACTION_ID, + transactionIndex: 0, + transactionData: `${SELECTOR}${NEW_WORD}`, + }); + }); + + it('preserves the original index when only some nested transactions match', () => { + replaceAccountInNestedTransactions({ + transactionId: TRANSACTION_ID, + nestedTransactions: [ + { data: '0xdeadbeef' as Hex }, + { data: `${SELECTOR}${OLD_WORD}` as Hex }, + { data: undefined }, + ], + oldAddress: OLD_ADDRESS, + newAddress: NEW_ADDRESS, + }); + + expect(updateAtomicBatchDataMock).toHaveBeenCalledTimes(1); + expect(updateAtomicBatchDataMock).toHaveBeenCalledWith({ + transactionId: TRANSACTION_ID, + transactionIndex: 1, + transactionData: `${SELECTOR}${NEW_WORD}`, + }); + }); + + it('does nothing when oldAddress is undefined', () => { + replaceAccountInNestedTransactions({ + transactionId: TRANSACTION_ID, + nestedTransactions: [{ data: `${SELECTOR}${OLD_WORD}` as Hex }], + oldAddress: undefined, + newAddress: NEW_ADDRESS, + }); + + expect(updateAtomicBatchDataMock).not.toHaveBeenCalled(); + }); + + it('does nothing when nestedTransactions is undefined or empty', () => { + replaceAccountInNestedTransactions({ + transactionId: TRANSACTION_ID, + nestedTransactions: undefined, + oldAddress: OLD_ADDRESS, + newAddress: NEW_ADDRESS, + }); + replaceAccountInNestedTransactions({ + transactionId: TRANSACTION_ID, + nestedTransactions: [], + oldAddress: OLD_ADDRESS, + newAddress: NEW_ADDRESS, + }); + + expect(updateAtomicBatchDataMock).not.toHaveBeenCalled(); + }); + + it('does nothing when old and new addresses are the same', () => { + replaceAccountInNestedTransactions({ + transactionId: TRANSACTION_ID, + nestedTransactions: [{ data: `${SELECTOR}${OLD_WORD}` as Hex }], + oldAddress: OLD_ADDRESS, + newAddress: OLD_ADDRESS.toUpperCase(), + }); + + expect(updateAtomicBatchDataMock).not.toHaveBeenCalled(); + }); + + it('skips nested transactions whose data does not contain the old word', () => { + replaceAccountInNestedTransactions({ + transactionId: TRANSACTION_ID, + nestedTransactions: [{ data: '0xdeadbeef' as Hex }], + oldAddress: OLD_ADDRESS, + newAddress: NEW_ADDRESS, + }); + + expect(updateAtomicBatchDataMock).not.toHaveBeenCalled(); + }); + + it('logs an error when updateAtomicBatchData rejects', async () => { + const error = new Error('boom'); + const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => undefined); + updateAtomicBatchDataMock.mockRejectedValueOnce(error); + + replaceAccountInNestedTransactions({ + transactionId: TRANSACTION_ID, + nestedTransactions: [{ data: `${SELECTOR}${OLD_WORD}` as Hex }], + oldAddress: OLD_ADDRESS, + newAddress: NEW_ADDRESS, + }); + + await Promise.resolve(); + await Promise.resolve(); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Failed to update account in nested transaction', + error, + ); + consoleErrorSpy.mockRestore(); + }); + }); }); diff --git a/ui/pages/confirmations/utils/transaction-pay.ts b/ui/pages/confirmations/utils/transaction-pay.ts index f8837f1eec9d..706cb755d4c7 100644 --- a/ui/pages/confirmations/utils/transaction-pay.ts +++ b/ui/pages/confirmations/utils/transaction-pay.ts @@ -1,4 +1,7 @@ -import { TransactionMeta } from '@metamask/transaction-controller'; +import { + TransactionMeta, + type NestedTransactionMetadata, +} from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { PaymentOverride, @@ -8,12 +11,78 @@ import { import { BigNumber } from 'bignumber.js'; import { isTestNetwork } from '../../../helpers/utils/network-helper'; import { isPostQuoteWithdrawTransaction } from '../../../../shared/lib/transactions.utils'; +import { updateAtomicBatchData } from '../../../store/controller-actions/transaction-controller'; import { setPaymentOverride } from '../../../store/controller-actions/transaction-pay-controller'; import type { BlockedPayTokensListConfig } from '../selectors/feature-flags'; import { Asset, AssetStandard } from '../types/send'; const FOUR_BYTE_TOKEN_TRANSFER = '0xa9059cbb'; +function toAddressWord(address: string): string { + return address.toLowerCase().replace(/^0x/u, '').padStart(64, '0'); +} + +/** + * Replace every occurrence of `oldAddress` (encoded as a 32-byte ABI word) + * inside the `data` of each nested transaction with `newAddress`, and persist + * the change via `updateAtomicBatchData`. No-ops when there are no nested + * transactions or no old address to replace. + * + * Mirrors mobile `replaceAccountInNestedTransactions`. Used when the From-row + * changes the withdraw recipient so the transfer calldata is rewritten + * immediately, not only on the next amount encode. + * + * @param params - Replacement inputs. + * @param params.transactionId - Id of the parent batch transaction. + * @param params.nestedTransactions - Nested calls to scan. + * @param params.oldAddress - Address currently encoded in calldata. + * @param params.newAddress - Address to write in its place. + */ +export function replaceAccountInNestedTransactions({ + transactionId, + nestedTransactions, + oldAddress, + newAddress, +}: { + transactionId: string; + nestedTransactions: NestedTransactionMetadata[] | undefined; + oldAddress: string | undefined; + newAddress: string; +}): void { + if (!oldAddress || !nestedTransactions?.length) { + return; + } + + const oldWord = toAddressWord(oldAddress); + const newWord = toAddressWord(newAddress); + + if (oldWord === newWord) { + return; + } + + nestedTransactions.forEach((nested, index) => { + const { data } = nested; + if (!data) { + return; + } + + const lowerData = data.toLowerCase(); + if (!lowerData.includes(oldWord)) { + return; + } + + const newData = lowerData.split(oldWord).join(newWord) as Hex; + + updateAtomicBatchData({ + transactionId, + transactionIndex: index, + transactionData: newData, + }).catch((error) => { + console.error('Failed to update account in nested transaction', error); + }); + }); +} + export function getTokenTransferData( transactionMeta: TransactionMeta | undefined, ): diff --git a/ui/selectors/activity/enrich-local-activity.test.ts b/ui/selectors/activity/enrich-local-activity.test.ts index bdcac6a9d161..2c7a43f20221 100644 --- a/ui/selectors/activity/enrich-local-activity.test.ts +++ b/ui/selectors/activity/enrich-local-activity.test.ts @@ -131,6 +131,52 @@ describe('enrichLocalActivity', () => { expect(enriched.type).toBe('revokeSpendingCap'); }); + it('maps a money account withdraw batch to a send of mUSD', () => { + const group = buildTokenTransferGroup({ + type: TransactionType.batch, + txParams: { + from: '0x1111111111111111111111111111111111111111', + to: '0x3333333333333333333333333333333333333333', + data: '0x', + value: '0x0', + }, + nestedTransactions: [ + { type: TransactionType.moneyAccountWithdraw, data: '0x00' }, + { + type: TransactionType.tokenMethodTransfer, + to: DAI_ADDRESS, + data: TRANSFER_DATA, + }, + ], + }); + const activity = { + type: 'contractInteraction', + chainId: 'eip155:1', + status: 'success', + timestamp: 1, + data: { + from: '0x1111111111111111111111111111111111111111', + to: '0x3333333333333333333333333333333333333333', + }, + } as ActivityListItem; + + const enriched = enrichLocalActivity(activity, group); + + expect(enriched).toMatchObject({ + type: 'send', + data: { + from: '0x1111111111111111111111111111111111111111', + to: RECIPIENT, + token: { + direction: 'out', + symbol: 'mUSD', + decimals: 6, + amount: '10000000000000000000', + }, + }, + }); + }); + it('does not change unrelated activity items', () => { const group = buildTokenTransferGroup({ type: TransactionType.simpleSend, diff --git a/ui/selectors/activity/enrich-local-activity.ts b/ui/selectors/activity/enrich-local-activity.ts index b05433637ce7..4016eab7436e 100644 --- a/ui/selectors/activity/enrich-local-activity.ts +++ b/ui/selectors/activity/enrich-local-activity.ts @@ -1,10 +1,18 @@ +import { + MUSD_DECIMALS, + MUSD_TOKEN, + MUSD_TOKEN_ASSET_ID_BY_CHAIN, +} from '@metamask/money-account-utils'; import { TransactionType } from '@metamask/transaction-controller'; +import { KnownCaipNamespace, toCaipChainId } from '@metamask/utils'; import type { ActivityListItem } from '../../../shared/lib/activity/types'; +import { toAssetId } from '../../../shared/lib/asset-utils'; import type { TransactionGroup } from '../../../shared/lib/multichain/types'; import { parseApprovalTransactionData, parseStandardTokenTransactionData, } from '../../../shared/lib/transaction.utils'; +import { hasTransactionType } from '../../../shared/lib/transactions.utils'; import { enrichLocalMusdClaimActivity } from './enrich-local-musd-claim'; const TOKEN_TRANSFER_TYPES = new Set([ @@ -113,11 +121,70 @@ function enrichApprovalActivity( }; } +function enrichMoneyAccountWithdrawActivity( + activity: ActivityListItem, + transactionGroup: LocalActivitySource, +): ActivityListItem { + const transaction = transactionGroup.initialTransaction; + if ( + !hasTransactionType(transaction, [TransactionType.moneyAccountWithdraw]) + ) { + return activity; + } + + const transfer = transaction.nestedTransactions?.find( + (nested) => nested.type === TransactionType.tokenMethodTransfer, + ); + const parsed = transfer?.data + ? parseStandardTokenTransactionData(transfer.data) + : undefined; + const recipient = parsed?.args?._to ?? parsed?.args?.to; + if (typeof recipient !== 'string') { + return activity; + } + + const parsedAmount = parsed?.args?._value ?? parsed?.args?.value; + const amount = + parsedAmount === undefined || parsedAmount === null + ? undefined + : parsedAmount.toString(); + const tokenAddress = transfer?.to; + const { chainId } = transaction; + const assetId = + (chainId ? MUSD_TOKEN_ASSET_ID_BY_CHAIN[chainId] : undefined) ?? + (tokenAddress && chainId + ? toAssetId( + tokenAddress, + toCaipChainId( + KnownCaipNamespace.Eip155, + Number.parseInt(chainId, 16).toString(), + ), + ) + : undefined); + + return { + ...activity, + type: 'send', + data: { + from: transaction.txParams?.from ?? '', + to: recipient, + token: { + direction: 'out', + symbol: MUSD_TOKEN.symbol, + decimals: MUSD_DECIMALS, + ...(amount ? { amount } : {}), + ...(assetId ? { assetId } : {}), + }, + }, + }; +} + export function enrichLocalActivity( activity: ActivityListItem, transactionGroup: LocalActivitySource, ): ActivityListItem { let next = activity; + next = enrichMoneyAccountWithdrawActivity(next, transactionGroup); next = enrichTokenTransferActivity(next, transactionGroup); next = enrichApprovalActivity(next, transactionGroup); next = enrichLocalMusdClaimActivity(next, transactionGroup); diff --git a/ui/store/controller-actions/transaction-pay-controller.test.ts b/ui/store/controller-actions/transaction-pay-controller.test.ts index 9fc4fb48306e..5f58fe2ed5f7 100644 --- a/ui/store/controller-actions/transaction-pay-controller.test.ts +++ b/ui/store/controller-actions/transaction-pay-controller.test.ts @@ -6,6 +6,8 @@ import { setPostQuote, setAccountOverride, setPaymentOverride, + updateMoneyAccountWithdrawAmount, + getLastMoneyAccountWithdrawAmount, } from './transaction-pay-controller'; jest.mock('../background-connection'); @@ -155,4 +157,32 @@ describe('transaction-pay-controller actions', () => { ); }); }); + + describe('updateMoneyAccountWithdrawAmount', () => { + it('forwards transactionId, amount, and recipient override', async () => { + const transactionId = 'tx-withdraw'; + const recipientOverride = + '0xabcdef1234567890abcdef1234567890abcdef12' as const; + + await updateMoneyAccountWithdrawAmount( + transactionId, + '0.05', + recipientOverride, + ); + + expect(mockSubmitRequestToBackground).toHaveBeenCalledWith( + 'updateMoneyAccountWithdrawAmount', + [transactionId, '0.05', recipientOverride], + ); + }); + + it('records the last withdraw amount for confirm to re-encode', async () => { + const transactionId = 'tx-withdraw-last-amount'; + mockSubmitRequestToBackground.mockResolvedValue({ id: transactionId }); + + await updateMoneyAccountWithdrawAmount(transactionId, '1.25'); + + expect(getLastMoneyAccountWithdrawAmount(transactionId)).toBe('1.25'); + }); + }); }); diff --git a/ui/store/controller-actions/transaction-pay-controller.ts b/ui/store/controller-actions/transaction-pay-controller.ts index 9e4b34e4e08c..309eeeeb0473 100644 --- a/ui/store/controller-actions/transaction-pay-controller.ts +++ b/ui/store/controller-actions/transaction-pay-controller.ts @@ -2,6 +2,12 @@ import type { PaymentOverride } from '@metamask/transaction-pay-controller'; import type { Hex } from '@metamask/utils'; import { submitRequestToBackground } from '../background-connection'; +export type MoneyAccountWithdrawAmountUpdate = { + transactionData?: Hex; + transferData: Hex; + withdrawData: Hex; +}; + export async function updateTransactionPaymentToken({ transactionId, tokenAddress, @@ -82,23 +88,76 @@ export async function createMoneyAccountWithdrawTransaction(): Promise<{ ); } +const lastWithdrawAmountByTransactionId = new Map(); +const lastWithdrawAmountListeners = new Set<() => void>(); + /** - * Prepares and commits a Money Account withdrawal amount in the background: - * re-encodes the withdraw + transfer calldata for the new amount, with the - * redeemed mUSD forwarded to the currently selected account. Superseded - * intents resolve `false`. + * Last human-readable withdraw amount dispatched for this transaction. + * Confirm uses this so Send can re-encode even if the confirmation UI still + * holds the unencoded placeholder. The footer uses it to enable Send when + * TPC has no required token / quote totals (direct withdraws). + * + * @param transactionId - Id of the Money Account withdrawal transaction. + * @returns The last amount, if any update has been dispatched. + */ +export function getLastMoneyAccountWithdrawAmount( + transactionId: string, +): string | undefined { + return lastWithdrawAmountByTransactionId.get(transactionId); +} + +/** + * Record the typed withdraw amount immediately so Send can encode before the + * debounced background write finishes. * * @param transactionId - Id of the Money Account withdrawal transaction. * @param amountHuman - Exact human-readable amount. - * @returns Whether this intent committed transaction metadata. + */ +export function setLastMoneyAccountWithdrawAmount( + transactionId: string, + amountHuman: string, +): void { + lastWithdrawAmountByTransactionId.set(transactionId, amountHuman); + lastWithdrawAmountListeners.forEach((listener) => listener()); +} + +/** + * Subscribe to last-withdraw-amount writes. Used by + * `useLastMoneyAccountWithdrawAmount`. + * + * @param onStoreChange - Listener invoked after any amount is recorded. + * @returns Unsubscribe function. + */ +export function subscribeLastMoneyAccountWithdrawAmount( + onStoreChange: () => void, +): () => void { + lastWithdrawAmountListeners.add(onStoreChange); + return () => { + lastWithdrawAmountListeners.delete(onStoreChange); + }; +} + +/** + * Encodes and commits a Money Account withdrawal amount in the background. + * Confirm approves the returned transaction so Send does not use the empty + * placeholder. Superseded or zero-amount intents resolve `false`. + * + * @param transactionId - Id of the Money Account withdrawal transaction. + * @param amountHuman - Exact human-readable amount. + * @param recipientOverride - Optional EVM address to receive the redeemed mUSD. + * @returns The encoded nested calldata, or `false` if this intent did not + * commit. */ export async function updateMoneyAccountWithdrawAmount( transactionId: string, amountHuman: string, -): Promise { + recipientOverride?: Hex, +): Promise { + setLastMoneyAccountWithdrawAmount(transactionId, amountHuman); return await submitRequestToBackground('updateMoneyAccountWithdrawAmount', [ transactionId, amountHuman, + recipientOverride, ]); }