Staking
Introduction
Staking is an analogue of a bank deposit, providing passive earnings through the simple storage of cryptocurrency tokens. The percentage of income may vary depending on the term of the deposit.
Anyone can create a Staking program and run it on the Gear Network. The source code, developed using the Sails framework, is available on GitHub.
The staking program interacts with the Vara Fungible Token (VFT) standard to handle token transfers seamlessly, enabling staking deposits, reward distributions, and withdrawals through secure and efficient transfer operations. These transfers are executed via asynchronous calls to the VFT contract, ensuring accurate state synchronization and error handling during token movements.
This article explains the programming interface, data structure, basic functions, and their purposes. The program can be used as-is or modified to suit specific scenarios.
Mathematics
Tokens can be deposited into the staking program and later claimed, along with rewards.
Staking involves depositing fungible tokens into a program to earn rewards. These rewards, minted at regular intervals (e.g., per minute), are distributed equitably among all stakers.
How Staking Works
Consider Alice staking 100 tokens and Bob staking 50 tokens. If reward tokens are minted every minute, and after one week Alice decides to unstake her tokens, the total tokens in the staking program remain 150. The duration of Alice's staking period is 7 days, and the reward tokens accumulated can be calculated based on this timeframe:
A week later, Bob chooses to unstake his 50 tokens. In the initial week, he staked 50 tokens out of 150. In the second week, he staked 50 tokens out of 50. Here’s how to determine his reward:
The formula can be generalized as:
where:
- - reward for the user for the time interval ;
- - rewards minted per minute;
- - total staked amount of tokens at time ;
- - tokens staked by the user at time .
To apply the formula, for each user and time interval, and for each time interval must be stored. Calculating a reward involves executing a loop for each time interval, consuming significant gas and storage. A more efficient approach is feasible:
If for a user is constant for , then:
This can be further simplified:
So, the equation to calculate the reward that a user will receive from t=a to t=b under the condition that the number of tokens staked is constant:
Based on that equation, the implementation in the program can be written as:
(staker.balance * self.tokens_per_stake) / DECIMALS_FACTOR + staker.reward_allowed - staker.reward_debt - staker.distributed
Program Description
The staking program is initialized by the admin using the StakingProgram::new
method, which sets up the reward token address, the distribution time, and the total reward to be distributed.
The admin can view the list of stakers and their staking details using the stakers()
method and can update the reward token address, distribution time, or total reward through the update_staking
function.
Users can participate in the program by staking tokens using the stake
function, which transfers tokens from the user's account to the contract and updates their staking balance. Accumulated rewards can be claimed on demand via the get_reward
function. Users can also partially or fully withdraw their staked tokens using the withdraw
function, which adjusts their balance and returns the specified tokens to their account.
Structs
The program has the following structs:
struct Staking {
owner: ActorId,
reward_token_address: ActorId,
tokens_per_stake: u128,
total_staked: u128,
distribution_time: u64,
produced_time: u64,
reward_total: u128,
all_produced: u128,
reward_produced: u128,
stakers: HashMap<ActorId, Staker>,
}
where:
owner
- the owner of the staking programreward_token_address
- address of the reward token programtokens_per_stake
- the calculated value of tokens per staketotal_staked
- total amount of depositsdistribution_time
- time of distribution of rewardreward_total
- the reward to be distributed within distribution timeproduced_time
- time ofreward_total
updateall_produced
- the reward received before the updatereward_total
reward_produced
- the reward produced so farstakers
- map of the stakers
pub struct Staker {
pub balance: u128,
pub reward_allowed: u128,
pub reward_debt: u128,
pub distributed: u128,
}
where:
balance
- staked amountreward_allowed
- the reward that could have been received from the withdrawn amountreward_debt
- the reward that the depositor would have received if he had initially paid this amountdistributed
- total remuneration paid
Event
pub enum Event {
StakeAccepted(u128),
Updated,
Reward(u128),
Withdrawn(u128),
}
Functions
Thе produced
function determines the total rewards generated since the last update. It ensures the reward does not exceed the total defined by the distribution_time
.
fn produced(&mut self) -> u128 {
let mut elapsed_time = exec::block_timestamp() - self.produced_time;
if elapsed_time > self.distribution_time {
elapsed_time = self.distribution_time;
}
self.all_produced
+ self.reward_total.saturating_mul(elapsed_time as u128)
/ self.distribution_time as u128
}
Thе update_reward
function updates the state to reflect the latest reward distribution and recalculates the tokens_per_stake
based on new rewards and the total staked amount.
fn update_reward(&mut self) {
let reward_produced_at_now = self.produced();
if reward_produced_at_now > self.reward_produced {
let produced_new = reward_produced_at_now - self.reward_produced;
if self.total_staked > 0 {
self.tokens_per_stake = self
.tokens_per_stake
.saturating_add((produced_new * DECIMALS_FACTOR) / self.total_staked);
}
self.reward_produced = self.reward_produced.saturating_add(produced_new);
}
}
Thе get_max_reward
function calculates the theoretical maximum reward for a given staked amount based on tokens_per_stake
.
fn get_max_reward(&self, amount: u128) -> u128 {
(amount * self.tokens_per_stake) / DECIMALS_FACTOR
}
Thе calc_reward
function the reward that a specific staker is eligible to claim. It factors in:
- Current staked balance.
- Rewards already allowed, debt, and previously distributed amounts.
fn calc_reward(&self, id: &ActorId) -> u128 {
match self.stakers.get(id) {
Some(staker) => {
self.get_max_reward(staker.balance) + staker.reward_allowed
- staker.reward_debt
- staker.distributed
}
None => panic!("Staker not found"),
}
}
Thе update_staking
function allows the admin to modify key parameters like the reward token, distribution time, and total reward. Resets reward tracking fields and recalculates staking-related variables. It ensures the program remains adaptable to changing requirements.
pub fn update_staking(
&mut self,
reward_token_address: ActorId,
distribution_time: u64,
reward_total: u128,
) {
if reward_total == 0 {
panic!("Reward is zero");
}
if distribution_time == 0 {
panic!("Distribution time is zero");
}
let storage = self.get_mut();
if msg::source() != storage.owner {
panic!("Not owner");
}
storage.reward_token_address = reward_token_address;
storage.distribution_time = distribution_time;
storage.update_reward();
storage.all_produced = storage.reward_produced;
storage.produced_time = exec::block_timestamp();
storage.reward_total = reward_total;
self.notify_on(Event::Updated).expect("Notification Error");
}
The stake
function enables users to deposit tokens into the staking program. Its operations include:
- Transferring the specified amount of tokens from the user's account to the contract.
- Updating the user's staking balance and associated reward debt in the program's state.
pub async fn stake(&mut self, amount: u128) {
if amount == 0 {
panic!("Amount is zero");
}
let storage = self.get_mut();
let msg_src = msg::source();
let request = vft_io::TransferFrom::encode_call(msg_src, exec::program_id(), amount.into());
msg::send_bytes_with_gas_for_reply(
storage.reward_token_address,
request,
5_000_000_000,
0,
0,
)
.expect("Error in sending a message")
.await
.expect("Error in transfer Fungible Token");
storage.update_reward();
let amount_per_token = storage.get_max_reward(amount);
storage
.stakers
.entry(msg_src)
.and_modify(|stake| {
stake.reward_debt = stake.reward_debt.saturating_add(amount_per_token);
stake.balance = stake.balance.saturating_add(amount);
})
.or_insert(Staker {
reward_debt: amount_per_token,
balance: amount,
..Default::default()
});
storage.total_staked = storage.total_staked.saturating_add(amount);
self.notify_on(Event::StakeAccepted(amount))
.expect("Notification Error");
}
The get_reward
function enables users to claim their accumulated rewards. Its operations include:
- Calculating the eligible reward amount using the
calc_reward
method. - Transferring the calculated reward to the user's account.
- Updating the program's state to reflect the distribution of the reward.
pub async fn get_reward(&mut self) {
let storage = self.get_mut();
storage.update_reward();
let msg_src = msg::source();
let reward = storage.calc_reward(&msg_src);
if reward == 0 {
panic!("Zero reward")
}
let request = vft_io::Transfer::encode_call(msg_src, reward.into());
msg::send_bytes_with_gas_for_reply(
storage.reward_token_address,
request,
5_000_000_000,
0,
0,
)
.expect("Error in sending a message")
.await
.expect("Error in transfer Fungible Token");
storage
.stakers
.entry(msg::source())
.and_modify(|stake| stake.distributed = stake.distributed.saturating_add(reward));
self.notify_on(Event::Reward(reward))
.expect("Notification Error");
}
The withdraw
function enables users to withdraw their staked tokens, either partially or in full. Its operations include:
- Validating the requested withdrawal amount to ensure it does not exceed the user’s available balance.
- Transferring the specified amount of tokens back to the user’s account.
- Updating the user’s staking balance and associated reward debt in the program's state.
pub async fn withdraw(&mut self, amount: u128) {
if amount == 0 {
panic!("Amount is zero");
}
let storage = self.get_mut();
storage.update_reward();
let amount_per_token = storage.get_max_reward(amount);
let msg_src = msg::source();
let staker = storage.stakers.get_mut(&msg_src).expect("Staker not found");
if staker.balance < amount {
panic!("Insufficent balance");
}
let request = vft_io::Transfer::encode_call(msg_src, amount.into());
msg::send_bytes_with_gas_for_reply(
storage.reward_token_address,
request,
5_000_000_000,
0,
0,
)
.expect("Error in sending a message")
.await
.expect("Error in transfer Fungible Token");
staker.reward_allowed = staker.reward_allowed.saturating_add(amount_per_token);
staker.balance = staker.balance.saturating_sub(amount);
storage.total_staked = storage.total_staked.saturating_sub(amount);
self.notify_on(Event::Withdrawn(amount))
.expect("Notification Error");
}
Query
The following functions are provided to read the current state of the staking program. These queries allow both administrators and users to retrieve information about the program's configuration, status, and individual staking details without modifying its state:
pub fn owner(&self) -> ActorId {
self.get().owner
}
pub fn reward_token_address(&self) -> ActorId {
self.get().reward_token_address
}
pub fn tokens_per_stake(&self) -> u128 {
self.get().tokens_per_stake
}
pub fn total_staked(&self) -> u128 {
self.get().total_staked
}
pub fn distribution_time(&self) -> u64 {
self.get().distribution_time
}
pub fn produced_time(&self) -> u64 {
self.get().produced_time
}
pub fn reward_total(&self) -> u128 {
self.get().reward_total
}
pub fn all_produced(&self) -> u128 {
self.get().all_produced
}
pub fn reward_produced(&self) -> u128 {
self.get().reward_produced
}
pub fn stakers(&self) -> Vec<(ActorId, Staker)> {
self.get().stakers.clone().into_iter().collect()
}
pub fn calc_reward(&self, id: ActorId) -> u128 {
self.get().calc_reward(&id)
}
owner
: Returns theActorId
of the staking program's owner.reward_token_address
: Retrieves theActorId
of the reward token associated with the staking program.tokens_per_stake
: Returns the number of reward tokens allocated per unit of stake, adjusted dynamically.total_staked
: Provides the total amount of tokens currently staked in the program.distribution_time
: Returns the time period (in milliseconds) over which rewards are distributed.produced_time
: Retrieves the timestamp of the last reward calculation or update.reward_total
: Returns the total reward allocated for the entire distribution period.all_produced
: Provides the cumulative rewards generated before the most recent reward update.reward_produced
: Returns the total rewards produced up to the current moment.stakers
: Retrieves a list of all stakers, including theirActorId
and corresponding staking details.calc_reward
: Calculates and returns the reward amount available for a specific staker identified by theirActorId
.
Conclusion
The source code of this example of a staking program is available on GitHub: gear-foundation/dapp/contracts/staking.
See also an example of the smart contract testing implementation based on gtest
: gear-foundation/dapps/contracts/staking/tests.
For more details about testing programs written on Vara, refer to this article: Program testing.