Перейти к основному содержимому

MagnetFarming

Обзор​

MagnetFarming — прослойка между пользователем Magnet и внешними доходными протоколами. Пользователь выбирает рынок, деньги уходят напрямую в пул концентрированной ликвидности (Uniswap V3 в Polygon, PancakeSwap V3 со стейкингом в MasterChefV3 в BSC) или в Aave v3. Накопленную прибыль пользователь клеймит в любой момент, тело выводит в любой момент.

Ключевые свойства:

  • Свопов внутри контракта нет. Пользователь входит и выходит в исходных токенах. Нет payToken, нет payoutToken, нет интеграции с агрегаторами.
  • Комиссия удерживается только с прибыли. Депозит и вывод тела — без удержаний.
  • Позиции — обычный mapping, без ERC-721. Позиция не передаётся и не торгуется.
  • UUPS, AccessControl, Pausable, ReentrancyGuard. Solidity 0.8.27, viaIR, optimizer runs=1.
  • Пауза блокирует только депозиты. Клейм и вывод работают всегда, иначе пауза превращается в заморозку чужих денег.
  • Два режима партнёрки: ончейн-чтение из MagnetDiamond и резерв с последующим разносом кипером.

Состав модуля​

ФайлОтветственность
contracts/MagnetFarming/MagnetFarming.solядро: рынки, позиции, комиссия, партнёрка, кипер
storage/FarmingStorage.solраскладка хранилища с __gap
libs/UniswapV3FarmLib.solmint, collect, decreaseLiquidity, стейк в MasterChefV3, harvest, view-расчёты
libs/AaveV3FarmLib.solsupply, withdraw, claimAllRewards
libs/RayMath.solарифметика RAY для scaled-баланса Aave (internal)
interfaces/IFarmingExternal.solIMasterChefV3, IAavePool, IAToken, IRewardsController

UniswapV3FarmLib и AaveV3FarmLib — внешние библиотеки, линкуются при деплое. Без этого имплементация не помещается в лимит EIP-170: сейчас она занимает 23 817 байт из 24 576, запас 759 байт. Контроль в test/ContractSize.test.ts.

FeeViewLib из MagnetLiquidity переиспользуется для view-расчёта несобранных комиссий и линкуется внутрь UniswapV3FarmLib, а не в сам контракт.

Рынки​

enum MarketKind { UniswapV3, AaveV3 }

struct Market { MarketKind kind; bool enabled; }
struct DexMarket { address token0; address token1; uint24 poolFee;
address positionManager; address masterChef; address cakeToken; }
struct AaveMarket { address asset; address aToken; address pool; address rewardsController; }

masterChef == address(0) означает, что фарма у пула нет и NFT-позиция остаётся на контракте. rewardsController == address(0) полностью выключает путь наград ликвидити-майнинга.

Адреса протоколов (проверены вызовом eth_getCode):

Polygon (137)BSC (56)
V3 NonfungiblePositionManager0xC36442b4a4522E871399CD717aBDD847Ab11FE880x46A15B0b27311cedF172AB29E4f4766fbE7F4364
MasterChefV3нет фарма0x556B9306565093C855AEA9AE92A594704c2Cd59e
CAKE—0x0E09FaBB73Bd3Ade0a17ECC321fD13a19e81cE82
Aave V3 Pool0x794a61358D6845594F94dc1DB02A252b5b4814aD0x6807dc923806fE8Fd134338EABCA509979a7e0cB
Aave RewardsController0x929EC64c34a17401F460460D4B9390518E5B473e0xC206C2764A9dBF27d599613b8F9A63ACd1160ab4

Учёт позиции​

struct Position {
address owner;
uint256 marketId;
bool closed;
uint256 nftId; // UniswapV3
uint128 liquidity; // UniswapV3
uint256 aaveScaled; // AaveV3
uint256 principal; // AaveV3
}

Докладывать в существующую позицию нельзя: повторный депозит создаёт новую. У пользователя может быть сколько угодно позиций в одном рынке.

Uniswap и PancakeSwap V3​

Прибыль — то, что отдаёт collect() у позиции, у которой ликвидность ещё не уменьшали: накопленные торговые комиссии в token0 и token1. Плюс masterChef.harvest() → CAKE, если позиция застейкана. Тело и комиссии разделяет сам протокол.

