# elm-web3

Type-safe EVM interaction for Elm. The runtime-exception guarantee extends past the wallet boundary.

> **See it, don't take our word:** [**what is actually proved**](https://intrepidshape.github.io/elm-web3/) — the verification story: state-machine diagrams, the coverage ledger, and the CI that enforces it · [**live UI gallery**](https://intrepidshape.github.io/elm-web3-ui/) — every primitive of the companion [elm-web3-ui](https://package.elm-lang.org/packages/intrepidshape/elm-web3-ui/latest/) package, driven by these state machines with zero JS · [coverage ledger](https://github.com/IntrepidShape/elm-web3/blob/master/proofs/COVERAGE.md) · [spec↔code conformance audit](https://github.com/IntrepidShape/elm-web3/blob/master/proofs/TLA_CONFORMANCE.md) · [EVM API coverage](https://github.com/IntrepidShape/elm-web3/blob/master/proofs/EVM_API_COVERAGE.md)

Wraps `window.ethereum`, signs and sends transactions, decodes ABI, watches events — all behind explicit state machines and opaque types. Errors arrive as typed `Msg` values, not uncaught exceptions. No `any`, no stringly-typed addresses, no Promises swallowed at the boundary.

## Why elm-web3

Most DeFi exploits hit the frontend, not the contracts. The JS dapp stack maximises that surface. This lib collapses it.

### Supply chain & JS surface
- **Next to no JavaScript.** App compiles to one small ES5 artifact. Runtime is ~20 KB.
- **No React.** No reconciler, no JSX-as-string, no `dangerouslySetInnerHTML`.
- **No Next.js, no Vite, no Webpack.** No middleware, no SSR proxy, no plugin chain.
- **No npm dependency tree.** Elm packages are curated; transitive supply-chain attacks have nowhere to land.
- **No `eval`, no template-string code execution.** Elm has neither primitive.
- **Reproducible builds.** Same input → byte-identical output. Audit the bundle, ship the bundle.
- **No mutable globals.** No `window` prototype-pollution surface.

### Type safety where it matters
- **Addresses are opaque.** You cannot construct an invalid address. Compiler refuses.
- **BigInt is opaque.** You cannot do float math on wei. Compiler refuses.
- **Chain IDs are tagged.** You cannot mix mainnet and testnet. Compiler refuses.
- **No `any`, no casts, no escape hatches.** Anywhere.
- **Signatures carry their EIP type.** EIP-191, EIP-712, raw — never confused.
- **Decoders fail typed.** Malformed RPC → typed `Msg`, never a crash.

### State machines, not booleans
- **Wallet state is a union.** Disconnected, Connecting, Connected, WrongChain — handled or it doesn't compile.
- **Transaction state is a union.** Idle, Pending, Confirmed, Failed — handled or it doesn't compile.
- **Signature state is a union.** Same discipline.
- **Cannot send a tx from a disconnected wallet.** Compile error, not runtime undefined.
- **Cannot read a balance from the wrong chain.** Compile error, not stale UI.

### Auditable, the way contracts are
- **Pure functions.** No mocks needed in tests.
- **Total view functions.** No "what if this prop is undefined" branch.
- **Single compiled artifact.** What you audit is what you ship.
- **No async/await footguns.** Cmds are explicit, traced through `update`.

### Closing the frontend security gap
- Phishing-UI address swaps depend on string-typed addresses. Ours aren't strings.
- Fake-balance overlays depend on mutable view state. Ours isn't mutable.
- Wallet-bridge spoofs depend on prototype pollution. There's no prototype chain to pollute.
- The frontend deserves the same rigour as the contracts. This is what that looks like.

## Install

```sh
elm install intrepidshape/elm-web3
```

Copy `js/elm-web3-ports.js` into your project and wire it up after your compiled Elm bundle.

**The shipped `.js` is an ES module bundle** — it ends in `export{...}`. Load it with `type="module"` and an `import`. A classic `<script src="elm-web3-ports.js">` throws `SyntaxError: Unexpected token 'export'` and nothing works:

```html
<script src="elm.js"></script>
<script type="module">
  import { setupPorts } from './elm-web3-ports.js'

  const app = Elm.Main.init({ node: document.getElementById('app') })

  setupPorts(app, {
    // Optional. Omit it and every read goes through the connected wallet.
    // Supply it and reads also work with no wallet at all (Wallet.ReadOnly).
    rpcUrls: ['https://rpc.example.org', 'https://rpc-backup.example.org'],
  })
</script>
```

ES modules are not loaded over `file://`, so serve the directory (`bunx serve .`) rather than double-clicking the HTML. Working versions of exactly this wiring are in [`examples/hello-read/index.html`](examples/hello-read/index.html) (no wallet, one `eth_call`) and [`examples/basic/index.html`](examples/basic/index.html) (wallet + transfer).

