Skip to content

Commit ac85959

Browse files
authored
feat: arbitrator for early withdrawals (#1700)
**Motivation:** *Explain here the context, and why you're making that change. What is the problem you're trying to solve.* **Modifications:** *Describe the modifications you've done.* **Result:** *After your change, what will change.*
1 parent 6f102c0 commit ac85959

File tree

8 files changed

+165
-7
lines changed

8 files changed

+165
-7
lines changed

docs/core/DurationVaultStrategy.md

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@ The `DurationVaultStrategy` is a **time-bound, single-use EigenLayer strategy**
3131
3. **Stakers deposit** during the open window, subject to per-deposit and total caps
3232
4. **Admin locks the vault** — deposits/withdrawal queuing blocked; full magnitude allocated to operator set
3333
5. **AVS submits rewards** — stakers can claim rewards via normal EigenLayer reward flow
34-
6. **Duration elapses** — anyone calls `markMatured()` to deallocate and enable withdrawals
34+
6. **Vault exits lock**:
35+
- **Normal exit**: duration elapses and anyone calls `markMatured()` to deallocate and enable withdrawals
36+
- **Early exit**: arbitrator calls `advanceToWithdrawals()` after lock but before duration elapses
3537
7. **Stakers withdraw** — receive principal minus any slashing that occurred
3638

3739
> **Note**: Duration vaults are **single-use**. Once matured, a vault cannot be re-locked. Deploy a new vault for new duration commitments.
@@ -46,7 +48,7 @@ The `DurationVaultStrategy` is a **time-bound, single-use EigenLayer strategy**
4648
| [Configuration](#configuration) | [`updateTVLLimits`](#updatetvllimits), [`setTVLLimits`](#settvllimits), [`updateMetadataURI`](#updatemetadatauri) |
4749
| [State: DEPOSITS](#state-deposits) | [`beforeAddShares`](#beforeaddshares), [`beforeRemoveShares`](#beforeremoveshares) |
4850
| [State: ALLOCATIONS](#state-allocations) | [`lock`](#lock) |
49-
| [State: WITHDRAWALS](#state-withdrawals) | [`markMatured`](#markmatured) |
51+
| [State: WITHDRAWALS](#state-withdrawals) | [`markMatured`](#markmatured), [`advanceToWithdrawals`](#advancetowithdrawals) |
5052

5153
---
5254

@@ -117,6 +119,7 @@ function deployDurationVaultStrategy(
117119
struct VaultConfig {
118120
IERC20 underlyingToken; // Token stakers deposit
119121
address vaultAdmin; // Address that can lock the vault
122+
address arbitrator; // Address that can advance to withdrawals early (after lock, pre-duration)
120123
uint32 duration; // Lock duration in seconds
121124
uint256 maxPerDeposit; // Max deposit per transaction
122125
uint256 stakeCap; // Max total deposits (TVL cap)
@@ -139,6 +142,7 @@ struct VaultConfig {
139142
* Pause status MUST NOT be set: `PAUSED_NEW_STRATEGIES`
140143
* Token MUST NOT be blacklisted
141144
* `vaultAdmin` MUST NOT be zero address
145+
* `arbitrator` MUST NOT be zero address
142146
* `duration` MUST be non-zero and <= `MAX_DURATION` (2 years for now)
143147
* `maxPerDeposit` MUST be <= `stakeCap`
144148
* `operatorSet.avs` MUST NOT be zero address
@@ -220,6 +224,7 @@ stateDiagram-v2
220224

221225
DEPOSITS --> ALLOCATIONS: lock()<br/>vaultAdmin only
222226
ALLOCATIONS --> WITHDRAWALS: markMatured()<br/>anyone, after duration
227+
ALLOCATIONS --> WITHDRAWALS: advanceToWithdrawals()<br/>arbitrator only, before duration
223228

224229
note right of DEPOSITS
225230
✓ Deposits
@@ -248,7 +253,7 @@ _* Slashable after allocation delay passes. ** Slashable until deallocation dela
248253
|-------|:--------:|:-----------------:|:---------:|---------|-----|
249254
| `DEPOSITS` | ✓ | ✓ | ✗ | — | — |
250255
| `ALLOCATIONS` | ✗ | ✗ | ✓* | `lock()` | `vaultAdmin` |
251-
| `WITHDRAWALS` | ✗ | ✓ | ✓** | `markMatured()` | Anyone |
256+
| `WITHDRAWALS` | ✗ | ✓ | ✓** | `markMatured()` / `advanceToWithdrawals()` | Anyone / `arbitrator` |
252257

253258
---
254259

@@ -354,6 +359,26 @@ Transitions the vault from `ALLOCATIONS` to `WITHDRAWALS`. Callable by anyone on
354359

355360
> **NOTE**: Even after `markMatured()`, the vault **remains slashable** for `DEALLOCATION_DELAY` blocks until the deallocation takes effect on the `AllocationManager`. This is standard EigenLayer behavior for any deallocation.
356361

362+
### `advanceToWithdrawals`
363+
364+
```js
365+
function advanceToWithdrawals() external
366+
```
367+
368+
Transitions the vault from `ALLOCATIONS` to `WITHDRAWALS` **early**, after lock but before `unlockAt`. This is intended for use cases where an external agreement is violated (e.g., premiums not paid) and the vault should allow stakers to exit before the duration elapses.
369+
370+
*Effects*:
371+
* Sets `maturedAt` to current timestamp
372+
* Transitions state to `WITHDRAWALS`
373+
* Attempts to deallocate magnitude to 0 (best-effort)
374+
* Attempts to deregister from operator set (best-effort)
375+
* Emits `VaultAdvancedToWithdrawals(arbitrator, maturedAt)` (and `VaultMatured(maturedAt)`)
376+
377+
*Requirements*:
378+
* Caller MUST be `arbitrator`
379+
* State MUST be `ALLOCATIONS` (i.e., vault must be locked)
380+
* `block.timestamp` MUST be < `unlockAt`
381+
357382
### Withdrawals
358383

359384
After maturity, stakers can queue and complete withdrawals through the standard EigenLayer flow via `DelegationManager`. The `beforeRemoveShares` hook allows withdrawal queuing when state is `WITHDRAWALS`.
@@ -374,6 +399,7 @@ Rewards follow the standard EigenLayer flow:
374399
|----------|---------|
375400
| `state()` | Current `VaultState` enum |
376401
| `vaultAdmin()` | Vault administrator address |
402+
| `arbitrator()` | Vault arbitrator address |
377403
| `duration()` | Configured lock duration in seconds |
378404
| `lockedAt()` | Timestamp when vault was locked (0 if not locked) |
379405
| `unlockTimestamp()` | Timestamp when vault matures (0 if not locked) |
@@ -412,9 +438,10 @@ Rewards follow the standard EigenLayer flow:
412438

413439
| Event | Description |
414440
|-------|-------------|
415-
| `VaultInitialized(vaultAdmin, underlyingToken, duration, maxPerDeposit, stakeCap, metadataURI)` | Vault initialized with configuration |
441+
| `VaultInitialized(vaultAdmin, arbitrator, underlyingToken, duration, maxPerDeposit, stakeCap, metadataURI)` | Vault initialized with configuration |
416442
| `VaultLocked(lockedAt, unlockAt)` | Vault transitioned to `ALLOCATIONS` |
417443
| `VaultMatured(maturedAt)` | Vault transitioned to `WITHDRAWALS` |
444+
| `VaultAdvancedToWithdrawals(arbitrator, maturedAt)` | Vault transitioned to `WITHDRAWALS` early by the arbitrator |
418445
| `MetadataURIUpdated(newMetadataURI)` | Metadata URI changed |
419446
| `MaxPerDepositUpdated(previousValue, newValue)` | Per-deposit cap changed |
420447
| `MaxTotalDepositsUpdated(previousValue, newValue)` | Total deposit cap changed |
@@ -426,13 +453,17 @@ Rewards follow the standard EigenLayer flow:
426453
| Error | When Thrown |
427454
|-------|-------------|
428455
| `InvalidVaultAdmin` | Zero-address vault admin in config |
456+
| `InvalidArbitrator` | Zero-address arbitrator in config |
429457
| `InvalidDuration` | Zero or excessive duration (> `MAX_DURATION`) in config |
430458
| `OnlyVaultAdmin` | Non-admin calls admin-only function |
459+
| `OnlyArbitrator` | Non-arbitrator calls arbitrator-only function |
431460
| `VaultAlreadyLocked` | Attempting to lock an already locked vault |
432461
| `DepositsLocked` | Deposit attempted after vault is locked |
433462
| `WithdrawalsLockedDuringAllocations` | Withdrawal queuing attempted during `ALLOCATIONS` state |
434463
| `MustBeDelegatedToVaultOperator` | Staker not delegated to vault before deposit |
435464
| `DurationNotElapsed` | `markMatured()` called before `unlockAt` timestamp |
465+
| `DurationAlreadyElapsed` | `advanceToWithdrawals()` called at/after `unlockAt` timestamp |
466+
| `VaultNotLocked` | `advanceToWithdrawals()` called before the vault is locked |
436467
| `OperatorIntegrationInvalid` | Invalid operator integration config (zero AVS address) |
437468
| `UnderlyingTokenBlacklisted` | Deposit attempted with blacklisted token |
438469
| `PendingAllocation` | `lock()` attempted with pending allocation modification |

src/contracts/interfaces/IDurationVaultStrategy.sol

Lines changed: 24 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;
@@ -76,13 +85,15 @@ interface IDurationVaultStrategyTypes {
7685
interface IDurationVaultStrategyEvents {
7786
/// @notice Emitted when a vault is initialized with its configuration.
7887
/// @param vaultAdmin The address of the vault administrator.
88+
/// @param arbitrator The address of the vault arbitrator.
7989
/// @param underlyingToken The ERC20 token used for deposits.
8090
/// @param duration The lock duration in seconds.
8191
/// @param maxPerDeposit Maximum deposit amount per transaction.
8292
/// @param stakeCap Maximum total deposits allowed.
8393
/// @param metadataURI URI pointing to vault metadata.
8494
event VaultInitialized(
8595
address indexed vaultAdmin,
96+
address indexed arbitrator,
8697
IERC20 indexed underlyingToken,
8798
uint32 duration,
8899
uint256 maxPerDeposit,
@@ -99,6 +110,11 @@ interface IDurationVaultStrategyEvents {
99110
/// @param maturedAt Timestamp when the vault matured.
100111
event VaultMatured(uint32 maturedAt);
101112

113+
/// @notice Emitted when the vault is advanced to WITHDRAWALS early by the arbitrator.
114+
/// @param arbitrator The arbitrator that performed the early advance.
115+
/// @param maturedAt Timestamp when the vault transitioned to WITHDRAWALS.
116+
event VaultAdvancedToWithdrawals(address indexed arbitrator, uint32 maturedAt);
117+
102118
/// @notice Emitted when the vault metadata URI is updated.
103119
/// @param newMetadataURI The new metadata URI.
104120
event MetadataURIUpdated(string newMetadataURI);
@@ -138,6 +154,11 @@ interface IDurationVaultStrategy is
138154
/// the duration has elapsed.
139155
function markMatured() external;
140156

157+
/// @notice Advances the vault to WITHDRAWALS early, after lock but before duration elapses.
158+
/// @dev Transitions state from ALLOCATIONS to WITHDRAWALS, and triggers the same best-effort operator cleanup
159+
/// as `markMatured()`. Only callable by the configured arbitrator.
160+
function advanceToWithdrawals() external;
161+
141162
/// @notice Updates the vault metadata URI.
142163
/// @param newMetadataURI The new metadata URI to set.
143164
/// @dev Only callable by the vault admin.
@@ -157,6 +178,9 @@ interface IDurationVaultStrategy is
157178
/// @notice Returns the vault administrator address.
158179
function vaultAdmin() external view returns (address);
159180

181+
/// @notice Returns the arbitrator address.
182+
function arbitrator() external view returns (address);
183+
160184
/// @notice Returns the configured lock duration in seconds.
161185
function duration() external view returns (uint32);
162186

src/contracts/strategies/DurationVaultStrategy.sol

Lines changed: 29 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

@@ -88,6 +96,7 @@ contract DurationVaultStrategy is DurationVaultStrategyStorage, StrategyBase {
8896

8997
emit VaultInitialized(
9098
vaultAdmin,
99+
arbitrator,
91100
config.underlyingToken,
92101
duration,
93102
config.maxPerDeposit,
@@ -127,6 +136,26 @@ contract DurationVaultStrategy is DurationVaultStrategyStorage, StrategyBase {
127136
_deregisterFromOperatorSet();
128137
}
129138

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

0 commit comments

Comments
 (0)