китаев.tech

Проверка адресов и calldata

Как проверять address и calldata перед внешним вызовом, чтобы контракт не отправлял средства или команды не тому получателю.

Зачем проверять адреса и calldata

address и bytes calldata часто приходят от пользователя. Если передать их напрямую в call, пользователь сам выбирает контракт, функцию и аргументы.

Это опасно для router, multicall, vault и admin-контрактов: одна пропущенная проверка может отправить ETH на нулевой адрес, вызвать не ту функцию или выполнить произвольный внешний вызов.


Пишем небезопасный и безопасный вызов

Создайте файл contracts/ValidateAddressCalldata.sol:

solidity
// 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:

typescript
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 попытался вызвать нулевой адрес");
}

Запуск:

bash
npx hardhat run scripts/validate-address-calldata.ts

SafeVaultRouter пропускает только 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 формируется на стороне пользователя. Контракт обязан проверять её сам.


Что дальше

Материалы китаev.tech публикуются в образовательных целях и не являются инвестиционной рекомендацией. Примеры кода и описания протоколов — для обучения; использование в продакшне на ваш собственный риск. Дисклеймер и политика конфиденциальности.