Skip to content
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

- Export `generateEIP7702BatchTransaction` for building EIP-7702 batch transaction calldata from nested calls ([#9143](https://github.com/MetaMask/core/pull/9143))

### Fixed

- Set `isExternalSign` to `true` when `isGasFeeSponsored` is confirmed by simulation, so gas-sponsored transactions from accounts that cannot locally sign (e.g. Money Account keyring) skip `KeyringController:signTransaction` ([#9148](https://github.com/MetaMask/core/pull/9148))
Expand Down
5 changes: 4 additions & 1 deletion packages/transaction-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,10 @@ export {
WalletDevice,
} from './types';
export { mergeGasFeeEstimates } from './utils/gas-flow';
export { decodeAuthorizationSignature } from './utils/eip7702';
export {
decodeAuthorizationSignature,
generateEIP7702BatchTransaction,
} from './utils/eip7702';
export {
isEIP1559Transaction,
normalizeTransactionParams,
Expand Down
162 changes: 98 additions & 64 deletions packages/transaction-controller/src/utils/batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,17 @@ type IsAtomicBatchSupportedRequestInternal = {
publicKeyEIP7702?: Hex;
};

type PrepareEIP7702BatchTransactionRequest = {
messenger: TransactionControllerMessenger;
publicKeyEIP7702?: Hex;
request: TransactionBatchRequest;
};

type EIP7702BatchTransaction = {
nestedTransactions: NestedTransactionMetadata[];
txParams: TransactionParams;
};

const log = createModuleLogger(projectLogger, 'batch');

export const ERROR_MESSAGE_NO_UPGRADE_CONTRACT =
Expand Down Expand Up @@ -238,79 +249,19 @@ export function generateBatchId(): Hex {
return bytesToHex(idBytes);
}

/**
* Generate the metadata for a nested transaction.
*
* @param request - The batch request.
* @param singleRequest - The request for a single transaction.
* @param messenger - The transaction controller messenger.
* @param networkClientId - The network client ID.
* @returns The metadata for the nested transaction.
*/
async function getNestedTransactionMeta(
request: TransactionBatchRequest,
singleRequest: TransactionBatchSingleRequest,
messenger: TransactionControllerMessenger,
networkClientId: NetworkClientId,
): Promise<NestedTransactionMetadata> {
const { from } = request;
const { params, type: requestedType } = singleRequest;

if (requestedType) {
return {
...params,
type: requestedType,
};
}

const { type: determinedType } = await determineTransactionType(
{ from, ...params },
{ messenger, networkClientId },
);

return {
...params,
type: determinedType,
};
}

/**
* Process a batch transaction using an EIP-7702 transaction.
*
* @param request - The request object including the user request and necessary callbacks.
* @returns The batch result object including the batch ID.
*/
async function addTransactionBatchWith7702(
request: AddTransactionBatchRequest,
): Promise<TransactionBatchResult> {
const {
addTransaction,
messenger,
publicKeyEIP7702,
request: userRequest,
} = request;
async function prepareEIP7702BatchTransaction(
request: PrepareEIP7702BatchTransactionRequest,
): Promise<EIP7702BatchTransaction> {
const { messenger, publicKeyEIP7702, request: userRequest } = request;

const {
atomic,
batchId: batchIdOverride,
disableUpgrade,
from,
gasFeeToken,
gasLimit7702,
isInternal,
networkClientId,
origin,
overwriteUpgrade,
requestId,
requiredAssets,
requireApproval,
securityAlertId,
skipInitialGasEstimate,
transactions,
excludeNativeTokenForFee,
isGasFeeIncluded,
isGasFeeSponsored,
validateSecurity,
} = userRequest;

const chainId = getChainId({ messenger, networkClientId });
Expand Down Expand Up @@ -389,6 +340,89 @@ async function addTransactionBatchWith7702(
txParams.authorizationList = [{ address: upgradeContractAddress }];
}

return { nestedTransactions, txParams };
}

/**
* Generate the metadata for a nested transaction.
*
* @param request - The batch request.
* @param singleRequest - The request for a single transaction.
* @param messenger - The transaction controller messenger.
* @param networkClientId - The network client ID.
* @returns The metadata for the nested transaction.
*/
async function getNestedTransactionMeta(
request: TransactionBatchRequest,
singleRequest: TransactionBatchSingleRequest,
messenger: TransactionControllerMessenger,
networkClientId: NetworkClientId,
): Promise<NestedTransactionMetadata> {
const { from } = request;
const { params, type: requestedType } = singleRequest;

if (requestedType) {
return {
...params,
type: requestedType,
};
}

const { type: determinedType } = await determineTransactionType(
{ from, ...params },
{ messenger, networkClientId },
);

return {
...params,
type: determinedType,
};
}

/**
* Process a batch transaction using an EIP-7702 transaction.
*
* @param request - The request object including the user request and necessary callbacks.
* @returns The batch result object including the batch ID.
*/
async function addTransactionBatchWith7702(
request: AddTransactionBatchRequest,
): Promise<TransactionBatchResult> {
const {
addTransaction,
messenger,
publicKeyEIP7702,
request: userRequest,
} = request;

const {
batchId: batchIdOverride,
gasFeeToken,
isInternal,
networkClientId,
origin,
requestId,
requiredAssets,
requireApproval,
securityAlertId,
skipInitialGasEstimate,
transactions,
excludeNativeTokenForFee,
isGasFeeIncluded,
isGasFeeSponsored,
validateSecurity,
} = userRequest;

const { nestedTransactions, txParams } = await prepareEIP7702BatchTransaction(
{
messenger,
publicKeyEIP7702,
request: userRequest,
},
);

const chainId = getChainId({ messenger, networkClientId });

if (validateSecurity) {
const securityRequest: ValidateSecurityRequest = {
method: 'eth_sendTransaction',
Expand Down
1 change: 1 addition & 0 deletions packages/transaction-pay-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Add Relay quote validation and transaction simulation before Transaction Pay quotes are surfaced ([#9143](https://github.com/MetaMask/core/pull/9143))
- Add test-only fiat execution options to bypass fiat on-ramp settlement during local QA by funding the expected fiat asset from a configured account before continuing the normal MM Pay fiat submit flow ([#9161](https://github.com/MetaMask/core/pull/9161))

### Fixed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,7 @@ describe('AcrossStrategy', () => {
transaction: TRANSACTION_META_MOCK,
});

expect(result).toBe(false);
expect(result).toStrictEqual({ isSupported: false });
});

it('supports source 7702 authorization lists for Predict withdraw post-quote quotes without Across actions', () => {
Expand All @@ -390,7 +390,9 @@ describe('AcrossStrategy', () => {
} as TransactionMeta,
} as PayStrategyCheckQuoteSupportRequest<AcrossQuote>;

expect(strategy.checkQuoteSupport(request)).toBe(true);
expect(strategy.checkQuoteSupport(request)).toStrictEqual({
isSupported: true,
});
});

it('does not support first-time 7702 authorization lists for non-post-quote Across quotes', () => {
Expand All @@ -404,7 +406,9 @@ describe('AcrossStrategy', () => {
} as TransactionMeta,
} as PayStrategyCheckQuoteSupportRequest<AcrossQuote>;

expect(strategy.checkQuoteSupport(request)).toBe(false);
expect(strategy.checkQuoteSupport(request)).toStrictEqual({
isSupported: false,
});
});

it('does not support first-time 7702 authorization lists when the Across quote has embedded destination actions', () => {
Expand Down Expand Up @@ -441,7 +445,9 @@ describe('AcrossStrategy', () => {
} as TransactionMeta,
} as PayStrategyCheckQuoteSupportRequest<AcrossQuote>;

expect(strategy.checkQuoteSupport(request)).toBe(false);
expect(strategy.checkQuoteSupport(request)).toStrictEqual({
isSupported: false,
});
});

it('supports 7702 quotes that do not require an authorization list', () => {
Expand All @@ -461,7 +467,7 @@ describe('AcrossStrategy', () => {
quotes: [quote],
transaction: TRANSACTION_META_MOCK,
}),
).toBe(true);
).toStrictEqual({ isSupported: true });
});

it('returns false for unsupported destination actions', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
PayStrategyCheckQuoteSupportRequest,
PayStrategyExecuteRequest,
PayStrategyGetQuotesRequest,
PayStrategyQuoteSupportResult,
TransactionPayQuote,
} from '../../types';
import { getPayStrategiesConfig } from '../../utils/feature-flags';
Expand Down Expand Up @@ -79,7 +80,7 @@ export class AcrossStrategy implements PayStrategy<AcrossQuote> {

checkQuoteSupport(
request: PayStrategyCheckQuoteSupportRequest<AcrossQuote>,
): boolean {
): PayStrategyQuoteSupportResult {
// Gas planning can discover that TransactionController would add an
// authorization list for a first-time 7702 upgrade. `is7702` alone is not a
// blocker because it also covers already-upgraded accounts.
Expand All @@ -88,21 +89,23 @@ export class AcrossStrategy implements PayStrategy<AcrossQuote> {
);

if (!requiresAuthorizationList) {
return true;
return { isSupported: true };
}

if (!isPredictWithdrawTransaction(request.transaction)) {
return false;
return { isSupported: false };
}

// A first-time 7702 authorization list is acceptable here only because it is
// attached to MetaMask's source-chain batch transaction. It must not be
// smuggled into Across destination post-swap actions.
return request.quotes.every(
(quote) =>
quote.request.isPostQuote === true &&
quote.original.request.actions.length === 0,
);
return {
isSupported: request.quotes.every(
(quote) =>
quote.request.isPostQuote === true &&
quote.original.request.actions.length === 0,
),
};
}

async getQuotes(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Hex } from '@metamask/utils';

import { TransactionPayStrategy } from '../../constants';
import type {
PayStrategyExecuteRequest,
PayStrategyGetQuotesRequest,
Expand All @@ -8,17 +9,20 @@ import type {
import { getPayStrategiesConfig } from '../../utils/feature-flags';
import { getRelayQuotes } from './relay-quotes';
import { submitRelayQuotes } from './relay-submit';
import { validateRelayQuoteSupport } from './relay-validation';
import { RelayStrategy } from './RelayStrategy';
import type { RelayQuote } from './types';

jest.mock('./relay-quotes');
jest.mock('./relay-submit');
jest.mock('./relay-validation');
jest.mock('../../utils/feature-flags');

describe('RelayStrategy', () => {
const getRelayQuotesMock = jest.mocked(getRelayQuotes);
const submitRelayQuotesMock = jest.mocked(submitRelayQuotes);
const getPayStrategiesConfigMock = jest.mocked(getPayStrategiesConfig);
const validateRelayQuoteSupportMock = jest.mocked(validateRelayQuoteSupport);

const messenger = {} as never;

Expand Down Expand Up @@ -57,6 +61,7 @@ describe('RelayStrategy', () => {
enabled: true,
},
});
validateRelayQuoteSupportMock.mockResolvedValue({ isSupported: true });
});

it('returns true from supports when relay is enabled', () => {
Expand Down Expand Up @@ -92,6 +97,40 @@ describe('RelayStrategy', () => {
expect(getRelayQuotesMock).toHaveBeenCalledWith(request);
});

it('delegates checkQuoteSupport', async () => {
const quote = buildQuote();
const supportResult = {
isSupported: false,
validationError: 'RPC down',
};

validateRelayQuoteSupportMock.mockResolvedValue(supportResult);

const strategy = new RelayStrategy();
const checkRequest = {
messenger,
quotes: [quote],
transaction: request.transaction,
};
const result = await strategy.checkQuoteSupport(checkRequest);

expect(result).toStrictEqual(supportResult);
expect(validateRelayQuoteSupportMock).toHaveBeenCalledWith(checkRequest);
});

function buildQuote(
requestOverrides: Partial<TransactionPayQuote<RelayQuote>['request']> = {},
): TransactionPayQuote<RelayQuote> {
return {
request: {
sourceChainId: '0x1' as Hex,
sourceTokenAddress: '0xabc' as Hex,
...requestOverrides,
},
strategy: TransactionPayStrategy.Relay,
} as TransactionPayQuote<RelayQuote>;
}

it('delegates execute', async () => {
const executeRequest = {
messenger,
Expand Down
Loading