FeeRouter
Обзор
FeeRouter — единая точка входа для всех свопов внутри экосистемы Magnet на Polygon. Контракт удерживает комиссию с входящего токена, распределяет её между статическими получателями и 15 линиями партнёрской сети (через MagnetDiamond.getUserPartnersByAddress), затем направляет остаток через Uniswap V3 SwapRouter02.
Ключевые свойства:
- Иммютабельный контракт (без UUPS). Solidity 0.8.27, viaIR, optimizer runs=1.
- Ownable2Step +
Pausable+ReentrancyGuard. Owner — multisig. - Единый
FeeConfig(без разделения Buy/Sell). Размер комиссии настраивается owner'ом, ограниченMAX_FEE_BPS = 1000(10%). - Allowlist пар через
mapping(tokenA => mapping(tokenB => bool)). Запретить можно одним вызовом. - try/catch на трансферах комиссии — если адрес одного из получателей сломан, его доля идёт в
fallbackTreasury, а свопу ничто не мешает завершиться. - Комиссия удерживается в входящем токене (
tokenIn), до похода в Uniswap. Это упрощает учёт и избавляет от ситуаций «комиссия в неликвидном выходном токене». feePayerпередаётся отдельным параметром: для обычного свопа это пользователь, для лимит-ордера — maker (чтобы партнёрка работала корректно).
Контекст и зависимости
- MagnetDiamond — источник партнёрских цепочек. Используется функция
getUserPartnersByAddress(address, uint8 programLevel) returns (uint32[15] ids, address[15] addresses). По умолчаниюpartnerLevel = 1, можно изменить. - Uniswap V3 SwapRouter02 — на Polygon
0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45. ВызываетсяexactInputSingle(single-hop). Pool fee tier (uint24 poolFee) передаётся caller'ом вswap(). - LimitOrderBook — вызывает
swap()сfeePayer = maker, чтобы партнёрская доля шла maker'у, а не исполнителю.
Состояние
feeConfig(приватный) — структураFeeConfig { totalFeeBps, staticRecipients[], partnerLineBps[15] }. Геттер — публичная функцияfeeConfig().partnerProvider— адресMagnetDiamond(или другого контракта-реализатораIPartnerProvider).partnerLevel—uint8, по умолчанию1. Какой program level использовать при запросе цепочки.fallbackTreasury— адрес, куда сваливаются недостigers. Поведение по умолчанию идентично "casino-house": ловит всё, что не дошло до целевого получателя.uniswapRouter— адрес SwapRouter02.pairAllowed[tokenA][tokenB]— allowlist разрешённых направлений свопа.
Константы
MAX_FEE_BPS = 1000(10% максимум).BPS_DENOMINATOR = 10000.PARTNER_LINES = 15.MAX_STATIC_RECIPIENTS = 20.
Структуры
struct Recipient {
address wallet;
uint16 shareBps;
}
struct FeeConfig {
uint16 totalFeeBps; // 0..MAX_FEE_BPS
Recipient[] staticRecipients;
uint16[15] partnerLineBps;
// Инвариант: если totalFeeBps > 0, то
// sum(staticRecipients[*].shareBps) + sum(partnerLineBps[*]) == 10000
// если totalFeeBps == 0, то все доли должны быть 0
}
События
Swap(sender, feePayer, tokenIn, tokenOut, amountIn, amountOut, feeAmount)— главное событие для индексеров.FeeDistributed(token, feeAmount, distributed, toFallback)— итоговая сводка распределения.StaticRecipientFailed(wallet, share)— статический трансфер не удался, доля ушла в fallback.PartnerTransferFailed(line, recipient, share)— партнёрский трансфер не удался либоaddress(0)в цепочке.PairAllowedUpdated,FeeConfigUpdated,PartnerProviderUpdated,PartnerLevelUpdated,FallbackTreasuryUpdated,UniswapRouterUpdated— административные события.
Ошибки
PairNotAllowed(tokenIn, tokenOut)— запрошенная пара не в allowlist.InvalidAmount—amountIn == 0.InvalidRecipient—recipient == address(0).InvalidFeeConfig— сумма bps не равна 10000 при ненулевой комиссии.FeeTooHigh—totalFeeBps > MAX_FEE_BPS.InvalidPartnerLevel—partnerLevelвне[1, 15].ZeroAddress— попытка установить нулевой адрес в admin-функциях или статическом получателе.TooManyRecipients—staticRecipients.length > 20.
Функции: осно вное
swap
function swap(
address tokenIn,
address tokenOut,
uint24 poolFee,
uint256 amountIn,
uint256 minAmountOut,
address recipient,
address feePayer
) external nonReentrant whenNotPaused returns (uint256 amountOut);
Логика:
- Валидирует параметры (
amountIn > 0,recipient != address(0), пара разрешена). - Pull
amountInотmsg.senderчерезsafeTransferFrom. - Вычисляет
fee = amountIn * totalFeeBps / 10000. Еслиfee > 0, вызывает внутренний_distributeFee. - Approves
(amountIn - fee)к SwapRouter (черезforceApprove, ленивый approve сtype(uint256).maxпри необходимости). - Вызывает
exactInputSingleна Uniswap V3 сrecipient = recipient(вышеуказанный) — то есть выходной токен идёт сразу пользователю, минуя промежуточный баланс контракта. - Эмитит
Swap.
Логика _distributeFee
- Перебирает
staticRecipients. Каждому шлётfee * shareBps / 10000через_trySafeTransfer(низкоуровневыйcallкIERC20.transfer). Если трансфер не удался, доля попадает вleftoverи эмититсяStaticRecipientFailed. - Если есть хотя бы один ненулевой
partnerLineBps, запрашивает уpartnerProviderцепочкуgetUserPartnersByAddress(feePayer, partnerLevel). Для каждой из 15 линий распределяет долю.address(0)→ fallback. Битый трансфер → fallback с событиемPartnerTransferFailed. - Сумма
leftoverуходит вfallbackTreasury. - Эмитит
FeeDistributed.
Функции: административные (только owner)
setFeeConfig(FeeConfig calldata cfg)— валидирует инварианты и сохраняет конфиг.setPairAllowed(address tokenA, address tokenB, bool allowed)— управление allowlist.setPartnerProvider(address)— поменять контракт партнёрки.setPartnerLevel(uint8)— поменять program level (1-15).setFallbackTreasury(address)— поменять fallback адрес.setUniswapRouter(address)— поменять SwapRouter (например, мигрировать на новую версию).pause()/unpause()— экстренная остановка всех свопов.
Конструктор
constructor(
address initialOwner,
address _partnerProvider,
uint8 _partnerLevel,
address _fallbackTreasury,
address _uniswapRouter
)
После деплоя owner обязан:
- Вызвать
setFeeConfig(...)с целевой конфигурацией. - Для каждой разрешённой пары —
setPairAllowed(tokenA, tokenB, true)и обратное направление, если нужно. - Опционально передать ownership на multisig через
transferOwnership(Ownable2Step — нуженacceptOwnershipот multisig).
Безопасность
- Reentrancy:
swap()помеченnonReentrant. Внутри идут только трансферы в ERC20 и вызов SwapRouter; ни pool, ни наш контракт не передают управление обратно. - Pause: в случае инцидента owner-multisig может приостановить все свопы.
- try/catch на трансферах: не позволяет битому адресу заблокировать всю экосистему.
- MAX_FEE_BPS: жёсткий потолок 10% — даже если owner-multisig скомпрометируется, никто не может назначить 100% комиссию.
- Allowlist: случайные токены не могут быть свопнуты через router. Защита от спама и манипуляций.
- No upgradeable: иммютабельный контракт, проще аудитить, исключает риск злонамеренного upgrade.
Типовой сценарий использования
- Пользователь подписывает approve
tokenInкFeeRouter. - Фронт считает ожидаемый output:
expectedOut = quoter.quoteExactInputSingle(tokenIn, tokenOut, poolFee, amountIn * (1 - feeBps/10000), 0). - Пользователь вызывает
feeRouter.swap(...)соminAmountOut = expectedOut * 0.99(1% slippage). - Контракт удерживает комиссию, шлёт её получателям, остаток меняет на Uniswap, выходной токен приходит на
recipient.