https://github.com/elser-lang/elser
Smart-contract oriented language with emphasis on explicitness for critical and mutative operations and enforcement of a structured approach to smart-contract building.
https://github.com/elser-lang/elser
blockchain clojure dsl ethereum evm language lisp programming-language smart-contracts solidity vyper
Last synced: 3 months ago
JSON representation
Smart-contract oriented language with emphasis on explicitness for critical and mutative operations and enforcement of a structured approach to smart-contract building.
- Host: GitHub
- URL: https://github.com/elser-lang/elser
- Owner: elser-lang
- License: gpl-3.0
- Created: 2025-06-17T14:08:04.000Z (7 months ago)
- Default Branch: main
- Last Pushed: 2025-09-22T12:54:41.000Z (3 months ago)
- Last Synced: 2025-09-22T14:34:10.955Z (3 months ago)
- Topics: blockchain, clojure, dsl, ethereum, evm, language, lisp, programming-language, smart-contracts, solidity, vyper
- Language: Clojure
- Homepage:
- Size: 210 KB
- Stars: 4
- Watchers: 2
- Forks: 0
- Open Issues: 30
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
[](https://www.gnu.org/licenses/gpl-3.0)
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 ***E**xplicit **L**isp-like **S**mart-contract language with **E**nforced structure and **R**estrictness*.
> **NOTE:** Elser is in alpha and not yet suitable for production use.
# Table of Contents
- [Quickstart](#quickstart)
- [Background](#background-and-motivation)
- [Program Layout](#program-layout)
- [Storage Variables](#storage-variables)
- [Functions](#functions)
- [Constants](#constants)
- [Events](#events)
- [Types](#types)
- [REPL](#repl)
- [Tutorial](#tutorial)
## Quickstart
### Prerequisites
- [Java 8+](https://www.java.com/en/download/manual.jsp) (required for Clojure)
- [Clojure](https://clojure.org/guides/install_clojure)
- [Leiningen](https://leiningen.org/#install)
- [Solidity 0.8.18+](https://docs.soliditylang.org/en/latest/installing-solidity.html) (required to compile Yul to EVM bytecode)
### Getting Started
```sh
## 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`**](https://github.com/elser-lang/elser-mode).
## Background and Motivation
Elser is a statically‑typed DSL designed around these core principles for safe, predictable smart‑contract development:
### Restrictive & Uniform Syntax
Contracts must be unambiguous. Elser enforces program structure at the language-level and disallows implicit behavior.
### Explicit Mutations & Control Flow
Every state‑modifying operation (e.g. storage reads/writes) and flow control construct must be spelled out in the source.
### Explicit Permissions for Storage Access
Every function is required to contain a storage-permissions attribute that specifies allowed storage operations.
## Program Layout
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.
```clj
(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.
### Storage Variables
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: https://github.com/elser-lang/elser/issues/20).
Definition syntax looks as follows:
```clj
(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:
```solidity
address owner;
mapping(address => uin256) balanceOf;
mapping(address => mapping(address => uint256)) allowance;
```
-------
### Functions
Every function is put either in `:external` functions list, or in `:internal` functions list.
Definition syntax looks as follows:
```clj
(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:
```solidity
function transfer(address to, uint256 amt) external returns (bool) {}
function approve(address spender, uint256 amt) external returns (bool) {}
```
#### Function Parameters
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
```clj
(defn transfer ((to :addr) (amt mut :u256)) (@sto :w 1 :r 1) (-> ((ok mut :bool)))
(...)
(set! amt (invoke! feeOnTransfer amt))
(...))
```
#### Function Permissions
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 $i,j \in$ {0,1,2,3}
Function $x$ can invoke function $y$
$$\iff x.w \geq y.w \land x.r \geq y.r$$
In other words, $x$ must have at least as much write and read‑privilege as $y$.
**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})**
```clj
(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:
```clj
(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:
```clj
;; > 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:
```mermaid
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
```
#### Function Returns
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.
```clj
(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.
```clj
(defn withdraw ((wad :u256)) (@sto :w 1 :r 1) (-> ()))
```
#### Function Body
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.
```clj
(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)))
)))
```
### Constants
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: https://github.com/elser-lang/elser/issues/9).
```clj
(def SUPPLY (-> (:u256)) 10000000000000000000000000000)
```
To access constant inside a function it's needed to refer it by its name:
```clj
(def setSupply () (@sto :w 1 :r 0) (-> ())
(sto write! totalSupply SUPPLY))
```
### Events
Events are stored in one set with all events, and defined as:
```clj
(def OwnershipTransferred ((prevOwner :addr) (newOwner :addr)))
```
Currently in generated `Yul` code events are emitted as `LOG0`, thus there are no `indexed` parameters (https://github.com/elser-lang/elser/issues/7).
### Types
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:
```clj
:u256
:bool
:addr
:b32
(map ...)
;; TODO:
:i256
(list [size] ...)
(struct :type_0 ... :type_n)
```
## REPL
Running Elser without arguments will start REPL that provides `read -> compile (to Yul) -> print` pipeline.

## Tutorial
Let's create basic `counter` contract.
[An example Solidity program will look like this](https://solidity-by-example.org/first-app/):
```solidity
// 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;
}
}
```
### 1. Creating Elser program
Firstly, we'll create a `.els` file that will store all the code:
```sh
touch counter.els
```
### 2. Defining Namespace and Storage
Now, we'll define a namespace and will place storage variables inside `storage` block:
```clj
(ns counter (:pragma "0.8.26"))
(storage
(:external
( (def count (-> (:u256))) ))) ; = uint256 public count;
```
### 3. Defining Functions
Next, we'll store the main logic in functions:
```clj
(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;
)))
```
### 4. Compiling Contract
Now, when we have everything we can compile `counter.els`:
```sh
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.
### 5. Interacting with the Contract
#### 5.1 Remix IDE
The simplest way to interact with the compiled contract is to use [Remix IDE](https://remix.ethereum.org/#optimize=true&version=soljson-v0.4.24+commit.e67f0147.js&lang=en&runs=200&evmVersion=null&language=Solidity).
##### - Copy `counter.yul` inside `/contracts` folder

##### - Set compiler version to the one specified in `:pragma`, and set language to `Yul`.

##### - Compile and Deploy contract.
##### - You can interact with it via `Low level interactions`: