quietgarden/elm-web3/2.0.0

NOTE: the latest version is 2.0.1

Unavailable. Nobody can download this version any more, with elm or with the Lamdera compiler: github.com/quietgarden/elm-web3 is gone (deleted or private); Software Heritage not asked yet (this run's --swh-calls were used up); no fork with tag 2.0.0 found (5 same-name repos checked); no cached copy. Use another version.

Details

elm-web3

Type-safe EVM blockchain interaction for Elm. Zero runtime exceptions.

80% of your frontend code gets zero runtime exceptions. Not fewer. Zero. The Elm compiler guarantees it.

The remaining 20% is a ~500-line JavaScript port bridge that catches every error and routes it back to Elm as a typed message. Your app never white-screens.

What it is

A package for building EVM dapps in Elm. It provides:

  • Opaque, validated types for addresses, transaction hashes, chain IDs, and wei amounts — the compiler prevents mixing them up
  • Wallet state machine that makes invalid states unrepresentable (no "connected but address is null")
  • Transaction lifecycle state machine with exhaustive pattern matching including revert reason decoding
  • Typed contract calls (reads and writes) with JSON encoders/decoders
  • Multicall for batching multiple reads into one RPC round-trip
  • EIP-712 typed data signing with a full state machine for the signing flow
  • EIP-6963 multi-wallet discovery — MetaMask, Rabby, and any injected wallet
  • Balance, block, fee, and query modules for on-chain reads with correlation IDs
  • Pure-Elm unit conversion (ETH ↔ Wei, ERC-20 decimals)
  • ABI code generator that reads Solidity ABI JSON and outputs typed Elm modules

The JS layer is ~500 lines with zero npm dependencies. No ethers. No viem. No web3.js. Just window.ethereum.request().

The Elm layer depends only on the Elm standard library (elm/core, elm/json, elm/http, elm/time). No third-party Elm packages.

Install

elm install quietgarden/elm-web3

Then copy js/elm-web3-ports.js into your project and load it after your compiled Elm JS:

<script src="elm.js"></script>
<script src="elm-web3-ports.js"></script>
<script>
  var app = Elm.Main.init({ node: document.getElementById('app') });
  setupPorts(app);
</script>

Define your Elm ports:

port module Ports exposing (..)

import Json.Encode as E
import Json.Decode as D

port web3Cmd : E.Value -> Cmd msg
port web3Sub : (D.Value -> msg) -> Sub msg

Modules

Web3.Types

Opaque types that prevent mixing up addresses, transaction hashes, and chain IDs.

address : String -> Maybe Address        -- validates 0x + 40 hex chars
txHash : String -> Maybe TxHash          -- validates 0x + 64 hex chars
chainId : Int -> ChainId
encodeBlockNumber : BlockNumber -> E.Value

You can't pass a TxHash where an Address is expected. The compiler catches it at build time.

Web3.Wallet

Wallet connection as an explicit state machine. The central type is State:

type State
    = Disconnected
    | ReadOnly                                    -- rpcUrl configured, no wallet
    | Connecting
    | Connected { address : Address, chainId : ChainId }
    | WrongChain { address : Address, chainId : ChainId } ChainId
    | Error String

Key functions:

startConnect : State -> State              -- Disconnected → Connecting
update : ChainId -> Msg -> State -> State  -- drive state machine from port messages
connect, disconnect : WalletCmd
switchChain : ChainId -> WalletCmd
selectWallet : String -> WalletCmd         -- EIP-6963 RDNS identifier
addChain : ChainConfig -> WalletCmd        -- EIP-3085
watchAsset : { address, symbol, decimals, image } -> WalletCmd  -- EIP-747
requestPermissions, getPermissions : WalletCmd  -- EIP-2255
encode : WalletCmd -> E.Value
decoder : D.Decoder Msg

Web3.Transaction

Transaction lifecycle as a state machine. The central type is Status:

type Status
    = Idle
    | AwaitingSignature
    | Submitted TxHash
    | Confirming TxHash Int      -- hash + confirmation count
    | Confirmed Receipt
    | Failed String              -- includes decoded revert reason when available
    | Rejected

Key functions:

update : Msg -> Status -> Status
isTerminal : Status -> Bool
isPending : Status -> Bool
transactionConfirmations : Int -> Receipt -> Int
encodeCmd : TxCmd -> E.Value          -- encode RequestReceipt for port
decoder : D.Decoder Msg
parseReceiptEvents : List (EventLog -> Maybe a) -> Receipt -> List a

Web3.Contract.Call

Typed read-only contract calls (eth_call).

readCall :
    { contract : Address, method : String, args : List E.Value
    , decoder : D.Decoder a, id : String }
    -> ReadCall a

withBlock : BlockNumber -> ReadCall a -> ReadCall a
withFrom  : Address    -> ReadCall a -> ReadCall a   -- simulate write without broadcasting
encode : ReadCall a -> E.Value
responseDecoder : ReadCall a -> D.Decoder a

Web3.Contract.Send

Typed write calls (eth_sendTransaction).

writeCall   : { contract, method, args } -> WriteCall
payableCall : { contract, method, args, value : BigInt } -> WriteCall
withGasLimit : Int -> WriteCall -> WriteCall
encode : WriteCall -> E.Value
estimateGas : WriteCall -> E.Value
deployCall : { bytecode, args, gasLimit } -> E.Value
encodeRawSend : String -> E.Value    -- broadcast pre-signed transaction

Web3.Contract.Event

Event subscriptions and historical log queries.

watchEvent : EventFilter -> E.Value
getLogs : GetLogsQuery -> E.Value
decoder : D.Decoder a -> D.Decoder (EventLog a)
logsDecoder : D.Decoder a -> D.Decoder (List (EventLog a))

EventLog a carries data, contract, topics, blockNumber, txHash, and logIndex.

Web3.Multicall

Batch multiple contract reads into one eth_call via the Multicall3 contract (0xcA11bde05977b3631167028862bE2a173976CA11, deployed on all major networks).

callSpec : Address -> String -> List E.Value -> CallSpec
batch : String -> List CallSpec -> MulticallRequest
encode : MulticallRequest -> E.Value
responseDecoder : List (D.Decoder a) -> D.Decoder (List (Result String a))

One RPC round-trip instead of N. Essential for dashboards.

Web3.Sign

EIP-712 typed data signing and EIP-191 personal signing.

typedData : { domain, types, primaryType, message } -> TypedData
encode : String -> Address -> TypedData -> E.Value    -- send via port
personalSign : String -> Address -> String -> E.Value

type SignState = SignIdle | SignPending String | Signed String String
              | SignFailed String String | SignRejected String

startSign : String -> SignState -> SignState
signUpdate : SignMsg -> SignState -> SignState
isSignTerminal : SignState -> Bool
signatureDecoder : D.Decoder String

Web3.Balance

Native ETH/PLS balance queries. Each query carries a correlation ID so multiple queries can be in flight at once.

getBalance : Address -> String -> Cmd
encode : Cmd -> E.Value
decoder : D.Decoder Msg    -- GotBalance id wei

Web3.Block

Block number queries, block data fetching, and block number watching.

getBlockNumber : String -> Cmd
getBlock : BlockNumber -> String -> Cmd
watchBlockNumber : String -> Cmd          -- polls every 4 seconds
getBlockTransactionCount : BlockNumber -> String -> Cmd
encode : Cmd -> E.Value
decoder : D.Decoder Msg    -- GotBlockNumber | GotBlock | GotBlockTxCount

Web3.Fee

Gas price and EIP-1559 fee history.

getGasPrice : String -> Cmd
getFeeHistory : String -> Int -> Cmd    -- id, blockCount
encode : Cmd -> E.Value
decoder : D.Decoder Msg    -- GotGasPrice | GotFeeHistory

Web3.Query

On-chain read queries: transaction count (nonce), storage slots, contract bytecode, and transaction lookup.

getTxCount : Address -> String -> Cmd
getStorageAt : Address -> Int -> String -> Cmd
getCode : Address -> String -> Cmd
getTransaction : TxHash -> String -> Cmd
encode : Cmd -> E.Value
decoder : D.Decoder Msg    -- GotTxCount | GotStorageAt | GotCode | GotTransaction | TransactionNotFound

Web3.Units

Pure-Elm ETH and ERC-20 unit conversion. No port required.

formatEther : BigInt -> String           -- Wei to ETH string, trailing zeros trimmed
parseEther  : String -> Maybe BigInt     -- ETH string to Wei

formatUnits : Int -> BigInt -> String    -- custom decimals
parseUnits  : Int -> String -> Maybe BigInt

Web3.Chain

Chain definitions for all major EVM networks:

ethereum, sepolia : Chain
pulsechain, pulsechainTestnet : Chain
bsc, polygon, arbitrum, optimism, base : Chain
avalanche, zksync, fantom, gnosis, linea, scroll : Chain
custom : { chainId, name, rpcUrl, blockExplorer, nativeCurrency } -> Chain

chainId : Chain -> T.ChainId
name, rpcUrl, blockExplorer : Chain -> String

Web3.BigInt

Arbitrary-precision integers for uint256 and int256 values. No external dependencies — base-10^7 digit representation, safe within Elm's 53-bit integer range.

fromInt : Int -> BigInt
fromString : String -> Maybe BigInt
fromIntString : String -> Maybe BigInt
fromHexString : String -> Maybe BigInt    -- 0x-prefixed
toString : BigInt -> String

add, sub, mul : BigInt -> BigInt -> BigInt
div, mod : BigInt -> BigInt -> Maybe BigInt
compare : BigInt -> BigInt -> Order
gt, gte, lt, lte, eq : BigInt -> BigInt -> Bool
zero : BigInt
isZero : BigInt -> Bool
decoder : D.Decoder BigInt

Web3.Abi.Encode / Web3.Abi.Decode

Helpers for encoding contract call parameters and decoding return values.

Encoders: address, uint256, int256, bool, string, bytes, bytes32, bytesN, list, tuple2, tuple3

Decoders: address, uint256, int256, bool, string, bytes32, uint8, uint16, uint32, uint64, uint128

Hex-slot decoders (for decoding raw ABI-encoded hex return values without a JS ABI library): hexSlot, uint256Slot, addressSlot, boolSlot, stringSlot, listSlot, tuple2Hex, tuple3Hex

Revert reason extraction:

decodeRevertReason : String -> Maybe String   -- hex data -> human-readable string

Web3.Crypto

Keccak256 hashing via port (JS side uses the built-in implementation).

keccak256 : String -> String -> Cmd    -- message, id
encode : Cmd -> E.Value
decoder : D.Decoder Msg    -- GotKeccak256 id hash

Wallet connection flow

type alias Model =
    { wallet : Wallet.State
    , availableWallets : List Wallet.WalletProvider
    }

type Msg
    = ConnectWallet
    | SelectWallet String
    | Web3Response D.Value

expectedChain : T.ChainId
expectedChain = Web3.Chain.chainId Web3.Chain.pulsechain

update msg model =
    case msg of
        ConnectWallet ->
            ( { model | wallet = Wallet.startConnect model.wallet }
            , Ports.web3Cmd (Wallet.encode Wallet.connect)
            )

        SelectWallet rdns ->
            ( model
            , Ports.web3Cmd (Wallet.encode (Wallet.selectWallet rdns))
            )

        Web3Response value ->
            case D.decodeValue Wallet.decoder value of
                Ok walletMsg ->
                    let
                        newWallet =
                            Wallet.update expectedChain walletMsg model.wallet
                        wallets =
                            case walletMsg of
                                Wallet.WalletsDiscovered providers ->
                                    providers
                                _ ->
                                    model.availableWallets
                    in
                    ( { model | wallet = newWallet, availableWallets = wallets }
                    , Cmd.none
                    )
                Err _ ->
                    ( model, Cmd.none )

view model =
    case model.wallet of
        Wallet.Disconnected ->
            button [ onClick ConnectWallet ] [ text "Connect Wallet" ]

        Wallet.Connecting ->
            text "Connecting..."

        Wallet.Connected info ->
            text ("Connected: " ++ T.addressToString info.address)

        Wallet.WrongChain _ expectedId ->
            button
                [ onClick (Web3Response (Wallet.encode (Wallet.switchChain expectedId) |> always D.value)) ]
                [ text "Switch to PulseChain" ]

        Wallet.ReadOnly ->
            text "Read-only mode"

        Wallet.Error err ->
            text ("Error: " ++ err)

Sending a transaction

import Web3.Contract.Send as Send
import Web3.Transaction as Tx

type Msg
    = Buy
    | TxResponse D.Value

buyCall : T.Address -> T.Wei -> Send.WriteCall
buyCall router value =
    Send.payableCall
        { contract = router
        , method = "buy(uint256)"
        , args = [ Encode.uint256 minTokensOut ]
        , value = value
        }

update msg model =
    case msg of
        Buy ->
            case Wallet.getAddress model.wallet of
                Just _ ->
                    ( { model | tx = Tx.AwaitingSignature }
                    , Ports.web3Cmd (Send.encode (buyCall routerAddress weiAmount))
                    )
                Nothing ->
                    ( model, Cmd.none )

        TxResponse value ->
            case D.decodeValue Tx.decoder value of
                Ok txMsg ->
                    ( { model | tx = Tx.update txMsg model.tx }, Cmd.none )
                Err _ ->
                    ( model, Cmd.none )

viewTx model =
    case model.tx of
        Tx.Idle          -> text ""
        Tx.AwaitingSignature -> text "Sign in wallet..."
        Tx.Submitted h   -> text ("Pending: " ++ T.txHashToString h)
        Tx.Confirming h n -> text (String.fromInt n ++ " confirmations")
        Tx.Confirmed r   -> text "Confirmed!"
        Tx.Failed err    -> text ("Failed: " ++ err)
        Tx.Rejected      -> text "Rejected"

Contract reads and Multicall

import Web3.Contract.Call as Call
import Web3.Multicall as Multicall
import Web3.Abi.Encode as Encode
import Web3.Abi.Decode as Decode

-- Single read
balanceOfCall : T.Address -> T.Address -> Call.ReadCall T.Wei
balanceOfCall token holder =
    Call.readCall
        { contract = token
        , method = "balanceOf(address)"
        , args = [ Encode.address holder ]
        , decoder = Decode.uint256
        , id = "balance-" ++ T.addressToString holder
        }

-- Batch read (one RPC call)
batchBalances : T.Address -> List T.Address -> Multicall.MulticallRequest
batchBalances token holders =
    Multicall.batch "all-balances"
        (List.map
            (\holder ->
                Multicall.callSpec token "balanceOf(address)" [ Encode.address holder ]
            )
            holders
        )

ABI encoding example

import Web3.Abi.Encode as E

-- function transfer(address to, uint256 amount) returns (bool)
transferArgs : T.Address -> BigInt -> List Json.Encode.Value
transferArgs to amount =
    [ E.address to
    , E.uint256 amount
    ]

-- function multicall((address target, bytes callData)[] calls)
multicallArgs : List ( T.Address, String ) -> List Json.Encode.Value
multicallArgs calls =
    [ E.list
        (E.tuple2 E.address E.bytes)
        calls
    ]

EIP-712 signing

import Web3.Sign as Sign
import Dict

permitRequest : T.Address -> T.Address -> BigInt -> Int -> Sign.TypedData
permitRequest owner spender value nonce =
    Sign.typedData
        { domain =
            { name = Just "MyToken"
            , version = Just "1"
            , chainId = Just 369
            , verifyingContract = Just tokenAddress
            , salt = Nothing
            }
        , types =
            Dict.fromList
                [ ( "Permit"
                  , [ { name = "owner",    typeName = "address" }
                    , { name = "spender",  typeName = "address" }
                    , { name = "value",    typeName = "uint256" }
                    , { name = "nonce",    typeName = "uint256" }
                    , { name = "deadline", typeName = "uint256" }
                    ]
                  )
                ]
        , primaryType = "Permit"
        , message =
            Json.Encode.object
                [ ( "owner",    Json.Encode.string (T.addressToString owner) )
                , ( "spender",  Json.Encode.string (T.addressToString spender) )
                , ( "value",    Json.Encode.string (BigInt.toString value) )
                , ( "nonce",    Json.Encode.int nonce )
                , ( "deadline", Json.Encode.int 9999999999 )
                ]
        }

-- Send: web3Cmd (Sign.encode "permit-1" signerAddress (permitRequest ...))
-- Receive: case D.decodeValue Sign.signatureDecoder incoming of Ok sig -> ...

Chain management

import Web3.Chain as Chain

-- Switch to a known chain
switchToPulseChain : Cmd msg
switchToPulseChain =
    Ports.web3Cmd
        (Wallet.encode (Wallet.switchChain (Chain.chainId Chain.pulsechain)))

-- Add a custom chain to the wallet
addCustomChain : Cmd msg
addCustomChain =
    Ports.web3Cmd
        (Wallet.encode
            (Wallet.addChain
                { chainId = 369
                , chainName = "PulseChain"
                , rpcUrls = [ "https://rpc.pulsechain.com" ]
                , nativeCurrency = { name = "Pulse", symbol = "PLS", decimals = 18 }
                , blockExplorerUrls = [ "https://scan.pulsechain.com" ]
                }
            )
        )

Formal verification

The proofs/ directory contains machine-checked Lean 4 proofs and TLA+ model-checked specifications. All proofs use only core Lean 4 — no Mathlib.

Fully proved (zero sorry):

  • Address.lean — soundness, injectivity, and roundtrip for address/addressToString
  • TxHash.lean — same three properties for txHash
  • HexString.lean — same three properties for hexString
  • WalletCodec.lean — encode/decode roundtrip, injectivity, partial inverse, and tag separation for WalletCmd
  • BigInt.lean — nine arithmetic theorems (natNormalize, natAdd, natMulSmall, natAddSmall, shiftLeft, natSub, parseUnsigned, and more)
  • AbiCodec.lean — bytes32 and address codec soundness/completeness/roundtrip
  • RevertReason.lean — six theorems covering hex parsing, UTF-8 roundtrip, and selector/length guards

TLA+ model-checked:

  • WalletSpec.tla — wallet state invariants and no-deadlock liveness
  • TransactionSpec.tla — terminal states stay terminal, confirmation count is monotonic

See proofs/COVERAGE.md for the full coverage map.

Code generator

bun codegen/generate.ts \
  out/MyContract.sol/MyContract.json \
  Generated.MyContract \
  src/Generated/MyContract.elm

Generates typed Elm modules from Solidity ABI JSON. Each function becomes a typed encoder; each event becomes a typed decoder. No magic strings in your app code.

Supported chains

ChainIDConstructor
Ethereum1Chain.ethereum
Sepolia11155111Chain.sepolia
PulseChain369Chain.pulsechain
PulseChain Testnet943Chain.pulsechainTestnet
BNB Smart Chain56Chain.bsc
Polygon137Chain.polygon
Arbitrum One42161Chain.arbitrum
Optimism10Chain.optimism
Base8453Chain.base
Avalanche C-Chain43114Chain.avalanche
zkSync Era324Chain.zksync
Fantom250Chain.fantom
Gnosis100Chain.gnosis
Linea59144Chain.linea
Scroll534352Chain.scroll
Any EVM chaincustomChain.custom

Prior art

  • cmditch/elm-ethereum (2018) — web3.js era, Task-based, no longer maintained
  • purescript-web3 — similar concept in PureScript, stronger types but smaller ecosystem
  • This package targets the modern wallet API (window.ethereum) with zero external JS dependencies

License

MIT