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