Skip to main content

viem Adapter

Use SpendSafe with viem, the modern TypeScript alternative to ethers.js.

Overview

Best for: Modern TypeScript projects, small bundle sizes, functional programming style Chains: All EVM-compatible blockchains Bundle size: ~35kB (88% smaller than ethers.js) Performance: Fast tree-shaking, optimised for modern bundlers

Installation

npm install @spendsafe/sdk viem

Peer dependencies: viem v2.x

Basic Setup

1. Import the adapter

import { PolicyWallet, createViemAdapter } from '@spendsafe/sdk';
import { createWalletClient, http } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { mainnet } from 'viem/chains';

2. Create the adapter

const privateKey = process.env.PRIVATE_KEY as `0x${string}`;
const rpcUrl = process.env.RPC_URL;

const adapter = await createViemAdapter(privateKey, rpcUrl, mainnet);

3. Wrap with PolicyWallet

const wallet = new PolicyWallet(adapter, {
apiKey: process.env.SPENDSAFE_API_KEY,
});

4. Send transactions

// Native ETH transfer
await wallet.send({
to: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb1',
amount: '1000000000000000000', // 1 ETH in wei
});

// ERC-20 token transfer (e.g., USDC)
await wallet.send({
to: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb1',
amount: '1000000', // 1 USDC (6 decimals)
asset: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // USDC contract
});

Configuration

Environment Variables

# Required
PRIVATE_KEY=0x... # Your wallet private key (with 0x prefix)
RPC_URL=https://... # Your RPC endpoint
SPENDSAFE_API_KEY=sk_... # Your SpendSafe API key

Chain Configuration

viem uses chain objects for network configuration:

import { mainnet, sepolia, base, baseSepolia, polygon } from 'viem/chains';

// Ethereum Mainnet
const adapter = await createViemAdapter(privateKey, rpcUrl, mainnet);

// Ethereum Sepolia (testnet)
const adapter = await createViemAdapter(privateKey, rpcUrl, sepolia);

// Base Mainnet
const adapter = await createViemAdapter(privateKey, rpcUrl, base);

// Base Sepolia
const adapter = await createViemAdapter(privateKey, rpcUrl, baseSepolia);

// Polygon Mainnet
const adapter = await createViemAdapter(privateKey, rpcUrl, polygon);

Custom Chains

Define custom EVM chains:

import { defineChain } from 'viem';

const myCustomChain = defineChain({
id: 12345,
name: 'My Custom Chain',
nativeCurrency: {
decimals: 18,
name: 'Ether',
symbol: 'ETH',
},
rpcUrls: {
default: {
http: ['https://rpc.mycustomchain.com'],
},
},
blockExplorers: {
default: { name: 'Explorer', url: 'https://explorer.mycustomchain.com' },
},
});

const adapter = await createViemAdapter(privateKey, rpcUrl, myCustomChain);

Complete Example

import { PolicyWallet, createViemAdapter } from '@spendsafe/sdk';
import { mainnet } from 'viem/chains';
import * as dotenv from 'dotenv';

dotenv.config();

async function main() {
// 1. Create viem adapter
const adapter = await createViemAdapter(
process.env.PRIVATE_KEY as `0x${string}`,
process.env.RPC_URL!,
mainnet
);

// 2. Create PolicyWallet
const wallet = new PolicyWallet(adapter, {
apiKey: process.env.SPENDSAFE_API_KEY!,
});

try {
// 3. Send ETH with policy enforcement
const result = await wallet.send({
to: '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb1',
amount: '1000000000000000000', // 1 ETH
});

console.log('✅ Transaction sent:', result.hash);
console.log('⛽ Gas fee:', result.fee);
} catch (error) {
if (error.code === 'POLICY_VIOLATION') {
console.log('❌ Policy violation:', error.message);
console.log(' Reason:', error.details.reason);
console.log(' Remaining today:', error.details.remainingDaily);
} else {
throw error;
}
}
}

main();

Advanced Usage

ERC-20 Token Transfers

// USDC transfer (6 decimals)
await wallet.send({
to: recipientAddress,
amount: '1000000', // 1 USDC
asset: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // USDC on Ethereum
});

// DAI transfer (18 decimals)
await wallet.send({
to: recipientAddress,
amount: '1000000000000000000', // 1 DAI
asset: '0x6B175474E89094C44Da98b954EedeAC495271d0F', // DAI on Ethereum
});