Declare two ports in your Elm app:

```elm
port module Ports exposing (web3Cmd, web3Sub)

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


port web3Cmd : E.Value -> Cmd msg


port web3Sub : (D.Value -> msg) -> Sub msg
```

### What the shim exports

`js/elm-web3-ports.js` is built from the type-checked `js/elm-web3-ports.ts` by `bun js/build.ts`; `js/elm-web3-ports.d.ts` carries the types for TS consumers. Four entry points plus three type guards, and no npm dependencies:

| Export | Signature | Use it for |
|---|---|---|
| `setupPorts` | `(app, opts?: SetupOptions) => void` | Required. Subscribes `web3Cmd`, starts emitting on `web3Sub`. Call once, right after `Elm.Main.init`. |
| `watchWallets` | `(app) => void` | EIP-6963 multi-wallet discovery. Emits `walletsDiscovered`; feed it to `Wallet.decoder` and render a picker. Call after `setupPorts`. |
| `registerProvider` | `(app, info, provider) => void` | Slot a non-EIP-6963 provider (WalletConnect, Coinbase SDK, an embedded wallet) into that same picker. `info` is `{ name, icon, rdns }`. Idempotent per `rdns`. |
| `setupExternalProvider` | `(app, provider) => void` | Bring your own EIP-1193 transport and make it *the* provider. Rebinds `chainChanged` / `accountsChanged` / `disconnect` so Elm stays in sync. |
| `isHex`, `isAddress`, `isTxHash` | `(value: unknown) => boolean` | Type guards, if you have your own JS/TS at the boundary. |

`SetupOptions` is the entire read-only / no-wallet path:

```ts
interface SetupOptions {
  rpcUrls?: readonly string[]  // preferred: pool of JSON-RPC HTTPS endpoints
  wsUrls?:  readonly string[]  // eth_subscribe endpoints; derived from rpcUrls (https -> wss) if omitted
  rpcUrl?:  string             // deprecated single-endpoint alias for rpcUrls
}
```

A connected wallet is canonical for every read; `rpcUrls` is the fallback used when there is no wallet or the wallet errors on a read. The pool order is shuffled per page load, so no endpoint is trusted by default, and an endpoint that returns three consecutive transport failures is benched for 60 seconds. Logical JSON-RPC errors (a revert, say) propagate immediately rather than counting as a health signal — every endpoint returns the same answer for a given query. Writes never touch the pool.

With `rpcUrls` set and no wallet present, the shim emits `readOnly` and `Wallet.State` lands on `ReadOnly`: reads work, writes are a compile-time impossibility.

## Design

The package communicates with the browser over two ports — one outgoing (`web3Cmd`) and one incoming (`web3Sub`). Everything in the JS bridge is wrapped in try/catch so errors arrive as typed `Msg` values rather than uncaught exceptions.

State machines are central to the design. `Wallet.State`, `Transaction.Status`, and `Sign.SignState` are all explicit union types — the compiler will tell you if you forget a branch.

The JS bridge has no npm dependencies. It calls `window.ethereum.request()` directly.

## Modules

> Unless a block says otherwise, the `elm` blocks below are **signature listings and fragments**, not compilable modules — they omit imports and the surrounding program so the shape of each API is readable at a glance. Blocks introduced as *"a complete, compiling module"* are exactly that: paste one into `src/Main.elm` and it builds against 2.0.0 unchanged. For whole programs you can build and run, see [`examples/`](examples/), which CI compiles on every push.

### `Web3.Types`

Opaque types for the primitives that appear everywhere:

```elm
address : String -> Maybe Address        -- "0x" + 40 hex chars
txHash  : String -> Maybe TxHash         -- "0x" + 64 hex chars
chainId : Int    -> ChainId

addressToString : Address -> String
txHashToString  : TxHash  -> String
chainIdToInt    : ChainId -> Int
```

Passing a `TxHash` where an `Address` is expected is a compile error.

---

### `Web3.Wallet`

Wallet state as an explicit state machine:

```elm
type State
    = Disconnected
    | ReadOnly                                        -- rpcUrls set, no wallet
    | Connecting RequestId                            -- id of the attempt in flight
    | Connected { address : Address, chainId : ChainId }
    | WrongChain { address : Address, chainId : ChainId } ChainId
    | Error String

type alias RequestId =
    Int
```

`Connecting` carries a `RequestId` because a user can click Connect, give up, pick a different wallet, and click again while the first prompt is still open. **Your app owns the counter** — increment it on every connect attempt; this module only compares ids, it never mints them. `update` drops any response tagged with an id that is no longer the current one, so a stale response can never clobber a newer attempt and you need no "already connecting, ignore this click" guard.

