Advance

 

Staking smart contract

Staking has become a popular way for blockchain users to earn rewards for participating in the network’s security and maintaining its decentralization. In this tutorial, we will learn how to build a staking smart contract on the MultiversX platform. By the end of this tutorial, you will have a solid understanding of the basics of staking and be able to build your own custom staking contract to deploy on the MultiversX blockchain.

This tutorial will guide you through the process of setting up a smart contract that allows users to stake their tokens and earn rewards based on the amount they have staked. You will also learn how to deploy and interact with the smart contract on the MultiversX network. Whether you are new to smart contract development or a seasoned veteran, this tutorial is a great way to expand your knowledge of staking and smart contract development on the MultiversX platform.

Introduction

This tutorial aims to teach you how to write a simple staking contract and to illustrate and correct the common pitfalls new smart contract developers might fall into.

Prerequisites

mxpy

First and foremost, you need to have mxpy installed:

If you already have mxpy installed, make sure to update it to the latest version, using the same instructions as for the installation.

We’re going to use mxpy for interacting with our contracts, so if you need more details about some of the steps we will perform, you can check here for more detailed explanations regarding what each command does:

Rust

Once you have mxpy installed, you also have to install Rust through it, and the VM tools for testing:

mxpy deps install rust
mxpy deps install vmtools --overwrite

If you installed Rust already without mxpy, you might run into some issues when building your smart contracts. It’s recommended to uninstall Rust and install it through mxpy instead.

Example of error:

