Skip to main content

Quick Start

import { Effect, Schedule, Layer } from 'effect'
import {
  SignerService, Signer, Provider, LocalAccount,
  HttpTransport, Secp256k1Live, KeccakLive,
  withTimeout, withRetrySchedule
} from 'voltaire-effect'
import { Hex } from '@tevm/voltaire'

const privateKey = Hex.fromHex('0xac0974bec...')

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

const program = Effect.gen(function* () {
  const signer = yield* SignerService

  // Per-request timeout for transaction broadcast
  return yield* signer.sendTransaction({
    to: recipientAddress,
    value: 1000000000000000000n
  }).pipe(withTimeout('30 seconds'))
}).pipe(Effect.provide(SignerLayer))

Constructors

Signer.fromPrivateKey

Convenience constructor for local signing:
const signer = Signer.fromPrivateKey(privateKey, Provider)

// Equivalent to:
const signer = Signer.fromProvider(Provider, LocalAccount(privateKey))

Signer.fromProvider

Compose a Signer from existing Provider and Account layers:
import { Signer, Provider, LocalAccount, JsonRpcAccount } from 'voltaire-effect'

// With local account (private key signing)
const localSigner = Signer.fromProvider(Provider, LocalAccount(privateKey))

// With JSON-RPC account (browser wallet)
const browserSigner = Signer.fromProvider(Provider, JsonRpcAccount(userAddress))

Signer.Live

Base layer requiring all dependencies explicitly:
const DepsLayer = Layer.mergeAll(Provider, LocalAccount(privateKey))
const signer = Signer.Live.pipe(Layer.provide(DepsLayer))

// Type: Layer.Layer<SignerService, never, TransportService>

Layer Pattern

import * as Layer from 'effect/Layer'

// Signer.Live depends on Provider + Account + Transport
const Signer = {
  Live: Layer.effect(SignerService, Effect.gen(function* () {
    const provider = yield* ProviderService
    const account = yield* AccountService
    const transport = yield* TransportService
    // ... implementation
  })) as Layer.Layer<SignerService, never, ProviderService | AccountService | TransportService>,

  // Compose Provider + Account into ready-to-use Signer
  fromProvider: (
    providerLayer: Layer.Layer<ProviderService, any, TransportService>,
    accountLayer: Layer.Layer<AccountService>
  ): Layer.Layer<SignerService, any, TransportService> =>
    Signer.Live.pipe(
      Layer.provide(Layer.mergeAll(providerLayer, accountLayer))
    ),

  // Convenience: local private key signer
  fromPrivateKey: (
    privateKey: Hex,
    providerLayer: Layer.Layer<ProviderService, any, TransportService>
  ): Layer.Layer<SignerService, any, TransportService> =>
    Signer.fromProvider(providerLayer, LocalAccount(privateKey)),
}

Browser Wallet

import { JsonRpcAccount, BrowserTransport } from 'voltaire-effect'
import { Layer } from 'effect'

// Compose layers first
const BrowserSignerLayer = Signer.fromProvider(Provider, JsonRpcAccount(userAddress)).pipe(
  Layer.provide(BrowserTransport)
)

const program = Effect.gen(function* () {
  const signer = yield* SignerService
  const accounts = yield* signer.requestAddresses() // triggers popup
  const txHash = yield* signer.sendTransaction({ to, value })
  return txHash
}).pipe(Effect.provide(BrowserSignerLayer))

Methods

signMessageEIP-191 personal_sign
const signature = yield* signer.signMessage(Hex.fromString('Hello!'))
signTransaction — Sign without broadcast (auto-fills nonce, gas, chainId)
const signedTx = yield* signer.signTransaction({ to, value })
signTypedDataEIP-712 structured data
const sig = yield* signer.signTypedData({
  domain: { name: 'App', version: '1', chainId: 1n },
  primaryType: 'Order',
  types: { Order: [{ name: 'amount', type: 'uint256' }] },
  message: { amount: 1000n }
})
sendTransaction — Sign and broadcast
const txHash = yield* signer.sendTransaction({ to, value: 1000000000000000000n })
sendRawTransaction — Broadcast pre-signed tx
const txHash = yield* signer.sendRawTransaction(signedTxHex)
requestAddresses — Get connected accounts (browser popup)
const accounts = yield* signer.requestAddresses()
switchChain — Change network
yield* signer.switchChain(137) // Polygon

TransactionRequest

// AddressInput accepts both branded AddressType and hex strings (matches Provider API)
type AddressInput = AddressType | `0x${string}`

