• Blog
  • Talks
  • Investing
  • About

My first smart contract in Rust on Elrond VM

2020-07-17This post is over 2 years old and may now be out of date

(7 minute read)

Elrond - a new Proof-of-Stake blockchain with full state sharding - is soon launching its mainnet. In anticipation of this I decided to get down and dirty with the Elrond SDK and see what it was like building smart contracts in Rust.

My blockchain background is primarily Ethereum. I'm a Dapp developer (co-founded Kickback) and most recently I've written the contracts for Nayms. Thus, as it stands I've very familiar with Solidity and the Ethereum developer tooling ecosystem including Remix, Truffle, Web3, etc. I was interested in seeing how quickly I could transfer my knowledge over to a new blockchain, VM and programming language.

The current docs and tutorials recommend using the ElrondIDE extension for VSCode to build smart contracts. This gives you quick access to context menus for deploying your contracts as well as running tests locally. For the purposes of this article I will focus on doing things from the command-line as that's what I'm used to.

I'll go through the setup, step-by-step. For those who want to see the contract code and/or do it themselves, the repo is at https://github.com/hiddentao/elrond-voting-contract.

Setup erdpy

Make sure you have atleast Python 3.8 installed. It must be 3.8 or above, otherwise the tools won't work.

The SDK comes in the form of a tool called erdpy, written in Python. I installed it using the official installer script, erdpy-up:

$ wget https://raw.githubusercontent.com/ElrondNetwork/elrond-sdk/master/erdpy-up.py
$ python3 erdpy-up.py

One done, I had to close and re-open the shell. The following command then worked:

$ erdpy -v

This output:

erdpy 0.7.1

So far so good.

Setting up the contracts project

I didn't know what files or folder structure I needed to start writing contract, but it turns out you can create a new project based on an existing project template. I chose the adder Rust contract template:

$ erdpy contract new vote --template adder

This created a folder called vote which then contained the following files:

vote/
    debug/
        src/
            main.rs
        Cargo.toml
    interaction/
        adder.py
    src/
        lib.rs
    test/
        adder.scen.json
    Cargo.toml
    elrond.json
    snippets.sh

Admittedly I don't yet know what all of these different parts do (and I'm new to Rust), but from inspection I infered the following:

  • debug/ - for debugging contract code, step-by-step, I didn't end up using this
  • interaction/ - scripts for deploying and interacting with the contract on the testnet
  • src/ - the contract source code in Rust
  • test/ - unit/functional tests
  • Cargo.toml - the Rust equivalent of Node's package.json.
  • elrond.json - this just contained { "language": "rust" } so I presume it's for configuring erdpy parameters.
  • snippets.sh - this seemed to contain bash script snippets and functions. Not yet clear when this gets used.

Anyway, I stripped out what I didn't think I needed and kept the base minimum, so my project folder looked like this:

vote/
    src/
        lib.rs
    test/
        adder.scen.json
    Cargo.toml

To check that this worked I ran the command to build the contracts:

erdpy contract build vote

This seemed to work. It downloaded all the Rust dependencies, did the build and generated an output folder within the vote project which contained:

vote.hex    <--   not sure what this is for!
vote.wasm   <--   the compiled contract, ready to run on the VM

Note: Running the build command later on was a lot quicker since all the Rust dependencies had already been downloaded during the first invocation.

I was now ready to start coding.

Writing the contract

This is where things were a little more difficult at first. Since I've been building in Solidity for so long and had no previous Rust experience there was a learning curve involved.

The contract implements a one-time vote on two outcomes, utilizing commit-reveal voting. The flow is roughly as follows:

  1. Contract gets deployed, with the maximum no. of allowed voters set. Note that if this is number is even then it's technically possible for the final vote tally to be 50/50 in either direction, i.e. no majority.
  2. The commit phase can now begin.
  3. Users send their vote choices to the contract in an obfuscated format.
  4. Repeat 2 until the maximum possible no. of people have voted (this equals the value set during contract deployment). Until this limit is reached every user can keep changing their vote choice.
  5. One the limit is reached, the reveal phase begins.
  6. Each user sends their final un-obfuscated vote choice to the contract. The contract checks to ensure that the obfuscated version of this matches what the user last sent in during the commit phase. The user's vote only counts towards the final tally if there is a match. Note that users can perform this action once.
  7. Anyone can query the contract for the current vote tally at any point.

