Skip to main content
This guide helps ethers.js users transition to voltaire-effect’s Effect-based patterns.

Provider Setup

ethers.js

import { JsonRpcProvider } from 'ethers'

const provider = new JsonRpcProvider('https://eth.llamarpc.com')
const blockNumber = await provider.getBlockNumber()

voltaire-effect

import { Effect, Layer } from 'effect'
import { getBlockNumber, Provider, HttpTransport } from 'voltaire-effect'

// Compose layers first
const ProviderLayer = Provider.pipe(
  Layer.provide(HttpTransport('https://eth.llamarpc.com'))
)

const program = Effect.gen(function* () {
  return yield* getBlockNumber()
})

// Provide composed layer once
const blockNumber = await Effect.runPromise(program.pipe(Effect.provide(ProviderLayer)))

Wallet/Signer

ethers.js

import { Wallet, JsonRpcProvider } from 'ethers'

const provider = new JsonRpcProvider('https://eth.llamarpc.com')
const wallet = new Wallet(privateKey, provider)

const tx = await wallet.sendTransaction({
  to: '0x...',
  value: 1000000000000000000n
})

voltaire-effect

import { Effect, Layer } from 'effect'
import {
  SignerService,
  Signer,
  LocalAccount,
  Provider,
  HttpTransport
} from 'voltaire-effect'
import * as Hex from 'voltaire-effect/primitives/Hex'
import * as S from 'effect/Schema'

const privateKey = S.decodeSync(Hex.String)('0xac0974bec...')

// Compose layers first
const TransportLayer = HttpTransport('https://eth.llamarpc.com')
const ProviderLayer = Provider.pipe(Layer.provide(TransportLayer))
const SignerLayer = Signer.Live.pipe(
  Layer.provide(LocalAccount(privateKey)),
  Layer.provide(ProviderLayer)
)

const program = Effect.gen(function* () {
  const signer = yield* SignerService
  return yield* signer.sendTransaction({
    to: '0x...',
    value: 1000000000000000000n
  })
})

// Provide composed layer once
const tx = await Effect.runPromise(program.pipe(Effect.provide(SignerLayer)))

HD Wallet

ethers.js

import { HDNodeWallet, Mnemonic } from 'ethers'

const mnemonic = 'test test test test test test test test test test test junk'
const wallet = HDNodeWallet.fromMnemonic(Mnemonic.fromPhrase(mnemonic))
const secondAddress = wallet.derivePath("m/44'/60'/0'/0/1")

voltaire-effect

import { Effect, Layer } from 'effect'
import {
  AccountService,
  SignerService,
  Signer,
  Provider,
  HttpTransport
} from 'voltaire-effect'
import { MnemonicAccount } from 'voltaire-effect/native'
import { Secp256k1Live, KeccakLive } from 'voltaire-effect/crypto'

const mnemonic = 'test test test test test test test test test test test junk'

// Compose crypto layers
const CryptoLayer = Layer.mergeAll(Secp256k1Live, KeccakLive)

// Default address (index 0)
const AccountLayer = MnemonicAccount(mnemonic).pipe(Layer.provide(CryptoLayer))

const program = Effect.gen(function* () {
  const account = yield* AccountService
  console.log('Address:', account.address)
}).pipe(Effect.provide(AccountLayer))

// Second address (index 1)
const SecondAccountLayer = MnemonicAccount(mnemonic, { index: 1 }).pipe(
  Layer.provide(CryptoLayer)
)

const secondAddr = Effect.gen(function* () {
  const account = yield* AccountService
  console.log('Address:', account.address)
}).pipe(Effect.provide(SecondAccountLayer))

Contract Interaction

ethers.js

import { Contract, JsonRpcProvider } from 'ethers'

const abi = ['function balanceOf(address) view returns (uint256)']
const provider = new JsonRpcProvider('https://eth.llamarpc.com')
const contract = new Contract(tokenAddress, abi, provider)

const balance = await contract.balanceOf(userAddress)

voltaire-effect

import { Effect, Layer } from 'effect'
import { Contract, Provider, HttpTransport } from 'voltaire-effect'

const abi = [
  {
    type: 'function',
    name: 'balanceOf',
    stateMutability: 'view',
    inputs: [{ name: 'account', type: 'address' }],
    outputs: [{ name: 'balance', type: 'uint256' }]
  }
] as const

// Compose layers first
const ProviderLayer = Provider.pipe(
  Layer.provide(HttpTransport('https://eth.llamarpc.com'))
)

const program = Effect.gen(function* () {
  const token = yield* Contract(tokenAddress, abi)
  return yield* token.read.balanceOf(userAddress)
})

// Provide composed layer once
const balance = await Effect.runPromise(program.pipe(Effect.provide(ProviderLayer)))

Error Handling

ethers.js

try {
  const tx = await wallet.sendTransaction({ to, value })
  const receipt = await tx.wait()
} catch (error) {
  if (error.code === 'INSUFFICIENT_FUNDS') {
    console.log('Not enough ETH')
  }
}

voltaire-effect

import { Effect } from 'effect'
import { SignerService, SignerError, TransportError } from 'voltaire-effect'

