Skip to content

feat: autoSwapper for WUSDN to SDEX #921

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
May 16, 2025
5 changes: 1 addition & 4 deletions src/interfaces/Utils/IAutoSwapperWstethSdex.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down
46 changes: 46 additions & 0 deletions src/interfaces/Utils/IAutoSwapperWusdnSdex.sol
Original file line number Diff line number Diff line change
@@ -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;
}
6 changes: 1 addition & 5 deletions src/utils/AutoSwapperWstethSdex.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;

Expand Down
140 changes: 140 additions & 0 deletions src/utils/AutoSwapperWusdnSdex.sol
Original file line number Diff line number Diff line change
@@ -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
})
);
}
}
85 changes: 85 additions & 0 deletions test/integration/AutoSwapper/AutoSwapperWusdnSdex.t.sol
Original file line number Diff line number Diff line change
@@ -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();
}
}