Elser is a domain-specific language (DSL) for smart-contract development. It emphasizes explicitness for critical and mutative operations and enforces a structured approach to smart-contract building. It natively compiles to Yul and uses solc
for optimization and final EVM bytecode generation.
ELSER stands for Explicit Lisp-like Smart-contract language with Enforced structure and Restrictness.
NOTE: Elser is in alpha and not yet suitable for production use.
- Java 8+ (required for Clojure)
- Clojure
- Leiningen
- Solidity 0.8.18+ (required to compile Yul to EVM bytecode)
## Clone the repo.
git clone https://github.com/elser-lang/elser
## Build the JAR
lein uberjar
## 1. Launch (elser -> Yul) REPL
java -jar target/uberjar/elser-0.0.4-alpha-standalone.jar
## 2. Check available commands.
java -jar target/uberjar/elser-0.0.4-alpha-standalone.jar --help
## 3. Compile elser program.
java -jar target/uberjar/elser-0.0.4-alpha-standalone.jar -c examples/counter.els
## 4. Generated bytecode & Yul will be saved in '/out' directory
ls out/
Syntax highlighting can be enabled in Emacs using elser-mode
.
Elser is a statically‑typed DSL designed around these core principles for safe, predictable smart‑contract development:
Contracts must be unambiguous. Elser enforces program structure at the language-level and disallows implicit behavior.
Every state‑modifying operation (e.g. storage reads/writes) and flow control construct must be spelled out in the source.
Every function is required to contain a storage-permissions attribute that specifies allowed storage operations.
Elser programs require fixed program layout:
- Namespace definition inside
(ns )
block (aka contract's name). - All storage variables are grouped inside
(storage ())
block. - All functions are grouped inside
(functions ())
block. - All constants are grouped inside
(constants ())
block. - All events are grouped inside
(events ())
block.
This structure makes it trivial to navigate code and integrate IDE features.
(ns <your-ns> (:pragma "0.8.30")) ;; Solc version
(constructor ())
(events (
(def Event_0 ((arg_0 :type) … (arg_n :type)))
))
(constants
(:external ( (def NAME_0 (-> (:type)) value) …)
:internal ( (def NAME_1 (-> (:type)) value) …]))
(storage
(:external ( (def var_0 (-> (:type))) …)
:internal ( (def var_1 (-> (:type))) …)))
(functions
(:external
( (defn x ((arg_0 [mut] :type) …) (@sto :w i :r j) (-> ((ret_0 [mut] :type))) (body …))
… )
:internal
( (defn y ((arg_1 [mut] :type) …) (@sto :w i :r j) (-> ((ret_1 [mut] :type))) (body …))
… )
))
:external
definitions become part of ABI.:internal
definitions are only visible/invokable within the namespace.
Every storage variable is put either in :external
variables list, or in :internal
variables list.
By default, all storage variables are mutable (it might be changed in the future: #20).
Definition syntax looks as follows:
(def owner (-> (:addr)))
(def balanceOf (-> (map :addr :u256)))
(def allowance (-> (map :addr (map :addr :u256))))
In Solidity, these variables would have been defined like this:
address owner;
mapping(address => uin256) balanceOf;
mapping(address => mapping(address => uint256)) allowance;
Every function is put either in :external
functions list, or in :internal
functions list.
Definition syntax looks as follows:
(defn transfer ((to :addr) (amt :u256)) (@sto :w 1 :r 1) (-> ((ok mut :bool))) (body))
(defn approve ((spender :addr) (amt :u256)) (@sto :w 1 :r 1) (-> ((ok mut :bool))) (body))
In Solidity these functions would have been defined like this:
function transfer(address to, uint256 amt) external returns (bool) {}
function approve(address spender, uint256 amt) external returns (bool) {}
Every function's parameter is immutable by default (e.g., to
and amt
can't be mutated), to mutate it inside the function, it's needed to add a special mut
attribute (amount mut :u256)
For instance, if transfer
function applies fee to the amount and overwrites it, we need to mark amt
as mutable
(defn transfer ((to :addr) (amt mut :u256)) (@sto :w 1 :r 1) (-> ((ok mut :bool)))
(...)
(set! amt (invoke! feeOnTransfer amt))
(...))
Storage-access attribute (@sto :w 0 :r 0)
can't be omitted, and should always explicitly specify allowed operations for the function:
(:w i)
- permission to write to storage.(:r j)
- permission to read from storage.
Where
Function
In other words,
Example of Multi-Level Permissions
It can be useful to limit access to certain critical functions withtin a namespace. For example, we can have a pausableWallet
namespace that will implement ERC20-storing wallet and will have pausable functionality.
We create two tiers of functionality:
1. Core operations (w ∈ {0,1}, r ∈ {0,1})
(defn withdraw ((token :addr) (amt :u256)) (@sto :w 1 :r 1) (-> ()) ...)
(defn getBalance ((token :addr)) (@sto :w 0 :r 1) (-> ((b mut :u256))) ...)
2. Critical operations (w = 2, r = 1)
Pushed to higher level of permissions:
(defn emergencyWithdraw ((token :addr)) @sto{:w 2 :r 1} (-> ()) ...)
(defn pause () (@sto :w 2 :r 1) (-> ()) ...)
Attempting to call a level‑2 function from a level‑0 or level‑1 function results in a compile‑time error:
;; > elser: invalid permissions: fn emergencyWithdraw | have {:r 1, :w 1} | want {:r 1, :w 2}
(defn withdraw ((token :addr)) (@sto :w 1 :r 1) (-> ())
(invoke! emergencyWithdraw token))
The multi-level privilege relations can be represented via such diagram:
flowchart TD
A(["emergencyWithdraw"]) <--> B(["pause"])
A --> n1(["withdraw"])
B --> n1
n1 --x B & A
style A fill:#C8E6C9
style B fill:#C8E6C9
style n1 fill:#BBDEFB
Return parameters are also immutable by default, and should be explicitly marked as mut
to map values to them. All functions should include return syntax, even if they don't return anything:
E.g., transfer
returns :bool
on success.
(defn transfer ((to :addr) (amt :u256)) (@sto :w 1 :r 1) (-> ((ok mut :bool)))
(...)
(-> ok true)) ;; map TRUE to `ok`
While withdraw
function from WETH
contract doesn't return anything, but still requires to specify "void" return.
(defn withdraw ((wad :u256)) (@sto :w 1 :r 1) (-> ()))
Functions will contain execution logic - they can interact with other blocks of a namespace [storage | constants | events]
and invoke function calls.
Function calls should be invoked via (invoke! functionName args)
function, therefore every function call inside Elser program can be tracked via invoke!
keywords.
Storage can be accessed via (sto read! var) | (sto write! var args)
function. Therefore, every storage interaction is easily trackable as well.
(functions
(:external
(
;; This function requires read access to storage, since it invokes `_checkOwner`
;; that read from storage.
;; And it requires write access, since invoked `_transferOwnership` will
;; write to `owner` storage variable.
(defn transferOwnership ((newOwner :addr)) (@sto :w 1 :r 1) (-> ())
(invoke! _checkOwner)
(assert (!= newOwner ADDRESS_ZERO))
(invoke! _transferOwnership newOwner))
)
:internal
(
;; Throws if the sender is not the owner.
(defn _checkOwner () (@sto :w 0 :r 1) (-> ())
(assert (= (caller) (sto read! owner))))
(defn _transferOwnership ((newOwner :addr)) (@sto :w 1 :r 1) (-> ())
(let (oldOwner (sto read! owner))
(sto write! owner newOwner)
(emit! OwnershipTransferred oldOwner newOwner)))
)))
Every constant is put either in :external
constants list, or in :internal
constants list.
Constants are defined just like storage variables, but they also require to have a value assigned to them (FYI, types aren't checked there yet: #9).
(def SUPPLY (-> (:u256)) 10000000000000000000000000000)
To access constant inside a function it's needed to refer it by its name:
(def setSupply () (@sto :w 1 :r 0) (-> ())
(sto write! totalSupply SUPPLY))
Events are stored in one set with all events, and defined as:
(def OwnershipTransferred ((prevOwner :addr) (newOwner :addr)))
Currently in generated Yul
code events are emitted as LOG0
, thus there are no indexed
parameters (#7).
There are only 256-bits types to restrict variables packing, and prevent casting stuff back-and-forth during code generation.
The list of currently supported types:
:u256
:bool
:addr
:b32
(map ...)
;; TODO:
:i256
(list [size] ...)
(struct :type_0 ... :type_n)
Running Elser without arguments will start REPL that provides read -> compile (to Yul) -> print
pipeline.
Let's create basic counter
contract.
An example Solidity program will look like this:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.26;
contract Counter {
uint256 public count;
// Function to get the current count
function get() public view returns (uint256) {
return count;
}
// Function to increment count by 1
function inc() public {
count += 1;
}
// Function to decrement count by 1
function dec() public {
// This function will fail if count = 0
count -= 1;
}
}
Firstly, we'll create a .els
file that will store all the code:
touch counter.els
Now, we'll define a namespace and will place storage variables inside storage
block:
(ns counter (:pragma "0.8.26"))
(storage
(:external
( (def count (-> (:u256))) ))) ; = uint256 public count;
Next, we'll store the main logic in functions:
(ns counter (:pragma "0.8.30"))
(storage
(:external
( (def count (-> (:u256))) ))) ; = uint256 public count;
(functions
;; All required functions are public in Solidity, so we put them in :external block.
(:external
( ;; This function needs to read storage so we set {:r 1}
(defn get () (@sto :w 0 :r 1) (-> ((c mut :u256))) ; function get() public view returns (uint256)
(-> c (sto read! count))) ; return count;
(defn inc () (@sto :w 1 :r 1) (-> ()) ; function inc() public
(let (c (sto read! count)) ; store `count` in memory.
(sto write! count (+ c 1)))) ; count += 1;
(defn dec () (@sto :w 1 :r 1) (-> ()) ; function dec() public
(let (c (sto read! count)) ; store `count` in memory.
(sto write! count (- c 1)))) ; count -= 1;
)))
Now, when we have everything we can compile counter.els
:
java -jar target/uberjar/elser-0.0.4-alpha-standalone.jar --compile counter.els
It will produce counter.yul & counter.bytecode
files in /out
folder.
The simplest way to interact with the compiled contract is to use Remix IDE.


