EVM Recipes
End-to-end copy-paste flows for the common EVM tasks — approve-and-transfer, batch balances, NFT mint, EIP-2612 permit, and more.
Each recipe below is complete — imports, session setup, error handling, explorer URL. Paste, swap the addresses, and ship.
All recipes use @oviato/connect (React). Swap for @oviato/sdk if you're
using a different framework — the function signatures are identical.
Approve-and-transfer (ERC-20)
Check allowance, approve if needed, then transfer through a contract (e.g. a DEX router).
import { evm } from '@oviato/connect';
import { parseUnits } from '@oviato/connect';
const USDC = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48';
const ROUTER = '0x...';
const amount = parseUnits('100', 6); // 100 USDC
const ERC20_ABI = [
{
type: 'function', name: 'approve',
inputs: [{ name: 'spender', type: 'address' }, { name: 'value', type: 'uint256' }],
outputs: [{ type: 'bool' }], stateMutability: 'nonpayable',
},
] as const;
async function approveAndTransfer(session: { address: `0x${string}` }) {
// 1. Check current allowance
const { raw: current } = await evm.getTokenAllowance({
tokenAddress: USDC,
owner: session.address,
spender: ROUTER,
});
// 2. Approve if short
if (current < amount) {
const approve = await evm.writeContract({
address: USDC,
abi: ERC20_ABI,
functionName: 'approve',
args: [ROUTER, amount],
});
if (approve.status !== 'success') {
throw new Error(approve.status === 'error' ? approve.message : 'Approve failed');
}
// Wait for approval to land before using it
await evm.waitForTransactionReceipt({ hash: approve.data.hash });
}
// 3. Now call the router
// ... evm.writeContract({ address: ROUTER, ... })
}Always waitForTransactionReceipt on the approve before using the
allowance. A pending approve tx doesn't count — the router will still see
the old allowance until it mines.
Dashboard-style balance fetch (multicall)
Fetch balances for multiple tokens in one round-trip.
import { evm, formatUnits } from '@oviato/connect';
const ERC20_BALANCE_OF_ABI = [
{
type: 'function', name: 'balanceOf',
inputs: [{ name: 'account', type: 'address' }],
outputs: [{ type: 'uint256' }], stateMutability: 'view',
},
] as const;
async function getMultiBalance(owner: `0x${string}`) {
// Get the curated token list
const { tokens } = await evm.getTokens();
// Batch their balances in one call
const results = await evm.multicall({
contracts: tokens.map((t) => ({
address: t.address,
abi: ERC20_BALANCE_OF_ABI,
functionName: 'balanceOf',
args: [owner],
})),
});
return tokens.map((t, i) => {
const r = results[i];
if (r.status !== 'success') {
return { ...t, balance: '0', raw: 0n, error: r.error };
}
const raw = r.result as bigint;
return {
...t,
raw,
balance: formatUnits(raw, t.decimals),
};
});
}
// Output: [{ symbol: 'USDC', address, balance: '1234.56', raw: 1234560000n, ... }, ...]Mint an NFT (no-args mint)
Many test/community NFTs expose a parameterless mintNft() function.
const MINTABLE_NFT_ABI = [
{ type: 'function', name: 'mintNft', inputs: [], outputs: [], stateMutability: 'nonpayable' },
] as const;
async function mintNft(contract: `0x${string}`) {
const r = await evm.writeContract({
address: contract,
abi: MINTABLE_NFT_ABI,
functionName: 'mintNft',
args: [],
});
if (r.status !== 'success') {
throw new Error(r.status === 'error' ? r.message : 'Mint failed');
}
const rx = await evm.waitForTransactionReceipt({ hash: r.data.hash });
// Your newly-minted token id often comes from a Transfer event in the receipt.
// For simple contracts, the token id is 1 + totalSupply before mint.
return { hash: r.data.hash, receipt: rx };
}Sign an EIP-2612 Permit (gasless approval)
Permit lets a user approve a spender without an on-chain transaction — the signature is presented later by the spender alongside permit() + the intended action. USDC on Ethereum supports this natively.
import { evm } from '@oviato/connect';
async function signPermit(session: { address: `0x${string}` }) {
const chainId = 1; // mainnet
const deadline = Math.floor(Date.now() / 1000) + 3600; // +1h
// Read the current nonce from the token (USDC permit nonces are token-side, not eth nonce)
const USDC_PERMIT_ABI = [
{
type: 'function', name: 'nonces',
inputs: [{ name: 'owner', type: 'address' }],
outputs: [{ type: 'uint256' }], stateMutability: 'view',
},
] as const;
const nonce = await evm.readContract<bigint>({
address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
abi: USDC_PERMIT_ABI,
functionName: 'nonces',
args: [session.address],
});
const r = await evm.signTypedData({
domain: {
name: 'USD Coin',
version: '2',
chainId,
verifyingContract: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
},
types: {
Permit: [
{ name: 'owner', type: 'address' },
{ name: 'spender', type: 'address' },
{ name: 'value', type: 'uint256' },
{ name: 'nonce', type: 'uint256' },
{ name: 'deadline', type: 'uint256' },
],
},
primaryType: 'Permit',
message: {
owner: session.address,
spender: '0x...',
value: 100_000_000n, // 100 USDC
nonce,
deadline: BigInt(deadline),
},
});
if (r.status !== 'success') return null;
// Split the signature for the permit() call — r, s, v
const sig = r.data.signature as `0x${string}`;
return {
deadline,
v: parseInt(sig.slice(130, 132), 16),
r: `0x${sig.slice(2, 66)}`,
s: `0x${sig.slice(66, 130)}`,
};
}Check owner + metadata + transfer an NFT
async function inspectAndTransfer(
contract: `0x${string}`,
tokenId: bigint,
newOwner: `0x${string}`,
session: { address: `0x${string}` },
) {
// 1. Confirm the session owns this NFT
const { owner } = await evm.getNftOwner({ contractAddress: contract, tokenId });
if (owner.toLowerCase() !== session.address.toLowerCase()) {
throw new Error(`You don't own token ${tokenId}`);
}
// 2. Pull metadata for the confirmation UI (bridge does this automatically too)
const meta = await evm.getNftMetadata({ contractAddress: contract, tokenId });
console.log(`Transferring ${meta.metadata?.name ?? `#${tokenId}`}`);
// 3. Transfer
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;
const r = await evm.writeContract({
address: contract,
abi: ERC721_ABI,
functionName: 'safeTransferFrom',
args: [session.address, newOwner, tokenId],
});
if (r.status !== 'success') throw new Error('Transfer failed');
return r.data;
}Pay with USDC via x402 (gasless)
Oviato supports the x402 payment protocol — an HTTP-level payment flow for gated resources. The SDK signs an ERC-3009 authorization, attaches it as an HTTP header, and your server validates.
import { x402 } from '@oviato/connect';
const r = await x402.pay('https://api.example.com/premium-data');
if (r.paid) {
const data = await r.response.json();
console.log(data);
} else if (r.error) {
console.error('Payment failed:', r.error);
}See x402 Payments for the full flow, including facilitator setup.
Fee-aware transaction
Let the user pick a speed tier before signing.
import { evm, formatUnits } from '@oviato/connect';
async function previewAndSend(to: `0x${string}`, value: bigint) {
// 1. Get current fees
const fees = await evm.estimateFeesPerGas();
const gasUnits = await evm.estimateGas({ to, value });
const costWei = gasUnits * fees.maxFeePerGas;
const costEth = formatUnits(costWei, 18);
// 2. Show the user the cost before proceeding
if (!confirm(`Send ${formatUnits(value, 18)} ETH? (gas ≈ ${costEth} ETH)`)) {
return null;
}
// 3. Send — the bridge will ultimately recompute fees, this preview is UI-only
const r = await evm.sendTransaction({ to, value, fee: 'medium' });
return r;
}More
- Writes reference — full parameter details
- Troubleshooting — error-keyed lookup
- Migrating from viem — side-by-side