Skip to main content
The Provider service wraps Ethereum JSON-RPC methods with typed error unions and Effect composition. voltaire-effect exposes all provider operations as free functions (idiomatic Effect.ts pattern). Import and use them directly without yielding a service:

Quick Start

import { Effect, Schedule, Layer } from 'effect'
import {
  getBlockNumber, getBalance, getBlock,
  Provider, HttpTransport, withTimeout, withRetrySchedule
} from 'voltaire-effect'

// Compose layers first
const ProviderLayer = Provider.pipe(
  Layer.provide(HttpTransport({
    url: 'https://eth.llamarpc.com',
    timeout: '30 seconds',
    retrySchedule: Schedule.exponential('1 second').pipe(
      Schedule.jittered,
      Schedule.compose(Schedule.recurs(3))
    )
  }))
)

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

  // Per-request timeout override
  const balance = yield* getBalance('0x1234...').pipe(
    withTimeout('5 seconds')
  )

  // Custom retry schedule for critical calls
  const block = yield* getBlock({ blockTag: 'latest' }).pipe(
    withRetrySchedule(Schedule.exponential('500 millis').pipe(
      Schedule.jittered,
      Schedule.compose(Schedule.recurs(5))
    ))
  )

  return { blockNum, balance, block }
}).pipe(Effect.provide(ProviderLayer))

Transport Layer

Provider requires a transport layer for network communication. Three transports are available:

HttpTransport

import { Schedule } from 'effect'

// Simple
HttpTransport('https://eth.llamarpc.com')

// With Effect Duration and Schedule
HttpTransport({
  url: 'https://eth.llamarpc.com',
  headers: { 'X-Api-Key': 'secret' },
  timeout: '60 seconds',  // Effect Duration (default: "30 seconds")
  retrySchedule: Schedule.exponential('500 millis').pipe(
    Schedule.jittered,
    Schedule.compose(Schedule.recurs(5))
  )
})
timeout accepts any Effect DurationInput (strings, numbers in ms, or Duration). Use strings for readability.

WebSocketTransport

import { WebSocketTransport } from 'voltaire-effect'

program.pipe(
  Effect.provide(WebSocketTransport('wss://eth.llamarpc.com'))
)

BrowserTransport

Uses window.ethereum (EIP-1193).
import { BrowserTransport } from 'voltaire-effect'

const program = Effect.gen(function* () {
  const transport = yield* TransportService
  const accounts = yield* transport.request<string[]>('eth_accounts', [])
  return accounts[0]
}).pipe(
  Effect.provide(BrowserTransport)
)

Request Batching

Combine multiple JSON-RPC requests into a single HTTP call for better performance.
import { HttpTransport, TransportService } from 'voltaire-effect'
import { Effect } from 'effect'

const transport = HttpTransport({
  url: 'https://eth.llamarpc.com',
  batch: {
    batchSize: 50,    // Max requests per batch (default: 100)
    wait: 10          // Wait time in ms before flushing (default: 0)
  }
})

// Requests made concurrently are automatically batched
const program = Effect.gen(function* () {
  const t = yield* TransportService
  const { blockNumber, chainId, gasPrice } = yield* Effect.all({
    blockNumber: t.request<string>('eth_blockNumber'),
    chainId: t.request<string>('eth_chainId'),
    gasPrice: t.request<string>('eth_gasPrice')
  })
  return { blockNumber, chainId, gasPrice }
}).pipe(Effect.provide(transport))
  • Requests are queued and sent together when batchSize is reached or after wait ms
  • Each request in the batch is matched to its response by ID
  • If one request fails, only that request fails (others still succeed)

TestTransport

import { TestTransport, TransportError } from 'voltaire-effect'

const mockResponses = new Map([
  ['eth_blockNumber', '0x1234'],
  ['eth_chainId', '0x1'],
])

program.pipe(Effect.provide(TestTransport(mockResponses)))

// Mock errors
const errorResponses = new Map([
  ['eth_call', new TransportError({ code: -32000, message: 'execution reverted' })],
])

Per-Request Overrides

Use FiberRef-based helpers for scoped overrides:
import { getBalance, getBlockNumber, withTimeout, withRetrySchedule, withoutCache, withTracing } from 'voltaire-effect'
import { Schedule } from 'effect'

// Override timeout for a single call
const balance = yield* getBalance(addr).pipe(
  withTimeout('5 seconds')
)

// Custom retry schedule
const block = yield* getBlockNumber().pipe(
  withRetrySchedule(Schedule.recurs(1))
)

