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
3 changes: 3 additions & 0 deletions packages/transaction-pay-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Use the native Transak buy quote's fee for the MM Pay fiat estimate when Transak Native is the resolved provider, falling back to the aggregator quote's fee when native is unavailable or the lookup fails ([#9317](https://github.com/MetaMask/core/pull/9317))
- The native lookup and fee reconciliation are owned by `RampsController:getQuoteWithFees`; clients must delegate `RampsController:getQuoteWithFees` to the `TransactionPayController` messenger (the previously required `TransakService:getBuyQuote` delegation is no longer needed) ([#10238](https://github.com/MetaMask/core/pull/10238)).
- Charge direct Monad mUSD on-ramp fees on top of the entered amount (fee-on-top), so the total is the entered amount plus fees ([#9317](https://github.com/MetaMask/core/pull/9317))
- Subsidized (fixed-spread) max direct Money Account Relay deposits now use `EXACT_OUTPUT` quoting with vault calls embedded atomically in the Relay quote. ([#10224](https://github.com/MetaMask/core/pull/10224))
- Failed atomic promotions are terminal `no-quotes` errors prefixed with `Atomic promotion failed`, and will block rather than silently fall back.
- Non-subsidized max behavior is unchanged.
- Bump `@metamask/utils` from `^11.12.0` to `^12.0.0` ([#10192](https://github.com/MetaMask/core/pull/10192))
- Bump `@metamask/assets-controller` from `^16.0.0` to `^16.1.0` ([#10242](https://github.com/MetaMask/core/pull/10242))
- Bump `@metamask/assets-controllers` from `^112.0.1` to `^112.0.2` ([#10242](https://github.com/MetaMask/core/pull/10242))
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,6 @@
import { toHex } from '@metamask/controller-utils';
import { TransactionType } from '@metamask/transaction-controller';
import type { TransactionMeta } from '@metamask/transaction-controller';
import { createModuleLogger } from '@metamask/utils';
import { BigNumber } from 'bignumber.js';

Expand All @@ -8,6 +11,8 @@ import type {
TransactionPayControllerMessenger,
TransactionPayQuote,
} from '../../types.js';
import { prefixError } from '../../utils/error-prefix.js';
import { isAtomicMaxPromotionEnabled } from '../../utils/feature-flags.js';
import {
getGasStationEligibility,
getGasStationCostInSourceTokenRaw,
Expand All @@ -17,9 +22,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 @@ -466,3 +474,250 @@ function markQuoteAsMaxGasStation(
isMaxGasStation: true,
};
}

export async function maybePromoteSubsidizedMaxMoneyAccountQuote({
discoveryQuote,
fullRequest,
getSingleQuote,
request,
}: {
discoveryQuote: TransactionPayQuote<RelayQuote>;
fullRequest: PayStrategyGetQuotesRequest;
getSingleQuote: GetSingleQuoteFn;
request: QuoteRequest;
}): Promise<TransactionPayQuote<RelayQuote>> {
if (
!shouldAttemptAtomicPromotion(
request,
fullRequest.transaction,
discoveryQuote,
fullRequest.messenger,
)
) {
return discoveryQuote;
}

try {
const targetAmount = validatePositiveIntegerString(
discoveryQuote.original.details.currencyOut.amount,
);

const transactionClone = cloneTransactionForPromotion(
fullRequest.transaction,
);
const promotionTransaction = await applyAmountDataUpdates({
amount: targetAmount,
messenger: fullRequest.messenger,
transaction: transactionClone,
});

const promotedQuote = await getSingleQuote(
{
...request,
atomic: true,
isMaxAmount: false,
targetAmountMinimum: targetAmount,
},
{
...fullRequest,
transaction: promotionTransaction,
},
);

assertAtomicPromotionIsValid({
discoveryQuote,
promotedQuote,
targetAmount,
});

return {
...promotedQuote,
request: {
...promotedQuote.request,
atomic: true,
isMaxAmount: true,
},
};
} catch (error) {
return throwAtomicPromotionFailed(error);
}
}

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 isPromotedSubsidizedMaxMoneyAccountQuote(
quote: TransactionPayQuote<RelayQuote>,
): boolean {
return (
quote.request.isMaxAmount === true &&
quote.request.atomic === true &&
isSubsidizedRelayQuote(quote.original)
);
}

function shouldAttemptAtomicPromotion(
request: QuoteRequest,
transaction: TransactionMeta,
discoveryQuote: TransactionPayQuote<RelayQuote>,
messenger: TransactionPayControllerMessenger,
): boolean {
return (
isAtomicMaxPromotionEnabled(messenger, transaction) &&
request.isMaxAmount === true &&
request.isPostQuote !== true &&
request.atomic !== true &&
isSubsidizedRelayQuote(discoveryQuote.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,
};
}

function assertAtomicPromotionIsValid({
discoveryQuote,
promotedQuote,
targetAmount,
}: {
discoveryQuote: TransactionPayQuote<RelayQuote>;
promotedQuote: TransactionPayQuote<RelayQuote>;
targetAmount: string;
}): void {
if (!isSubsidizedRelayQuote(promotedQuote.original)) {
throw new Error('Promoted quote lost subsidy');
}

if (discoveryQuote.original.details.currencyOut.amount !== targetAmount) {
throw new Error('Discovery quote target amount changed before promotion');
}
}

export async function maybeDemoteUnsubsidizedAtomicQuote({
fullRequest,
getSingleQuote,
quote,
request,
}: {
fullRequest: PayStrategyGetQuotesRequest;
getSingleQuote: GetSingleQuoteFn;
quote: TransactionPayQuote<RelayQuote>;
request: QuoteRequest;
}): Promise<TransactionPayQuote<RelayQuote>> {
if (
!shouldAttemptAtomicDemotion(
request,
fullRequest.transaction,
fullRequest.messenger,
)
) {
return quote;
}

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

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

function shouldAttemptAtomicDemotion(
request: QuoteRequest,
transaction: TransactionMeta,
messenger: TransactionPayControllerMessenger,
): boolean {
return (
isAtomicMaxPromotionEnabled(messenger, transaction) &&
request.isMaxAmount !== true &&
request.isPostQuote !== true &&
request.atomic !== false &&
transaction.type !== TransactionType.perpsDepositAndOrder &&
transaction.type !== TransactionType.predictDepositAndOrder
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Order demotion misses nested types

Medium Severity

The demote path skips order flows by comparing transaction.type to perpsDepositAndOrder and predictDepositAndOrder, so nested order transactions still demote. An unsubsidized atomic quote then drops its embedded order calls and submits as a non-atomic vault deposit instead.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6616ef8. Configure here.

}
Loading