Русский
English
RU
Сетка BlockFirst
Картинка BlockFirst
Ссылка
Иконка копировать
Иконка скопированно

Стейкинг и награды в SushiSwap MasterChef

В этой статье объясняется, как работает система MasterChef в SushiSwap, которая управляет стейкингом и распределением вознаграждений. Рассматриваются ключевые механизмы распределения токенов, взаимодействие пользователей с контрактами и принципы начисления наград за участие в пуле ликвидности.

29.06.2025
Перевод
Начинающий уровень
Автор BlockFirst
Поделиться
Иконка поделиться
Ссылка
Иконка копировать
Иконка скопировано
ARTICLE BELOW
Картинка
Превью BlockFirst

StakerARewards =
(BlockRewardsOn0to10 * StakerAShareOn0to10) +
(BlockRewardsOn10to15 * StakerAShareOn10to15) +
(BlockRewardsOn15to25 * StakerAShareOn15to25) +
(BlockRewardsOn25to30 * StakerAShareOn25to30)

StakerARewards = 14

it.only("Harvest rewards according with the staker pool's share", async function ()
{ // Arrange Pool
const stakeToken = rewardToken;
await stakeToken.transfer( account2.address, ethers.utils.parseEther("200000") // 200.000 );
await createStakingPool(stakingManager, stakeToken.address);
const amount1 = ethers.utils.parseEther("80");
const amount2 = ethers.utils.parseEther("20");

// Arrange Account1 staking await stakeToken.approve(stakingManager.address, amount1);
await stakingManager.deposit(0, amount1);

// Arrange Account 2 staking await stakeToken.connect(account2).approve(stakingManager.address, amount2);
await stakingManager.connect(account2).deposit(0, amount2);

// Act const acc1HarvestTransaction = await stakingManager.harvestRewards(0);
const acc2HarvestTransaction = await stakingManager .connect(account2) .harvestRewards(0);

// Assert // 2 blocks with 100% participation = 4 reward tokens * 2 blocks = 8 // 1 block with 80% participation = 3.2 reward tokens * 1 block = 3.2 // Account1 Total = 8 + 3.2 = 11.2 reward tokens const expectedAccount1Rewards = ethers.utils.parseEther("11.2");

await expect(acc1HarvestTransaction) .to.emit(stakingManager, "HarvestRewards") .withArgs(account1.address, 0, expectedAccount1Rewards); // 2 block with 20% participation = 0.8 reward tokens * 2 block // Account 1 Total = 1.6 reward tokens const expectedAccount2Rewards = ethers.utils.parseEther("1.6");

await expect(acc2HarvestTransaction) .to.emit(stakingManager, "HarvestRewards") .withArgs(account2.address, 0, expectedAccount2Rewards); });

StakerATokens = 100
AccumulatedRewardsPerShare = (10 / 100) + (5 / 500) + (10 / 500) + (5 / 500)
AccumulatedRewardsPerShare = (10 / 100) + (5 / 500)

StakerARewardsOn15to30 = StakerATokens *
(AccumulatedRewardsPerShare - AccumulatedRewardsPerShareOn0to15)
StakerARewardsOn15to30 = 100 * ((10 / 500) + (5 / 500))
StakerARewardsOn15to30 = 3

Так что да, это работает. Это значит, что если мы сохраняем значение AccumulatedRewardsPerShare, умноженное на количество токенов стеекера, каждый раз, когда он вносит депозит или снимает средства, мы можем использовать это значение, чтобы просто вычитать его из их общей суммы наград. В контракте MasterChef это называется rewardDebt. Это похоже на вычисление общих наград стеекера с блока 0, но с вычетом наград, которые он уже собрал, или тех, на которые он не имел права претендовать, потому что ещё не участвовал в стекинге.

Мы также можем подставить некоторые значения, чтобы проверить, действительно ли это работает.

StakerBRewards = StakerBTokens *
(AccumulatedRewardsPerShare - RewardsPerShareOn0to10)

И это очень похоже на формулу, которую мы получили ранее для StakerBRewards.

StakerARewardsOn15to30 = StakerATokens *
(AccumulatedRewardsPerShare - AccumulatedRewardsPerShareOn0to15)

Теперь вы, возможно, заметили, что мы снова можем изолировать StakerATokens.

StakerARewardsOn15to30 = StakerATokens *
AccumulatedRewardsPerShare - StakerATokens *
AccumulatedRewardsPerShareOn0to15

И заменить StakerARewardsOn0to15 в предыдущей формуле.

StakerARewardsOn0to15 = StakerATokens *
AccumulatedRewardsPerShareOn0to15

Теперь мы можем использовать следующую формулу для блоков с 0 по 15.

StakerARewardsOn15to30 = StakerATokens *
AccumulatedRewardsPerShare - StakerARewardsOn0to15

Мы получаем

StakerARewardsOn15to30 = StakerARewards - StakerARewardsOn0to15
StakerARewards = StakerATokens * AccumulatedRewardsPerShare

Если изолировать StakerARewardsOn15to30 в первой формуле и заменить в ней StakerATokens на значение из второй формулы, то

StakerARewards = StakerARewardsOn0to15 + StakerARewardsOn15to30
StakerARewards = StakerATokens * AccumulatedRewardsPerShare

StakerBRewards = StakerBTokens *
(AccumulatedRewardsPerShare - RewardsPerShareOn0to10)

Это важно, потому что хотя мы можем использовать AccumulatedRewardsPerShare для расчёта наград каждого стеекера, нам нужно вычесть RewardsPerShare, которые накопились до их действия по внесению депозита или сбору наград. Давайте выясним, сколько Стеекер A собрал при своём первом сборе наград, используя то, что мы узнали до этого момента.

Поскольку AccumulatedRewardsPerShare одинаково для всех стеекеров, мы можем сказать, что StakerBRewards — это это значение минус награды, которые они не получили за блоки с 0 по 10.

StakerARewards = StakerATokens *
AccumulatedRewardsPerShare

Тогда мы можем сказать, что StakerARewards — это произведение StakerATokens на AccumulatedRewardsPerShare.

AccumulatedRewardsPerShare =
RewardsPerShareOn0to10 +
RewardsPerShareOn10to15 +
RewardsPerShareOn15to25 +
RewardsPerShareOn25to30

А вместо accSushiPerShare мы будем называть их сумму AccumulatedRewardsPerShare.

RewardsPerShareOn0to10 = (10 / 100)
RewardsPerShareOn10to15 = (5 / 500)
RewardsPerShareOn15to25 = (10 / 500)
RewardsPerShareOn25to30 = (5 / 500)

(5 / 500) + (10 / 500) + (5 / 500)

Контракт SushiSwap называет эту сумму accSushiPerShare, поэтому давайте назовём каждое деление RewardsPerShare.

StakerBRewards = 16

StakerBRewards =
400 * ( (5 / 500) +
(10 / 500) +
(5 / 500)
)

StakerBRewards =
StakerBTokens * ( (BlockRewardsOn10to15 / TotalTokensOn10to15) +
(BlockRewardsOn15to25 / TotalTokensOn15to25) +
(BlockRewardsOn25to30 / TotalTokensOn25to30)
)

StakerBRewards =
(BlockRewardsOn10to15 * StakerBTokens / TotalTokensOn10to15) +
(BlockRewardsOn15to25 * StakerBTokens / TotalTokensOn15to25) +
(BlockRewardsOn25to30 * StakerBTokens / TotalTokensOn25to30)

Теперь, когда награды обоих стеекеров совпадают с тем, что мы видели ранее, давайте посмотрим, что можно использовать повторно в расчёте наград для каждого. Как видите, в формулах наград обоих стеекеров есть общая сумма делений.

Что совпадает с нашими ожиданиями. Давайте сделаем то же самое для стеекера B.

StakerARewards = 100 * (
(10 / 100) +
(5 / 500) +
(10 / 500) +
(5 / 500)
)

Мы можем убедиться, что это работает в нашем сценарии, заменив эти сложные слова на числа и вычислив общие награды для Стеекера A.

StakerARewards =
StakerATokens * ( (BlockRewardsOn0to10 / TotalTokensOn0to10) +
(BlockRewardsOn10to15 / TotalTokensOn10to15) +
(BlockRewardsOn15to25 / TotalTokensOn15to25) +
(BlockRewardsOn25to30 / TotalTokensOn25to30)
)

И, выделив StakerATokens, мы получаем следующее.

StakerARewards =
(BlockRewardsOn0to10 * StakerATokens / TotalTokensOn0to10) +
(BlockRewardsOn10to15 * StakerATokens / TotalTokensOn10to15) +
(BlockRewardsOn15to25 * StakerATokens / TotalTokensOn15to25) +
(BlockRewardsOn25to30 * StakerATokens / TotalTokensOn25to30)

Тогда мы можем упростить нашу формулу StakerARewards.

StakerATokensOn0to10 =
StakerATokensOn10to15 =
StakerATokensOn15to25 =
StakerATokensOn25to30 =
StakerATokens

Но в этом случае у стеекера было одинаковое количество токенов, внесённых во всех диапазонах.

StakerARewards =
(BlockRewardsOn0to10 * StakerATokensOn0to10 / TotalTokensOn0to10) +
(BlockRewardsOn10to15 * StakerATokensOn10to15 / TotalTokensOn10to15) +
(BlockRewardsOn15to25 * StakerATokensOn15to25 / TotalTokensOn15to25) +
(BlockRewardsOn25to30 * StakerATokensOn25to30 / TotalTokensOn25to30)

У нас есть это

StakerAShareOnNtoM = StakerATokensOnNtoM / TotalTokensOnNtoM

StakerANtoMRewards = BlockRewardsOnNtoM * StakerAShareOnNtoM

StakerARewards =
StakerA0to10Rewards +
StakerA10to15Rewards +
StakerA15to25Rewards +
StakerA25to30Rewards

From Block 25 to 30:
BlocksPassed: 5
BlockRewards: $5
StakerATokens: $100
StakerBTokens: $400
TotalTokens: $500
StakerAAccumulatedRewards: $1 + $2
StakerBAccumulatedRewards: $4

From Block 15 to 25:
BlocksPassed: 10
BlockRewards: $10
StakerATokens: $100
StakerBTokens: $400
TotalTokens: $500
StakerAAccumulatedRewards: $2
StakerBAccumulatedRewards: $8 + $4

From Block 10 to 15:
BlocksPassed: 5
BlockRewards = BlocksPassed * RewardsPerBlock
BlockRewards = $5
StakerATokens: $100
StakerBTokens: $400
TotalTokens: $500

StakerAShare = StakerATokens / TotalTokens
StakerAShare = 1/5
StakerAAccumulatedRewards = (BlockRewards * StakerAShare) + StakerAAccumulatedRewards
StakerAAccumulatedRewards = $1 + $10

StakerBShare = StakerBTokens / TotalTokens
StakerBShare = 4/5
StakerBAccumulatedRewards = BlockRewards * StakerBShare
StakerBAccumulatedRewards = $4

From block 0 to 10:
BlocksPassed: 10
BlockRewards = BlocksPassed * RewardsPerBlock
BlockRewards = $10
StakerATokens:
$100 TotalTokens: $100

StakerAShare = StakerATokens / TotalTokens
StakerAShare = 1
StakerAAccumulatedRewards = BlockRewards * StakerAShare
StakerAAccumulatedRewards = $10

RewardsPerBlock = $1 On block 0,
Staker A deposits $100 On block 10,
Staker B deposits $400 On block 15,
Staker A harvests all rewards On block 25,
Staker B harvests all rewards On block 30,
both stakers harvests all rewards.

//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.4;

import "hardhat/console.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "./RewardToken.sol";

contract StakingManager is Ownable{
using SafeERC20 for IERC20; // Wrappers around ERC20 operations that throw on failure

RewardToken public rewardToken; // Token to be payed as reward

uint256 private rewardTokensPerBlock; // Number of reward tokens minted per block
uint256 private constant STAKER_SHARE_PRECISION = 1e12; // A big number to perform mul and div operations

// Staking user for a pool
struct PoolStaker {
uint256 amount; // The tokens quantity the user has staked.
uint256 rewards; // The reward tokens quantity the user can harvest
uint256 lastRewardedBlock; // Last block number the user had their rewards calculated
}

// Staking pool
struct Pool {
IERC20 stakeToken; // Token to be staked
uint256 tokensStaked; // Total tokens staked
address[] stakers; // Stakers in this pool
}

Pool[] public pools; // Staking pools

// Mapping poolId => staker address => PoolStaker
mapping(uint256 => mapping(address => PoolStaker)) public poolStakers;

// Events
event Deposit(address indexed user, uint256 indexed poolId, uint256 amount);
event Withdraw(address indexed user, uint256 indexed poolId, uint256 amount);
event HarvestRewards(address indexed user, uint256 indexed poolId, uint256 amount);
event PoolCreated(uint256 poolId);

// Constructor
constructor(address _rewardTokenAddress, uint256 _rewardTokensPerBlock) {
rewardToken = RewardToken(_rewardTokenAddress);
rewardTokensPerBlock = _rewardTokensPerBlock;
}

/**
* @dev Create a new staking pool
*/
function createPool(IERC20 _stakeToken) external onlyOwner {
Pool memory pool;
pool.stakeToken = _stakeToken;
pools.push(pool);
uint256 poolId = pools.length - 1;
emit PoolCreated(poolId);
}

/**
* @dev Add staker address to the pool stakers if it's not there already
* We don't have to remove it because if it has amount 0 it won't affect rewards.
* (but it might save gas in the long run)
*/
function addStakerToPoolIfInexistent(uint256 _poolId, address depositingStaker) private {
Pool storage pool = pools[_poolId];
for (uint256 i; i < pool.stakers.length; i++) {
address existingStaker = pool.stakers[i];
if (existingStaker == depositingStaker) return;
}
pool.stakers.push(msg.sender);
}

/**
* @dev Deposit tokens to an existing pool
*/
function deposit(uint256 _poolId, uint256 _amount) external {
require(_amount > 0, "Deposit amount can't be zero");
Pool storage pool = pools[_poolId];
PoolStaker storage staker = poolStakers[_poolId][msg.sender];

// Update pool stakers
updateStakersRewards(_poolId);
addStakerToPoolIfInexistent(_poolId, msg.sender);

// Update current staker
staker.amount = staker.amount + _amount;
staker.lastRewardedBlock = block.number;

// Update pool
pool.tokensStaked = pool.tokensStaked + _amount;

// Deposit tokens
emit Deposit(msg.sender, _poolId, _amount);
pool.stakeToken.safeTransferFrom(
address(msg.sender),
address(this),
_amount
);
}

/**
* @dev Withdraw all tokens from an existing pool
*/
function withdraw(uint256 _poolId) external {
Pool storage pool = pools[_poolId];
PoolStaker storage staker = poolStakers[_poolId][msg.sender];
uint256 amount = staker.amount;
require(amount > 0, "Withdraw amount can't be zero");

// Update pool stakers
updateStakersRewards(_poolId);

// Pay rewards
harvestRewards(_poolId);

// Update staker
staker.amount = 0;

// Update pool
pool.tokensStaked = pool.tokensStaked - amount;

// Withdraw tokens
emit Withdraw(msg.sender, _poolId, amount);
pool.stakeToken.safeTransfer(
address(msg.sender),
amount
);
}
/**
* @dev Harvest user rewards from a given pool id
*/
function harvestRewards(uint256 _poolId) public {
updateStakersRewards(_poolId);
PoolStaker storage staker = poolStakers[_poolId][msg.sender];
uint256 rewardsToHarvest = staker.rewards;
staker.rewards = 0;
emit HarvestRewards(msg.sender, _poolId, rewardsToHarvest);
rewardToken.mint(msg.sender, rewardsToHarvest);
}
/**
* @dev Loops over all stakers from a pool, updating their accumulated rewards according
* to their participation in the pool.
*/
function updateStakersRewards(uint256 _poolId) private {
Pool storage pool = pools[_poolId];
for (uint256 i; i < pool.stakers.length; i++) {
address stakerAddress = pool.stakers[i];
PoolStaker storage staker = poolStakers[_poolId][stakerAddress];
if (staker.amount == 0) return;
uint256 stakedAmount = staker.amount;
uint256 stakerShare = (stakedAmount * STAKER_SHARE_PRECISION / pool.tokensStaked);
uint256 blocksSinceLastReward = block.number - staker.lastRewardedBlock;
uint256 rewards = (blocksSinceLastReward * rewardTokensPerBlock * stakerShare) / STAKER_SHARE_PRECISION;
staker.lastRewardedBlock = block.number;
staker.rewards = staker.rewards + rewards;
}
}
}

Превью BlockFirst

А если использовать следующую формулу, которая представляет долю стейкера как отношение его токенов к общему количеству токенов в пуле

Тогда награда стейкера получается как сумма произведений между наградами и его долей участия для каждого диапазона блоков — до текущего момента.

А если рассматривать его вознаграждение с блока N по M как произведение наград, распределённых за этот диапазон, на его долю участия в том же диапазоне.

Функция updateStakersRewards отвечает за проход по всем стейкерам и обновление их накопленных наград каждый раз, когда кто-то делает депозит, вывод или снимает вознаграждение.
Но что, если можно обойтись без этого цикла?

Это целых 20% экономии газа, даже при всего двух стеекерах. Вот почему контракт MasterChef от SushiSwap похож на последний вариант, который я показал. На самом деле он ещё более эффективен, потому что в нём нет отдельной функции harvestRewards — сбор наград происходит при вызове функции deposit с количеством 0.

Для последней (использование AccumulatedRewardsPerShare):

С помощью hardhat-gas-reporter мы можем увидеть, насколько каждая реализация затратна по газу. Для первой (с циклом по всем стеекерам):

Я не смог продолжить свой проект, не разобравшись, как большинство DeFi рассчитывают свои награды, и провёл последние дни, изучая работу контракта SushiSwap. Я смог понять смысл некоторых переменных MasterChef (особенно accSushiPerShare и rewardDebt) только после того, как сам реализовал и поработал с математикой системы наград. Хотя я находил материалы, объясняющие контракт, все они были слишком поверхностными. Поэтому я решил объяснить всё своими словами. Надеюсь, это будет полезно тем, кто тоже изучает DeFi более глубоко.

Заключение

Поскольку accSushiPerShare может быть числом с десятичными знаками, а Solidity не поддерживает числа с плавающей запятой, они умножают sushiReward на большое число, например 1e12, при вычислениях, а затем делят на то же число при использовании.

Что насчёт умножения и деления на 1e12?

Причина, по которой мы избегаем цикла, — это в основном экономия газа. Как вы можете представить, чем больше стеекеров, тем дороже становится функция updateStakersRewards. Мы можем сравнить затраты газа обеих подходов с помощью теста в Hardhat:

Экономия газа

Используя предыдущий контракт в качестве основы, мы можем просто вычислять accumulatedRewardsPerShare в функции updatePoolRewards (переименованной из updateStakersRewards) и получать rewardDebt стеекера каждый раз, когда он выполняет действие.
Вы можете увидеть изменения в коде в этом коммите.

Реализация AccumulatedRewardsPerShare

Мы знаем, что награды, которые получил Стеекер A, — это сумма их первого и последнего сбора наград, то есть за блоки с 0 по 15 и с 15 по 30. Также мы знаем, что можем получить то же значение с помощью формулы StakerARewards, которую мы только что использовали выше.

Выяснение rewardDebt

При таком подходе для каждого действия (депозита или снятия наград) нам приходится проходить по всем стейкерам и пересчитывать их накопленные вознаграждения. Вот простой контракт стейкинга с такой реализацией:

Применим немного математики

При таком подходе для каждого действия (депозита или снятия наград) нам приходится проходить по всем стейкерам и пересчитывать их накопленные вознаграждения. Вот простой контракт стейкинга с такой реализацией:

Реализация

Стейкер B снимает $12, и его накопленные награды (StakerBAccumulatedRewards) сбрасываются до 0. Наконец, оба стейкера забирают свои вознаграждения на блоке 30.

Стейкер A снимает $11, и его накопленные награды (StakerAAccumulatedRewards) сбрасываются до 0. Стейкер B за последние 5 блоков накопил $4. Затем проходит ещё 10 блоков, и Стейкер B также решает забрать свои награды.

Я начал с реализации стейкинга, используя в качестве примера смарт-контракт из DeFi-сервиса, которым пользуюсь. Выяснилось, что большинство стейкинг-контрактов — это копии MasterChef от SushiSwap. Читая контракт, я понял, как на самом деле рассчитываются вознаграждения за стейкинг.

То есть понятно, что токены создаются для каждого блока и распределяются между всеми стейкерами пропорционально их вкладу в пул.
Однако непонятно, какую роль в этом расчёте играют переменные accSushiPerShare и rewardDebt.
В этом посте я хочу поделиться тем, как мне удалось разобраться в логике контракта MasterChef и объяснить, почему он написан именно так.
Для начала попробуем сами понять, что было бы справедливым вознаграждением для стейкеров.

На блоке 10 Стейкер B вносит $400. Теперь, на блоке 15, Стейкер А снимает свои награды. Хотя с блоков 0 по 10 он получал 100% вознаграждений, с блоков 10 по 15 ему положено только 20% (1/5) из-за доли участия.

Стейкер А внес $100 на блоке 0, а десять блоков спустя Стейкер B внёс $400. За первые десять блоков Стейкер А получил 100% вознаграждений, что составляет $10.

Допустим, что...

Простая симуляция вознаграждений

В рамках своей цели перейти от Web2 к Web3-разработке я создаю DeFi-приложение с нуля, чтобы изучать и практиковать Solidity.

Введение

uint256 multiplier = getMultiplier(pool.lastRewardBlock, block.number);
uint256 sushiReward = multiplier.mul(sushiPerBlock).mul(pool.allocPoint).div( totalAllocPoint );
accSushiPerShare = accSushiPerShare.add( sushiReward.mul(1e12).div(lpSupply) );
}
return user.amount.mul(accSushiPerShare).div(1e12).sub(user.rewardDebt);

