From 35f1cec3dc6761467a9c1e3eb95ff8796bca6482 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Thu, 9 Jul 2026 16:52:42 +0300 Subject: [PATCH 01/12] fix: gate Money Account payment override prepend to same-chain flows Cross-chain flows (e.g. Predict withdraw on Polygon depositing to a Money Account on Monad) carry the deposit in the Relay quote's destination txs[] with a delegation signed for the destination chain. Prepending it onto the source-chain execute batch made that delegation get redeemed on the source chain, so the on-chain signature check recovered a wrong signer and reverted with InvalidEOASignature() (0x3db6791c). Only prepend the override for same-chain flows; cross-chain flows fall through to prepend the original tx. --- .../src/strategy/relay/relay-submit.test.ts | 20 +++++++++++++++++++ .../src/strategy/relay/relay-submit.ts | 13 +++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) 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 395c4a75bb1..aa3f6e2c5ef 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 @@ -966,6 +966,12 @@ describe('Relay Submit Utils', () => { [ORIGINAL_TRANSACTION_ID_MOCK]: TRANSACTION_DATA_MOCK, }, }); + + // The payment override is only prepended onto the source execute batch + // for same-chain flows, so align the quote's source and destination + // chains for these override-prepend assertions. + request.quotes[0].original.details.currencyOut.currency.chainId = + request.quotes[0].original.details.currencyIn.currency.chainId; }); it('prepends override tx params to submit batch', async () => { @@ -999,6 +1005,20 @@ describe('Relay Submit Utils', () => { expect(getPaymentOverrideDataMock).not.toHaveBeenCalled(); }); + it('does not prepend override 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).not.toHaveBeenCalled(); + }); + it('does not prepend when callback returns empty array', async () => { request.quotes[0].request.paymentOverride = PaymentOverride.MoneyAccount; 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 5f7da9bbec5..ed3142a52a5 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -430,9 +430,20 @@ async function submitTransactions( quote.request.from.toLowerCase() !== (transaction.txParams.from as Hex).toLowerCase(); + // Only prepend the payment override onto the source execute batch for + // same-chain flows. For cross-chain flows (e.g. Predict withdraw on Polygon + // depositing to a Money Account on Monad) the deposit is carried in the relay + // quote's destination txs[] and runs on the destination chain; its delegation + // is signed for the destination chainId, so redeeming it inside the + // source-chain batch recovers a wrong signer and reverts. In that case we + // fall through and prepend the original (e.g. Predict withdraw) tx instead. + const isSameChainOverride = + quote.original.details.currencyIn.currency.chainId === + quote.original.details.currencyOut.currency.chainId; + let allParams = normalizedParams; - if (quote.request.paymentOverride) { + if (quote.request.paymentOverride && isSameChainOverride) { const { transactionData } = messenger.call( 'TransactionPayController:getState', ); From acd0987e2ce75e7dd5bd87dffe310ca2cbb56349 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 22 Jul 2026 10:50:46 +0300 Subject: [PATCH 02/12] Apply atomic --- .../transaction-pay-controller/CHANGELOG.md | 4 + .../src/TransactionPayController.ts | 18 +- .../relay/relay-post-ma-vault.test.ts | 257 ------------------ .../src/strategy/relay/relay-post-ma-vault.ts | 136 --------- .../src/strategy/relay/relay-quotes.test.ts | 76 ++++++ .../src/strategy/relay/relay-quotes.ts | 12 +- .../src/strategy/relay/relay-submit.test.ts | 246 ++++++++++++++++- .../src/strategy/relay/relay-submit.ts | 187 ++++++++++++- .../transaction-pay-controller/src/types.ts | 38 +++ .../src/utils/ma-vault-deposit.test.ts | 92 ++++++- .../src/utils/ma-vault-deposit.ts | 161 +++++++---- .../src/utils/quotes.ts | 67 +++-- 12 files changed, 818 insertions(+), 476 deletions(-) delete mode 100644 packages/transaction-pay-controller/src/strategy/relay/relay-post-ma-vault.test.ts delete mode 100644 packages/transaction-pay-controller/src/strategy/relay/relay-post-ma-vault.ts diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 7d4f4f6415e..25c93e08d65 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` and `recipient` fields on `TransactionConfig` / `TransactionData` / `QuoteRequest` for a generic non-atomic post-Relay flow: when `atomic` is `false`, Relay bridges to `recipient` and the second leg is submitted separately after completion via `getPaymentOverrideData` (post-quote) or `getAmountData` (non-post-quote), replacing the removed `relay-post-ma-vault` module ([#9497](https://github.com/MetaMask/core/pull/9497)) + ### Changed - Consume `hasTransactionType` helper from `@metamask/transaction-controller` to derive relevant transaction type against the top-level `TransactionMeta` ([#9570](https://github.com/MetaMask/core/pull/9570)) diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index c9e31d480d1..76bd0cc1888 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -149,14 +149,16 @@ export class TransactionPayController extends BaseController< ): void { this.#updateTransactionData(transactionId, (transactionData) => { const config = { - isMaxAmount: transactionData.isMaxAmount, - isPostQuote: transactionData.isPostQuote, + 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, + recipient: transactionData.recipient, + refundTo: transactionData.refundTo, }; const previousAccountOverride = config.accountOverride; @@ -164,14 +166,16 @@ export class TransactionPayController extends BaseController< callback(config); transactionData.accountOverride = config.accountOverride; - transactionData.isMaxAmount = config.isMaxAmount; - transactionData.isPostQuote = config.isPostQuote; + transactionData.atomic = config.atomic; transactionData.isHyperliquidSource = config.isHyperliquidSource; + transactionData.isMaxAmount = config.isMaxAmount; transactionData.isPolymarketDepositWallet = config.isPolymarketDepositWallet; + transactionData.isPostQuote = config.isPostQuote; transactionData.isQuoteRequired = config.isQuoteRequired; - transactionData.refundTo = config.refundTo; transactionData.paymentOverride = config.paymentOverride; + transactionData.recipient = config.recipient; + transactionData.refundTo = config.refundTo; 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 445183f585c..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'; -import { submitMoneyAccountVaultDeposit } from '../../utils/ma-vault-deposit'; -import { getTransferredAmountFromTxHash } from '../../utils/transaction'; -import { MUSD_MONAD_FIAT_ASSET } from '../fiat/constants'; -import { FALLBACK_HASH } from './constants'; -import { submitPostRelayVaultDeposit } from './relay-post-ma-vault'; -import type { RelayCompletionOutcome, RelayQuote } from './types'; - -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 8c2cd7768c2..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'; -import type { - TransactionPayControllerMessenger, - TransactionPayQuote, -} from '../../types'; -import { submitMoneyAccountVaultDeposit } from '../../utils/ma-vault-deposit'; -import { getTransferredAmountFromTxHash } from '../../utils/transaction'; -import { MUSD_MONAD_FIAT_ASSET } from '../fiat/constants'; -import { FALLBACK_HASH } from './constants'; -import type { RelayCompletionOutcome, RelayQuote } from './types'; - -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 cd6ffa21974..264065f1b7b 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 @@ -3505,6 +3505,82 @@ describe('Relay Quotes Utils', () => { }); }); + describe('non-atomic post-quote (atomic: false)', () => { + const NON_ATOMIC_REQUEST: QuoteRequest = { + ...QUOTE_REQUEST_MOCK, + atomic: false, + isPostQuote: true, + }; + + beforeEach(() => { + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => QUOTE_MOCK, + } as never); + }); + + it('does not call processTransactions or getPaymentOverrideData when atomic is false', async () => { + await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [NON_ATOMIC_REQUEST], + transaction: TRANSACTION_META_MOCK, + }); + + expect(getPaymentOverrideDataMock).not.toHaveBeenCalled(); + }); + + it('bypasses the Money Account post-quote override embedding when atomic is false', async () => { + await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [ + { + ...NON_ATOMIC_REQUEST, + paymentOverride: PaymentOverride.MoneyAccount, + }, + ], + transaction: TRANSACTION_META_MOCK, + }); + + expect(getPaymentOverrideDataMock).not.toHaveBeenCalled(); + }); + + 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); + }); + + it('honours caller-specified recipient when atomic is false', async () => { + const recipient = '0xbb00000000000000000000000000000000000042' as Hex; + + await getRelayQuotes({ + accountSupports7702: true, + messenger, + requests: [{ ...NON_ATOMIC_REQUEST, recipient }], + transaction: TRANSACTION_META_MOCK, + }); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + + expect(body.recipient).toBe(recipient); + }); + }); + 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 969ba352c3c..74fc826dcd0 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -326,18 +326,28 @@ async function getSingleQuote( await applyPolymarketDepositWalletOverrides(body, request, messenger); } + // Non-atomic flow: the quote only bridges/swaps to deliver the required + // asset to `recipient`. The second leg (target transaction or + // paymentOverride batch) is NOT embedded in the quote; it is submitted + // separately after Relay completion (see relay-submit). Skip all + // embedding paths and honour caller-specified refundTo. + const isNonAtomic = request.atomic === false; + // 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. + // Skip for non-atomic flows — the second leg is submitted after settlement. const shouldProcessTransactions = !(request.skipProcessTransactions ?? request.isPostQuote) && - !request.isPolymarketDepositWallet; + !request.isPolymarketDepositWallet && + !isNonAtomic; if (shouldProcessTransactions) { await processTransactions(transaction, request, body, messenger); } else if ( + !isNonAtomic && request.isPostQuote && request.paymentOverride === PaymentOverride.MoneyAccount ) { 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 aa3f6e2c5ef..126647fce2d 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,18 +20,21 @@ import { getRelayPollingInterval, getRelayPollingTimeout, } from '../../utils/feature-flags'; +import { submitMoneyAccountVaultDeposit } from '../../utils/ma-vault-deposit'; import { getLiveTokenBalance, normalizeTokenAddress } from '../../utils/token'; import { collectTransactionIds, getTransaction, + getTransferredAmountFromTxHash, updateTransaction, waitForTransactionConfirmed, } from '../../utils/transaction'; -import { RELAY_STATUS_URL } from './constants'; +import { FALLBACK_HASH, RELAY_STATUS_URL } from './constants'; import { submitRelayQuotes } from './relay-submit'; import { submitViaRelayExecute } from './relay-submit-execute'; import type { RelayQuote } from './types'; +jest.mock('../../utils/ma-vault-deposit'); jest.mock('../../utils/token'); jest.mock('../../utils/transaction'); jest.mock('../../utils/feature-flags'); @@ -160,6 +163,12 @@ describe('Relay Submit Utils', () => { ); const submitViaRelayExecuteMock = jest.mocked(submitViaRelayExecute); + const submitMoneyAccountVaultDepositMock = jest.mocked( + submitMoneyAccountVaultDeposit, + ); + const getTransferredAmountFromTxHashMock = jest.mocked( + getTransferredAmountFromTxHash, + ); beforeEach(() => { jest.resetAllMocks(); @@ -1728,5 +1737,240 @@ describe('Relay Submit Utils', () => { expect(submitViaRelayExecuteMock).toHaveBeenCalledTimes(1); }); }); + + 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('falls back to quote minimum output when on-chain amount is unavailable', async () => { + getTransferredAmountFromTxHashMock.mockResolvedValue({ + amountRaw: undefined, + blockNumber: undefined, + }); + + await submitRelayQuotes(request); + + expect(submitMoneyAccountVaultDepositMock).toHaveBeenCalledWith( + expect.objectContaining({ sourceAmountRaw: MINIMUM_AMOUNT_MOCK }), + ); + }); + + it('falls back to quote minimum output when on-chain read throws', async () => { + getTransferredAmountFromTxHashMock.mockRejectedValue( + new Error('rpc error'), + ); + + await submitRelayQuotes(request); + + expect(submitMoneyAccountVaultDepositMock).toHaveBeenCalledWith( + expect.objectContaining({ sourceAmountRaw: MINIMUM_AMOUNT_MOCK }), + ); + }); + + 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('throws when neither on-chain nor quote-minimum amount is available', async () => { + request.quotes[0].original.details.currencyOut.minimumAmount = ''; + getTransferredAmountFromTxHashMock.mockResolvedValue({ + amountRaw: undefined, + blockNumber: undefined, + }); + + await expect(submitRelayQuotes(request)).rejects.toThrow( + 'Cannot resolve post-completion amount', + ); + }); + + 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('forwards undefined depositCalls when getPaymentOverrideData returns no calls', async () => { + getPaymentOverrideDataMock.mockResolvedValue({ calls: [] }); + + await submitRelayQuotes(request); + + expect(submitMoneyAccountVaultDepositMock).toHaveBeenCalledWith( + expect.objectContaining({ + depositCalls: undefined, + sourceAmountRaw: ON_CHAIN_AMOUNT_MOCK, + }), + ); + }); + }); + + 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 ed3142a52a5..ffc46ab20eb 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -1,9 +1,10 @@ import { ORIGIN_METAMASK, toHex } from '@metamask/controller-utils'; import { TransactionType } from '@metamask/transaction-controller'; -import type { TransactionParams } from '@metamask/transaction-controller'; import type { AuthorizationList, + BatchTransactionParams, TransactionMeta, + TransactionParams, } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; @@ -21,6 +22,7 @@ import { getRelayPollingInterval, getRelayPollingTimeout, } from '../../utils/feature-flags'; +import { submitMoneyAccountVaultDeposit } from '../../utils/ma-vault-deposit'; import { getNetworkClientId } from '../../utils/provider'; import { getLiveTokenBalance, @@ -30,6 +32,7 @@ import { import { collectTransactionIds, getTransaction, + getTransferredAmountFromTxHash, updateTransaction, waitForTransactionConfirmed, } from '../../utils/transaction'; @@ -182,9 +185,191 @@ 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 submitPostCompletionBatch({ + completion, + messenger, + quote, + 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 at `completion.targetHash`, then submits the + * post-completion 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`. + * + * @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 transaction meta. + * @returns Hash of the final submitted child transaction, if available. + */ +async function submitPostCompletionBatch({ + completion, + messenger, + quote, + transaction, +}: { + completion: RelayCompletionOutcome; + messenger: TransactionPayControllerMessenger; + quote: TransactionPayQuote; + transaction: TransactionMeta; +}): Promise<{ transactionHash?: Hex }> { + const sourceAmountRaw = await resolveSettledAmount({ + completion, + messenger, + quote, + }); + + const recipient = quote.request.recipient ?? quote.request.from; + + const depositCalls = quote.request.isPostQuote + ? await buildPostQuoteDepositCalls({ + messenger, + sourceAmountRaw, + transaction, + quote, + }) + : undefined; + + return submitMoneyAccountVaultDeposit({ + messenger, + moneyAccountAddress: recipient, + depositCalls, + 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. + * + * @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, or undefined when none are returned. + */ +async function buildPostQuoteDepositCalls({ + messenger, + quote, + sourceAmountRaw, + transaction, +}: { + messenger: TransactionPayControllerMessenger; + quote: TransactionPayQuote; + sourceAmountRaw: string; + transaction: TransactionMeta; +}): Promise { + const { transactionData } = messenger.call( + 'TransactionPayController:getState', + ); + + const { decimals } = quote.original.details.currencyOut.currency; + const amountHuman = new BigNumber(sourceAmountRaw) + .shiftedBy(-decimals) + .toFixed(); + + const { calls } = await messenger.call( + 'TransactionPayController:getPaymentOverrideData', + { + amount: amountHuman, + transaction, + transactionData: transactionData[transaction.id], + }, + ); + + return calls.length ? calls : undefined; +} + +/** + * Resolves the actual amount that landed on the recipient after a Relay bridge. + * 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. + * @returns The raw (atomic) settled amount as a decimal string. + */ +async function resolveSettledAmount({ + completion, + messenger, + quote, +}: { + completion: RelayCompletionOutcome; + messenger: TransactionPayControllerMessenger; + quote: TransactionPayQuote; +}): Promise { + const recipient = (quote.request.recipient ?? quote.request.from) as + | Hex + | undefined; + + if ( + recipient && + completion.targetHash && + completion.targetHash !== FALLBACK_HASH + ) { + try { + const { amountRaw: onChainAmount } = await getTransferredAmountFromTxHash( + { + messenger, + txHash: completion.targetHash, + chainId: quote.request.targetChainId, + tokenAddress: quote.request.targetTokenAddress, + walletAddress: recipient, + }, + ); + + if (onChainAmount) { + log('Resolved settled 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-completion amount'); + } + + log('Resolved settled amount from quote minimum output', { + fallback, + targetHash: completion.targetHash, + }); + + return fallback; +} + function setRelaySourceHash( transaction: TransactionMeta, messenger: TransactionPayControllerMessenger, diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index c1904e38f0d..bf17384985b 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -133,6 +133,26 @@ export type TransactionConfig = { /** When true, a quote is always fetched even when the source and target tokens are identical. */ isQuoteRequired?: boolean; + /** + * 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; + + /** + * Final recipient of the Relay quote output. When set, overrides the default + * recipient (the EOA) in the quote request. Required for flows whose Relay + * output must settle on an address other than the funding EOA (e.g. a Money + * Account smart account for withdraw-to-MA). + */ + recipient?: Hex; + /** * Optional address to receive refunds if the quote provider transaction fails. * When set, overrides the default refund recipient (EOA) in the quote @@ -322,6 +342,18 @@ export type TransactionData = { /** When true, a quote is always fetched even when the source and target tokens are identical. */ isQuoteRequired?: boolean; + /** + * Whether the target transaction is executed atomically with the Relay + * quote. See {@link TransactionConfig.atomic}. + */ + atomic?: boolean; + + /** + * Final recipient of the Relay quote output. See + * {@link TransactionConfig.recipient}. + */ + recipient?: Hex; + /** * Optional address to receive refunds if the quote provider transaction fails. * When set, overrides the default refund recipient (EOA) in the quote @@ -500,6 +532,12 @@ export type QuoteRequest = { /** Whether this quote is the direct mUSD-to-Money-Account fiat flow. */ isDirectMusdMoneyAccount?: boolean; + /** + * Whether the target transaction is executed atomically with the Relay + * quote. See {@link TransactionConfig.atomic}. + */ + atomic?: boolean; + /** Overrides the payment source for the transaction. */ paymentOverride?: PaymentOverride; 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 6a24aac3c91..b625a1e835e 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'; @@ -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 ad2d02e39af..dae4b67156a 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 7ae137bdc7f..41be726bc8a 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.ts @@ -83,14 +83,16 @@ export async function updateQuotes( const { accountOverride, - isMaxAmount, - isPostQuote, + atomic, + fiatPayment, isHyperliquidSource, + isMaxAmount, isPolymarketDepositWallet, + isPostQuote, isQuoteRequired, paymentOverride, paymentToken: originalPaymentToken, - fiatPayment, + recipient, refundTo, sourceAmounts, tokens, @@ -121,13 +123,15 @@ export async function updateQuotes( } const requests = buildQuoteRequests({ + atomic, from, - isMaxAmount: isMaxAmount ?? false, - isPostQuote, isHyperliquidSource, + isMaxAmount: isMaxAmount ?? false, isPolymarketDepositWallet, + isPostQuote, paymentOverride, paymentToken, + recipient, refundTo, sourceAmounts, tokens, @@ -368,13 +372,15 @@ 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. * @param request.paymentToken - Payment token (source for standard flows, destination for post-quote). + * @param request.recipient - Optional final recipient of the Relay quote output. * @param request.refundTo - Optional address to receive refunds if the Relay transaction fails. * @param request.sourceAmounts - Source amounts for the transaction. * @param request.tokens - Required tokens for the transaction. @@ -382,25 +388,29 @@ function clearControllerIfCurrent( * @returns Array of quote requests. */ function buildQuoteRequests({ + atomic, from, - isMaxAmount, - isPostQuote, isHyperliquidSource, + isMaxAmount, isPolymarketDepositWallet, + isPostQuote, paymentOverride, paymentToken, + recipient, refundTo, sourceAmounts, tokens, transactionId, }: { + atomic?: boolean; from: Hex; - isMaxAmount: boolean; - isPostQuote?: boolean; isHyperliquidSource?: boolean; + isMaxAmount: boolean; isPolymarketDepositWallet?: boolean; + isPostQuote?: boolean; paymentOverride?: PaymentOverride; paymentToken: TransactionPaymentToken | undefined; + recipient?: Hex; refundTo?: Hex; sourceAmounts: TransactionPaySourceAmount[] | undefined; tokens: TransactionPayRequiredToken[]; @@ -412,12 +422,14 @@ function buildQuoteRequests({ if (isPostQuote) { return buildPostQuoteRequests({ + atomic, + destinationToken: paymentToken, from, - isMaxAmount, isHyperliquidSource, + isMaxAmount, isPolymarketDepositWallet, paymentOverride, - destinationToken: paymentToken, + recipient, refundTo, sourceAmounts, transactionId, @@ -431,13 +443,16 @@ function buildQuoteRequests({ ) as TransactionPayRequiredToken; return { + atomic, from, isMaxAmount, paymentOverride, + recipient, + 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, @@ -457,34 +472,40 @@ 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.recipient - Optional final recipient of the Relay quote output. * @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, + recipient, refundTo, sourceAmounts, transactionId, }: { + atomic?: boolean; + destinationToken: TransactionPaymentToken; from: Hex; - isMaxAmount: boolean; isHyperliquidSource?: boolean; + isMaxAmount: boolean; isPolymarketDepositWallet?: boolean; paymentOverride?: PaymentOverride; - destinationToken: TransactionPaymentToken; + recipient?: Hex; refundTo?: Hex; sourceAmounts: TransactionPaySourceAmount[] | undefined; transactionId: string; @@ -509,17 +530,19 @@ function buildPostQuoteRequests({ } const request: QuoteRequest = { + atomic, from, - isMaxAmount, - isPostQuote: true, isHyperliquidSource, + isMaxAmount, isPolymarketDepositWallet, + isPostQuote: true, paymentOverride, + recipient, refundTo, sourceBalanceRaw: sourceAmount.sourceBalanceRaw, - sourceTokenAmount: sourceAmount.sourceAmountRaw, sourceChainId: sourceAmount.sourceChainId, sourceTokenAddress: sourceAmount.sourceTokenAddress, + sourceTokenAmount: sourceAmount.sourceAmountRaw, targetAmountMinimum: '0', targetChainId: destinationToken.chainId, targetTokenAddress: destinationToken.address, From d945c019daff597cd1da68c3519b9b79c5f216f6 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 22 Jul 2026 11:05:15 +0300 Subject: [PATCH 03/12] Fix typesre-order --- .../transaction-pay-controller/src/types.ts | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index bf17384985b..d09218e5811 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -107,6 +107,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 @@ -133,18 +145,6 @@ export type TransactionConfig = { /** When true, a quote is always fetched even when the source and target tokens are identical. */ isQuoteRequired?: boolean; - /** - * 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; - /** * Final recipient of the Relay quote output. When set, overrides the default * recipient (the EOA) in the quote request. Required for flows whose Relay @@ -312,6 +312,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; @@ -342,12 +348,6 @@ export type TransactionData = { /** When true, a quote is always fetched even when the source and target tokens are identical. */ isQuoteRequired?: boolean; - /** - * Whether the target transaction is executed atomically with the Relay - * quote. See {@link TransactionConfig.atomic}. - */ - atomic?: boolean; - /** * Final recipient of the Relay quote output. See * {@link TransactionConfig.recipient}. @@ -514,6 +514,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; @@ -532,12 +538,6 @@ export type QuoteRequest = { /** Whether this quote is the direct mUSD-to-Money-Account fiat flow. */ isDirectMusdMoneyAccount?: boolean; - /** - * Whether the target transaction is executed atomically with the Relay - * quote. See {@link TransactionConfig.atomic}. - */ - atomic?: boolean; - /** Overrides the payment source for the transaction. */ paymentOverride?: PaymentOverride; From e0816fd63f16bba1d80c313d263165e4a9543895 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 22 Jul 2026 11:09:10 +0300 Subject: [PATCH 04/12] Fix empty call case --- .../src/strategy/relay/relay-submit.test.ts | 12 ++++------ .../src/strategy/relay/relay-submit.ts | 22 +++++++++++++++---- 2 files changed, 22 insertions(+), 12 deletions(-) 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 126647fce2d..36c6ddd73bf 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 @@ -1939,17 +1939,13 @@ describe('Relay Submit Utils', () => { ); }); - it('forwards undefined depositCalls when getPaymentOverrideData returns no calls', async () => { + it('throws when getPaymentOverrideData returns no calls', async () => { getPaymentOverrideDataMock.mockResolvedValue({ calls: [] }); - await submitRelayQuotes(request); - - expect(submitMoneyAccountVaultDepositMock).toHaveBeenCalledWith( - expect.objectContaining({ - depositCalls: undefined, - sourceAmountRaw: ON_CHAIN_AMOUNT_MOCK, - }), + await expect(submitRelayQuotes(request)).rejects.toThrow( + 'Missing post-quote deposit calls from getPaymentOverrideData', ); + expect(submitMoneyAccountVaultDepositMock).not.toHaveBeenCalled(); }); }); 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 ffc46ab20eb..bbb5910607e 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -237,7 +237,8 @@ async function submitPostCompletionBatch({ const recipient = quote.request.recipient ?? quote.request.from; - const depositCalls = quote.request.isPostQuote + const depositCalls: BatchTransactionParams[] | undefined = quote.request + .isPostQuote ? await buildPostQuoteDepositCalls({ messenger, sourceAmountRaw, @@ -261,12 +262,19 @@ async function submitPostCompletionBatch({ * 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. + * * @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, or undefined when none are returned. + * @returns The batch calls. + * @throws If the callback returns an empty batch. */ async function buildPostQuoteDepositCalls({ messenger, @@ -278,7 +286,7 @@ async function buildPostQuoteDepositCalls({ quote: TransactionPayQuote; sourceAmountRaw: string; transaction: TransactionMeta; -}): Promise { +}): Promise { const { transactionData } = messenger.call( 'TransactionPayController:getState', ); @@ -297,7 +305,13 @@ async function buildPostQuoteDepositCalls({ }, ); - return calls.length ? calls : undefined; + if (!calls.length) { + throw new Error( + 'Missing post-quote deposit calls from getPaymentOverrideData', + ); + } + + return calls; } /** From b23b7fd9b83f243af8d1eedd6846176195b457dd Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 22 Jul 2026 11:32:00 +0300 Subject: [PATCH 05/12] Resolve cursor comment --- .../src/strategy/relay/relay-submit.test.ts | 24 +++++++++++++++++++ .../src/strategy/relay/relay-submit.ts | 22 +++++++++++------ 2 files changed, 39 insertions(+), 7 deletions(-) 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 36c6ddd73bf..335374de13b 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 @@ -1028,6 +1028,30 @@ describe('Relay Submit Utils', () => { expect(getPaymentOverrideDataMock).not.toHaveBeenCalled(); }); + it('does not prepend override for non-atomic same-chain 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; 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 bbb5910607e..45fd2ad124b 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -630,19 +630,27 @@ async function submitTransactions( (transaction.txParams.from as Hex).toLowerCase(); // Only prepend the payment override onto the source execute batch for - // same-chain flows. For cross-chain flows (e.g. Predict withdraw on Polygon - // depositing to a Money Account on Monad) the deposit is carried in the relay - // quote's destination txs[] and runs on the destination chain; its delegation - // is signed for the destination chainId, so redeeming it inside the - // source-chain batch recovers a wrong signer and reverts. In that case we - // fall through and prepend the original (e.g. Predict withdraw) tx instead. + // same-chain atomic flows. For cross-chain flows (e.g. Predict withdraw on + // Polygon depositing to a Money Account on Monad) the deposit is carried in + // the relay quote's destination txs[] and runs on the destination chain; its + // delegation is signed for the destination chainId, so redeeming it inside + // the source-chain batch recovers a wrong signer and reverts. In that case + // we fall through and prepend the original (e.g. Predict withdraw) tx + // instead. Non-atomic flows are also excluded: their second leg is submitted + // separately by `submitPostCompletionBatch` after Relay completion, so + // prepending it here would double-embed the vault deposit. const isSameChainOverride = quote.original.details.currencyIn.currency.chainId === quote.original.details.currencyOut.currency.chainId; + const isNonAtomic = quote.request.atomic === false; let allParams = normalizedParams; - if (quote.request.paymentOverride && isSameChainOverride) { + if ( + quote.request.paymentOverride && + isSameChainOverride && + !isNonAtomic + ) { const { transactionData } = messenger.call( 'TransactionPayController:getState', ); From be8d28c1a4bcd8c3b2b1f78cf3f7e112897cef14 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 22 Jul 2026 11:39:22 +0300 Subject: [PATCH 06/12] Fix lint --- .../src/strategy/relay/relay-submit.test.ts | 3 +-- .../src/strategy/relay/relay-submit.ts | 6 +----- 2 files changed, 2 insertions(+), 7 deletions(-) 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 335374de13b..e089c59e623 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 @@ -1032,8 +1032,7 @@ describe('Relay Submit Utils', () => { request.quotes[0].request.paymentOverride = PaymentOverride.MoneyAccount; request.quotes[0].request.atomic = false; - request.quotes[0].original.details.currencyOut.minimumAmount = - '530000'; + request.quotes[0].original.details.currencyOut.minimumAmount = '530000'; submitMoneyAccountVaultDepositMock.mockResolvedValue({ transactionHash: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' as Hex, 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 45fd2ad124b..162d98ec009 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -646,11 +646,7 @@ async function submitTransactions( let allParams = normalizedParams; - if ( - quote.request.paymentOverride && - isSameChainOverride && - !isNonAtomic - ) { + if (quote.request.paymentOverride && isSameChainOverride && !isNonAtomic) { const { transactionData } = messenger.call( 'TransactionPayController:getState', ); From 9e37e57ce853ff64343c54af886960085d005e1b Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Wed, 22 Jul 2026 13:26:55 +0300 Subject: [PATCH 07/12] Remove sameChainOverride --- .../src/strategy/relay/relay-submit.test.ts | 20 ++++++++++-------- .../src/strategy/relay/relay-submit.ts | 21 +++++++------------ 2 files changed, 18 insertions(+), 23 deletions(-) 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 e089c59e623..8458d7dc3f4 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 @@ -975,12 +975,6 @@ describe('Relay Submit Utils', () => { [ORIGINAL_TRANSACTION_ID_MOCK]: TRANSACTION_DATA_MOCK, }, }); - - // The payment override is only prepended onto the source execute batch - // for same-chain flows, so align the quote's source and destination - // chains for these override-prepend assertions. - request.quotes[0].original.details.currencyOut.currency.chainId = - request.quotes[0].original.details.currencyIn.currency.chainId; }); it('prepends override tx params to submit batch', async () => { @@ -1014,7 +1008,7 @@ describe('Relay Submit Utils', () => { expect(getPaymentOverrideDataMock).not.toHaveBeenCalled(); }); - it('does not prepend override for cross-chain flows', async () => { + 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; @@ -1025,10 +1019,18 @@ describe('Relay Submit Utils', () => { await submitRelayQuotes(request); - expect(getPaymentOverrideDataMock).not.toHaveBeenCalled(); + 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 same-chain flows', async () => { + it('does not prepend override for non-atomic flows', async () => { request.quotes[0].request.paymentOverride = PaymentOverride.MoneyAccount; request.quotes[0].request.atomic = false; 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 162d98ec009..37d2007ae7e 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -629,24 +629,17 @@ async function submitTransactions( quote.request.from.toLowerCase() !== (transaction.txParams.from as Hex).toLowerCase(); - // Only prepend the payment override onto the source execute batch for - // same-chain atomic flows. For cross-chain flows (e.g. Predict withdraw on - // Polygon depositing to a Money Account on Monad) the deposit is carried in - // the relay quote's destination txs[] and runs on the destination chain; its - // delegation is signed for the destination chainId, so redeeming it inside - // the source-chain batch recovers a wrong signer and reverts. In that case - // we fall through and prepend the original (e.g. Predict withdraw) tx - // instead. Non-atomic flows are also excluded: their second leg is submitted - // separately by `submitPostCompletionBatch` after Relay completion, so - // prepending it here would double-embed the vault deposit. - const isSameChainOverride = - quote.original.details.currencyIn.currency.chainId === - quote.original.details.currencyOut.currency.chainId; + // Non-atomic flows are excluded from the paymentOverride prepend: their + // second leg is submitted separately by `submitPostCompletionBatch` 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 && isSameChainOverride && !isNonAtomic) { + if (quote.request.paymentOverride && !isNonAtomic) { const { transactionData } = messenger.call( 'TransactionPayController:getState', ); From 7336dc273dcf185345340ff74316b82c44ea9e4c Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Fri, 24 Jul 2026 16:12:37 +0300 Subject: [PATCH 08/12] Adress comments --- .../src/TransactionPayController.ts | 15 +-- .../src/strategy/relay/relay-quotes.ts | 62 +++++++----- .../src/strategy/relay/relay-submit.test.ts | 60 +++++++---- .../src/strategy/relay/relay-submit.ts | 99 ++++++++++--------- 4 files changed, 133 insertions(+), 103 deletions(-) diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index a1803169139..ec759865e6f 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,7 +149,7 @@ export class TransactionPayController extends BaseController< callback: TransactionConfigCallback, ): void { this.#updateTransactionData(transactionId, (transactionData) => { - const config = { + const config: TransactionConfig = { accountOverride: transactionData.accountOverride, atomic: transactionData.atomic, isHyperliquidSource: transactionData.isHyperliquidSource, @@ -165,17 +166,7 @@ export class TransactionPayController extends BaseController< callback(config); - transactionData.accountOverride = config.accountOverride; - transactionData.atomic = config.atomic; - transactionData.isHyperliquidSource = config.isHyperliquidSource; - transactionData.isMaxAmount = config.isMaxAmount; - transactionData.isPolymarketDepositWallet = - config.isPolymarketDepositWallet; - transactionData.isPostQuote = config.isPostQuote; - transactionData.isQuoteRequired = config.isQuoteRequired; - transactionData.paymentOverride = config.paymentOverride; - transactionData.recipient = config.recipient; - transactionData.refundTo = config.refundTo; + Object.assign(transactionData, config); if ( !config.isPostQuote && 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 3773189f745..102f80d169a 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -336,33 +336,23 @@ async function getSingleQuote( await applyPolymarketDepositWalletOverrides(body, request, messenger); } - // Non-atomic flow: the quote only bridges/swaps to deliver the required - // asset to `recipient`. The second leg (target transaction or - // paymentOverride batch) is NOT embedded in the quote; it is submitted - // separately after Relay completion (see relay-submit). Skip all - // embedding paths and honour caller-specified refundTo. - const isNonAtomic = request.atomic === false; - - // 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. - // Skip for non-atomic flows — the second leg is submitted after settlement. - const shouldProcessTransactions = - !(request.skipProcessTransactions ?? request.isPostQuote) && - !request.isPolymarketDepositWallet && - !isNonAtomic; - - if (shouldProcessTransactions) { - await processTransactions(transaction, request, body, messenger); - } else if ( - !isNonAtomic && + const isAtomic = request.atomic !== false; + + const processedTransactions = await processTransactions( + transaction, + request, + body, + messenger, + ); + + if ( + !processedTransactions && + isAtomic && request.isPostQuote && request.paymentOverride === PaymentOverride.MoneyAccount ) { await processMoneyAccountPostQuote(transaction, request, body, messenger); - } else if (request.refundTo) { + } else if (!processedTransactions && request.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. @@ -402,13 +392,33 @@ 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 { + const isAtomic = request.atomic !== false; + + // 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. + const skipProcessTransactions = + request.skipProcessTransactions ?? request.isPostQuote; + const shouldSkip = + skipProcessTransactions === true || + request.isPolymarketDepositWallet === true || + !isAtomic; + + if (shouldSkip) { + return false; + } + const { nestedTransactions, txParams } = transaction; const { isMaxAmount, targetChainId } = request; const data = txParams?.data as Hex | undefined; @@ -432,7 +442,7 @@ async function processTransactions( if (skipDelegation) { log('Skipping delegation as token transfer or Hypercore deposit'); - return; + return true; } if (isMaxAmount) { @@ -477,6 +487,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 98e81c819e4..e8c23fa8982 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 @@ -1888,29 +1888,25 @@ describe('Relay Submit Utils', () => { expect(result).toStrictEqual({ transactionHash: VAULT_HASH_MOCK }); }); - it('falls back to quote minimum output when on-chain amount is unavailable', async () => { + it('throws when the cross-chain on-chain amount is unavailable', async () => { getTransferredAmountFromTxHashMock.mockResolvedValue({ amountRaw: undefined, blockNumber: undefined, }); - await submitRelayQuotes(request); - - expect(submitMoneyAccountVaultDepositMock).toHaveBeenCalledWith( - expect.objectContaining({ sourceAmountRaw: MINIMUM_AMOUNT_MOCK }), + await expect(submitRelayQuotes(request)).rejects.toThrow( + 'Cannot resolve settled amount from on-chain transaction', ); + expect(submitMoneyAccountVaultDepositMock).not.toHaveBeenCalled(); }); - it('falls back to quote minimum output when on-chain read throws', async () => { + it('propagates the error when the cross-chain on-chain read throws', async () => { getTransferredAmountFromTxHashMock.mockRejectedValue( new Error('rpc error'), ); - await submitRelayQuotes(request); - - expect(submitMoneyAccountVaultDepositMock).toHaveBeenCalledWith( - expect.objectContaining({ sourceAmountRaw: MINIMUM_AMOUNT_MOCK }), - ); + 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 () => { @@ -1931,16 +1927,21 @@ describe('Relay Submit Utils', () => { ); }); - it('throws when neither on-chain nor quote-minimum amount is available', async () => { + 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 = ''; - getTransferredAmountFromTxHashMock.mockResolvedValue({ - amountRaw: undefined, - blockNumber: undefined, - }); 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 () => { @@ -2023,11 +2024,36 @@ describe('Relay Submit Utils', () => { ); }); + 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 from getPaymentOverrideData', + 'Missing post-quote deposit calls', ); expect(submitMoneyAccountVaultDepositMock).not.toHaveBeenCalled(); }); 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 811deb40505..254ddd61fdf 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -216,7 +216,7 @@ async function executeSingleQuote( // 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 submitPostCompletionBatch({ + const { transactionHash } = await submitPostNonAtomic({ completion, messenger, quote, @@ -232,10 +232,10 @@ async function executeSingleQuote( /** * Runs the second leg of a non-atomic Relay quote. Resolves the settled amount * from the on-chain Transfer log at `completion.targetHash`, then submits the - * post-completion 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`. + * 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`. * * @param options - Submit options. * @param options.completion - Outcome of `waitForRelayCompletion`. @@ -244,7 +244,7 @@ async function executeSingleQuote( * @param options.transaction - Original transaction meta. * @returns Hash of the final submitted child transaction, if available. */ -async function submitPostCompletionBatch({ +async function submitPostNonAtomic({ completion, messenger, quote, @@ -261,10 +261,7 @@ async function submitPostCompletionBatch({ quote, }); - const recipient = quote.request.recipient ?? quote.request.from; - - const depositCalls: BatchTransactionParams[] | undefined = quote.request - .isPostQuote + const override = quote.request.isPostQuote ? await buildPostQuoteDepositCalls({ messenger, sourceAmountRaw, @@ -273,10 +270,13 @@ async function submitPostCompletionBatch({ }) : undefined; + const recipient = + override?.recipient ?? quote.request.recipient ?? quote.request.from; + return submitMoneyAccountVaultDeposit({ messenger, moneyAccountAddress: recipient, - depositCalls, + depositCalls: override?.calls, sourceAmountRaw, transaction, vaultDisabled: false, @@ -294,12 +294,15 @@ async function submitPostCompletionBatch({ * 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. + * @returns The batch calls and optional recipient. * @throws If the callback returns an empty batch. */ async function buildPostQuoteDepositCalls({ @@ -312,7 +315,7 @@ async function buildPostQuoteDepositCalls({ quote: TransactionPayQuote; sourceAmountRaw: string; transaction: TransactionMeta; -}): Promise { +}): Promise<{ calls: BatchTransactionParams[]; recipient?: Hex }> { const { transactionData } = messenger.call( 'TransactionPayController:getState', ); @@ -322,7 +325,7 @@ async function buildPostQuoteDepositCalls({ .shiftedBy(-decimals) .toFixed(); - const { calls } = await messenger.call( + const { calls, recipient } = await messenger.call( 'TransactionPayController:getPaymentOverrideData', { amount: amountHuman, @@ -332,19 +335,23 @@ async function buildPostQuoteDepositCalls({ ); if (!calls.length) { - throw new Error( - 'Missing post-quote deposit calls from getPaymentOverrideData', - ); + throw new Error('Missing post-quote deposit calls'); } - return calls; + return { calls, recipient }; } /** * Resolves the actual amount that landed on the recipient after a Relay bridge. - * 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. + * + * When Relay settled on a different chain there is a real target-chain hash, so + * the exact settled amount is read from its 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. + * + * Same-chain relays have no separate settlement hash (the `FALLBACK_HASH` + * placeholder), so there is nothing to read and the quote's minimum output is + * used as the only available source. * * @param options - Resolution options. * @param options.completion - Outcome of `waitForRelayCompletion`. @@ -365,35 +372,29 @@ async function resolveSettledAmount({ | Hex | undefined; - if ( - recipient && - completion.targetHash && - completion.targetHash !== FALLBACK_HASH - ) { - try { - const { amountRaw: onChainAmount } = await getTransferredAmountFromTxHash( - { - messenger, - txHash: completion.targetHash, - chainId: quote.request.targetChainId, - tokenAddress: quote.request.targetTokenAddress, - walletAddress: recipient, - }, - ); + const hasSettlementHash = Boolean( + completion.targetHash && completion.targetHash !== FALLBACK_HASH, + ); - if (onChainAmount) { - log('Resolved settled 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 }, - ); + if (recipient && hasSettlementHash) { + const { amountRaw: onChainAmount } = await getTransferredAmountFromTxHash({ + messenger, + txHash: completion.targetHash as Hex, + 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', { + targetHash: completion.targetHash, + onChainAmount, + }); + + return onChainAmount; } const fallback = quote.original.details.currencyOut.minimumAmount; @@ -701,7 +702,7 @@ async function buildRelaySubmitParams({ (transaction.txParams.from as Hex).toLowerCase(); // Non-atomic flows are excluded from the paymentOverride prepend: their - // second leg is submitted separately by `submitPostCompletionBatch` after + // 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 + From f358cad708b43f2e5520660535889e5d4df2ee5e Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Mon, 27 Jul 2026 12:38:37 +0300 Subject: [PATCH 09/12] Fix lint --- .../src/strategy/relay/relay-submit.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 254ddd61fdf..cf4a3f0d56a 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -386,7 +386,9 @@ async function resolveSettledAmount({ }); if (!onChainAmount) { - throw new Error('Cannot resolve settled amount from on-chain transaction'); + throw new Error( + 'Cannot resolve settled amount from on-chain transaction', + ); } log('Resolved settled amount from on-chain transaction', { From cf0519b5200fc09572d3507c9ebc870ff9f7bea7 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Thu, 30 Jul 2026 17:20:40 +0300 Subject: [PATCH 10/12] Remove recipient config --- .../src/TransactionPayController.ts | 1 - .../src/strategy/relay/relay-quotes.test.ts | 95 ++++++++++++---- .../src/strategy/relay/relay-quotes.ts | 102 ++++++++++++++---- .../src/strategy/relay/relay-submit.test.ts | 24 +++++ .../src/strategy/relay/relay-submit.ts | 58 ++++++---- .../transaction-pay-controller/src/types.ts | 22 ++-- .../src/utils/quotes.ts | 11 -- 7 files changed, 230 insertions(+), 83 deletions(-) diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index ec759865e6f..2aa314e4ff9 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -158,7 +158,6 @@ export class TransactionPayController extends BaseController< isPostQuote: transactionData.isPostQuote, isQuoteRequired: transactionData.isQuoteRequired, paymentOverride: transactionData.paymentOverride, - recipient: transactionData.recipient, refundTo: transactionData.refundTo, }; 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 520e08bc68d..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 @@ -3514,14 +3514,50 @@ describe('Relay Quotes Utils', () => { 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 call processTransactions or getPaymentOverrideData when atomic is false', async () => { + 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, @@ -3529,32 +3565,55 @@ describe('Relay Quotes Utils', () => { transaction: TRANSACTION_META_MOCK, }); - expect(getPaymentOverrideDataMock).not.toHaveBeenCalled(); + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + + expect(body.recipient).toBe(CALLBACK_RECIPIENT_MOCK); }); - it('bypasses the Money Account post-quote override embedding when atomic is false', async () => { + it('defaults recipient to from when getPaymentOverrideData returns no recipient', async () => { await getRelayQuotes({ accountSupports7702: true, messenger, - requests: [ - { - ...NON_ATOMIC_REQUEST, - paymentOverride: PaymentOverride.MoneyAccount, - }, - ], + requests: [NON_ATOMIC_REQUEST], transaction: TRANSACTION_META_MOCK, }); - expect(getPaymentOverrideDataMock).not.toHaveBeenCalled(); + 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; + 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: [{ ...NON_ATOMIC_REQUEST, refundTo }], + 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, }); @@ -3562,16 +3621,16 @@ describe('Relay Quotes Utils', () => { successfulFetchMock.mock.calls[0][1]?.body as string, ); - expect(body.refundTo).toBe(refundTo); + expect(body.recipient).toBe(QUOTE_REQUEST_MOCK.from); }); - it('honours caller-specified recipient when atomic is false', async () => { - const recipient = '0xbb00000000000000000000000000000000000042' as Hex; + it('honours caller-specified refundTo when atomic is false', async () => { + const refundTo = '0xaa00000000000000000000000000000000000042' as Hex; await getRelayQuotes({ accountSupports7702: true, messenger, - requests: [{ ...NON_ATOMIC_REQUEST, recipient }], + requests: [{ ...NON_ATOMIC_REQUEST, refundTo }], transaction: TRANSACTION_META_MOCK, }); @@ -3579,7 +3638,7 @@ describe('Relay Quotes Utils', () => { successfulFetchMock.mock.calls[0][1]?.body as string, ); - expect(body.recipient).toBe(recipient); + expect(body.refundTo).toBe(refundTo); }); }); 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 102f80d169a..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,21 +336,25 @@ 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, + ); } - const isAtomic = request.atomic !== false; + const isAtomic = effectiveRequest.atomic !== false; const processedTransactions = await processTransactions( transaction, - request, + effectiveRequest, body, messenger, ); @@ -348,15 +362,20 @@ async function getSingleQuote( if ( !processedTransactions && isAtomic && - request.isPostQuote && - request.paymentOverride === PaymentOverride.MoneyAccount + effectiveRequest.isPostQuote && + effectiveRequest.paymentOverride === PaymentOverride.MoneyAccount ) { - await processMoneyAccountPostQuote(transaction, request, body, messenger); - } else if (!processedTransactions && 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. * @@ -401,21 +468,16 @@ async function processTransactions( requestBody: RelayQuoteRequest, messenger: TransactionPayControllerMessenger, ): Promise { - const isAtomic = request.atomic !== false; - // 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. - const skipProcessTransactions = - request.skipProcessTransactions ?? request.isPostQuote; - const shouldSkip = - skipProcessTransactions === true || + if ( + (request.skipProcessTransactions ?? request.isPostQuote) === true || request.isPolymarketDepositWallet === true || - !isAtomic; - - if (shouldSkip) { + request.atomic === false + ) { return false; } 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 e8c23fa8982..4a210d2d258 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 @@ -1927,6 +1927,30 @@ describe('Relay Submit Utils', () => { ); }); + 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, 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 cf4a3f0d56a..75bc2bfd0d9 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -160,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 }; @@ -172,7 +173,7 @@ async function executeSingleQuote( polymarketPreSubmitUsdceBalance = preSubmitUsdceBalance; setRelaySourceHash(transaction, messenger, sourceHash); } else { - await submitTransactions( + submittedSourceHash = await submitTransactions( { ...quote, original: mutableOriginal }, transaction, messenger, @@ -220,6 +221,7 @@ async function executeSingleQuote( completion, messenger, quote, + submittedSourceHash, transaction, }); @@ -231,16 +233,20 @@ async function executeSingleQuote( /** * Runs the second leg of a non-atomic Relay quote. Resolves the settled amount - * from the on-chain Transfer log at `completion.targetHash`, 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`. + * 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. */ @@ -248,17 +254,20 @@ 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 @@ -344,42 +353,55 @@ async function buildPostQuoteDepositCalls({ /** * Resolves the actual amount that landed on the recipient after a Relay bridge. * - * When Relay settled on a different chain there is a real target-chain hash, so - * the exact settled amount is read from its 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. - * - * Same-chain relays have no separate settlement hash (the `FALLBACK_HASH` - * placeholder), so there is nothing to read and the quote's minimum output is - * used as the only available source. + * 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. * * @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 hasSettlementHash = Boolean( + const isSameChain = + quote.request.sourceChainId === quote.request.targetChainId; + + const hasPolledTargetHash = Boolean( completion.targetHash && completion.targetHash !== FALLBACK_HASH, ); - if (recipient && hasSettlementHash) { + let settlementHash: Hex | undefined; + + if (hasPolledTargetHash) { + settlementHash = completion.targetHash as Hex; + } else if (isSameChain) { + settlementHash = submittedSourceHash; + } + + if (recipient && settlementHash) { const { amountRaw: onChainAmount } = await getTransferredAmountFromTxHash({ messenger, - txHash: completion.targetHash as Hex, + txHash: settlementHash, chainId: quote.request.targetChainId, tokenAddress: quote.request.targetTokenAddress, walletAddress: recipient, @@ -392,7 +414,7 @@ async function resolveSettledAmount({ } log('Resolved settled amount from on-chain transaction', { - targetHash: completion.targetHash, + settlementHash, onChainAmount, }); diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index 99b5baebf10..cca6ced06da 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -147,14 +147,6 @@ export type TransactionConfig = { /** When true, a quote is always fetched even when the source and target tokens are identical. */ isQuoteRequired?: boolean; - /** - * Final recipient of the Relay quote output. When set, overrides the default - * recipient (the EOA) in the quote request. Required for flows whose Relay - * output must settle on an address other than the funding EOA (e.g. a Money - * Account smart account for withdraw-to-MA). - */ - recipient?: Hex; - /** * Optional address to receive refunds if the quote provider transaction fails. * When set, overrides the default refund recipient (EOA) in the quote @@ -350,12 +342,6 @@ export type TransactionData = { /** When true, a quote is always fetched even when the source and target tokens are identical. */ isQuoteRequired?: boolean; - /** - * Final recipient of the Relay quote output. See - * {@link TransactionConfig.recipient}. - */ - recipient?: Hex; - /** * Optional address to receive refunds if the quote provider transaction fails. * When set, overrides the default refund recipient (EOA) in the quote @@ -576,7 +562,13 @@ export type QuoteRequest = { /** Overrides the payment source for the transaction. */ paymentOverride?: PaymentOverride; - /** Optional recipient address for Relay requests. When set, overrides the default `from` address. */ + /** + * Final recipient of the Relay quote output. Not client-configurable — + * derived internally at quote time for non-atomic flows: the + * `getPaymentOverrideData` callback's `recipient` for post-quote flows, + * otherwise the parent transaction's own `from` (e.g. the Money Account for + * max-amount deposits). When unset, the quote settles on `from`. + */ recipient?: Hex; /** diff --git a/packages/transaction-pay-controller/src/utils/quotes.ts b/packages/transaction-pay-controller/src/utils/quotes.ts index 7afeb76b9db..c6dcd8488e9 100644 --- a/packages/transaction-pay-controller/src/utils/quotes.ts +++ b/packages/transaction-pay-controller/src/utils/quotes.ts @@ -94,7 +94,6 @@ export async function updateQuotes( isQuoteRequired, paymentOverride, paymentToken: originalPaymentToken, - recipient, refundTo, sourceAmounts, tokens, @@ -134,7 +133,6 @@ export async function updateQuotes( isPostQuote, paymentOverride, paymentToken, - recipient, refundTo, sourceAmounts, tokens, @@ -384,7 +382,6 @@ function clearControllerIfCurrent( * @param request.isPostQuote - Whether this is a post-quote flow. * @param request.paymentOverride - Optional payment override type for the transaction. * @param request.paymentToken - Payment token (source for standard flows, destination for post-quote). - * @param request.recipient - Optional final recipient of the Relay quote output. * @param request.refundTo - Optional address to receive refunds if the Relay transaction fails. * @param request.sourceAmounts - Source amounts for the transaction. * @param request.tokens - Required tokens for the transaction. @@ -400,7 +397,6 @@ function buildQuoteRequests({ isPostQuote, paymentOverride, paymentToken, - recipient, refundTo, sourceAmounts, tokens, @@ -414,7 +410,6 @@ function buildQuoteRequests({ isPostQuote?: boolean; paymentOverride?: PaymentOverride; paymentToken: TransactionPaymentToken | undefined; - recipient?: Hex; refundTo?: Hex; sourceAmounts: TransactionPaySourceAmount[] | undefined; tokens: TransactionPayRequiredToken[]; @@ -433,7 +428,6 @@ function buildQuoteRequests({ isMaxAmount, isPolymarketDepositWallet, paymentOverride, - recipient, refundTo, sourceAmounts, transactionId, @@ -451,7 +445,6 @@ function buildQuoteRequests({ from, isMaxAmount, paymentOverride, - recipient, refundTo, sourceBalanceRaw: paymentToken.balanceRaw, sourceChainId: paymentToken.chainId, @@ -483,7 +476,6 @@ function buildQuoteRequests({ * @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.recipient - Optional final recipient of the Relay quote output. * @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. @@ -497,7 +489,6 @@ function buildPostQuoteRequests({ isMaxAmount, isPolymarketDepositWallet, paymentOverride, - recipient, refundTo, sourceAmounts, transactionId, @@ -509,7 +500,6 @@ function buildPostQuoteRequests({ isMaxAmount: boolean; isPolymarketDepositWallet?: boolean; paymentOverride?: PaymentOverride; - recipient?: Hex; refundTo?: Hex; sourceAmounts: TransactionPaySourceAmount[] | undefined; transactionId: string; @@ -541,7 +531,6 @@ function buildPostQuoteRequests({ isPolymarketDepositWallet, isPostQuote: true, paymentOverride, - recipient, refundTo, sourceBalanceRaw: sourceAmount.sourceBalanceRaw, sourceChainId: sourceAmount.sourceChainId, From 4b50fa81e825eccf57eab9eb7d50dcfdf674bed1 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Thu, 30 Jul 2026 17:31:38 +0300 Subject: [PATCH 11/12] Address comment --- .../transaction-pay-controller/CHANGELOG.md | 2 +- .../src/strategy/relay/relay-submit.test.ts | 25 +++++++++++++++++++ .../src/strategy/relay/relay-submit.ts | 6 ++++- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index f0feeb5d522..256dacb7240 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `atomic` and `recipient` fields on `TransactionConfig` / `TransactionData` / `QuoteRequest` for a generic non-atomic post-Relay flow: when `atomic` is `false`, Relay bridges to `recipient` and the second leg is submitted separately after completion via `getPaymentOverrideData` (post-quote) or `getAmountData` (non-post-quote), replacing the removed `relay-post-ma-vault` module ([#9497](https://github.com/MetaMask/core/pull/9497)) +- 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 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 4a210d2d258..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 @@ -1927,6 +1927,31 @@ describe('Relay Submit Utils', () => { ); }); + 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; 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 75bc2bfd0d9..e2ac51c15a0 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -360,6 +360,10 @@ async function buildPostQuoteDepositCalls({ * 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. @@ -394,7 +398,7 @@ async function resolveSettledAmount({ if (hasPolledTargetHash) { settlementHash = completion.targetHash as Hex; - } else if (isSameChain) { + } else if (isSameChain && submittedSourceHash !== FALLBACK_HASH) { settlementHash = submittedSourceHash; } From bbc766e54abc1b7cd4ba9719f164ae58ebb33a51 Mon Sep 17 00:00:00 2001 From: Goktug Poyraz Date: Thu, 30 Jul 2026 17:54:05 +0300 Subject: [PATCH 12/12] Revert comment --- packages/transaction-pay-controller/src/types.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index cca6ced06da..b53023133af 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -562,13 +562,7 @@ export type QuoteRequest = { /** Overrides the payment source for the transaction. */ paymentOverride?: PaymentOverride; - /** - * Final recipient of the Relay quote output. Not client-configurable — - * derived internally at quote time for non-atomic flows: the - * `getPaymentOverrideData` callback's `recipient` for post-quote flows, - * otherwise the parent transaction's own `from` (e.g. the Money Account for - * max-amount deposits). When unset, the quote settles on `from`. - */ + /** Optional recipient address for Relay requests. When set, overrides the default `from` address. */ recipient?: Hex; /**