Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 35 additions & 4 deletions docs/core/DurationVaultStrategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ The `DurationVaultStrategy` is a **time-bound, single-use EigenLayer strategy**
3. **Stakers deposit** during the open window, subject to per-deposit and total caps
4. **Admin locks the vault** — deposits/withdrawal queuing blocked; full magnitude allocated to operator set
5. **AVS submits rewards** — stakers can claim rewards via normal EigenLayer reward flow
6. **Duration elapses** — anyone calls `markMatured()` to deallocate and enable withdrawals
6. **Vault exits lock**:
- **Normal exit**: duration elapses and anyone calls `markMatured()` to deallocate and enable withdrawals
- **Early exit**: arbitrator calls `advanceToWithdrawals()` after lock but before duration elapses
7. **Stakers withdraw** — receive principal minus any slashing that occurred

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

---

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

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

note right of DEPOSITS
✓ Deposits
Expand Down Expand Up @@ -248,7 +253,7 @@ _* Slashable after allocation delay passes. ** Slashable until deallocation dela
|-------|:--------:|:-----------------:|:---------:|---------|-----|
| `DEPOSITS` | ✓ | ✓ | ✗ | — | — |
| `ALLOCATIONS` | ✗ | ✗ | ✓* | `lock()` | `vaultAdmin` |
| `WITHDRAWALS` | ✗ | ✓ | ✓** | `markMatured()` | Anyone |
| `WITHDRAWALS` | ✗ | ✓ | ✓** | `markMatured()` / `advanceToWithdrawals()` | Anyone / `arbitrator` |

---

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

> **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.

### `advanceToWithdrawals`

```js
function advanceToWithdrawals() external
```

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.

*Effects*:
* Sets `maturedAt` to current timestamp
* Transitions state to `WITHDRAWALS`
* Attempts to deallocate magnitude to 0 (best-effort)
* Attempts to deregister from operator set (best-effort)
* Emits `VaultAdvancedToWithdrawals(arbitrator, maturedAt)` (and `VaultMatured(maturedAt)`)

*Requirements*:
* Caller MUST be `arbitrator`
* State MUST be `ALLOCATIONS` (i.e., vault must be locked)
* `block.timestamp` MUST be < `unlockAt`