Here is the full contract code:

#![no_std]
#![no_main]
#![allow(non_snake_case)]
#![allow(unused_attributes)]

imports!();

#[elrond_wasm_derive::contract(VoteImpl)]
pub trait Vote {
    // max votes
    #[private]
    #[storage_set("maxVotes")]
    fn _setMaxVotes(&self, maxVotes: u16);

    #[storage_get("maxVotes")]
    fn getMaxVotes(&self) -> u16;

    // votes so far
    #[private]
    #[storage_set("voteCount")]
    fn _setVoteCount(&self, count: u16);

    #[storage_get("voteCount")]
    fn getVoteCount(&self) -> u16;

    // votes revealed so far
    #[private]
    #[storage_set("vote1Tally")]
    fn _setVote1Tally(&self, count: u16);

    #[storage_get("vote1Tally")]
    fn getVote1Tally(&self) -> u16;

    #[private]
    #[storage_set("vote0Tally")]
    fn _setVote0Tally(&self, count: u16);

    #[storage_get("vote0Tally")]
    fn getVote0Tally(&self) -> u16;

    // vote commitments
    #[private]
    #[storage_set("voteCommitment")]
    fn _setVoteCommitment(&self, voter: &Address, value: &H256);

    #[storage_get("voteCommitment")]
    fn getVoteCommitment(&self, voter: &Address) -> H256;

    // vote reveals
    #[private]
    #[storage_set("voteReveal")]
    fn _setVoteReveal(&self, voter: &Address, value: u8);

    #[storage_get("voteReveal")]
    fn getVoteReveal(&self, voter: &Address) -> u8;

    // constructor
    fn init(&self, maxVotes: u16) {
        self._setMaxVotes(maxVotes);
    }

    fn allVotesCast(&self) -> bool {
        let voteCount = self.getVoteCount();
        let maxVotes = self.getMaxVotes();
        voteCount == maxVotes
    }

    fn allVotesRevealed(&self) -> bool {
        let voteRevealCount = self.getVote1Tally() + self.getVote0Tally();
        let maxVotes = self.getMaxVotes();
        voteRevealCount == maxVotes
    }

    // commit vote
    fn commit(&self, value: &H256) -> Result<(), SCError> {
        // check that a vote can still be cast
        let allVotesCast = self.allVotesCast();
        if allVotesCast {
            return sc_error!("voting over");
        }

        let voter = self.get_caller();

        // inc. count
        let existing = self.getVoteCommitment(&voter);
        if &existing == &H256::zero() {
            self._setVoteCount(self.getVoteCount() + 1);
        }

        // save vote
        self._setVoteCommitment(&voter, &value);

        Ok(())
    }

    // reveal vote
    fn reveal(&self, vote: u8, salt: &H256) -> Result<(), SCError> {
        // check that all votes have been cast
        let allVotesCast = self.allVotesCast();
        if !allVotesCast {
            return sc_error!("voting not over")
        }

        let voter = self.get_caller();

        // check that caller has previously voted a commitment
        let voteCommitment = self.getVoteCommitment(&voter);
        if &voteCommitment == &H256::zero()  {
            return sc_error!("not a voter");
        }

        // check that caller has not already revealed their vote
        let voteReveal = self.getVoteReveal(&voter);
        if 0 < voteReveal {
            return sc_error!("already revealed");
        }

        // calculate expected commitment
        let mut raw_key: Vec<u8> = Vec::with_capacity(33);
        raw_key.push(vote);
        raw_key.extend_from_slice(salt.as_fixed_bytes());
        let key = self.keccak256(&raw_key);
        let expectedCommitment = H256::from_slice(&key);

        // check that it matches the stored commitment
        if &expectedCommitment != &voteCommitment {
            return sc_error!("vote mismatch");
        }

        // save the revealed vote
        self._setVoteReveal(&voter, vote);

        // inc tally
        if vote == 1 {
            self._setVote1Tally(self.getVote1Tally() + 1)
        } else {
            self._setVote0Tally(self.getVote0Tally() + 1)
        }

        Ok(())
    }
}

