Skip to content
Merged
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 @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- Gate the server pay strategy per transaction type so flows can be enabled individually ([#10312](https://github.com/MetaMask/core/pull/10312))
- `ServerStrategy.supports` now also requires a transaction type listed in the new `payStrategies.server.enabledTransactionTypes` remote feature flag, which defaults to empty.
- `ServerStrategy.supports` now declines flows using capabilities the strategy does not implement yet, regardless of the flag.
- 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.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import type { TransactionMeta } from '@metamask/transaction-controller';
import { TransactionType } from '@metamask/transaction-controller';

import type { TransactionPayControllerMessenger } from '../../index.js';
import type { TransactionPayQuote } from '../../types.js';
import type {
PayStrategyGetQuotesRequest,
QuoteRequest,
TransactionPayQuote,
} from '../../types.js';
import { getPayStrategiesConfig } from '../../utils/feature-flags.js';
import { getServerQuotes } from './server-quotes.js';
import { submitServerQuotes } from './server-submit.js';
Expand All @@ -16,6 +21,23 @@ const QUOTE_MOCK = {
estimatedDuration: 5,
} as TransactionPayQuote<ServerQuote>;

const QUOTE_REQUEST_MOCK = {
from: '0x1234567890123456789012345678901234567890',
sourceBalanceRaw: '1000000',
sourceChainId: '0x1',
sourceTokenAddress: '0x0000000000000000000000000000000000000001',
sourceTokenAmount: '1000',
targetAmountMinimum: '900',
targetChainId: '0xa4b1',
targetTokenAddress: '0x0000000000000000000000000000000000000002',
} as QuoteRequest;

const SUPPORTS_REQUEST_MOCK = {
messenger: {} as TransactionPayControllerMessenger,
requests: [QUOTE_REQUEST_MOCK],
transaction: { type: TransactionType.perpsDeposit } as TransactionMeta,
} as PayStrategyGetQuotesRequest;

describe('ServerStrategy', () => {
const getServerQuotesMock = jest.mocked(getServerQuotes);
const submitServerQuotesMock = jest.mocked(submitServerQuotes);
Expand All @@ -25,30 +47,49 @@ describe('ServerStrategy', () => {
jest.resetAllMocks();
getServerQuotesMock.mockResolvedValue([QUOTE_MOCK]);
getPayStrategiesConfigMock.mockReturnValue({
server: { enabled: true },
server: {
enabled: true,
enabledTransactionTypes: [TransactionType.perpsDeposit],
},
relay: { enabled: false },
} as ReturnType<typeof getPayStrategiesConfig>);
});

describe('supports', () => {
it('returns true when server strategy is enabled', () => {
expect(
new ServerStrategy().supports({
messenger: {} as TransactionPayControllerMessenger,
} as never),
).toBe(true);
it('returns true when enabled and the flow is allowlisted', () => {
expect(new ServerStrategy().supports(SUPPORTS_REQUEST_MOCK)).toBe(true);
});

it('returns false when server strategy is disabled', () => {
getPayStrategiesConfigMock.mockReturnValue({
server: { enabled: false },
server: {
enabled: false,
enabledTransactionTypes: [TransactionType.perpsDeposit],
},
relay: { enabled: true },
} as ReturnType<typeof getPayStrategiesConfig>);

expect(new ServerStrategy().supports(SUPPORTS_REQUEST_MOCK)).toBe(false);
});

it('returns false when the flow is not allowlisted', () => {
getPayStrategiesConfigMock.mockReturnValue({
server: {
enabled: true,
enabledTransactionTypes: [] as TransactionType[],
},
relay: { enabled: false },
} as ReturnType<typeof getPayStrategiesConfig>);

expect(new ServerStrategy().supports(SUPPORTS_REQUEST_MOCK)).toBe(false);
});

it('returns false when a request uses an unsupported capability', () => {
expect(
new ServerStrategy().supports({
messenger: {} as TransactionPayControllerMessenger,
} as never),
...SUPPORTS_REQUEST_MOCK,
requests: [{ ...QUOTE_REQUEST_MOCK, isMaxAmount: true }],
}),
).toBe(false);
});
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { createModuleLogger } from '@metamask/utils';

import { projectLogger } from '../../logger.js';
import type {
PayStrategy,
PayStrategyExecuteRequest,
Expand All @@ -7,18 +10,42 @@ import type {
import { getPayStrategiesConfig } from '../../utils/feature-flags.js';
import { getServerQuotes } from './server-quotes.js';
import { submitServerQuotes } from './server-submit.js';
import { getServerUnsupportedReason } from './server-support.js';
import type { ServerQuote } from './types.js';

const log = createModuleLogger(projectLogger, 'server-strategy');

/**
* Pay strategy that routes quote, submit, and status requests through the
* MetaMask intents API. Supports both gasless (delegated server-execute) and
* non-gasless (TransactionController-submitted) execution paths. Gated by the
* `payStrategies.server.enabled` remote feature flag.
* `payStrategies.server.enabled` remote feature flag, then narrowed per flow by
* `payStrategies.server.enabledTransactionTypes`.
*/
export class ServerStrategy implements PayStrategy<ServerQuote> {
supports(request: PayStrategyGetQuotesRequest): boolean {
const config = getPayStrategiesConfig(request.messenger);
return config.server.enabled;

if (!config.server.enabled) {
return false;
}

const reason = getServerUnsupportedReason({
enabledTransactionTypes: config.server.enabledTransactionTypes,
requests: request.requests,
transaction: request.transaction,
});

if (reason) {
log('Unsupported request', {
reason,
transactionType: request.transaction?.type,
});

return false;
}

return true;
}

async getQuotes(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import type { TransactionMeta } from '@metamask/transaction-controller';
import { TransactionType } from '@metamask/transaction-controller';

import type { QuoteRequest } from '../../types.js';
import {
DEFAULT_SERVER_ENABLED_TRANSACTION_TYPES,
getServerUnsupportedReason,
ServerUnsupportedReason,
} from './server-support.js';

const REQUEST_MOCK: QuoteRequest = {
from: '0x1234567890123456789012345678901234567890',
sourceBalanceRaw: '1000000',
sourceChainId: '0x1',
sourceTokenAddress: '0x0000000000000000000000000000000000000001',
sourceTokenAmount: '1000',
targetAmountMinimum: '900',
targetChainId: '0xa4b1',
targetTokenAddress: '0x0000000000000000000000000000000000000002',
};

const TRANSACTION_MOCK = {
type: TransactionType.perpsDeposit,
} as TransactionMeta;

/**
* Invoke the util with sensible defaults for a supported perps deposit.
*
* @param overrides - Values to override on the default arguments.
* @param overrides.enabledTransactionTypes - Allowlisted transaction types.
* @param overrides.requests - Quote requests.
* @param overrides.transaction - Parent transaction.
* @returns The unsupported reason, if any.
*/
function getReason({
enabledTransactionTypes = [TransactionType.perpsDeposit],
requests = [REQUEST_MOCK],
transaction = TRANSACTION_MOCK,
}: {
enabledTransactionTypes?: TransactionType[];
requests?: QuoteRequest[];
transaction?: TransactionMeta;
} = {}): ServerUnsupportedReason | undefined {
return getServerUnsupportedReason({
enabledTransactionTypes,
requests,
transaction,
});
}

describe('server-support', () => {
describe('DEFAULT_SERVER_ENABLED_TRANSACTION_TYPES', () => {
it('is empty so enabling the strategy alone diverts no traffic', () => {
expect(DEFAULT_SERVER_ENABLED_TRANSACTION_TYPES).toStrictEqual([]);
});
});

describe('getServerUnsupportedReason', () => {
it('returns undefined when the flow is allowlisted and uses no unsupported capability', () => {
expect(getReason()).toBeUndefined();
});

it('returns undefined when there are no requests', () => {
expect(getReason({ requests: [] })).toBeUndefined();
});

it('supports a flow only once it is added to the allowlist', () => {
expect(getReason({ enabledTransactionTypes: [] })).toBe(
ServerUnsupportedReason.DisabledTransactionType,
);

expect(
getReason({ enabledTransactionTypes: [TransactionType.perpsDeposit] }),
).toBeUndefined();
});

it('returns disabled transaction type when the flow is not allowlisted', () => {
expect(
getReason({
enabledTransactionTypes: [TransactionType.predictDeposit],
}),
).toBe(ServerUnsupportedReason.DisabledTransactionType);
});

it('returns disabled transaction type when the transaction has no type', () => {
expect(getReason({ transaction: {} as TransactionMeta })).toBe(
ServerUnsupportedReason.DisabledTransactionType,
);
});

it('returns undefined when a nested transaction has an allowlisted type', () => {
expect(
getReason({
transaction: {
nestedTransactions: [{ type: TransactionType.perpsDeposit }],
type: TransactionType.batch,
} as TransactionMeta,
}),
).toBeUndefined();
});

it.each([
TransactionType.perpsDepositAndOrder,
TransactionType.predictDepositAndOrder,
])(
'returns exact output for %s even when explicitly allowlisted',
(transactionType) => {
expect(
getReason({
enabledTransactionTypes: [transactionType],
transaction: { type: transactionType } as TransactionMeta,
}),
).toBe(ServerUnsupportedReason.ExactOutput);
},
);

it('returns exact output when a nested transaction requires it', () => {
expect(
getReason({
transaction: {
nestedTransactions: [
{ type: TransactionType.perpsDepositAndOrder },
],
type: TransactionType.perpsDeposit,
} as TransactionMeta,
}),
).toBe(ServerUnsupportedReason.ExactOutput);
});

it.each([
['atomic', { atomic: false }, ServerUnsupportedReason.NonAtomic],
[
'hyperliquidActivationFeeUsd',
{ hyperliquidActivationFeeUsd: '1' },
ServerUnsupportedReason.HyperliquidActivationFee,
],
[
'isDirectMusdMoneyAccount',
{ isDirectMusdMoneyAccount: true },
ServerUnsupportedReason.DirectMusdMoneyAccount,
],
['isMaxAmount', { isMaxAmount: true }, ServerUnsupportedReason.MaxAmount],
[
'isPolymarketDepositWallet',
{ isPolymarketDepositWallet: true },
ServerUnsupportedReason.PolymarketDepositWallet,
],
] as const)(
'returns %s reason for an unsupported capability',
(_name, overrides, expected) => {
expect(
getReason({ requests: [{ ...REQUEST_MOCK, ...overrides }] }),
).toBe(expected);
},
);

it('returns a reason when any request is unsupported', () => {
expect(
getReason({
requests: [REQUEST_MOCK, { ...REQUEST_MOCK, isMaxAmount: true }],
}),
).toBe(ServerUnsupportedReason.MaxAmount);
});

it('ignores capability flags that are explicitly false or undefined', () => {
expect(
getReason({
requests: [
{
...REQUEST_MOCK,
atomic: true,
isDirectMusdMoneyAccount: false,
isMaxAmount: false,
isPolymarketDepositWallet: false,
},
],
}),
).toBeUndefined();
});
});
});
Loading
Loading