```elm
startConnect   : RequestId -> State -> State
timeoutConnect : RequestId -> State -> State
update         : ChainId -> Msg -> State -> State

isConnecting        : State -> Bool
connectingRequestId : State -> Maybe RequestId

connect             : RequestId -> WalletCmd
selectWallet        : RequestId -> String -> WalletCmd   -- EIP-6963 RDNS
disconnect          : WalletCmd
switchChain         : ChainId -> WalletCmd
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
```

A minimal wallet flow — a complete, compiling module:

```elm
port module Main exposing (main)

import Browser
import Html exposing (Html, button, text)
import Html.Events exposing (onClick)
import Json.Decode as D
import Json.Encode as E
import Web3.Chain as Chain
import Web3.Types as T
import Web3.Wallet as Wallet


port web3Cmd : E.Value -> Cmd msg


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


type alias Model =
    { wallet : Wallet.State
    , nextRequestId : Wallet.RequestId
    , providers : List Wallet.WalletProvider
    }


type Msg
    = ConnectWallet
    | PickWallet String
    | Web3Msg D.Value


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


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        -- Call startConnect unconditionally, even while already Connecting.
        -- The fresh id supersedes the attempt in flight; no guard needed.
        ConnectWallet ->
            ( { model
                | wallet = Wallet.startConnect model.nextRequestId model.wallet
                , nextRequestId = model.nextRequestId + 1
              }
            , web3Cmd (Wallet.encode (Wallet.connect model.nextRequestId))
            )

        PickWallet rdns ->
            ( { model
                | wallet = Wallet.startConnect model.nextRequestId model.wallet
                , nextRequestId = model.nextRequestId + 1
              }
            , web3Cmd (Wallet.encode (Wallet.selectWallet model.nextRequestId rdns))
            )

        Web3Msg raw ->
            case D.decodeValue Wallet.decoder raw of
                Ok walletMsg ->
                    ( { model
                        | wallet = Wallet.update expectedChain walletMsg model.wallet
                        , providers =
                            case walletMsg of
                                Wallet.WalletsDiscovered ps ->
                                    ps

                                _ ->
                                    model.providers
                      }
                    , Cmd.none
                    )

                Err _ ->
                    ( model, Cmd.none )


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

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

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

        Wallet.WrongChain _ _ ->
            button [ onClick ConnectWallet ] [ text "Switch network" ]

        Wallet.ReadOnly ->
            text "Read-only"

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


main : Program () Model Msg
main =
    Browser.element
        { init = \_ -> ( Model Wallet.Disconnected 1 [], Cmd.none )
        , update = update
        , view = view
        , subscriptions = \_ -> web3Sub Web3Msg
        }
```

Upgrading from 1.x? The `Connecting` / `startConnect` / `connect` arity changes above are breaking; [`docs/UPGRADING.md`](docs/UPGRADING.md) has the before/after and the compatibility matrix.

---

### `Web3.Transaction`

Transaction lifecycle:

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

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

Sending a transaction:

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

type Msg
    = Submit
    | TxMsg D.Value

update msg model =
    case msg of
        Submit ->
            ( { model | tx = Tx.AwaitingSignature }
            , Ports.web3Cmd
                (Send.encode
                    (Send.payableCall
                        { contract = routerAddress
                        , method   = "buy(uint256)"
                        , args     = [ Encode.uint256 minOut ]
                        , value    = weiAmount
                        }
                    )
                )
            )

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

viewTx status =
    case status of
        Tx.Idle             -> text ""
        Tx.AwaitingSignature -> text "Sign in your wallet…"
        Tx.Submitted h      -> text ("Pending: " ++ T.txHashToString h)
        Tx.Confirming h n   -> text (String.fromInt n ++ " confirmations")
        Tx.Confirmed _      -> text "Confirmed"
        Tx.Failed err       -> text ("Failed: " ++ err)
        Tx.Rejected         -> text "Rejected"
```

---

### `Web3.Contract.Call`

Read-only contract calls (`eth_call`):

```elm
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
encode    : ReadCall a -> E.Value
responseDecoder : ReadCall a -> D.Decoder a
```

---

### `Web3.Contract.Send`

Write calls and deployments:

```elm
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
```

---

### `Web3.Contract.Event`

Event subscriptions and log queries:

```elm
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 reads into one `eth_call` using the Multicall3 contract
(`0xcA11bde05977b3631167028862bE2a173976CA11`):

```elm
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))
```

---

### `Web3.Sign`

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

```elm
typedData    : { domain, types, primaryType, message } -> TypedData
encode       : String -> Address -> TypedData -> E.Value
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
```

