MultiversX is a proof-of-stake blockchain protocol that seeks to offer extremely fast transaction speeds by using adaptive state sharding. By being a proof-of-stake blockchain, it incentives holders to stake their assets (EGLD), in order to increase the network's security, by offering them staking rewards at a protocol level. As more and more products appear on the network, it raises the question for the holders, if they should just use their EGLD in the ecosystem, instead of just staking it. This Liquid Staking SC tries to solve this problem by offering users the ability to stake their EGLD in exchange for another token, that can be further implemented in various products within the MultiversX ecosystem, while still receiving the staking rewards from the protocol.
The Liquid Staking Smart Contract allows users to stake their EGLD in return of lsEGLD, a fungible ESDT that can be used in multiple ways in the MultiversX ecosystem, all while retaining the standard staking rewards. It offers users the possibility to stake or unstake their EGLD, while rewards are cumulated and redelegated. This means that, in time, the value of lsEGLD will only continue to outgrow that of the EGLD, as rewards are compounded every epoch. Also, it offers delegation contract owners means to maintain the status of their delegation contracts, through a whitelisted admin-only endpoint.
The Liquid Staking SC is designed to work with both user addresses and other smart contracts. That being said, it is important to note that the contracts that interact with the Liquid Staking SC need to be payable or payable by SC, otherwise any interaction may result in loss of funds.
#[init]
fn init(&self);
The init function is called when deploying/upgrading the smart contract. It sets various parameters that the contract needs, including setting the contract's state as Inactive.
#[payable("EGLD")]
#[endpoint(addLiquidity)]
fn add_liquidity(&self);
The addLiquidity
endpoint is the one that allows users to stake their EGLD in exchange for lsEGLD. After the initial checks are verified, the endpoint chooses a delegation address from the available delegation contracts list, and tries to delegate those EGLD tokens by sending an async call, hooked with add_liquidity_callback
, to that address.
In the callback, in case of a succesful result, the staking data for that delegation contract is updated accordingly, and liquidity is then computed and added, resulting in the total lsEGLD that needs to be created. The lsEGLD fungible ESDTs are then minted and sent to the initial caller. In case of an unsuccesful delegation, the EGLD tokens are then sent back to the caller.
One important observation here is that in time, with each redelegation of rewards, the value of the lsEGLD token will outgrow that of the EGLD token, so users will receive less and less lsEGLD tokens, in exchange for their EGLD.
#[payable("*")]
#[endpoint(removeLiquidity)]
fn remove_liquidity(&self);
This endpoint allows users to unstake their EGLD, by sending a payment of lsEGLD. Unlike the addLiquidity
endpoint, the liquidity is first removed and the lsEGLD token burnt, in order to get the correct amount of EGLD that needs to be undelegated. Again, a new delegation contract with enough available staked tokens is chosen and then the undelegate
function is called through an async call, hooked with the remove_liquidity_callback
.
In the callback, in case of a succesful undelegation, the supply storage is updated accordingly and then a new NFT is minted and sent to the initial caller, containing the necessary data for the later withdraw operation.
pub struct UnstakeTokenAttributes {
pub delegation_contract: ManagedAddress,
pub unstake_epoch: u64,
pub unstake_amount: BigUint,
pub unbond_epoch: u64,
}
In case of an unsuccesful undelegation, the contract adds back the liquidity, mints and then sends back the lsEGLD token to the caller.
#[payable("*")]
#[endpoint(unbondTokens)]
fn unbond_tokens(&self);
The unbondTokens
endpoint allows users to receive back their EGLD after the unbond period has passed. It receives only the payment of unstake_token_NFT, where it first checks if the unbond period has passed, and then it reads the delegation contract from where the tokens were unstaked and how much the users must receive. After that, it sends an async call to that contract, hooked with the withdraw_tokens_callback
.
In the callback, in case of a succesful result, the reserves storage is updated, the NFT is burnt and the EGLD tokens are sent to the caller. In case of an unsuccesful result, the NFT is sent back to the user.
#[endpoint(claimRewards)]
fn claim_rewards(&self);
The claimRewards
endpoint is callable by any user, and allows each epoch to claim all pending rewards and store them in a rewards_reserve storage, until those rewards are redelegated and are taken into account in the general virtual_egld_reserve storage.
The claimRewards
function implements an ongoing operation mechanism, that claims rewards from delegation contracts until the list is completely covered or until the transaction runs out of gas. In this case, it saves the current iteration of the delegation contract list, being able to later continue from where it left off in a secondary call of the same endpoint. After all delegation contracts have been covered, the claim operation status is set to Finished.
The available claim status types are:
pub enum ClaimStatusType {
None,
Pending,
Finished,
Delegable,
Insufficient,
Redelegated,
}
The workflow is as follows:
- In order to start a new
claimRewards
operation, the previous claim status must be Insufficient or Redelegated. Once the operation has started, the new claim operation is updated to the Pending status. After the claim operation has finished, it is marked with the Finished status. TherecomputeTokenReserve
endpoint then updates the rewards storage values, and in case the total available rewards are greater than the minimum delegation amount required (1 EGLD), the status is then updated to Delegable, otherwise it is updated to Insufficient. ThedelegateRewards
endpoint is then callable (only if the claim status is Delegable), which then updates the status to Redelegated, allowing the cycle to start once again. In case the status of the claim operation is Insufficient at the end of the rewards reserve recomputation, a new claim operation can be started the next epoch, without any further steps.
#[endpoint(recomputeTokenReserve)]
fn recompute_token_reserve(&self);
The recomputeTokenReserve
is an endpoint that allows for the rewards reserve recomputation. It is a mandatory step after the claimRewards
endpoint and it looks at the initial EGLD balance of the contract (the one that was saved at the beginning of the claim operation), compares it with the current balance of the contract (the one after the claim operation has finised claiming the rewards from all the delegation contracts) and saves the difference in the rewards_reserve storage. It also takes into account the withdrawn_egld that users may have unbonded. In case the newly obtained rewards reserve is bigger that the minimum delegation amount, then the claim status is updated to Delegable. Otherwise, it is updated to Insufficient, in which case a new claim operation can be started the next epoch, without any further steps regarding the rewards.
The endpoint is callable by any user and can be called only when the claim operation is in the Finished status.
#[endpoint(delegateRewards)]
fn delegate_rewards(&self);
As stated before, the delegateRewards
endpoint allows for the delegation of all the cumulated rewards from the claimRewards
endpoint, but only if the rewards amount is greater than the minimum amount required (marked by the Delegable claim status). If all check conditions are met, then a new delegation contract is chosen from the whitelist and a new delegation operation is called through an async call hooked with the delegate_rewards_callback
.
In the callback, if the result is succesful, the storage is updated accordingly, adding the rewards_reserve value to the virtual_egld_reserve, which in turn increases the value of the lsEGLD, compared to the EGLD token.
#[only_owner]
#[endpoint(whitelistDelegationContract)]
fn whitelist_delegation_contract(
&self,
contract_address: ManagedAddress,
admin_address: ManagedAddress,
total_staked: BigUint,
delegation_contract_cap: BigUint,
nr_nodes: u64,
apy: u64,
);
Endpoint that allows the owner to whitelist a delegation contract with a set of parameters, sent as arguments (DelegationContractData). From the list below, the first 5 variables are user updatable, while total_staked_from_ls_contract and total_undelegated_from_ls_contract variables are automatically updated throughout the contract's workflow.
pub struct DelegationContractData {
pub admin_address: ManagedAddress,
pub total_staked: BigUint,
pub delegation_contract_cap: BigUint,
pub nr_nodes: u64,
pub apy: u64,
pub total_staked_from_ls_contract: BigUint,
pub total_undelegated_from_ls_contract: BigUint,
}
#[only_owner]
#[endpoint(changeDelegationContractAdmin)]
fn change_delegation_contract_admin(
&self,
contract_address: ManagedAddress,
admin_address: ManagedAddress,
)
Endpoint that allows the owner to update the admin of a specific delegation contract. It takes as arguments the address of the delegation contract and the address of the new admin.
#[endpoint(changeDelegationContractParams)]
fn change_delegation_contract_params(
&self,
contract_address: ManagedAddress,
total_staked: BigUint,
delegation_contract_cap: BigUint,
nr_nodes: u64,
apy: u64,
);
Endpoint that allows the admin of a whitelisted delegation contract to update the given parameters, by sending them as arguments. The caller of the endpoint must be the same as the admin_address that was previously saved for that said delegation contract.
#[only_owner]
#[payable("EGLD")]
#[endpoint(registerLsToken)]
fn register_ls_token(
&self,
token_display_name: ManagedBuffer,
token_ticker: ManagedBuffer,
num_decimals: usize,
);
A setup endpoint, that receives an exact payment of 0.05 EGLD (amount required for the deployment of a new token) and that issues and sets all roles for the newly created token. Being a fungible ESDT, the lsEGLD is handled through a FungibleTokenMapper.
#[only_owner]
#[payable("EGLD")]
#[endpoint(registerUnstakeToken)]
fn register_unstake_token(
&self,
token_display_name: ManagedBuffer,
token_ticker: ManagedBuffer,
num_decimals: usize,
);
A setup endpoint, that receives an exact payment of 0.05 EGLD (amount required for the deployment of a new token) and that issues and sets all roles for the newly created token. Being a non-fungible ESDT, the unstake_token is handled through a NonFungibleTokenMapper.
#[only_owner]
#[endpoint(setStateActive)]
fn set_state_active(&self);
A setup endpoint, that updates the state of the contract to Active.
#[only_owner]
#[endpoint(setStateInactive)]
fn set_state_inactive(&self);
A setup endpoint, that updates the state of the contract to Inactive.
The contract has been tested through both unit and system tests. Local tests have been done using Rust Testing Framework, which can be found in the tests folder. Here, the testing setup is organized in two folders, setup and interactions. The actual testing logic is defined in the test.rs file. In order to replicate the entire workflow of the contract, a delegation-mock contract has been created, that has a basic custom logic that replicates the delegation rewarding system from the protocol level.
The interaction scripts are located in the interaction folder. The scripts are written in python and erdpy is required in order to be used. Interaction scripts are scripts that ease the interaction with the deployed contract by wrapping erdpy sdk functionality in bash scripts. Make sure to update the PEM path and the PROXY and CHAINID values in order to correctly use the scripts.
The testing of this contract has been conducted on the Devnet.