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

Viem-style PublicClient

This Skill demonstrates how to build a viem-compatible PublicClient using Voltaire primitives. The implementation follows viem’s architecture patterns while leveraging Voltaire’s type-safe primitives.

Overview

The PublicClient provides access to public Ethereum JSON-RPC methods like fetching blocks, transactions, balances, and making calls. Key features:
  • Transport abstraction - HTTP, WebSocket, or custom transports
  • Extend pattern - Composable client extension for custom actions
  • Type safety - Full TypeScript types for all methods
  • Caching - Built-in request caching with configurable TTL

Quick Start

import { createPublicClient, http, mainnet } from './examples/viem-publicclient/index.js';

const client = createPublicClient({
  chain: mainnet,
  transport: http('https://eth.example.com')
});

// Get block number
const blockNumber = await client.getBlockNumber();
// => 19123456n

// Get balance
const balance = await client.getBalance({
  address: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb0'
});
// => 1000000000000000000n (1 ETH)

// Make a call
const result = await client.call({
  to: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
  data: '0x70a08231...'
});

Architecture

Client Structure

PublicClient
├── Base Properties
│   ├── chain        - Chain configuration
│   ├── transport    - Transport config + request function
│   ├── cacheTime    - Cache duration (ms)
│   ├── pollingInterval - Block polling interval
│   └── uid          - Unique client identifier
├── Public Actions
│   ├── getBlockNumber()
│   ├── getBalance()
│   ├── getBlock()
│   ├── call()
│   ├── estimateGas()
│   ├── getTransaction()
│   ├── getTransactionReceipt()
│   ├── getLogs()
│   └── ...
└── extend()        - Composable extension

Transport Layer

The transport layer handles JSON-RPC communication:
import { http } from './examples/viem-publicclient/index.js';

// HTTP transport with custom config
const transport = http('https://eth.example.com', {
  timeout: 10_000,
  retryCount: 3,
  retryDelay: 150,
  fetchOptions: {
    headers: { 'Authorization': 'Bearer ...' }
  }
});

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

Extend Pattern

Extend the client with custom actions:
const client = createPublicClient({
  chain: mainnet,
  transport: http()
});

// Add custom actions
const extendedClient = client.extend((base) => ({
  async getDoubleBlockNumber() {
    const blockNumber = await base.getBlockNumber();
    return blockNumber * 2n;
  },

  async getFormattedBalance(address: string) {
    const balance = await base.getBalance({ address });
    return `${balance / 10n ** 18n} ETH`;
  }
}));

// Use custom actions
const doubled = await extendedClient.getDoubleBlockNumber();
const formatted = await extendedClient.getFormattedBalance('0x...');
Chain extensions:
const client = createPublicClient({
  chain: mainnet,
  transport: http()
})
  .extend((base) => ({
    action1: () => base.getBlockNumber()
  }))
  .extend((base) => ({
    action2: () => base.action1()  // Can use previous extensions
  }));

API Reference

createPublicClient

Creates a PublicClient with public actions.
function createPublicClient(config: PublicClientConfig): PublicClient
Parameters:
NameTypeDescription
chainChainChain configuration (optional)
transportTransportFactoryTransport factory (required)
cacheTimenumberCache duration in ms (default: pollingInterval)
pollingIntervalnumberBlock polling interval (default: blockTime/2)
keystringClient key (default: ‘public’)
namestringClient name (default: ‘Public Client’)

Public Actions

getBlockNumber

Returns the current block number.
const blockNumber = await client.getBlockNumber();
// => 19123456n

getBalance

Returns the balance of an address.
const balance = await client.getBalance({
  address: '0x...',
  blockTag: 'latest'  // or blockNumber: 19000000n
});
// => 1000000000000000000n

getBlock

Returns block information.
const block = await client.getBlock({
  blockNumber: 19000000n,
  // or blockHash: '0x...',
  includeTransactions: true
});

call

Executes a call without creating a transaction.
const result = await client.call({
  to: '0x...',
  data: '0x...',
  value: 0n,
  blockTag: 'latest'
});
// => { data: '0x...' }

estimateGas

Estimates gas for a transaction.
const gas = await client.estimateGas({
  to: '0x...',
  value: 1000000000000000000n
});
// => 21000n

getTransaction

Returns transaction by hash.
const tx = await client.getTransaction({
  hash: '0x...'
});

