Skip to main content
Skill — Copyable reference implementation. Use as-is or customize. See Skills Philosophy.

WalletClient Implementation

This Skill documents how to implement a viem-compatible WalletClient using Voltaire primitives. The implementation provides a drop-in replacement for viem’s wallet functionality.

Quick Start

import { createWalletClient, http, custom } from './examples/viem-walletclient/index.js';
import { privateKeyToAccount } from './examples/viem-account/index.js';

// Local account (signing locally)
const account = privateKeyToAccount('0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80');
const client = createWalletClient({
  account,
  chain: mainnet,
  transport: http('https://mainnet.infura.io/v3/YOUR_KEY'),
});

// Send transaction
const hash = await client.sendTransaction({
  to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8',
  value: 1000000000000000000n,
});

// Sign message
const signature = await client.signMessage({
  message: 'Hello, Ethereum!',
});

Account Types

Local Account

Local accounts sign transactions and messages locally using a private key. The signed data is then sent to the network via eth_sendRawTransaction.
import { privateKeyToAccount } from './examples/viem-account/index.js';

const account = privateKeyToAccount('0x...');
const client = createWalletClient({
  account,
  chain: mainnet,
  transport: http('https://...'),
});

// Account is "hoisted" - no need to specify per action
const hash = await client.sendTransaction({
  to: '0x...',
  value: 1n,
});

JSON-RPC Account

JSON-RPC accounts delegate signing to an external wallet (like MetaMask). The wallet handles signing via eth_sendTransaction, personal_sign, etc.
// Browser: using injected wallet
const client = createWalletClient({
  transport: custom(window.ethereum),
});

// Request wallet connection
const addresses = await client.requestAddresses();

// Wallet will prompt user to sign
const hash = await client.sendTransaction({
  account: addresses[0],
  to: '0x...',
  value: 1n,
});

Wallet Actions

sendTransaction

Creates, signs, and sends a transaction to the network.
// Local account: signs locally, sends via eth_sendRawTransaction
const hash = await client.sendTransaction({
  to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8',
  value: 1000000000000000000n, // 1 ETH
  gas: 21000n,                 // Optional
  maxFeePerGas: 20000000000n,  // Optional (EIP-1559)
});

// JSON-RPC account: calls eth_sendTransaction
const hash = await client.sendTransaction({
  account: '0xA0Cf798816D4b9b9866b5330EEa46a18382f251e',
  to: '0x...',
  value: 1n,
});

signMessage

Signs a message using EIP-191 format.
// String message
const signature = await client.signMessage({
  message: 'Hello, Ethereum!',
});

// Raw bytes
const signature = await client.signMessage({
  message: { raw: new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f]) },
});

// Raw hex
const signature = await client.signMessage({
  message: { raw: '0x48656c6c6f' },
});

signTypedData

Signs EIP-712 typed data.
const signature = await client.signTypedData({
  domain: {
    name: 'Ether Mail',
    version: '1',
    chainId: 1n,
    verifyingContract: '0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC',
  },
  types: {
    Person: [
      { name: 'name', type: 'string' },
      { name: 'wallet', type: 'address' },
    ],
    Mail: [
      { name: 'from', type: 'Person' },
      { name: 'to', type: 'Person' },
      { name: 'contents', type: 'string' },
    ],
  },
  primaryType: 'Mail',
  message: {
    from: { name: 'Alice', wallet: '0x...' },
    to: { name: 'Bob', wallet: '0x...' },
    contents: 'Hello!',
  },
});

signTransaction

Signs a transaction without broadcasting it.
const signedTx = await client.signTransaction({
  to: '0x70997970c51812dc3a010c7d01b50e0d17dc79c8',
  value: 1000000000000000000n,
});

// Later: send the signed transaction
const hash = await client.sendRawTransaction({
  serializedTransaction: signedTx,
});

getAddresses / requestAddresses

// Get addresses without prompting (eth_accounts)
const addresses = await client.getAddresses();

// Request addresses with wallet prompt (eth_requestAccounts)
const addresses = await client.requestAddresses();

Extend Pattern

Add custom actions to the client:
const client = createWalletClient({
  account,
  chain: mainnet,
  transport: http(),
}).extend((base) => ({
  // Custom action using base client
  sendEth: async (to: string, amount: bigint) => {
    return base.sendTransaction({ to, value: amount });
  },

  // Access chain info
  getChainName: () => base.chain?.name ?? 'Unknown',
}));

