Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/transaction-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]

### Added

- Add `isPostQuote` field to `MetamaskPayMetadata` type for withdrawal flows ([#7773](https://github.com/MetaMask/core/pull/7773))

## [62.11.0]

### Added
Expand Down
6 changes: 6 additions & 0 deletions packages/transaction-controller/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2082,6 +2082,12 @@ export type MetamaskPayMetadata = {
/** Chain ID of the payment token. */
chainId?: Hex;

/**
* Whether this is a post-quote transaction (e.g., withdrawal flow).
* When true, the token represents the destination rather than source.
*/
isPostQuote?: boolean;

/** Total network fee in fiat currency, including the original and bridge transactions. */
networkFeeFiat?: string;

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

## [Unreleased]

### Added

- Add `isPostQuote` field to `TransactionData` type for withdrawal flows ([#7773](https://github.com/MetaMask/core/pull/7773))
- Add `setIsPostQuote` action type ([#7773](https://github.com/MetaMask/core/pull/7773))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can nest changes here, so could we have a high level summary like Support quote execution after transaction in Relay strategy with details in nested lines?


## [12.0.2]

### Changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ export class TransactionPayController extends BaseController<
});
}

setIsPostQuote(transactionId: string, isPostQuote: boolean): void {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than adding more individual setters and actions, could we define a new TransactionConfig type and a single setTransactionConfig that uses a callback to allow custom changes to the config without defining it each time?

this.#updateTransactionData(transactionId, (transactionData) => {
transactionData.isPostQuote = isPostQuote;
});
}

updatePaymentToken(request: UpdatePaymentTokenRequest): void {
updatePaymentToken(request, {
messenger: this.messenger,
Expand All @@ -105,6 +111,7 @@ export class TransactionPayController extends BaseController<
const originalPaymentToken = current?.paymentToken;
const originalTokens = current?.tokens;
const originalIsMaxAmount = current?.isMaxAmount;
const originalIsPostQuote = current?.isPostQuote;

if (!current) {
transactionData[transactionId] = {
Expand All @@ -122,8 +129,14 @@ export class TransactionPayController extends BaseController<

const isTokensUpdated = current.tokens !== originalTokens;
const isIsMaxUpdated = current.isMaxAmount !== originalIsMaxAmount;

if (isPaymentTokenUpdated || isIsMaxUpdated || isTokensUpdated) {
const isPostQuoteUpdated = current.isPostQuote !== originalIsPostQuote;

if (
isPaymentTokenUpdated ||
isIsMaxUpdated ||
isTokensUpdated ||
isPostQuoteUpdated
) {
updateSourceAmounts(transactionId, current as never, this.messenger);

shouldUpdateQuotes = true;
Expand Down Expand Up @@ -157,6 +170,11 @@ export class TransactionPayController extends BaseController<
this.setIsMaxAmount.bind(this),
);

this.messenger.registerActionHandler(
'TransactionPayController:setIsPostQuote',
this.setIsPostQuote.bind(this),
);

this.messenger.registerActionHandler(
'TransactionPayController:updatePaymentToken',
this.updatePaymentToken.bind(this),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@ import type { TransactionMeta } from '@metamask/transaction-controller';
import { noop } from 'lodash';

import { updatePaymentToken } from './update-payment-token';
import type { TransactionData } from '../types';
import type { TransactionData, TransactionPaymentToken } from '../types';
import {
getTokenBalance,
getTokenInfo,
getTokenFiatRate,
getTokenInfo,
} from '../utils/token';
import { getTransaction } from '../utils/transaction';

Expand All @@ -18,18 +18,37 @@ const CHAIN_ID_MOCK = '0x1';
const FROM_MOCK = '0x456';
const TRANSACTION_ID_MOCK = '123-456';

const PAYMENT_TOKEN_MOCK: TransactionPaymentToken = {
address: TOKEN_ADDRESS_MOCK,
balanceFiat: '2.46',
balanceHuman: '1.23',
balanceRaw: '1230000',
balanceUsd: '3.69',
chainId: CHAIN_ID_MOCK,
decimals: 6,
symbol: 'TST',
};

describe('Update Payment Token Action', () => {
const getTokenBalanceMock = jest.mocked(getTokenBalance);
const getTokenInfoMock = jest.mocked(getTokenInfo);
const getTokenFiatRateMock = jest.mocked(getTokenFiatRate);
const getTokenBalanceMock = jest.mocked(getTokenBalance);
const getTransactionMock = jest.mocked(getTransaction);

beforeEach(() => {
jest.resetAllMocks();

getTokenInfoMock.mockReturnValue({ decimals: 6, symbol: 'TST' });
getTokenInfoMock.mockReturnValue({
decimals: PAYMENT_TOKEN_MOCK.decimals,
symbol: PAYMENT_TOKEN_MOCK.symbol,
});

getTokenFiatRateMock.mockReturnValue({
fiatRate: '2',
usdRate: '3',
});

getTokenBalanceMock.mockReturnValue('1230000');
getTokenFiatRateMock.mockReturnValue({ fiatRate: '2.0', usdRate: '3.0' });

getTransactionMock.mockReturnValue({
id: TRANSACTION_ID_MOCK,
Expand All @@ -52,6 +71,12 @@ describe('Update Payment Token Action', () => {
},
);

expect(getTokenInfoMock).toHaveBeenCalledWith(
{},
TOKEN_ADDRESS_MOCK,
CHAIN_ID_MOCK,
);

expect(updateTransactionDataMock).toHaveBeenCalledTimes(1);

const transactionDataMock = {} as TransactionData;
Expand All @@ -69,7 +94,7 @@ describe('Update Payment Token Action', () => {
});
});

it('throws if decimals not found', () => {
it('throws if token info not found', () => {
getTokenInfoMock.mockReturnValue(undefined);

expect(() =>
Expand All @@ -87,7 +112,7 @@ describe('Update Payment Token Action', () => {
).toThrow('Payment token not found');
});

it('throws if token fiat rate not found', () => {
it('throws if fiat rate not found', () => {
getTokenFiatRateMock.mockReturnValue(undefined);

expect(() =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export type UpdatePaymentTokenOptions = {
/**
* Update the payment token for a specific transaction.
*
* @param request - Request parameters.
* @param request - Request parameters.
* @param options - Options bag.
*/
export function updatePaymentToken(
Expand Down
1 change: 1 addition & 0 deletions packages/transaction-pay-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export type {
TransactionPayControllerMessenger,
TransactionPayControllerOptions,
TransactionPayControllerSetIsMaxAmountAction,
TransactionPayControllerSetIsPostQuoteAction,
TransactionPayControllerState,
TransactionPayControllerStateChangeEvent,
TransactionPayControllerUpdatePaymentTokenAction,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -638,20 +638,47 @@ describe('Relay Quotes Utils', () => {
);
});

it('ignores requests with no target minimum', async () => {
it('ignores gas fee token requests (target=0 and source=0)', async () => {
successfulFetchMock.mockResolvedValue({
json: async () => QUOTE_MOCK,
} as never);

await getRelayQuotes({
messenger,
requests: [{ ...QUOTE_REQUEST_MOCK, targetAmountMinimum: '0' }],
requests: [
{
...QUOTE_REQUEST_MOCK,
targetAmountMinimum: '0',
sourceTokenAmount: '0',
},
],
transaction: TRANSACTION_META_MOCK,
});

expect(successfulFetchMock).not.toHaveBeenCalled();
});

it('processes post-quote requests (target=0 but source>0)', async () => {
successfulFetchMock.mockResolvedValue({
json: async () => QUOTE_MOCK,
} as never);

await getRelayQuotes({
messenger,
requests: [{ ...QUOTE_REQUEST_MOCK, targetAmountMinimum: '0' }],
transaction: TRANSACTION_META_MOCK,
});

expect(successfulFetchMock).toHaveBeenCalled();

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

expect(body.tradeType).toBe('EXACT_INPUT');
expect(body.amount).toBe(QUOTE_REQUEST_MOCK.sourceTokenAmount);
});

it('includes duration in quote', async () => {
successfulFetchMock.mockResolvedValue({
json: async () => QUOTE_MOCK,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,13 @@ export async function getRelayQuotes(

try {
const normalizedRequests = requests
// Ignore gas fee token requests
.filter((singleRequest) => singleRequest.targetAmountMinimum !== '0')
// Ignore gas fee token requests (which have both target=0 and source=0)
// but keep post-quote requests (which have target=0 but source>0)
.filter(
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new logic keeps requests where either target > 0 (normal deposit flows) or source > 0 (withdrawal/post-quote flows). Only requests with both at zero (gas fee token placeholders) get filtered out.

(singleRequest) =>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For simplicity, could we remove this logic and provide isPostQuote in the pay strategy requests for clarity?

singleRequest.targetAmountMinimum !== '0' ||
singleRequest.sourceTokenAmount !== '0',
)
.map((singleRequest) => normalizeRequest(singleRequest));

log('Normalized requests', normalizedRequests);
Expand Down Expand Up @@ -111,15 +116,20 @@ async function getSingleQuote(
);

try {
// For post-quote (withdrawal) flows, use EXACT_INPUT - user specifies how much
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm concerned we keep mentioning withdrawal everywhere. It's definitely a good example of a post-quote flow, but conceptually we could be moving funds after a transaction for any reason, to a different recipient for example.

// to withdraw, and we show them how much they'll receive after fees.
// For regular flows with a target amount, use EXPECTED_OUTPUT.
const useExactInput = isMaxAmount === true || targetAmountMinimum === '0';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned above, could just check isPostQuote directly.


const body: RelayQuoteRequest = {
amount: isMaxAmount ? sourceTokenAmount : targetAmountMinimum,
amount: useExactInput ? sourceTokenAmount : targetAmountMinimum,
Copy link
Member

@matthewwalsh0 matthewwalsh0 Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we also need to update processTransactions to skip changing the quote at all, since we never want to also execute the same transaction data on the target chain of the quote.

I assume it is skipping now since it's just a token transfer for the Predict withdraw.

destinationChainId: Number(targetChainId),
destinationCurrency: targetTokenAddress,
originChainId: Number(sourceChainId),
originCurrency: sourceTokenAddress,
recipient: from,
slippageTolerance,
tradeType: isMaxAmount ? 'EXACT_INPUT' : 'EXPECTED_OUTPUT',
tradeType: useExactInput ? 'EXACT_INPUT' : 'EXPECTED_OUTPUT',
user: from,
};

Expand All @@ -140,7 +150,7 @@ async function getSingleQuote(

log('Fetched relay quote', quote);

return normalizeQuote(quote, request, fullRequest);
return await normalizeQuote(quote, request, fullRequest);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this functionally any different if we're returning from an async function?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't have to be this PR, but since we're adding a transaction to the Relay deposit transaction, we'll need to update the source gas fees in the normalized quote to include the gas fee of the original transaction.

Technically, we may also need to move the fee to the target property, and set source to zero.

Depends if we consider the fees relative to the transaction or the quote 😅

} catch (error) {
log('Error fetching relay quote', error);
throw error;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import type { Hex } from '@metamask/utils';
import { cloneDeep } from 'lodash';

import { RELAY_STATUS_URL } from './constants';
import { submitRelayQuotes } from './relay-submit';
import {
getSubmitContext,
setupTransactionCollection,
submitRelayQuotes,
} from './relay-submit';
import type { RelayQuote } from './types';
import { getMessengerMock } from '../../tests/messenger-mock';
import type {
Expand Down Expand Up @@ -535,4 +539,90 @@ describe('Relay Submit Utils', () => {
);
});
});

describe('getSubmitContext', () => {
it('extracts and normalizes params from quote', () => {
const quote = REQUEST_MOCK.quotes[0];
const context = getSubmitContext(quote, messenger);

expect(context).toStrictEqual({
from: FROM_MOCK,
gasFeeToken: undefined,
gasLimits: [21000, 21000],
networkClientId: NETWORK_CLIENT_ID_MOCK,
normalizedParams: [
{
data: '0x1234',
from: FROM_MOCK,
gas: '0x5208',
maxFeePerGas: '0x5d21dba00',
maxPriorityFeePerGas: '0x3b9aca00',
to: '0xfedcb',
value: '0x4d2',
},
],
sourceChainId: CHAIN_ID_MOCK,
sourceTokenAddress: TOKEN_ADDRESS_MOCK,
});
});

it('returns gas fee token if isSourceGasFeeToken', () => {
const quote = {
...REQUEST_MOCK.quotes[0],
fees: { ...REQUEST_MOCK.quotes[0].fees, isSourceGasFeeToken: true },
};
const context = getSubmitContext(quote, messenger);

expect(context.gasFeeToken).toBe(TOKEN_ADDRESS_MOCK);
});

it('throws if step kind is unsupported', () => {
const quote = cloneDeep(REQUEST_MOCK.quotes[0]);
quote.original.steps[0].kind = 'unsupported' as never;

expect(() => getSubmitContext(quote, messenger)).toThrow(
'Unsupported step kind: unsupported',
);
});
});

describe('setupTransactionCollection', () => {
it('collects transaction IDs and updates parent transaction', () => {
const { transactionIds, end } = setupTransactionCollection({
sourceChainId: CHAIN_ID_MOCK,
from: FROM_MOCK,
messenger,
parentTransactionId: ORIGINAL_TRANSACTION_ID_MOCK,
note: 'Test note',
});

expect(transactionIds).toStrictEqual([TRANSACTION_META_MOCK.id]);
expect(updateTransactionMock).toHaveBeenCalledWith(
{
transactionId: ORIGINAL_TRANSACTION_ID_MOCK,
messenger,
note: 'Test note',
},
expect.any(Function),
);
expect(typeof end).toBe('function');
});

it('adds transaction ID to requiredTransactionIds', () => {
setupTransactionCollection({
sourceChainId: CHAIN_ID_MOCK,
from: FROM_MOCK,
messenger,
parentTransactionId: ORIGINAL_TRANSACTION_ID_MOCK,
note: 'Test note',
});

const txDraft = { txParams: {} } as TransactionMeta;
updateTransactionMock.mock.calls[0][1](txDraft);

expect(txDraft.requiredTransactionIds).toStrictEqual([
TRANSACTION_META_MOCK.id,
]);
});
});
});
Loading