Skip to content

Commit 190ec4a

Browse files
authored
Merge pull request #252 from sprintertech/feat/stash-dex
Feat/stash dex
2 parents 70e5834 + 5f2df97 commit 190ec4a

10 files changed

Lines changed: 1308 additions & 7 deletions

File tree

contracts/StashDex.sol

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
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+
}

contracts/interfaces/ILiquidityPool.sol

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,11 @@ interface ILiquidityPool is ILiquidityPoolBase {
5757
) external;
5858

5959
function repay(address[] calldata borrowTokens) external;
60-
60+
6161
function repayDirect(address[] calldata borrowTokens, uint256[] calldata maxAmounts) external;
6262

63+
function directDebt(address token) external view returns (uint256);
64+
6365
function pauseBorrow() external;
6466

6567
function unpauseBorrow() external;

contracts/testing/TestLiquidityPool.sol

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ contract TestLiquidityPool is ILiquidityPool, AccessControl {
99
IERC20 public immutable ASSETS;
1010
bytes32 public constant LIQUIDITY_ADMIN_ROLE = "LIQUIDITY_ADMIN_ROLE";
1111
IWrappedNativeToken immutable public WRAPPED_NATIVE_TOKEN;
12+
mapping(address => uint256) private _directDebt;
1213

1314
event Deposit();
1415
event Repaid();
@@ -44,7 +45,14 @@ contract TestLiquidityPool is ILiquidityPool, AccessControl {
4445
return;
4546
}
4647

47-
function borrowDirect(address, uint256) external pure override { return; }
48+
function directDebt(address token) external view override returns (uint256) {
49+
return _directDebt[token];
50+
}
51+
52+
function borrowDirect(address borrowToken, uint256 amount) external override {
53+
_directDebt[borrowToken] += amount;
54+
IERC20(borrowToken).approve(msg.sender, amount);
55+
}
4856

4957
function borrowMany(
5058
address[] calldata,
@@ -88,7 +96,14 @@ contract TestLiquidityPool is ILiquidityPool, AccessControl {
8896
emit Repaid();
8997
}
9098

91-
function repayDirect(address[] calldata, uint256[] calldata) external override {
99+
function repayDirect(address[] calldata borrowTokens, uint256[] calldata maxAmounts) external override {
100+
for (uint256 i = 0; i < borrowTokens.length; i++) {
101+
uint256 debt = _directDebt[borrowTokens[i]];
102+
if (debt == 0) continue;
103+
uint256 repayAmount = maxAmounts[i] < debt ? maxAmounts[i] : debt;
104+
_directDebt[borrowTokens[i]] = debt - repayAmount;
105+
IERC20(borrowTokens[i]).transferFrom(msg.sender, address(this), repayAmount);
106+
}
92107
emit Repaid();
93108
}
94109

contracts/testing/TestWETH.sol

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ contract TestWETH is ERC20 {
1111

1212
constructor() ERC20("Wrapped Ether", "WETH") {}
1313

14+
function mint(address to, uint256 amount) external {
15+
_mint(to, amount);
16+
}
17+
1418
function deposit() external payable {
1519
_mint(msg.sender, msg.value);
1620
emit Deposit(msg.sender, msg.value);

coverage-baseline.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
2-
"lines": "99.68",
3-
"functions": "99.65",
4-
"branches": "92.47",
5-
"statements": "99.68"
2+
"lines": "99.71",
3+
"functions": "99.68",
4+
"branches": "93.18",
5+
"statements": "99.71"
66
}

0 commit comments

Comments
 (0)