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.sol | mint, collect, decreaseLiquidity, стейк в MasterChefV3, harvest, view-расчёты |
libs/AaveV3FarmLib.sol | supply, withdraw, claimAllRewards |
libs/RayMath.sol | арифметика RAY для scaled-баланса Aave (internal) |
interfaces/IFarmingExternal.sol | IMasterChefV3, 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 NonfungiblePositionManager | 0xC36442b4a4522E871399CD717aBDD847Ab11FE88 | 0x46A15B0b27311cedF172AB29E4f4766fbE7F4364 |
| MasterChefV3 | нет фарма | 0x556B9306565093C855AEA9AE92A594704c2Cd59e |
| CAKE | — | 0x0E09FaBB73Bd3Ade0a17ECC321fD13a19e81cE82 |
| Aave V3 Pool | 0x794a61358D6845594F94dc1DB02A252b5b4814aD | 0x6807dc923806fE8Fd134338EABCA509979a7e0cB |
| Aave RewardsController | 0x929EC64c34a17401F460460D4B9390518E5B473e | 0xC206C2764A9dBF27d599613b8F9A63ACd1160ab4 |
Учёт позиции
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-токен.
- Статические получатели — мгновенный трансфер. Провал → доля в
fallbackTreasury, клейм не падает. - Партнёрка — по режиму (см. ниже).
- Остаток уходит пользователю.
Незарегистрированный пользователь. 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_RECIPIENTS | JSON-массив { wallet, shareBps } |
MAGNET_FARMING_PARTNER_LINE_BPS | 15 значений через запятую |
Скрипт сам деплоит библиотеки, линкует их, поднимает прокси и заводит рынки той сети, в которую идёт деплой. Режим партнёрки выбирается по сети: Polygon — Onchain, остальные — Reserved.