intrepidshape/elm-web3/3.0.0

Web3.Wallet

Wallet connection state machine.

The wallet is modeled as an explicit state -- you can't accidentally call a contract without a connected wallet because the compiler won't give you an Address from a Disconnected state.

case model.wallet of
    Connected info ->
        -- info.address : T.Address -- only available here
        buy info.address amount


    WrongChain _ _ ->
        -- wallet is connected but on the wrong chain
        button [ onClick SwitchChain ] [ text "Switch chain" ]


    ReadOnly ->
        -- rpcUrl configured but no wallet; reads work, writes will fail
        viewReadOnlyBanner


    _ ->
        showConnectButton

EIP-6963 multi-wallet discovery -- listen for WalletsDiscovered on the port and present the list to the user; call selectWallet rdns when they pick.

Typical connection flow:

  1. User clicks "Connect" -> mint a fresh RequestId (an incrementing counter you own), call startConnect requestId on state, send connect requestId via port. Do this unconditionally, even if already Connecting -- a second click (or picking a different wallet mid-prompt) is meant to supersede the in-flight attempt, not be swallowed as a no-op.
  2. WalletsDiscovered providers arrives -> show picker if providers is non-empty.
  3. User picks a wallet -> mint a fresh RequestId the same way, send selectWallet requestId rdns via port.
  4. WalletConnected (Just requestId) addr chainId arrives -> update checks the id against the active Connecting request (dropping it silently if a newer attempt has since superseded it) and transitions to Connected or WrongChain.
  5. If WalletConnectRejected/WalletConnectFailed arrives instead, update returns to Disconnected/Error respectively (again only if the id still matches). Arm a timeout (e.g. 30s via Process.sleep) when entering Connecting; on fire, call timeoutConnect requestId -- a no-op if the request already resolved.
  6. If WrongChain -> send switchChain expectedChain via port.

For native balance queries, use Web3.Balance. For adding chains, use addChain with a ChainConfig record and follow up with switchChain.

Failures keep their type. Error carries a Failure, not a string. A connect that failed still knows WHY it failed (ConnectFailureReason, which the decoder has always parsed and update used to throw away), and everything arriving on the untagged failure channel is a Web3.Error.Error -- so 4902 is ChainNotAdded and the addChain-then-switch retry is writable:

case model.wallet of
    Error (PortFailed Web3.Error.ChainNotAdded) ->
        ( model, web3Cmd (Wallet.encode (Wallet.addChain pulseChainConfig)) )


    Error other ->
        ( { model | banner = Just (Wallet.failureMessage other) }, Cmd.none )
type State
= Disconnected
| ReadOnly
| Connecting RequestId
| Connected ConnectedInfo
| WrongChain ConnectedInfo ChainId
| Error Failure

Wallet connection state. Every possible state is explicit.

  • Disconnected -- no wallet detected, no rpcUrl configured
  • ReadOnly -- rpcUrl is present but no wallet; reads work, writes will fail
  • Connecting -- wallet connection in progress
  • Connected -- wallet connected on the expected chain
  • WrongChain -- wallet connected but on the wrong chain
  • Error -- unrecoverable error, carrying WHY (see Failure)
type alias ConnectedInfo =
{ address : Address
, chainId : ChainId
}

The account details of a live wallet connection: the connected address and the chainId it is currently on. Carried by both Connected and WrongChain -- the latter still knows who is connected, just on the wrong network.

type alias RequestId =
Int

Identifies a single connect attempt so a stale response or timeout from a superseded attempt (e.g. the user clicked Connect, gave up, and clicked again) can never clobber a newer one. Callers own the counter (increment on every startConnect call) -- this module only compares ids, it never generates them.

= NotFound
| NoAccounts
| NetworkError

Why a connect attempt failed to resolve into Connected/WrongChain. Does not cover explicit rejection (WalletConnectRejected) or a request that's still pending elsewhere (WalletConnectPending) -- those are distinct, expected outcomes, not failures.

type Failure
= ConnectFailed ConnectFailureReason String
| PortFailed Error

Why the wallet is in Error.

  • ConnectFailed reason message -- a connectFailed reply for the in-flight attempt. reason is the parsed ConnectFailureReason; before 3.0.0 update decoded it and then discarded it, keeping only message, so an app that wanted to say "no wallet extension found" versus "the RPC is down" had to match on the wallet's own English.
  • PortFailed err -- everything on the untagged failure channel, typed as a Web3.Error.Error. This is where 4902 (ChainNotAdded) surfaces after a switchChain against a chain the wallet does not have, and where a malformed address from the bridge surfaces as DecodeError.

A one-line rendering of a Failure, for a banner. Match on the constructors for anything the app should actually DO about it.