Multiple Chains

import { mainnet, base } from 'viem/chains';

// Ethereum mainnet wallet
const ethAdapter = await createViemAdapter(privateKey, ethRpcUrl, mainnet);
const ethWallet = new PolicyWallet(ethAdapter, { apiKey: 'key-1' });

// Base mainnet wallet
const baseAdapter = await createViemAdapter(privateKey, baseRpcUrl, base);
const baseWallet = new PolicyWallet(baseAdapter, { apiKey: 'key-2' });

// Send on different chains
await ethWallet.send({ to: '0x...', amount: '1000000' });
await baseWallet.send({ to: '0x...', amount: '1000000' });

Error Handling

import { PolicyError } from '@spendsafe/sdk';

try {
await wallet.send({ to: merchant, amount: '1000000' });
} catch (error) {
if (error instanceof PolicyError) {
// Policy violation
console.log('Policy blocked transaction:');
console.log('- Reason:', error.details.reason);
console.log('- Daily limit:', error.details.limits.dailyLimit);
console.log('- Remaining:', error.details.counters.remainingDaily);
} else if (error.name === 'InsufficientFundsError') {
// Not enough balance (viem error type)
console.log('Insufficient funds');
} else {
// Other error (network, gas, etc.)
console.error('Transaction failed:', error.message);
}
}

Type Safety with viem

viem provides excellent TypeScript support:

import type { Address, Hash } from 'viem';

// Type-safe addresses
const recipient: Address = '0x742d35Cc6634C0532925a3b844Bc9e7595f0bEb1';

// Send with type safety
const result = await wallet.send({
to: recipient,
amount: '1000000',
});

// Result is typed
const txHash: Hash = result.hash;

Balance Checks

// Get wallet balance
const balance = await adapter.getBalance();
console.log('Wallet balance:', balance, 'wei');

// viem utilities for formatting
import { formatEther } from 'viem';
const balanceEth = formatEther(BigInt(balance));
console.log('Balance:', balanceEth, 'ETH');

Gas Estimation

// Adapter handles gas estimation automatically
const result = await wallet.send({
to: '0x...',
amount: '1000000',
// Gas estimated automatically using viem's estimateGas
});

console.log('Gas fee paid:', result.fee); // in ETH

Network Configuration

Ethereum Networks

import { mainnet, sepolia } from 'viem/chains';

// Mainnet
const adapter = await createViemAdapter(privateKey, rpcUrl, mainnet);

// Sepolia testnet
const adapter = await createViemAdapter(privateKey, rpcUrl, sepolia);

Base Networks

import { base, baseSepolia } from 'viem/chains';

// Base Mainnet
const adapter = await createViemAdapter(privateKey, rpcUrl, base);

// Base Sepolia
const adapter = await createViemAdapter(privateKey, rpcUrl, baseSepolia);

Other EVM Networks

import { polygon, optimism, arbitrum, avalanche } from 'viem/chains';

// Polygon
const adapter = await createViemAdapter(privateKey, rpcUrl, polygon);

// Optimism
const adapter = await createViemAdapter(privateKey, rpcUrl, optimism);

// Arbitrum
const adapter = await createViemAdapter(privateKey, rpcUrl, arbitrum);

// Avalanche
const adapter = await createViemAdapter(privateKey, rpcUrl, avalanche);

Troubleshooting

"Invalid private key format" error

Problem: Private key doesn't match TypeScript type

Solution: Ensure private key is typed as hex string:

// ✅ Correct
const privateKey = process.env.PRIVATE_KEY as `0x${string}`;

// ❌ Wrong
const privateKey = process.env.PRIVATE_KEY; // Type error

"Chain not supported" error

Problem: Chain object not imported or defined

Solution: Import chain from viem/chains or define custom:

// ✅ Correct
import { mainnet } from 'viem/chains';
const adapter = await createViemAdapter(pk, rpc, mainnet);

// ❌ Wrong
const adapter = await createViemAdapter(pk, rpc, 'mainnet');

"Insufficient funds" error

Problem: Wallet doesn't have enough ETH for transaction + gas

Solution: Fund your wallet with ETH for gas fees:

// Check balance first
const balance = await adapter.getBalance();
console.log('Balance (wei):', balance);

// Ensure balance > amount + gas fees
import { formatEther } from 'viem';
console.log('Balance (ETH):', formatEther(BigInt(balance)));