PoolInfo storage pool = poolInfo[_pid];
UserInfo storage user = userInfo[_pid][_user];
uint256 accSushiPerShare = pool.accSushiPerShare;
uint256 lpSupply = pool.lpToken.balanceOf(address(this));
if (block.number > pool.lastRewardBlock && lpSupply != 0) {

external
view
returns (uint256)

{

function pendingSushi(uint256 _pid, address _user)

//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.4;

import "hardhat/console.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "./RewardToken.sol";

contract StakingManager is Ownable{
using SafeERC20 for IERC20; // Wrappers around ERC20 operations that throw on failure

RewardToken public rewardToken; // Token to be payed as reward

uint256 private rewardTokensPerBlock; // Number of reward tokens minted per block
uint256 private constant STAKER_SHARE_PRECISION = 1e12; // A big number to perform mul and div operations

// Staking user for a pool
struct PoolStaker {
uint256 amount; // The tokens quantity the user has staked.
uint256 rewards; // The reward tokens quantity the user can harvest
uint256 lastRewardedBlock; // Last block number the user had their rewards calculated
}

// Staking pool
struct Pool {
IERC20 stakeToken; // Token to be staked
uint256 tokensStaked; // Total tokens staked
address[] stakers; // Stakers in this pool
}

Pool[] public pools; // Staking pools

// Mapping poolId => staker address => PoolStaker
mapping(uint256 => mapping(address => PoolStaker)) public poolStakers;

// Events
event Deposit(address indexed user, uint256 indexed poolId, uint256 amount);
event Withdraw(address indexed user, uint256 indexed poolId, uint256 amount);
event HarvestRewards(address indexed user, uint256 indexed poolId, uint256 amount);
event PoolCreated(uint256 poolId);

// Constructor
constructor(address _rewardTokenAddress, uint256 _rewardTokensPerBlock) {
rewardToken = RewardToken(_rewardTokenAddress);
rewardTokensPerBlock = _rewardTokensPerBlock;
}

/**
* @dev Create a new staking pool
*/
function createPool(IERC20 _stakeToken) external onlyOwner {
Pool memory pool;
pool.stakeToken = _stakeToken;
pools.push(pool);
uint256 poolId = pools.length - 1;
emit PoolCreated(poolId);
}

/**
* @dev Add staker address to the pool stakers if it's not there already
* We don't have to remove it because if it has amount 0 it won't affect rewards.
* (but it might save gas in the long run)
*/
function addStakerToPoolIfInexistent(uint256 _poolId, address depositingStaker) private {
Pool storage pool = pools[_poolId];
for (uint256 i; i < pool.stakers.length; i++) {
address existingStaker = pool.stakers[i];
if (existingStaker == depositingStaker) return;
}
pool.stakers.push(msg.sender);
}

/**
* @dev Deposit tokens to an existing pool
*/
function deposit(uint256 _poolId, uint256 _amount) external {
require(_amount > 0, "Deposit amount can't be zero");
Pool storage pool = pools[_poolId];
PoolStaker storage staker = poolStakers[_poolId][msg.sender];

// Update pool stakers
updateStakersRewards(_poolId);
addStakerToPoolIfInexistent(_poolId, msg.sender);

// Update current staker
staker.amount = staker.amount + _amount;
staker.lastRewardedBlock = block.number;

// Update pool
pool.tokensStaked = pool.tokensStaked + _amount;

// Deposit tokens
emit Deposit(msg.sender, _poolId, _amount);
pool.stakeToken.safeTransferFrom(
address(msg.sender),
address(this),
_amount
);
}

/**
* @dev Withdraw all tokens from an existing pool
*/
function withdraw(uint256 _poolId) external {
Pool storage pool = pools[_poolId];
PoolStaker storage staker = poolStakers[_poolId][msg.sender];
uint256 amount = staker.amount;
require(amount > 0, "Withdraw amount can't be zero");

// Update pool stakers
updateStakersRewards(_poolId);

// Pay rewards
harvestRewards(_poolId);

// Update staker
staker.amount = 0;

// Update pool
pool.tokensStaked = pool.tokensStaked - amount;

// Withdraw tokens
emit Withdraw(msg.sender, _poolId, amount);
pool.stakeToken.safeTransfer(
address(msg.sender),
amount
);
}

/**
* @dev Harvest user rewards from a given pool id
*/
function harvestRewards(uint256 _poolId) public {
updateStakersRewards(_poolId);
PoolStaker storage staker = poolStakers[_poolId][msg.sender];
uint256 rewardsToHarvest = staker.rewards;
staker.rewards = 0;
emit HarvestRewards(msg.sender, _poolId, rewardsToHarvest);
rewardToken.mint(msg.sender, rewardsToHarvest);
}

/**
* @dev Loops over all stakers from a pool, updating their accumulated rewards according
* to their participation in the pool.
*/
function updateStakersRewards(uint256 _poolId) private {
Pool storage pool = pools[_poolId];
for (uint256 i; i < pool.stakers.length; i++) {
address stakerAddress = pool.stakers[i];
PoolStaker storage staker = poolStakers[_poolId][stakerAddress];
if (staker.amount == 0) return;
uint256 stakedAmount = staker.amount;
uint256 stakerShare = (stakedAmount * STAKER_SHARE_PRECISION / pool.tokensStaked);
uint256 blocksSinceLastReward = block.number - staker.lastRewardedBlock;
uint256 rewards = (blocksSinceLastReward * rewardTokensPerBlock * stakerShare) / STAKER_SHARE_PRECISION;
staker.lastRewardedBlock = block.number;
staker.rewards = staker.rewards + rewards;
}
}
}

StakerARewards =
(BlockRewardsOn0to10 * StakerAShareOn0to10) +
(BlockRewardsOn10to15 * StakerAShareOn10to15) +
(BlockRewardsOn15to25 * StakerAShareOn15to25) +
(BlockRewardsOn25to30 * StakerAShareOn25to30)

StakerARewards = 14

it.only("Harvest rewards according with the staker pool's share", async function ()
{ // Arrange Pool
const stakeToken = rewardToken;
await stakeToken.transfer( account2.address, ethers.utils.parseEther("200000") // 200.000 );
await createStakingPool(stakingManager, stakeToken.address);
const amount1 = ethers.utils.parseEther("80");
const amount2 = ethers.utils.parseEther("20");

// Arrange Account1 staking await stakeToken.approve(stakingManager.address, amount1);
await stakingManager.deposit(0, amount1);

// Arrange Account 2 staking await stakeToken.connect(account2).approve(stakingManager.address, amount2);
await stakingManager.connect(account2).deposit(0, amount2);

// Act const acc1HarvestTransaction = await stakingManager.harvestRewards(0);
const acc2HarvestTransaction = await stakingManager .connect(account2) .harvestRewards(0);

// Assert // 2 blocks with 100% participation = 4 reward tokens * 2 blocks = 8 // 1 block with 80% participation = 3.2 reward tokens * 1 block = 3.2 // Account1 Total = 8 + 3.2 = 11.2 reward tokens const expectedAccount1Rewards = ethers.utils.parseEther("11.2");

await expect(acc1HarvestTransaction) .to.emit(stakingManager, "HarvestRewards") .withArgs(account1.address, 0, expectedAccount1Rewards); // 2 block with 20% participation = 0.8 reward tokens * 2 block // Account 1 Total = 1.6 reward tokens const expectedAccount2Rewards = ethers.utils.parseEther("1.6");

await expect(acc2HarvestTransaction) .to.emit(stakingManager, "HarvestRewards") .withArgs(account2.address, 0, expectedAccount2Rewards); });

StakerATokens = 100
AccumulatedRewardsPerShare = (10 / 100) + (5 / 500) + (10 / 500) + (5 / 500)
AccumulatedRewardsPerShare = (10 / 100) + (5 / 500)

StakerARewardsOn15to30 = StakerATokens *
(AccumulatedRewardsPerShare - AccumulatedRewardsPerShareOn0to15)
StakerARewardsOn15to30 = 100 * ((10 / 500) + (5 / 500))
StakerARewardsOn15to30 = 3

Так что да, это работает. Это значит, что если мы сохраняем значение AccumulatedRewardsPerShare, умноженное на количество токенов стеекера, каждый раз, когда он вносит депозит или снимает средства, мы можем использовать это значение, чтобы просто вычитать его из их общей суммы наград. В контракте MasterChef это называется rewardDebt. Это похоже на вычисление общих наград стеекера с блока 0, но с вычетом наград, которые он уже собрал, или тех, на которые он не имел права претендовать, потому что ещё не участвовал в стекинге.

Мы также можем подставить некоторые значения, чтобы проверить, действительно ли это работает.

StakerBRewards = StakerBTokens *
(AccumulatedRewardsPerShare - RewardsPerShareOn0to10)

И это очень похоже на формулу, которую мы получили ранее для StakerBRewards.

StakerARewardsOn15to30 = StakerATokens *
(AccumulatedRewardsPerShare - AccumulatedRewardsPerShareOn0to15)

Теперь вы, возможно, заметили, что мы снова можем изолировать StakerATokens.

StakerARewardsOn15to30 = StakerATokens *
AccumulatedRewardsPerShare - StakerATokens *
AccumulatedRewardsPerShareOn0to15

И заменить StakerARewardsOn0to15 в предыдущей формуле.

StakerARewardsOn0to15 = StakerATokens *
AccumulatedRewardsPerShareOn0to15

Теперь мы можем использовать следующую формулу для блоков с 0 по 15.

StakerARewardsOn15to30 = StakerATokens *
AccumulatedRewardsPerShare - StakerARewardsOn0to15

Мы получаем

StakerARewardsOn15to30 = StakerARewards - StakerARewardsOn0to15
StakerARewards = StakerATokens * AccumulatedRewardsPerShare

Если изолировать StakerARewardsOn15to30 в первой формуле и заменить в ней StakerATokens на значение из второй формулы, то

StakerARewards = StakerARewardsOn0to15 + StakerARewardsOn15to30
StakerARewards = StakerATokens * AccumulatedRewardsPerShare

StakerBRewards = StakerBTokens *
(AccumulatedRewardsPerShare - RewardsPerShareOn0to10)

Это важно, потому что хотя мы можем использовать AccumulatedRewardsPerShare для расчёта наград каждого стеекера, нам нужно вычесть RewardsPerShare, которые накопились до их действия по внесению депозита или сбору наград. Давайте выясним, сколько Стеекер A собрал при своём первом сборе наград, используя то, что мы узнали до этого момента.

Поскольку AccumulatedRewardsPerShare одинаково для всех стеекеров, мы можем сказать, что StakerBRewards — это это значение минус награды, которые они не получили за блоки с 0 по 10.

StakerARewards = StakerATokens *
AccumulatedRewardsPerShare

Тогда мы можем сказать, что StakerARewards — это произведение StakerATokens на AccumulatedRewardsPerShare.

AccumulatedRewardsPerShare =
RewardsPerShareOn0to10 +
RewardsPerShareOn10to15 +
RewardsPerShareOn15to25 +
RewardsPerShareOn25to30

А вместо accSushiPerShare мы будем называть их сумму AccumulatedRewardsPerShare.

RewardsPerShareOn0to10 = (10 / 100)
RewardsPerShareOn10to15 = (5 / 500)
RewardsPerShareOn15to25 = (10 / 500)
RewardsPerShareOn25to30 = (5 / 500)

(5 / 500) + (10 / 500) + (5 / 500)

Контракт SushiSwap называет эту сумму accSushiPerShare, поэтому давайте назовём каждое деление RewardsPerShare.

StakerBRewards = 16

StakerBRewards =
400 * ( (5 / 500) +
(10 / 500) +
(5 / 500)
)

StakerBRewards =
StakerBTokens * ( (BlockRewardsOn10to15 / TotalTokensOn10to15) +
(BlockRewardsOn15to25 / TotalTokensOn15to25) +
(BlockRewardsOn25to30 / TotalTokensOn25to30)
)

StakerBRewards =
(BlockRewardsOn10to15 * StakerBTokens / TotalTokensOn10to15) +
(BlockRewardsOn15to25 * StakerBTokens / TotalTokensOn15to25) +
(BlockRewardsOn25to30 * StakerBTokens / TotalTokensOn25to30)

Теперь, когда награды обоих стеекеров совпадают с тем, что мы видели ранее, давайте посмотрим, что можно использовать повторно в расчёте наград для каждого. Как видите, в формулах наград обоих стеекеров есть общая сумма делений.

Что совпадает с нашими ожиданиями. Давайте сделаем то же самое для стеекера B.

StakerARewards = 100 * (
(10 / 100) +
(5 / 500) +
(10 / 500) +
(5 / 500)
)

Мы можем убедиться, что это работает в нашем сценарии, заменив эти сложные слова на числа и вычислив общие награды для Стеекера A.

StakerARewards =
StakerATokens * ( (BlockRewardsOn0to10 / TotalTokensOn0to10) +
(BlockRewardsOn10to15 / TotalTokensOn10to15) +
(BlockRewardsOn15to25 / TotalTokensOn15to25) +
(BlockRewardsOn25to30 / TotalTokensOn25to30)
)

И, выделив StakerATokens, мы получаем следующее.

StakerARewards =
(BlockRewardsOn0to10 * StakerATokens / TotalTokensOn0to10) +
(BlockRewardsOn10to15 * StakerATokens / TotalTokensOn10to15) +
(BlockRewardsOn15to25 * StakerATokens / TotalTokensOn15to25) +
(BlockRewardsOn25to30 * StakerATokens / TotalTokensOn25to30)

Тогда мы можем упростить нашу формулу StakerARewards.

StakerATokensOn0to10 =
StakerATokensOn10to15 =
StakerATokensOn15to25 =
StakerATokensOn25to30 =
StakerATokens

Но в этом случае у стеекера было одинаковое количество токенов, внесённых во всех диапазонах.

StakerARewards =
(BlockRewardsOn0to10 * StakerATokensOn0to10 / TotalTokensOn0to10) +
(BlockRewardsOn10to15 * StakerATokensOn10to15 / TotalTokensOn10to15) +
(BlockRewardsOn15to25 * StakerATokensOn15to25 / TotalTokensOn15to25) +
(BlockRewardsOn25to30 * StakerATokensOn25to30 / TotalTokensOn25to30)

У нас есть это

StakerAShareOnNtoM = StakerATokensOnNtoM / TotalTokensOnNtoM

StakerANtoMRewards = BlockRewardsOnNtoM * StakerAShareOnNtoM

StakerARewards =
StakerA0to10Rewards +
StakerA10to15Rewards +
StakerA15to25Rewards +
StakerA25to30Rewards

А если использовать следующую формулу, которая представляет долю стейкера как отношение его токенов к общему количеству токенов в пуле

Тогда награда стейкера получается как сумма произведений между наградами и его долей участия для каждого диапазона блоков — до текущего момента.

А если рассматривать его вознаграждение с блока N по M как произведение наград, распределённых за этот диапазон, на его долю участия в том же диапазоне.

Функция updateStakersRewards отвечает за проход по всем стейкерам и обновление их накопленных наград каждый раз, когда кто-то делает депозит, вывод или снимает вознаграждение.
Но что, если можно обойтись без этого цикла?

Это целых 20% экономии газа, даже при всего двух стеекерах. Вот почему контракт MasterChef от SushiSwap похож на последний вариант, который я показал. На самом деле он ещё более эффективен, потому что в нём нет отдельной функции harvestRewards — сбор наград происходит при вызове функции deposit с количеством 0.

Для последней (использование AccumulatedRewardsPerShare):

С помощью hardhat-gas-reporter мы можем увидеть, насколько каждая реализация затратна по газу. Для первой (с циклом по всем стеекерам):

Я не смог продолжить свой проект, не разобравшись, как большинство DeFi рассчитывают свои награды, и провёл последние дни, изучая работу контракта SushiSwap. Я смог понять смысл некоторых переменных MasterChef (особенно accSushiPerShare и rewardDebt) только после того, как сам реализовал и поработал с математикой системы наград. Хотя я находил материалы, объясняющие контракт, все они были слишком поверхностными. Поэтому я решил объяснить всё своими словами. Надеюсь, это будет полезно тем, кто тоже изучает DeFi более глубоко.

Заключение

Поскольку accSushiPerShare может быть числом с десятичными знаками, а Solidity не поддерживает числа с плавающей запятой, они умножают sushiReward на большое число, например 1e12, при вычислениях, а затем делят на то же число при использовании.

Что насчёт умножения и деления на 1e12?

Причина, по которой мы избегаем цикла, — это в основном экономия газа. Как вы можете представить, чем больше стеекеров, тем дороже становится функция updateStakersRewards. Мы можем сравнить затраты газа обеих подходов с помощью теста в Hardhat:

Экономия газа

Используя предыдущий контракт в качестве основы, мы можем просто вычислять accumulatedRewardsPerShare в функции updatePoolRewards (переименованной из updateStakersRewards) и получать rewardDebt стеекера каждый раз, когда он выполняет действие.
Вы можете увидеть изменения в коде в этом коммите.

Реализация AccumulatedRewardsPerShare

Мы знаем, что награды, которые получил Стеекер A, — это сумма их первого и последнего сбора наград, то есть за блоки с 0 по 15 и с 15 по 30. Также мы знаем, что можем получить то же значение с помощью формулы StakerARewards, которую мы только что использовали выше.

Выяснение rewardDebt

При таком подходе для каждого действия (депозита или снятия наград) нам приходится проходить по всем стейкерам и пересчитывать их накопленные вознаграждения. Вот простой контракт стейкинга с такой реализацией:

Применим немного математики

it.only("Harvest rewards according with the staker pool's share", async function ()
{ // Arrange Pool
const stakeToken = rewardToken;
await stakeToken.transfer( account2.address, ethers.utils.parseEther("200000") // 200.000 );
await createStakingPool(stakingManager, stakeToken.address);
const amount1 = ethers.utils.parseEther("80");
const amount2 = ethers.utils.parseEther("20");

// Arrange Account1 staking await stakeToken.approve(stakingManager.address, amount1);
await stakingManager.deposit(0, amount1);

// Arrange Account 2 staking await stakeToken.connect(account2).approve(stakingManager.address, amount2);
await stakingManager.connect(account2).deposit(0, amount2);

// Act const acc1HarvestTransaction = await stakingManager.harvestRewards(0);
const acc2HarvestTransaction = await stakingManager .connect(account2) .harvestRewards(0);

// Assert // 2 blocks with 100% participation = 4 reward tokens * 2 blocks = 8 // 1 block with 80% participation = 3.2 reward tokens * 1 block = 3.2 // Account1 Total = 8 + 3.2 = 11.2 reward tokens const expectedAccount1Rewards = ethers.utils.parseEther("11.2");

await expect(acc1HarvestTransaction) .to.emit(stakingManager, "HarvestRewards") .withArgs(account1.address, 0, expectedAccount1Rewards); // 2 block with 20% participation = 0.8 reward tokens * 2 block // Account 1 Total = 1.6 reward tokens const expectedAccount2Rewards = ethers.utils.parseEther("1.6");

await expect(acc2HarvestTransaction) .to.emit(stakingManager, "HarvestRewards") .withArgs(account2.address, 0, expectedAccount2Rewards); });

Это целых 20% экономии газа, даже при всего двух стеекерах. Вот почему контракт MasterChef от SushiSwap похож на последний вариант, который я показал. На самом деле он ещё более эффективен, потому что в нём нет отдельной функции harvestRewards — сбор наград происходит при вызове функции deposit с количеством 0.

Для последней (использование AccumulatedRewardsPerShare):

С помощью hardhat-gas-reporter мы можем увидеть, насколько каждая реализация затратна по газу. Для первой (с циклом по всем стеекерам):

Я не смог продолжить свой проект, не разобравшись, как большинство DeFi рассчитывают свои награды, и провёл последние дни, изучая работу контракта SushiSwap. Я смог понять смысл некоторых переменных MasterChef (особенно accSushiPerShare и rewardDebt) только после того, как сам реализовал и поработал с математикой системы наград. Хотя я находил материалы, объясняющие контракт, все они были слишком поверхностными. Поэтому я решил объяснить всё своими словами. Надеюсь, это будет полезно тем, кто тоже изучает DeFi более глубоко.

Заключение

Поскольку accSushiPerShare может быть числом с десятичными знаками, а Solidity не поддерживает числа с плавающей запятой, они умножают sushiReward на большое число, например 1e12, при вычислениях, а затем делят на то же число при использовании.

Что насчёт умножения и деления на 1e12?

(Перевод)
Виталий Дорожко
Автор
Поделиться
Иконка поделиться
Ссылка
Иконка копировать
Иконка скопированно
Назад в блог
Кнопка назад
Оригинал статьи
кнопка вперед
Поделиться
Иконка поделиться
Ссылка
Иконка копировать
Иконка скопировано
Назад в блог
Кнопка назад
Оригинал статьи
кнопка вперед
сетка BlockFirst
сетка BlockFirst
сетка BlockFirst

Для запросов от пользователей

hello@blockfirst.io

Icon mail

Для бизнес запросов

business@blockfirst.io

Icon mail

Телеграм для быстрых ответов

Icon mail

компания

Сообщество

медиа

Подписываясь на рассылку, вы можете быть уверены, что мы не будем спамить Вам :)

Новости. Скидки. Анонсы

В начало
© 2025-2026 BlockFirst. Все права защищены.
Сетка BlockFirst
hello@blockfirst.io
Для коммерческих предложений
Компания
Телеграм для быстрых ответов
Кнопка копировать
Скопировано