// Disable caching
const fresh = yield* getBlockNumber().pipe(withoutCache)

// Enable tracing
const traced = yield* getBalance(addr).pipe(withTracing())

## Layer Composition

`Provider` is a Layer that requires `TransportService`:

```typescript
import * as Layer from 'effect/Layer'

// Provider depends on Transport
const Provider: Layer.Layer<ProviderService, never, TransportService>

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

// Use in program
const program = myEffect.pipe(Effect.provide(FullProvider))

Block Queries

import {
  getBlockNumber, getBlock, getBlockReceipts, getUncleCount
} from 'voltaire-effect'

const blockNum = yield* getBlockNumber()
const block = yield* getBlock({ blockTag: 'latest', includeTransactions: true })
const blockByHash = yield* getBlock({ blockHash: '0x...' })
const receipts = yield* getBlockReceipts({ blockTag: 'latest' })
const uncleCount = yield* getUncleCount({ blockTag: 'latest' })

Account State

import { getBalance, getTransactionCount, getCode, getStorageAt } from 'voltaire-effect'

const balance = yield* getBalance('0x1234...', 'latest')
const nonce = yield* getTransactionCount('0x1234...')
const code = yield* getCode('0x1234...')
const slot = yield* getStorageAt('0x1234...', '0x0')

Transactions

import {
  getTransaction, getTransactionByBlockHashAndIndex,
  getTransactionByBlockNumberAndIndex, getTransactionReceipt,
  waitForTransactionReceipt, withTimeout
} from 'voltaire-effect'

const tx = yield* getTransaction('0x...')
const txByIndex = yield* getTransactionByBlockHashAndIndex('0x...', 0)
const txByNumber = yield* getTransactionByBlockNumberAndIndex('latest', 0)
const receipt = yield* getTransactionReceipt('0x...')

// waitForTransactionReceipt with per-request timeout
const confirmed = yield* waitForTransactionReceipt('0x...', {
  confirmations: 3
}).pipe(withTimeout('60 seconds'))

RPC Account Operations (node-dependent)

Some nodes expose account signing/sending methods. These are optional and may be disabled:
import { sendTransaction, sign, signTransaction } from 'voltaire-effect'

const txHash = yield* sendTransaction({
  from: '0x1234...',
  to: '0xabcd...',
  value: 1_000_000_000_000_000_000n
})

const sig = yield* sign('0x1234...', '0x...')
const signed = yield* signTransaction({
  from: '0x1234...',
  to: '0xabcd...',
  value: 1_000_000_000_000_000_000n
})
For local signing, use AccountService or SignerService.

Read Contract

Type-safe contract reads without creating a Contract instance:
import { readContract } from 'voltaire-effect'

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

const balance = yield* readContract({
  address: '0x6B175474E89094C44Da98b954EecdEfaE6E286AB',
  abi: erc20Abi,
  functionName: 'balanceOf',
  args: ['0x1234567890123456789012345678901234567890']
})
// balance is bigint

Multi-Return Functions

const pairAbi = [
  {
    type: 'function',
    name: 'getReserves',
    stateMutability: 'view',
    inputs: [],
    outputs: [
      { name: 'reserve0', type: 'uint112' },
      { name: 'reserve1', type: 'uint112' },
      { name: 'blockTimestampLast', type: 'uint32' }
    ]
  }
] as const

const [reserve0, reserve1, timestamp] = yield* readContract({
  address: pairAddress,
  abi: pairAbi,
  functionName: 'getReserves'
})

Block Tag

const balance = yield* readContract({
  address: tokenAddress,
  abi: erc20Abi,
  functionName: 'balanceOf',
  args: [account],
  blockTag: 'finalized'
})

Call & Estimate

import { call, estimateGas } from 'voltaire-effect'

const result = yield* call({
  to: '0x1234567890123456789012345678901234567890',
  data: '0x...'
})

const gas = yield* estimateGas({
  to: '0x1234567890123456789012345678901234567890',
  data: '0x...',
  value: 1000000000000000000n
})

Simulation APIs

Free functions for simulation RPCs when supported by your node:
import { simulateV1, simulateV2 } from 'voltaire-effect'

const simV1 = yield* simulateV1({
  blockStateCalls: [
    { calls: [{ to: tokenAddress, data: '0x...' }] }
  ]
})

const simV2 = yield* simulateV2({ calls: [{ to: tokenAddress }] })

multicall

Batches multiple contract reads into a single RPC call using the Multicall3 contract at 0xcA11bde05977b3631167028862bE2a173976CA11. Reduces network overhead by 10-100x.
import { multicall } from 'voltaire-effect'

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

const results = yield* multicall({
  contracts: [
    {
      address: '0x6B175474E89094C44Da98b954EecdEfaE6E286AB',
      abi: erc20Abi,
      functionName: 'balanceOf',
      args: ['0x1234567890123456789012345678901234567890']
    },
    {
      address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
      abi: erc20Abi,
      functionName: 'balanceOf',
      args: ['0x1234567890123456789012345678901234567890']
    }
  ]
})
// results: [{ status: 'success', result: 1000n }, { status: 'success', result: 500n }]

allowFailure Mode

By default, allowFailure is true and results include status:
const results = yield* multicall({
  contracts: [...],
  allowFailure: true  // default
})

for (const r of results) {
  if (r.status === 'success') {
    console.log(r.result)
  } else {
    console.log('Call failed:', r.error)
  }
}
Set allowFailure: false to throw on any failure and get unwrapped results:
const results = yield* multicall({
  contracts: [...],
  allowFailure: false
})
// results: [1000n, 500n] - throws if any call fails

Block Tag

const results = yield* multicall({
  contracts: [...],
  blockTag: 'finalized'
})

simulateContract

Simulates a contract write function before sending. Returns both the decoded result and a prepared transaction request.
import { simulateContract } from 'voltaire-effect'

const { result, request } = yield* simulateContract({
  address: tokenAddress,
  abi: erc20Abi,
  functionName: 'transfer',
  args: [recipient, amount],
  account: senderAddress
})

// Check if transfer would succeed
if (result === false) {
  throw new Error('Transfer would fail')
}

// Send the transaction using the prepared request
const hash = yield* signer.sendTransaction({
  to: request.to,
  data: request.data,
  value: request.value
})

Account Simulation

Pass account to simulate from a specific sender address (affects msg.sender in the call):
const { result } = yield* simulateContract({
  address: contractAddress,
  abi,
  functionName: 'restrictedFunction',
  account: ownerAddress  // Simulate as owner
})

Use Case: Validate Before Sending

simulateContract is essential for validating writes before spending gas:
import { Effect } from 'effect'
import { simulateContract, ProviderValidationError, TransportError } from 'voltaire-effect'

const safeTransfer = Effect.gen(function* () {
  const { result, request } = yield* simulateContract({
    address: tokenAddress,
    abi: erc20Abi,
    functionName: 'transfer',
    args: [recipient, amount],
    account: sender
  })
  
  if (!result) {
    return yield* Effect.fail(new Error('Transfer would fail'))
  }
  
  return request
}).pipe(
  Effect.catchTags({
    ProviderValidationError: (e) =>
      Effect.fail(new Error(`Invalid ABI or function: ${e.message}`)),
    TransportError: (e) =>
      Effect.fail(new Error(`Simulation failed: ${e.message}`))
  })
)

Event Logs

import { getLogs } from 'voltaire-effect'

const logs = yield* getLogs({
  address: '0x1234567890123456789012345678901234567890',
  topics: ['0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'],
  fromBlock: '0x100000',
  toBlock: 'latest'
})

Streaming & Subscriptions

import { watchBlocks, subscribe, unsubscribe } from 'voltaire-effect'

const stream = watchBlocks()
const subId = yield* subscribe('newHeads')
const unsub = yield* unsubscribe(subId as `0x${string}`)
Subscriptions require a WebSocket transport and a node with pubsub enabled.

Gas Pricing

import { getGasPrice, getMaxPriorityFeePerGas, getFeeHistory } from 'voltaire-effect'

const gasPrice = yield* getGasPrice()
const priorityFee = yield* getMaxPriorityFeePerGas()
const feeHistory = yield* getFeeHistory(10, 'latest', [25, 50, 75])

Optional Network RPCs

import { getSyncing, getAccounts, getCoinbase, netVersion } from 'voltaire-effect'

const syncing = yield* getSyncing()
const accounts = yield* getAccounts()
const coinbase = yield* getCoinbase()
const version = yield* netVersion()

Mining & Protocol (optional)

import {
  getProtocolVersion, getMining, getHashrate,
  getWork, submitWork, submitHashrate
} from 'voltaire-effect'

const protocol = yield* getProtocolVersion()
const mining = yield* getMining()
const hashrate = yield* getHashrate()
const work = yield* getWork()

const submitted = yield* submitWork(
  '0x0000000000000001',
  '0xpowHash',
  '0xmixDigest'
)
const submitHashrateResult = yield* submitHashrate('0xhashrate', '0xid')

Error Handling

import { ProviderNotFoundError, ProviderResponseError, TransportError } from 'voltaire-effect'

program.pipe(
  Effect.catchTags({
    TransportError: (e) => {
      console.error('RPC failed:', e.message)
      return Effect.succeed(0n)
    },
    ProviderResponseError: (e) => {
      console.error('Unexpected response:', e.message)
      return Effect.succeed(0n)
    },
    ProviderNotFoundError: (e) => {
      console.error('Missing resource:', e.message)
      return Effect.succeed(0n)
    }
  })
)

Free Functions Reference

All provider operations are available as free functions that internally use ProviderService: Block operations: getBlockNumber, getBlock, getBlockTransactionCount, getBlockReceipts, getUncle, getUncleCount Account operations: getBalance, getTransactionCount, getCode, getStorageAt, getProof Transaction operations: getTransaction, getTransactionReceipt, getTransactionByBlockHashAndIndex, getTransactionByBlockNumberAndIndex, sendRawTransaction, waitForTransactionReceipt, getTransactionConfirmations Call/Simulation: call, estimateGas, createAccessList, simulateV1, simulateV2 Events/Logs: getLogs, createEventFilter, createBlockFilter, createPendingTransactionFilter, getFilterChanges, getFilterLogs, uninstallFilter Network: getChainId, getGasPrice, getMaxPriorityFeePerGas, getFeeHistory, getBlobBaseFee, getSyncing, getAccounts, getCoinbase, netVersion, getProtocolVersion, getMining, getHashrate Streaming: watchBlocks, backfillBlocks, subscribe, unsubscribe Node-dependent: sendTransaction, sign, signTransaction

Type Reference

type AddressInput = AddressType | `0x${string}`
type HashInput = HashType | `0x${string}`

// Block identifier - named tag or hex block number
type BlockTag = 'latest' | 'earliest' | 'pending' | 'safe' | 'finalized' | `0x${string}`

// Block args - XOR: use blockTag OR blockHash, never both
type GetBlockArgs =
  | { blockTag?: BlockTag; includeTransactions?: boolean; blockHash?: never }
  | { blockHash: HashInput; includeTransactions?: boolean; blockTag?: never }

// Block tx count args - XOR: use blockTag OR blockHash, never both
type GetBlockTransactionCountArgs =
  | { blockTag?: BlockTag; blockHash?: never }
  | { blockHash: HashInput; blockTag?: never }

// Log filter - XOR: use blockHash OR fromBlock/toBlock, never both
type LogFilter =
  | { blockHash: HashInput; fromBlock?: never; toBlock?: never; address?: ...; topics?: ... }
  | { blockHash?: never; fromBlock?: BlockTag; toBlock?: BlockTag; address?: ...; topics?: ... }

// Block type with nullable fields for pending blocks
interface BlockType {
  number: string | null               // null for pending blocks
  hash: string | null                 // null for pending blocks
  parentHash: string
  // ... other header fields
  baseFeePerGas?: string              // EIP-1559 (optional)
  withdrawals?: WithdrawalType[]      // EIP-4895 (optional)
  withdrawalsRoot?: string            // EIP-4895 (optional)
  blobGasUsed?: string                // EIP-4844 (optional)
  excessBlobGas?: string              // EIP-4844 (optional)
  parentBeaconBlockRoot?: string      // EIP-4788 (optional)
}

// Transaction type with nullable fields for pending txs
interface TransactionType {
  hash: string
  blockHash: string | null            // null if pending
  blockNumber: string | null          // null if pending
  transactionIndex: string | null     // null if pending
  to: string | null                   // null for contract creation
  // ... other fields
  maxFeePerBlobGas?: string           // EIP-4844 (optional)
  blobVersionedHashes?: string[]      // EIP-4844 (optional)
  yParity?: string                    // EIP-2930+ (optional)
}

Chain-Specific Providers

Use Layer composition for L2-specific features:
import { Effect } from 'effect'
import { getBlockNumber, OpStackActionsService, OpStackActions, Provider, HttpTransport } from 'voltaire-effect'

// Compose OP Stack extension with base Provider
const OpStackProvider = Layer.merge(Provider, OpStackActions)

// Compose layers first
const OpStackLayer = OpStackProvider.pipe(
  Layer.provide(HttpTransport('https://mainnet.optimism.io'))
)

const program = Effect.gen(function* () {
  const opStack = yield* OpStackActionsService

  const block = yield* getBlockNumber()
  const l1BaseFee = yield* opStack.getL1BaseFee()

  return { block, l1BaseFee }
}).pipe(Effect.provide(OpStackLayer))

See Also