"RPC request failed" error

Problem: RPC endpoint is down or rate limited

Solution: Use reliable RPC provider (Alchemy, Infura):

// ✅ Good - Reliable provider
const rpcUrl = 'https://eth-mainnet.g.alchemy.com/v2/YOUR-KEY';

// ❌ Risky - Public RPC (rate limits)
const rpcUrl = 'https://eth.public-rpc.com';

Type errors with amounts

Problem: Amount type mismatch

Solution: Use string type for amounts:

// ✅ Correct
await wallet.send({
to: '0x...',
amount: '1000000', // String
});

// ❌ Wrong
await wallet.send({
to: '0x...',
amount: 1000000, // Number - type error
});

Best Practises

1. Use viem's type system

import type { Address, Hash, Hex } from 'viem';

// Type-safe development
const recipient: Address = '0x...';
const privateKey: Hex = '0x...';

2. Leverage viem utilities

import { formatEther, parseEther, formatUnits, parseUnits } from 'viem';

// Format amounts for display
const ethAmount = formatEther(1000000000000000000n); // "1"

// Parse user input to wei
const weiAmount = parseEther('1.5'); // 1500000000000000000n

// Handle custom decimals (e.g., USDC with 6 decimals)
const usdcAmount = parseUnits('10.5', 6); // 10500000n

3. Use testnet for development

import { sepolia } from 'viem/chains';

// Development: Use Sepolia testnet
const adapter = await createViemAdapter(privateKey, rpcUrl, sepolia);

// Production: Use mainnet
import { mainnet } from 'viem/chains';
const adapter = await createViemAdapter(privateKey, rpcUrl, mainnet);

4. Handle errors gracefully

import { PolicyError } from '@spendsafe/sdk';
import { BaseError } from 'viem';

try {
await wallet.send({ to, amount });
} catch (error) {
if (error instanceof PolicyError) {
// Policy violation
await notifyAdmin(`Policy blocked: ${error.details.reason}`);
} else if (error instanceof BaseError) {
// viem error (network, gas, etc.)
console.error('viem error:', error.shortMessage);
}
}

5. Use environment variables

// ✅ Good
const privateKey = process.env.PRIVATE_KEY as `0x${string}`;

// ❌ Never hardcode
const privateKey = '0xabc123...' as `0x${string}`; // DON'T DO THIS

Why Choose viem?

Bundle Size Comparison

  • ethers.js v6: ~300kB
  • viem: ~35kB (88% smaller)
  • Impact: Faster page loads, better mobile performance

TypeScript-First Design

// viem provides full type safety
import type { Address, Hash, TransactionReceipt } from 'viem';

// No type casting needed
const address: Address = await adapter.getAddress();
const balance: bigint = BigInt(await adapter.getBalance());

Modern API Design

// Functional, composable, tree-shakeable
import { formatEther, parseEther } from 'viem';

// vs ethers.js object-oriented style
// import { ethers } from 'ethers';
// ethers.utils.formatEther(...)

Performance

  • Better tree-shaking (smaller production bundles)
  • Optimised for modern bundlers (Vite, Rollup, esbuild)
  • Faster transaction building and signing

Comparison with ethers.js

Featureviemethers.js
Bundle size~35kB~300kB
TypeScript supportNative, excellentGood
API styleFunctionalObject-oriented
Tree-shakingExcellentGood
Learning curveModerateEasy
DocumentationModernComprehensive
CommunityGrowingLarge, established

Use viem if:

  • Bundle size matters (mobile, low-bandwidth users)
  • You want modern TypeScript features
  • You prefer functional programming style

Use ethers.js if:

  • You need maximum compatibility
  • You're already using ethers.js
  • Bundle size isn't critical

Next Steps

API Reference

createViemAdapter()

function createViemAdapter(
privateKey: `0x${string}`,
rpcUrl: string,
chain: Chain
): Promise<WalletAdapter>

Parameters:

  • privateKey - Wallet private key (with 0x prefix, typed as hex)
  • rpcUrl - Ethereum RPC endpoint URL
  • chain - viem Chain object (from viem/chains or custom)

Returns: Promise resolving to WalletAdapter

Example:

import { mainnet } from 'viem/chains';

const adapter = await createViemAdapter(
'0xabc123...' as `0x${string}`,
'https://eth-mainnet.g.alchemy.com/v2/YOUR-KEY',
mainnet
);