Запись в контракт: writeContract и simulateContract
Сначала симулируешь transfer через simulateContract, затем отправляешь ту же транзакцию через writeContract на локальном Hardhat.
Сначала симуляция, потом отправка
writeContract меняет State: нужна подпись, ETH на gas и успешное выполнение контракта. simulateContract прогоняет тот же вызов через eth_call до отправки и либо возвращает готовый request для writeContract, либо падает с revert — без траты газа в мемпуле.
В ethers часто делают contract.transfer(...) сразу. В viem идиома такая: simulateContract → writeContract(request).
transfer ERC-20 на локальном Hardhat
В одном терминале:
npx hardhat nodeВ другом — пример-проект:
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 scriptscontracts/DemoToken.sol:
// 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:
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");Запуск:
npx tsx scripts/transfer.tsКак это работает
simulateContract — eth_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
const tx = await token.transfer(recipient, amount);
await tx.wait();| ethers v6 | viem |
|---|---|
contract.transfer(to, amount) сразу шлёт tx | simulateContract → writeContract(request) |
revert часто виден на estimateGas / после майнинга | revert симуляции — до отправки |
tx.wait() | waitForTransactionReceipt({ hash }) |
receipt.status === 1 | receipt.status === "success" |
writeContract без симуляции тоже возможен. Симуляция не обязательна синтаксически — это рекомендуемый безопасный порядок.
Частые ошибки
Пропустил account в simulateContract → симуляция идёт от address(0) или без корректного msg.sender. Для transfer баланс проверяется у отправителя — передавай тот же account, которым потом подписываешь.
Собрал writeContract({...}) заново вместо request → легко разъехаться с тем, что симулировал. Передавай request из симуляции.
Mainnet + тестовый ключ Hardhat → ключ публичный, средств нет, транзакция не имеет смысла. Пример только для hardhat / локальной ноды.
Что дальше
- Работа с событиями: watchContractEvent и getLogs — поймать
Transferпосле записи - Работа с транзакциями: nonce, gas, EIP-1559 — gas и комиссии в синтаксисе viem
- ERC-20: transfer — тот же сценарий на ethers.js v6