getTransactionReceipt

Returns transaction receipt.
const receipt = await client.getTransactionReceipt({
  hash: '0x...'
});
console.log(receipt.status); // 'success' | 'reverted'

getLogs

Returns logs matching filter.
const logs = await client.getLogs({
  address: '0x...',
  fromBlock: 19000000n,
  toBlock: 19000100n,
  topics: ['0xddf252ad...']
});

getCode

Returns contract bytecode.
const code = await client.getCode({
  address: '0x...'
});

getStorageAt

Returns storage at slot.
const value = await client.getStorageAt({
  address: '0x...',
  slot: '0x0'
});

getTransactionCount

Returns transaction count (nonce).
const nonce = await client.getTransactionCount({
  address: '0x...'
});

getChainId

Returns chain ID.
const chainId = await client.getChainId();
// => 1

getGasPrice

Returns current gas price.
const gasPrice = await client.getGasPrice();
// => 20000000000n (20 gwei)

Chain Definitions

Pre-configured chain definitions:
import { mainnet, sepolia, optimism, arbitrum, polygon, base } from './chains.js';

const client = createPublicClient({
  chain: mainnet,  // Uses default RPC
  transport: http()
});
Create custom chains:
const myChain = {
  id: 12345,
  name: 'My Chain',
  nativeCurrency: {
    name: 'Ether',
    symbol: 'ETH',
    decimals: 18
  },
  rpcUrls: {
    default: {
      http: ['https://rpc.mychain.com']
    }
  },
  blockTime: 2_000,
  blockExplorers: {
    default: {
      name: 'Explorer',
      url: 'https://explorer.mychain.com'
    }
  }
};

Error Handling

The client throws typed errors:
import {
  RpcRequestError,
  TransactionNotFoundError,
  BlockNotFoundError,
  UrlRequiredError
} from './errors.js';

try {
  const tx = await client.getTransaction({ hash: '0x...' });
} catch (error) {
  if (error instanceof TransactionNotFoundError) {
    console.log('Transaction not found:', error.details.hash);
  } else if (error instanceof RpcRequestError) {
    console.log('RPC error:', error.details.error.message);
  }
}

Testing

Run the test suite:
cd examples/viem-publicclient
npx vitest run

Implementation Notes

Caching

Block number is cached to reduce RPC calls:
// First call hits RPC
await client.getBlockNumber();

// Second call returns cached value (within cacheTime)
await client.getBlockNumber();

Retry Logic

HTTP transport automatically retries on network errors (not RPC errors):
const transport = http('https://eth.example.com', {
  retryCount: 3,     // Max retries
  retryDelay: 150    // Delay between retries (ms)
});

Polling Interval

Polling interval is derived from chain block time:
  • Mainnet (12s blocks): 4000ms polling
  • Arbitrum (250ms blocks): 500ms polling

File Structure

examples/viem-publicclient/
├── REQUIREMENTS.md          # Requirements from viem analysis
├── index.ts                 # Main exports
├── PublicClientType.ts      # Type definitions
├── createPublicClient.js    # Client factory
├── createClient.js          # Base client factory
├── createTransport.js       # Transport factory
├── http.js                  # HTTP transport
├── publicActions.js         # Actions decorator
├── chains.js                # Chain definitions
├── errors.ts                # Error types
├── actions/                 # Action implementations
│   ├── index.ts
│   ├── getBlockNumber.js
│   ├── getBalance.js
│   ├── getChainId.js
│   ├── call.js
│   ├── getBlock.js
│   ├── estimateGas.js
│   ├── getTransaction.js
│   ├── getTransactionReceipt.js
│   ├── getLogs.js
│   ├── getCode.js
│   ├── getStorageAt.js
│   ├── getTransactionCount.js
│   └── getGasPrice.js
├── utils/
│   ├── uid.js               # Unique ID generator
│   ├── cache.js             # Caching utilities
│   └── encoding.js          # Hex encoding utilities
├── PublicClient.test.ts     # Tests
└── vitest.config.ts         # Test config

Next Steps

Future enhancements:
  1. readContract - ABI-aware contract reads
  2. multicall - Batched contract calls
  3. watchBlockNumber - Block number subscription
  4. watchBlocks - Block subscription
  5. WebSocket transport - Real-time subscriptions
  6. ENS support - Name resolution