Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
103ddbe
feat(transaction-pay-controller): atomic quoting for subsidized max M…
OGPoyraz Sep 14, 2026
b5837e3
style(transaction-pay-controller): fix formatting
OGPoyraz Sep 14, 2026
5747aa9
test(transaction-pay-controller): cover atomic promotion guards and s…
OGPoyraz Sep 14, 2026
b4296ef
docs(transaction-pay-controller): reference PR in changelog
OGPoyraz Sep 14, 2026
e81860e
Fix changelog
OGPoyraz Sep 14, 2026
ada5102
feat(transaction-pay-controller): address review feedback on atomic m…
OGPoyraz Sep 16, 2026
6616ef8
Merge remote-tracking branch 'origin/main' into ogp/CONF-1949
OGPoyraz Sep 16, 2026
793fe59
refactor(transaction-pay-controller): remove non-max atomic demote path
OGPoyraz Sep 17, 2026
aeb9751
Merge remote-tracking branch 'origin/main' into ogp/CONF-1949
OGPoyraz Sep 17, 2026
aa57e3d
refactor(transaction-pay-controller): drop discovery target shape check
OGPoyraz Sep 17, 2026
55ba79f
feat(transaction-pay-controller): honor atomic hints for max quotes
OGPoyraz Sep 17, 2026
8a5459d
docs(transaction-pay-controller): describe atomic max hint handling
OGPoyraz Sep 17, 2026
d50b3de
fix(transaction-pay-controller): derive max output from source budget
OGPoyraz Sep 17, 2026
d43f7b9
docs(transaction-pay-controller): clarify max discovery requirement
OGPoyraz Sep 17, 2026
82988d3
fix(transaction-pay-controller): quote atomic max from source budget
OGPoyraz Sep 22, 2026
b8081a9
fix(transaction-pay-controller): use standard quote error fallback
OGPoyraz Sep 22, 2026
b1c62ee
docs(transaction-pay-controller): clarify atomic max subsidy hints
OGPoyraz Sep 22, 2026
d3f9964
Merge origin/main into ogp/CONF-1949
OGPoyraz Sep 22, 2026
5fd8ff1
Merge branch 'main' into ogp/CONF-1949
OGPoyraz Sep 22, 2026
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
7 changes: 7 additions & 0 deletions packages/transaction-pay-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- Support subsidized max Relay deposits using atomic `EXACT_OUTPUT` quotes with transaction calls embedded, gated by `payStrategies.relay.atomicMaxEnabled` (disabled by default, with per-transaction-type overrides). ([#10224](https://github.com/MetaMask/core/pull/10224))
- Honor the client's `atomic` hint: atomic max quotes use the source-token budget adjusted to destination decimals for 1:1 subsidized stablecoin routes, without a discovery quote or reusing the original deposit amount. Unsubsidized responses are re-quoted non-atomically.
- Non-atomic hints start with `EXACT_INPUT` and upgrade to atomic execution when subsidized.
- Atomic promotion errors retain the `Atomic promotion failed` prefix through standard quote error handling and strategy fallback.

## [29.0.2]

### Fixed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import type { RelayStatus } from './types.js';

export const FALLBACK_HASH = '0x0' as Hex;

export const ATOMIC_PROMOTION_FAILURE_PREFIX = 'Atomic promotion failed: ';

export const RELAY_URL_BASE = 'https://api.relay.link';
export const RELAY_AUTHORIZE_URL = `${RELAY_URL_BASE}/authorize`;
export const RELAY_EXECUTE_URL = `${RELAY_URL_BASE}/execute`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
getTokenBalance,
getTokenInfo,
} from '../../utils/token.js';
import { getRelayMaxGasStationQuote } from './relay-max-gas-station.js';
import { getRelayMaxGasStationQuote } from './relay-max.js';
import type { RelayQuote } from './types.js';

jest.mock('../../utils/token');
Expand Down Expand Up @@ -153,7 +153,7 @@ function makeFullRequest(
};
}

