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