// SPDX-License-Identifier: Apache-2.0 pragma solidity 0.8.28; import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; import {FeeSwitch} from "./FeeSwitch.sol"; import {ISwapRouter02} from "./interfaces/ISwapRouter02.sol"; import {IUniswapV2Pair} from "./interfaces/IUniswapV2Pair.sol"; /// @title AggregatorRouter /// @notice Routes a swap through an external Uniswap v3 pool or any v2-compatible pair (ours or /// someone else's) and withholds the aggregator fee. /// @dev Non-custodial by construction: tokens enter and leave within a single transaction, /// so no user balance is ever held between transactions. contract AggregatorRouter is ReentrancyGuard { using SafeERC20 for IERC20; FeeSwitch public immutable feeSwitch; ISwapRouter02 public immutable swapRouter; error DeadlinePassed(); error ZeroAmount(); error InsufficientOutput(uint256 got, uint256 min); error EmptyReserves(); /// @dev The pair would refuse this trade: its reserves are uint112 and cannot hold the result. error ReserveOverflow(uint256 wouldBe, uint256 max); error InvalidPair(); error ZeroOutput(); event Swapped( address indexed taker, address tokenIn, address tokenOut, uint256 amountIn, uint256 amountOut, uint256 feeAmount ); event SwappedV2( address indexed taker, address indexed pair, address tokenIn, address tokenOut, uint256 amountIn, uint256 amountOut, uint256 feeAmount ); constructor(FeeSwitch feeSwitch_, ISwapRouter02 swapRouter_) { feeSwitch = feeSwitch_; swapRouter = swapRouter_; } /// @param amountOutMinimum Minimum the taker accepts AFTER the aggregator fee. /// @return userAmountOut Amount actually transferred to the taker. function swapExactInputSingle( address tokenIn, address tokenOut, uint24 poolFee, uint256 amountIn, uint256 amountOutMinimum, uint256 deadline ) external nonReentrant returns (uint256 userAmountOut) { if (block.timestamp > deadline) revert DeadlinePassed(); // amountIn == 0 is SwapRouter02's CONTRACT_BALANCE sentinel: it would swap the router's own // tokenIn balance and emit a meaningless Swapped(amountIn = 0). Rejected up front. if (amountIn == 0) revert ZeroAmount(); IERC20(tokenIn).safeTransferFrom(msg.sender, address(this), amountIn); IERC20(tokenIn).forceApprove(address(swapRouter), amountIn); // amountOutMinimum is 0 on the inner call on purpose: the taker's protection is // enforced below, on the post-fee amount. Checking it here would let the fee push // the taker below the minimum they agreed to. uint256 grossOut = swapRouter.exactInputSingle( ISwapRouter02.ExactInputSingleParams({ tokenIn: tokenIn, tokenOut: tokenOut, fee: poolFee, recipient: address(this), amountIn: amountIn, amountOutMinimum: 0, sqrtPriceLimitX96: 0 }) ); uint256 feeAmount = (grossOut * feeSwitch.feeBps()) / feeSwitch.BPS_DENOMINATOR(); userAmountOut = grossOut - feeAmount; if (userAmountOut < amountOutMinimum) revert InsufficientOutput(userAmountOut, amountOutMinimum); IERC20(tokenIn).forceApprove(address(swapRouter), 0); if (feeAmount > 0) IERC20(tokenOut).safeTransfer(feeSwitch.feeCollector(), feeAmount); IERC20(tokenOut).safeTransfer(msg.sender, userAmountOut); emit Swapped(msg.sender, tokenIn, tokenOut, amountIn, userAmountOut, feeAmount); } /// @notice Constant-product output with the pool's 0.30% fee. /// @dev Written from the published formula rather than imported, to keep this artifact /// free of GPL-3.0. AggregatorRouterV2ForkTest checks it against a live pair. function getAmountOut(uint256 amountIn, uint256 reserveIn, uint256 reserveOut) public pure returns (uint256 amountOut) { if (amountIn == 0) revert ZeroAmount(); if (reserveIn == 0 || reserveOut == 0) revert EmptyReserves(); // A pair stores reserves as uint112 and its _update reverts with "UniswapV2: OVERFLOW" when // a balance would not fit. Arithmetic here is uint256, so without this check the formula // happily returns a number for a trade the chain will refuse — quoting the unexecutable, // which is precisely what this product promises not to do. The band is real, not theoretical: // uint112 tops out near 5.19e33 while the multiplication below only overflows around 2^134. uint256 balanceIn = reserveIn + amountIn; if (balanceIn > type(uint112).max) revert ReserveOverflow(balanceIn, type(uint112).max); uint256 amountInWithFee = amountIn * 997; amountOut = (amountInWithFee * reserveOut) / (reserveIn * 1000 + amountInWithFee); } /// @notice Swap through any v2-compatible pair, ours or someone else's. /// @dev Fee-on-transfer and rebasing tokens are not supported: the quote assumes the pair /// receives exactly `amountIn` and the router receives exactly `grossOut`. Any shortfall /// makes the call revert (the pair's K check or the outbound transfer fails); nothing is /// silently mis-paid, but the caller should know up front that such tokens cannot go /// through this path. /// @param amountOutMinimum Minimum the taker accepts AFTER the aggregator fee. /// @return userAmountOut Amount actually transferred to the taker. function swapExactInputV2( address pair, address tokenIn, address tokenOut, uint256 amountIn, uint256 amountOutMinimum, uint256 deadline ) external nonReentrant returns (uint256 userAmountOut) { if (block.timestamp > deadline) revert DeadlinePassed(); bool inIsToken0; // Scoped blocks: the legacy codegen keeps every local on the stack until the end of its // block, and this function would otherwise exceed the 16-slot reach at the fee transfer. { // {tokenIn, tokenOut} must be exactly the pair's two tokens, in either order. Trusting // the caller here would let a mismatched tokenOut pay the taker out of whatever stray // balance the router holds while the pair's real output stays behind as dust. address token0 = IUniswapV2Pair(pair).token0(); address token1 = IUniswapV2Pair(pair).token1(); if (tokenIn == token0 && tokenOut == token1) inIsToken0 = true; else if (tokenIn == token1 && tokenOut == token0) inIsToken0 = false; else revert InvalidPair(); } uint256 grossOut; { (uint112 r0, uint112 r1,) = IUniswapV2Pair(pair).getReserves(); (uint256 reserveIn, uint256 reserveOut) = inIsToken0 ? (uint256(r0), uint256(r1)) : (uint256(r1), uint256(r0)); grossOut = getAmountOut(amountIn, reserveIn, reserveOut); } // A dust-sized amountIn can round to a zero quote. Rejected here, before anything is // paid to the pair: a canonical pair would revert with its own opaque error, and a // non-standard one that accepts a zero output would leave the taker with nothing. if (grossOut == 0) revert ZeroOutput(); // v2 pairs are paid by transferring in first, then calling swap(). IERC20(tokenIn).safeTransferFrom(msg.sender, pair, amountIn); { (uint256 amount0Out, uint256 amount1Out) = inIsToken0 ? (uint256(0), grossOut) : (grossOut, uint256(0)); IUniswapV2Pair(pair).swap(amount0Out, amount1Out, address(this), ""); } uint256 feeAmount = (grossOut * feeSwitch.feeBps()) / feeSwitch.BPS_DENOMINATOR(); userAmountOut = grossOut - feeAmount; if (userAmountOut < amountOutMinimum) revert InsufficientOutput(userAmountOut, amountOutMinimum); if (feeAmount > 0) IERC20(tokenOut).safeTransfer(feeSwitch.feeCollector(), feeAmount); IERC20(tokenOut).safeTransfer(msg.sender, userAmountOut); emit SwappedV2(msg.sender, pair, tokenIn, tokenOut, amountIn, userAmountOut, feeAmount); } }