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
_ ->
showConnectButtonEIP-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:
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.WalletsDiscovered providers arrives -> show picker if providers is non-empty.RequestId the same way, send
selectWallet requestId rdns via port.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.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.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 )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.
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.
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.
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.
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.
Commands to send to JS via port.
A wallet provider discovered via EIP-6963.
Configuration for adding a new chain to the wallet (EIP-3085).
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.
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.
The RequestId of the in-flight connect attempt, if any.
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.
Command to select a specific wallet by its RDNS identifier (EIP-6963).
Pass the same RequestId given to startConnect.
selectWallet requestId "io.metamask"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" ]
}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).
True if the wallet is in the Connected state.
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.
Wallet connection state. Every possible state is explicit.
Disconnected-- no wallet detected, no rpcUrl configuredReadOnly-- rpcUrl is present but no wallet; reads work, writes will failConnecting-- wallet connection in progressConnected-- wallet connected on the expected chainWrongChain-- wallet connected but on the wrong chainError-- unrecoverable error, carrying WHY (seeFailure)