// Use custom actions
await client.sendEth('0x...', 1n);
console.log(client.getChainName()); // 'Ethereum'

Transports

HTTP Transport

import { http } from './examples/viem-walletclient/index.js';

const client = createWalletClient({
  transport: http('https://mainnet.infura.io/v3/YOUR_KEY', {
    timeout: 10_000,    // Request timeout (ms)
    retryCount: 3,      // Number of retries
    retryDelay: 150,    // Delay between retries (ms)
  }),
});

Custom Transport (EIP-1193 Provider)

import { custom } from './examples/viem-walletclient/index.js';

// Browser wallet
const client = createWalletClient({
  transport: custom(window.ethereum),
});

// Or any EIP-1193 compatible provider
const client = createWalletClient({
  transport: custom(myProvider),
});

Fallback Transport

import { fallback, http } from './examples/viem-walletclient/index.js';

const client = createWalletClient({
  transport: fallback([
    http('https://mainnet.infura.io/v3/...'),
    http('https://eth-mainnet.alchemyapi.io/v2/...'),
    http('https://cloudflare-eth.com'),
  ]),
});

Chain Configuration

const mainnet = {
  id: 1,
  name: 'Ethereum',
  nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
  rpcUrls: {
    default: { http: ['https://eth.llamarpc.com'] },
  },
  blockExplorers: {
    default: { name: 'Etherscan', url: 'https://etherscan.io' },
  },
  blockTime: 12_000, // Block time in ms (affects polling interval)
};

const client = createWalletClient({
  chain: mainnet,
  transport: http(),
});

Error Handling

import {
  AccountNotFoundError,
  ChainMismatchError,
  TransactionExecutionError,
} from './examples/viem-walletclient/index.js';

try {
  await client.sendTransaction({ to: '0x...', value: 1n });
} catch (error) {
  if (error instanceof AccountNotFoundError) {
    console.error('No account configured');
  } else if (error instanceof ChainMismatchError) {
    console.error(`Expected chain ${error.expectedChainId}, got ${error.currentChainId}`);
  } else if (error instanceof TransactionExecutionError) {
    console.error('Transaction failed:', error.message);
  }
}

Integration with PublicClient

WalletClient is typically used alongside PublicClient for read operations:
import { createPublicClient, http } from 'viem';
import { createWalletClient } from './examples/viem-walletclient/index.js';

const publicClient = createPublicClient({
  chain: mainnet,
  transport: http(),
});

const walletClient = createWalletClient({
  account: privateKeyToAccount('0x...'),
  chain: mainnet,
  transport: http(),
});

// Read: use public client
const balance = await publicClient.getBalance({ address: account.address });

// Write: use wallet client
const hash = await walletClient.sendTransaction({
  to: '0x...',
  value: balance / 2n,
});

// Wait for confirmation: use public client
const receipt = await publicClient.waitForTransactionReceipt({ hash });

File Structure

examples/viem-walletclient/
├── REQUIREMENTS.md           # Extracted viem patterns
├── WalletClientTypes.ts      # Type definitions
├── errors.ts                 # Custom error classes
├── createWalletClient.js     # Main factory function
├── getAddresses.js           # eth_accounts action
├── requestAddresses.js       # eth_requestAccounts action
├── sendTransaction.js        # Send transaction action
├── signMessage.js            # Sign message action
├── signTypedData.js          # Sign typed data action
├── signTransaction.js        # Sign transaction action
├── getChainId.js             # Get chain ID action
├── prepareTransactionRequest.js  # Prepare transaction
├── sendRawTransaction.js     # Send raw transaction
├── transports.js             # Transport implementations
├── index.ts                  # Module exports
└── WalletClient.test.ts      # Tests

Implementation Notes

  1. Account Hoisting: Account can be set on client creation, automatically used by all actions
  2. parseAccount: Converts string address to JSON-RPC account object
  3. Chain Validation: Validates connected chain matches expected chain before sending
  4. Fee Estimation: Automatically estimates EIP-1559 fees if not provided
  5. Transaction Preparation: Fills nonce, gas, fees automatically
  6. Extend Pattern: Allows adding custom actions while preserving base functionality