diff --git a/src/interfaces/Utils/IAutoSwapperWstethSdex.sol b/src/interfaces/Utils/IAutoSwapperWstethSdex.sol index 03fdfe065..23203411a 100644 --- a/src/interfaces/Utils/IAutoSwapperWstethSdex.sol +++ b/src/interfaces/Utils/IAutoSwapperWstethSdex.sol @@ -21,10 +21,7 @@ interface IAutoSwapperWstethSdex { /// @notice Thrown when the caller is not authorized to perform the operation. error AutoSwapperInvalidCaller(); - /** - * @notice Swap wstETH to SDEX. - * @dev This function can only be called by the contract itself. - */ + /// @notice Swap wstETH to SDEX. function swapWstethToSdex() external; /** diff --git a/src/interfaces/Utils/IAutoSwapperWusdnSdex.sol b/src/interfaces/Utils/IAutoSwapperWusdnSdex.sol new file mode 100644 index 000000000..84cf11701 --- /dev/null +++ b/src/interfaces/Utils/IAutoSwapperWusdnSdex.sol @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity >=0.8.0; + +/// @notice Interface for the AutoSwapperWusdnSdex contract that provides automated token swapping functionality. +interface IAutoSwapperWusdnSdex { + /** + * @notice Emitted when the swap slippage percentage is updated. + * @param newSwapSlippage The new swap slippage (in basis points). + */ + event SwapSlippageUpdated(uint256 newSwapSlippage); + + /// @notice Emitted when a swap fails. + event FailedSwap(); + + /// @notice Thrown when a swap fails. + error AutoSwapperSwapFailed(); + + /// @notice Thrown when slippage configuration is invalid. + error AutoSwapperInvalidSwapSlippage(); + + /// @notice Thrown when the caller is not authorized to perform the operation. + error AutoSwapperInvalidCaller(); + + /// @notice Swap WUSDN to SDEX. + function swapWusdnToSdex() external; + + /** + * @notice Admin function to send the contract token balance to a specified address. + * @param token The address of the token to send. + * @param to The recipient address. + * @param amount The amount of tokens to send. + */ + function sweep(address token, address to, uint256 amount) external; + + /** + * @notice Get the current swap slippage setting (in basis points) + * @return Current slippage tolerance + */ + function getSwapSlippage() external view returns (uint256); + + /** + * @notice Updates the allowed slippage percentage for swaps. + * @param swapSlippage The new slippage value (in basis points). + */ + function updateSwapSlippage(uint256 swapSlippage) external; +} diff --git a/src/utils/AutoSwapperWstethSdex.sol b/src/utils/AutoSwapperWstethSdex.sol index 29af24111..16614942b 100644 --- a/src/utils/AutoSwapperWstethSdex.sol +++ b/src/utils/AutoSwapperWstethSdex.sol @@ -17,7 +17,7 @@ import { IFeeCollectorCallback } from "./../interfaces/UsdnProtocol/IFeeCollecto import { IAutoSwapperWstethSdex } from "./../interfaces/Utils/IAutoSwapperWstethSdex.sol"; /** - * @title SDEX buy-back and burn Autoswapper + * @title SDEX buy-back and burn AutoSwapper * @notice Automates protocol fee conversion from wstETH to SDEX via Uniswap V3 and Smardex. */ contract AutoSwapperWstethSdex is @@ -31,10 +31,6 @@ contract AutoSwapperWstethSdex is using SafeERC20 for IERC20; using SafeERC20 for IWstETH; - /* -------------------------------------------------------------------------- */ - /* Constants */ - /* -------------------------------------------------------------------------- */ - /// @notice Decimal points for basis points (bps). uint16 internal constant BPS_DIVISOR = 10_000; diff --git a/src/utils/AutoSwapperWusdnSdex.sol b/src/utils/AutoSwapperWusdnSdex.sol new file mode 100644 index 000000000..4f490c94f --- /dev/null +++ b/src/utils/AutoSwapperWusdnSdex.sol @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: BUSL-1.1 +pragma solidity 0.8.26; + +import { Ownable, Ownable2Step } from "@openzeppelin/contracts/access/Ownable2Step.sol"; +import { IERC20, SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { ERC165 } from "@openzeppelin/contracts/utils/introspection/ERC165.sol"; +import { IERC165 } from "@openzeppelin/contracts/utils/introspection/IERC165.sol"; +import { ISmardexPair } from "@smardex-dex-contracts/contracts/ethereum/core/v2/interfaces/ISmardexPair.sol"; +import { ISmardexSwapCallback } from + "@smardex-dex-contracts/contracts/ethereum/core/v2/interfaces/ISmardexSwapCallback.sol"; +import { SmardexLibrary } from "@smardex-dex-contracts/contracts/ethereum/core/v2/libraries/SmardexLibrary.sol"; +import { FixedPointMathLib } from "solady/src/utils/FixedPointMathLib.sol"; + +import { IFeeCollectorCallback } from "./../interfaces/UsdnProtocol/IFeeCollectorCallback.sol"; +import { IAutoSwapperWusdnSdex } from "./../interfaces/Utils/IAutoSwapperWusdnSdex.sol"; + +/** + * @title SDEX buy-back and burn AutoSwapper + * @notice Automates protocol fee conversion from WUSDN to SDEX via Smardex. + */ +contract AutoSwapperWusdnSdex is + Ownable2Step, + IAutoSwapperWusdnSdex, + IFeeCollectorCallback, + ERC165, + ISmardexSwapCallback +{ + using SafeERC20 for IERC20; + + /// @notice Decimal points for basis points (bps). + uint16 internal constant BPS_DIVISOR = 10_000; + + /// @notice Wrapped USDN token address. + IERC20 internal constant WUSDN = IERC20(0x99999999999999Cc837C997B882957daFdCb1Af9); + + /// @notice SmarDex pair address for WUSDN/SDEX swaps. + ISmardexPair internal constant SMARDEX_WUSDN_SDEX_PAIR = ISmardexPair(0x11443f5B134c37903705e64129BEFc20e35a3725); + + /// @notice Fee rates for LP on the SmarDex pair. + uint128 internal immutable FEE_LP; + + /// @notice Fee rates for the pool on the SmarDex pair. + uint128 internal immutable FEE_POOL; + + /// @notice Allowed slippage for swaps (in basis points). + uint256 internal _swapSlippage = 100; // 1% + + constructor() Ownable(msg.sender) { + (FEE_LP, FEE_POOL) = SMARDEX_WUSDN_SDEX_PAIR.getPairFees(); + } + + /// @inheritdoc IFeeCollectorCallback + function feeCollectorCallback(uint256) external { + try this.swapWusdnToSdex() { } + catch { + emit FailedSwap(); + } + } + + /// @inheritdoc IAutoSwapperWusdnSdex + function swapWusdnToSdex() external { + uint256 wusdnAmount = WUSDN.balanceOf(address(this)); + + uint256 quoteAmountSdexOut = _quoteAmountOut(wusdnAmount); + uint256 minSdexAmount = FixedPointMathLib.mulDiv(quoteAmountSdexOut, BPS_DIVISOR - _swapSlippage, BPS_DIVISOR); + (int256 amountSdexOut,) = SMARDEX_WUSDN_SDEX_PAIR.swap(address(0xdead), false, int256(wusdnAmount), ""); + + if (uint256(-amountSdexOut) < minSdexAmount) { + revert AutoSwapperSwapFailed(); + } + } + + /// @inheritdoc ISmardexSwapCallback + function smardexSwapCallback(int256, int256 amountWusdnIn, bytes calldata) external { + if (msg.sender != address(SMARDEX_WUSDN_SDEX_PAIR)) { + revert AutoSwapperInvalidCaller(); + } + WUSDN.safeTransfer(msg.sender, uint256(amountWusdnIn)); + } + + /// @inheritdoc IAutoSwapperWusdnSdex + function sweep(address token, address to, uint256 amount) external onlyOwner { + IERC20(token).safeTransfer(to, amount); + } + + /// @inheritdoc IAutoSwapperWusdnSdex + function getSwapSlippage() external view returns (uint256) { + return _swapSlippage; + } + + /// @inheritdoc IAutoSwapperWusdnSdex + function updateSwapSlippage(uint256 newSwapSlippage) external onlyOwner { + if (newSwapSlippage == 0) { + revert AutoSwapperInvalidSwapSlippage(); + } + _swapSlippage = newSwapSlippage; + emit SwapSlippageUpdated(newSwapSlippage); + } + + /// @inheritdoc ERC165 + function supportsInterface(bytes4 interfaceId) public view override(ERC165, IERC165) returns (bool) { + if (interfaceId == type(IFeeCollectorCallback).interfaceId) { + return true; + } + return super.supportsInterface(interfaceId); + } + + /** + * @notice Calculates the amount of SDEX that can be obtained from a given amount of WUSDN. + * @param amountIn The amount of WUSDN to swap + * @return amountOut_ The amount of SDEX that can be obtained + */ + function _quoteAmountOut(uint256 amountIn) internal view returns (uint256 amountOut_) { + (uint256 fictiveReserveSdex, uint256 fictiveReserveWusdn) = SMARDEX_WUSDN_SDEX_PAIR.getFictiveReserves(); + (uint256 reservesSdex, uint256 reservesWusdn) = SMARDEX_WUSDN_SDEX_PAIR.getReserves(); + (uint256 priceAvSdex, uint256 priceAvWusdn, uint256 priceAvTimestamp) = + SMARDEX_WUSDN_SDEX_PAIR.getPriceAverage(); + + (priceAvWusdn, priceAvSdex) = SmardexLibrary.getUpdatedPriceAverage( + fictiveReserveWusdn, fictiveReserveSdex, priceAvTimestamp, priceAvWusdn, priceAvSdex, block.timestamp + ); + + (fictiveReserveWusdn, fictiveReserveSdex) = + SmardexLibrary.computeFictiveReserves(reservesWusdn, reservesSdex, fictiveReserveWusdn, fictiveReserveSdex); + + (amountOut_,,,,) = SmardexLibrary.applyKConstRuleOut( + SmardexLibrary.GetAmountParameters({ + amount: amountIn, + reserveIn: reservesWusdn, + reserveOut: reservesSdex, + fictiveReserveIn: fictiveReserveWusdn, + fictiveReserveOut: fictiveReserveSdex, + priceAverageIn: priceAvWusdn, + priceAverageOut: priceAvSdex, + feesLP: FEE_LP, + feesPool: FEE_POOL + }) + ); + } +} diff --git a/test/integration/AutoSwapper/AutoSwapperWusdnSdex.t.sol b/test/integration/AutoSwapper/AutoSwapperWusdnSdex.t.sol new file mode 100644 index 000000000..7287464d0 --- /dev/null +++ b/test/integration/AutoSwapper/AutoSwapperWusdnSdex.t.sol @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity 0.8.26; + +import { Test } from "forge-std/Test.sol"; + +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; + +import { SDEX as SDEX_ADDR } from "../../utils/Constants.sol"; + +import { IAutoSwapperWusdnSdex } from "../../../src/interfaces/Utils/IAutoSwapperWusdnSdex.sol"; +import { AutoSwapperWusdnSdex } from "../../../src/utils/AutoSwapperWusdnSdex.sol"; + +/** + * @custom:feature The `AutoSwapperWusdnSdex` contract + * @custom:background Given a `AutoSwapperWusdnSdex` contract and a forked mainnet + */ +contract TestForkAutoSwapperWusdnSdex is Test { + AutoSwapperWusdnSdex public autoSwapper; + address constant BURN_ADDRESS = 0x000000000000000000000000000000000000dEaD; + IERC20 constant WUSDN = IERC20(0x99999999999999Cc837C997B882957daFdCb1Af9); + IERC20 constant SDEX = IERC20(SDEX_ADDR); + uint256 constant AMOUNT_TO_SWAP = 2000 ether; + + function setUp() public { + vm.createSelectFork("mainnet"); + + autoSwapper = new AutoSwapperWusdnSdex(); + + deal(address(WUSDN), address(this), AMOUNT_TO_SWAP); + } + + /** + * @custom:scenario Test the AutoSwapper's swap execution via the callback function + * @custom:when `feeCollectorCallback` is called + * @custom:then It should perform the swap + * @custom:and the SDEX balance of the burn address should increase + * @custom:and the WUSDN and SDEX balances of the contract should be zero + */ + function test_ForkFeeCollectorCallback() public { + uint256 initialBurnAddressBalance = SDEX.balanceOf(BURN_ADDRESS); + + WUSDN.transfer(address(autoSwapper), AMOUNT_TO_SWAP); + autoSwapper.feeCollectorCallback(1); + + assertEq(WUSDN.balanceOf(address(autoSwapper)), 0, "WUSDN balance not zero"); + assertEq(SDEX.balanceOf(address(autoSwapper)), 0, "SDEX balance not zero"); + assertGt( + SDEX.balanceOf(BURN_ADDRESS), initialBurnAddressBalance, "Swap did not increase burn address SDEX balance" + ); + } + + /** + * @custom:scenario Test the `Ownable` access control of the AutoSwapper + * @custom:when the `sweep` and `updateSwapSlippage` functions are called + * @custom:then It should revert with the `OwnableUnauthorizedAccount` error + */ + function test_ForkAdmin() public { + address user = vm.addr(1); + vm.startPrank(user); + + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, user)); + autoSwapper.sweep(address(0), address(0), 1); + + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, user)); + autoSwapper.updateSwapSlippage(1); + + vm.stopPrank(); + } + + /** + * @custom:scenario Test the external function calls of the AutoSwapper + * @custom:when the `smardexSwapCallback` function is called + * @custom:then it should revert with the `AutoSwapperInvalidCaller` error + */ + function test_ForkInvalidCaller() public { + address user = vm.addr(1); + vm.startPrank(user); + + vm.expectRevert(IAutoSwapperWusdnSdex.AutoSwapperInvalidCaller.selector); + autoSwapper.smardexSwapCallback(1, 1, ""); + + vm.stopPrank(); + } +}