diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 6491a8e01a0..256dacb7240 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `atomic` field on `TransactionConfig` / `TransactionData` for a generic non-atomic post-Relay flow: when `atomic` is `false`, Relay bridges to an internally derived recipient (`getPaymentOverrideData` recipient for post-quote flows, otherwise the transaction's own `from`) and the second leg is submitted separately after completion, replacing the removed `relay-post-ma-vault` module ([#9497](https://github.com/MetaMask/core/pull/9497)) + ### Changed - Bump `@metamask/ramps-controller` from `^17.0.0` to `^17.1.0` ([#9646](https://github.com/MetaMask/core/pull/9646)) diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index 43794db9f30..2aa314e4ff9 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -17,6 +17,7 @@ import type { GetDelegationTransactionCallback, GetPaymentOverrideDataCallback, PolymarketCallbacks, + TransactionConfig, TransactionConfigCallback, TransactionData, TransactionPayControllerMessenger, @@ -148,30 +149,23 @@ export class TransactionPayController extends BaseController< callback: TransactionConfigCallback, ): void { this.#updateTransactionData(transactionId, (transactionData) => { - const config = { - isMaxAmount: transactionData.isMaxAmount, - isPostQuote: transactionData.isPostQuote, + const config: TransactionConfig = { + accountOverride: transactionData.accountOverride, + atomic: transactionData.atomic, isHyperliquidSource: transactionData.isHyperliquidSource, + isMaxAmount: transactionData.isMaxAmount, isPolymarketDepositWallet: transactionData.isPolymarketDepositWallet, + isPostQuote: transactionData.isPostQuote, isQuoteRequired: transactionData.isQuoteRequired, - refundTo: transactionData.refundTo, - accountOverride: transactionData.accountOverride, paymentOverride: transactionData.paymentOverride, + refundTo: transactionData.refundTo, }; const previousAccountOverride = config.accountOverride; callback(config); - transactionData.accountOverride = config.accountOverride; - transactionData.isMaxAmount = config.isMaxAmount; - transactionData.isPostQuote = config.isPostQuote; - transactionData.isHyperliquidSource = config.isHyperliquidSource; - transactionData.isPolymarketDepositWallet = - config.isPolymarketDepositWallet; - transactionData.isQuoteRequired = config.isQuoteRequired; - transactionData.refundTo = config.refundTo; - transactionData.paymentOverride = config.paymentOverride; + Object.assign(transactionData, config); if ( !config.isPostQuote && diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-post-ma-vault.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-post-ma-vault.test.ts deleted file mode 100644 index 04c19b3ac12..00000000000 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-post-ma-vault.test.ts +++ /dev/null @@ -1,257 +0,0 @@ -import type { TransactionMeta } from '@metamask/transaction-controller'; -import type { Hex } from '@metamask/utils'; - -import type { - TransactionPayControllerMessenger, - TransactionPayQuote, -} from '../../types.js'; -import { submitMoneyAccountVaultDeposit } from '../../utils/ma-vault-deposit.js'; -import { getTransferredAmountFromTxHash } from '../../utils/transaction.js'; -import { MUSD_MONAD_FIAT_ASSET } from '../fiat/constants.js'; -import { FALLBACK_HASH } from './constants.js'; -import { submitPostRelayVaultDeposit } from './relay-post-ma-vault.js'; -import type { RelayCompletionOutcome, RelayQuote } from './types.js'; - -jest.mock('../../utils/transaction'); -jest.mock('../../utils/ma-vault-deposit'); - -const TRANSACTION_ID_MOCK = 'tx-id'; -const MONEY_ACCOUNT_ADDRESS_MOCK = - '0xf9611ffaa445d0c5e728b5a514747e85405ad19b' as Hex; -const TARGET_HASH_MOCK = - '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890' as Hex; -const ON_CHAIN_AMOUNT_MOCK = '535000'; -const MINIMUM_AMOUNT_MOCK = '530000'; -const VAULT_HASH_MOCK = - '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' as Hex; - -const TRANSACTION_MOCK = { - id: TRANSACTION_ID_MOCK, - txParams: { from: MONEY_ACCOUNT_ADDRESS_MOCK }, -} as unknown as TransactionMeta; - -function buildMessenger(): TransactionPayControllerMessenger { - return {} as TransactionPayControllerMessenger; -} - -function buildQuote(overrides?: { - recipient?: Hex; - from?: Hex; - minimumAmount?: string; -}): TransactionPayQuote { - return { - request: { - from: overrides?.from ?? MONEY_ACCOUNT_ADDRESS_MOCK, - recipient: overrides?.recipient, - }, - original: { - details: { - currencyOut: { - minimumAmount: overrides?.minimumAmount ?? MINIMUM_AMOUNT_MOCK, - }, - }, - }, - } as unknown as TransactionPayQuote; -} - -function buildCompletion(targetHash?: Hex): RelayCompletionOutcome { - return { status: 'success', targetHash }; -} - -describe('submitPostRelayVaultDeposit', () => { - const getTransferredAmountMock = jest.mocked(getTransferredAmountFromTxHash); - const submitMoneyAccountVaultDepositMock = jest.mocked( - submitMoneyAccountVaultDeposit, - ); - - beforeEach(() => { - jest.resetAllMocks(); - submitMoneyAccountVaultDepositMock.mockResolvedValue({ - transactionHash: VAULT_HASH_MOCK, - }); - }); - - describe('resolvePostRelayAmount — on-chain path', () => { - it('uses the on-chain transferred amount when targetHash is present and not FALLBACK_HASH', async () => { - getTransferredAmountMock.mockResolvedValue({ - amountRaw: ON_CHAIN_AMOUNT_MOCK, - blockNumber: undefined, - }); - - await submitPostRelayVaultDeposit({ - completion: buildCompletion(TARGET_HASH_MOCK), - messenger: buildMessenger(), - quote: buildQuote(), - transaction: TRANSACTION_MOCK, - }); - - expect(getTransferredAmountMock).toHaveBeenCalledWith({ - messenger: expect.anything(), - txHash: TARGET_HASH_MOCK, - chainId: MUSD_MONAD_FIAT_ASSET.chainId, - tokenAddress: MUSD_MONAD_FIAT_ASSET.address, - walletAddress: MONEY_ACCOUNT_ADDRESS_MOCK, - }); - expect(submitMoneyAccountVaultDepositMock).toHaveBeenCalledWith( - expect.objectContaining({ sourceAmountRaw: ON_CHAIN_AMOUNT_MOCK }), - ); - }); - - it('prefers quote.request.recipient over quote.request.from for the on-chain lookup', async () => { - const recipientAddress = - '0xrecipient000000000000000000000000000001' as Hex; - getTransferredAmountMock.mockResolvedValue({ - amountRaw: ON_CHAIN_AMOUNT_MOCK, - blockNumber: undefined, - }); - - await submitPostRelayVaultDeposit({ - completion: buildCompletion(TARGET_HASH_MOCK), - messenger: buildMessenger(), - quote: buildQuote({ recipient: recipientAddress }), - transaction: TRANSACTION_MOCK, - }); - - expect(getTransferredAmountMock).toHaveBeenCalledWith( - expect.objectContaining({ walletAddress: recipientAddress }), - ); - }); - - it('falls back to transaction.txParams.from when quote has neither recipient nor from', async () => { - getTransferredAmountMock.mockResolvedValue({ - amountRaw: ON_CHAIN_AMOUNT_MOCK, - blockNumber: undefined, - }); - const quote = { - request: {}, - original: { - details: { currencyOut: { minimumAmount: MINIMUM_AMOUNT_MOCK } }, - }, - } as unknown as TransactionPayQuote; - - await submitPostRelayVaultDeposit({ - completion: buildCompletion(TARGET_HASH_MOCK), - messenger: buildMessenger(), - quote, - transaction: TRANSACTION_MOCK, - }); - - expect(getTransferredAmountMock).toHaveBeenCalledWith( - expect.objectContaining({ walletAddress: MONEY_ACCOUNT_ADDRESS_MOCK }), - ); - }); - - it('falls back to quote minimum when getTransferredAmountFromTxHash returns no amount', async () => { - getTransferredAmountMock.mockResolvedValue({ - amountRaw: undefined, - blockNumber: undefined, - }); - - await submitPostRelayVaultDeposit({ - completion: buildCompletion(TARGET_HASH_MOCK), - messenger: buildMessenger(), - quote: buildQuote(), - transaction: TRANSACTION_MOCK, - }); - - expect(submitMoneyAccountVaultDepositMock).toHaveBeenCalledWith( - expect.objectContaining({ sourceAmountRaw: MINIMUM_AMOUNT_MOCK }), - ); - }); - - it('falls back to quote minimum when getTransferredAmountFromTxHash throws', async () => { - getTransferredAmountMock.mockRejectedValue(new Error('rpc error')); - - await submitPostRelayVaultDeposit({ - completion: buildCompletion(TARGET_HASH_MOCK), - messenger: buildMessenger(), - quote: buildQuote(), - transaction: TRANSACTION_MOCK, - }); - - expect(submitMoneyAccountVaultDepositMock).toHaveBeenCalledWith( - expect.objectContaining({ sourceAmountRaw: MINIMUM_AMOUNT_MOCK }), - ); - }); - }); - - describe('resolvePostRelayAmount — fallback-hash path', () => { - it('skips on-chain lookup and uses quote minimum when targetHash is FALLBACK_HASH', async () => { - await submitPostRelayVaultDeposit({ - completion: buildCompletion(FALLBACK_HASH), - messenger: buildMessenger(), - quote: buildQuote(), - transaction: TRANSACTION_MOCK, - }); - - expect(getTransferredAmountMock).not.toHaveBeenCalled(); - expect(submitMoneyAccountVaultDepositMock).toHaveBeenCalledWith( - expect.objectContaining({ sourceAmountRaw: MINIMUM_AMOUNT_MOCK }), - ); - }); - - it('skips on-chain lookup and uses quote minimum when targetHash is undefined', async () => { - await submitPostRelayVaultDeposit({ - completion: buildCompletion(undefined), - messenger: buildMessenger(), - quote: buildQuote(), - transaction: TRANSACTION_MOCK, - }); - - expect(getTransferredAmountMock).not.toHaveBeenCalled(); - expect(submitMoneyAccountVaultDepositMock).toHaveBeenCalledWith( - expect.objectContaining({ sourceAmountRaw: MINIMUM_AMOUNT_MOCK }), - ); - }); - - it('throws when quote minimum is missing and no on-chain amount is available', async () => { - await expect( - submitPostRelayVaultDeposit({ - completion: buildCompletion(FALLBACK_HASH), - messenger: buildMessenger(), - quote: buildQuote({ minimumAmount: '' }), - transaction: TRANSACTION_MOCK, - }), - ).rejects.toThrow('Cannot resolve post-Relay vault deposit amount'); - }); - }); - - describe('submitMoneyAccountVaultDeposit delegation', () => { - it('calls submitMoneyAccountVaultDeposit with vaultDisabled=false and the resolved amount', async () => { - getTransferredAmountMock.mockResolvedValue({ - amountRaw: ON_CHAIN_AMOUNT_MOCK, - blockNumber: undefined, - }); - - await submitPostRelayVaultDeposit({ - completion: buildCompletion(TARGET_HASH_MOCK), - messenger: buildMessenger(), - quote: buildQuote(), - transaction: TRANSACTION_MOCK, - }); - - expect(submitMoneyAccountVaultDepositMock).toHaveBeenCalledWith({ - messenger: expect.anything(), - sourceAmountRaw: ON_CHAIN_AMOUNT_MOCK, - transaction: TRANSACTION_MOCK, - vaultDisabled: false, - }); - }); - - it('returns the transactionHash from submitMoneyAccountVaultDeposit', async () => { - getTransferredAmountMock.mockResolvedValue({ - amountRaw: ON_CHAIN_AMOUNT_MOCK, - blockNumber: undefined, - }); - - const result = await submitPostRelayVaultDeposit({ - completion: buildCompletion(TARGET_HASH_MOCK), - messenger: buildMessenger(), - quote: buildQuote(), - transaction: TRANSACTION_MOCK, - }); - - expect(result).toStrictEqual({ transactionHash: VAULT_HASH_MOCK }); - }); - }); -}); diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-post-ma-vault.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-post-ma-vault.ts deleted file mode 100644 index 82b5f53d742..00000000000 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-post-ma-vault.ts +++ /dev/null @@ -1,136 +0,0 @@ -import type { TransactionMeta } from '@metamask/transaction-controller'; -import type { Hex } from '@metamask/utils'; -import { createModuleLogger } from '@metamask/utils'; - -import { projectLogger } from '../../logger.js'; -import type { - TransactionPayControllerMessenger, - TransactionPayQuote, -} from '../../types.js'; -import { submitMoneyAccountVaultDeposit } from '../../utils/ma-vault-deposit.js'; -import { getTransferredAmountFromTxHash } from '../../utils/transaction.js'; -import { MUSD_MONAD_FIAT_ASSET } from '../fiat/constants.js'; -import { FALLBACK_HASH } from './constants.js'; -import type { RelayCompletionOutcome, RelayQuote } from './types.js'; - -const log = createModuleLogger(projectLogger, 'relay-post-ma-vault'); - -/** - * Runs the Money Account vault deposit after a max-amount Relay bridge has - * settled mUSD into the Money Account. Resolves the actually-settled amount, - * then delegates to the shared `submitMoneyAccountVaultDeposit` util. - * - * @param options - Submit options. - * @param options.completion - Outcome of `waitForRelayCompletion`. - * @param options.messenger - Controller messenger. - * @param options.quote - The Relay quote that was submitted. - * @param options.transaction - Original Money Account transaction meta. - * @returns Hash of the final submitted child transaction, if available. - */ -export async function submitPostRelayVaultDeposit({ - completion, - messenger, - quote, - transaction, -}: { - completion: RelayCompletionOutcome; - messenger: TransactionPayControllerMessenger; - quote: TransactionPayQuote; - transaction: TransactionMeta; -}): Promise<{ transactionHash?: Hex }> { - const sourceAmountRaw = await resolvePostRelayAmount({ - completion, - messenger, - quote, - transaction, - }); - - log('Submitting post-Relay vault deposit', { - sourceAmountRaw, - targetHash: completion.targetHash, - transactionId: transaction.id, - }); - - return submitMoneyAccountVaultDeposit({ - messenger, - sourceAmountRaw, - transaction, - // This is intentionally set to false, turning this on will leverage - // CHOMP usage for vault deposits. - vaultDisabled: false, - }); -} - -/** - * Resolves the actual mUSD amount that landed in the Money Account after a - * Relay bridge to Monad. Prefers the on-chain Transfer log on - * `completion.targetHash`; falls back to the Relay quote's minimum output - * when the target hash is the same-chain `FALLBACK_HASH` placeholder or the - * on-chain read fails. - * - * @param options - Resolution options. - * @param options.completion - Outcome of `waitForRelayCompletion`. - * @param options.messenger - Controller messenger. - * @param options.quote - The Relay quote that was submitted. - * @param options.transaction - Original Money Account transaction meta. - * @returns The raw (atomic) settled mUSD amount as a decimal string. - */ -async function resolvePostRelayAmount({ - completion, - messenger, - quote, - transaction, -}: { - completion: RelayCompletionOutcome; - messenger: TransactionPayControllerMessenger; - quote: TransactionPayQuote; - transaction: TransactionMeta; -}): Promise { - const moneyAccountAddress = (quote.request.recipient ?? - quote.request.from ?? - transaction.txParams.from) as Hex | undefined; - - if ( - moneyAccountAddress && - completion.targetHash && - completion.targetHash !== FALLBACK_HASH - ) { - try { - const { amountRaw: onChainAmount } = await getTransferredAmountFromTxHash( - { - messenger, - txHash: completion.targetHash, - chainId: MUSD_MONAD_FIAT_ASSET.chainId, - tokenAddress: MUSD_MONAD_FIAT_ASSET.address, - walletAddress: moneyAccountAddress, - }, - ); - - if (onChainAmount) { - log('Resolved post-Relay amount from on-chain transaction', { - targetHash: completion.targetHash, - onChainAmount, - }); - return onChainAmount; - } - } catch (error) { - log( - 'Failed to read on-chain amount, falling back to quote minimum output', - { targetHash: completion.targetHash, error }, - ); - } - } - - const fallback = quote.original.details.currencyOut.minimumAmount; - - if (!fallback) { - throw new Error('Cannot resolve post-Relay vault deposit amount'); - } - - log('Resolved post-Relay amount from quote minimum output', { - fallback, - targetHash: completion.targetHash, - }); - - return fallback; -} diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts index 0bf88e00f27..69ee629ae48 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts @@ -3507,6 +3507,141 @@ describe('Relay Quotes Utils', () => { }); }); + describe('non-atomic post-quote (atomic: false)', () => { + const NON_ATOMIC_REQUEST: QuoteRequest = { + ...QUOTE_REQUEST_MOCK, + atomic: false, + isPostQuote: true, + }; + + const CALLBACK_RECIPIENT_MOCK = + '0xbb00000000000000000000000000000000000042' as Hex; + + beforeEach(() => { + getControllerStateMock.mockReturnValue({ + transactionData: {}, + } as never); + + getPaymentOverrideDataMock.mockResolvedValue({ + calls: [], + }); + + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => QUOTE_MOCK, + } as never); + }); + + it('does not embed transactions in the quote body when atomic is false', async () => { + await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...NON_ATOMIC_REQUEST, + paymentOverride: PaymentOverride.MoneyAccount, + }, + ], + transaction: TRANSACTION_META_MOCK, + }); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + + expect(body.txs).toBeUndefined(); + }); + + it('uses recipient from getPaymentOverrideData when atomic is false', async () => { + getPaymentOverrideDataMock.mockResolvedValue({ + calls: [], + recipient: CALLBACK_RECIPIENT_MOCK, + }); + + await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [NON_ATOMIC_REQUEST], + transaction: TRANSACTION_META_MOCK, + }); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + + expect(body.recipient).toBe(CALLBACK_RECIPIENT_MOCK); + }); + + it('defaults recipient to from when getPaymentOverrideData returns no recipient', async () => { + await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [NON_ATOMIC_REQUEST], + transaction: TRANSACTION_META_MOCK, + }); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + + expect(body.recipient).toBe(QUOTE_REQUEST_MOCK.from); + }); + + it('uses transaction from as recipient for non-post-quote when atomic is false', async () => { + const transactionFrom = + '0xcc00000000000000000000000000000000000042' as Hex; + + await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [{ ...QUOTE_REQUEST_MOCK, atomic: false }], + transaction: { + ...TRANSACTION_META_MOCK, + txParams: { from: transactionFrom }, + } as TransactionMeta, + }); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + + expect(getPaymentOverrideDataMock).not.toHaveBeenCalled(); + expect(body.recipient).toBe(transactionFrom); + }); + + it('falls back to request from for non-post-quote when transaction has no from', async () => { + await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [{ ...QUOTE_REQUEST_MOCK, atomic: false }], + transaction: TRANSACTION_META_MOCK, + }); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + + expect(body.recipient).toBe(QUOTE_REQUEST_MOCK.from); + }); + + it('honours caller-specified refundTo when atomic is false', async () => { + const refundTo = '0xaa00000000000000000000000000000000000042' as Hex; + + await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [{ ...NON_ATOMIC_REQUEST, refundTo }], + transaction: TRANSACTION_META_MOCK, + }); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + + expect(body.refundTo).toBe(refundTo); + }); + }); + describe('HyperLiquid source (isHyperliquidSource)', () => { const HL_REQUEST: QuoteRequest = { ...QUOTE_REQUEST_MOCK, diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts index 6afdb789009..faf40470811 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -314,6 +314,16 @@ async function getSingleQuote( isRelayExecuteEnabled(messenger) && isEIP7702Chain(messenger, sourceChainId); + const nonAtomicRecipient = await resolveNonAtomicRecipient( + transaction, + request, + messenger, + ); + + const effectiveRequest = nonAtomicRecipient + ? { ...request, recipient: nonAtomicRecipient } + : request; + const body: RelayQuoteRequest = { amount: useExactInput ? sourceTokenAmount : targetAmountMinimum, destinationChainId: Number(targetChainId), @@ -326,37 +336,46 @@ async function getSingleQuote( metamask: { executeVersion: 2 }, } : {}), - recipient: request.recipient ?? from, + recipient: effectiveRequest.recipient ?? from, slippageTolerance, tradeType: useExactInput ? 'EXACT_INPUT' : 'EXPECTED_OUTPUT', user: from, }; - if (request.isPolymarketDepositWallet) { - await applyPolymarketDepositWalletOverrides(body, request, messenger); + if (effectiveRequest.isPolymarketDepositWallet) { + await applyPolymarketDepositWalletOverrides( + body, + effectiveRequest, + messenger, + ); } - // Skip transaction processing when skipProcessTransactions (defaulting to - // isPostQuote) is true — the original transaction will be included in the - // batch separately, not as part of the quote. - // Skip for Polymarket deposit wallet flows — the source is already a - // bridged token transfer, not a contract call to embed. - const shouldProcessTransactions = - !(request.skipProcessTransactions ?? request.isPostQuote) && - !request.isPolymarketDepositWallet; - - if (shouldProcessTransactions) { - await processTransactions(transaction, request, body, messenger); - } else if ( - request.isPostQuote && - request.paymentOverride === PaymentOverride.MoneyAccount + const isAtomic = effectiveRequest.atomic !== false; + + const processedTransactions = await processTransactions( + transaction, + effectiveRequest, + body, + messenger, + ); + + if ( + !processedTransactions && + isAtomic && + effectiveRequest.isPostQuote && + effectiveRequest.paymentOverride === PaymentOverride.MoneyAccount ) { - await processMoneyAccountPostQuote(transaction, request, body, messenger); - } else if (request.refundTo) { + await processMoneyAccountPostQuote( + transaction, + effectiveRequest, + body, + messenger, + ); + } else if (!processedTransactions && effectiveRequest.refundTo) { // For post-quote flows, honour the caller-specified refund address so that // failed Relay transactions refund to the correct account (e.g. the Predict // Safe proxy) rather than defaulting to the EOA. - body.refundTo = request.refundTo; + body.refundTo = effectiveRequest.refundTo; } log('Request body', body); @@ -365,7 +384,7 @@ async function getSingleQuote( log('Fetched relay quote', quote); - return await normalizeQuote(quote, request, fullRequest); + return await normalizeQuote(quote, effectiveRequest, fullRequest); } catch (error) { log('Error fetching relay quote', error); throw error; @@ -385,6 +404,54 @@ function normalizeAuthorizationList( })); } +/** + * Derives the Relay quote recipient for non-atomic flows, where the second leg + * runs after settlement so funds must land directly on the account submitting + * that leg. + * + * Post-quote flows (e.g. Perps/Predict withdraw to Money Account) ask the + * client `getPaymentOverrideData` callback, which knows the Money Account + * address that cannot be derived from the request. Non-post-quote flows (e.g. + * max-amount Money Account deposit) use the parent transaction's own `from`, + * which is the Money Account rather than the funding EOA in `request.from`. + * + * @param transaction - Transaction metadata. + * @param request - Quote request. + * @param messenger - Controller messenger. + * @returns The recipient address, or `undefined` for atomic flows. + */ +async function resolveNonAtomicRecipient( + transaction: TransactionMeta, + request: QuoteRequest, + messenger: TransactionPayControllerMessenger, +): Promise { + if (request.atomic !== false) { + return undefined; + } + + if (!request.isPostQuote) { + return (transaction.txParams?.from as Hex | undefined) ?? request.from; + } + + const { transactionData: transactionDataList } = messenger.call( + 'TransactionPayController:getState', + ); + + const transactionData = transactionDataList[transaction.id]; + const amountHuman = transactionData?.tokens?.[0]?.amountHuman ?? '0'; + + const { recipient } = await messenger.call( + 'TransactionPayController:getPaymentOverrideData', + { + amount: amountHuman, + transaction, + transactionData, + }, + ); + + return recipient; +} + /** * Add tranasction data to request body if needed. * @@ -392,13 +459,28 @@ function normalizeAuthorizationList( * @param request - Quote request. * @param requestBody - Request body to populate. * @param messenger - Controller messenger. + * @returns `true` when the transaction was embedded in the quote; `false` when + * skipped so the caller can route to an alternate handler. */ async function processTransactions( transaction: TransactionMeta, request: QuoteRequest, requestBody: RelayQuoteRequest, messenger: TransactionPayControllerMessenger, -): Promise { +): Promise { + // Skip when skipProcessTransactions (defaulting to isPostQuote) is set — the + // original transaction is submitted separately, not embedded in the quote. + // Skip Polymarket deposit wallet flows — the source is already a bridged + // token transfer, not a contract call to embed. Skip non-atomic flows — the + // second leg is submitted after Relay settlement. + if ( + (request.skipProcessTransactions ?? request.isPostQuote) === true || + request.isPolymarketDepositWallet === true || + request.atomic === false + ) { + return false; + } + const { nestedTransactions, txParams } = transaction; const { isMaxAmount, targetChainId } = request; const data = txParams?.data as Hex | undefined; @@ -422,7 +504,7 @@ async function processTransactions( if (skipDelegation) { log('Skipping delegation as token transfer or Hypercore deposit'); - return; + return true; } if (isMaxAmount) { @@ -467,6 +549,8 @@ async function processTransactions( value: delegation.value, }, ]; + + return true; } async function processMoneyAccountPostQuote( diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts index 6fa630f562b..42e017c9673 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts @@ -20,6 +20,7 @@ import { getRelayPollingInterval, getRelayPollingTimeout, } from '../../utils/feature-flags.js'; +import { submitMoneyAccountVaultDeposit } from '../../utils/ma-vault-deposit.js'; import { getLiveTokenBalance, normalizeTokenAddress, @@ -27,14 +28,16 @@ import { import { collectTransactionIds, getTransaction, + getTransferredAmountFromTxHash, updateTransaction, waitForTransactionConfirmed, } from '../../utils/transaction.js'; -import { RELAY_STATUS_URL } from './constants.js'; +import { FALLBACK_HASH, RELAY_STATUS_URL } from './constants.js'; import { submitViaRelayExecute } from './relay-submit-execute.js'; import { getRelaySubmitCalls, submitRelayQuotes } from './relay-submit.js'; import type { RelayQuote } from './types.js'; +jest.mock('../../utils/ma-vault-deposit'); jest.mock('../../utils/token'); jest.mock('../../utils/transaction'); jest.mock('../../utils/feature-flags'); @@ -162,6 +165,12 @@ describe('Relay Submit Utils', () => { ); const submitViaRelayExecuteMock = jest.mocked(submitViaRelayExecute); + const submitMoneyAccountVaultDepositMock = jest.mocked( + submitMoneyAccountVaultDeposit, + ); + const getTransferredAmountFromTxHashMock = jest.mocked( + getTransferredAmountFromTxHash, + ); beforeEach(() => { jest.resetAllMocks(); @@ -1001,6 +1010,51 @@ describe('Relay Submit Utils', () => { expect(getPaymentOverrideDataMock).not.toHaveBeenCalled(); }); + it('prepends override tx params to submit batch for cross-chain flows', async () => { + request.quotes[0].request.paymentOverride = + PaymentOverride.MoneyAccount; + request.quotes[0].original.details.currencyIn.currency.chainId = 137; + request.quotes[0].original.details.currencyOut.currency.chainId = 143; + getPaymentOverrideDataMock.mockResolvedValue({ + calls: [PAYMENT_OVERRIDE_TX_MOCK], + }); + + await submitRelayQuotes(request); + + expect(getPaymentOverrideDataMock).toHaveBeenCalled(); + const batchCall = addTransactionBatchMock.mock.calls[0][0]; + expect(batchCall.transactions[0].params).toStrictEqual( + expect.objectContaining({ + data: PAYMENT_OVERRIDE_TX_MOCK.data, + to: PAYMENT_OVERRIDE_TX_MOCK.to, + value: PAYMENT_OVERRIDE_TX_MOCK.value, + }), + ); + }); + + it('does not prepend override for non-atomic flows', async () => { + request.quotes[0].request.paymentOverride = + PaymentOverride.MoneyAccount; + request.quotes[0].request.atomic = false; + request.quotes[0].original.details.currencyOut.minimumAmount = '530000'; + submitMoneyAccountVaultDepositMock.mockResolvedValue({ + transactionHash: + '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' as Hex, + }); + successfulFetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + status: 'success', + inTxHashes: [SOURCE_HASH_MOCK], + txHashes: [FALLBACK_HASH], + }), + } as Response); + + await submitRelayQuotes(request); + + expect(getPaymentOverrideDataMock).not.toHaveBeenCalled(); + }); + it('does not prepend when callback returns empty array', async () => { request.quotes[0].request.paymentOverride = PaymentOverride.MoneyAccount; @@ -1767,5 +1821,311 @@ describe('Relay Submit Utils', () => { to: '0xfedcb', }); }); + + describe('atomic: false post-completion', () => { + const RECIPIENT_MOCK = '0xrecip0000000000000000000000000000000001' as Hex; + const TARGET_HASH_MOCK = + '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890' as Hex; + const ON_CHAIN_AMOUNT_MOCK = '535000'; + const MINIMUM_AMOUNT_MOCK = '530000'; + const VAULT_HASH_MOCK = + '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' as Hex; + const TARGET_CHAIN_ID_MOCK = '0x2797' as Hex; + const TARGET_TOKEN_ADDRESS_MOCK = + '0xtoken000000000000000000000000000000001' as Hex; + + beforeEach(() => { + request.quotes[0].request.atomic = false; + request.quotes[0].request.recipient = RECIPIENT_MOCK; + request.quotes[0].request.targetChainId = TARGET_CHAIN_ID_MOCK; + request.quotes[0].request.targetTokenAddress = + TARGET_TOKEN_ADDRESS_MOCK; + request.quotes[0].original.details.currencyOut = { + ...request.quotes[0].original.details.currencyOut, + currency: { + ...request.quotes[0].original.details.currencyOut.currency, + decimals: 6, + }, + minimumAmount: MINIMUM_AMOUNT_MOCK, + }; + + submitMoneyAccountVaultDepositMock.mockResolvedValue({ + transactionHash: VAULT_HASH_MOCK, + }); + + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + status: 'success', + inTxHashes: [SOURCE_HASH_MOCK], + txHashes: [TARGET_HASH_MOCK], + }), + } as Response); + }); + + it('resolves settled amount from on-chain Transfer log and submits vault deposit', async () => { + getTransferredAmountFromTxHashMock.mockResolvedValue({ + amountRaw: ON_CHAIN_AMOUNT_MOCK, + blockNumber: undefined, + }); + + const result = await submitRelayQuotes(request); + + expect(getTransferredAmountFromTxHashMock).toHaveBeenCalledWith({ + messenger: expect.anything(), + txHash: TARGET_HASH_MOCK, + chainId: TARGET_CHAIN_ID_MOCK, + tokenAddress: TARGET_TOKEN_ADDRESS_MOCK, + walletAddress: RECIPIENT_MOCK, + }); + expect(submitMoneyAccountVaultDepositMock).toHaveBeenCalledWith( + expect.objectContaining({ + sourceAmountRaw: ON_CHAIN_AMOUNT_MOCK, + moneyAccountAddress: RECIPIENT_MOCK, + vaultDisabled: false, + }), + ); + expect(result).toStrictEqual({ transactionHash: VAULT_HASH_MOCK }); + }); + + it('throws when the cross-chain on-chain amount is unavailable', async () => { + getTransferredAmountFromTxHashMock.mockResolvedValue({ + amountRaw: undefined, + blockNumber: undefined, + }); + + await expect(submitRelayQuotes(request)).rejects.toThrow( + 'Cannot resolve settled amount from on-chain transaction', + ); + expect(submitMoneyAccountVaultDepositMock).not.toHaveBeenCalled(); + }); + + it('propagates the error when the cross-chain on-chain read throws', async () => { + getTransferredAmountFromTxHashMock.mockRejectedValue( + new Error('rpc error'), + ); + + await expect(submitRelayQuotes(request)).rejects.toThrow('rpc error'); + expect(submitMoneyAccountVaultDepositMock).not.toHaveBeenCalled(); + }); + + it('skips on-chain read and uses quote minimum when targetHash is FALLBACK_HASH', async () => { + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + status: 'success', + inTxHashes: [SOURCE_HASH_MOCK], + txHashes: [FALLBACK_HASH], + }), + } as Response); + + await submitRelayQuotes(request); + + expect(getTransferredAmountFromTxHashMock).not.toHaveBeenCalled(); + expect(submitMoneyAccountVaultDepositMock).toHaveBeenCalledWith( + expect.objectContaining({ sourceAmountRaw: MINIMUM_AMOUNT_MOCK }), + ); + }); + + it('uses quote minimum on same-chain flows when submit returns FALLBACK_HASH', async () => { + request.quotes[0].request.targetChainId = CHAIN_ID_MOCK; + request.quotes[0].original.details.currencyIn.currency.chainId = 1; + request.quotes[0].original.details.currencyOut.currency.chainId = 1; + request.quotes[0].original.metamask.isExecute = true; + + submitViaRelayExecuteMock.mockResolvedValue(FALLBACK_HASH); + + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + status: 'success', + inTxHashes: [SOURCE_HASH_MOCK], + txHashes: [FALLBACK_HASH], + }), + } as Response); + + await submitRelayQuotes(request); + + expect(getTransferredAmountFromTxHashMock).not.toHaveBeenCalled(); + expect(submitMoneyAccountVaultDepositMock).toHaveBeenCalledWith( + expect.objectContaining({ sourceAmountRaw: MINIMUM_AMOUNT_MOCK }), + ); + }); + + it('reads settled amount from submitted source hash on same-chain flows', async () => { + request.quotes[0].request.targetChainId = CHAIN_ID_MOCK; + request.quotes[0].original.details.currencyIn.currency.chainId = 1; + request.quotes[0].original.details.currencyOut.currency.chainId = 1; + + getTransferredAmountFromTxHashMock.mockResolvedValue({ + amountRaw: ON_CHAIN_AMOUNT_MOCK, + blockNumber: undefined, + }); + + await submitRelayQuotes(request); + + expect(getTransferredAmountFromTxHashMock).toHaveBeenCalledWith( + expect.objectContaining({ + txHash: TRANSACTION_HASH_MOCK, + chainId: CHAIN_ID_MOCK, + walletAddress: RECIPIENT_MOCK, + }), + ); + expect(submitMoneyAccountVaultDepositMock).toHaveBeenCalledWith( + expect.objectContaining({ sourceAmountRaw: ON_CHAIN_AMOUNT_MOCK }), + ); + }); + + it('throws when the same-chain flow has no quote-minimum amount', async () => { + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ + status: 'success', + inTxHashes: [SOURCE_HASH_MOCK], + txHashes: [FALLBACK_HASH], + }), + } as Response); + request.quotes[0].original.details.currencyOut.minimumAmount = ''; + + await expect(submitRelayQuotes(request)).rejects.toThrow( + 'Cannot resolve post-completion amount', + ); + expect(getTransferredAmountFromTxHashMock).not.toHaveBeenCalled(); + }); + + it('falls back to completion targetHash when submit returns no hash', async () => { + getTransferredAmountFromTxHashMock.mockResolvedValue({ + amountRaw: ON_CHAIN_AMOUNT_MOCK, + blockNumber: undefined, + }); + submitMoneyAccountVaultDepositMock.mockResolvedValue({ + transactionHash: undefined, + }); + + const result = await submitRelayQuotes(request); + + expect(result).toStrictEqual({ transactionHash: TARGET_HASH_MOCK }); + }); + + it('falls back to quote.request.from when no recipient is set', async () => { + request.quotes[0].request.recipient = undefined; + getTransferredAmountFromTxHashMock.mockResolvedValue({ + amountRaw: ON_CHAIN_AMOUNT_MOCK, + blockNumber: undefined, + }); + + await submitRelayQuotes(request); + + expect(getTransferredAmountFromTxHashMock).toHaveBeenCalledWith( + expect.objectContaining({ walletAddress: FROM_MOCK }), + ); + expect(submitMoneyAccountVaultDepositMock).toHaveBeenCalledWith( + expect.objectContaining({ moneyAccountAddress: FROM_MOCK }), + ); + }); + + describe('post-quote flow', () => { + const DEPOSIT_CALLS_MOCK: BatchTransactionParams[] = [ + { + to: '0xapprove0000000000000000000000000000001' as Hex, + data: '0xapprove' as Hex, + value: '0x0' as Hex, + }, + { + to: '0xteller00000000000000000000000000000001' as Hex, + data: '0xdeposit' as Hex, + value: '0x0' as Hex, + }, + ]; + + beforeEach(() => { + request.quotes[0].request.isPostQuote = true; + getControllerStateMock.mockReturnValue({ + transactionData: { + [ORIGINAL_TRANSACTION_ID_MOCK]: {}, + }, + } as never); + getPaymentOverrideDataMock.mockResolvedValue({ + calls: DEPOSIT_CALLS_MOCK, + }); + getTransferredAmountFromTxHashMock.mockResolvedValue({ + amountRaw: ON_CHAIN_AMOUNT_MOCK, + blockNumber: undefined, + }); + }); + + it('calls getPaymentOverrideData with the settled amount (in human units) and forwards deposit calls', async () => { + await submitRelayQuotes(request); + + expect(getPaymentOverrideDataMock).toHaveBeenCalledWith({ + // 535000 raw with 6 decimals → 0.535 human + amount: '0.535', + transaction: expect.objectContaining({ + id: ORIGINAL_TRANSACTION_ID_MOCK, + }), + transactionData: expect.anything(), + }); + expect(submitMoneyAccountVaultDepositMock).toHaveBeenCalledWith( + expect.objectContaining({ + depositCalls: DEPOSIT_CALLS_MOCK, + sourceAmountRaw: ON_CHAIN_AMOUNT_MOCK, + }), + ); + }); + + it('prefers the recipient returned by getPaymentOverrideData', async () => { + const CALLBACK_RECIPIENT_MOCK = + '0xcallback00000000000000000000000000000001' as Hex; + getPaymentOverrideDataMock.mockResolvedValue({ + calls: DEPOSIT_CALLS_MOCK, + recipient: CALLBACK_RECIPIENT_MOCK, + }); + + await submitRelayQuotes(request); + + expect(submitMoneyAccountVaultDepositMock).toHaveBeenCalledWith( + expect.objectContaining({ + moneyAccountAddress: CALLBACK_RECIPIENT_MOCK, + }), + ); + }); + + it('falls back to the quote recipient when getPaymentOverrideData omits it', async () => { + await submitRelayQuotes(request); + + expect(submitMoneyAccountVaultDepositMock).toHaveBeenCalledWith( + expect.objectContaining({ moneyAccountAddress: RECIPIENT_MOCK }), + ); + }); + + it('throws when getPaymentOverrideData returns no calls', async () => { + getPaymentOverrideDataMock.mockResolvedValue({ calls: [] }); + + await expect(submitRelayQuotes(request)).rejects.toThrow( + 'Missing post-quote deposit calls', + ); + expect(submitMoneyAccountVaultDepositMock).not.toHaveBeenCalled(); + }); + }); + + describe('non-post-quote flow', () => { + beforeEach(() => { + request.quotes[0].request.isPostQuote = false; + getTransferredAmountFromTxHashMock.mockResolvedValue({ + amountRaw: ON_CHAIN_AMOUNT_MOCK, + blockNumber: undefined, + }); + }); + + it('does not call getPaymentOverrideData and forwards no depositCalls', async () => { + await submitRelayQuotes(request); + + expect(getPaymentOverrideDataMock).not.toHaveBeenCalled(); + expect(submitMoneyAccountVaultDepositMock).toHaveBeenCalledWith( + expect.objectContaining({ depositCalls: undefined }), + ); + }); + }); + }); }); }); diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts index 43971254dfa..e2ac51c15a0 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -2,6 +2,7 @@ import { ORIGIN_METAMASK, toHex } from '@metamask/controller-utils'; import { TransactionType } from '@metamask/transaction-controller'; import type { AuthorizationList, + BatchTransactionParams, TransactionBatchRequest, TransactionBatchSingleRequest, TransactionMeta, @@ -23,6 +24,7 @@ import { getRelayPollingInterval, getRelayPollingTimeout, } from '../../utils/feature-flags.js'; +import { submitMoneyAccountVaultDeposit } from '../../utils/ma-vault-deposit.js'; import { getNetworkClientId } from '../../utils/provider.js'; import { getLiveTokenBalance, @@ -32,6 +34,7 @@ import { import { collectTransactionIds, getTransaction, + getTransferredAmountFromTxHash, updateTransaction, waitForTransactionConfirmed, } from '../../utils/transaction.js'; @@ -157,6 +160,7 @@ async function executeSingleQuote( ); let polymarketPreSubmitUsdceBalance = 0n; + let submittedSourceHash: Hex | undefined; // Shallow clone so the server-returned requestId can be written back (state is frozen by Immer). const mutableOriginal: RelayQuote = { ...quote.original }; @@ -169,7 +173,7 @@ async function executeSingleQuote( polymarketPreSubmitUsdceBalance = preSubmitUsdceBalance; setRelaySourceHash(transaction, messenger, sourceHash); } else { - await submitTransactions( + submittedSourceHash = await submitTransactions( { ...quote, original: mutableOriginal }, transaction, messenger, @@ -208,9 +212,233 @@ async function executeSingleQuote( }, ); + // Non-atomic flow: the quote bridged funds to `recipient` without embedding + // the second leg. Now that Relay has settled, resolve the settled amount from + // the on-chain Transfer log and submit the second-leg batch (approve + vault + // deposit) sponsored from `recipient`. + if (quote.request.atomic === false && completion.status === 'success') { + const { transactionHash } = await submitPostNonAtomic({ + completion, + messenger, + quote, + submittedSourceHash, + transaction, + }); + + return { transactionHash: transactionHash ?? completion.targetHash }; + } + return { transactionHash: completion.targetHash }; } +/** + * Runs the second leg of a non-atomic Relay quote. Resolves the settled amount + * from the on-chain Transfer log, then submits the batch via + * `submitMoneyAccountVaultDeposit`. Post-quote flows fetch pre-built calls via + * the client `getPaymentOverrideData` callback; non-post-quote flows fall + * through to the transaction's own nested calls re-encoded via + * `getAmountData`. Funds settled on `quote.request.recipient`, derived at + * quote time by `resolveNonAtomicRecipient`. + * + * @param options - Submit options. + * @param options.completion - Outcome of `waitForRelayCompletion`. + * @param options.messenger - Controller messenger. + * @param options.quote - The Relay quote that was submitted. + * @param options.submittedSourceHash - Hash of the submitted source + * transaction, used to read the settled amount when Relay skips polling on + * same-chain flows. + * @param options.transaction - Original transaction meta. + * @returns Hash of the final submitted child transaction, if available. + */ +async function submitPostNonAtomic({ + completion, + messenger, + quote, + submittedSourceHash, + transaction, +}: { + completion: RelayCompletionOutcome; + messenger: TransactionPayControllerMessenger; + quote: TransactionPayQuote; + submittedSourceHash?: Hex; + transaction: TransactionMeta; +}): Promise<{ transactionHash?: Hex }> { + const sourceAmountRaw = await resolveSettledAmount({ + completion, + messenger, + quote, + submittedSourceHash, + }); + + const override = quote.request.isPostQuote + ? await buildPostQuoteDepositCalls({ + messenger, + sourceAmountRaw, + transaction, + quote, + }) + : undefined; + + const recipient = + override?.recipient ?? quote.request.recipient ?? quote.request.from; + + return submitMoneyAccountVaultDeposit({ + messenger, + moneyAccountAddress: recipient, + depositCalls: override?.calls, + sourceAmountRaw, + transaction, + vaultDisabled: false, + }); +} + +/** + * Builds the post-completion batch for a post-quote flow whose parent + * transaction carries no vault calls. Delegates to the client + * `getPaymentOverrideData` callback with the settled amount. + * + * The callback MUST return a non-empty batch. Post-quote parent metas (e.g. + * Perps/Predict withdraws) carry no vault-side nested calls, so falling back + * to `getAmountData` in `resolveVaultDepositBatch` cannot recover the second + * leg once Relay has already settled funds to the recipient. Throw eagerly so + * the failure surfaces at the correct call site with an actionable message. + * + * The callback may also return the `recipient` that funds settled on, which the + * caller prefers as the source of truth for the second-leg account. + * + * @param options - Build options. + * @param options.messenger - Controller messenger. + * @param options.quote - The Relay quote that was submitted. + * @param options.sourceAmountRaw - Settled amount in raw units. + * @param options.transaction - Original transaction meta. + * @returns The batch calls and optional recipient. + * @throws If the callback returns an empty batch. + */ +async function buildPostQuoteDepositCalls({ + messenger, + quote, + sourceAmountRaw, + transaction, +}: { + messenger: TransactionPayControllerMessenger; + quote: TransactionPayQuote; + sourceAmountRaw: string; + transaction: TransactionMeta; +}): Promise<{ calls: BatchTransactionParams[]; recipient?: Hex }> { + const { transactionData } = messenger.call( + 'TransactionPayController:getState', + ); + + const { decimals } = quote.original.details.currencyOut.currency; + const amountHuman = new BigNumber(sourceAmountRaw) + .shiftedBy(-decimals) + .toFixed(); + + const { calls, recipient } = await messenger.call( + 'TransactionPayController:getPaymentOverrideData', + { + amount: amountHuman, + transaction, + transactionData: transactionData[transaction.id], + }, + ); + + if (!calls.length) { + throw new Error('Missing post-quote deposit calls'); + } + + return { calls, recipient }; +} + +/** + * Resolves the actual amount that landed on the recipient after a Relay bridge. + * + * Cross-chain relays surface a real target-chain hash from polling; same-chain + * relays skip polling (the `FALLBACK_HASH` placeholder) but the submitted + * source transaction itself moved the funds, so its hash is read instead. In + * both cases the exact settled amount comes from the on-chain Transfer log; a + * read failure or missing amount throws rather than guessing, since using the + * quote minimum would knowingly strand dust and defeat an EXACT_INPUT quote. + * + * Relay execute submissions return `FALLBACK_HASH` instead of a real source + * hash, leaving nothing to read; only then is the quote's minimum output used + * as the last available source. + * + * @param options - Resolution options. + * @param options.completion - Outcome of `waitForRelayCompletion`. + * @param options.messenger - Controller messenger. + * @param options.quote - The Relay quote that was submitted. + * @param options.submittedSourceHash - Hash of the submitted source + * transaction, used when polling was skipped on same-chain flows. + * @returns The raw (atomic) settled amount as a decimal string. + */ +async function resolveSettledAmount({ + completion, + messenger, + quote, + submittedSourceHash, +}: { + completion: RelayCompletionOutcome; + messenger: TransactionPayControllerMessenger; + quote: TransactionPayQuote; + submittedSourceHash?: Hex; +}): Promise { + const recipient = (quote.request.recipient ?? quote.request.from) as + | Hex + | undefined; + + const isSameChain = + quote.request.sourceChainId === quote.request.targetChainId; + + const hasPolledTargetHash = Boolean( + completion.targetHash && completion.targetHash !== FALLBACK_HASH, + ); + + let settlementHash: Hex | undefined; + + if (hasPolledTargetHash) { + settlementHash = completion.targetHash as Hex; + } else if (isSameChain && submittedSourceHash !== FALLBACK_HASH) { + settlementHash = submittedSourceHash; + } + + if (recipient && settlementHash) { + const { amountRaw: onChainAmount } = await getTransferredAmountFromTxHash({ + messenger, + txHash: settlementHash, + chainId: quote.request.targetChainId, + tokenAddress: quote.request.targetTokenAddress, + walletAddress: recipient, + }); + + if (!onChainAmount) { + throw new Error( + 'Cannot resolve settled amount from on-chain transaction', + ); + } + + log('Resolved settled amount from on-chain transaction', { + settlementHash, + onChainAmount, + }); + + return onChainAmount; + } + + const fallback = quote.original.details.currencyOut.minimumAmount; + + if (!fallback) { + throw new Error('Cannot resolve post-completion amount'); + } + + log('Resolved settled amount from quote minimum output', { + fallback, + targetHash: completion.targetHash, + }); + + return fallback; +} + function setRelaySourceHash( transaction: TransactionMeta, messenger: TransactionPayControllerMessenger, @@ -501,9 +729,17 @@ async function buildRelaySubmitParams({ quote.request.from.toLowerCase() !== (transaction.txParams.from as Hex).toLowerCase(); + // Non-atomic flows are excluded from the paymentOverride prepend: their + // second leg is submitted separately by `submitPostNonAtomic` after + // Relay completion, so prepending here would double-embed the vault deposit. + // Cross-chain atomic flows (e.g. `moneyAccountWithdraw` on Monad settling + // USDC on Arbitrum for Perps) still need the source-side vault withdraw + + // transfer prepended here so Relay has funds to bridge. + const isNonAtomic = quote.request.atomic === false; + let allParams = normalizedParams; - if (quote.request.paymentOverride) { + if (quote.request.paymentOverride && !isNonAtomic) { const { transactionData } = messenger.call( 'TransactionPayController:getState', ); diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index 0c25e972cc7..b53023133af 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -109,6 +109,18 @@ export type TransactionConfig = { */ accountOverride?: Hex; + /** + * Whether the target transaction (or `paymentOverride` batch) is executed + * atomically with the Relay quote. Defaults to `true` (embedded in the quote + * and executed by the Relay solver). When `false`, Pay does not embed the + * target/override calls in the quote; the quote only bridges the required + * asset to `recipient`, and the calls are submitted separately after Relay + * completion. Used by flows whose second-leg amount is only known after + * Relay settles (EXACT_INPUT max flows) or that require the second leg to + * originate from a different signer than the Relay solver. + */ + atomic?: boolean; + /** * Whether the source of funds is HyperLiquid (HyperCore). * When true, the Relay strategy uses the HyperLiquid 2-step withdrawal @@ -294,6 +306,12 @@ export type TransactionData = { */ accountOverride?: Hex; + /** + * Whether the target transaction is executed atomically with the Relay + * quote. See {@link TransactionConfig.atomic}. + */ + atomic?: boolean; + /** Fiat payment method state. */ fiatPayment?: TransactionFiatPayment; @@ -517,6 +535,12 @@ export type FiatRates = { /** Request for a quote to retrieve a required token. */ export type QuoteRequest = { + /** + * Whether the target transaction is executed atomically with the Relay + * quote. See {@link TransactionConfig.atomic}. + */ + atomic?: boolean; + /** Address of the user's account. */ from: Hex; diff --git a/packages/transaction-pay-controller/src/utils/ma-vault-deposit.test.ts b/packages/transaction-pay-controller/src/utils/ma-vault-deposit.test.ts index 21d1085e8c1..1aca3ef304c 100644 --- a/packages/transaction-pay-controller/src/utils/ma-vault-deposit.test.ts +++ b/packages/transaction-pay-controller/src/utils/ma-vault-deposit.test.ts @@ -1,5 +1,8 @@ import { TransactionType } from '@metamask/transaction-controller'; -import type { TransactionMeta } from '@metamask/transaction-controller'; +import type { + BatchTransactionParams, + TransactionMeta, +} from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import type { TransactionPayControllerMessenger } from '../types.js'; @@ -41,20 +44,26 @@ function buildMessenger( function callSubmit({ callMock = jest.fn(), + depositCalls, + moneyAccountAddress, sourceAmountRaw = '5000000', transaction = TRANSACTION_MOCK, vaultDisabled = false, fromBlock, }: { callMock?: jest.Mock; + depositCalls?: BatchTransactionParams[]; + moneyAccountAddress?: Hex; sourceAmountRaw?: string; transaction?: TransactionMeta; vaultDisabled?: boolean; fromBlock?: Hex; } = {}): Promise<{ transactionHash?: Hex }> { return submitMoneyAccountVaultDeposit({ + depositCalls, fromBlock, messenger: buildMessenger(callMock), + moneyAccountAddress, sourceAmountRaw, transaction, vaultDisabled, @@ -175,6 +184,87 @@ describe('submitMoneyAccountVaultDeposit', () => { expect(result).toStrictEqual({ transactionHash: '0xvault' }); }); + it('submits pre-built depositCalls without calling getAmountData', async () => { + const depositCalls: BatchTransactionParams[] = [ + { data: '0xwithdrawApprove' as Hex, to: '0xw-approve' as Hex }, + { data: '0xwithdrawDeposit' as Hex, to: '0xw-deposit' as Hex }, + ]; + const callMock = jest.fn((action: string) => { + if (action === 'TransactionController:addTransactionBatch') { + return Promise.resolve({ batchId: 'batch-id' }); + } + throw new Error(`Unexpected action: ${action}`); + }); + + const result = await callSubmit({ callMock, depositCalls }); + + expect(callMock).not.toHaveBeenCalledWith( + 'TransactionPayController:getAmountData', + expect.anything(), + ); + expect(updateTransactionMock).not.toHaveBeenCalledWith( + expect.objectContaining({ + note: 'Money Account vault deposit: update vault amount', + }), + expect.any(Function), + ); + expect(callMock).toHaveBeenCalledWith( + 'TransactionController:addTransactionBatch', + expect.objectContaining({ + from: MONEY_ACCOUNT_ADDRESS_MOCK, + transactions: [ + { + params: { + data: '0xwithdrawApprove', + to: '0xw-approve', + value: '0x0', + }, + type: TransactionType.tokenMethodApprove, + }, + { + params: { + data: '0xwithdrawDeposit', + to: '0xw-deposit', + value: '0x0', + }, + type: TransactionType.contractInteraction, + }, + ], + }), + ); + expect(result).toStrictEqual({ transactionHash: '0xvault' }); + }); + + it('uses moneyAccountAddress override instead of transaction.txParams.from', async () => { + const overrideAddress = '0x2222222222222222222222222222222222222222' as Hex; + const depositCalls: BatchTransactionParams[] = [ + { data: '0xd' as Hex, to: '0xt' as Hex }, + ]; + const callMock = jest.fn((action: string) => { + if (action === 'TransactionController:addTransactionBatch') { + return Promise.resolve({ batchId: 'batch-id' }); + } + throw new Error(`Unexpected action: ${action}`); + }); + + await callSubmit({ + callMock, + depositCalls, + moneyAccountAddress: overrideAddress, + }); + + expect(callMock).toHaveBeenCalledWith( + 'TransactionController:addTransactionBatch', + expect.objectContaining({ from: overrideAddress }), + ); + expect(collectTransactionIdsMock).toHaveBeenCalledWith( + expect.anything(), + overrideAddress, + expect.anything(), + expect.any(Function), + ); + }); + it('skips the vault batch when vaultDisabled is true', async () => { const callMock = jest.fn(); diff --git a/packages/transaction-pay-controller/src/utils/ma-vault-deposit.ts b/packages/transaction-pay-controller/src/utils/ma-vault-deposit.ts index ebf85361fb6..8f3facf82f5 100644 --- a/packages/transaction-pay-controller/src/utils/ma-vault-deposit.ts +++ b/packages/transaction-pay-controller/src/utils/ma-vault-deposit.ts @@ -1,5 +1,9 @@ import { ORIGIN_METAMASK } from '@metamask/controller-utils'; -import type { TransactionMeta } from '@metamask/transaction-controller'; +import type { + BatchTransactionParams, + NestedTransactionMetadata, + TransactionMeta, +} from '@metamask/transaction-controller'; import { TransactionType } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; @@ -32,6 +36,13 @@ export const VAULT_ERROR_PREFIX = 'Vault: '; * @param options - Submit options. * @param options.fromBlock - Block number to start searching for CHOMP deposits. * @param options.messenger - Controller messenger. + * @param options.moneyAccountAddress - Money Account address sending the vault + * deposit. Defaults to `transaction.txParams.from` (the deposit-flow sender); + * withdraw flows pass it explicitly since their `from` is the funding EOA. + * @param options.depositCalls - Pre-built vault-deposit batch for withdraw + * flows, whose parent transaction carries no vault calls. When omitted the + * batch is derived from the transaction's own nested calls re-encoded via + * `getAmountData`. * @param options.sourceAmountRaw - Settled mUSD amount in raw units. * @param options.transaction - Original Money Account transaction meta. * @param options.vaultDisabled - When `true`, skip the vault batch and leave @@ -41,18 +52,23 @@ export const VAULT_ERROR_PREFIX = 'Vault: '; export async function submitMoneyAccountVaultDeposit({ fromBlock, messenger, + moneyAccountAddress: moneyAccountAddressOverride, + depositCalls, sourceAmountRaw, transaction, vaultDisabled, }: { fromBlock?: Hex; messenger: TransactionPayControllerMessenger; + moneyAccountAddress?: Hex; + depositCalls?: BatchTransactionParams[]; sourceAmountRaw: string; transaction: TransactionMeta; vaultDisabled: boolean; }): Promise<{ transactionHash?: Hex }> { const transactionId = transaction.id; - const moneyAccountAddress = transaction.txParams.from as Hex | undefined; + const moneyAccountAddress = (moneyAccountAddressOverride ?? + transaction.txParams.from) as Hex | undefined; if (!moneyAccountAddress) { throw new Error('Missing Money Account address'); @@ -68,54 +84,13 @@ export async function submitMoneyAccountVaultDeposit({ return { transactionHash: '0x' }; } - const updatedTransaction = - getTransaction(transactionId, messenger) ?? transaction; - const { updates } = await messenger.call( - 'TransactionPayController:getAmountData', - { - amount: sourceAmountRaw, - transaction: updatedTransaction, - }, - ); - - if (!updates.length) { - throw new Error('No amount updates'); - } - - const nestedTransactions = updatedTransaction.nestedTransactions?.map( - (nestedTransaction) => ({ ...nestedTransaction }), - ); - - if (!nestedTransactions?.length) { - throw new Error('Missing nested transactions'); - } - - for (const { nestedTransactionIndex, data } of updates) { - if (nestedTransactions[nestedTransactionIndex]) { - nestedTransactions[nestedTransactionIndex].data = data; - } - } - - updateTransaction( - { - transactionId, - messenger, - note: 'Money Account vault deposit: update vault amount', - }, - (tx) => { - for (const { nestedTransactionIndex, data } of updates) { - if (tx.nestedTransactions?.[nestedTransactionIndex]) { - tx.nestedTransactions[nestedTransactionIndex].data = data; - } - } - - if (tx.requiredAssets?.[0]) { - tx.requiredAssets[0].amount = `0x${BigInt(sourceAmountRaw).toString( - 16, - )}`; - } - }, - ); + const nestedTransactions = await resolveVaultDepositBatch({ + depositCalls, + messenger, + sourceAmountRaw, + transaction, + transactionId, + }); // CHOMP pre-check: skip addTransactionBatch entirely if CHOMP has already // auto-vaulted the funds during or before the checkout window. @@ -244,6 +219,92 @@ export async function submitMoneyAccountVaultDeposit({ return { transactionHash: hash as Hex }; } +/** + * Resolves the vault-deposit batch (approve + teller deposit) to submit. + * + * Withdraw flows have no vault calls on their own nested transactions, so the + * caller supplies a freshly built `depositCalls` batch. Deposit flows re-encode + * their existing nested vault calldata with the settled amount via + * `getAmountData` and also mutate the parent transaction so its stored calls + * and `requiredAssets` reflect the settled amount. + * + * @param options - Resolution options. + * @param options.depositCalls - Pre-built deposit batch for withdraw flows. + * @param options.messenger - Controller messenger. + * @param options.sourceAmountRaw - Settled mUSD amount in raw units. + * @param options.transaction - Original Money Account transaction meta. + * @param options.transactionId - ID of the original transaction. + * @returns Nested transactions to submit as the vault deposit batch. + */ +async function resolveVaultDepositBatch({ + depositCalls, + messenger, + sourceAmountRaw, + transaction, + transactionId, +}: { + depositCalls?: BatchTransactionParams[]; + messenger: TransactionPayControllerMessenger; + sourceAmountRaw: string; + transaction: TransactionMeta; + transactionId: string; +}): Promise { + if (depositCalls?.length) { + return depositCalls; + } + + const updatedTransaction = + getTransaction(transactionId, messenger) ?? transaction; + const { updates } = await messenger.call( + 'TransactionPayController:getAmountData', + { + amount: sourceAmountRaw, + transaction: updatedTransaction, + }, + ); + + if (!updates.length) { + throw new Error('No amount updates'); + } + + const nestedTransactions = updatedTransaction.nestedTransactions?.map( + (nestedTransaction) => ({ ...nestedTransaction }), + ); + + if (!nestedTransactions?.length) { + throw new Error('Missing nested transactions'); + } + + for (const { nestedTransactionIndex, data } of updates) { + if (nestedTransactions[nestedTransactionIndex]) { + nestedTransactions[nestedTransactionIndex].data = data; + } + } + + updateTransaction( + { + transactionId, + messenger, + note: 'Money Account vault deposit: update vault amount', + }, + (tx) => { + for (const { nestedTransactionIndex, data } of updates) { + if (tx.nestedTransactions?.[nestedTransactionIndex]) { + tx.nestedTransactions[nestedTransactionIndex].data = data; + } + } + + if (tx.requiredAssets?.[0]) { + tx.requiredAssets[0].amount = `0x${BigInt(sourceAmountRaw).toString( + 16, + )}`; + } + }, + ); + + return nestedTransactions; +} + async function tryFindChompDeposit({ fromBlock, messenger, diff --git a/packages/transaction-pay-controller/src/utils/quotes.ts b/packages/transaction-pay-controller/src/utils/quotes.ts index 515b0734daa..c6dcd8488e9 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.ts @@ -85,14 +85,15 @@ export async function updateQuotes( const { accountOverride, - isMaxAmount, - isPostQuote, + atomic, + fiatPayment, isHyperliquidSource, + isMaxAmount, isPolymarketDepositWallet, + isPostQuote, isQuoteRequired, paymentOverride, paymentToken: originalPaymentToken, - fiatPayment, refundTo, sourceAmounts, tokens, @@ -124,11 +125,12 @@ export async function updateQuotes( } const requests = buildQuoteRequests({ + atomic, from, - isMaxAmount: isMaxAmount ?? false, - isPostQuote, isHyperliquidSource, + isMaxAmount: isMaxAmount ?? false, isPolymarketDepositWallet, + isPostQuote, paymentOverride, paymentToken, refundTo, @@ -372,9 +374,10 @@ function clearControllerIfCurrent( * Build quote requests required to retrieve quotes. * * @param request - Request parameters. + * @param request.atomic - Whether the target transaction is executed atomically with the Relay quote. * @param request.from - Address from which the transaction is sent. - * @param request.isMaxAmount - Whether the transaction is a maximum amount transaction. * @param request.isHyperliquidSource - Whether the source of funds is HyperLiquid. + * @param request.isMaxAmount - Whether the transaction is a maximum amount transaction. * @param request.isPolymarketDepositWallet - Whether the source of funds is a Polymarket deposit wallet. * @param request.isPostQuote - Whether this is a post-quote flow. * @param request.paymentOverride - Optional payment override type for the transaction. @@ -386,11 +389,12 @@ function clearControllerIfCurrent( * @returns Array of quote requests. */ function buildQuoteRequests({ + atomic, from, - isMaxAmount, - isPostQuote, isHyperliquidSource, + isMaxAmount, isPolymarketDepositWallet, + isPostQuote, paymentOverride, paymentToken, refundTo, @@ -398,11 +402,12 @@ function buildQuoteRequests({ tokens, transactionId, }: { + atomic?: boolean; from: Hex; - isMaxAmount: boolean; - isPostQuote?: boolean; isHyperliquidSource?: boolean; + isMaxAmount: boolean; isPolymarketDepositWallet?: boolean; + isPostQuote?: boolean; paymentOverride?: PaymentOverride; paymentToken: TransactionPaymentToken | undefined; refundTo?: Hex; @@ -416,12 +421,13 @@ function buildQuoteRequests({ if (isPostQuote) { return buildPostQuoteRequests({ + atomic, + destinationToken: paymentToken, from, - isMaxAmount, isHyperliquidSource, + isMaxAmount, isPolymarketDepositWallet, paymentOverride, - destinationToken: paymentToken, refundTo, sourceAmounts, transactionId, @@ -435,13 +441,15 @@ function buildQuoteRequests({ ) as TransactionPayRequiredToken; return { + atomic, from, isMaxAmount, paymentOverride, + refundTo, sourceBalanceRaw: paymentToken.balanceRaw, - sourceTokenAmount: sourceAmount.sourceAmountRaw, sourceChainId: paymentToken.chainId, sourceTokenAddress: paymentToken.address, + sourceTokenAmount: sourceAmount.sourceAmountRaw, targetAmountMinimum: token.allowUnderMinimum ? '0' : token.amountRaw, targetChainId: token.chainId, targetTokenAddress: token.address, @@ -461,34 +469,37 @@ function buildQuoteRequests({ * and the target is the user's selected destination token (paymentToken). * * @param request - Request parameters. + * @param request.atomic - Whether the target transaction is executed atomically with the Relay quote. + * @param request.destinationToken - Destination token (paymentToken in post-quote mode). * @param request.from - Address from which the transaction is sent. - * @param request.isMaxAmount - Whether the transaction is a maximum amount transaction. * @param request.isHyperliquidSource - Whether the source of funds is HyperLiquid. + * @param request.isMaxAmount - Whether the transaction is a maximum amount transaction. * @param request.isPolymarketDepositWallet - Whether the source of funds is a Polymarket deposit wallet. * @param request.paymentOverride - Optional payment override type for the transaction. - * @param request.destinationToken - Destination token (paymentToken in post-quote mode). * @param request.refundTo - Optional address to receive refunds if the Relay transaction fails. * @param request.sourceAmounts - Source amounts for the transaction (includes source token info). * @param request.transactionId - ID of the transaction. * @returns Array of quote requests for post-quote flow. */ function buildPostQuoteRequests({ + atomic, + destinationToken, from, - isMaxAmount, isHyperliquidSource, + isMaxAmount, isPolymarketDepositWallet, paymentOverride, - destinationToken, refundTo, sourceAmounts, transactionId, }: { + atomic?: boolean; + destinationToken: TransactionPaymentToken; from: Hex; - isMaxAmount: boolean; isHyperliquidSource?: boolean; + isMaxAmount: boolean; isPolymarketDepositWallet?: boolean; paymentOverride?: PaymentOverride; - destinationToken: TransactionPaymentToken; refundTo?: Hex; sourceAmounts: TransactionPaySourceAmount[] | undefined; transactionId: string; @@ -513,17 +524,18 @@ function buildPostQuoteRequests({ } const request: QuoteRequest = { + atomic, from, - isMaxAmount, - isPostQuote: true, isHyperliquidSource, + isMaxAmount, isPolymarketDepositWallet, + isPostQuote: true, paymentOverride, refundTo, sourceBalanceRaw: sourceAmount.sourceBalanceRaw, - sourceTokenAmount: sourceAmount.sourceAmountRaw, sourceChainId: sourceAmount.sourceChainId, sourceTokenAddress: sourceAmount.sourceTokenAddress, + sourceTokenAmount: sourceAmount.sourceAmountRaw, targetAmountMinimum: '0', targetChainId: destinationToken.chainId, targetTokenAddress: destinationToken.address,