Skip to content

Commit 5ed8ee8

Browse files
committed
feature: arbitrator for early withdrawals
1 parent f036c62 commit 5ed8ee8

File tree

7 files changed

+127
-3
lines changed

7 files changed

+127
-3
lines changed

src/contracts/interfaces/IDurationVaultStrategy.sol

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ interface IDurationVaultStrategyErrors {
1717
error InvalidDuration();
1818
/// @dev Thrown when attempting to mutate configuration from a non-admin.
1919
error OnlyVaultAdmin();
20+
/// @dev Thrown when attempting to call arbitrator-only functionality from a non-arbitrator.
21+
error OnlyArbitrator();
22+
/// @dev Thrown when attempting to configure a zero-address arbitrator.
23+
error InvalidArbitrator();
2024
/// @dev Thrown when attempting to lock an already locked vault.
2125
error VaultAlreadyLocked();
2226
/// @dev Thrown when attempting to deposit after the vault has been locked.
@@ -27,6 +31,10 @@ interface IDurationVaultStrategyErrors {
2731
error MustBeDelegatedToVaultOperator();
2832
/// @dev Thrown when attempting to mark the vault as matured before duration elapses.
2933
error DurationNotElapsed();
34+
/// @dev Thrown when attempting to use the arbitrator early-advance after the duration has elapsed.
35+
error DurationAlreadyElapsed();
36+
/// @dev Thrown when attempting to use the arbitrator early-advance before the vault is locked.
37+
error VaultNotLocked();
3038
/// @dev Thrown when operator integration inputs are missing or invalid.
3139
error OperatorIntegrationInvalid();
3240
/// @dev Thrown when attempting to deposit into a vault whose underlying token is blacklisted.
@@ -61,6 +69,7 @@ interface IDurationVaultStrategyTypes {
6169
struct VaultConfig {
6270
IERC20 underlyingToken;
6371
address vaultAdmin;
72+
address arbitrator;
6473
uint32 duration;
6574
uint256 maxPerDeposit;
6675
uint256 stakeCap;
@@ -99,6 +108,11 @@ interface IDurationVaultStrategyEvents {
99108
/// @param maturedAt Timestamp when the vault matured.
100109
event VaultMatured(uint32 maturedAt);
101110

111+
/// @notice Emitted when the vault is advanced to WITHDRAWALS early by the arbitrator.
112+
/// @param arbitrator The arbitrator that performed the early advance.
113+
/// @param maturedAt Timestamp when the vault transitioned to WITHDRAWALS.
114+
event VaultAdvancedToWithdrawals(address indexed arbitrator, uint32 maturedAt);
115+
102116
/// @notice Emitted when the vault metadata URI is updated.
103117
/// @param newMetadataURI The new metadata URI.
104118
event MetadataURIUpdated(string newMetadataURI);
@@ -138,6 +152,11 @@ interface IDurationVaultStrategy is
138152
/// the duration has elapsed.
139153
function markMatured() external;
140154

155+
/// @notice Advances the vault to WITHDRAWALS early, after lock but before duration elapses.
156+
/// @dev Transitions state from ALLOCATIONS to WITHDRAWALS, and triggers the same best-effort operator cleanup
157+
/// as `markMatured()`. Only callable by the configured arbitrator.
158+
function advanceToWithdrawals() external;
159+
141160
/// @notice Updates the vault metadata URI.
142161
/// @param newMetadataURI The new metadata URI to set.
143162
/// @dev Only callable by the vault admin.
@@ -157,6 +176,9 @@ interface IDurationVaultStrategy is
157176
/// @notice Returns the vault administrator address.
158177
function vaultAdmin() external view returns (address);
159178

179+
/// @notice Returns the arbitrator address.
180+
function arbitrator() external view returns (address);
181+
160182
/// @notice Returns the configured lock duration in seconds.
161183
function duration() external view returns (uint32);
162184

src/contracts/strategies/DurationVaultStrategy.sol

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ contract DurationVaultStrategy is DurationVaultStrategyStorage, StrategyBase {
4343
_;
4444
}
4545

46+
/// @dev Restricts function access to the vault arbitrator.
47+
modifier onlyArbitrator() {
48+
require(msg.sender == arbitrator, OnlyArbitrator());
49+
_;
50+
}
51+
4652
/// @param _strategyManager The StrategyManager contract.
4753
/// @param _pauserRegistry The PauserRegistry contract.
4854
/// @param _delegationManager The DelegationManager contract for operator registration.
@@ -75,11 +81,13 @@ contract DurationVaultStrategy is DurationVaultStrategyStorage, StrategyBase {
7581
VaultConfig memory config
7682
) public initializer {
7783
require(config.vaultAdmin != address(0), InvalidVaultAdmin());
84+
require(config.arbitrator != address(0), InvalidArbitrator());
7885
require(config.duration != 0 && config.duration <= MAX_DURATION, InvalidDuration());
7986
_setTVLLimits(config.maxPerDeposit, config.stakeCap);
8087
_initializeStrategyBase(config.underlyingToken);
8188

8289
vaultAdmin = config.vaultAdmin;
90+
arbitrator = config.arbitrator;
8391
duration = config.duration;
8492
metadataURI = config.metadataURI;
8593

@@ -127,6 +135,26 @@ contract DurationVaultStrategy is DurationVaultStrategyStorage, StrategyBase {
127135
_deregisterFromOperatorSet();
128136
}
129137

138+
/// @notice Advances the vault to withdrawals early, after lock but before duration elapses.
139+
/// @dev Only callable by the configured arbitrator.
140+
function advanceToWithdrawals() external override onlyArbitrator {
141+
if (_state == VaultState.WITHDRAWALS) {
142+
// already recorded; noop
143+
return;
144+
}
145+
require(_state == VaultState.ALLOCATIONS, VaultNotLocked());
146+
require(block.timestamp < unlockAt, DurationAlreadyElapsed());
147+
148+
_state = VaultState.WITHDRAWALS;
149+
maturedAt = uint32(block.timestamp);
150+
151+
emit VaultMatured(maturedAt);
152+
emit VaultAdvancedToWithdrawals(msg.sender, maturedAt);
153+
154+
_deallocateAll();
155+
_deregisterFromOperatorSet();
156+
}
157+
130158
/// @notice Updates the metadata URI describing the vault.
131159
function updateMetadataURI(
132160
string calldata newMetadataURI

src/contracts/strategies/DurationVaultStrategyStorage.sol

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ abstract contract DurationVaultStrategyStorage is IDurationVaultStrategy {
1717
/// @notice Address empowered to configure and lock the vault.
1818
address public vaultAdmin;
1919

20+
/// @notice Address empowered to advance the vault to withdrawals early (after lock, before duration elapses).
21+
address public arbitrator;
22+
2023
/// @notice The enforced lock duration once `lock` is called.
2124
uint32 public duration;
2225

@@ -46,8 +49,8 @@ abstract contract DurationVaultStrategyStorage is IDurationVaultStrategy {
4649

4750
/// @dev This empty reserved space is put in place to allow future versions to add new
4851
/// variables without shifting down storage in the inheritance chain.
49-
/// Storage slots used: vaultAdmin (1) + duration/lockedAt/unlockAt/maturedAt/_state (packed, 1) +
52+
/// Storage slots used: vaultAdmin (1) + arbitrator (1) + duration/lockedAt/unlockAt/maturedAt/_state (packed, 1) +
5053
/// metadataURI (1) + _operatorSet (1) + maxPerDeposit (1) + maxTotalDeposits (1) = 6.
51-
/// Gap: 50 - 6 = 44.
52-
uint256[44] private __gap;
54+
/// Gap: 50 - 7 = 43.
55+
uint256[43] private __gap;
5356
}

src/test/integration/tests/DurationVaultIntegration.t.sol

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,50 @@ contract Integration_DurationVault is IntegrationCheckUtils {
201201
assertEq(ctx.asset.balanceOf(address(staker)), depositAmount, "staker should recover deposit");
202202
}
203203

204+
function test_durationVault_arbitrator_can_advance_to_withdrawals_early() public {
205+
DurationVaultContext memory ctx = _deployDurationVault(_randomInsuranceRecipient());
206+
User staker = new User("duration-arbitrator-staker");
207+
208+
uint depositAmount = 50 ether;
209+
ctx.asset.transfer(address(staker), depositAmount);
210+
IStrategy[] memory strategies = _durationStrategyArray(ctx.vault);
211+
uint[] memory tokenBalances = _singleAmountArray(depositAmount);
212+
_delegateToVault(staker, ctx.vault);
213+
staker.depositIntoEigenlayer(strategies, tokenBalances);
214+
215+
// Arbitrator cannot advance before lock.
216+
cheats.expectRevert(IDurationVaultStrategyErrors.VaultNotLocked.selector);
217+
ctx.vault.advanceToWithdrawals();
218+
219+
ctx.vault.lock();
220+
assertTrue(ctx.vault.allocationsActive(), "allocations should be active after lock");
221+
assertFalse(ctx.vault.withdrawalsOpen(), "withdrawals should be closed while locked");
222+
223+
// Non-arbitrator cannot advance.
224+
cheats.prank(address(0x1234));
225+
cheats.expectRevert(IDurationVaultStrategyErrors.OnlyArbitrator.selector);
226+
ctx.vault.advanceToWithdrawals();
227+
228+
// Arbitrator can advance after lock but before duration elapses.
229+
cheats.warp(block.timestamp + 1);
230+
ctx.vault.advanceToWithdrawals();
231+
232+
assertTrue(ctx.vault.withdrawalsOpen(), "withdrawals should open after arbitrator advance");
233+
assertFalse(ctx.vault.allocationsActive(), "allocations should be inactive after arbitrator advance");
234+
assertTrue(ctx.vault.isMatured(), "vault should be matured after arbitrator advance");
235+
236+
// Withdrawals should actually be possible in this early-advance path.
237+
uint[] memory withdrawableShares = _getStakerWithdrawableShares(staker, strategies);
238+
Withdrawal[] memory withdrawals = staker.queueWithdrawals(strategies, withdrawableShares);
239+
_rollBlocksForCompleteWithdrawals(withdrawals);
240+
IERC20[] memory tokens = staker.completeWithdrawalAsTokens(withdrawals[0]);
241+
assertEq(address(tokens[0]), address(ctx.asset), "unexpected token returned");
242+
assertEq(ctx.asset.balanceOf(address(staker)), depositAmount, "staker should recover deposit after arbitrator advance");
243+
244+
// markMatured should be a noop after the state has already transitioned.
245+
ctx.vault.markMatured();
246+
}
247+
204248
function test_durationVault_operatorIntegrationAndMetadataUpdate() public {
205249
DurationVaultContext memory ctx = _deployDurationVault(_randomInsuranceRecipient());
206250

@@ -396,6 +440,7 @@ contract Integration_DurationVault is IntegrationCheckUtils {
396440
IDurationVaultStrategyTypes.VaultConfig memory config;
397441
config.underlyingToken = asset;
398442
config.vaultAdmin = address(this);
443+
config.arbitrator = address(this);
399444
config.duration = DEFAULT_DURATION;
400445
config.maxPerDeposit = VAULT_MAX_PER_DEPOSIT;
401446
config.stakeCap = VAULT_STAKE_CAP;

src/test/unit/DurationVaultStrategyUnit.t.sol

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ contract DurationVaultStrategyUnitTests is StrategyBaseUnitTests {
5757
IDurationVaultStrategyTypes.VaultConfig memory config = IDurationVaultStrategyTypes.VaultConfig({
5858
underlyingToken: underlyingToken,
5959
vaultAdmin: address(this),
60+
arbitrator: address(this),
6061
duration: defaultDuration,
6162
maxPerDeposit: maxPerDeposit,
6263
stakeCap: maxTotalDeposits,
@@ -168,6 +169,28 @@ contract DurationVaultStrategyUnitTests is StrategyBaseUnitTests {
168169
assertEq(allocationManagerMock.deregisterFromOperatorSetsCallCount(), 0, "unexpected deregister count");
169170
}
170171

172+
function testAdvanceToWithdrawals_onlyArbitrator_and_onlyBeforeUnlock() public {
173+
// Cannot advance before lock (even as arbitrator).
174+
cheats.expectRevert(IDurationVaultStrategyErrors.VaultNotLocked.selector);
175+
durationVault.advanceToWithdrawals();
176+
177+
durationVault.lock();
178+
179+
// Non-arbitrator cannot advance.
180+
cheats.prank(address(0xBEEF));
181+
cheats.expectRevert(IDurationVaultStrategyErrors.OnlyArbitrator.selector);
182+
durationVault.advanceToWithdrawals();
183+
184+
// After unlockAt, arbitrator advance is not allowed.
185+
cheats.warp(block.timestamp + defaultDuration + 1);
186+
cheats.expectRevert(IDurationVaultStrategyErrors.DurationAlreadyElapsed.selector);
187+
durationVault.advanceToWithdrawals();
188+
189+
// markMatured works once duration has elapsed.
190+
durationVault.markMatured();
191+
assertTrue(durationVault.withdrawalsOpen(), "withdrawals should open after maturity");
192+
}
193+
171194
// ===================== VAULT STATE TESTS =====================
172195

173196
function testDepositsBlockedAfterLock() public {

src/test/unit/StrategyFactoryUnit.t.sol

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ contract StrategyFactoryUnitTests is EigenLayerUnitTestSetup {
151151
IDurationVaultStrategyTypes.VaultConfig memory config = IDurationVaultStrategyTypes.VaultConfig({
152152
underlyingToken: underlyingToken,
153153
vaultAdmin: address(this),
154+
arbitrator: address(this),
154155
duration: uint32(30 days),
155156
maxPerDeposit: 10 ether,
156157
stakeCap: 100 ether,
@@ -176,6 +177,7 @@ contract StrategyFactoryUnitTests is EigenLayerUnitTestSetup {
176177
IDurationVaultStrategyTypes.VaultConfig memory config = IDurationVaultStrategyTypes.VaultConfig({
177178
underlyingToken: underlyingToken,
178179
vaultAdmin: address(this),
180+
arbitrator: address(this),
179181
duration: uint32(7 days),
180182
maxPerDeposit: 5 ether,
181183
stakeCap: 50 ether,

src/test/unit/StrategyManagerDurationUnit.t.sol

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ contract StrategyManagerDurationUnitTests is EigenLayerUnitTestSetup, IStrategyM
7373
IDurationVaultStrategyTypes.VaultConfig memory cfg = IDurationVaultStrategyTypes.VaultConfig({
7474
underlyingToken: IERC20(address(underlyingToken)),
7575
vaultAdmin: address(this),
76+
arbitrator: address(this),
7677
duration: uint32(30 days),
7778
maxPerDeposit: 1_000_000 ether,
7879
stakeCap: 10_000_000 ether,

0 commit comments

Comments
 (0)