Writing state
Every payable and nonpayable function in the ABI becomes an action creator on
contract.write. Like reads, calling one returns a plain object and performs no I/O — it
describes a transaction, it does not send one.
The basics
import { createContract } from '@tevm/contract'
const Dai = createContract({
name: 'Dai',
humanReadableAbi: [
'function balanceOf(address owner) view returns (uint256)',
'function transfer(address to, uint256 amount) returns (bool)',
'function approve(address spender, uint256 amount) returns (bool)',
],
address: '0x6B175474E89094C44Da98b954EedeAC495271d0F',
})
const action = Dai.write.transfer('0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', 1000n)
console.log(action.functionName) // 'transfer'
console.log(action.args) // ['0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', 1000n]
console.log(action.address) // '0x6B175474E89094C44Da98b954EedeAC495271d0F'
console.log(action.to) // same value — for viem compatibilityDai.write.balanceOf does not exist — balanceOf is view, so it is on
.read.
The action has no transaction options
A write action carries abi, humanReadableAbi, functionName, args, and (when known)
address/to/code. It deliberately carries no from, value, gas, nonce, or
maxFeePerGas. Those are execution concerns and belong to whatever sends the transaction.
The idiom is to spread the action and add the options:
import { ERC20 } from '@tevm/contract'
import { createMemoryClient, PREFUNDED_ACCOUNTS } from 'tevm'
const client = createMemoryClient()
await client.tevmReady()
const Token = ERC20.withAddress('0x6B175474E89094C44Da98b954EedeAC495271d0F')
await client.tevmContract({
...Token.write.transfer('0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', 1000n),
from: PREFUNDED_ACCOUNTS[0].address,
addToMempool: true,
})
await client.tevmMine({ blockCount: 1 })Payable functions
payable functions appear on .write just like nonpayable ones. The ETH value is not an ABI
argument, so it is not a parameter of the action creator — pass value alongside the spread
action.
import { createContract } from '@tevm/contract'
import { createMemoryClient, PREFUNDED_ACCOUNTS } from 'tevm'
import { parseEther } from 'viem'
const Wrapped = createContract({
name: 'WETH',
humanReadableAbi: [
'function deposit() payable',
'function withdraw(uint256 amount)',
'function balanceOf(address owner) view returns (uint256)',
],
address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
})
const client = createMemoryClient()
await client.tevmReady()
await client.tevmContract({
...Wrapped.write.deposit(),
from: PREFUNDED_ACCOUNTS[0].address,
value: parseEther('1'),
addToMempool: true,
})
await client.tevmMine({ blockCount: 1 })Simulate first, then send
Because the action object is inert, the same object can be executed twice — once as a simulation and once for real. Nothing about the action encodes which it is.
import { ERC20 } from '@tevm/contract'
import { createMemoryClient, PREFUNDED_ACCOUNTS } from 'tevm'
const client = createMemoryClient()
await client.tevmReady()
const Token = ERC20.withAddress('0x6B175474E89094C44Da98b954EedeAC495271d0F')
const transfer = Token.write.transfer('0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', 1000n)
// Simulate — no mempool, no mining.
const simulated = await client.tevmContract({
...transfer,
from: PREFUNDED_ACCOUNTS[0].address,
throwOnFail: false,
})
if (simulated.errors?.length) {
console.error('would revert:', simulated.errors)
} else {
await client.tevmContract({
...transfer,
from: PREFUNDED_ACCOUNTS[0].address,
addToMempool: true,
})
await client.tevmMine({ blockCount: 1 })
}Custom errors
As with reads, error entries from the ABI are appended to each write action's abi, so a
revert can be decoded into a named error rather than raw bytes.
import { createContract } from '@tevm/contract'
const Vault = createContract({
name: 'Vault',
humanReadableAbi: [
'function withdraw(uint256 amount)',
'error InsufficientBalance(uint256 available, uint256 required)',
],
})
console.log(Vault.write.withdraw(1n).abi.map((entry) => entry.type))
// ['function', 'error']The package ships an ErrorContract with every revert flavour —
custom errors with and without arguments, string reverts, and bare reverts — for exercising
this path in tests.
Zero-argument functions omit args
Exactly as with reads: a function with no parameters produces an action with no args key.
import { createContract } from '@tevm/contract'
const C = createContract({ name: 'C', humanReadableAbi: ['function poke()'] })
console.log('args' in C.write.poke()) // false
