intrepidshape/elm-web3-ui/2.1.1

NOTE: the latest version is 4.0.0

Web3.Ui.ApprovalFlow

The approve-then-act state machine — the most repeated flow in all of web3. Every ERC-20 interaction (deposit, stake, swap, lock…) is really two transactions gated by an allowance read, and every dapp hand-rolls the same fragile ladder. This is that ladder, once, with the guards proven by fuzz tests (ApprovalFlowTest) and modeled in proofs/tla/ApprovalSpec.tla.

┌──────────────────┐  insufficient   ┌────────────────┐
│ CheckingAllowance├────────────────▶│ ApprovalNeeded │
└───────┬──────────┘                 └───────┬────────┘
        │ sufficient                 startApprove
        ▼                                    ▼
┌──────────────┐   approve confirmed ┌────────────┐
│  ReadyToAct  │◀───(via re-check)───│  Approving │
└───────┬──────┘                     └────────────┘
   startAction
        ▼
┌──────────────┐    tx confirmed     ┌────────────┐
│    Acting    ├────────────────────▶│  Completed │
└──────────────┘                     └────────────┘

Design choices, deliberately:

  • A confirmed approval re-checks the allowance instead of optimistically assuming it. Non-standard tokens (USDT's approve-to-zero-first rule, fee-on-transfer tokens) make optimism a lie; the chain is the truth.
  • Wallet rejection is not failure. Rejecting the approve returns to ApprovalNeeded; rejecting the action returns to ReadyToAct — the user changed their mind, nothing is broken.
  • The two transactions cannot cross. ApproveTx messages are ignored while Acting, ActionTx messages while Approving — a late confirmation from one leg can never corrupt the other (the same no-cross-confusion rule elm-web3's SignSpec proves).

Wire-up sketch:

-- fire the allowance read, then:
ApprovalFlow.update { required = amountWei } (AllowanceLoaded current) step


-- user clicks approve:
( { model | step = ApprovalFlow.startApprove model.step }
, web3Cmd approveCall
)


-- port messages for the approve tx:
ApprovalFlow.update req (ApproveTx txMsg) step
type Step
= CheckingAllowance
| ApprovalNeeded
| Approving Status
| ReadyToAct
| Acting Status
| Completed Receipt
| Blocked String

Where the flow is. Approving/Acting carry the inner transaction lifecycle; Blocked carries the error that stopped the flow.

type Msg
= AllowanceLoaded BigInt
| AllowanceFailed String
| ApproveTx Msg
| ActionTx Msg
| Retry

Inputs to the machine. ApproveTx/ActionTx wrap the port's Tx.Msg for whichever leg it belongs to — route by your correlation id.

The flow begins by asking the chain what is already approved.

update : { required : BigInt } -> Msg -> Step -> Step

Advance the machine. required is the amount the action needs approved.

User clicked Approve: ApprovalNeeded -> Approving AwaitingSignature. No-op from every other step — this is the only door into Approving.

User clicked the action: ReadyToAct -> Acting AwaitingSignature. No-op from every other step — the only door into Acting, which is what makes "never act on an unverified allowance" a structural guarantee rather than a convention.

Leave a finished or blocked flow: Completed/Blocked -> CheckingAllowance (a fresh flow re-reads the chain). No-op elsewhere.

isTerminal : Step -> Bool

True in Completed — the flow finished; only reset leaves.

True when the user's next click should be Approve.

view : Config msg -> Step -> Html msg

A two-step control: numbered step indicator plus the one button that is valid right now. The root carries a state modifier so every step is independently styleable.

CSS classes: web3-approval, web3-approval--checking, --approve, --approving, --ready, --acting, --done, --blocked; web3-approval__steps, web3-approval__step, web3-approval__step--active, web3-approval__step--done, web3-approval__button, web3-approval__error.

type alias Config msg =
{ approveLabel : String
, actionLabel : String
, onApprove : msg
, onAction : msg
, onRetry : msg
}

Labels and handlers for the two-step control.