Skip to main content
Multicall is not a service - it provides effectful functions (aggregate3, BalanceResolver) that depend on TransportService. Unlike services which use Context.Tag, these are simply Effect functions you call directly.

Quick Start

import { Effect } from 'effect'
import { aggregate3, HttpTransport } from 'voltaire-effect'

const program = Effect.gen(function* () {
  const results = yield* aggregate3([
    { target: '0x6B175474E89094C44Da98b954EecdEfaE6E286AB', callData: '0x70a08231...' },
    { target: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', callData: '0x70a08231...' }
  ])
  return results
}).pipe(
  Effect.provide(HttpTransport('https://eth.llamarpc.com'))
)

MulticallCall Type

interface MulticallCall {
  /** Target contract address to call */
  readonly target: `0x${string}`
  /** ABI-encoded function call data */
  readonly callData: `0x${string}`
  /** Whether this call is allowed to fail without reverting the batch */
  readonly allowFailure?: boolean
}

MulticallResult

interface MulticallResult {
  /** Whether the call succeeded */
  readonly success: boolean
  /** The return data from the call (empty if failed) */
  readonly returnData: `0x${string}`
}

Success/Failure Handling

const results = yield* aggregate3([
  { target: tokenA, callData: balanceOfData, allowFailure: true },
  { target: tokenB, callData: balanceOfData, allowFailure: true }
])

for (const result of results) {
  if (result.success) {
    const balance = decodeBalance(result.returnData)
    console.log('Balance:', balance)
  } else {
    console.log('Call failed')
  }
}

Error Handling

import { MulticallError } from 'voltaire-effect'

program.pipe(
  Effect.catchTag('MulticallError', (e) => {
    console.error('Multicall failed:', e.message)
    if (e.failedCalls) {
      console.error('Failed call indices:', e.failedCalls)
    }
    return Effect.succeed([])
  })
)

MulticallError

class MulticallError extends Data.TaggedError("MulticallError")<{
  /** Human-readable error message */
  readonly message: string
  /** Indices of calls that failed (if applicable) */
  readonly failedCalls?: readonly number[]
  /** Underlying error that caused the failure */
  readonly cause?: unknown
}>

ERC-20 Balance Checking

Batch multiple balanceOf calls for different tokens:
import { Effect } from 'effect'
import { encodeParameters } from '@tevm/voltaire/Abi'
import { aggregate3, HttpTransport } from 'voltaire-effect'

const BALANCE_OF_SELECTOR = '0x70a08231'

const encodeBalanceOf = (account: `0x${string}`): `0x${string}` => {
  const encoded = encodeParameters([{ type: 'address' }], [account])
  return `${BALANCE_OF_SELECTOR}${Buffer.from(encoded).toString('hex')}` as `0x${string}`
}

const checkBalances = Effect.gen(function* () {
  const account = '0x1234567890123456789012345678901234567890'

  const tokens = [
    '0x6B175474E89094C44Da98b954EecdEfaE6E286AB', // DAI
    '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // USDC
    '0xdAC17F958D2ee523a2206206994597C13D831ec7', // USDT
  ] as const

  const results = yield* aggregate3(
    tokens.map(token => ({
      target: token,
      callData: encodeBalanceOf(account),
      allowFailure: true
    }))
  )

  return results.map((r, i) => ({
    token: tokens[i],
    balance: r.success ? BigInt(r.returnData) : 0n
  }))
}).pipe(
  Effect.provide(HttpTransport('https://eth.llamarpc.com'))
)

Custom Multicall Address

For chains where Multicall3 is deployed at a different address, pass an override:
const results = yield* aggregate3(
  calls,
  'latest',
  '0x0000000000000000000000000000000000000000'
)

Request Configuration

Use Effect-native helpers for timeout and retry:
import { withTimeout, withRetrySchedule, withoutCache } from 'voltaire-effect'
import { Schedule } from 'effect'

// Multicall with timeout
const results = yield* aggregate3(calls).pipe(
  withTimeout("10 seconds")
)

// Retry with exponential backoff
const resultsWithRetry = yield* aggregate3(calls).pipe(
  withRetrySchedule(
    Schedule.exponential("500 millis").pipe(
      Schedule.jittered,
      Schedule.compose(Schedule.recurs(5))
    )
  )
)

// Bypass cache for fresh data
const freshResults = yield* aggregate3(calls).pipe(withoutCache)

TransportService Dependency

aggregate3 is an Effect function (not a service) that depends on TransportService:
import { Effect } from 'effect'
import { aggregate3, HttpTransport } from 'voltaire-effect'

const program = aggregate3([{ target, callData }]).pipe(
  Effect.provide(HttpTransport('https://eth.llamarpc.com'))
)

Function Signature

aggregate3 is an Effect function, not a service method:
const aggregate3: (
  calls: readonly MulticallCall[],
  blockTag?: BlockTag,
  multicallAddress?: `0x${string}`
) => Effect.Effect<readonly MulticallResult[], MulticallError, TransportService>

Multicall3 Contract

The helper uses the Multicall3 contract deployed at 0xcA11bde05977b3631167028862bE2a173976CA11 on Ethereum mainnet and most EVM-compatible chains.

Either for Result Handling

Transform MulticallResult to Either for ergonomic partial failure handling:
import { Effect, Either } from 'effect'
import { aggregate3, HttpTransport } from 'voltaire-effect'

const program = Effect.gen(function* () {
  const results = yield* aggregate3(calls)
  
  // Transform to Either<Error, Data>
  const eitherResults = results.map((r, i) =>
    r.success
      ? Either.right(r.returnData)
      : Either.left({ index: i, message: 'Call failed' })
  )
  
  // Exhaustive handling with Either.match
  const processed = eitherResults.map(r =>
    Either.match(r, {
      onLeft: (err) => ({ data: null, error: err.message }),
      onRight: (data) => ({ data: BigInt(data), error: null })
    })
  )
  
  // Filter and extract
  const successes = eitherResults.filter(Either.isRight).map(r => r.right)
  const failures = eitherResults.filter(Either.isLeft).map(r => r.left)
  
  return { processed, successes, failures }
}).pipe(
  Effect.provide(HttpTransport('https://eth.llamarpc.com'))
)

Either API Reference

MethodSignatureDescription
Either.right<A>(value: A) => Either<never, A>Create success
Either.left<E>(error: E) => Either<E, never>Create failure
Either.isRight(e: Either<E, A>) => e is Right<A>Type guard
Either.isLeft(e: Either<E, A>) => e is Left<E>Type guard
Either.match(e, { onLeft, onRight }) => BExhaustive match
Either.map(e, f: A => B) => Either<E, B>Map success
Either.mapLeft(e, f: E => E2) => Either<E2, A>Map error
Either.getOrElse(e, default: () => A) => AExtract with fallback

See Also