Skip to content
4 changes: 4 additions & 0 deletions packages/transaction-pay-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- Fix post-quote flow for MM Pay transactions transferring between same chain and tokens when `isQuoteRequired` is set ([#9150](https://github.com/MetaMask/core/pull/9150))

## [23.9.0]

### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1373,6 +1373,151 @@ describe('Relay Quotes Utils', () => {
expect(estimateGasBatchMock).not.toHaveBeenCalled();
});

it('sets user to txParams.from when same token and chain with accountOverride', async () => {
const txParamsFrom = '0xOriginalSender000000000000000000000000' as Hex;
const accountOverride =
'0xOverrideAccount0000000000000000000000000' as Hex;
const tokenAddress = '0xTokenAddress00000000000000000000000000' as Hex;
const chainId = '0x89' as Hex;

successfulFetchMock.mockResolvedValue({
ok: true,
json: async () => QUOTE_MOCK,
} as never);

await getRelayQuotes({
accountSupports7702: true,
messenger,
requests: [
{
...QUOTE_REQUEST_MOCK,
from: accountOverride,
sourceChainId: chainId,
sourceTokenAddress: tokenAddress,
targetChainId: chainId,
targetTokenAddress: tokenAddress,
},
],
transaction: {
...TRANSACTION_META_MOCK,
txParams: { from: txParamsFrom },
} as TransactionMeta,
});

const body = JSON.parse(
successfulFetchMock.mock.calls[0][1]?.body as string,
);

expect(body.user).toBe(txParamsFrom);
});

it('keeps user as from when same token and chain with accountOverride but recipient differs', async () => {
const txParamsFrom = '0xOriginalSender000000000000000000000000' as Hex;
const accountOverride =
'0xOverrideAccount0000000000000000000000000' as Hex;
const externalRecipient =
'0xExternalRecipient000000000000000000000000' as Hex;
const tokenAddress = '0xTokenAddress00000000000000000000000000' as Hex;
const chainId = '0x89' as Hex;

successfulFetchMock.mockResolvedValue({
ok: true,
json: async () => QUOTE_MOCK,
} as never);

await getRelayQuotes({
accountSupports7702: true,
messenger,
requests: [
{
...QUOTE_REQUEST_MOCK,
from: accountOverride,
recipient: externalRecipient,
sourceChainId: chainId,
sourceTokenAddress: tokenAddress,
targetChainId: chainId,
targetTokenAddress: tokenAddress,
},
],
transaction: {
...TRANSACTION_META_MOCK,
txParams: { from: txParamsFrom },
} as TransactionMeta,
});

const body = JSON.parse(
successfulFetchMock.mock.calls[0][1]?.body as string,
);

expect(body.user).toBe(accountOverride);
});

it('keeps user as from when source and destination differ even with accountOverride', async () => {
const txParamsFrom = '0xOriginalSender000000000000000000000000' as Hex;
const accountOverride =
'0xOverrideAccount0000000000000000000000000' as Hex;

successfulFetchMock.mockResolvedValue({
ok: true,
json: async () => QUOTE_MOCK,
} as never);

await getRelayQuotes({
accountSupports7702: true,
messenger,
requests: [
{
...QUOTE_REQUEST_MOCK,
from: accountOverride,
},
],
transaction: {
...TRANSACTION_META_MOCK,
txParams: { from: txParamsFrom },
} as TransactionMeta,
});

const body = JSON.parse(
successfulFetchMock.mock.calls[0][1]?.body as string,
);

expect(body.user).toBe(accountOverride);
});

it('keeps user as from when same token and chain without accountOverride', async () => {
const tokenAddress = '0xTokenAddress00000000000000000000000000' as Hex;
const chainId = '0x89' as Hex;

successfulFetchMock.mockResolvedValue({
ok: true,
json: async () => QUOTE_MOCK,
} as never);

await getRelayQuotes({
accountSupports7702: true,
messenger,
requests: [
{
...QUOTE_REQUEST_MOCK,
sourceChainId: chainId,
sourceTokenAddress: tokenAddress,
targetChainId: chainId,
targetTokenAddress: tokenAddress,
},
],
transaction: {
...TRANSACTION_META_MOCK,
txParams: { from: FROM_MOCK },
} as TransactionMeta,
});

const body = JSON.parse(
successfulFetchMock.mock.calls[0][1]?.body as string,
);

expect(body.user).toBe(FROM_MOCK);
});