Когда позиция застейкана, collect и decreaseLiquidity идут через MasterChefV3: NFT принадлежит фарму, и его интерфейс повторяет NPM для этих методов.

Aave v3: тело и проценты​

aToken ребейзится, поэтому тело и проценты разделяются через scaled-баланс:

index    = pool.getReserveNormalizedIncome(asset)     // RAY, монотонно растёт

deposit: aaveScaled += amount.rayDiv(index); principal += amount
value = aaveScaled.rayMulDown(index)
profit = value > principal ? value - principal : 0
claim: withdraw(profit) → aaveScaled -= received.rayDivUp(index) // principal не трогаем
withdraw: withdraw(amount) → aaveScaled -= received.rayDivUp(index); principal -= amount

Округление всегда в пользу контракта. Оценка покрытия (rayMulDown) округляется вниз, списание доли (rayDivUp) — вверх. Без этого полный выход уходил в переполнение: позиция запрашивала у Aave на единицу больше, чем покрывала её scaled-доля. При полном выходе остаточная пыль списывается вместе с позицией и остаётся на контракте.

Запрос дополнительно ограничен фактическим балансом aToken. И клейм процентов, и вывод тела перед обращением к Aave урезают сумму до aToken.balanceOf(address(this)). Aave реверит с NotEnoughAvailableUserBalance, если попросить больше, чем реально лежит, и расхождение внутреннего учёта с балансом в одну единицу округления заблокировало бы пользователю вывод целиком. Форк-тест на BSC ловил ровно этот ревёрт. Цена страховки — один view-вызов.

Aave v3: награды RewardsController​

Награды начисляются на контракт целиком, поэтому распределяются накопителем в стиле MasterChef:

totalScaled[marketId]
accRewardPerScaled[marketId][rewardToken] // масштаб 1e18
rewardDebt[positionId][rewardToken]

pending = aaveScaled * accRewardPerScaled / 1e18 - rewardDebt

_harvestAaveRewards(marketId) вызывается до любого изменения totalScaled — в депозите, клейме и выводе. Иначе доли вкладчиков разъезжаются.

totalScaled ведётся всегда, даже при выключенном контроллере: восстановить его задним числом нельзя.

На 2026-09-20 ликвидити-майнинг на рабочих рынках не начисляется (Polygon: настроен только на stMATIC и MaticX с нулевым APR; BSC: ни на одном из 8 рынков). Поэтому в проде rewardsController ставится в ноль, а код остаётся готовым к запуску кампании.

Комиссия​

Проценты считаются от суммы прибыли, не от комиссии — как в FeeRouter и MagnetLiquidity.

Recipient[] claimRecipients;   // «иные распределения», платятся мгновенно
uint16[15] partnerLineBps; // партнёрка по 15 линиям

totalFeeBps = Σ claimRecipients.shareBps + Σ partnerLineBps // потолок 5000 (50%)

_takeFee(token, gross, user) выполняется отдельно для каждого токена прибыли: token0, token1, CAKE, underlying Aave, каждый reward-токен.

  1. Статические получатели — мгновенный трансфер. Провал → доля в fallbackTreasury, клейм не падает.
  2. Партнёрка — по режиму (см. ниже).
  3. Остаток уходит пользователю.

Незарегистрированный пользователь. CoreLib.getUserPartners реверит для адреса, которого нет в Diamond. Вызов обёрнут в try/catch: не зарегистрирован → вся партнёрская доля уходит в fallbackTreasury, клейм проходит. Без этого любой пользователь не из Diamond вообще не смог бы забрать прибыль.

Партнёрка: два режима​

PartnerMode.Onchain (Polygon)​

Цепочка читается прямо из Diamond: getUserPartnersByAddress(user, partnerLevel) → address[15]. Доли уходят мгновенно, в той же транзакции клейма. Пустая или сломанная линия → fallbackTreasury.

PartnerMode.Reserved (BSC и прочие сети)​

На клейме доли раскладываются по линиям и резервируются:

_reserved[user][token][line] += gross * partnerLineBps[line] / 10000;
reservedTotal[token] += ...;

Позже кипер с ролью KEEPER_ROLE разносит их:

