Staking smart contract
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.
If you find anything not answered here, feel free to ask further questions on the MultiversX Developers Telegram channel: https://t.me/MultiversXDevelopers
Prerequisites
Before starting this tutorial, make sure you have the following:
stable
Rust version≥ 1.83.0
(install via rustup)sc-meta
(install multiversx-sc-meta)
For contract developers, we generally recommend VSCode with the following extensions:
Creating the contract
Run the following command in the folder in which you want your smart contract to be created:
sc-meta new --name staking-contract --template empty
Open VSCode, select File > Open Folder, and open the newly created staking-contract
folder.
Building the contract
In the terminal, run the following command to build the contract:
sc-meta all build
After the building has completed, our folder should look like this:
├── Cargo.lock
├── Cargo.toml
├── meta
│ ├── Cargo.toml
│ └── src
├── multiversx.json
├── output
│ ├── staking-contract.abi.json
│ ├── staking-contract.imports.json
│ ├── staking-contract.mxsc.json
│ └── staking-contract.wasm
├── scenarios
│ └── staking_contract.scen.json
├── src
│ └── staking_contract.rs
├── target
│ ├── CACHEDIR.TAG
│ ├── debug
│ ├── release
│ ├── tmp
│ └── wasm32-unknown-unknown
├── tests
│ ├── staking_contract_scenario_go_test.rs
│ └── staking_contract_scenario_rs_test.rs
└── wasm
├── Cargo.lock
├── Cargo.toml
└── src
A new folder, called output
was created, which contains the compiled contract code. More on this is used later.
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 would expect to have a stake
function, right?
First, remove all the code in the staking-contract/src/staking_contract.rs
file and replace it with this:
#![no_std]
use multiversx_sc::imports::*;
#[multiversx_sc::contract]
pub trait StakingContract {
#[init]
fn init(&self) {}
#[upgrade]
fn upgrade(&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.
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.
We need to see how much a user paid, and save their staking information in storage.
#![no_std]
use multiversx_sc::imports::*;
#[multiversx_sc::contract]
pub trait StakingContract {
#[init]
fn init(&self) {}
#[upgrade]
fn upgrade(&self) {}
#[payable("EGLD")]
#[endpoint]
fn stake(&self) {
let payment_amount = self.call_value().egld().clone();
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 smart contract. 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, allowing us to later perform queries on those storage entries. You can read more about annotations here.
If you're confused about some of the functions used or the storage mappers, you can read more here:
Now, there is intentionally written some bad code here. Can you spot any improvements we could make?
-
The last
clone()
fromstake()
function is unnecessary. If you're cloning variables all the time,s then you need to take some time to read the ownership chapter of the Rust book and also about the implications of cloning types from the Rust framework. -
The
staking_position
does not need an owned value of theaddr
argument. We can pass a reference instead. -
There is a logic error: what happens if an user stakes twice? Their position will be overwritten with the new value. Instead, we should add the new stake amount to their existing amount, using the
update
method.
After fixing these issues, we end up with the following code:
#![no_std]
use multiversx_sc::imports::*;
#[multiversx_sc::contract]
pub trait StakingContract {
#[init]
fn init(&self) {}
#[upgrade]
fn upgrade(&self) {}
#[payable("EGLD")]
#[endpoint]
fn stake(&self) {
let payment_amount = self.call_value().egld().clone();
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>;
}
What's with the empty init and upgrade function?
Every smart contract must include a function annotated with #[init]
and another with #[upgrade]
.
The init()
function is called when the contract is first deployed, while upgrade()
is triggered during an upgrade. For now, we need no logic inside it, but we still need to have those functions.
Creating a wallet
You can skip this section if you already have a Devnet wallet setup.
Open the terminal and run the following commands:
mkdir -p ~/MyTestWallets
sc-meta wallet new --format pem --outfile ~/MyTestWallets/tutorialKey.pem
Deploy the contract
Now that we've created a wallet, it's time to deploy our contract.
Make sure you build the contract before deploying it. Open the terminal and run the following command from the contract root directory:
sc-meta all build
Once the contract is built, generate the interactor:
sc-meta all snippets
Add the interactor to your project. In staking-contract/Cargo.toml
, add interactor
as a member of the workspace:
[workspace]
members = [
".",
"meta",
"interactor" # <- new member added
]
Next, update the wallet path for sending transactions. In staking-contract/interactor/src/interact.rs
locate the function new(config: Config)
and modify the wallet_address
variable with the absolute path to your wallet:
let wallet_address = interactor
.register_wallet(
Wallet::from_pem_file("/MyTestWallets/tutorialKey.pem").expect(
"Unable to load wallet. Please ensure the file exists.",
),
)
.await;
Finally, deploy the contract to Devnet:
cd interactor/
cargo run deploy
To use Testnet instead, update gateway_uri
in staking-contract/interactor/config.toml
to: https://testnet-gateway.multiversx.com
.
For mainnet, use: https://gateway.multiversx.com
.
More details can be found here.
You're going to see an error like the following:
error sending tx (possible API failure): transaction generation failed: insufficient funds for address erd1...
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
This is because your account has no EGLD in it.
Getting EGLD on Devnet
There are many ways of getting EGLD on Devnet:
- Through the Devnet wallet;
- Using an external faucet;
- From the MultiversX Builders Discord Server faucet;
- By asking a team member on Telegram.
Getting EGLD through devnet wallet
Go to Devnet Wallet and login to your account using your PEM file. From the left side menu, select Faucet:
Request the tokens. After a few seconds you should have 5 xEGLD in your wallet.
Getting EGLD through external faucet
Go to https://r3d4.fr/faucet and submit a request:
Make sure you selected Devnet
and input your address!
It might take a little while, depending on how "busy" the faucet is.
Deploying the contract, second try
Run the deploy
command again and let's see the results:
sender's recalled nonce: 0
-- tx nonce: 0
sc deploy tx hash: 8a007...
deploy address: erd1qqqqqqqqqqqqq...
new address: erd1qqqqqqqqqqqqq...
Alternatively, you can check the address in the logs tab on Devnet Explorer, namely the SCDeploy
method.
Too much gas error?
Everything should work just fine, but you'll see this message:
This is not an error. This simply means you provided way more gas than needed, so all the gas was consumed instead of the leftover being returned to you.
This is done to protect the network against certain attacks. For instance, one could always provide the maximum gas limit and only use very little, decreasing the network's throughput significantly.
The first stake
Let's update the stake
function from staking-contract/interactor/src/interact.rs
to do the first stake.
Initialize the egld_amount
variable with 1
instead of 0
:
let egld_amount = BigUint::<StaticApi>::from(1u128);
This variable represents the amount of EGLD to be sent with the transaction.
Let's stake! At path staking-contract/interactor
run the following command:
cargo run stake
We've now successfully staked 1 EGLD... or have we?
If we look at the transaction, that's not quite the case:
Why was a smaller amount of EGLD sent?
This happens because EGLD uses 18 decimals. So, to send 1 EGLD, you actually need to send the value 1000000000000000000
(i.e. 1018).
The blockchain works only with unsigned integers. 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?
Since we know EGLD has 18 decimals, we have to simply multiply 0.5 by 1018, which yields 500000000000000000.
Actually staking 1 EGLD
To stake 1 EGLD, simply update the egld_amount
in the stake
function from staking-contract/interactor/src/interact.rs
with the following:
let egld_amount = BigUint::<StaticApi>::from(1000000000000000000u128);
Now let's try staking again:
Performing contract queries
To perform smart contract queries for the getStakingPosition
view, update the addr
variable in the staking_position
function from staking-contract/interactor/src/interact.rs
with the address of the wallet you used for the staking transaction:
let addr = bech32::decode("erd1vx...");
Query the staking position by running the following command in the terminal, inside the staking-contract/interactor
directory:
cargo run getStakingPosition
This will show the staking amount according to the smart contract's internal state:
Result: 1000000000000000001
The result includes 1 EGLD plus the initial 10-18 EGLD that was sent.
Next, query the stakers list. Replace the next line from staked_addresses
function in staking-contract/interactor/src/interact.rs
:
println!("Result: {result_value:?}");
with the following:
for result in result_value.iter() {
println!("Result: {}", Bech32Address::from(result).to_bech32_string());
}
It is necessary to iterate through result_value
because it is a MultiValueVec
of Address
. Each address is converted to Bech32Address
to ensure it’s printed in Bech32 format, not as raw ASCII.
Run in terminal at path staking-contract/interactor
:
cargo run getStakedAddresses
Running this function should yield a result like this:
Result: erd1vx...
Adding unstake functionality
Currently, users can only stake, but they cannot actually get their EGLD back... at all. Let's add the unstake functionality in our smart contract:
#[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.tx().to(caller).egld(caller_stake).transfer();
}
You might notice the stake_mapper
variable. Just to remind you, the mapper's definition looks like this:
#[storage_mapper("stakingPosition")]
fn staking_position(&self, addr: &ManagedAddress) -> SingleValueMapper<BigUint>;
In Rust terms, this is a method of our contract trait, with one argument, that returns a SingleValueMapper<BigUint>
. All mappers are nothing more than structure 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.tx().to(caller).egld(unstake_amount).transfer();
}
As you might notice, the code changed quite a bit:
- To handle invalid user input, we used the
require!
statement; - Previously, we used
clear
to reset the staking position. However, now that we need to modify the stored value, we use theupdate
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.
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.tx().to(caller).egld(unstake_amount).transfer();
}
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 the unstake function has been added, let's test it out on Devnet. Build the smart contract again. In the contract root (staking-contract/
), run the next command:
sc-meta all build
After building the contract, regenerate the interactor by running the following command in the terminal, also in the contract root:
sc-meta all snippets
Make sure wallet_address
stores the wallet that has to execute the transactions.
Let's unstake some EGLD! Replace variable opt_unstake_amount
from unstake
function in staking-contract/interactor/src/interact.rs
with:
let opt_unstake_amount = OptionalValue::Some(BigUint::<StaticApi>::from(500000000000000000u128));
Then, run in terminal at path staking-contract/interactor
:
cargo run unstake
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. Build the contract and then run the following command at path staking-contract/interactor
:
cargo run upgrade
All the storage is kept on upgrade, so make sure any storage changes you make to storage mapping are backwards compatible!
Try unstaking again
Run the unstake
snippet again. This time, it should work just fine. Afterwards, let's query our staked amount through getStakingPosition
, to see if it updated our amount properly.
Make sure that function staking_position
has the changes previously made.
cargo run getStakingPosition
Result: 500000000000000001
We had 1 EGLD, and we 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
Now let’s test how the contract behaves when no amount is explicitly provided. This will confirm that the OptionalValue::None
path works as expected.
In staking-contract/interactor/src/interact.rs
, update the unstake
function. Replace the opt_unstake_amount
variable with:
let opt_unstake_amount: OptionalValue<BigUint<StaticApi>> = OptionalValue::None;
And then unstake:
cargo run unstake
After unstaking, query stakingPosition
and stakedAddresses
to see if the state was cleaned up properly:
These should show that:
- Your staking position is now empty: 0 EGLD;
- Your address has been removed from the stakers list.
Writing tests
As you might've noticed, it can be quite a chore to keep upgrading the contract after every little change, especially if all we want to do is test a new feature. So, let's recap what we've done until now:
- deploy our contract
- stake
- partial unstake
- full unstake
A more detailed explanation of Rust tests can be found here.
Before developing the tests, you will have to generate the contract's proxy.
You will add to staking-contract/sc-config.toml
:
[[proxy]]
path = "tests/staking_contract_proxy.rs"
Then run in terminal at path staking-contract/
:
sc-meta all proxy
You’ll find the contract’s proxy in staking-contract/tests
, inside the file staking_contract_proxy.rs
. This proxy will be used to help us write and run tests for the smart contract.
To test the previously described scenario, we're going to need a user address, and a new test function.
Create file staking_contract_blackbox_test.rs
in staking-contract/tests
with the following:
mod staking_contract_proxy;
use multiversx_sc::{
imports::OptionalValue,
types::{TestAddress, TestSCAddress},
};
use multiversx_sc_scenario::imports::*;
const OWNER_ADDRESS: TestAddress = TestAddress::new("owner");
const STAKING_CONTRACT_ADDRESS: TestSCAddress = TestSCAddress::new("staking-contract");
const USER_ADDRESS: TestAddress = TestAddress::new("user");
const WASM_PATH: MxscPath = MxscPath::new("output/staking-contract.mxsc.json");
const USER_BALANCE: u64 = 1_000_000_000_000_000_000;
struct ContractSetup {
pub world: ScenarioWorld,
}
impl ContractSetup {
pub fn new() -> Self {
let mut world = ScenarioWorld::new();
world.set_current_dir_from_workspace("staking-contract");
world.register_contract(WASM_PATH, staking_contract::ContractBuilder);
world.account(OWNER_ADDRESS).nonce(1).balance(0);
world.account(USER_ADDRESS).nonce(1).balance(USER_BALANCE);
// simulate deploy
world
.tx()
.from(OWNER_ADDRESS)
.typed(staking_contract_proxy::StakingContractProxy)
.init()
.code(WASM_PATH)
.new_address(STAKING_CONTRACT_ADDRESS)
.run();
ContractSetup { world }
}
}
#[test]
fn stake_unstake_test() {
let mut setup = ContractSetup::new();
setup
.world
.check_account(USER_ADDRESS)
.balance(USER_BALANCE);
setup.world.check_account(OWNER_ADDRESS).balance(0);
// stake full
setup
.world
.tx()
.from(USER_ADDRESS)
.to(STAKING_CONTRACT_ADDRESS)
.typed(staking_contract_proxy::StakingContractProxy)
.stake()
.egld(USER_BALANCE)
.run();
setup
.world
.query()
.to(STAKING_CONTRACT_ADDRESS)
.typed(staking_contract_proxy::StakingContractProxy)
.staking_position(USER_ADDRESS)
.returns(ExpectValue(USER_BALANCE))
.run();
setup.world.check_account(USER_ADDRESS).balance(0);
setup
.world
.check_account(STAKING_CONTRACT_ADDRESS)
.balance(USER_BALANCE);
// unstake partial
setup
.world
.tx()
.from(USER_ADDRESS)
.to(STAKING_CONTRACT_ADDRESS)
.typed(staking_contract_proxy::StakingContractProxy)
.unstake(OptionalValue::Some(USER_BALANCE / 2))
.run();
setup
.world
.query()
.to(STAKING_CONTRACT_ADDRESS)
.typed(staking_contract_proxy::StakingContractProxy)
.staking_position(USER_ADDRESS)
.returns(ExpectValue(USER_BALANCE / 2))
.run();
setup
.world
.check_account(USER_ADDRESS)
.balance(USER_BALANCE / 2);
setup
.world
.check_account(STAKING_CONTRACT_ADDRESS)
.balance(USER_BALANCE / 2);
// unstake full
setup
.world
.tx()
.from(USER_ADDRESS)
.to(STAKING_CONTRACT_ADDRESS)
.typed(staking_contract_proxy::StakingContractProxy)
.unstake(OptionalValue::None::<u64>)
.run();
setup
.world
.query()
.to(STAKING_CONTRACT_ADDRESS)
.typed(staking_contract_proxy::StakingContractProxy)
.staking_position(USER_ADDRESS)
.returns(ExpectValue(0u8))
.run();
setup
.world
.check_account(USER_ADDRESS)
.balance(USER_BALANCE);
setup
.world
.check_account(STAKING_CONTRACT_ADDRESS)
.balance(0);
}
We've added a USER_ADDRESS
constant, which is initialized with USER_BALANCE
EGLD in their account.
For the test we're going to use small numbers for balances, since there is no reason to work with big numbers. For this test, we're using 1 EGLD for user balance.
Then, we've staked the user's entire balance, unstaked half, then unstaked fully. After each transaction, we've checked the smart contract's internal staking storage, and also the balance of the user and the smart contract respectively.
Running the test
To run a test, you can use click on the Run Test
button from under the test name.
There is also a Debug
button, which can be used to debug smart contracts. More details on that here.
Alternatively, you can run all the tests in the file by running the following command in the terminal, in the staking-contract/
folder:
sc-meta test
Staking Rewards
Right now, there is no incentive to stake EGLD in this smart contract. Let's say we want to give every staker 10% APY (Annual Percentage Yield). For example, if someone staked 100 EGLD, they will receive a total of 10 EGLD per year.
For this, we're also going to need to save the time at which each user staked. Also, we can't simply make each user wait one year to get their rewards. We need a more fine-tuned solution, so we're going to calculate rewards per block instead of per year.
You can also use rounds, timestamp, epochs etc. for time keeping in smart contracts, but number of blocks is the recommended approach.
User-defined struct types
A single BigUint
for each user is not enough anymore. As stated before, we need to also store the stake block, and we need to update this block number on every action.
So, we're going to use a struct:
pub struct StakingPosition<M: ManagedTypeApi> {
pub stake_amount: BigUint<M>,
pub last_action_block: u64,
}
Every managed type from the SpaceCraft needs a ManagedTypeApi
implementation, which allows it to access the VM functions for performing operations. For example, adding two BigUint
numbers, concatenating two ManagedBuffers
, etc. Inside smart contract code, the ManagedTypeApi
associated type is automatically added, but outside of it, we have to manually specify it.
Additionally, since we need to store this in storage, we need to tell the Rust framework how to encode and decode this type. This can be done automatically by deriving (i.e. auto-implementing) these traits, via the #[derive]
annotation:
use multiversx_sc::derive_imports::*;
#[type_abi]
#[derive(TopEncode, TopDecode, PartialEq, Debug)]
pub struct StakingPosition<M: ManagedTypeApi> {
pub stake_amount: BigUint<M>,
pub last_action_block: u64,
}
We've also added #[type_abi]
, since this is required for ABI generation. ABIs are used by decentralized applications and such to decode custom smart contract types, but this is out of scope of this tutorial.
Additionally, we've added PartialEq
and Debug
derives, for easier use within tests. This will not affect performance in any way, as the code for these is only used during testing/debugging. PartialEq
allows us to use ==
for comparing instances, while Debug
will pretty-print the struct, field by field, in case of errors.
If you want to learn more about how such a structure is encoded, and the difference between top and nested encoding/decoding, you can read more here.
Rewards formula
A block is produced about every 6 seconds, so total blocks in a year would be seconds in year, divided by 6:
pub const BLOCKS_IN_YEAR: u64 = 60 * 60 * 24 * 365 / 6;
More specifically: 60 seconds per minute x 60 minutes per hour x 24 hours per day x 365 days, divided by the 6-second block duration.
This is calculated and replaced with the exact value at compile time, so there is no performance penalty of having a constant with mathematical operations in its value definition.
Having defined this constant, rewards formula should look like this:
let reward_amt = apy / 100 * user_stake * blocks_since_last_claim / BLOCKS_IN_YEAR;
Using 10% as the APY, and assuming exactly one year has passed since last claim.
In this case, the reward should be calculated as: 10/100 * user_stake
, which is exactly 10% APY.
However, there is something wrong with the current formula. We will always get reward_amt
= 0.
BigUint division
BigUint division works the same as unsigned integer division. If you divide x
by y
, where x < y
, you will always get 0
as result. So in our previous example, 10/100 is NOT 0.1
, but 0
.
To fix this, we need to take care of our operation order:
let reward_amt = user_stake * apy / 100 * blocks_since_last_claim / BLOCKS_IN_YEAR;
How to express percentages like 50.45%?
In this case, we need to extend our precision by using fixed point precision. Instead of having 100
as the maximum percentage, we will extend it to 100_00
, and give 50.45%
as 50_45
. Updating our above formula results in this:
pub const MAX_PERCENTAGE: u64 = 100_00;
let reward_amt = user_stake * apy / MAX_PERCENTAGE * blocks_since_last_claim / BLOCKS_IN_YEAR;
For example, let's assume the user stake is 100, and 1 year has passed. Using 50_45
as APY value, the formula would become:
reward_amt = 100 * 50_45 / 100_00 = 5045_00 / 100_00 = 50
Since we're still using the BigUint division, we don't get 50.45
, but 50
. This precision can be increased by using more zeroes for the MAX_PERCENTAGE
and the respective APY, but this is also "inherently fixed" on the blockchain because we work with very big numbers for user_stake
.
Rewards functionality
Implementation
Now let's see how this would look in our Rust smart contract code. The smart contract looks like this after doing all the specified changes:
#![no_std]
use multiversx_sc::derive_imports::*;
use multiversx_sc::imports::*;
pub const BLOCKS_IN_YEAR: u64 = 60 * 60 * 24 * 365 / 6;
pub const MAX_PERCENTAGE: u64 = 10_000;
#[type_abi]
#[derive(TopEncode, TopDecode, PartialEq, Debug)]
pub struct StakingPosition<M: ManagedTypeApi> {
pub stake_amount: BigUint<M>,
pub last_action_block: u64,
}
#[multiversx_sc::contract]
pub trait StakingContract {
#[init]
fn init(&self, apy: u64) {
self.apy().set(apy);
}
#[upgrade]
fn upgrade(&self) {}
#[payable("EGLD")]
#[endpoint]
fn stake(&self) {
let payment_amount = self.call_value().egld().clone();
require!(payment_amount > 0, "Must pay more than 0");
let caller = self.blockchain().get_caller();
self.staking_position(&caller).update(|staking_pos| {
self.claim_rewards_for_user(&caller, staking_pos);
staking_pos.stake_amount += payment_amount
});
self.staked_addresses().insert(caller);
}
#[endpoint]
fn unstake(&self, opt_unstake_amount: OptionalValue<BigUint>) {
let caller = self.blockchain().get_caller();
let stake_mapper = self.staking_position(&caller);
let mut staking_pos = stake_mapper.get();
let unstake_amount = match opt_unstake_amount {
OptionalValue::Some(amt) => amt,
OptionalValue::None => staking_pos.stake_amount.clone(),
};
require!(
unstake_amount > 0 && unstake_amount <= staking_pos.stake_amount,
"Invalid unstake amount"
);
self.claim_rewards_for_user(&caller, &mut staking_pos);
staking_pos.stake_amount -= &unstake_amount;
if staking_pos.stake_amount > 0 {
stake_mapper.set(&staking_pos);
} else {
stake_mapper.clear();
self.staked_addresses().swap_remove(&caller);
}
self.tx().to(caller).egld(unstake_amount).transfer();
}
#[endpoint(claimRewards)]
fn claim_rewards(&self) {
let caller = self.blockchain().get_caller();
let stake_mapper = self.staking_position(&caller);
let mut staking_pos = stake_mapper.get();
self.claim_rewards_for_user(&caller, &mut staking_pos);
stake_mapper.set(&staking_pos);
}
fn claim_rewards_for_user(
&self,
user: &ManagedAddress,
staking_pos: &mut StakingPosition<Self::Api>,
) {
let reward_amount = self.calculate_rewards(staking_pos);
let current_block = self.blockchain().get_block_nonce();
staking_pos.last_action_block = current_block;
if reward_amount > 0 {
self.tx().to(user).egld(&reward_amount).transfer();
}
}
fn calculate_rewards(&self, staking_position: &StakingPosition<Self::Api>) -> BigUint {
let current_block = self.blockchain().get_block_nonce();
if current_block <= staking_position.last_action_block {
return BigUint::zero();
}
let apy = self.apy().get();
let block_diff = current_block - staking_position.last_action_block;
&staking_position.stake_amount * apy / MAX_PERCENTAGE * block_diff / BLOCKS_IN_YEAR
}
#[view(calculateRewardsForUser)]
fn calculate_rewards_for_user(&self, addr: ManagedAddress) -> BigUint {
let staking_pos = self.staking_position(&addr).get();
self.calculate_rewards(&staking_pos)
}
#[view(getStakedAddresses)]
#[storage_mapper("stakedAddresses")]
fn staked_addresses(&self) -> UnorderedSetMapper<ManagedAddress>;
#[view(getStakingPosition)]
#[storage_mapper("stakingPosition")]
fn staking_position(
&self,
addr: &ManagedAddress,
) -> SingleValueMapper<StakingPosition<Self::Api>>;
#[view(getApy)]
#[storage_mapper("apy")]
fn apy(&self) -> SingleValueMapper<u64>;
}
Now, rebuild the contract and regenerate the proxy:
sc-meta all build
sc-meta all proxy
Let's update our test, to use our new StakingPosition
structure, and also provide the APY
as an argument for the init
function.
mod staking_contract_proxy;
use multiversx_sc::{
imports::OptionalValue,
types::{BigUint, TestAddress, TestSCAddress},
};
use multiversx_sc_scenario::imports::*;
use staking_contract_proxy::StakingPosition;
const OWNER_ADDRESS: TestAddress = TestAddress::new("owner");
const USER_ADDRESS: TestAddress = TestAddress::new("user");
const STAKING_CONTRACT_ADDRESS: TestSCAddress = TestSCAddress::new("staking-contract");
const WASM_PATH: MxscPath = MxscPath::new("output/staking-contract.mxsc.json");
const USER_BALANCE: u64 = 1_000_000_000_000_000_000;
const APY: u64 = 10_00; // 10%
struct ContractSetup {
pub world: ScenarioWorld,
}
impl ContractSetup {
pub fn new() -> Self {
let mut world = ScenarioWorld::new();
world.set_current_dir_from_workspace("staking-contract");
world.register_contract(WASM_PATH, staking_contract::ContractBuilder);
world.account(OWNER_ADDRESS).nonce(1).balance(0);
world.account(USER_ADDRESS).nonce(1).balance(USER_BALANCE);
// simulate deploy
world
.tx()
.from(OWNER_ADDRESS)
.typed(staking_contract_proxy::StakingContractProxy)
.init(APY)
.code(WASM_PATH)
.new_address(STAKING_CONTRACT_ADDRESS)
.run();
ContractSetup { world }
}
}
#[test]
fn stake_unstake_test() {
let mut setup = ContractSetup::new();
setup
.world
.check_account(USER_ADDRESS)
.balance(USER_BALANCE);
setup.world.check_account(OWNER_ADDRESS).balance(0);
// stake full
setup
.world
.tx()
.from(USER_ADDRESS)
.to(STAKING_CONTRACT_ADDRESS)
.typed(staking_contract_proxy::StakingContractProxy)
.stake()
.egld(USER_BALANCE)
.run();
let expected_result_1 = StakingPosition {
stake_amount: BigUint::from(USER_BALANCE),
last_action_block: 0,
};
setup
.world
.query()
.to(STAKING_CONTRACT_ADDRESS)
.typed(staking_contract_proxy::StakingContractProxy)
.staking_position(USER_ADDRESS)
.returns(ExpectValue(expected_result_1))
.run();
setup.world.check_account(USER_ADDRESS).balance(0);
setup
.world
.check_account(STAKING_CONTRACT_ADDRESS)
.balance(USER_BALANCE);
// unstake partial
setup
.world
.tx()
.from(USER_ADDRESS)
.to(STAKING_CONTRACT_ADDRESS)
.typed(staking_contract_proxy::StakingContractProxy)
.unstake(OptionalValue::Some(USER_BALANCE / 2))
.run();
let expected_result_2 = StakingPosition {
stake_amount: BigUint::from(USER_BALANCE / 2),
last_action_block: 0,
};
setup
.world
.query()
.to(STAKING_CONTRACT_ADDRESS)
.typed(staking_contract_proxy::StakingContractProxy)
.staking_position(USER_ADDRESS)
.returns(ExpectValue(expected_result_2))
.run();
let staked_addresses_1 = setup
.world
.query()
.to(STAKING_CONTRACT_ADDRESS)
.typed(staking_contract_proxy::StakingContractProxy)
.staked_addresses()
.returns(ReturnsResultUnmanaged)
.run();
assert!(staked_addresses_1
.into_vec()
.contains(&USER_ADDRESS.to_address()));
setup
.world
.check_account(USER_ADDRESS)
.balance(USER_BALANCE / 2);
setup
.world
.check_account(STAKING_CONTRACT_ADDRESS)
.balance(USER_BALANCE / 2);
// unstake full
setup
.world
.tx()
.from(USER_ADDRESS)
.to(STAKING_CONTRACT_ADDRESS)
.typed(staking_contract_proxy::StakingContractProxy)
.unstake(OptionalValue::None::<u64>)
.run();
let staked_addresses_2 = setup
.world
.query()
.to(STAKING_CONTRACT_ADDRESS)
.typed(staking_contract_proxy::StakingContractProxy)
.staked_addresses()
.returns(ReturnsResultUnmanaged)
.run();
assert!(staked_addresses_2.is_empty());
setup
.world
.check_account(USER_ADDRESS)
.balance(USER_BALANCE);
setup
.world
.check_account(STAKING_CONTRACT_ADDRESS)
.balance(0);
}
Now let's run the test... it didn't work. You should see the following error:
Error: result code mismatch.
Tx id: ''
Want: "0"
Have: 4
Message: storage decode error (key: stakingPositionuser____________________________): input too short
But why? Everything worked fine before.
This is because instead of using a simple BigUint
for staking positions, we now use the StakingPosition
structure. If you follow the error trace, you will see exactly where it failed:
28: staking_contract::StakingContract::stake
at ./src/staking_contract.rs:33:9
Which leads to the following line:
self.staking_position(&caller).update(|staking_pos| {
self.claim_rewards_for_user(&caller, staking_pos);
staking_pos.stake_amount += payment_amount
});
Because we're trying to add a new user, which has no staking entry yet, the decoding fails.
For a simple BigUint
, decoding from an empty storage yields the 0
value, which is exactly what we want, but for a struct type, it cannot give us any default value.
For this reason, we have to add some additional checks. The endpoint implementations will have to be changed to the following (the rest of the code remains the same):
#[payable("EGLD")]
#[endpoint]
fn stake(&self) {
let payment_amount = self.call_value().egld().clone();
require!(payment_amount > 0, "Must pay more than 0");
let caller = self.blockchain().get_caller();
let stake_mapper = self.staking_position(&caller);
let new_user = self.staked_addresses().insert(caller.clone());
let mut staking_pos = if !new_user {
stake_mapper.get()
} else {
let current_block = self.blockchain().get_block_epoch();
StakingPosition {
stake_amount: BigUint::zero(),
last_action_block: current_block,
}
};
self.claim_rewards_for_user(&caller, &mut staking_pos);
staking_pos.stake_amount += payment_amount;
stake_mapper.set(&staking_pos);
}
#[endpoint]
fn unstake(&self, opt_unstake_amount: OptionalValue<BigUint>) {
let caller = self.blockchain().get_caller();
self.require_user_staked(&caller);
let stake_mapper = self.staking_position(&caller);
let mut staking_pos = stake_mapper.get();
let unstake_amount = match opt_unstake_amount {
OptionalValue::Some(amt) => amt,
OptionalValue::None => staking_pos.stake_amount.clone(),
};
require!(
unstake_amount > 0 && unstake_amount <= staking_pos.stake_amount,
"Invalid unstake amount"
);
self.claim_rewards_for_user(&caller, &mut staking_pos);
staking_pos.stake_amount -= &unstake_amount;
if staking_pos.stake_amount > 0 {
stake_mapper.set(&staking_pos);
} else {
stake_mapper.clear();
self.staked_addresses().swap_remove(&caller);
}
self.tx().to(caller).egld(unstake_amount).transfer();
}
#[endpoint(claimRewards)]
fn claim_rewards(&self) {
let caller = self.blockchain().get_caller();
self.require_user_staked(&caller);
let stake_mapper = self.staking_position(&caller);
let mut staking_pos = stake_mapper.get();
self.claim_rewards_for_user(&caller, &mut staking_pos);
stake_mapper.set(&staking_pos);
}
fn require_user_staked(&self, user: &ManagedAddress) {
require!(self.staked_addresses().contains(user), "Must stake first");
}
For the stake
endpoint, if the user was not previously staked, we provide a default entry. The insert
method of UnorderedSetMapper
returns true
if the entry is new and false
if the user already exists in the list. This means we can use that result directly, instead of checking for stake_mapper.is_empty()
.
For the unstake
and claimRewards
endpoints, we have to check if the user was already staked and return an error otherwise (as they'd have nothing to unstake/claim anyway).
Once you've applied all the suggested changes, rebuilt the contract and regenerate the proxy, running the test should work just fine now:
running 1 test
test stake_unstake_test ... ok
In order to apply these changes on devnet, you should build the contract, regenerate the interactor and then upgrade it.
Whenever you regenerate the interactor, make sure that wallet_address points to the wallet you intend to use for executing transactions.
By default, the interactor registers Alice's wallet, so you’ll need to update wallet_address manually if you’re using a different one.
sc-meta all build
sc-meta all snippets
cd interactor/
Set the apy
variable inside the upgrade
function from staking-contract/interactor/src/interact.rs
to 100
, then run:
cargo run upgrade
To verify the change, query the apy
storage mapper by running:
cargo run getApy
You should see the following output:
Result: 100
Testing
Now that we've implemented rewards logic, let's add the following test to staking-contract/tests/staking_contract_blackbox_test.rs
:
#[test]
fn rewards_test() {
let mut setup = ContractSetup::new();
// stake full
setup
.world
.tx()
.from(USER_ADDRESS)
.to(STAKING_CONTRACT_ADDRESS)
.typed(staking_contract_proxy::StakingContractProxy)
.stake()
.egld(USER_BALANCE)
.run();
let expected_result_1 = StakingPosition {
stake_amount: BigUint::from(USER_BALANCE),
last_action_block: 0,
};
setup
.world
.query()
.to(STAKING_CONTRACT_ADDRESS)
.typed(staking_contract_proxy::StakingContractProxy)
.staking_position(USER_ADDRESS)
.returns(ExpectValue(&expected_result_1))
.run();
setup.world.current_block().block_nonce(BLOCKS_IN_YEAR);
// query rewards
setup
.world
.query()
.to(STAKING_CONTRACT_ADDRESS)
.typed(staking_contract_proxy::StakingContractProxy)
.calculate_rewards_for_user(USER_ADDRESS)
.returns(ExpectValue(
BigUint::from(USER_BALANCE) * APY / MAX_PERCENTAGE,
))
.run();
// claim rewards
setup
.world
.query()
.to(STAKING_CONTRACT_ADDRESS)
.typed(staking_contract_proxy::StakingContractProxy)
.staking_position(USER_ADDRESS)
.returns(ExpectValue(&expected_result_1))
.run();
setup
.world
.tx()
.from(USER_ADDRESS)
.to(STAKING_CONTRACT_ADDRESS)
.typed(staking_contract_proxy::StakingContractProxy)
.claim_rewards()
.run();
let expected_result_2 = StakingPosition {
stake_amount: BigUint::from(USER_BALANCE),
last_action_block: BLOCKS_IN_YEAR,
};
setup
.world
.query()
.to(STAKING_CONTRACT_ADDRESS)
.typed(staking_contract_proxy::StakingContractProxy)
.staking_position(USER_ADDRESS)
.returns(ExpectValue(expected_result_2))
.run();
setup
.world
.check_account(USER_ADDRESS)
.balance(BigUint::from(USER_BALANCE) * APY / MAX_PERCENTAGE);
// query rewards after claim
setup
.world
.query()
.to(STAKING_CONTRACT_ADDRESS)
.typed(staking_contract_proxy::StakingContractProxy)
.calculate_rewards_for_user(USER_ADDRESS)
.returns(ExpectValue(0u32))
.run();
}
In the test, we perform the following steps:
- Stake 1 EGLD;
- Set block nonce after 1 year (i.e. simulating 1 year worth of blocks passing);
- Querying rewards, which should give use 10% of 1 EGLD = 0.1 EGLD;
- Claiming said rewards and checking the internal state and user balance;
- Querying again after claim, to check that double-claim is not possible.
This test should work without any errors.
Conclusion
Currently, there is no way to deposit rewards into the smart contract, unless the owner makes it payable, which is generally bad practice, and not recommended.
As this is a fairly simple task compared to what we've done already, we'll leave this as an exercise to the reader. You'll have to add a payable("EGLD")
endpoint, and additionally, a storage mapper that keeps track of the remaining rewards.
Good luck!