it('does not prepend original transaction for post-quote when txParams.to is missing', async () => {
successfulFetchMock.mockResolvedValue({
ok: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,8 @@ async function getSingleQuote(
isRelayExecuteEnabled(messenger) &&
isEIP7702Chain(messenger, sourceChainId);

const quoteUser = getQuoteUser(request, transaction, from);

const body: RelayQuoteRequest = {
amount: useExactInput ? sourceTokenAmount : targetAmountMinimum,
destinationChainId: Number(targetChainId),
Expand All @@ -292,7 +294,7 @@ async function getSingleQuote(
recipient: request.recipient ?? from,
slippageTolerance,
tradeType: useExactInput ? 'EXACT_INPUT' : 'EXPECTED_OUTPUT',
user: from,
user: quoteUser,
};

if (request.isPolymarketDepositWallet) {
Expand Down Expand Up @@ -1181,6 +1183,49 @@ function getTransferRecipient(data: Hex): Hex {
.decodeFunctionData('transfer', data)
.to.toLowerCase();
}
/**
* Determine the `user` address for a Relay quote request.
*
* When source and destination are the same token on the same chain and an
* accountOverride is active, use the original `txParams.from` so that Relay
* sees the transaction sender rather than the override address.
*
* @param request - Quote request.
* @param transaction - Parent transaction metadata.
* @param from - Resolved wallet address (`accountOverride ?? txParams.from`).
* @returns The address to set as `user` on the quote body.
*/
function getQuoteUser(
request: QuoteRequest,
transaction: TransactionMeta,
from: Hex,
): Hex {
const {
sourceChainId,
sourceTokenAddress,
targetChainId,
targetTokenAddress,
} = request;

const isSameSourceAndTarget =
sourceChainId === targetChainId &&
sourceTokenAddress.toLowerCase() === targetTokenAddress.toLowerCase();

const txParamsFrom = transaction.txParams?.from as Hex | undefined;
const hasAccountOverride =
txParamsFrom && from.toLowerCase() !== txParamsFrom.toLowerCase();

const recipient = request.recipient ?? from;
const isRecipientAccountOverride =
recipient.toLowerCase() === from.toLowerCase();

return isSameSourceAndTarget &&
hasAccountOverride &&
isRecipientAccountOverride
? txParamsFrom
: from;
Comment thread
jpuri marked this conversation as resolved.
}

function getSubsidizedFeeAmountUsd(quote: RelayQuote): BigNumber {
const subsidizedFee = quote.fees?.subsidized;
const amountUsd = new BigNumber(subsidizedFee?.amountUsd ?? '0');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,57 @@ describe('Source Amounts Utils', () => {
]);
});

it('does not filter out same token when isQuoteRequired is true in post-quote flow', () => {
const transactionData: TransactionData = {
isLoading: false,
isPostQuote: true,
isQuoteRequired: true,
paymentToken: DESTINATION_TOKEN_MOCK,
tokens: [
{
...TRANSACTION_TOKEN_MOCK,
address: DESTINATION_TOKEN_MOCK.address,
chainId: DESTINATION_TOKEN_MOCK.chainId,
skipIfBalance: false,
},
],
};

updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, messenger);

expect(transactionData.sourceAmounts).toStrictEqual([
{
sourceAmountHuman: TRANSACTION_TOKEN_MOCK.amountHuman,
sourceAmountRaw: TRANSACTION_TOKEN_MOCK.amountRaw,
sourceBalanceRaw: TRANSACTION_TOKEN_MOCK.balanceRaw,
sourceChainId: DESTINATION_TOKEN_MOCK.chainId,
sourceTokenAddress: DESTINATION_TOKEN_MOCK.address,
targetTokenAddress: DESTINATION_TOKEN_MOCK.address,
},
]);
});

it('still filters out same token when isQuoteRequired is false in post-quote flow', () => {
const transactionData: TransactionData = {
isLoading: false,
isPostQuote: true,
isQuoteRequired: false,
paymentToken: DESTINATION_TOKEN_MOCK,
tokens: [
{
...TRANSACTION_TOKEN_MOCK,
address: DESTINATION_TOKEN_MOCK.address,
chainId: DESTINATION_TOKEN_MOCK.chainId,
skipIfBalance: false,
},
],
};

updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, messenger);

expect(transactionData.sourceAmounts).toStrictEqual([]);
});

it('still filters out same token when isHyperliquidSource is false in post-quote flow', () => {
const transactionData: TransactionData = {
isLoading: false,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,15 @@ export function updateSourceAmounts(
// For post-quote flows, source amounts are calculated differently
// The source is the transaction's required token, not the selected token
if (isPostQuote) {
const { isHyperliquidSource, isPolymarketDepositWallet } = transactionData;
const { isHyperliquidSource, isPolymarketDepositWallet, isQuoteRequired } =
transactionData;
const sourceAmounts = calculatePostQuoteSourceAmounts(
tokens,
paymentToken,
isMaxAmount ?? false,
isHyperliquidSource,
isPolymarketDepositWallet,
isQuoteRequired,
);
log('Updated post-quote source amounts', { transactionId, sourceAmounts });
transactionData.sourceAmounts = sourceAmounts;
Expand Down Expand Up @@ -94,6 +96,7 @@ export function updateSourceAmounts(
* @param isMaxAmount - Whether the transaction is a maximum amount transaction.
* @param isHyperliquidSource - Whether the source is HyperLiquid (perps withdrawal).
* @param isPolymarketDepositWallet - Whether the source is a Polymarket deposit wallet.
* @param isQuoteRequired - When true, a quote is always fetched even when source and target tokens are identical.
* @returns Array of source amounts.
*/
function calculatePostQuoteSourceAmounts(
Expand All @@ -102,6 +105,7 @@ function calculatePostQuoteSourceAmounts(
isMaxAmount: boolean,
isHyperliquidSource?: boolean,
isPolymarketDepositWallet?: boolean,
isQuoteRequired?: boolean,
): TransactionPaySourceAmount[] {
return tokens
.filter((token) => {
Expand All @@ -121,7 +125,8 @@ function calculatePostQuoteSourceAmounts(
if (
isSameToken(token, paymentToken) &&
!isHyperliquidSource &&
!isPolymarketDepositWallet
!isPolymarketDepositWallet &&
!isQuoteRequired
) {
log('Skipping token as same as destination token');
return false;
Expand Down
Loading