Skip to content
LogoLogo

Entrypoint dispatch

Solidity puts access control on each function with modifiers such as onlyOwner. That makes it easy to forget one. Lithe can group entrypoints by who may call them, write the check once for the group, and still produce an ordinary flat ABI.

A variant parameter becomes several ABI functions

When an external function takes a variant, the compiler exposes one ABI function per case, and does not expose the dispatcher itself:

import "std/evm";
 
type Action is
  | Deposit external deposit of (amount : uint)
  | Withdraw external withdraw of (amount : uint)
  | Reset external reset
 
error Insufficient of uint;
 
storage is record {
  balances : map(address, uint);
}
 
external function main(const action : Action)
  raises Insufficient
is
  case action of {
  | Deposit(amount) -> {
      storage.balances[Evm.sender()] := storage.balances[Evm.sender()] + amount;
    }
  | Withdraw(amount) -> {
      const have : uint := storage.balances[Evm.sender()];
      assert(have >= amount, Insufficient(have));
      storage.balances[Evm.sender()] := have - amount;
    }
  | Reset -> { storage.balances[Evm.sender()] := 0; }
  }

This contract's ABI is deposit(uint256 amount), withdraw(uint256 amount), and reset(). There is no main, and callers never encode a variant.

A function dispatches when its first parameter is a variant and its body is a case on it, after any consts and asserts. Any other external function takes a variant with no payloads as a uint8, and a variant with payloads not at all.

CaseABI function
namethe name after external: SetFee external setFee is setFee. A case without one is named as written, so SetFee alone is SetFee
no payloadno parameters
a single valueone parameter, named by the declaration: SetFee of (fee : uint) is setFee(uint256 fee), and SetFee of uint leaves it unnamed
a recordone parameter per field, with the field names
a tuple recordone tuple parameter, named like a single value, with the fields as its components

The ABI is read off the type declaration, never the function body, so a case's external name and its payload together are the entrypoint's signature: renaming the binding in | SetFee(f) -> changes nothing callers see.

case must cover every case, and each entrypoint's raises includes only what its own path can raise.

Groups and guards

Cases can carry other variants. A function that handles a group can run a guard before matching. The guard is a block placed before with case:

import "std/evm";
 
error NotOwner;
error Paused;
 
type OwnerAction is
  | SetFee external setFee of (fee : uint)
  | Pause external pause
 
type UserAction is
  | Deposit external deposit of (amount : uint)
 
type Action is
  | Owner of OwnerAction
  | User of UserAction
 
storage is record {
  owner : address;
  paused : bool;
  fee : uint;
  deposits : map(address, uint);
}
 
function ownerMain(const action : OwnerAction)
  raises NotOwner
is {
  assert(Evm.sender() = storage.owner, NotOwner);
}
with case action of {
| SetFee(fee) -> { storage.fee := fee; }
| Pause -> { storage.paused := true; }
}
 
function userMain(const action : UserAction)
  raises Paused
is {
  assert(not storage.paused, Paused);
}
with case action of {
| Deposit(amount) -> {
    storage.deposits[Evm.sender()] := storage.deposits[Evm.sender()] + amount;
  }
}
 
external function main(const action : Action)
  raises NotOwner | Paused
is
  case action of {
  | Owner(a) -> ownerMain(a)
  | User(a) -> userMain(a)
  }

The ABI is flat: setFee(uint256), pause(), and deposit(uint256). Each entrypoint runs the guards on its path through the tree. Here setFee checks the owner, deposit checks the pause flag, and setFee does not check the pause flag. To add an owner-only action, add a case to OwnerAction. It cannot be added without the guard.

Inspecting the tree

lithe abi-tree prints each flattened entrypoint with its path, guards, and effects:

setFee
  path:
    main -> ownerMain -> setFee

  calls:
    none

  guards:
    - Evm.sender() = storage.owner

  storage reads:
    - storage.owner

  storage writes:
    - storage.fee

  ...

  raises:
    - NotOwner()

Rules

  • Routing must be static. A dispatcher is a guard block plus a case, and it cannot choose a branch from storage or other runtime state.
  • Every flattened entrypoint must be unique. A case Deposit of uint and an external function deposit(const x : uint) collide as deposit(uint256). Distinct signatures whose 4-byte selectors are equal, such as transferFrom(address,address,uint256) and gasprice_bit_ether(int128), are rejected too, as are ones equal to a selector the compiler adds: supportsInterface(bytes4), proxiableUUID(), upgradeToAndCall(address,bytes), and the #drain(uint256) entry for hooks that defer calls.
  • Variant values exist only along a dispatch path. They cannot yet be constructed, stored, or returned.