function settlePartnerRewards(address user, address token, address[15] calldata wallets) external;
function batchSettlePartnerRewards(SettleParams[] calldata items) external;

struct SettleParams { address user; address token; address[15] wallets; }

Кипер присылает только кошельки. Суммы зафиксированы в момент клейма по действующим ончейн-процентам — подменить их кипер не может, только указать адресата. Пустой wallets[i] или провал трансфера → в fallbackTreasury. Повторный вызов ничего не платит: суммы обнулены.

Побочный эффект такой фиксации полезен: админ может менять partnerLineBps когда угодно, и это не переписывает задним числом уже заработанное.

Защита резерва​

reservedTotal[token] учитывает всё зарезервированное и ещё не разнесённое. rescueTokens проверяет:

uint256 balance = IERC20(token).balanceOf(address(this));
if (balance < locked || amount > balance - locked) revert FarmingAmountReserved();

Без этой проверки админ одной кнопкой вымел бы деньги партнёров.

Роли​

РольПрава
DEFAULT_ADMIN_ROLEрынки, комиссии, проценты, fallbackTreasury, rescueTokens, пауза, апгрейд
KEEPER_ROLEтолько settlePartnerRewards и batchSettlePartnerRewards

Публичный ABI​

Депозит​

function depositUniswapV3(
uint256 marketId,
uint256 amount0Desired, uint256 amount1Desired,
uint256 amount0Min, uint256 amount1Min,
int24 tickLower, int24 tickUpper,
uint256 deadline
) external returns (uint256 positionId);

function depositAaveV3(uint256 marketId, uint256 amount) external returns (uint256 positionId);

Перед вызовом нужен approve на адрес контракта: для V3 — по обоим токенам. Неизрасходованный остаток возвращается в той же транзакции. tickLower/tickUpper считает фронт и обязан выровнять по tickSpacing пула, иначе mint ревертит.

Клейм и вывод​

function claimProfit(uint256 positionId)
external returns (address[] memory tokens, uint256[] memory amountsReceived);

function withdrawUniswapV3(uint256 positionId, uint128 liquidityToWithdraw,
uint256 amount0Min, uint256 amount1Min, uint256 deadline)
external returns (uint256 amount0, uint256 amount1);

function withdrawAaveV3(uint256 positionId, uint256 amount) external returns (uint256 withdrawn);

claimProfit единый для обоих протоколов — фронт не разбирает тип рынка. Оба withdraw клеймят прибыль автоматически внутри, отдельно дёргать claimProfit не нужно. Ноль в размере вывода означает полный выход: позиция закрывается, NFT снимается со стейка и сжигается.

Порядок в выводе V3 критичен. Сначала collect — он отдаёт только торговые комиссии, потому что ликвидность ещё не уменьшали. Затем decreaseLiquidity и второй collect — это тело, без удержаний. Перепутанный порядок означал бы удержание комиссии с тела пользователя.

View​

function getUserPositions(address user) external view returns (uint256[] memory);
function getPosition(uint256 positionId) external view returns (Position memory);
function pendingProfit(uint256 positionId)
external view returns (address[] memory tokens, uint256[] memory gross, uint256[] memory net);
function positionBody(uint256 positionId)
external view returns (address[] memory tokens, uint256[] memory amounts);
function marketsCount() external view returns (uint256);
function getMarket(uint256 marketId)
external view returns (Market memory, DexMarket memory, AaveMarket memory);
function totalFeeBps() external view returns (uint16);
function reservedPartnerRewards(address user, address token)
external view returns (uint256 total, uint256[15] memory byLine);

pendingProfit — главный метод для экрана позиции. Форк-тест на Polygon подтвердил, что его gross совпадает с фактически собранной суммой до wei.

События​

event Deposited(uint256 indexed positionId, address indexed owner, uint256 indexed marketId,
address[] tokens, uint256[] amounts);
event ProfitClaimed(uint256 indexed positionId, address indexed owner,
address[] tokens, uint256[] gross, uint256[] net);
event BodyWithdrawn(uint256 indexed positionId, address indexed owner,
address[] tokens, uint256[] amounts, bool closed);
event FeeDistributed(address indexed token, uint256 gross,
uint256 toRecipients, uint256 toPartners, uint256 toFallback);
event PartnerRewardReserved(address indexed user, address indexed token, uint8 line, uint256 amount);
event PartnerRewardsSettled(address indexed user, address indexed token,
uint256 settled, uint256 toFallback);

