Проверка адресов и calldata
Как проверять address и calldata перед внешним вызовом, чтобы контракт не отправлял средства или команды не тому получателю.
Зачем проверять адреса и calldata
address и bytes calldata часто приходят от пользователя. Если передать их напрямую в call, пользователь сам выбирает контракт, функцию и аргументы.
Это опасно для router, multicall, vault и admin-контрактов: одна пропущенная проверка может отправить ETH на нулевой адрес, вызвать не ту функцию или выполнить произвольный внешний вызов.
Пишем небезопасный и безопасный вызов
Создайте файл contracts/ValidateAddressCalldata.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract TokenVault {
mapping(address => uint256) public balances;
error ZeroAddress();
error ZeroAmount();
error NotEnoughBalance();
function deposit(address receiver) external payable {
if (receiver == address(0)) {
revert ZeroAddress();
}
if (msg.value == 0) {
revert ZeroAmount();
}
balances[receiver] += msg.value;
}
function withdraw(address payable receiver, uint256 amount) external {
if (receiver == address(0)) {
revert ZeroAddress();
}
if (amount == 0) {
revert ZeroAmount();
}
if (balances[msg.sender] < amount) {
revert NotEnoughBalance();
}
balances[msg.sender] -= amount;
receiver.transfer(amount);
}
}
contract UnsafeExecutor {
function execute(address target, bytes calldata data) external payable returns (bytes memory) {
(bool success, bytes memory result) = target.call{value: msg.value}(data);
require(success, "Call failed");
return result;
}
}
contract SafeVaultRouter {
bytes4 private constant DEPOSIT_SELECTOR = TokenVault.deposit.selector;
error ZeroAddress();
error TargetIsNotContract();
error InvalidSelector();
error InvalidReceiver();
error InvalidCallData();
error CallFailed();
function safeDeposit(address target, bytes calldata data) external payable returns (bytes memory) {
_validateTarget(target);
_validateDepositCall(data);
(bool success, bytes memory result) = target.call{value: msg.value}(data);
if (!success) {
revert CallFailed();
}
return result;
}
function _validateTarget(address target) private view {
if (target == address(0)) {
revert ZeroAddress();
}
if (target.code.length == 0) {
revert TargetIsNotContract();
}
}
function _validateDepositCall(bytes calldata data) private pure {
if (data.length != 4 + 32) {
revert InvalidCallData();
}
bytes4 selector = bytes4(data[:4]);
if (selector != DEPOSIT_SELECTOR) {
revert InvalidSelector();
}
address receiver = abi.decode(data[4:], (address));
if (receiver == address(0)) {
revert InvalidReceiver();
}
}
}Создайте файл scripts/validate-address-calldata.ts:
import { network } from "hardhat";
const { ethers } = await network.create();
const [alice, bob] = await ethers.getSigners();
const vault = await ethers.deployContract("TokenVault");
await vault.waitForDeployment();
const unsafeExecutor = await ethers.deployContract("UnsafeExecutor");
await unsafeExecutor.waitForDeployment();
const safeRouter = await ethers.deployContract("SafeVaultRouter");
await safeRouter.waitForDeployment();
const vaultAddress = await vault.getAddress();
const validDepositCall = vault.interface.encodeFunctionData("deposit", [bob.address]);
const invalidWithdrawCall = vault.interface.encodeFunctionData("withdraw", [
alice.address,
ethers.parseEther("1"),
]);
await safeRouter.safeDeposit(vaultAddress, validDepositCall, {
value: ethers.parseEther("1"),
});
console.log("Баланс Bob после safeDeposit:", ethers.formatEther(await vault.balances(bob.address)));
try {
await safeRouter.safeDeposit(vaultAddress, invalidWithdrawCall, {
value: ethers.parseEther("1"),
});
} catch {
console.log("SafeVaultRouter отклонил calldata с неправильным selector");
}
try {
await unsafeExecutor.execute(ethers.ZeroAddress, validDepositCall, {
value: ethers.parseEther("1"),
});
} catch {
console.log("UnsafeExecutor попытался вызвать нулевой адрес");
}Запуск:
npx hardhat run scripts/validate-address-calldata.tsSafeVaultRouter пропускает только TokenVault.deposit(address) с ненулевым получателем. Любой другой selector, пустой target или EOA-адрес отклоняются до внешнего вызова.
Как это работает
target == address(0) — базовая проверка. Нулевой адрес почти никогда не должен быть получателем вызова или владельцем прав.
target.code.length == 0 — проверяет, что по адресу есть код контракта. Это защищает router от случайного вызова EOA.
bytes4(data[:4]) — первые 4 байта calldata. Это selector функции, которую пользователь хочет вызвать.
TokenVault.deposit.selector — ожидаемый selector. Контракт сравнивает его с calldata и отклоняет все остальные функции.
abi.decode(data[4:], (address)) — декодирует аргументы после selector. Так можно проверить receiver до выполнения call.
Что проверять перед call
Проверяйте target: не нулевой адрес, контрактный код, allowlist для критичных маршрутов.
Проверяйте selector: контракт должен явно знать, какие функции разрешены.
Проверяйте аргументы: получатель, сумма, deadline, chainId, token address и лимиты должны быть валидны до внешнего вызова.
Проверяйте msg.value: если calldata не должна отправлять ETH, требуйте msg.value == 0.
Частые ошибки
Принимать bytes calldata data без selector allowlist → пользователь может вызвать любую функцию target-контракта.
Проверять только target → даже правильный контракт может иметь опасные функции, которые не должны быть доступны через router.
Доверять frontend → calldata формируется на стороне пользователя. Контракт обязан проверять её сам.
Что дальше
- Что такое ABI — как function selector и аргументы превращаются в calldata
- Вызов write-функций контракта — как ethers кодирует вызовы функций
- Batch транзакции через Multicall — почему batch-контракты особенно нуждаются в проверке target и calldata