Skip to content

Commit 374c0ed

Browse files
fix(transaction-controller): reject transactions that would deploy an empty contract (MetaMask#8744)
## Explanation `TransactionController` would silently classify a transaction with no recipient and no real bytecode as a contract deployment and broadcast it, creating an empty contract on-chain with any `value` permanently locked inside. The root cause is that `'0x'` is a non-empty string, so `if (data)` truthiness checks in `validateParamRecipient` and `determineTransactionType` treated it as real deployment bytecode. `validateParamRecipient` also didn't recognize `to === ''` as missing. Validation now treats `to === ''` as missing alongside `'0x'` / `undefined`, and both the validator and the type classifier require `data.length > 2` before accepting a missing recipient as a legitimate deployment. ## References ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Tightens core transaction validation/type inference around contract deployments; could cause some previously-accepted malformed transactions to be rejected or reclassified, but the change is narrowly scoped and well-covered by tests. > > **Overview** > Prevents accidental empty contract deployments by treating missing recipients (`to` of `''`, `'0x'`, or `undefined`) as **invalid unless** `data` contains *real* deployment bytecode (more than just `'0x'`). > > Updates both `validateTxParams`/`validateParamRecipient` and `determineTransactionType` to require non-empty bytecode before allowing a missing `to`, adds targeted unit tests for the new edge cases, and records the fix in the `transaction-controller` changelog. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 7154b2f. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 687f9ea commit 374c0ed

5 files changed

Lines changed: 124 additions & 13 deletions

File tree

packages/transaction-controller/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- Trigger the first-time-interaction warning correctly for `safeTransferFrom` token transfers by including `TransactionType.tokenMethodSafeTransferFrom` in the effective-recipient decoding logic ([#8723](https://github.com/MetaMask/core/pull/8723))
1313

14+
### Fixed
15+
16+
- Reject transactions with a missing `to` and empty `data` to prevent accidental empty contract deployments ([#8744](https://github.com/MetaMask/core/pull/8744))
17+
1418
## [65.2.0]
1519

1620
### Added

packages/transaction-controller/src/utils/transaction-type.test.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ describe('determineTransactionType', () => {
165165
});
166166
});
167167

168-
it('returns a contract deployment type when "to" is falsy and there is data', async () => {
168+
it('returns a contract deployment type when "to" is falsy and there is real bytecode', async () => {
169169
const result = await determineTransactionType(
170170
{
171171
...txParams,
@@ -180,6 +180,34 @@ describe('determineTransactionType', () => {
180180
});
181181
});
182182

183+
it('does NOT classify as deployContract when "to" is falsy but data is "0x" (empty bytecode)', async () => {
184+
jest.mocked(rpcRequest).mockResolvedValue('0x');
185+
186+
const result = await determineTransactionType(
187+
{
188+
...txParams,
189+
to: '',
190+
data: '0x',
191+
},
192+
{ messenger: messengerMock, networkClientId: networkClientIdMock },
193+
);
194+
expect(result.type).not.toBe(TransactionType.deployContract);
195+
});
196+
197+
it('does NOT classify as deployContract when "to" is undefined but data is "0x"', async () => {
198+
jest.mocked(rpcRequest).mockResolvedValue('0x');
199+
200+
const result = await determineTransactionType(
201+
{
202+
...txParams,
203+
to: undefined,
204+
data: '0x',
205+
},
206+
{ messenger: messengerMock, networkClientId: networkClientIdMock },
207+
);
208+
expect(result.type).not.toBe(TransactionType.deployContract);
209+
});
210+
183211
it('returns a simple send type with a 0x getCodeResponse when there is data, but the "to" address is not a contract address', async () => {
184212
jest.mocked(rpcRequest).mockResolvedValue('0x');
185213

packages/transaction-controller/src/utils/transaction-type.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,9 @@ export async function determineTransactionType(
4141
): Promise<InferTransactionTypeResult> {
4242
const { data, to } = txParams;
4343

44-
if (data && !to) {
44+
const hasRealBytecode = Boolean(data && data !== '0x' && data.length > 2);
45+
46+
if (hasRealBytecode && !to) {
4547
return { type: TransactionType.deployContract, getCodeResponse: undefined };
4648
}
4749

packages/transaction-controller/src/utils/validation.test.ts

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,35 +77,97 @@ describe('validation', () => {
7777
);
7878
});
7979

80-
it('should throw if no data', () => {
80+
it('should throw if to is missing and no real bytecode', () => {
81+
const expectedError = rpcErrors.invalidParams(
82+
'Invalid "to" address: must be specified for transactions without contract deployment bytecode.',
83+
);
84+
8185
expect(() =>
8286
validateTxParams({
8387
from: FROM_MOCK,
8488
to: '0x',
8589
// TODO: Replace `any` with type
8690
// eslint-disable-next-line @typescript-eslint/no-explicit-any
8791
} as any),
88-
).toThrow(rpcErrors.invalidParams('Invalid "to" address.'));
92+
).toThrow(expectedError);
8993

9094
expect(() =>
9195
validateTxParams({
9296
from: FROM_MOCK,
9397
// TODO: Replace `any` with type
9498
// eslint-disable-next-line @typescript-eslint/no-explicit-any
9599
} as any),
96-
).toThrow(rpcErrors.invalidParams('Invalid "to" address.'));
100+
).toThrow(expectedError);
101+
});
102+
103+
it('should throw if to is empty string and no real bytecode', () => {
104+
expect(() =>
105+
validateTxParams({
106+
from: FROM_MOCK,
107+
to: '' as Hex,
108+
data: '0x',
109+
value: '0x1',
110+
// TODO: Replace `any` with type
111+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
112+
} as any),
113+
).toThrow(
114+
rpcErrors.invalidParams(
115+
'Invalid "to" address: must be specified for transactions without contract deployment bytecode.',
116+
),
117+
);
97118
});
98119

99-
it('should delete data', () => {
120+
it('should throw if to is empty string and data is "0x" (would deploy empty contract)', () => {
121+
expect(() =>
122+
validateTxParams({
123+
from: FROM_MOCK,
124+
to: '' as Hex,
125+
data: '0x',
126+
// TODO: Replace `any` with type
127+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
128+
} as any),
129+
).toThrow(
130+
rpcErrors.invalidParams(
131+
'Invalid "to" address: must be specified for transactions without contract deployment bytecode.',
132+
),
133+
);
134+
});
135+
136+
it('should throw if to is undefined and data is "0x" (would deploy empty contract)', () => {
137+
expect(() =>
138+
validateTxParams({
139+
from: FROM_MOCK,
140+
data: '0x',
141+
// TODO: Replace `any` with type
142+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
143+
} as any),
144+
).toThrow(
145+
rpcErrors.invalidParams(
146+
'Invalid "to" address: must be specified for transactions without contract deployment bytecode.',
147+
),
148+
);
149+
});
150+
151+
it('should remove "to" when missing and data has real bytecode (legitimate deployment)', () => {
100152
const transaction = {
101-
data: 'foo',
153+
data: '0x608060405234',
102154
from: TO_MOCK,
103155
to: '0x',
104156
};
105157
validateTxParams(transaction);
106158
expect(transaction.to).toBeUndefined();
107159
});
108160

161+
it('should remove "to" when empty string and data has real bytecode', () => {
162+
const transaction = {
163+
data: '0x608060405234',
164+
from: TO_MOCK,
165+
to: '' as Hex,
166+
};
167+
validateTxParams(transaction);
168+
expect(transaction.to).toBeUndefined();
169+
});
170+
109171
it('should throw if invalid to address', () => {
110172
expect(() =>
111173
validateTxParams({

packages/transaction-controller/src/utils/validation.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -205,16 +205,31 @@ function validateParamValue(value?: string): void {
205205
*
206206
* @param txParams - The transaction parameters object to validate.
207207
* @throws Throws an error if the recipient address is invalid:
208-
* - If the recipient address is an empty string ('0x') or undefined and the transaction contains data,
209-
* the "to" field is removed from the transaction parameters.
210-
* - If the recipient address is not a valid hexadecimal Ethereum address, an error is thrown.
208+
* - If the recipient address is missing (empty string, '0x', or undefined) and the
209+
* transaction does not contain real bytecode (data must be longer than `0x`),
210+
* an error is thrown. This prevents accidental contract deployments with empty
211+
* `to` and empty `data` from locking funds.
212+
* - If the recipient address is missing and the transaction contains real
213+
* bytecode (data longer than `0x`), the "to" field is removed from the
214+
* transaction parameters (legitimate contract deployment).
215+
* - If the recipient address is not a valid hexadecimal Ethereum address, an
216+
* error is thrown.
211217
*/
212218
function validateParamRecipient(txParams: TransactionParams): void {
213-
if (txParams.to === '0x' || txParams.to === undefined) {
214-
if (txParams.data) {
219+
const isMissingRecipient =
220+
txParams.to === '0x' || txParams.to === '' || txParams.to === undefined;
221+
222+
if (isMissingRecipient) {
223+
const hasRealBytecode = Boolean(
224+
txParams.data && txParams.data !== '0x' && txParams.data.length > 2,
225+
);
226+
227+
if (hasRealBytecode) {
215228
delete txParams.to;
216229
} else {
217-
throw rpcErrors.invalidParams(`Invalid "to" address.`);
230+
throw rpcErrors.invalidParams(
231+
`Invalid "to" address: must be specified for transactions without contract deployment bytecode.`,
232+
);
218233
}
219234
} else if (txParams.to !== undefined && !isValidHexAddress(txParams.to)) {
220235
throw rpcErrors.invalidParams(`Invalid "to" address.`);

0 commit comments

Comments
 (0)