### Withdrawals

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`.
Expand All @@ -374,6 +399,7 @@ Rewards follow the standard EigenLayer flow:
|----------|---------|
| `state()` | Current `VaultState` enum |
| `vaultAdmin()` | Vault administrator address |
| `arbitrator()` | Vault arbitrator address |
| `duration()` | Configured lock duration in seconds |
| `lockedAt()` | Timestamp when vault was locked (0 if not locked) |
| `unlockTimestamp()` | Timestamp when vault matures (0 if not locked) |
Expand Down Expand Up @@ -412,9 +438,10 @@ Rewards follow the standard EigenLayer flow:

| Event | Description |
|-------|-------------|
| `VaultInitialized(vaultAdmin, underlyingToken, duration, maxPerDeposit, stakeCap, metadataURI)` | Vault initialized with configuration |
| `VaultInitialized(vaultAdmin, arbitrator, underlyingToken, duration, maxPerDeposit, stakeCap, metadataURI)` | Vault initialized with configuration |
| `VaultLocked(lockedAt, unlockAt)` | Vault transitioned to `ALLOCATIONS` |
| `VaultMatured(maturedAt)` | Vault transitioned to `WITHDRAWALS` |
| `VaultAdvancedToWithdrawals(arbitrator, maturedAt)` | Vault transitioned to `WITHDRAWALS` early by the arbitrator |
| `MetadataURIUpdated(newMetadataURI)` | Metadata URI changed |
| `MaxPerDepositUpdated(previousValue, newValue)` | Per-deposit cap changed |
| `MaxTotalDepositsUpdated(previousValue, newValue)` | Total deposit cap changed |
Expand All @@ -426,13 +453,17 @@ Rewards follow the standard EigenLayer flow:
| Error | When Thrown |
|-------|-------------|
| `InvalidVaultAdmin` | Zero-address vault admin in config |
| `InvalidArbitrator` | Zero-address arbitrator in config |
| `InvalidDuration` | Zero or excessive duration (> `MAX_DURATION`) in config |
| `OnlyVaultAdmin` | Non-admin calls admin-only function |
| `OnlyArbitrator` | Non-arbitrator calls arbitrator-only function |
| `VaultAlreadyLocked` | Attempting to lock an already locked vault |
| `DepositsLocked` | Deposit attempted after vault is locked |
| `WithdrawalsLockedDuringAllocations` | Withdrawal queuing attempted during `ALLOCATIONS` state |
| `MustBeDelegatedToVaultOperator` | Staker not delegated to vault before deposit |
| `DurationNotElapsed` | `markMatured()` called before `unlockAt` timestamp |
| `DurationAlreadyElapsed` | `advanceToWithdrawals()` called at/after `unlockAt` timestamp |
| `VaultNotLocked` | `advanceToWithdrawals()` called before the vault is locked |
| `OperatorIntegrationInvalid` | Invalid operator integration config (zero AVS address) |
| `UnderlyingTokenBlacklisted` | Deposit attempted with blacklisted token |
| `PendingAllocation` | `lock()` attempted with pending allocation modification |
Expand Down
24 changes: 24 additions & 0 deletions src/contracts/interfaces/IDurationVaultStrategy.sol
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ interface IDurationVaultStrategyErrors {
error InvalidDuration();
/// @dev Thrown when attempting to mutate configuration from a non-admin.
error OnlyVaultAdmin();
/// @dev Thrown when attempting to call arbitrator-only functionality from a non-arbitrator.
error OnlyArbitrator();
/// @dev Thrown when attempting to configure a zero-address arbitrator.
error InvalidArbitrator();
/// @dev Thrown when attempting to lock an already locked vault.
error VaultAlreadyLocked();
/// @dev Thrown when attempting to deposit after the vault has been locked.
Expand All @@ -27,6 +31,10 @@ interface IDurationVaultStrategyErrors {
error MustBeDelegatedToVaultOperator();
/// @dev Thrown when attempting to mark the vault as matured before duration elapses.
error DurationNotElapsed();
/// @dev Thrown when attempting to use the arbitrator early-advance after the duration has elapsed.
error DurationAlreadyElapsed();
/// @dev Thrown when attempting to use the arbitrator early-advance before the vault is locked.
error VaultNotLocked();
/// @dev Thrown when operator integration inputs are missing or invalid.
error OperatorIntegrationInvalid();
/// @dev Thrown when attempting to deposit into a vault whose underlying token is blacklisted.
Expand Down Expand Up @@ -61,6 +69,7 @@ interface IDurationVaultStrategyTypes {
struct VaultConfig {
IERC20 underlyingToken;
address vaultAdmin;
address arbitrator;
uint32 duration;
uint256 maxPerDeposit;
uint256 stakeCap;
Expand All @@ -76,13 +85,15 @@ interface IDurationVaultStrategyTypes {
interface IDurationVaultStrategyEvents {
/// @notice Emitted when a vault is initialized with its configuration.
/// @param vaultAdmin The address of the vault administrator.
/// @param arbitrator The address of the vault arbitrator.
/// @param underlyingToken The ERC20 token used for deposits.
/// @param duration The lock duration in seconds.
/// @param maxPerDeposit Maximum deposit amount per transaction.
/// @param stakeCap Maximum total deposits allowed.
/// @param metadataURI URI pointing to vault metadata.
event VaultInitialized(
address indexed vaultAdmin,
address indexed arbitrator,
IERC20 indexed underlyingToken,
uint32 duration,
uint256 maxPerDeposit,
Expand All @@ -99,6 +110,11 @@ interface IDurationVaultStrategyEvents {
/// @param maturedAt Timestamp when the vault matured.
event VaultMatured(uint32 maturedAt);

/// @notice Emitted when the vault is advanced to WITHDRAWALS early by the arbitrator.
/// @param arbitrator The arbitrator that performed the early advance.
/// @param maturedAt Timestamp when the vault transitioned to WITHDRAWALS.
event VaultAdvancedToWithdrawals(address indexed arbitrator, uint32 maturedAt);

/// @notice Emitted when the vault metadata URI is updated.
/// @param newMetadataURI The new metadata URI.
event MetadataURIUpdated(string newMetadataURI);
Expand Down Expand Up @@ -138,6 +154,11 @@ interface IDurationVaultStrategy is
/// the duration has elapsed.
function markMatured() external;

/// @notice Advances the vault to WITHDRAWALS early, after lock but before duration elapses.
/// @dev Transitions state from ALLOCATIONS to WITHDRAWALS, and triggers the same best-effort operator cleanup
/// as `markMatured()`. Only callable by the configured arbitrator.
function advanceToWithdrawals() external;

/// @notice Updates the vault metadata URI.
/// @param newMetadataURI The new metadata URI to set.
/// @dev Only callable by the vault admin.
Expand All @@ -157,6 +178,9 @@ interface IDurationVaultStrategy is
/// @notice Returns the vault administrator address.
function vaultAdmin() external view returns (address);

/// @notice Returns the arbitrator address.
function arbitrator() external view returns (address);

/// @notice Returns the configured lock duration in seconds.
function duration() external view returns (uint32);

Expand Down
29 changes: 29 additions & 0 deletions src/contracts/strategies/DurationVaultStrategy.sol
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ contract DurationVaultStrategy is DurationVaultStrategyStorage, StrategyBase {
_;
}

/// @dev Restricts function access to the vault arbitrator.
modifier onlyArbitrator() {
require(msg.sender == arbitrator, OnlyArbitrator());
_;
}

/// @param _strategyManager The StrategyManager contract.
/// @param _pauserRegistry The PauserRegistry contract.
/// @param _delegationManager The DelegationManager contract for operator registration.
Expand Down Expand Up @@ -75,11 +81,13 @@ contract DurationVaultStrategy is DurationVaultStrategyStorage, StrategyBase {
VaultConfig memory config
) public initializer {
require(config.vaultAdmin != address(0), InvalidVaultAdmin());
require(config.arbitrator != address(0), InvalidArbitrator());
require(config.duration != 0 && config.duration <= MAX_DURATION, InvalidDuration());
_setTVLLimits(config.maxPerDeposit, config.stakeCap);
_initializeStrategyBase(config.underlyingToken);

vaultAdmin = config.vaultAdmin;
arbitrator = config.arbitrator;
duration = config.duration;
metadataURI = config.metadataURI;

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

emit VaultInitialized(
vaultAdmin,
arbitrator,
config.underlyingToken,
duration,
config.maxPerDeposit,
Expand Down Expand Up @@ -127,6 +136,26 @@ contract DurationVaultStrategy is DurationVaultStrategyStorage, StrategyBase {
_deregisterFromOperatorSet();
}

/// @notice Advances the vault to withdrawals early, after lock but before duration elapses.
/// @dev Only callable by the configured arbitrator.
function advanceToWithdrawals() external override onlyArbitrator {
if (_state == VaultState.WITHDRAWALS) {
// already recorded; noop
return;
}
require(_state == VaultState.ALLOCATIONS, VaultNotLocked());
require(block.timestamp < unlockAt, DurationAlreadyElapsed());

_state = VaultState.WITHDRAWALS;
maturedAt = uint32(block.timestamp);

emit VaultMatured(maturedAt);
emit VaultAdvancedToWithdrawals(msg.sender, maturedAt);

_deallocateAll();
_deregisterFromOperatorSet();
}

/// @notice Updates the metadata URI describing the vault.
function updateMetadataURI(
string calldata newMetadataURI
Expand Down
9 changes: 6 additions & 3 deletions src/contracts/strategies/DurationVaultStrategyStorage.sol
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ abstract contract DurationVaultStrategyStorage is IDurationVaultStrategy {
/// @notice Address empowered to configure and lock the vault.
address public vaultAdmin;

/// @notice Address empowered to advance the vault to withdrawals early (after lock, before duration elapses).
address public arbitrator;

/// @notice The enforced lock duration once `lock` is called.
uint32 public duration;

Expand Down Expand Up @@ -46,8 +49,8 @@ abstract contract DurationVaultStrategyStorage is IDurationVaultStrategy {

/// @dev This empty reserved space is put in place to allow future versions to add new
/// variables without shifting down storage in the inheritance chain.
/// Storage slots used: vaultAdmin (1) + duration/lockedAt/unlockAt/maturedAt/_state (packed, 1) +
/// Storage slots used: vaultAdmin (1) + arbitrator (1) + duration/lockedAt/unlockAt/maturedAt/_state (packed, 1) +
/// metadataURI (1) + _operatorSet (1) + maxPerDeposit (1) + maxTotalDeposits (1) = 6.
/// Gap: 50 - 6 = 44.
uint256[44] private __gap;
/// Gap: 50 - 7 = 43.
uint256[43] private __gap;
}
45 changes: 45 additions & 0 deletions src/test/integration/tests/DurationVaultIntegration.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,50 @@ contract Integration_DurationVault is IntegrationCheckUtils {
assertEq(ctx.asset.balanceOf(address(staker)), depositAmount, "staker should recover deposit");
}

function test_durationVault_arbitrator_can_advance_to_withdrawals_early() public {
DurationVaultContext memory ctx = _deployDurationVault(_randomInsuranceRecipient());
User staker = new User("duration-arbitrator-staker");

uint depositAmount = 50 ether;
ctx.asset.transfer(address(staker), depositAmount);
IStrategy[] memory strategies = _durationStrategyArray(ctx.vault);
uint[] memory tokenBalances = _singleAmountArray(depositAmount);
_delegateToVault(staker, ctx.vault);
staker.depositIntoEigenlayer(strategies, tokenBalances);

// Arbitrator cannot advance before lock.
cheats.expectRevert(IDurationVaultStrategyErrors.VaultNotLocked.selector);
ctx.vault.advanceToWithdrawals();

ctx.vault.lock();
assertTrue(ctx.vault.allocationsActive(), "allocations should be active after lock");
assertFalse(ctx.vault.withdrawalsOpen(), "withdrawals should be closed while locked");

// Non-arbitrator cannot advance.
cheats.prank(address(0x1234));
cheats.expectRevert(IDurationVaultStrategyErrors.OnlyArbitrator.selector);
ctx.vault.advanceToWithdrawals();

// Arbitrator can advance after lock but before duration elapses.
cheats.warp(block.timestamp + 1);
ctx.vault.advanceToWithdrawals();

assertTrue(ctx.vault.withdrawalsOpen(), "withdrawals should open after arbitrator advance");
assertFalse(ctx.vault.allocationsActive(), "allocations should be inactive after arbitrator advance");
assertTrue(ctx.vault.isMatured(), "vault should be matured after arbitrator advance");

// Withdrawals should actually be possible in this early-advance path.
uint[] memory withdrawableShares = _getStakerWithdrawableShares(staker, strategies);
Withdrawal[] memory withdrawals = staker.queueWithdrawals(strategies, withdrawableShares);
_rollBlocksForCompleteWithdrawals(withdrawals);
IERC20[] memory tokens = staker.completeWithdrawalAsTokens(withdrawals[0]);
assertEq(address(tokens[0]), address(ctx.asset), "unexpected token returned");
assertEq(ctx.asset.balanceOf(address(staker)), depositAmount, "staker should recover deposit after arbitrator advance");

// markMatured should be a noop after the state has already transitioned.
ctx.vault.markMatured();
}

function test_durationVault_operatorIntegrationAndMetadataUpdate() public {
DurationVaultContext memory ctx = _deployDurationVault(_randomInsuranceRecipient());

Expand Down Expand Up @@ -396,6 +440,7 @@ contract Integration_DurationVault is IntegrationCheckUtils {
IDurationVaultStrategyTypes.VaultConfig memory config;
config.underlyingToken = asset;
config.vaultAdmin = address(this);
config.arbitrator = address(this);
config.duration = DEFAULT_DURATION;
config.maxPerDeposit = VAULT_MAX_PER_DEPOSIT;
config.stakeCap = VAULT_STAKE_CAP;
Expand Down
23 changes: 23 additions & 0 deletions src/test/unit/DurationVaultStrategyUnit.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ contract DurationVaultStrategyUnitTests is StrategyBaseUnitTests {
IDurationVaultStrategyTypes.VaultConfig memory config = IDurationVaultStrategyTypes.VaultConfig({
underlyingToken: underlyingToken,
vaultAdmin: address(this),
arbitrator: address(this),
duration: defaultDuration,
maxPerDeposit: maxPerDeposit,
stakeCap: maxTotalDeposits,
Expand Down Expand Up @@ -168,6 +169,28 @@ contract DurationVaultStrategyUnitTests is StrategyBaseUnitTests {
assertEq(allocationManagerMock.deregisterFromOperatorSetsCallCount(), 0, "unexpected deregister count");
}

function testAdvanceToWithdrawals_onlyArbitrator_and_onlyBeforeUnlock() public {
// Cannot advance before lock (even as arbitrator).
cheats.expectRevert(IDurationVaultStrategyErrors.VaultNotLocked.selector);
durationVault.advanceToWithdrawals();

durationVault.lock();

// Non-arbitrator cannot advance.
cheats.prank(address(0xBEEF));
cheats.expectRevert(IDurationVaultStrategyErrors.OnlyArbitrator.selector);
durationVault.advanceToWithdrawals();

// After unlockAt, arbitrator advance is not allowed.
cheats.warp(block.timestamp + defaultDuration + 1);
cheats.expectRevert(IDurationVaultStrategyErrors.DurationAlreadyElapsed.selector);
durationVault.advanceToWithdrawals();

// markMatured works once duration has elapsed.
durationVault.markMatured();
assertTrue(durationVault.withdrawalsOpen(), "withdrawals should open after maturity");
}

// ===================== VAULT STATE TESTS =====================

function testDepositsBlockedAfterLock() public {
Expand Down
Loading