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

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).
  • partnerLeveluint8, по умолчанию 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.
  • InvalidAmountamountIn == 0.
  • InvalidRecipientrecipient == address(0).
  • InvalidFeeConfig — сумма bps не равна 10000 при ненулевой комиссии.
  • FeeTooHightotalFeeBps > MAX_FEE_BPS.
  • InvalidPartnerLevelpartnerLevel вне [1, 15].
  • ZeroAddress — попытка установить нулевой адрес в admin-функциях или статическом получателе.
  • TooManyRecipientsstaticRecipients.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);

Логика:

  1. Валидирует параметры (amountIn > 0, recipient != address(0), пара разрешена).
  2. Pull amountIn от msg.sender через safeTransferFrom.
  3. Вычисляет fee = amountIn * totalFeeBps / 10000. Если fee > 0, вызывает внутренний _distributeFee.
  4. Approves (amountIn - fee) к SwapRouter (через forceApprove, ленивый approve с type(uint256).max при необходимости).
  5. Вызывает exactInputSingle на Uniswap V3 с recipient = recipient (вышеуказанный) — то есть выходной токен идёт сразу пользователю, минуя промежуточный баланс контракта.
  6. Эмитит Swap.

Логика _distributeFee

  1. Перебирает staticRecipients. Каждому шлёт fee * shareBps / 10000 через _trySafeTransfer (низкоуровневый call к IERC20.transfer). Если трансфер не удался, доля попадает в leftover и эмитится StaticRecipientFailed.
  2. Если есть хотя бы один ненулевой partnerLineBps, запрашивает у partnerProvider цепочку getUserPartnersByAddress(feePayer, partnerLevel). Для каждой из 15 линий распределяет долю. address(0) → fallback. Битый трансфер → fallback с событием PartnerTransferFailed.
  3. Сумма leftover уходит в fallbackTreasury.
  4. Эмитит 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 обязан:

  1. Вызвать setFeeConfig(...) с целевой конфигурацией.
  2. Для каждой разрешённой пары — setPairAllowed(tokenA, tokenB, true) и обратное направление, если нужно.
  3. Опционально передать 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.

Типовой сценарий использования

  1. Пользователь подписывает approve tokenIn к FeeRouter.
  2. Фронт считает ожидаемый output: expectedOut = quoter.quoteExactInputSingle(tokenIn, tokenOut, poolFee, amountIn * (1 - feeBps/10000), 0).
  3. Пользователь вызывает feeRouter.swap(...) со minAmountOut = expectedOut * 0.99 (1% slippage).
  4. Контракт удерживает комиссию, шлёт её получателям, остаток меняет на Uniswap, выходной токен приходит на recipient.