const program = Effect.gen(function* () {
  const signer = yield* SignerService
  const txHash = yield* signer.sendTransaction({ to, value })
  return txHash
}).pipe(
  Effect.catchTag('SignerError', (e) => {
    if (e.message.includes('insufficient funds')) {
      return Effect.succeed(null)
    }
    return Effect.fail(e)
  }),
  Effect.catchTag('TransportError', (e) => {
    console.log('RPC error:', e.message)
    return Effect.fail(e)
  })
)

Event Listening

ethers.js

const contract = new Contract(tokenAddress, abi, provider)

contract.on('Transfer', (from, to, value) => {
  console.log(`Transfer: ${from}${to}: ${value}`)
})

// Stop after some time
setTimeout(() => contract.removeAllListeners(), 60000)

voltaire-effect

import { Effect, Stream, Fiber, Duration } from 'effect'
import { makeEventStream, HttpTransport } from 'voltaire-effect'

const transferEvent = {
  type: 'event',
  name: 'Transfer',
  inputs: [
    { name: 'from', type: 'address', indexed: true },
    { name: 'to', type: 'address', indexed: true },
    { name: 'value', type: 'uint256', indexed: false }
  ]
} as const

const program = Effect.gen(function* () {
  const eventStream = yield* makeEventStream()

  const fiber = yield* Effect.fork(
    Stream.runForEach(
      eventStream.watch({ address: tokenAddress, event: transferEvent }),
      ({ log }) => Effect.log(`Transfer: ${log.args.from}${log.args.to}: ${log.args.value}`)
    )
  )

  // Stop after 60 seconds
  yield* Effect.sleep(Duration.seconds(60))
  yield* Fiber.interrupt(fiber)
}).pipe(
    Effect.provide(HttpTransport('https://eth.llamarpc.com'))
)

Utilities

Address Validation

// ethers.js
import { isAddress, getAddress } from 'ethers'
isAddress('0x...')
getAddress('0x...')  // Checksummed

// voltaire-effect
import * as Address from 'voltaire-effect/primitives/Address'
import * as S from 'effect/Schema'

Address.isValid('0x...')
S.decodeSync(Address.Hex)('0x...')  // Validated
// For checksum encoding:
S.encode(Address.Checksummed)(addr).pipe(Effect.provide(KeccakLive))

Hex Encoding

// ethers.js
import { hexlify, toUtf8Bytes } from 'ethers'
hexlify(toUtf8Bytes('hello'))

// voltaire-effect
import * as Hex from 'voltaire-effect/primitives/Hex'
import * as S from 'effect/Schema'

const bytes = new TextEncoder().encode('hello')
S.encodeSync(Hex.Bytes)(bytes)

Unit Conversion

// ethers.js
import { parseEther, formatEther } from 'ethers'
parseEther('1.0')  // 1000000000000000000n
formatEther(1000000000000000000n)  // "1.0"

// voltaire-effect (uses voltaire core)
import { Denomination } from '@tevm/voltaire'
Denomination.toWei('1.0', 'ether')    // 1000000000000000000n
Denomination.fromWei(1000000000000000000n, 'ether')  // "1.0"

Key Differences

Conceptethers.jsvoltaire-effect
ProviderClass instanceEffect Service + Layer
Errorstry/catchTyped error channel
AsyncPromiseEffect
StateMutable objectsImmutable + Layer composition
EventsCallbacksEffect Streams
TypesRuntime validationSchema validation

Gradual Migration

You can use both libraries during migration:
import { JsonRpcProvider } from 'ethers'
import { Effect } from 'effect'
import { getBlock, Provider, HttpTransport } from 'voltaire-effect'

// Keep ethers for some operations
const ethersProvider = new JsonRpcProvider('https://eth.llamarpc.com')

// Use voltaire-effect for new code
const program = Effect.gen(function* () {
  const block = yield* getBlock({ blockTag: 'latest' })

  // Can still use ethers for specific operations if needed
  const legacyResult = yield* Effect.promise(() =>
    ethersProvider.getBalance('0x...')
  )

  return { block, legacyResult }
})

Incremental Migration with tryPromise / trySync

If you are not ready to replace all ethers calls, wrap them into Effect first. Use Effect.tryPromise for async ethers APIs, and use Effect.try (sync) as a trySync helper for local parsing/validation.
import { Effect } from 'effect'
import { JsonRpcProvider } from 'ethers'
import * as Address from 'voltaire-effect/primitives/Address'
import * as S from 'effect/Schema'

const trySync = Effect.try
const provider = new JsonRpcProvider(rpcUrl)

export const getBalance = (address: string) =>
  Effect.gen(function* () {
    const addr = yield* trySync({
      try: () => S.decodeSync(Address.Hex)(address),
      catch: (error) => new Error(`Invalid address: ${String(error)}`)
    })

    const balance = yield* Effect.tryPromise({
      try: () => provider.getBalance(address),
      catch: (error) => new Error(`RPC failed: ${String(error)}`)
    })

    return { addr, balance }
  })
Once the call is Effect-native, replace the ethers call with the getBalance free function and provide a Provider layer (it supplies ProviderService) instead of tryPromise.

See Also