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
71 changes: 71 additions & 0 deletions src/validators/evm/base.validator.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { BaseValidator } from '../base.validator';
import { ValidationResult } from '../../types';
import { isDefined, isNonEmptyString } from '../../utils/validation';
import { ethers } from 'ethers';

export interface EVMTransaction {
to: string | null;
Expand Down Expand Up @@ -100,4 +101,74 @@ export abstract class BaseEVMValidator extends BaseValidator {
}
return null;
}

protected parseAndValidateCalldata(
tx: EVMTransaction,
iface: ethers.Interface,
): { parsed: ethers.TransactionDescription } | { error: ValidationResult } {
try {
const parsed = iface.parseTransaction({
data: tx.data ?? '0x',
value: tx.value,
});

if (!isDefined(parsed)) {
return {
error: this.blocked('Failed to parse transaction data'),
};
}

// Check for tampering
const tamperErr = this.ensureCalldataNotTampered(
tx.data ?? '0x',
iface,
parsed,
);

if (tamperErr) {
return { error: tamperErr };
}

return { parsed };
} catch (error) {
return {
error: this.blocked('Invalid transaction data', {
error: error instanceof Error ? error.message : String(error),
}),
};
}
}

protected ensureCalldataNotTampered(
originalCalldata: string,
iface: ethers.Interface,
parsedTx: ethers.TransactionDescription,
): ValidationResult | null {
try {
// Re-encode the function call with the parsed arguments
const expectedCalldata = iface.encodeFunctionData(
parsedTx.name,
parsedTx.args,
);

// Normalize both to lowercase for comparison
const normalizedOriginal = originalCalldata.toLowerCase();
const normalizedExpected = expectedCalldata.toLowerCase();

// Check if they match exactly
if (normalizedOriginal !== normalizedExpected) {
return this.blocked('Transaction calldata has been tampered with', {
expectedLength: expectedCalldata.length,
actualLength: originalCalldata.length,
lengthDifference: originalCalldata.length - expectedCalldata.length,
});
}

return null;
} catch (error) {
return this.blocked('Failed to validate calldata integrity', {
error: error instanceof Error ? error.message : String(error),
});
}
}
}
174 changes: 174 additions & 0 deletions src/validators/evm/lido/lido.validator.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Shield } from '../../../shield';
import { TransactionType } from '../../../types';
import { ethers } from 'ethers';

describe('LidoValidator via Shield', () => {
const shield = new Shield();
Expand Down Expand Up @@ -112,7 +113,7 @@
expect(result.reason).toContain('No matching operation pattern found');
// Check the specific error in the attempts
const stakeAttempt = result.details?.attempts?.find(
(a: any) => a.type === TransactionType.STAKE,

Check warning on line 116 in src/validators/evm/lido/lido.validator.test.ts

View workflow job for this annotation

GitHub Actions / Test & Build (22.x)

Unexpected any. Specify a different type

Check warning on line 116 in src/validators/evm/lido/lido.validator.test.ts

View workflow job for this annotation

GitHub Actions / Test & Build (20.17.0)

Unexpected any. Specify a different type
);
expect(stakeAttempt?.reason).toContain('not to Lido stETH contract');
});
Expand Down Expand Up @@ -166,6 +167,37 @@
expect(result.reason).toContain('No matching operation pattern found'); // Previously: toContain('Invalid referral address');
});

it('should reject stake transaction with appended bytes', () => {
const tx = {
to: lidoStEthAddress,
from: userAddress,
value: '0xde0b6b3a7640000',
data:
'0xa1903eab' +
referralAddress.slice(2).padStart(64, '0') +
'deadbeef', // Extra bytes appended
nonce: 0,
gasLimit: '0x30d40',
gasPrice: '0x4a817c800',
chainId: 1,
type: 0,
};

const serialized = JSON.stringify(tx);
const result = shield.validate({
yieldId,
unsignedTransaction: serialized,
userAddress,
});

expect(result.isValid).toBe(false);
expect(result.reason).toContain('No matching operation pattern found');
const stakeAttempt = result.details?.attempts?.find(
(a: any) => a.type === TransactionType.STAKE,

Check warning on line 196 in src/validators/evm/lido/lido.validator.test.ts

View workflow job for this annotation

GitHub Actions / Test & Build (22.x)

Unexpected any. Specify a different type

Check warning on line 196 in src/validators/evm/lido/lido.validator.test.ts

View workflow job for this annotation

GitHub Actions / Test & Build (20.17.0)

Unexpected any. Specify a different type
);
expect(stakeAttempt?.reason).toContain('calldata has been tampered');
});
Comment on lines +170 to +199
Copy link

Copilot AI Oct 15, 2025

Choose a reason for hiding this comment

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

[nitpick] Multiple tampering rejection tests (stake, unstake variants, claimWithdrawal, claimWithdrawals) duplicate the same pattern of constructing a tx, serializing, validating, and asserting on the tampered reason; consider extracting a helper (e.g., expectTampered(tx, type)) or using a table-driven test to reduce repetition and make future additions (other functions or tamper cases) simpler.

Copilot uses AI. Check for mistakes.