error[E0554]: #!
  • may not be used on the stable release channel --> /home/user/multiversx-sdk/vendor-rust/registry/src/github.com-1ecc6299db9ec823/elrond-wasm-derive-0.33.0/src/lib.rs:4:12

    VSCode and rust-analyser extension

    VSCode: https://code.visualstudio.com/

    Assuming you’re on Ubuntu, download the .deb version. Go to that folder:

    • open the folder in terminal

    • run the following command: sudo dpkg -i downloaded_file_name

    rust-analyser: https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer

    MultiversX VSCode extension: https://marketplace.visualstudio.com/items?itemName=MultiversX.vscode-elrond-ide

    Both can be easily installed from the “Extensions” menu in VSCode.

    Creating the contract

    Run the following command in the folder in which you want your smart contract to be created:

    mxpy contract new staking-contract --template empty

    Open VSCode, select File -> Open Folder, and open the newly created staking-contract folder.

    You should then have the following structure:

    For now, comment on all the code in the ./tests/empty_rust_test.rs file (ctrl + “A”, then ctrl + “/”). Otherwise, it will keep popping up errors as we modify the contract’s code.

    Setting up the workspace

    Now, to have all the extensions work properly, we have to set up our workspace. This is done by pressing ctrl + shift + P and selecting the “MultiversX: Setup Workspace” option from the menu. Choose the “Yes” option on the pop-up menu.

    Now let’s open the MultiversX VSCode extension and try building our contract, to see if everything is properly set up. Go to the extension’s tab, right-click on “staking-contract” and select the “Build Contract” option:

    Alternatively, you can run mxpy --verbose contract build yourself from the VSCode terminal. The command should be run inside the staking-contract folder.

    After the building has been completed, our folder should look like this:

    A new folder, called output was created, which contains the compiled contract code. More on this is used later. For now, let’s continue.

    Your first lines of Rust

    Currently, we just have an empty contract. Not very useful, is it? So let’s add some simple code for it. Since this is a staking contract, we’d expect to have a stake function, right?

    First, remove all the code in the ./src/empty.rs file and replace it with this:

    #![no_std]
    
    multiversx_sc::imports!();
    
    #[multiversx_sc::contract]
    pub trait StakingContract {
        #[init]
        fn init(&self) {}
    
        #[payable("EGLD")]
        #[endpoint]
        fn stake(&self) {}
    }
    

    Since we want this function to be callable by users, we have to annotate it with #[endpoint]. Also, since we want to be able to receive a payment, we mark it also as #[payable("EGLD)]. For now, we’ll use EGLD as our staking token.

    NOTE : The contract does NOT need to be payable for it to receive payments on endpoint calls. The payable flag at contract level is only for receiving payments without endpoint invocation.

    Now, it’s time to add an implementation for the function. We need to see how much a user paid, and save their staking information in storage. We end up with this code:

    #![no_std]
    
    multiversx_sc::imports!();
    
    #[multiversx_sc::contract]
    pub trait StakingContract {
        #[init]
        fn init(&self) {}
    
        #[payable("EGLD")]
        #[endpoint]
        fn stake(&self) {
            let payment_amount = self.call_value().egld_value();
            require!(payment_amount > 0, "Must pay more than 0");
    
            let caller = self.blockchain().get_caller();
            self.staking_position(caller.clone()).set(&payment_amount);
            self.staked_addresses().insert(caller.clone());
        }
    
        #[view(getStakedAddresses)]
        #[storage_mapper("stakedAddresses")]
        fn staked_addresses(&self) -> UnorderedSetMapper<ManagedAddress>;
    
        #[view(getStakingPosition)]
        #[storage_mapper("stakingPosition")]
        fn staking_position(&self, addr: ManagedAddress) -> SingleValueMapper<BigUint>;
    }

    require! is a macro that is a shortcut for if !condition { signal_error(msg) }. Signalling an error will terminate the execution and revert any changes made to the internal state, including token transfers from and to the SC. In this case, there is no reason to continue if the user did not pay anything.

    We’ve also added #[view] annotation for the storage mappers, so we can later perform queries on those storage entries.

    Firstly, the last clone is not needed. If you clone variables all the time, then you need to take some time to read the Rust ownership chapter of the Rust book: and also about the implications of cloning types from the Rust framework:

    Secondly, the staking_position does not need an owned value of the addr argument. We can take a reference instead.

    And lastly, there’s a logic error. What happens if a user stakes twice? That’s right, their position will be overwritten with the newest value. So instead, we need to add the newest stake amount over their current amount, using the update method.

    After fixing the above problems, we end up with the following code:

    #![no_std]
    
    multiversx_sc::imports!();
    
    #[multiversx_sc::contract]
    pub trait StakingContract {
        #[init]
        fn init(&self) {}
    
        #[payable("EGLD")]
        #[endpoint]
        fn stake(&self) {
            let payment_amount = self.call_value().egld_value();
            require!(payment_amount > 0, "Must pay more than 0");
    
            let caller = self.blockchain().get_caller();
            self.staking_position(&caller)
                .update(|current_amount| *current_amount += payment_amount);
            self.staked_addresses().insert(caller);
        }
    
        #[view(getStakedAddresses)]
        #[storage_mapper("stakedAddresses")]
        fn staked_addresses(&self) -> UnorderedSetMapper<ManagedAddress>;
    
        #[view(getStakingPosition)]
        #[storage_mapper("stakingPosition")]
        fn staking_position(&self, addr: &ManagedAddress) -> SingleValueMapper<BigUint>;
    }

    Deploying the contract

    Now that we’ve created a wallet, it’s time to deploy our contract. Open your snippets.sh file, and add the following:

    USER_PEM="~/Downloads/tutorialKey.pem"
    PROXY="https://devnet-gateway.multiversx.com"
    CHAIN_ID="D"
    
    deploy() {
        mxpy --verbose contract deploy --project=${PROJECT} \
        --recall-nonce --pem=${USER_PEM} \
        --gas-limit=10000000 \
        --send --outfile="deploy-devnet.interaction.json" \
        --proxy=${PROXY} --chain=${CHAIN_ID} || return
    }

    The only thing you need to edit is the USER_PEM variable with the previously created PEM file’s path.

    To run this snippet, we’re going to use the MultiversX IDE extension again. Open the extension in VSCode from the left-hand menu, right-click on the contract name, and select the Run Contract Snippet option. This should open a menu at the top:

    For now, we only have one option, as we only have a single function in our file, but any bash function we write in the snippets.sh file will appear there. Now, select the deploy option, and let’s deploy the contract.

    The first stake

    Let’s add a snippet for the staking function:

    USER_PEM="~/Downloads/tutorialKey.pem"
    PROXY="https://devnet-gateway.multiversx.com"
    CHAIN_ID="D"
    
    SC_ADDRESS=erd1qqqqqqqqqqqqq...
    STAKE_AMOUNT=1
    
    deploy() {
        mxpy --verbose contract deploy --project=${PROJECT} \
        --recall-nonce --pem=${USER_PEM} \
        --gas-limit=10000000 \
        --send --outfile="deploy-devnet.interaction.json" \
        --proxy=${PROXY} --chain=${CHAIN_ID} || return
    }
    
    stake() {
        mxpy --verbose contract call ${SC_ADDRESS} \
        --proxy=${PROXY} --chain=${CHAIN_ID} \
        --send --recall-nonce --pem=${USER_PEM} \
        --gas-limit=10000000 \
        --value=${STAKE_AMOUNT} \
        --function="stake"
    }

    To pay EGLD, the --value argument is used, and, as you can guess, the --function argument is used to select which endpoint we want to call.

    We’ve now successfully staked 1 EGLD… or have we? If we look at the transaction, that’s not quite the case:

    I sent 1 EGLD to the SC, but instead 0.000000000000000001 EGLD got sent?

    This is because EGLD has 18 decimals. So to send 1 EGLD, you actually have to send a value equal to 1000000000000000000 (i.e. 1 * 10^18). The blockchain only works with unsigned numbers. Floating point numbers are not allowed. The only reason the explorer displays the balances with a floating point is because it’s much more user-friendly to tell someone they have 1 EGLD instead of 1000000000000000000 EGLD, but internally, only the integer value is used.

    But how do I send 0.5 EGLD to the SC?

    Since we know EGLD has 18 decimals, we have to simply multiply 0.5 by 10^18, which yields 500000000000000000.

    Actually staking 1 EGLD

    To do this, we simply have to update our STAKE_AMOUNT variable in the snippet. This should be: STAKE_AMOUNT=1000000000000000000.

    Now let’s try staking again:

    Querying the view functions

    To perform smart contract queries, we also use mxpy. Let’s add the following to our snippet file:

    USER_ADDRESS=erd1...
    
    getStakeForAddress() {
        mxpy --verbose contract query ${SC_ADDRESS} \
        --proxy=${PROXY} \
        --function="getStakingPosition" \
        --arguments ${USER_ADDRESS}
    }

    Replace USER_ADDRESS value with your address. Now let’s see our staking amount, according to the SC’s internal state:

    getStakeForAddress
    [
        {
            "base64": "DeC2s6dkAAE=",
            "hex": "0de0b6b3a7640001",
            "number": 1000000000000000001
        }
    ]

    We get the expected amount, 1 EGLD, plus the initial 10^-18 EGLD we sent.

    Now let’s also query the stakers list:

    getAllStakers() {
        mxpy --verbose contract query ${SC_ADDRESS} \
        --proxy=${PROXY} \
        --function="getStakedAddresses"
    }

    Running this function should yield a result like this:

    getAllStakers
    [
        {
            "base64": "nKGLvsPooKhq/R30cdiu1SRbQysprPITCnvi04n0cR0=",
            "hex": "9ca18bbec3e8a0a86afd1df471d8aed5245b432b29acf2130a7be2d389f4711d",
            "number": 70846231242182541417246304875524977991498122361356467219989042906898688667933
        }
    ]

    ..but what’s this value? If we try to convert 9ca18bbec3e8a0a86afd1df471d8aed5245b432b29acf2130a7be2d389f4711d to ASCII, we get gibberish. So what happened to our pretty erd1 address?

    Converting erd1 addresses to hex

    The smart contracts never work with the erd1 address format, but rather with the hex format. This is NOT an ASCII to hex conversion. This is a bech32 to ASCII conversion.

    But then, why did the previous query work?

    getStakeForAddress() {
        mxpy --verbose contract query ${SC_ADDRESS} \
        --proxy=${PROXY} \
        --function="getStakingPosition" \
        --arguments ${USER_ADDRESS}
    }

    This is because mxpy automatically detected and converted the erd1 address to hex. To perform those conversions yourself, you can also use mxpy:

    bech32 to hex

    mxpy wallet bech32 --decode erd1...

    In the previous example, we used the address: erd1njsch0krazs2s6harh68rk9w65j9kset9xk0yyc2003d8z05wywsmmnn76

    Now let’s try and decode this with mxpy:

    mxpy wallet bech32 --decode erd1njsch0krazs2s6harh68rk9w65j9kset9xk0yyc2003d8z05wywsmmnn76
    9ca18bbec3e8a0a86afd1df471d8aed5245b432b29acf2130a7be2d389f4711d

    Which is precisely the value we received from the smart contract. Now let’s try it the other way around.

    hex to bech32

    mxpy wallet bech32 --encode hex_address

    Running the command with the previous example, we should get the same initial address:

    mxpy wallet bech32 --encode 9ca18bbec3e8a0a86afd1df471d8aed5245b432b29acf2130a7be2d389f4711d
    erd1njsch0krazs2s6harh68rk9w65j9kset9xk0yyc2003d8z05wywsmmnn76

    Adding unstake functionality

    For now, users can only stake, but they cannot actually get their EGLD back… at all. Let’s add the unstake endpoint in our SC:

    #[endpoint]
    fn unstake(&self) {
        let caller = self.blockchain().get_caller();
        let stake_mapper = self.staking_position(&caller);
    
        let caller_stake = stake_mapper.get();
        if caller_stake == 0 {
            return;
        }
    
        self.staked_addresses().swap_remove(&caller);
        stake_mapper.clear();
    
        self.send().direct_egld(&caller, &caller_stake);
    }

    You might notice the variable stake_mapper. Just to remind you, the mapper’s definition looks like this:

    #[storage_mapper("stakingPosition")]
    fn staking_position(&self, addr: &ManagedAddress) -> SingleValueMapper<BigUint>;

    In pure Rust terms, this is a method of our contract trait, with one argument, that returns a SingleValueMapper<BigUint>. All mappers are nothing more than struct types that provide an interface to the storage API.

    So then, why save the mapper in a variable?

    Better usage of storage mapper types

    Each time you access self.staking_position(&addr), the storage key has to be constructed again, by concatenating the static string stakingPosition with the given addr argument. The mapper saves its key internally, so if we reuse the same mapper, the key is only constructed once.

    This saves us the following operations:

    let mut key = ManagedBuffer::new_from_bytes(b"stakingPosition");
    key.append(addr.as_managed_buffer());

    Instead, we just reuse the key we built previously. This can be a great performance enhancement, especially for mappers with multiple arguments. For mappers with no arguments, the improvement is minimal, but might still be worth thinking about.

    Partial unstake

    Some users might only want to unstake a part of their tokens, so we could simply add an unstake_amount argument:

    #[endpoint]
    fn unstake(&self, unstake_amount: BigUint) {
        let caller = self.blockchain().get_caller();
        let remaining_stake = self.staking_position(&caller).update(|staked_amount| {
            require!(
                unstake_amount > 0 && unstake_amount <= *staked_amount,
                "Invalid unstake amount"
            );
            *staked_amount -= &unstake_amount;
    
            staked_amount.clone()
        });
        if remaining_stake == 0 {
            self.staked_addresses().swap_remove(&caller);
        }
    
        self.send().direct_egld(&caller, &unstake_amount);
    }

    As you might notice, the code changed quite a bit. We also need to account for invalid user input, so we add a require! statement. Additionally, since we no longer need to simply “clear” the storage, we use the update method, which allows us to change the currently stored value through a mutable reference.

    update is the same as doing get, followed by computation, and then set, but it’s just a lot more compact. Additionally, it also allows us to return anything we want from the given closure, so we use that to detect if this was a full unstake.

    pub fn update<R, F: FnOnce(&mut T) -> R>(&self, f: F) -> R {
        let mut value = self.get();
        let result = f(&mut value);
        self.set(value);
        result
    }

    Optional arguments

    For a bit of performance enhancement, we could have the unstake_amount as an optional argument, with the default being full unstake.

    #[endpoint]
    fn unstake(&self, opt_unstake_amount: OptionalValue<BigUint>) {
        let caller = self.blockchain().get_caller();
        let stake_mapper = self.staking_position(&caller);
        let unstake_amount = match opt_unstake_amount {
            OptionalValue::Some(amt) => amt,
            OptionalValue::None => stake_mapper.get(),
        };
    
        let remaining_stake = stake_mapper.update(|staked_amount| {
            require!(
                unstake_amount > 0 && unstake_amount <= *staked_amount,
                "Invalid unstake amount"
            );
            *staked_amount -= &unstake_amount;
    
            staked_amount.clone()
        });
        if remaining_stake == 0 {
            self.staked_addresses().swap_remove(&caller);
        }
    
        self.send().direct_egld(&caller, &unstake_amount);
    }

    This makes it so if someone wants to perform a full unstake, they can simply not give the argument at all.

    Unstaking our devnet tokens

    Now that we’ve added the unstake function, let’s test it out on devnet. Build your SC again through the MultiversX IDE extension or mxpy directly, and add the unstake function to our snippets.rs file:

    UNSTAKE_AMOUNT=500000000000000000
    
    unstake() {
        mxpy --verbose contract call ${SC_ADDRESS} \
        --proxy=${PROXY} --chain=${CHAIN_ID} \
        --send --recall-nonce --pem=${USER_PEM} \
        --gas-limit=10000000 \
        --function="unstake" \
        --arguments ${UNSTAKE_AMOUNT}
    }

    Now run this function, and you’ll get this result:

    …but why? We just added the function! Well, we might’ve added it to our code, but the contract on the devnet still has our old code. So, how do we upload our new code?

    Upgrading smart contracts

    Since we’ve added some new functionality, we also want to update the currently deployed implementation. Add the upgrade snippet to your snippets.sh and run it:

    upgrade() {
        mxpy --verbose contract upgrade ${SC_ADDRESS} \
        --project=${PROJECT} \
        --recall-nonce --pem=${USER_PEM} \
        --gas-limit=20000000 \
        --send --outfile="upgrade-devnet.interaction.json" \
        --proxy=${PROXY} --chain=${CHAIN_ID} || return
    }

    Try unstaking again

    Try running the unstake snippet again. This time, it should work just fine. Afterwards, let’s query our staked amount through getStakeForAddress, to see if it updated our amount properly:

    getStakeForAddress
    [
        {
            "base64": "BvBbWdOyAAE=",
            "hex": "06f05b59d3b20001",
            "number": 500000000000000001
        }
    ]

    We had 1 EGLD, and we’ve unstaked 0.5 EGLD. Now we have 0.5 EGLD staked. (with the extra 1 fraction of EGLD we’ve staked initially).

    Unstake with no arguments

    Let’s also test the optional argument functionality. Remove the --arguments line from the snippet, and run it again.

    unstake() {
        mxpy --verbose contract call ${SC_ADDRESS} \
        --proxy=${PROXY} --chain=${CHAIN_ID} \
        --send --recall-nonce --pem=${USER_PEM} \
        --gas-limit=10000000 \
        --function="unstake"
    }

    Let’s also query getStakeForAddress and getAllStakers afterwards to see if the state was cleaned up properly:

    getStakeForAddress
    [
        ""
    ]
    getAllStakers
    []

    As you can see, we get an empty result (which means the value 0), and an empty array respectively.

    By this, you complete this workshop successfully!!