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

### Added

- Support security validation of transaction batches ([#5526](https://github.com/MetaMask/core/pull/5526))
- Add `ValidateSecurityRequest` type.
- Add optional `securityAlertId` to `SecurityAlertResponse`.
- Add optional `securityAlertId` to `TransactionBatchRequest`.
- Add optional `validateSecurity` callback to `TransactionBatchRequest`.
- Support publish batch hook ([#5401](https://github.com/MetaMask/core/pull/5401))
- Add `hooks.publishBatch` option to constructor.
- Add `updateBatchTransactions` method.
Expand Down
1 change: 1 addition & 0 deletions packages/transaction-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export type {
TransactionMeta,
TransactionParams,
TransactionReceipt,
ValidateSecurityRequest,
} from './types';
export {
GasFeeEstimateLevel,
Expand Down
33 changes: 31 additions & 2 deletions packages/transaction-controller/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1138,10 +1138,11 @@ export type TransactionError = {
* Type for security alert response from transaction validator.
*/
export type SecurityAlertResponse = {
reason: string;
features?: string[];
result_type: string;
providerRequestsCount?: Record<string, number>;
reason: string;
result_type: string;
securityAlertId?: string;
};

/** Alternate priority levels for which values are provided in gas fee estimates. */
Expand Down Expand Up @@ -1513,6 +1514,9 @@ export type TransactionBatchRequest = {
/** Whether an approval request should be created to require confirmation from the user. */
requireApproval?: boolean;

/** Security alert ID to persist on the transaction. */
securityAlertId?: string;

/** Transactions to be submitted as part of the batch. */
transactions: TransactionBatchSingleRequest[];

Expand All @@ -1521,6 +1525,17 @@ export type TransactionBatchRequest = {
* Defaults to false.
*/
useHook?: boolean;

/**
* Callback to trigger security validation in the client.
*
* @param request - The JSON-RPC request to validate.
* @param chainId - The chain ID of the transaction batch.
*/
validateSecurity?: (
request: ValidateSecurityRequest,
chainId: Hex,
) => Promise<void>;
};

/**
Expand Down Expand Up @@ -1595,3 +1610,17 @@ export type PublishBatchHook = (
/** Data required to call the hook. */
request: PublishBatchHookRequest,
) => Promise<PublishBatchHookResult>;

/**
* Request to validate security of a transaction in the client.
*/
export type ValidateSecurityRequest = {
/** JSON-RPC method to validate. */
method: string;

/** Parameters of the JSON-RPC method to validate. */
params: unknown[];

/** Optional EIP-7702 delegation to mock for the transaction sender. */
delegationMock?: Hex;
};
139 changes: 137 additions & 2 deletions packages/transaction-controller/src/utils/batch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const TRANSACTION_HASH_2_MOCK = '0x456';
const TRANSACTION_SIGNATURE_MOCK = '0xabc';
const TRANSACTION_SIGNATURE_2_MOCK = '0xdef';
const ERROR_MESSAGE_MOCK = 'Test error';
const SECURITY_ALERT_ID_MOCK = '123-456';

const TRANSACTION_META_MOCK = {
id: BATCH_ID_CUSTOM_MOCK,
Expand All @@ -61,7 +62,7 @@ const TRANSACTION_META_MOCK = {
data: DATA_MOCK,
value: VALUE_MOCK,
},
} as TransactionMeta;
} as unknown as TransactionMeta;

describe('Batch Utils', () => {
const doesChainSupportEIP7702Mock = jest.mocked(doesChainSupportEIP7702);
Expand Down Expand Up @@ -100,11 +101,13 @@ describe('Batch Utils', () => {
jest.resetAllMocks();
addTransactionMock = jest.fn();
getChainIdMock = jest.fn();
updateTransactionMock = jest.fn();

determineTransactionTypeMock.mockResolvedValue({
type: TransactionType.simpleSend,
});
updateTransactionMock = jest.fn();

getChainIdMock.mockReturnValue(CHAIN_ID_MOCK);

request = {
addTransaction: addTransactionMock,
Expand Down Expand Up @@ -408,6 +411,138 @@ describe('Batch Utils', () => {
);
});

it('adds security alert ID to transaction', async () => {
doesChainSupportEIP7702Mock.mockReturnValueOnce(true);

isAccountUpgradedToEIP7702Mock.mockResolvedValueOnce({
delegationAddress: undefined,
isSupported: true,
});

addTransactionMock.mockResolvedValueOnce({
transactionMeta: TRANSACTION_META_MOCK,
result: Promise.resolve(''),
});

generateEIP7702BatchTransactionMock.mockReturnValueOnce({
to: TO_MOCK,
data: DATA_MOCK,
value: VALUE_MOCK,
});

request.request.securityAlertId = SECURITY_ALERT_ID_MOCK;

await addTransactionBatch(request);

expect(addTransactionMock).toHaveBeenCalledTimes(1);
expect(addTransactionMock).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
securityAlertResponse: {
securityAlertId: SECURITY_ALERT_ID_MOCK,
},
}),
);
});

describe('validates security', () => {
it('using transaction params', async () => {
doesChainSupportEIP7702Mock.mockReturnValueOnce(true);

isAccountUpgradedToEIP7702Mock.mockResolvedValueOnce({
delegationAddress: undefined,
isSupported: true,
});

addTransactionMock.mockResolvedValueOnce({
transactionMeta: TRANSACTION_META_MOCK,
result: Promise.resolve(''),
});

generateEIP7702BatchTransactionMock.mockReturnValueOnce({
to: TO_MOCK,
data: DATA_MOCK,
value: VALUE_MOCK,
});

const validateSecurityMock = jest.fn();
validateSecurityMock.mockResolvedValueOnce({});

request.request.validateSecurity = validateSecurityMock;

await addTransactionBatch(request);

expect(validateSecurityMock).toHaveBeenCalledTimes(1);
expect(validateSecurityMock).toHaveBeenCalledWith(
{
delegationMock: undefined,
method: 'eth_sendTransaction',
params: [
{
authorizationList: undefined,
data: DATA_MOCK,
from: FROM_MOCK,
to: TO_MOCK,
type: TransactionEnvelopeType.feeMarket,
value: VALUE_MOCK,
},
],
},
CHAIN_ID_MOCK,
);
});

it('using delegation mock if not upgraded', async () => {
doesChainSupportEIP7702Mock.mockReturnValueOnce(true);

isAccountUpgradedToEIP7702Mock.mockResolvedValueOnce({
delegationAddress: undefined,
isSupported: false,
});

addTransactionMock.mockResolvedValueOnce({
transactionMeta: TRANSACTION_META_MOCK,
result: Promise.resolve(''),
});

generateEIP7702BatchTransactionMock.mockReturnValueOnce({
to: TO_MOCK,
data: DATA_MOCK,
value: VALUE_MOCK,
});

getEIP7702UpgradeContractAddressMock.mockReturnValue(
CONTRACT_ADDRESS_MOCK,
);

const validateSecurityMock = jest.fn();
validateSecurityMock.mockResolvedValueOnce({});

request.request.validateSecurity = validateSecurityMock;

await addTransactionBatch(request);

expect(validateSecurityMock).toHaveBeenCalledTimes(1);
expect(validateSecurityMock).toHaveBeenCalledWith(
{
delegationMock: CONTRACT_ADDRESS_MOCK,
method: 'eth_sendTransaction',
params: [
{
authorizationList: undefined,
data: DATA_MOCK,
from: FROM_MOCK,
to: TO_MOCK,
type: TransactionEnvelopeType.feeMarket,
value: VALUE_MOCK,
},
],
},
CHAIN_ID_MOCK,
);
});
});

describe('with publish batch hook', () => {
it('adds each nested transaction', async () => {
const publishBatchHook = jest.fn();
Expand Down
29 changes: 29 additions & 0 deletions packages/transaction-controller/src/utils/batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,13 @@ import { CollectPublishHook } from '../hooks/CollectPublishHook';
import { projectLogger } from '../logger';
import type {
NestedTransactionMetadata,
SecurityAlertResponse,
TransactionBatchSingleRequest,
PublishBatchHook,
PublishBatchHookTransaction,
PublishHook,
TransactionBatchRequest,
ValidateSecurityRequest,
} from '../types';
import {
TransactionEnvelopeType,
Expand Down Expand Up @@ -95,8 +97,10 @@ export async function addTransactionBatch(
from,
networkClientId,
requireApproval,
securityAlertId,
transactions,
useHook,
validateSecurity,
} = userRequest;

log('Adding', userRequest);
Expand Down Expand Up @@ -161,15 +165,40 @@ export async function addTransactionBatch(
txParams.authorizationList = [{ address: upgradeContractAddress }];
}

if (validateSecurity) {
const securityRequest: ValidateSecurityRequest = {
method: 'eth_sendTransaction',
params: [
{
...txParams,
authorizationList: undefined,
type: TransactionEnvelopeType.feeMarket,
},
],
delegationMock: txParams.authorizationList?.[0]?.address,
};

log('Security request', securityRequest);

validateSecurity(securityRequest, chainId).catch((error) => {
log('Security validation failed', error);
});
}

log('Adding batch transaction', txParams, networkClientId);

const batchId = batchIdOverride ?? generateBatchId();

const securityAlertResponse = securityAlertId
? ({ securityAlertId } as SecurityAlertResponse)
: undefined;

const { result } = await addTransaction(txParams, {
batchId,
nestedTransactions,
networkClientId,
requireApproval,
securityAlertResponse,
type: TransactionType.batch,
});

Expand Down
Loading