describe('relay-max-gas-station', () => {
describe('relay-max', () => {
const calculateGasFeeTokenCostMock = jest.mocked(calculateGasFeeTokenCost);
const getNativeTokenMock = jest.mocked(getNativeToken);
const getTokenBalanceMock = jest.mocked(getTokenBalance);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { toHex } from '@metamask/controller-utils';
import type { TransactionMeta } from '@metamask/transaction-controller';
import { createModuleLogger } from '@metamask/utils';
import { BigNumber } from 'bignumber.js';

Expand All @@ -8,6 +10,8 @@ import type {
TransactionPayControllerMessenger,
TransactionPayQuote,
} from '../../types.js';
import { prefixError } from '../../utils/error-prefix.js';
import { isAtomicMaxEnabled } from '../../utils/feature-flags.js';
import {
getGasStationEligibility,
getGasStationCostInSourceTokenRaw,
Expand All @@ -17,9 +21,12 @@ import {
getTokenBalance,
getTokenInfo,
} from '../../utils/token.js';
import { QuoteError } from '../../utils/validation.js';
import { ATOMIC_PROMOTION_FAILURE_PREFIX } from './constants.js';
import { isSubsidizedRelayQuote } from './relay-submit-execute.js';
import type { RelayQuote, RelayTransactionStep } from './types.js';

const log = createModuleLogger(projectLogger, 'relay-max-gas-station');
const log = createModuleLogger(projectLogger, 'relay-max');

const PROBE_AMOUNT_PERCENTAGE = 0.25;

Expand Down Expand Up @@ -52,6 +59,93 @@ type MaxAmountQuoteContext = {
request: QuoteRequest;
};

/**
* Fetch a max quote using the client's atomic hint for subsidized routes.
*
* @param request - Max quote request.
* @param fullRequest - Full quote context.
* @param getSingleQuote - Fetcher for a single Relay quote.
* @returns An atomic subsidized quote or a non-atomic max quote.
*/
export async function getRelayMaxQuote(
request: QuoteRequest,
fullRequest: PayStrategyGetQuotesRequest,
getSingleQuote: GetSingleQuoteFn,
): Promise<TransactionPayQuote<RelayQuote>> {
const { messenger, transaction } = fullRequest;
const { atomic, isPostQuote } = request;

if (isPostQuote || !isAtomicMaxEnabled(messenger, transaction)) {
return getRelayMaxGasStationQuote(request, fullRequest, getSingleQuote);
}

if (atomic !== false) {
const sourceToken = getTokenInfo(
messenger,
request.sourceTokenAddress,
request.sourceChainId,
);
const targetToken = getTokenInfo(
messenger,
request.targetTokenAddress,
request.targetChainId,
);

if (!sourceToken || !targetToken) {
throw new Error('Token decimals not found for atomic max quote');
}

// Fixed-spread subsidized routes exchange stablecoins 1:1 without fees.
// Use the source budget, not the required amount from before Max.
const targetAmount = new BigNumber(request.sourceTokenAmount)
.shiftedBy(targetToken.decimals - sourceToken.decimals)
.toFixed(0, BigNumber.ROUND_DOWN);
const quote = await getAtomicMaxQuote({
amount: targetAmount,
fullRequest,
getSingleQuote,
request,
});

if (isSubsidizedRelayQuote(quote.original)) {
return quote;
}

return getRelayMaxGasStationQuote(
{ ...request, atomic: false },
fullRequest,
getSingleQuote,
);
}

const quote = await getRelayMaxGasStationQuote(
request,
fullRequest,
getSingleQuote,
);

if (!isSubsidizedRelayQuote(quote.original)) {
return quote;
}

try {
const promotedQuote = await getAtomicMaxQuote({
amount: quote.original.details.currencyOut.amount,
fullRequest,
getSingleQuote,
request,
});

if (!isSubsidizedRelayQuote(promotedQuote.original)) {
throw new Error('Promoted quote lost subsidy');
}

return promotedQuote;
} catch (error) {
return throwAtomicPromotionFailed(error);
}
}

/**
* Returns a Relay max-amount quote using a two-phase gas-station fallback.
*
Expand Down Expand Up @@ -466,3 +560,134 @@ function markQuoteAsMaxGasStation(
isMaxGasStation: true,
};
}

async function getAtomicMaxQuote({
amount,
fullRequest,
getSingleQuote,
request,
}: {
amount: string;
fullRequest: PayStrategyGetQuotesRequest;
getSingleQuote: GetSingleQuoteFn;
request: QuoteRequest;
}): Promise<TransactionPayQuote<RelayQuote>> {
const targetAmount = validatePositiveIntegerString(amount);
const atomicTransaction = await applyAmountDataUpdates({
amount: targetAmount,
messenger: fullRequest.messenger,
transaction: cloneTransactionForPromotion(fullRequest.transaction),
});

return getSingleQuote(
{
...request,
atomic: true,
targetAmountMinimum: targetAmount,
},
{ ...fullRequest, transaction: atomicTransaction },
);
}

export function throwAtomicPromotionFailed(error: unknown): never {
const prefixed = prefixError(error, ATOMIC_PROMOTION_FAILURE_PREFIX);
throw new QuoteError({
detail: [prefixed.message],
message: prefixed.message,
reason: 'no-quotes',
});
}

export function isSubsidizedAtomicMaxQuote(
quote: TransactionPayQuote<RelayQuote>,
): boolean {
return (
quote.request.isMaxAmount === true &&
quote.request.atomic === true &&
isSubsidizedRelayQuote(quote.original)
);
}

function validatePositiveIntegerString(value: string): string {
const amount = new BigNumber(value);

if (!amount.isFinite() || !amount.isInteger() || !amount.isGreaterThan(0)) {
throw new Error(`Invalid target amount: ${value}`);
}

return amount.toFixed(0);
}

function cloneTransactionForPromotion(
transaction: TransactionMeta,
): TransactionMeta {
return {
...transaction,
nestedTransactions: transaction.nestedTransactions?.map(
(nestedTransaction) => ({
...nestedTransaction,
}),
),
requiredAssets: transaction.requiredAssets?.map((requiredAsset) => ({
...requiredAsset,
})),
};
}

async function applyAmountDataUpdates({
amount,
messenger,
transaction,
}: {
amount: string;
messenger: TransactionPayControllerMessenger;
transaction: TransactionMeta;
}): Promise<TransactionMeta> {
const { updates } = await messenger.call(
'TransactionPayController:getAmountData',
{
amount,
transaction,
},
);

if (!updates.length) {
throw new Error('getAmountData returned no updates for atomic promotion');
}

const nestedTransactions = transaction.nestedTransactions?.map(
(nestedTransaction) => ({
...nestedTransaction,
}),
);

if (!nestedTransactions?.length) {
throw new Error('Missing nested transactions for atomic promotion');
}

for (const { nestedTransactionIndex, data } of updates) {
if (!nestedTransactions[nestedTransactionIndex]) {
throw new Error(
'getAmountData returned an unusable nested transaction update',
);
}

nestedTransactions[nestedTransactionIndex].data = data;
}

const requiredAssets = transaction.requiredAssets?.map((requiredAsset) => ({
...requiredAsset,
}));

if (!requiredAssets?.[0]) {
throw new Error('Missing required assets for atomic promotion');
}

requiredAssets[0].amount = toHex(BigInt(amount));

return {
...transaction,
nestedTransactions,
requiredAssets,
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale txParams in atomic max quotes

High Severity

Atomic max preparation rewrites nested calldata and requiredAssets on a clone, but leaves txParams on the pre-max deposit amount. Quote-time getDelegationTransaction and later execute validation still read that stale txParams, so the embedded vault call can lock the original amount while the paired transfer uses the source-budget size.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 5fd8ff1. Configure here.

}
Loading
Loading