Разработка системы распределения прибыли фонда
Когда фонд фиксирует прибыль и нужно распределить её между LP, простой расчёт "делим пропорционально долям" не работает. Разные LP зашли в разные моменты по разным ценам, часть из них запросила partial redemption, кто-то получал interim distributions, у менеджера накопился performance fee. Система распределения прибыли должна быть математически точной, аудитабельной и устойчивой к манипуляциям. Разберём как это делается правильно.
Модели распределения прибыли
Прежде чем писать контракты, выбираем фундаментальную модель учёта долей:
Share-based (токенизированные доли)
LP получают ERC-20 shares при входе. Прибыль отражается через рост NAV per share — один share стоит больше, но количество shares не меняется. Распределение прибыли означает либо рост стоимости shares (capital appreciation), либо выплату дивидендов с уменьшением NAV per share.
Плюсы: стандарт EIP-4626, легко интегрируется с DeFi. Минусы: сложность при частичных выходах и interim distributions.
Point-based (накопительные очки)
Каждый LP накапливает "очки" пропорционально времени и сумме депозита. Прибыль делится пропорционально очкам. Подходит для yield-фондов с регулярными выплатами.
// Паттерн "rewards per token" — стандарт для point-based
contract ProfitDistributor {
uint256 public rewardPerShareStored;
uint256 public lastUpdateTime;
uint256 public totalShares;
mapping(address => uint256) public rewardPerSharePaid;
mapping(address => uint256) public pendingRewards;
mapping(address => uint256) public shares;
modifier updateReward(address account) {
rewardPerShareStored = rewardPerShare();
lastUpdateTime = block.timestamp;
if (account != address(0)) {
pendingRewards[account] = earned(account);
rewardPerSharePaid[account] = rewardPerShareStored;
}
_;
}
function earned(address account) public view returns (uint256) {
return shares[account]
* (rewardPerShare() - rewardPerSharePaid[account])
/ 1e18
+ pendingRewards[account];
}
}
Этот паттерн ("Synthetix staking rewards") — проверенный способ эффективно считать накопленные rewards без итерации по всем LP.
Сложные случаи: что ломает простые системы
Входы и выходы в середине периода
Если LP зашёл в середине квартала, он не должен получать прибыль за период до его входа. И наоборот — при выходе в середине периода он должен получить свою долю начисленного, но ещё не распределённого.
Решение: snapshot-based distribution. При каждом изменении состояния (вход/выход LP) фиксируем checkpoint с текущим rewardPerShare. При расчёте используем разницу между текущим и checkpoint-значением:
function _updateCheckpoint(address lp) internal {
uint256 currentRPS = rewardPerShareStored;
uint256 lpShares = shares[lp];
uint256 lastRPS = checkpoints[lp].rewardPerShare;
if (lpShares > 0 && currentRPS > lastRPS) {
uint256 accrued = lpShares * (currentRPS - lastRPS) / PRECISION;
checkpoints[lp].pendingReward += accrued;
}
checkpoints[lp].rewardPerShare = currentRPS;
}
Performance fee с High Water Mark
Performance fee (обычно 20%) должна браться только с новой прибыли — выше предыдущего HWM. Это защита LP от двойного fee после просадки и восстановления.
Нюанс: HWM считается per-LP или globally? Два подхода:
Global HWM — один HWM для всего фонда. Простота, но несправедливость: LP который зашёл на пике не платит fee пока фонд не вернётся выше этого пика, даже если для нового LP это прибыль.
Per-LP HWM — у каждого LP свой HWM = NAV per share на момент его входа. Более справедливо, но сложнее в реализации и gas-затратно при многих LP.
mapping(address => uint256) public lpHighWaterMark; // NAV per share при входе LP
function calculatePerformanceFee(address lp, uint256 currentNAVPerShare)
public view returns (uint256 feeAmount)
{
uint256 hwm = lpHighWaterMark[lp];
if (currentNAVPerShare <= hwm) return 0;
uint256 profitPerShare = currentNAVPerShare - hwm;
uint256 lpShareBalance = shares[lp];
// fee = profit_per_share * lp_shares * fee_rate / PRECISION
feeAmount = profitPerShare * lpShareBalance * performanceFeeRate
/ (PRECISION * 10000);
}
Hurdle rate
Некоторые фонды берут performance fee только если доходность превысила benchmark (hurdle rate, например 8% годовых). Реализуется как дополнительный порог поверх HWM:
function isAboveHurdle(address lp, uint256 currentNAVPerShare) public view returns (bool) {
uint256 entryNAV = lpEntryNAV[lp];
uint256 entryTime = lpEntryTime[lp];
uint256 elapsed = block.timestamp - entryTime;
// Hurdle rate = entryNAV * (1 + annualRate * elapsed / YEAR)
uint256 hurdleNAV = entryNAV + entryNAV * hurdleRate * elapsed / (10000 * 365 days);
return currentNAVPerShare > hurdleNAV;
}
Механизм выплат: токены vs native
Реинвестирование (default): прибыль автоматически увеличивает NAV per share. LP не получает выплату — растёт стоимость его shares. Газово эффективно, не требует on-chain транзакций.
Cash distribution: менеджер инициирует распределение, LP клеймят свои выплаты. Два паттерна:
Pull pattern (рекомендуется): контракт хранит накопленные rewards, LP клеймит сам. Менеджер не знает адреса всех LP, нет риска gas exhaustion при большом числе LP.
function claimRewards() external updateReward(msg.sender) {
uint256 reward = pendingRewards[msg.sender];
require(reward > 0, "Nothing to claim");
pendingRewards[msg.sender] = 0;
IERC20(rewardToken).safeTransfer(msg.sender, reward);
emit RewardClaimed(msg.sender, reward);
}
Push pattern — менеджер рассылает всем LP. Проблема: если LP — смарт-контракт который reverts при получении токенов, вся транзакция упадёт. Или gas limit кончится при 1000+ LP. Используйте только для малого числа LP и после whitelist-проверки.
Merkle-based distribution для масштаба
При сотнях и тысячах LP — Merkle drop паттерн. Менеджер вычисляет выплаты off-chain, строит Merkle tree, публикует root on-chain. Каждый LP клеймит свою выплату с proof:
bytes32 public merkleRoot;
function claimMerkle(
uint256 index,
address account,
uint256 amount,
bytes32[] calldata merkleProof
) external {
require(!isClaimed(index), "Already claimed");
bytes32 node = keccak256(abi.encodePacked(index, account, amount));
require(MerkleProof.verify(merkleProof, merkleRoot, node), "Invalid proof");
_setClaimed(index);
IERC20(rewardToken).safeTransfer(account, amount);
emit Claimed(index, account, amount);
}
Это даёт O(log N) gas для клейма независимо от числа получателей. Uniswap использует этот паттерн для UNI-дистрибуции.
Налоговая и регуляторная отчётность
Каждое распределение должно генерировать отчёт для LP: дата, сумма, тип (return of capital / capital gain / ordinary income). В разных юрисдикциях классификация дохода разная.
Минимальный набор данных для каждого события распределения:
- Timestamp и block number
- LP address и идентификатор (KYC data хранится off-chain)
- Сумма в токенах и USD-эквивалент на момент distribution
- NAV per share до и после distribution
- Accumulated fee, удержанный менеджером
Хэш каждого distribution report публикуется on-chain — LP могут верифицировать что off-chain данные не изменялись.
Безопасность и аудит
Reentrancy — классическая уязвимость при выплатах. Всегда обнулять pendingRewards[msg.sender] до transfer, не после. OpenZeppelin ReentrancyGuard как дополнительный слой защиты.
Precision loss при делении. Все вычисления в fixed-point арифметике с масштабом 1e18. rewardPerShare хранить с максимальной точностью, округлять вниз при выплатах (в пользу контракта, не LP — иначе контракт опустеет раньше времени).
Oracle manipulation при расчёте performance fee. Если fee считается от NAV per share, а NAV зависит от DEX-цен — flash loan атака может временно поднять NAV, триггернуть settleFee, забрать fee, восстановить NAV. Защита: TWAP вместо spot prices, cooldown между NAV updates и fee settlements.
Централизация менеджера. Менеджер с правом updateNAV и settleFee имеет большую власть. Минимизация через timelock (операции вступают в силу через 24-48 часов) и multi-sig (3-of-5 для fee settlement).
Тестирование
Инвариантные тесты через Foundry (Invariant Testing):
-
sum(lpShares) == totalSupply()— всегда -
sum(pendingRewards) <= contractBalance(rewardToken)— контракт не может быть неплатёжеспособен - После любой последовательности deposit/withdraw/distribute — earned() должен быть корректен
// Foundry invariant test
contract ProfitDistributorInvariant is Test {
ProfitDistributor distributor;
function invariant_solvency() public {
uint256 totalPending = 0;
for (uint i = 0; i < lps.length; i++) {
totalPending += distributor.earned(lps[i]);
}
assertLe(totalPending, IERC20(rewardToken).balanceOf(address(distributor)));
}
}
Процесс разработки
Проектирование (3-5 дней). Выбор модели (share-based vs point-based), структуры fee, HWM per-LP vs global, механизма выплат. Формализация инвариантов системы.
Разработка смарт-контрактов (7-10 дней). Vault, distributor, fee module. Юнит-тесты + fuzz-тесты + инвариантные тесты на Foundry.
Off-chain компоненты (3-5 дней). NAV calculator, Merkle tree builder, скрипты клейма.
Аудит (1-2 недели). Внешний аудит обязателен для любой системы управляющей чужими деньгами.
Деплой и мониторинг (2-3 дня). Graduated rollout, мониторинг инвариантов в реальном времени.







