|
| 1 | +// SPDX-License-Identifier: LGPL-3.0-only |
| 2 | +pragma solidity 0.8.28; |
| 3 | + |
| 4 | +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; |
| 5 | +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; |
| 6 | +import {AccessControlUpgradeable} from "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; |
| 7 | +import {ILiquidityPool} from "./interfaces/ILiquidityPool.sol"; |
| 8 | +import {IOracle} from "./interfaces/IOracle.sol"; |
| 9 | +import {ERC7201Helper} from "./utils/ERC7201Helper.sol"; |
| 10 | +import {Math} from "@openzeppelin/contracts/utils/math/Math.sol"; |
| 11 | +import {SafeCast} from "@openzeppelin/contracts/utils/math/SafeCast.sol"; |
| 12 | + |
| 13 | +/// @title StashDex — upgradeable DEX that routes swaps through Sprinter liquidity pools. |
| 14 | +/// @notice Accepts tokenIn from the caller and delivers tokenOut borrowed from a configured |
| 15 | +/// ILiquidityPool. The exchange rate is validated against an oracle with a per-route fee. |
| 16 | +/// @notice Upgradeable. |
| 17 | +/// @author Sprinter |
| 18 | +contract StashDex is AccessControlUpgradeable { |
| 19 | + using SafeERC20 for IERC20; |
| 20 | + using SafeCast for uint256; |
| 21 | + |
| 22 | + bytes32 public constant CONFIG_ROLE = "CONFIG_ROLE"; |
| 23 | + bytes32 public constant PAUSER_ROLE = "PAUSER_ROLE"; |
| 24 | + bytes32 public constant FORWARD_ROLE = "FORWARD_ROLE"; |
| 25 | + |
| 26 | + uint256 private constant BPS = 10_000; |
| 27 | + |
| 28 | + IOracle immutable public ORACLE; |
| 29 | + address immutable public RECEIVER; |
| 30 | + |
| 31 | + struct RouteConfig { |
| 32 | + bool allowed; |
| 33 | + uint16 feeBps; |
| 34 | + address processor; |
| 35 | + } |
| 36 | + |
| 37 | + // pool (20 bytes) + totalBorrowed (12 bytes) = 32 bytes = one storage slot |
| 38 | + struct TokenConfig { |
| 39 | + ILiquidityPool pool; |
| 40 | + uint96 totalBorrowed; |
| 41 | + } |
| 42 | + |
| 43 | + struct PoolInit { |
| 44 | + address token; |
| 45 | + ILiquidityPool pool; |
| 46 | + } |
| 47 | + |
| 48 | + struct RouteInit { |
| 49 | + address tokenIn; |
| 50 | + address tokenOut; |
| 51 | + uint16 feeBps; |
| 52 | + address processor; |
| 53 | + } |
| 54 | + |
| 55 | + /// @custom:storage-location erc7201:sprinter.storage.StashDex |
| 56 | + struct StashDexStorage { |
| 57 | + mapping(address tokenIn => mapping(address tokenOut => RouteConfig)) routes; |
| 58 | + mapping(address token => TokenConfig) tokenConfig; |
| 59 | + bool paused; |
| 60 | + } |
| 61 | + |
| 62 | + bytes32 private constant STORAGE_LOCATION = 0xcf0fc60ec5775aeb9384817ddbc170789307c3e4b4fa0209ca63afb0f9c5ab00; |
| 63 | + |
| 64 | + error ZeroAddress(); |
| 65 | + error SameToken(); |
| 66 | + error RouteNotAllowed(); |
| 67 | + error InsufficientOutput(); |
| 68 | + error NothingToForward(); |
| 69 | + error InvalidIndex(); |
| 70 | + error InvalidFeeBps(); |
| 71 | + error OutstandingDebt(); |
| 72 | + error PoolNotConfigured(); |
| 73 | + error EnforcedPause(); |
| 74 | + error ExpectedPause(); |
| 75 | + |
| 76 | + modifier whenNotPaused() { |
| 77 | + require(!_getStorage().paused, EnforcedPause()); |
| 78 | + _; |
| 79 | + } |
| 80 | + |
| 81 | + event PoolSet(address indexed token, ILiquidityPool pool); |
| 82 | + event RouteSet(address indexed tokenIn, address indexed tokenOut, uint16 feeBps, address processor); |
| 83 | + event RouteDisabled(address tokenIn, address tokenOut); |
| 84 | + |
| 85 | + event Swapped( |
| 86 | + address tokenIn, |
| 87 | + address tokenOut, |
| 88 | + uint256 amountIn, |
| 89 | + uint256 amountOut, |
| 90 | + address recipient |
| 91 | + ); |
| 92 | + event Repaid(address token, uint256 amount); |
| 93 | + event Forwarded(address token, uint256 amount); |
| 94 | + event Paused(address account); |
| 95 | + event Unpaused(address account); |
| 96 | + |
| 97 | + constructor(address oracle, address receiver) { |
| 98 | + ERC7201Helper.validateStorageLocation(STORAGE_LOCATION, "sprinter.storage.StashDex"); |
| 99 | + require(oracle != address(0), ZeroAddress()); |
| 100 | + require(receiver != address(0), ZeroAddress()); |
| 101 | + ORACLE = IOracle(oracle); |
| 102 | + RECEIVER = receiver; |
| 103 | + _disableInitializers(); |
| 104 | + } |
| 105 | + |
| 106 | + function initialize( |
| 107 | + address admin, |
| 108 | + address configAdmin, |
| 109 | + address pauser, |
| 110 | + address forwarder, |
| 111 | + PoolInit[] calldata initialPools, |
| 112 | + RouteInit[] calldata initialRoutes |
| 113 | + ) external initializer { |
| 114 | + __AccessControl_init(); |
| 115 | + require(admin != address(0), ZeroAddress()); |
| 116 | + require(configAdmin != address(0), ZeroAddress()); |
| 117 | + require(pauser != address(0), ZeroAddress()); |
| 118 | + require(forwarder != address(0), ZeroAddress()); |
| 119 | + _grantRole(DEFAULT_ADMIN_ROLE, admin); |
| 120 | + _grantRole(CONFIG_ROLE, configAdmin); |
| 121 | + _grantRole(PAUSER_ROLE, pauser); |
| 122 | + _grantRole(FORWARD_ROLE, forwarder); |
| 123 | + for (uint256 i = 0; i < initialPools.length; i++) { |
| 124 | + _setPool(initialPools[i]); |
| 125 | + } |
| 126 | + for (uint256 i = 0; i < initialRoutes.length; i++) { |
| 127 | + _setRoute(initialRoutes[i]); |
| 128 | + } |
| 129 | + } |
| 130 | + |
| 131 | + /// @notice Swap tokenIn for tokenOut. The route must be configured and the rate (after fee) |
| 132 | + /// must cover the requested amountOut per oracle pricing. |
| 133 | + function swap( |
| 134 | + address tokenIn, |
| 135 | + address tokenOut, |
| 136 | + uint256 amountIn, |
| 137 | + uint256 amountOut, |
| 138 | + address recipient |
| 139 | + ) public whenNotPaused() { |
| 140 | + StashDexStorage storage $ = _getStorage(); |
| 141 | + RouteConfig memory route = $.routes[tokenIn][tokenOut]; |
| 142 | + require(route.allowed, RouteNotAllowed()); |
| 143 | + |
| 144 | + uint256 valueIn = ORACLE.getAssetValue(_tokenToAssetId(tokenIn), amountIn * 10**12); |
| 145 | + uint256 valueOut = ORACLE.getAssetValue(_tokenToAssetId(tokenOut), amountOut * 10**12); |
| 146 | + require(valueIn * (BPS - route.feeBps) >= valueOut * BPS, InsufficientOutput()); |
| 147 | + |
| 148 | + IERC20(tokenIn).safeTransferFrom(_msgSender(), route.processor, amountIn); |
| 149 | + |
| 150 | + TokenConfig storage tc = $.tokenConfig[tokenOut]; |
| 151 | + require(address(tc.pool) != address(0), PoolNotConfigured()); |
| 152 | + tc.pool.borrowDirect(tokenOut, amountOut); |
| 153 | + tc.totalBorrowed += amountOut.toUint96(); |
| 154 | + IERC20(tokenOut).safeTransferFrom(address(tc.pool), recipient, amountOut); |
| 155 | + |
| 156 | + emit Swapped(tokenIn, tokenOut, amountIn, amountOut, recipient); |
| 157 | + } |
| 158 | + |
| 159 | + /// @notice Convenience wrapper that accepts token addresses encoded as uint256 indices. |
| 160 | + /// @dev Meant to support UniversalRouter Curve Swap command. |
| 161 | + function exchange( |
| 162 | + uint256 indexIn, |
| 163 | + uint256 indexOut, |
| 164 | + uint256 amountIn, |
| 165 | + uint256 amountOut, |
| 166 | + address recipient |
| 167 | + ) external { |
| 168 | + swap(_indexToAddress(indexIn), _indexToAddress(indexOut), amountIn, amountOut, recipient); |
| 169 | + } |
| 170 | + |
| 171 | + /// @notice Repay the liquidity pool for token if debt is outstanding, using this contract's balance. |
| 172 | + function repay(address token) public whenNotPaused() { |
| 173 | + TokenConfig storage config = _getStorage().tokenConfig[token]; |
| 174 | + uint256 debt = config.totalBorrowed; |
| 175 | + if (debt == 0) return; |
| 176 | + |
| 177 | + uint256 available = IERC20(token).balanceOf(address(this)); |
| 178 | + uint256 repayAmount = Math.min(debt, available); |
| 179 | + if (repayAmount == 0) return; |
| 180 | + config.totalBorrowed = uint96(debt - repayAmount); |
| 181 | + |
| 182 | + address[] memory tokens = new address[](1); |
| 183 | + uint256[] memory amounts = new uint256[](1); |
| 184 | + tokens[0] = token; |
| 185 | + amounts[0] = repayAmount; |
| 186 | + |
| 187 | + config.pool.repayDirect(tokens, amounts); |
| 188 | + emit Repaid(token, repayAmount); |
| 189 | + } |
| 190 | + |
| 191 | + /// @notice Repay the pool if configured, then transfer any remaining balance to RECEIVER. |
| 192 | + function forward(address token) external onlyRole(FORWARD_ROLE) whenNotPaused() { |
| 193 | + if (address(_getStorage().tokenConfig[token].pool) != address(0)) { |
| 194 | + repay(token); |
| 195 | + } |
| 196 | + uint256 amount = IERC20(token).balanceOf(address(this)); |
| 197 | + require(amount > 0, NothingToForward()); |
| 198 | + IERC20(token).safeTransfer(RECEIVER, amount); |
| 199 | + emit Forwarded(token, amount); |
| 200 | + } |
| 201 | + |
| 202 | + /// @notice Returns the available balance of token in its configured liquidity pool. |
| 203 | + function balance(IERC20 token) external view returns (uint256) { |
| 204 | + ILiquidityPool pool = _getStorage().tokenConfig[address(token)].pool; |
| 205 | + if (address(pool) == address(0)) return 0; |
| 206 | + return pool.balance(token); |
| 207 | + } |
| 208 | + |
| 209 | + // --- PAUSER_ROLE functions --- |
| 210 | + |
| 211 | + function pause() external onlyRole(PAUSER_ROLE) { |
| 212 | + require(!_getStorage().paused, EnforcedPause()); |
| 213 | + _getStorage().paused = true; |
| 214 | + emit Paused(_msgSender()); |
| 215 | + } |
| 216 | + |
| 217 | + function unpause() external onlyRole(PAUSER_ROLE) { |
| 218 | + require(_getStorage().paused, ExpectedPause()); |
| 219 | + _getStorage().paused = false; |
| 220 | + emit Unpaused(_msgSender()); |
| 221 | + } |
| 222 | + |
| 223 | + function paused() external view returns (bool) { |
| 224 | + return _getStorage().paused; |
| 225 | + } |
| 226 | + |
| 227 | + // --- CONFIG_ROLE functions --- |
| 228 | + |
| 229 | + function setPool(address token, ILiquidityPool pool) external onlyRole(CONFIG_ROLE) { |
| 230 | + _setPool(PoolInit({token: token, pool: pool})); |
| 231 | + } |
| 232 | + |
| 233 | + function setRoute(RouteInit calldata route) external onlyRole(CONFIG_ROLE) { |
| 234 | + _setRoute(route); |
| 235 | + } |
| 236 | + |
| 237 | + function disableRoute(address tokenIn, address tokenOut) external onlyRole(CONFIG_ROLE) { |
| 238 | + StashDexStorage storage $ = _getStorage(); |
| 239 | + require($.routes[tokenIn][tokenOut].allowed, RouteNotAllowed()); |
| 240 | + $.routes[tokenIn][tokenOut].allowed = false; |
| 241 | + emit RouteDisabled(tokenIn, tokenOut); |
| 242 | + } |
| 243 | + |
| 244 | + function _setPool(PoolInit memory params) internal { |
| 245 | + require(params.token != address(0), ZeroAddress()); |
| 246 | + require(address(params.pool) != address(0), ZeroAddress()); |
| 247 | + TokenConfig storage config = _getStorage().tokenConfig[params.token]; |
| 248 | + address currentPool = address(config.pool); |
| 249 | + if (currentPool != address(0) && currentPool != address(params.pool)) { |
| 250 | + require(config.totalBorrowed == 0, OutstandingDebt()); |
| 251 | + IERC20(params.token).forceApprove(currentPool, 0); |
| 252 | + } |
| 253 | + config.pool = params.pool; |
| 254 | + IERC20(params.token).forceApprove(address(params.pool), type(uint256).max); |
| 255 | + emit PoolSet(params.token, params.pool); |
| 256 | + } |
| 257 | + |
| 258 | + function _setRoute(RouteInit memory route) internal { |
| 259 | + require(route.tokenIn != address(0), ZeroAddress()); |
| 260 | + require(route.tokenOut != address(0), ZeroAddress()); |
| 261 | + require(route.tokenIn != route.tokenOut, SameToken()); |
| 262 | + require(route.processor != address(0), ZeroAddress()); |
| 263 | + require(route.feeBps < BPS, InvalidFeeBps()); |
| 264 | + _getStorage().routes[route.tokenIn][route.tokenOut] = RouteConfig({ |
| 265 | + allowed: true, feeBps: route.feeBps, processor: route.processor |
| 266 | + }); |
| 267 | + emit RouteSet(route.tokenIn, route.tokenOut, route.feeBps, route.processor); |
| 268 | + } |
| 269 | + |
| 270 | + // --- View helpers --- |
| 271 | + |
| 272 | + function getRoute(address tokenIn, address tokenOut) external view returns (RouteConfig memory) { |
| 273 | + return _getStorage().routes[tokenIn][tokenOut]; |
| 274 | + } |
| 275 | + |
| 276 | + function getPool(address token) external view returns (ILiquidityPool) { |
| 277 | + return _getStorage().tokenConfig[token].pool; |
| 278 | + } |
| 279 | + |
| 280 | + function getTotalBorrowed(address token) external view returns (uint256) { |
| 281 | + return _getStorage().tokenConfig[token].totalBorrowed; |
| 282 | + } |
| 283 | + |
| 284 | + function _indexToAddress(uint256 index) internal pure returns (address) { |
| 285 | + require(index == uint256(uint160(index)), InvalidIndex()); |
| 286 | + return address(uint160(index)); |
| 287 | + } |
| 288 | + |
| 289 | + function _tokenToAssetId(address token) internal pure returns (bytes32) { |
| 290 | + return bytes32(uint256(uint160(token))); |
| 291 | + } |
| 292 | + |
| 293 | + function _getStorage() internal pure returns (StashDexStorage storage $) { |
| 294 | + assembly { |
| 295 | + $.slot := STORAGE_LOCATION |
| 296 | + } |
| 297 | + } |
| 298 | +} |
0 commit comments