diff --git a/docs/core/DurationVaultStrategy.md b/docs/core/DurationVaultStrategy.md
index aca3b9d039..35d0f1b4ac 100644
--- a/docs/core/DurationVaultStrategy.md
+++ b/docs/core/DurationVaultStrategy.md
@@ -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.
@@ -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) |
---
@@ -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)
@@ -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
@@ -220,6 +224,7 @@ stateDiagram-v2
DEPOSITS --> ALLOCATIONS: lock()
vaultAdmin only
ALLOCATIONS --> WITHDRAWALS: markMatured()
anyone, after duration
+ ALLOCATIONS --> WITHDRAWALS: advanceToWithdrawals()
arbitrator only, before duration
note right of DEPOSITS
✓ Deposits
@@ -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` |
---
@@ -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`.
@@ -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) |
@@ -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 |
@@ -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 |
diff --git a/src/contracts/interfaces/IDurationVaultStrategy.sol b/src/contracts/interfaces/IDurationVaultStrategy.sol
index 3adc8ed043..af7debc073 100644
--- a/src/contracts/interfaces/IDurationVaultStrategy.sol
+++ b/src/contracts/interfaces/IDurationVaultStrategy.sol
@@ -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.
@@ -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.
@@ -61,6 +69,7 @@ interface IDurationVaultStrategyTypes {
struct VaultConfig {
IERC20 underlyingToken;
address vaultAdmin;
+ address arbitrator;
uint32 duration;
uint256 maxPerDeposit;
uint256 stakeCap;
@@ -76,6 +85,7 @@ 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.
@@ -83,6 +93,7 @@ interface IDurationVaultStrategyEvents {
/// @param metadataURI URI pointing to vault metadata.
event VaultInitialized(
address indexed vaultAdmin,
+ address indexed arbitrator,
IERC20 indexed underlyingToken,
uint32 duration,
uint256 maxPerDeposit,
@@ -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);
@@ -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.
@@ -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);
diff --git a/src/contracts/strategies/DurationVaultStrategy.sol b/src/contracts/strategies/DurationVaultStrategy.sol
index 708fa1b09a..2730c6c5ab 100644
--- a/src/contracts/strategies/DurationVaultStrategy.sol
+++ b/src/contracts/strategies/DurationVaultStrategy.sol
@@ -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.
@@ -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;
@@ -88,6 +96,7 @@ contract DurationVaultStrategy is DurationVaultStrategyStorage, StrategyBase {
emit VaultInitialized(
vaultAdmin,
+ arbitrator,
config.underlyingToken,
duration,
config.maxPerDeposit,
@@ -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
diff --git a/src/contracts/strategies/DurationVaultStrategyStorage.sol b/src/contracts/strategies/DurationVaultStrategyStorage.sol
index 41d7594c0d..fa08ce0f44 100644
--- a/src/contracts/strategies/DurationVaultStrategyStorage.sol
+++ b/src/contracts/strategies/DurationVaultStrategyStorage.sol
@@ -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;
@@ -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;
}
diff --git a/src/test/integration/tests/DurationVaultIntegration.t.sol b/src/test/integration/tests/DurationVaultIntegration.t.sol
index 59ecf711d3..e860005013 100644
--- a/src/test/integration/tests/DurationVaultIntegration.t.sol
+++ b/src/test/integration/tests/DurationVaultIntegration.t.sol
@@ -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());
@@ -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;
diff --git a/src/test/unit/DurationVaultStrategyUnit.t.sol b/src/test/unit/DurationVaultStrategyUnit.t.sol
index ccc6c245f6..acea433c48 100644
--- a/src/test/unit/DurationVaultStrategyUnit.t.sol
+++ b/src/test/unit/DurationVaultStrategyUnit.t.sol
@@ -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,
@@ -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 {
diff --git a/src/test/unit/StrategyFactoryUnit.t.sol b/src/test/unit/StrategyFactoryUnit.t.sol
index 973157c64d..73291277d2 100644
--- a/src/test/unit/StrategyFactoryUnit.t.sol
+++ b/src/test/unit/StrategyFactoryUnit.t.sol
@@ -151,6 +151,7 @@ contract StrategyFactoryUnitTests is EigenLayerUnitTestSetup {
IDurationVaultStrategyTypes.VaultConfig memory config = IDurationVaultStrategyTypes.VaultConfig({
underlyingToken: underlyingToken,
vaultAdmin: address(this),
+ arbitrator: address(this),
duration: uint32(30 days),
maxPerDeposit: 10 ether,
stakeCap: 100 ether,
@@ -176,6 +177,7 @@ contract StrategyFactoryUnitTests is EigenLayerUnitTestSetup {
IDurationVaultStrategyTypes.VaultConfig memory config = IDurationVaultStrategyTypes.VaultConfig({
underlyingToken: underlyingToken,
vaultAdmin: address(this),
+ arbitrator: address(this),
duration: uint32(7 days),
maxPerDeposit: 5 ether,
stakeCap: 50 ether,
diff --git a/src/test/unit/StrategyManagerDurationUnit.t.sol b/src/test/unit/StrategyManagerDurationUnit.t.sol
index 932e5f9cef..80b119559b 100644
--- a/src/test/unit/StrategyManagerDurationUnit.t.sol
+++ b/src/test/unit/StrategyManagerDurationUnit.t.sol
@@ -73,6 +73,7 @@ contract StrategyManagerDurationUnitTests is EigenLayerUnitTestSetup, IStrategyM
IDurationVaultStrategyTypes.VaultConfig memory cfg = IDurationVaultStrategyTypes.VaultConfig({
underlyingToken: IERC20(address(underlyingToken)),
vaultAdmin: address(this),
+ arbitrator: address(this),
duration: uint32(30 days),
maxPerDeposit: 1_000_000 ether,
stakeCap: 10_000_000 ether,