Skip to content

Smart-contract oriented language with emphasis on explicitness for critical and mutative operations and enforcement of a structured approach to smart-contract building.

License

Notifications You must be signed in to change notification settings

elser-lang/elser

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

83 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

License: GPL v3

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.

Table of Contents

Quickstart

Prerequisites

Getting Started

## 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.

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.

(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.

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: #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;

Functions

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) {}

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

(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})

(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
Loading

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.

(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) (-> ()))

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.

(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: #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

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).

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:

: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.

repl

Tutorial

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;
    }
}

1. Creating Elser program

Firstly, we'll create a .els file that will store all the code:

touch counter.els

2. Defining Namespace and Storage

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;

3. Defining Functions

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;
  )))

4. Compiling Contract

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.

5. Interacting with the Contract

5.1 Remix IDE

The simplest way to interact with the compiled contract is to use Remix IDE.

- Copy counter.yul inside /contracts folder
step0
- Set compiler version to the one specified in :pragma, and set language to Yul.
step1
- Compile and Deploy contract.
- You can interact with it via Low level interactions:
step2

About

Smart-contract oriented language with emphasis on explicitness for critical and mutative operations and enforcement of a structured approach to smart-contract building.

Topics

Resources

License

Stars

Watchers

Forks