type TransactionRequest = {
  readonly to?: AddressInput | null    // null = contract deploy
  readonly value?: bigint
  readonly data?: HexType
  readonly nonce?: bigint              // auto-filled from getTransactionCount('pending')
  readonly gasLimit?: bigint           // auto-estimated via estimateGas
  readonly gasPrice?: bigint           // legacy (type 0/1)
  readonly maxFeePerGas?: bigint       // EIP-1559+ (type 2/3/4)
  readonly maxPriorityFeePerGas?: bigint
  readonly chainId?: bigint            // auto-detected from eth_chainId
  readonly accessList?: Array<{        // EIP-2930+ access list
    address: AddressInput
    storageKeys: Array<`0x${string}`>
  }>
  
  // Explicit transaction type (auto-detected if not provided)
  readonly type?: 0 | 1 | 2 | 3 | 4
  
  // EIP-4844 blob transaction fields
  readonly blobVersionedHashes?: readonly `0x${string}`[]
  readonly maxFeePerBlobGas?: bigint
  
  // EIP-7702 set code authorization
  readonly authorizationList?: readonly {
    chainId: bigint
    address: `0x${string}`
    nonce: bigint
    yParity: number
    r: `0x${string}`
    s: `0x${string}`
  }[]
}

Transaction Types

TypeEIPDescriptionKey Fields
0LegacyOriginal Ethereum txgasPrice
1EIP-2930Access list txgasPrice + accessList
2EIP-1559Priority fee txmaxFeePerGas + maxPriorityFeePerGas
3EIP-4844Blob tx (L2 data)blobVersionedHashes + maxFeePerBlobGas
4EIP-7702Set code txauthorizationList

Transaction Type Detection

The signer automatically detects which transaction type to use based on provided fields:
  1. If type is explicitly provided → Use that type
  2. If authorizationList provided → EIP-7702 (type 4)
  3. If blobVersionedHashes provided → EIP-4844 (type 3)
  4. If maxFeePerGas or maxPriorityFeePerGas provided → EIP-1559 (type 2)
  5. If accessList provided with gasPrice → EIP-2930 (type 1)
  6. If gasPrice provided (no accessList) → Legacy (type 0)
  7. If neither provided AND network supports EIP-1559 → EIP-1559 (type 2)
  8. If neither provided AND network doesn’t support EIP-1559 → Legacy (type 0)
On EIP-1559 networks (post-London), if you don’t specify any gas fields, the signer defaults to EIP-1559 transactions.

Fee Calculation

When using EIP-1559 and maxFeePerGas is not provided, it’s calculated as:
maxFeePerGas = baseFeePerGas * 1.2 + maxPriorityFeePerGas
The 1.2x multiplier (20% buffer) on base fee provides headroom for fee volatility across 1-2 blocks, matching the viem/ethers standard. Example: With baseFee=30 gwei and priorityFee=2 gwei:
  • maxFeePerGas = 30 * 1.2 + 2 = 38 gwei
Since bigint doesn’t support decimals, the implementation uses: (baseFee * 12n) / 10n + maxPriorityFeePerGas

Nonce Management

Nonces are fetched using getTransactionCount('pending') to correctly account for pending transactions. This makes sequential sends safe. ⚠️ Concurrent sends still require care. If you send multiple transactions concurrently without waiting, they may race to fetch the same pending nonce. For concurrent sends:
  • Manage nonces manually via nonce field
  • Send transactions sequentially

Error Handling

import { SignerError } from 'voltaire-effect'

program.pipe(
  Effect.catchTag('SignerError', (e) => {
    if (e.message.includes('insufficient funds')) return Effect.fail(e)
    if (e.message.includes('rejected')) return Effect.fail(e)
    return Effect.fail(e)
  })
)

Service Interface

type SignerShape = {
  readonly signMessage: (message: HexType) => Effect.Effect<SignatureType, SignerError>
  readonly signTransaction: (tx: TransactionRequest) => Effect.Effect<HexType, SignerError>
  readonly signTypedData: (typedData: TypedDataType) => Effect.Effect<SignatureType, SignerError>
  readonly sendTransaction: (tx: TransactionRequest) => Effect.Effect<HashType, SignerError>
  readonly sendRawTransaction: (signedTx: HexType) => Effect.Effect<HashType, SignerError>
  readonly requestAddresses: () => Effect.Effect<AddressType[], SignerError>
  readonly switchChain: (chainId: number) => Effect.Effect<void, SignerError>
}

// HexType is a branded `0x${string}` for signed transaction bytes
// HashType is a branded Uint8Array for 32-byte hashes (tx hash)
// SignatureType is a branded Uint8Array with signature metadata

Dependencies

Signer.Live requires these services:
ServicePurpose
ProviderServiceRequest-only layer used by free functions for gas/nonce/chainId lookups
AccountServiceSign messages and transactions
TransportServiceSend raw transactions via RPC
The fromProvider and fromPrivateKey constructors compose these for you.

See Also