I'll go over some of the key parts of this, and in particular the parts which differ from coding in Solidity.

First of all, though there is overlap between fundamental datatypes between Rust and Solidity (u8 <-> uint8) there are some big differences, e.g the use of vectors with generics (Vec<8>). Rust grants us more powerful programming constructs. On the other hand, the Elrond VM doesn't yet support Strings, though I'm told this may get added in the near future.

The bunch of meta attributes at the top function similarly to Solidity's pragmas. The main difference is that Solidity was built for coding smart contracts whereas Rust wasn't. So we have to configure the Rust compiler in a certain way. For example ![no_main] tells the compiler not to expect a main() entrypoint function:

#![no_std]
#![no_main]
#![allow(non_snake_case)]
#![allow(unused_attributes)]

imports!();

Storage access is done via getters/setters decorated with specific attributes, e.g:

// save to storage
#[private]
#[storage_set("voteReveal")]
fn _setVoteReveal(&self, voter: &Address, value: u8);

// load from storage
#[storage_get("voteReveal")]
fn getVoteReveal(&self, voter: &Address) -> u8;

Note: I'm told by the Elrond team that there will be a more elegant and succinct way to write the above coming soon.

The part that calculates the hashed version of the vote in order to compare it to the stored commitment is interesting. In Solidity we would use keccak256(abi.encodePacked(...)), but here a bit more boilerplate is needed:

// calculate expected commitment
let mut raw_key: Vec<u8> = Vec::with_capacity(33);
raw_key.push(vote);
raw_key.extend_from_slice(salt.as_fixed_bytes());
let key = self.keccak256(&raw_key);
let expectedCommitment = H256::from_slice(&key);

Note: Thanks to the Elrond team for helping me clarify some of this code.

Overall my impression was that a lot more boilerplate code was needed compared to the equivalent code in Solidity. I hope that atleast some of it can be reduced and/or avoided in the future, though I also accept that some of it just due to how the Rust language works. In the end, I think the memory safety and comparatively more powerful programming constructs are probably worth it as a trade-off.

Testing

Writing unit tests for my contract was another interesting part of the experience just due to how different the test framework is to anything I've used before. Elrond have a declarative test framework called Mandos. You basically declare your tests as a JSON file. This is so that you can write tests in the same way, regardless of what smart contract language you are using.

I was initially skeptical as to how well this would work because I figured that a declarative test spec couldn't possibly enable as much power and control as an imperative one to the same level of succinctness. Plus, a declarative spec was really an abstraction over an imperative implementation, and the problem with abstractions is that at some point they "leak". However, the Elrond team assured me that they had written some huge integration tests for the official staking and delegation contracts using this and that its expressiveness had been sufficient in all of those cases.

I first wrote the setState test step to setup the blockchain, down to account balances as well as contract addresses. This is basically how you're able to deploy and test a contract declaratively - by declaring its address ahead of time:

{
    "step": "setState",
    "accounts": {
        "0xacc1000000000000000000000000000000000000000000000000000000000001": {
            "nonce": "1",
            "balance": "0x5000000",
            "storage": {},
            "code": ""
        },
        "0xacc1000000000000000000000000000000000000000000000000000000000002": {
            "nonce": "1",
            "balance": "0x5000000",
            "storage": {},
            "code": ""
        },
        "0xacc1000000000000000000000000000000000000000000000000000000000003": {
            "nonce": "1",
            "balance": "0x5000000",
            "storage": {},
            "code": ""
        }
    },
    "newAddresses": [
        {
            "creatorAddress": "0xacc1000000000000000000000000000000000000000000000000000000000001",
            "creatorNonce": "1",
            "newAddress": "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
        }
    ]
},