Инструкция для бэкенда: кипер​

В сетях с PartnerMode.Reserved бэкенд слушает PartnerRewardReserved, по user восстанавливает партнёрскую цепочку из Diamond на Polygon и вызывает:

await farming.connect(keeper).batchSettlePartnerRewards([
{ user, token, wallets }, // wallets — ровно 15 адресов, нулевой = нет адресата
]);

Кошелёк кипера должен иметь KEEPER_ROLE. Суммы бэкенд не передаёт и не контролирует.

Деплой​

pnpm --filter=contract deploy-farming            # localhost
pnpm --filter=contract deploy-farming-polygon
pnpm --filter=contract deploy-farming-bsc

Переменные окружения:

ПеременнаяНазначение
MAGNET_FARMING_PARTNER_PROVIDERадрес Diamond, обязателен для Polygon
MAGNET_FARMING_FALLBACK_TREASURYадрес для долей без адресата, по умолчанию деплойер
MAGNET_FARMING_KEEPERкошелёк, которому выдаётся KEEPER_ROLE
MAGNET_FARMING_PARTNER_LEVELуровень программы для чтения цепочки, по умолчанию 1
MAGNET_FARMING_CLAIM_RECIPIENTSJSON-массив { wallet, shareBps }
MAGNET_FARMING_PARTNER_LINE_BPS15 значений через запятую

Скрипт сам деплоит библиотеки, линкует их, поднимает прокси и заводит рынки той сети, в которую идёт деплой. Режим партнёрки выбирается по сети: Polygon — Onchain, остальные — Reserved.

Тесты​

# юнит: локальный настоящий Uniswap V3 и моки Aave
# HARDHAT_FORKING=false обязателен: в .env проекта форк включён глобально,
# и без этого флага локальные прогоны молча ходят в мейннет по RPC
HARDHAT_FORKING=false npx hardhat test test/MagnetFarming.test.ts test/ContractSize.test.ts

# форк Polygon: живые Uniswap V3 и Aave v3
HARDHAT_FORKING=true HARDHAT_FORK_NETWORK=polygon \
npx hardhat test test/MagnetFarmingPolygon.fork.test.ts

# форк BSC: живые PancakeSwap V3, MasterChefV3 и Aave v3
HARDHAT_FORKING=true HARDHAT_FORK_NETWORK=bsc \
npx hardhat test test/MagnetFarmingBsc.fork.test.ts

Токены в форк-тестах достаются без китов: нативная монета выдаётся через hardhat_setBalance, заворачивается в WPOL/WBNB, часть меняется на стейбл через живой роутер.

Начисление CAKE продвигается только при обращении к пулу. pendingCake читает LMPool.getRewardGrowthInside, а LMPool двигается в accumulateReward, который дёргает сам пул при свопе. Перемотки времени самой по себе недостаточно: без обращений к пулу pendingCake останется нулём. В проде это незаметно — популярный пул торгуется постоянно, — но pendingProfit может кратковременно занижать CAKE на тихом пуле. MasterChefV3.updatePools доступен только оператору, так что принудительно продвинуть начисление контракт не может.

Ограничение BSC-теста: перематывать время можно не больше чем на 6 часов. MasterChefV3.latestPeriodEndTime опережает текущий блок примерно на сутки, за этой отметкой CAKE перестаёт начисляться, и тест проверял бы пустоту. Перед стейкингом тест явно проверяет v3PoolAddressPid(pool) != 0 — если фарм закроют, тест упадёт с внятной причиной, а не молча.

Известные ограничения​

  1. Запас байткода 759 байт. Любое заметное расширение ядра потребует выноса логики в библиотеки. Страж — ContractSize.test.ts.
  2. Пыль округления Aave при полном выходе остаётся на контракте. Забирается админским rescueTokens — он видит её как свободный остаток.
  3. Свопов нет. Фронт обязан завести оба токена пула сам; «войти одним USDT» не получится без внешнего свопа до вызова.
  4. Ребалансировка диапазона V3 не поддерживается. Вышедшая из диапазона позиция перестаёт зарабатывать, пока пользователь не закроет её и не откроет заново.