Skip to content

Commit d97b92c

Browse files
committed
feat: ERC4626 Exchange Rate Threshold (SC-1229)
1 parent f529c06 commit d97b92c

File tree

11 files changed

+231
-134
lines changed

11 files changed

+231
-134
lines changed

src/ForeignController.sol

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ contract ForeignController is ReentrancyGuard, AccessControlEnumerable {
5050

5151
event LayerZeroRecipientSet(uint32 indexed destinationEndpointId, bytes32 layerZeroRecipient);
5252
event MaxSlippageSet(address indexed pool, uint256 maxSlippage);
53+
event MaxExchangeRateSet(address indexed token, uint256 maxExchangeRate);
5354
event MintRecipientSet(uint32 indexed destinationDomain, bytes32 mintRecipient);
5455
event RelayerRemoved(address indexed relayer);
5556

@@ -84,6 +85,9 @@ contract ForeignController is ReentrancyGuard, AccessControlEnumerable {
8485
mapping(uint32 destinationDomain => bytes32 mintRecipient) public mintRecipients;
8586
mapping(uint32 destinationEndpointId => bytes32 layerZeroRecipient) public layerZeroRecipients;
8687

88+
// ERC4626 exchange rate thresholds
89+
mapping(address token => uint256 maxExchangeRate) public maxExchangeRates;
90+
8791
/**********************************************************************************************/
8892
/*** Initialization ***/
8993
/**********************************************************************************************/
@@ -154,6 +158,14 @@ contract ForeignController is ReentrancyGuard, AccessControlEnumerable {
154158
emit LayerZeroRecipientSet(destinationEndpointId, layerZeroRecipient);
155159
}
156160

161+
function setMaxExchangeRate(address token, uint256 maxExchangeRate) external nonReentrant {
162+
_checkRole(DEFAULT_ADMIN_ROLE);
163+
164+
require(token != address(0), "ForeignController/token-zero-address");
165+
166+
emit MaxExchangeRateSet(token, maxExchangeRates[token] = maxExchangeRate);
167+
}
168+
157169
/**********************************************************************************************/
158170
/*** Freezer functions ***/
159171
/**********************************************************************************************/
@@ -330,27 +342,25 @@ contract ForeignController is ReentrancyGuard, AccessControlEnumerable {
330342
rateLimitedAddress(LIMIT_4626_DEPOSIT, token, amount)
331343
returns (uint256 shares)
332344
{
333-
require(maxSlippages[token] != 0, "ForeignController/max-slippage-not-set");
345+
require(
346+
IERC4626(token).convertToAssets(1e18) <= maxExchangeRates[token],
347+
"ForeignController/exchange-rate-too-high"
348+
);
334349

335350
// Note that whitelist is done by rate limits.
336-
IERC20 asset = IERC20(IERC4626(token).asset());
351+
address asset = IERC4626(token).asset();
337352

338353
// Approve asset to token from the proxy (assumes the proxy has enough of the asset).
339-
_approve(address(asset), token, amount);
354+
_approve(asset, token, amount);
340355

341356
// Deposit asset into the token, proxy receives token shares, decode the resulting shares.
342-
shares = abi.decode(
357+
return abi.decode(
343358
proxy.doCall(
344359
token,
345360
abi.encodeCall(IERC4626(token).deposit, (amount, address(proxy)))
346361
),
347362
(uint256)
348363
);
349-
350-
require(
351-
IERC4626(token).convertToAssets(shares) >= amount * maxSlippages[token] / 1e18,
352-
"ForeignController/inflated-shares"
353-
);
354364
}
355365

356366
function withdrawERC4626(address token, uint256 amount)

src/MainnetController.sol

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ contract MainnetController is ReentrancyGuard, AccessControlEnumerable {
9999

100100
event LayerZeroRecipientSet(uint32 indexed destinationEndpointId, bytes32 layerZeroRecipient);
101101
event MaxSlippageSet(address indexed pool, uint256 maxSlippage);
102+
event MaxExchangeRateSet(address indexed token, uint256 maxExchangeRate);
102103
event MintRecipientSet(uint32 indexed destinationDomain, bytes32 mintRecipient);
103104
event OTCBufferSet(
104105
address indexed exchange,
@@ -188,6 +189,9 @@ contract MainnetController is ReentrancyGuard, AccessControlEnumerable {
188189

189190
mapping(address exchange => mapping(address asset => bool)) public otcWhitelistedAssets;
190191

192+
// ERC4626 exchange rate thresholds
193+
mapping(address token => uint256 maxExchangeRate) public maxExchangeRates;
194+
191195
/**********************************************************************************************/
192196
/*** Initialization ***/
193197
/**********************************************************************************************/
@@ -288,6 +292,14 @@ contract MainnetController is ReentrancyGuard, AccessControlEnumerable {
288292
otcWhitelistedAssets[exchange][asset] = isWhitelisted;
289293
}
290294

295+
function setMaxExchangeRate(address token, uint256 maxExchangeRate) external nonReentrant {
296+
_checkRole(DEFAULT_ADMIN_ROLE);
297+
298+
require(token != address(0), "MainnetController/token-zero-address");
299+
300+
emit MaxExchangeRateSet(token, maxExchangeRates[token] = maxExchangeRate);
301+
}
302+
291303
/**********************************************************************************************/
292304
/*** Freezer functions ***/
293305
/**********************************************************************************************/
@@ -439,13 +451,16 @@ contract MainnetController is ReentrancyGuard, AccessControlEnumerable {
439451
_checkRole(RELAYER);
440452
_rateLimitedAddress(LIMIT_4626_DEPOSIT, token, amount);
441453

442-
require(maxSlippages[token] != 0, "MainnetController/max-slippage-not-set");
454+
require(
455+
IERC4626(token).convertToAssets(1e18) <= maxExchangeRates[token],
456+
"MainnetController/exchange-rate-too-high"
457+
);
443458

444459
// Note that whitelist is done by rate limits
445-
IERC20 asset = IERC20(IERC4626(token).asset());
460+
address asset = IERC4626(token).asset();
446461

447462
// Approve asset to token from the proxy (assumes the proxy has enough of the asset).
448-
_approve(address(asset), token, amount);
463+
_approve(asset, token, amount);
449464

450465
// Deposit asset into the token, proxy receives token shares, decode the resulting shares
451466
shares = abi.decode(
@@ -455,11 +470,6 @@ contract MainnetController is ReentrancyGuard, AccessControlEnumerable {
455470
),
456471
(uint256)
457472
);
458-
459-
require(
460-
IERC4626(token).convertToAssets(shares) >= amount * maxSlippages[token] / 1e18,
461-
"MainnetController/slippage-too-high"
462-
);
463473
}
464474

465475
function withdrawERC4626(address token, uint256 amount)

test/base-fork/Morpho.t.sol

Lines changed: 21 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,8 @@ contract MorphoBaseTest is ForkTestBase {
102102
uint256(5_000_000e6) / 1 days
103103
);
104104

105-
foreignController.setMaxSlippage(MORPHO_VAULT_USDS, 1e18 - 1e4); // Rounding slippage
106-
foreignController.setMaxSlippage(MORPHO_VAULT_USDC, 1e18 - 1e4); // Rounding slippage
105+
foreignController.setMaxExchangeRate(MORPHO_VAULT_USDS, IERC4626(MORPHO_VAULT_USDS).convertToAssets(1e18));
106+
foreignController.setMaxExchangeRate(MORPHO_VAULT_USDC, IERC4626(MORPHO_VAULT_USDC).convertToAssets(1e18));
107107

108108
vm.stopPrank();
109109
}
@@ -140,50 +140,45 @@ contract MorphoDepositFailureTests is MorphoBaseTest {
140140
foreignController.depositERC4626(makeAddr("fake-token"), 1e18);
141141
}
142142

143-
function test_depositERC4626_zeroMaxSlippage() external {
144-
vm.prank(Base.SPARK_EXECUTOR);
145-
foreignController.setMaxSlippage(MORPHO_VAULT_USDS, 0);
143+
function test_depositERC4626_exchangeRateBoundary() external {
144+
deal(Base.USDS, address(almProxy), 25_000_000e18);
145+
146+
vm.startPrank(Base.SPARK_EXECUTOR);
147+
foreignController.setMaxExchangeRate(MORPHO_VAULT_USDS, IERC4626(MORPHO_VAULT_USDS).convertToAssets(1e18) - 1);
148+
vm.stopPrank();
149+
150+
vm.prank(relayer);
151+
vm.expectRevert("ForeignController/exchange-rate-too-high");
152+
foreignController.depositERC4626(MORPHO_VAULT_USDS, 25_000_000e18);
153+
154+
vm.startPrank(Base.SPARK_EXECUTOR);
155+
foreignController.setMaxExchangeRate(MORPHO_VAULT_USDS, IERC4626(MORPHO_VAULT_USDS).convertToAssets(1e18));
156+
vm.stopPrank();
146157

147158
vm.prank(relayer);
148-
vm.expectRevert("ForeignController/max-slippage-not-set");
149-
foreignController.depositERC4626(MORPHO_VAULT_USDS, 1e18);
159+
foreignController.depositERC4626(MORPHO_VAULT_USDS, 25_000_000e18);
150160
}
151161

152162
function test_morpho_usds_deposit_rateLimitedBoundary() external {
153163
deal(Base.USDS, address(almProxy), 25_000_000e18 + 1);
154164

155165
vm.expectRevert("RateLimits/rate-limit-exceeded");
156-
vm.startPrank(relayer);
166+
vm.prank(relayer);
157167
foreignController.depositERC4626(MORPHO_VAULT_USDS, 25_000_000e18 + 1);
158168

169+
vm.prank(relayer);
159170
foreignController.depositERC4626(MORPHO_VAULT_USDS, 25_000_000e18);
160171
}
161172

162173
function test_morpho_usdc_deposit_rateLimitedBoundary() external {
163174
deal(Base.USDC, address(almProxy), 25_000_000e6 + 1);
164175

165176
vm.expectRevert("RateLimits/rate-limit-exceeded");
166-
vm.startPrank(relayer);
167-
foreignController.depositERC4626(MORPHO_VAULT_USDC, 25_000_000e6 + 1);
168-
169-
foreignController.depositERC4626(MORPHO_VAULT_USDC, 25_000_000e6);
170-
}
171-
172-
function test_morpho_usds_deposit_slippageBoundary() external {
173-
deal(Base.USDS, address(almProxy), 5_000_000e18);
174-
175-
vm.prank(Base.SPARK_EXECUTOR);
176-
foreignController.setMaxSlippage(MORPHO_VAULT_USDS, 1e18 + 1); // Positive slippage needed to cause error
177-
178177
vm.prank(relayer);
179-
vm.expectRevert("ForeignController/inflated-shares");
180-
foreignController.depositERC4626(MORPHO_VAULT_USDS, 5_000_000e18);
181-
182-
vm.prank(Base.SPARK_EXECUTOR);
183-
foreignController.setMaxSlippage(MORPHO_VAULT_USDS, 1e18);
178+
foreignController.depositERC4626(MORPHO_VAULT_USDC, 25_000_000e6 + 1);
184179

185180
vm.prank(relayer);
186-
foreignController.depositERC4626(MORPHO_VAULT_USDS, 5_000_000e18);
181+
foreignController.depositERC4626(MORPHO_VAULT_USDC, 25_000_000e6);
187182
}
188183

189184
}

test/base-fork/MorphoAllocations.t.sol

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ contract MorphoReallocateMorphoSuccessTests is MorphoTestBase {
219219
25_000_000e6,
220220
uint256(5_000_000e6) / 1 days
221221
);
222-
foreignController.setMaxSlippage(address(morphoVault), 1e18 - 1e4); // Rounding slippage
222+
foreignController.setMaxExchangeRate(address(morphoVault), IERC4626(address(morphoVault)).convertToAssets(1e18));
223223
vm.stopPrank();
224224

225225
// Refresh markets so calculations don't include interest

test/base-fork/SparkVault.t.sol

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// SPDX-License-Identifier: AGPL-3.0-or-later
22
pragma solidity >=0.8.0;
33

4+
import { IERC4626 } from "forge-std/interfaces/IERC4626.sol";
5+
46
import { ERC20Mock as MockERC20 } from "../../lib/openzeppelin-contracts/contracts/mocks/token/ERC20Mock.sol";
57
import { ERC1967Proxy } from "../../lib/openzeppelin-contracts/contracts/proxy/ERC1967/ERC1967Proxy.sol";
68
import { ReentrancyGuard } from "../../lib/openzeppelin-contracts/contracts/utils/ReentrancyGuard.sol";
@@ -287,9 +289,9 @@ contract ForeignControllerTakeFromSparkVaultE2ETests is ForkTestBase {
287289

288290
rateLimits.setUnlimitedRateLimitData(morphoWithdrawKey);
289291

290-
// Step 4 (spell): Set maxSlippage for ERC4626 deposit
292+
// Step 4 (spell): Set maxExchangeRate for ERC4626 deposit
291293

292-
foreignController.setMaxSlippage(morphoUsdcVault, 1e18 - 1e4); // Rounding slippage
294+
foreignController.setMaxExchangeRate(morphoUsdcVault, 1.2e18);
293295

294296
vm.stopPrank();
295297
}

test/mainnet-fork/4626Calls.t.sol

Lines changed: 39 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,20 @@ contract SUSDSTestBase is ForkTestBase {
1515

1616
uint256 SUSDS_DRIP_AMOUNT;
1717

18+
bytes32 depositKey;
19+
bytes32 withdrawKey;
20+
1821
function setUp() override public {
1922
super.setUp();
2023

21-
bytes32 depositKey = RateLimitHelpers.makeAddressKey(mainnetController.LIMIT_4626_DEPOSIT(), Ethereum.SUSDS);
22-
bytes32 withdrawKey = RateLimitHelpers.makeAddressKey(mainnetController.LIMIT_4626_WITHDRAW(), Ethereum.SUSDS);
24+
depositKey = RateLimitHelpers.makeAddressKey(mainnetController.LIMIT_4626_DEPOSIT(), Ethereum.SUSDS);
25+
withdrawKey = RateLimitHelpers.makeAddressKey(mainnetController.LIMIT_4626_WITHDRAW(), Ethereum.SUSDS);
2326

2427
vm.startPrank(Ethereum.SPARK_PROXY);
28+
rateLimits.setRateLimitData(mainnetController.LIMIT_USDS_MINT(), 10_000_000e18, uint256(10_000_000e18) / 4 hours);
2529
rateLimits.setRateLimitData(depositKey, 5_000_000e18, uint256(1_000_000e18) / 4 hours);
2630
rateLimits.setRateLimitData(withdrawKey, 5_000_000e18, uint256(1_000_000e18) / 4 hours);
27-
mainnetController.setMaxSlippage(address(susds), 1e18 - 1e4); // Rounding slippage
31+
mainnetController.setMaxExchangeRate(address(susds), susds.convertToAssets(1e18));
2832
vm.stopPrank();
2933

3034
SUSDS_CONVERTED_ASSETS = susds.convertToAssets(1e18);
@@ -69,45 +73,36 @@ contract MainnetControllerDepositERC4626FailureTests is SUSDSTestBase {
6973
mainnetController.depositERC4626(makeAddr("fake-token"), 1e18);
7074
}
7175

72-
function test_depositERC4626_zeroMaxSlippage() external {
73-
vm.prank(Ethereum.SPARK_PROXY);
74-
mainnetController.setMaxSlippage(address(susds), 0);
75-
76-
vm.prank(relayer);
77-
vm.expectRevert("MainnetController/max-slippage-not-set");
78-
mainnetController.depositERC4626(address(susds), 1e18);
79-
}
80-
81-
function test_depositERC4626_rateLimitBoundary() external {
76+
function test_depositERC4626_exchangeRateBoundary() external {
8277
vm.startPrank(relayer);
8378
mainnetController.mintUSDS(5_000_000e18);
79+
vm.stopPrank();
8480

85-
// Have to warp to get back above rate limit
86-
skip(1 minutes);
87-
mainnetController.mintUSDS(1);
81+
vm.startPrank(Ethereum.SPARK_PROXY);
82+
mainnetController.setMaxExchangeRate(address(susds), IERC4626(address(susds)).convertToAssets(1e18) - 1);
83+
vm.stopPrank();
8884

89-
vm.expectRevert("RateLimits/rate-limit-exceeded");
90-
mainnetController.depositERC4626(address(susds), 5_000_000e18 + 1);
85+
vm.prank(relayer);
86+
vm.expectRevert("MainnetController/exchange-rate-too-high");
87+
mainnetController.depositERC4626(address(susds), 5_000_000e18);
88+
89+
vm.startPrank(Ethereum.SPARK_PROXY);
90+
mainnetController.setMaxExchangeRate(address(susds), IERC4626(address(susds)).convertToAssets(1e18));
91+
vm.stopPrank();
9192

93+
vm.prank(relayer);
9294
mainnetController.depositERC4626(address(susds), 5_000_000e18);
9395
}
9496

95-
function test_depositERC4626_slippageBoundary() external {
96-
vm.prank(relayer);
97+
function test_depositERC4626_rateLimitBoundary() external {
98+
vm.startPrank(relayer);
9799
mainnetController.mintUSDS(5_000_000e18);
98100

99-
vm.prank(Ethereum.SPARK_PROXY);
100-
mainnetController.setMaxSlippage(address(susds), 1e18);
101-
102-
vm.prank(relayer);
103-
vm.expectRevert("MainnetController/slippage-too-high");
104-
mainnetController.depositERC4626(address(susds), 5_000_000e18); // Rounding causes error
105-
106-
vm.prank(Ethereum.SPARK_PROXY);
107-
mainnetController.setMaxSlippage(address(susds), 1e18 - 1);
101+
vm.expectRevert("RateLimits/rate-limit-exceeded");
102+
mainnetController.depositERC4626(address(susds), 5_000_000e18 + 1);
108103

109-
vm.prank(relayer);
110104
mainnetController.depositERC4626(address(susds), 5_000_000e18);
105+
vm.stopPrank();
111106
}
112107

113108
}
@@ -176,19 +171,19 @@ contract MainnetControllerWithdrawERC4626FailureTests is SUSDSTestBase {
176171
}
177172

178173
function test_withdrawERC4626_rateLimitBoundary() external {
179-
vm.startPrank(relayer);
180-
mainnetController.mintUSDS(5_000_000e18);
181-
mainnetController.depositERC4626(address(susds), 5_000_000e18);
174+
vm.startPrank(Ethereum.SPARK_PROXY);
175+
rateLimits.setRateLimitData(depositKey, 10_000_000e18, uint256(1_000_000e18) / 4 hours);
176+
vm.stopPrank();
182177

183-
// Have to warp to get back above rate limit
184-
skip(1 minutes);
185-
mainnetController.mintUSDS(1);
186-
mainnetController.depositERC4626(address(susds), 1);
178+
vm.startPrank(relayer);
179+
mainnetController.mintUSDS(10_000_000e18);
180+
mainnetController.depositERC4626(address(susds), 10_000_000e18);
187181

188182
vm.expectRevert("RateLimits/rate-limit-exceeded");
189183
mainnetController.withdrawERC4626(address(susds), 5_000_000e18 + 1);
190184

191185
mainnetController.withdrawERC4626(address(susds), 5_000_000e18);
186+
vm.stopPrank();
192187
}
193188

194189
}
@@ -292,14 +287,13 @@ contract MainnetControllerRedeemERC4626FailureTests is SUSDSTestBase {
292287
}
293288

294289
function test_redeemERC4626_rateLimitBoundary() external {
295-
vm.startPrank(relayer);
296-
mainnetController.mintUSDS(5_000_000e18);
297-
mainnetController.depositERC4626(address(susds), 5_000_000e18);
290+
vm.startPrank(Ethereum.SPARK_PROXY);
291+
rateLimits.setRateLimitData(depositKey, 10_000_000e18, uint256(1_000_000e18) / 4 hours);
292+
vm.stopPrank();
298293

299-
// Have to warp to get back above rate limit
300-
skip(10 minutes);
301-
mainnetController.mintUSDS(100e18);
302-
mainnetController.depositERC4626(address(susds), 100e18);
294+
vm.startPrank(relayer);
295+
mainnetController.mintUSDS(10_000_000e18);
296+
mainnetController.depositERC4626(address(susds), 10_000_000e18);
303297

304298
uint256 overBoundaryShares = susds.convertToShares(5_000_000e18 + 2);
305299
uint256 atBoundaryShares = susds.convertToShares(5_000_000e18 + 1); // Still rounds down
@@ -311,6 +305,7 @@ contract MainnetControllerRedeemERC4626FailureTests is SUSDSTestBase {
311305
mainnetController.redeemERC4626(address(susds), overBoundaryShares);
312306

313307
mainnetController.redeemERC4626(address(susds), atBoundaryShares);
308+
vm.stopPrank();
314309
}
315310

316311
}

0 commit comments

Comments
 (0)