Given the above, my subsequent test steps needed to ensure the contract got deployed by user 0xacc1000000000000000000000000000000000000000000000000000000000001 and with a nonce of 1. I imagine that if one were to test a contract deploying another contract it would require a similar declaration as above, except that creatorAddress would be the address of a contract (this is something I hope to test soon!).

One thing I did like was that all contract function parameters are specified as strings, which makes things easy. Numbers can written in their full base-10 format, booleans are written as strings (e.g. "true"), and so on. For example, the following test step tests a user commiting their vote for position 1. The user sends in a hashed version of their vote whereby the hash = keccak256(vote choice, some random hex salt):

{
    "step": "scCall",
    "comment": "Person 1 votes for position 1",
    "tx": {
        "from": "0xacc1000000000000000000000000000000000000000000000000000000000001",
        "to": "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
        "value": "0",
        "function": "commit",
        "arguments": [
            "keccak256:1|0x9c22ff5f21f0b81b113e63f7db6da94fedef11b2119b4088b89664fb9a3cb658"
        ],
        "gasLimit": "0x100000",
        "gasPrice": "0x01"
    },
    ...
},

The keccak256:1|0x... argument is special - it tells Elrond to send in the keccak256 hash of the part after the :. The | is simply a concatenation operator. This was a really useful feature as it saved me from having to pre-calculate the needed argument value. The formatting docs indicate that you can even load and pass in the contents of a file if needed!

Testing the result of a transaction call was also easy. For example, when a user tries to reveal their vote but it mismatches their earlier commitment:

{
    "step": "scCall",
    "comment": "Person 1 tries to reveal with bad data - will fail",
    "tx": {
        "from": "0xacc1000000000000000000000000000000000000000000000000000000000001",
        "to": "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
        "value": "0",
        "function": "reveal",
        "arguments": [
            "0",
            "0x9c22ff5f21f0b81b113e63f7db6da94fedef11b2119b4088b89664fb9a3cb658"
        ],
        "gasLimit": "0x100000",
        "gasPrice": "0x01"
    },
    "expect": {
        "out": [],
        "status": "4",
        "message": "vote mismatch",
        "logs": [],
        "gas": "*",
        "refund": "*"
    }
},

The message attribute tests the error message that gets thrown from the code:

fn reveal(&self, vote: u8, salt: &H256) -> Result<(), SCError> {
    ...

    if &expectedCommitment != &voteCommitment {
            return sc_error!("vote mismatch");
    }

    ...
}

The out attribute tests what gets returned by the function call. In Mandos, there is no difference when declaring a call to a read-only function vs. one which requires sending a transaction. The difference is only apparent in the output post-condition of the step. If the out attribute contains values then it's a read-only call. If not then it's likely a transactional call.

Once I had all the tests written and placed in the test/ folder I was able to run them using:

$ erdpy contract test .

This command tells you which test is failing though it doesn't (yet) say why. To figure out why I had to run:

$ mandos-test <relative path to test file>

If a test step fails it outputs the transaction id as a way for you to identify exactly which step failed. This made me realize that the txId step attribute could be set to whatever you wanted, e.g:

{
    "step": "scCall",
    "comment": "Person 1 reveals with correct data - will pass",
    "txId": "1-reveal-pass",
    ...
}

All in all, I was able to write a full set of tests in Mandos without trouble. One thing I've realized is that because it's just JSON it would be very easy to dynamically generate test cases using a script.

Conclusion

Now that I've written my first contract I feel more comfortable coding in Rust and hope to do more. I'm particularly interesting in knowing how upgradeability works. Will there be a degegate call mechanism like in Solidity? To what extent is Rust supported? What are the limitations? The Elrond team have also expressed that at some point Typescript might be made available for Elrond as a smart contract language - it would be interesting to compare that to using Rust as I imagine it would be less verbose.

  • Home
  • Blog
  • Talks
  • Investing
  • About
  • Twitter
  • Github
  • Linked-in
  • Email
  • RSS
© Hiddentao Ltd