EVM Writes
Send transactions, call contracts, sign EIP-712 data, and switch chains on EVM networks.
All EVM writes open a popup or iframe where the user approves with their passkey. Signing happens server-side inside the bridge — your dapp never touches private keys.
import { evm } from '@oviato/connect';
const r = await evm.sendTransaction({ to, value: 10n ** 15n });
// → popup → passkey → broadcast → { hash, network, explorerUrl }New to Oviato writes? Core Concepts → The write flow walks through the full popup → sign → broadcast chain.
Response envelope
Every write returns a discriminated envelope:
type Result =
| { status: 'success'; type: 'sendTransactionSuccess'; data: { hash, network, explorerUrl } }
| { status: 'error'; message: string };Always narrow on status before reading .data:
const r = await evm.sendTransaction({ to, value });
if (r.status === 'success' && r.type === 'sendTransactionSuccess') {
console.log(r.data.hash, r.data.explorerUrl);
} else if (r.status === 'error') {
console.error(r.message);
}Prefer the raw-hash style from ethers/viem? See Migrating from viem for a one-line unwrap helper.
Send ETH / native
The simplest write — just move native value from the session address to a recipient.
import { evm } from '@oviato/connect';
const r = await evm.sendTransaction({
to: '0x742d35Cc6634C0532925a3b8D4C9db96C4b4d8b6',
value: 10n ** 15n, // 0.001 ETH in wei (bigint)
fee: 'auto',
});Parameters
| Field | Type | Notes |
|---|---|---|
to | Address | Required. |
value | bigint | string | number | Wei amount. Default 0n. |
data | Hex | Optional calldata (0x…). Default 0x. |
gas | bigint | string | number | Override gas limit. Usually not needed. |
maxFeePerGas | bigint | string | number | EIP-1559 override. |
maxPriorityFeePerGas | bigint | string | number | EIP-1559 override. |
gasPrice | bigint | string | number | Legacy override (mutually exclusive with 1559). |
nonce | number | Power-user override — bypasses the tracker. |
fee | 'auto' | 'fast' | 'medium' | 'slow' | Semantic tier. Default auto (≈ medium). |
method | 'popup' | 'iframe' | How the approval UI opens. |
fee: 'auto' is the right default. Tiers (fast/medium/slow) map to
priority-fee multipliers per chain; explicit values (maxFeePerGas etc.)
override them. Don't set both.
Transfer a token (ERC-20)
evm.writeContract takes an ABI + function name + args. The backend encodes the calldata and the bridge renders a specialized "Transfer Token" sheet — no raw hex shown to the user.
const ERC20_ABI = [
{
type: 'function',
name: 'transfer',
inputs: [
{ name: 'to', type: 'address' },
{ name: 'amount', type: 'uint256' },
],
outputs: [{ type: 'bool' }],
stateMutability: 'nonpayable',
},
] as const;
const r = await evm.writeContract({
address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // USDC on Ethereum
abi: ERC20_ABI,
functionName: 'transfer',
args: [recipient, 1_000_000n], // 1 USDC (6 decimals)
});1_000_000n is 1 USDC because USDC has 6 decimals. Always remember —
token amounts are in smallest units (wei-equivalent). Use parseUnits('1', 6) if you want to be explicit about the conversion.
Approve a spender
const ERC20_APPROVE_ABI = [
{
type: 'function',
name: 'approve',
inputs: [
{ name: 'spender', type: 'address' },
{ name: 'value', type: 'uint256' },
],
outputs: [{ type: 'bool' }],
stateMutability: 'nonpayable',
},
] as const;
// Approve exactly 100 USDC
await evm.writeContract({
address: USDC,
abi: ERC20_APPROVE_ABI,
functionName: 'approve',
args: [spender, 100_000_000n],
});
// Max uint256 — "infinite" approval (saves gas on repeat use)
await evm.writeContract({
address: USDC,
abi: ERC20_APPROVE_ABI,
functionName: 'approve',
args: [spender, 2n ** 256n - 1n],
});The bridge detects this is an ERC-20 approval (via the selector + known-contracts registry) and renders a dedicated "Approve" sheet with clear messaging about unlimited approvals when the value is max-uint256.
Transfer an NFT (ERC-721)
const ERC721_ABI = [
{
type: 'function',
name: 'safeTransferFrom',
inputs: [
{ name: 'from', type: 'address' },
{ name: 'to', type: 'address' },
{ name: 'tokenId', type: 'uint256' },
],
outputs: [],
stateMutability: 'nonpayable',
},
] as const;
await evm.writeContract({
address: nftContract,
abi: ERC721_ABI,
functionName: 'safeTransferFrom',
args: [session.address, recipient, tokenId],
});The bridge fetches the NFT's image via evm.getNftMetadata and renders it in a "Transfer NFT" sheet. If the image can't be resolved, the sheet shows a placeholder — the user still sees the collection name + token ID.
Call any contract (generic)
For contract calls that aren't erc20/erc721 standards — governance, DeFi, custom contracts — writeContract takes any ABI + function.
const ROUTER_ABI = [
{
type: 'function',
name: 'swapExactTokensForTokens',
inputs: [
{ name: 'amountIn', type: 'uint256' },
{ name: 'amountOutMin', type: 'uint256' },
{ name: 'path', type: 'address[]' },
{ name: 'to', type: 'address' },
{ name: 'deadline', type: 'uint256' },
],
outputs: [{ type: 'uint256[]' }],
stateMutability: 'nonpayable',
},
] as const;
await evm.writeContract({
address: UNISWAP_V2_ROUTER,
abi: ROUTER_ABI,
functionName: 'swapExactTokensForTokens',
args: [amountIn, 0n, [TOKEN_A, TOKEN_B], recipient, BigInt(deadline)],
});The bridge shows a generic "Contract Call" sheet with decoded args. Consider adding your contract to our curated registry (via the dashboard, coming in a later release) so users see a branded sheet instead.
Sign EIP-712 typed data
const r = await evm.signTypedData({
domain: {
name: 'MyApp',
version: '1',
chainId: 8453, // Base
},
types: {
Greeting: [
{ name: 'content', type: 'string' },
{ name: 'from', type: 'address' },
],
},
primaryType: 'Greeting',
message: {
content: 'Hi from Oviato',
from: session.address,
},
});
if (r.status === 'success' && r.type === 'signTypedDataSuccess') {
console.log('Signature:', r.data.signature);
}types must NOT include an EIP712Domain entry. The SDK computes the
domain separator from domain directly — adding it manually produces a
different hash and signatures won't recover to the expected address.
Verify the signature offchain with evm.verifyTypedData.
Switch chains
Ethers-style alias for switchNetwork.
import { evm } from '@oviato/connect';
import { Network } from '@oviato/connect';
const r = await evm.switchChain(Network.BASEMAINNET);
if (r.status === 'success') {
console.log('Now on', r.data.network);
}Chain switching requires a passkey tap — the server re-issues the session JWT scoped to the new network.
Wait for confirmations
After any write, pass the hash to evm.waitForTransactionReceipt to block until it mines.
const r = await evm.sendTransaction({ to, value });
if (r.status !== 'success') return;
const rx = await evm.waitForTransactionReceipt({ hash: r.data.hash });
if (rx?.status === 'success') {
console.log('Mined in block', rx.blockNumber);
}Fee tiers
Instead of setting maxFeePerGas manually, pass a semantic tier:
fee: 'fast'— high priority, 1–2 block inclusionfee: 'medium'— sensible default, 2–5 block inclusionfee: 'slow'— cheapest, may take 5–20 blocksfee: 'auto'— same as'medium'
The bridge UI's gas picker lets the user override inline — they can still bump to fast if they're in a hurry.
Rate limits
Per (app, address) pair:
sendTransaction/writeContract— 60 prepares/min, 30 broadcasts/minsignTypedData— no explicit limit (purely client-side, no chain state)switchChain/switchNetwork— 10/min
Exceeding any of these returns an error with a retryAfter hint. Your normal UI flow should be nowhere near these — they exist to bound runaway scripts.
Common questions
Related
- Reads — every method that doesn't need a popup
- Recipes — end-to-end flows (approve + transfer, mint, sign permit)
- Troubleshooting — error-keyed lookup
- Migrating from viem — side-by-side