Розробка контрактів yield farming
Контракт yield farming розподіляє винагороди між постачальниками ліквідності пропорційно їхній частці в пулі. Математика простою, але реалізація повна нюансів: від помилок у формулах накопленої винагороди до вразливостей, які дозволяють спорожнити пул винагород через маніпуляцію депозитом. Найвідоміший приклад — MasterChef від SushiSwap, чиї форки коштували протоколам десятків мільйонів через різні помилки реалізації.
Формула накопленої винагороди: чому наївна реалізація не працює
Наївний підхід: зберігати для кожного користувача lastClaimedBlock і обчислювати винагороди як (currentBlock - lastClaimedBlock) * rewardPerBlock * userShare. Проблема в тому, що userShare змінюється при кожному депозиті/виведенні від інших користувачів. Перерахування для всіх користувачів при кожній зміні — це операція O(n), яка при 1000 учасниках коштує мільйони газу.
Алгоритм MasterChef (стиль Compound) вирішує це через accRewardPerShare — накопленої винагороди за одиницю stake, яка тільки зростає:
accRewardPerShare += (newRewards / totalStaked)
Для кожного користувача зберігається rewardDebt — "борг" на момент останньої взаємодії:
rewardDebt = userAmount * accRewardPerShare
pendingReward = (userAmount * accRewardPerShare) - rewardDebt
При депозиті/виведенні оновлюємо accRewardPerShare для поточного моменту, виплачуємо очікувані винагороди та оновлюємо rewardDebt. Це O(1) незалежно від кількості учасників.
Проблема з цілими числами: accRewardPerShare зберігається помноженим на 1e12 (або 1e18 для токенів з 18 десятковими знаками), щоб уникнути втрати точності при діленні. Без цього множення при малих депозитах та великому totalStaked накопленої винагороди заокруглюється до 0.
Основні вразливості контрактів farming
Маніпуляція harvest через flash loan
Атака: в одній транзакції через flash loan взяти великий кредит, задепозитити в контракт farming, зібрати непропорційно велику частку накопленої винагороди, вивести депозит, повернути flash loan. Працює, якщо harvest() не вимагає мінімального часу стейкінгу.
Захист: мінімальний період блокування (навіть 1 блок значно ускладнює атаку) або snapshot-based винагороди (винагороди розподіляються за балансом на момент snapshot, а не поточним).
Не всі протоколи застосовують період блокування — це компроміс UX. Якщо період блокування неприйнятний, формула повинна бути розроблена так, щоб миттєвий депозит-harvest-виведення не давав прибутку (через збір депозиту/виведення).
Reentrancy через harvest + ERC-777
Якщо токен винагороди — ERC-777 (або будь-який токен з hook при передачі), то під час виплати винагороди токен викликає callback у одержувача. Якщо callback повторно викликає harvest() або withdraw() — це reentrancy. Стандартний захист через ReentrancyGuard від OpenZeppelin. Важливо: guard повинен бути на всіх функціях, які змінюють стан І взаємодіють із зовнішніми контрактами.
Вичерпання токена винагороди
Контракт обіцяє rewardPerBlock, але не перевіряє, чи достатньо токенів у пулі винагород. Якщо пул винагород порожній, transfer скасовується — користувачі не можуть ні отримати винагороди, ні вивести депозит (якщо harvest вбудований у withdraw). Паттерн: при виведенні спочатку вивести stake, потім спробувати виплатити винагороди з обробкою недостатнього балансу.
Реалізація з підтримкою кількох пулів
Розширення MasterChef для кількох staking токенів (multi-pool farming):
struct PoolInfo {
IERC20 stakingToken;
uint256 allocPoint; // вага пула в розподілі винагород
uint256 lastRewardBlock;
uint256 accRewardPerShare; // помножено на 1e12
uint256 totalStaked;
}
struct UserInfo {
uint256 amount;
uint256 rewardDebt;
}
PoolInfo[] public poolInfo;
mapping(uint256 => mapping(address => UserInfo)) public userInfo;
uint256 public rewardPerBlock;
uint256 public totalAllocPoint;
allocPoint розподіляє rewardPerBlock між пулами: пул з allocPoint = 100 при totalAllocPoint = 200 отримує 50% винагород. Це дозволяє керувати стимулами без зміни загальної швидкості емісії.
Комісія за депозит та захист від маніпуляцій китів
Комісія депозиту (0,1-0,5%) — додатковий механізм проти flash loan атак та джерело доходу казначейства. Реалізується як вичеркнення при депозиті:
uint256 depositFee = (amount * depositFeeBP) / 10000;
uint256 amountAfterFee = amount - depositFee;
stakingToken.safeTransfer(feeRecipient, depositFee);
depositFeeBP у базових пунктах (100 = 1%). Зміна depositFeeBP через governance з timelock обов'язкова, інакше власник може встановити 100% комісію та конфіскувати всі депозити.
Стек та тестування
Foundry для тестування: fuzzing граничних значень accRewardPerShare, тести сценаріїв багатьох користувачів (10 користувачів з різними депозитами/виведеннями), перевірка інваріантів через тести invariant.
Ключовий інваріант: SUM(pendingRewards для всіх користувачів) <= balance(rewardToken) у контракті. Порушення цього інваріанту означає, що контракт обіцяє більше, ніж має.
Echidna для property-based тестування математики винагород — генерує випадкові послідовності операцій та перевіряє, що інваріанти не порушуються.
Процес та терміни
Проектування (1 день). Вибір моделі винагород (single token vs multi-pool), параметри (rewardPerBlock, depositFee, lock period), модель governance для зміни параметрів.
Розробка (2-3 дні). Базова реалізація та розширення. OpenZeppelin для ReentrancyGuard, SafeERC20, Ownable. Користувацька логіка мінімальна.
Тестування (1-2 дні). Foundry fuzzing, сценарії багатьох користувачів, граничні випадки: нульовий totalStaked, переповнення при множенні, вичерпання пула винагород.
Всього: 3-5 днів до контракту готового до аудиту. Для продакшну рекомендуємо зовнішній аудит — контракти farming зберігають TVL та активно атакуються.