it('should validate STAKE transaction', () => {
const real = {
from: '0x4546fC1b71375eA0fa4D8cA32B9F2C2ED4FB2E82',
Expand Down Expand Up @@ -341,6 +373,77 @@
expect(result.reason).toContain('No matching operation pattern found'); // Previously: toContain('Withdrawal amounts array is empty');
});

it('should reject unstake transaction with appended bytes', () => {
const tx = {
to: lidoWithdrawalQueueAddress,
from: userAddress,
value: '0x0',
data:
'0xd6681042' +
'0000000000000000000000000000000000000000000000000000000000000040' +
'000000000000000000000000' +
userAddress.slice(2) +
'0000000000000000000000000000000000000000000000000000000000000001' +
'0000000000000000000000000000000000000000000000000de0b6b3a7640000' +
'cafebabe', // Extra bytes
nonce: 0,
gasLimit: '0x493e0',
gasPrice: '0x4a817c800',
chainId: 1,
type: 0,
};

const serialized = JSON.stringify(tx);
const result = shield.validate({
yieldId,
unsignedTransaction: serialized,
userAddress,
});

expect(result.isValid).toBe(false);
expect(result.reason).toContain('No matching operation pattern found');
const unstakeAttempt = result.details?.attempts?.find(
(a: any) => a.type === TransactionType.UNSTAKE,

Check warning on line 406 in src/validators/evm/lido/lido.validator.test.ts

View workflow job for this annotation

GitHub Actions / Test & Build (22.x)

Unexpected any. Specify a different type

Check warning on line 406 in src/validators/evm/lido/lido.validator.test.ts

View workflow job for this annotation

GitHub Actions / Test & Build (20.17.0)

Unexpected any. Specify a different type
);
expect(unstakeAttempt?.reason).toContain('calldata has been tampered');
});

it('should reject unstake with multiple amounts and appended bytes', () => {
// requestWithdrawals([1 ETH, 2 ETH], owner) + extra bytes
const tx = {
to: lidoWithdrawalQueueAddress,
from: userAddress,
value: '0x0',
data:
'0xd6681042' +
'0000000000000000000000000000000000000000000000000000000000000040' +
'000000000000000000000000' +
userAddress.slice(2) +
'0000000000000000000000000000000000000000000000000000000000000002' + // 2 amounts
'0000000000000000000000000000000000000000000000000de0b6b3a7640000' + // 1 ETH
'0000000000000000000000000000000000000000000000001bc16d674ec80000' + // 2 ETH
'12345678', // Extra bytes
nonce: 0,
gasLimit: '0x493e0',
gasPrice: '0x4a817c800',
chainId: 1,
type: 0,
};

const serialized = JSON.stringify(tx);
const result = shield.validate({
yieldId,
unsignedTransaction: serialized,
userAddress,
});

expect(result.isValid).toBe(false);
const unstakeAttempt = result.details?.attempts?.find(
(a: any) => a.type === TransactionType.UNSTAKE,

Check warning on line 442 in src/validators/evm/lido/lido.validator.test.ts

View workflow job for this annotation

GitHub Actions / Test & Build (22.x)

Unexpected any. Specify a different type

Check warning on line 442 in src/validators/evm/lido/lido.validator.test.ts

View workflow job for this annotation

GitHub Actions / Test & Build (20.17.0)

Unexpected any. Specify a different type
);
expect(unstakeAttempt?.reason).toContain('calldata has been tampered');
});

it('should validate UNSTAKE transaction', () => {
const real = {
from: '0x034A21184A8832EBa5D9fcD61D533D0d641ECe12',
Expand Down Expand Up @@ -563,6 +666,77 @@
expect(result.reason).toContain('No matching operation pattern found'); // Previously: toContain('arrays length mismatch');
});

it('should reject claimWithdrawal with appended bytes', () => {
const requestId = 123n;
const encodedParams = requestId.toString(16).padStart(64, '0');

const tx = {
to: lidoWithdrawalQueueAddress,
from: userAddress,
value: '0x0',
data: '0xf8444436' + encodedParams + '12345678', // Extra bytes
nonce: 0,
gasLimit: '0x30d40',
gasPrice: '0x4a817c800',
chainId: 1,
type: 0,
};

const serialized = JSON.stringify(tx);
const result = shield.validate({
yieldId,
unsignedTransaction: serialized,
userAddress,
});

expect(result.isValid).toBe(false);
expect(result.reason).toContain('No matching operation pattern found');
const claimAttempt = result.details?.attempts?.find(
(a: any) => a.type === TransactionType.CLAIM_UNSTAKED,
);
expect(claimAttempt?.reason).toContain('calldata has been tampered');
});

it('should reject claimWithdrawals with appended bytes', () => {
// Generate VALID calldata using ethers
const iface = new ethers.Interface([
'function claimWithdrawals(uint256[] _requestIds, uint256[] _hints)',
]);

const validCalldata = iface.encodeFunctionData('claimWithdrawals', [
[1n, 2n], // requestIds
[100n, 200n], // hints
]);

// Append malicious bytes
const tamperedCalldata = validCalldata + 'cafebabe';

const tx = {
to: lidoWithdrawalQueueAddress,
from: userAddress,
value: '0x0',
data: tamperedCalldata,
nonce: 0,
gasLimit: '0x30d40',
gasPrice: '0x4a817c800',
chainId: 1,
type: 0,
};

const serialized = JSON.stringify(tx);
const result = shield.validate({
yieldId,
unsignedTransaction: serialized,
userAddress,
});

expect(result.isValid).toBe(false);
const claimAttempt = result.details?.attempts?.find(
(a: any) => a.type === TransactionType.CLAIM_UNSTAKED,
);
expect(claimAttempt?.reason).toContain('calldata has been tampered');
});

it('should validate batch CLAIM_UNSTAKED transaction', () => {
const real = {
from: '0x6877BB79f680216BbdF01704939037F22193e771',
Expand Down
Loading
Loading