type Msg
= WalletConnected (Maybe RequestId) String Int
| WalletConnectRejected RequestId
| WalletConnectPending RequestId
| WalletConnectFailed RequestId ConnectFailureReason String
| WalletDisconnected
| ChainChanged Int
| AccountChanged String
| WalletError Error
| WalletsDiscovered (List WalletProvider)
| ReadOnlyMode
| ChainAdded
| SwitchChainOk Int
| AssetWatched
| GotPermissions (List String)

Messages from the JS wallet port.

WalletConnected's Maybe RequestId is Nothing only for a silent, non-prompting reconnect on page load (no request was ever "in flight" to match against); every user-initiated connect carries Just the id passed to connect/selectWallet.

= RequestConnect RequestId
| RequestDisconnect
| RequestSwitchChain Int
| RequestSelectWallet RequestId String
| RequestAddChain ChainConfig
| RequestWatchAsset
{ address : Address
, symbol : String
, decimals : Int
, image : String
}
| RequestPermissions
| GetPermissions

Commands to send to JS via port.

type alias WalletProvider =
{ name : String
, icon : String
, rdns : String
}

A wallet provider discovered via EIP-6963.

type alias ChainConfig =
{ chainId : Int
, chainName : String
, rpcUrls : List String
, nativeCurrency :
{ name : String
, symbol : String
, decimals : Int
}
, blockExplorerUrls : List String
}

Configuration for adding a new chain to the wallet (EIP-3085).

Update wallet state from a port message.

startConnect : RequestId -> State -> State

Transition to Connecting requestId before sending the connect port command.

Call this when the user clicks the connect button -- mint a fresh RequestId (an incrementing counter you own) -- then send connect via the port:

( { model
    | wallet = Wallet.startConnect requestId model.wallet
    , nextConnectId = requestId + 1
  }
, web3Cmd (Wallet.encode (Wallet.connect requestId))
)

Valid transitions: Disconnected -> Connecting, Error _ -> Connecting, and -- deliberately -- Connecting _ -> Connecting with the NEW id. That last one is what lets overlapping attempts work elegantly: the user can click Connect again (or pick a different wallet mid-prompt) while one is already in flight, and the new attempt simply supersedes the old one. No app-side "already connecting, ignore this click" guard is needed -- update already drops any response tagged with a RequestId that no longer matches the CURRENT Connecting id, so a stale response from the superseded attempt (if it ever resolves at all) is safely a no-op. Connected/WrongChain/ ReadOnly remain no-ops here, same as before -- there's no "already connected, reconnect" use case this module needs to support.

timeoutConnect : RequestId -> State -> State

Time out a Connecting requestId back to Disconnected if it's still the active request -- a no-op if the request already resolved (into Connected, WrongChain, Error, or back to Disconnected) or has already been superseded by a newer one. Call this from a Process.sleep-armed timeout started alongside startConnect, mirroring this codebase's existing tx-timeout-watchdog pattern.

True while a connect attempt is in flight -- use this to drive a "Connecting..." spinner/disabled visual state. Not meant for gating the click handler itself: startConnect deliberately allows a fresh attempt to supersede one already in flight (see its doc comment), so the click handler should call startConnect unconditionally rather than checking this first.

connectingRequestId : State -> Maybe RequestId

The RequestId of the in-flight connect attempt, if any.

connect : RequestId -> WalletCmd

Command to request wallet connection. Pass the same RequestId given to startConnect so the eventual response can be matched back to this attempt.

Command to request wallet disconnection.

Command to switch chain.

selectWallet : RequestId -> String -> WalletCmd

Command to select a specific wallet by its RDNS identifier (EIP-6963). Pass the same RequestId given to startConnect.

selectWallet requestId "io.metamask"
addChain : ChainConfig -> WalletCmd

Request wallet_addEthereumChain (EIP-3085) to add a new network.

addChain
    { chainId = 369
    , chainName = "PulseChain"
    , rpcUrls = [ "https://rpc.pulsechain.com" ]
    , nativeCurrency = { name = "Pulse", symbol = "PLS", decimals = 18 }
    , blockExplorerUrls = [ "https://scan.pulsechain.com" ]
    }
{ address : Address
, symbol : String
, decimals : Int
, image : String
}

Command to add a token to the wallet UI (EIP-747).

Command to request wallet permissions (EIP-2255).

Command to get current wallet permissions (EIP-2255).

isConnected : State -> Bool

True if the wallet is in the Connected state.

isReadOnly : State -> Bool

True if in ReadOnly mode (rpcUrl present, no wallet). Reads work; writes will fail.

Extract the connected address, if any.

Extract the connected chain ID, if any.

encode : WalletCmd -> Value

Encode a WalletCmd for the JS port.

decoder : Decoder Msg

Decode a Msg from the JS wallet port.