китаев.tech

Запись в контракт: writeContract и simulateContract

Сначала симулируешь transfer через simulateContract, затем отправляешь ту же транзакцию через writeContract на локальном Hardhat.

Сначала симуляция, потом отправка

writeContract меняет State: нужна подпись, ETH на gas и успешное выполнение контракта. simulateContract прогоняет тот же вызов через eth_call до отправки и либо возвращает готовый request для writeContract, либо падает с revert — без траты газа в мемпуле.

В ethers часто делают contract.transfer(...) сразу. В viem идиома такая: simulateContractwriteContract(request).


transfer ERC-20 на локальном Hardhat

В одном терминале:

bash
npx hardhat node

В другом — пример-проект:

bash
mkdir viem-write-contract-example
cd viem-write-contract-example

npm init -y
npm pkg set type="module"
npm install viem solc tsx
mkdir contracts scripts

contracts/DemoToken.sol:

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract DemoToken {
    string public name = "Demo Token";
    string public symbol = "DEMO";
    uint8 public decimals = 18;
    uint256 public totalSupply;
    mapping(address => uint256) public balanceOf;

    event Transfer(address indexed from, address indexed to, uint256 value);

    constructor(uint256 initialSupply) {
        totalSupply = initialSupply;
        balanceOf[msg.sender] = initialSupply;
        emit Transfer(address(0), msg.sender, initialSupply);
    }

    function transfer(address to, uint256 amount) external returns (bool) {
        require(to != address(0), "transfer to zero address");
        require(balanceOf[msg.sender] >= amount, "insufficient balance");
        balanceOf[msg.sender] -= amount;
        balanceOf[to] += amount;
        emit Transfer(msg.sender, to, amount);
        return true;
    }
}

scripts/transfer.ts:

typescript
import { readFileSync } from "node:fs";
import {
  createPublicClient,
  createWalletClient,
  http,
  parseUnits,
  formatUnits,
  getContract,
} from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { hardhat } from "viem/chains";
import solc from "solc";

const source = readFileSync("contracts/DemoToken.sol", "utf8");

const compiled = JSON.parse(
  solc.compile(
    JSON.stringify({
      language: "Solidity",
      sources: { "DemoToken.sol": { content: source } },
      settings: {
        outputSelection: { "*": { "*": ["abi", "evm.bytecode.object"] } },
      },
    }),
  ),
);

const artifact = compiled.contracts["DemoToken.sol"].DemoToken;
const abi = artifact.abi;
const bytecode = `0x${artifact.evm.bytecode.object}` as `0x${string}`;

const account = privateKeyToAccount(
  // Первый тестовый ключ Hardhat. Не используй его в mainnet.
  "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
);

const publicClient = createPublicClient({
  chain: hardhat,
  transport: http("http://127.0.0.1:8545"),
});

const walletClient = createWalletClient({
  account,
  chain: hardhat,
  transport: http("http://127.0.0.1:8545"),
});

const recipient = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8";
const initialSupply = parseUnits("1000000", 18);
const amount = parseUnits("100", 18);

const deployHash = await walletClient.deployContract({
  abi,
  bytecode,
  args: [initialSupply],
});

const deployReceipt = await publicClient.waitForTransactionReceipt({
  hash: deployHash,
});

const tokenAddress = deployReceipt.contractAddress;
if (!tokenAddress) {
  throw new Error("Контракт не задеплоился");
}

const token = getContract({
  address: tokenAddress,
  abi,
  client: { public: publicClient, wallet: walletClient },
});

// 1. Симуляция: упадёт здесь, если баланс недостаточный / to = 0x0
const { request } = await publicClient.simulateContract({
  address: tokenAddress,
  abi,
  functionName: "transfer",
  args: [recipient, amount],
  account,
});

// 2. Отправка ровно того request, который прошёл симуляцию
const hash = await walletClient.writeContract(request);

const receipt = await publicClient.waitForTransactionReceipt({ hash });
if (receipt.status !== "success") {
  throw new Error("transfer не подтвердился");
}

const [senderBalance, recipientBalance] = await Promise.all([
  token.read.balanceOf([account.address]),
  token.read.balanceOf([recipient]),
]);

console.log("Hash:", hash);
console.log("Sender:", formatUnits(senderBalance, 18), "DEMO");
console.log("Recipient:", formatUnits(recipientBalance, 18), "DEMO");

Запуск:

bash
npx tsx scripts/transfer.ts

Как это работает

simulateContracteth_call от имени account с тем же calldata, что у будущей транзакции. Если контракт сделает revert, исключение получишь сейчас, а не после майнинга.

request из результата симуляции — уже собранные параметры для writeContract (address, abi, functionName, args, account, gas-поля). Их не собираешь второй раз вручную.

writeContract(request) — подпись и eth_sendRawTransaction. Возвращает hash, не receipt.

waitForTransactionReceipt({ hash }) — аналог tx.wait() в ethers. Статус смотри как "success" / "reverted", не 1 / 0.


Если ты знаешь ethers.js

typescript
const tx = await token.transfer(recipient, amount);
await tx.wait();
ethers v6viem
contract.transfer(to, amount) сразу шлёт txsimulateContractwriteContract(request)
revert часто виден на estimateGas / после майнингаrevert симуляции — до отправки
tx.wait()waitForTransactionReceipt({ hash })
receipt.status === 1receipt.status === "success"

writeContract без симуляции тоже возможен. Симуляция не обязательна синтаксически — это рекомендуемый безопасный порядок.


Частые ошибки

Пропустил account в simulateContract → симуляция идёт от address(0) или без корректного msg.sender. Для transfer баланс проверяется у отправителя — передавай тот же account, которым потом подписываешь.

Собрал writeContract({...}) заново вместо request → легко разъехаться с тем, что симулировал. Передавай request из симуляции.

Mainnet + тестовый ключ Hardhat → ключ публичный, средств нет, транзакция не имеет смысла. Пример только для hardhat / локальной ноды.


Что дальше

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