EIP-712 permit example:

```elm
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 )
                ]
        }
```

---

### `Web3.Balance`

Native balance queries with correlation IDs:

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

---

### `Web3.Block`

Block queries and polling:

```elm
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
```

---

### `Web3.Fee`

Gas price and EIP-1559 fee history:

```elm
getGasPrice  : String -> Cmd
getFeeHistory : String -> Int -> Cmd
encode  : Cmd -> E.Value
decoder : D.Decoder Msg
```

---

### `Web3.Query`

Miscellaneous on-chain reads:

```elm
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
```

---

### `Web3.Units`

Pure-Elm unit conversion, no port needed:

```elm
formatEther : BigInt -> String
parseEther  : String -> Maybe BigInt

formatUnits : Int -> BigInt -> String
parseUnits  : Int -> String -> Maybe BigInt
```

---

### `Web3.Chain`

Chain definitions:

```elm
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         : Chain -> String
rpcUrl       : Chain -> String
blockExplorer : Chain -> String
```

| Chain | ID | Constructor |
|---|---|---|
| Ethereum | 1 | `Chain.ethereum` |
| Sepolia | 11155111 | `Chain.sepolia` |
| PulseChain | 369 | `Chain.pulsechain` |
| PulseChain Testnet | 943 | `Chain.pulsechainTestnet` |
| BNB Smart Chain | 56 | `Chain.bsc` |
| Polygon | 137 | `Chain.polygon` |
| Arbitrum One | 42161 | `Chain.arbitrum` |
| Optimism | 10 | `Chain.optimism` |
| Base | 8453 | `Chain.base` |
| Avalanche C-Chain | 43114 | `Chain.avalanche` |
| zkSync Era | 324 | `Chain.zksync` |
| Fantom | 250 | `Chain.fantom` |
| Gnosis | 100 | `Chain.gnosis` |
| Linea | 59144 | `Chain.linea` |
| Scroll | 534352 | `Chain.scroll` |
| Any EVM chain | custom | `Chain.custom` |

---

### `Web3.BigInt`

Arbitrary-precision integers for `uint256` and `int256` values. Base-10⁷ digit representation, no external dependencies.

```elm
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`

ABI parameter helpers.

**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 raw ABI hex without a JS ABI library):
`hexSlot`, `uint256Slot`, `addressSlot`, `boolSlot`, `stringSlot`, `listSlot`, `tuple2Hex`, `tuple3Hex`

**Revert reason decoding:**
```elm
decodeRevertReason : String -> Maybe String
```

---

### `Web3.Crypto`

Keccak256 via port:

```elm
keccak256 : String -> String -> Cmd
encode    : Cmd -> E.Value
decoder   : D.Decoder Msg
```

## Code generator

Generates typed Elm modules from Solidity ABI JSON:

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

Each ABI function becomes a typed encoder; each event becomes a typed decoder.

## Formal verification

The `proofs/` directory contains Lean 4 proofs and TLA+ specifications.

**Lean 4 (all proofs close without `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 (normalize, add, multiply, shift, subtract, parse)
- `AbiCodec.lean` — bytes32 and address codec soundness, completeness, and roundtrip
- `RevertReason.lean` — six theorems covering hex parsing, UTF-8 roundtrip, and selector/length guards

**TLA+ model-checked:**

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

See `proofs/COVERAGE.md` for the full coverage map. All proofs use only core Lean 4 — no Mathlib.

## Security

No external audit has been performed. [`SECURITY.md`](SECURITY.md) states exactly what is machine-checked and what is not, how to reproduce and pin the JS shim's bytes, and — because the Elm registry is append-only and nothing can ever be unpublished — the deprecate-and-supersede procedure for a security patch.

Disclosure: [Jake@intrepiddev.com.au](mailto:Jake@intrepiddev.com.au), subject `elm-web3 security`.

## Upgrading

[`docs/UPGRADING.md`](docs/UPGRADING.md) — 1.x to 2.0.0 with before/after code, plus the elm-web3 ↔ elm-web3-ui ↔ shim compatibility matrix. Upgrading the Elm package without replacing the JS shim is the failure mode to avoid.

## Prior art

- `cmditch/elm-ethereum` — web3.js era, Task-based, no longer maintained
- `purescript-web3` — similar concept in PureScript

## Made by

[Intrepid Development](https://intrepiddev.com.au) — Solidity team. Dapps, contracts, audits.

We write the contracts and the frontends that talk to them. They deserve the same rigour. This lib is what we use on our own.

If you want it wired into a production frontend, or the dapp side hardened alongside a contract engagement: [Jake@intrepiddev.com.au](mailto:Jake@intrepiddev.com.au).

## License

MIT © Intrepid Development
