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
38 changes: 21 additions & 17 deletions docs/access-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ All three named principals are set during deployment:
| `vetoer` | Owner | `setVetoer(addr)` | Immediate setter; no timelock |
| `guardian` | Owner via batch | `setEcosystem(cfg)` or `setHealthRegistry`-area setters | `cfg.guardian` required non-zero in `setEcosystem` |

The `guardian` field in `CoreStorage.Layout` is set either directly (owner-only setter) or via `setEcosystem`. It can be updated by the owner at any time post-deploy (no timelock).
The `guardian` field in `CoreStorage.Layout` is set either directly (owner-only setter) or via `setEcosystem`. It can be updated by the owner at any time pre-seal (no timelock) — but `setGuardian()` reverts with `SystemSealed()` once `FLAG_SYSTEM_SEALED` is set, same as `setVetoer()`. Post-seal, neither principal is rotatable at all.

### 3.2 How to Change Roles

Expand All @@ -109,7 +109,7 @@ The `guardian` field in `CoreStorage.Layout` is set either directly (owner-only
| `vetoer` | `setVetoer(addr)` (owner-only, immediate) | None | `NotOwner()` if non-owner |
| `guardian` | Direct setter or `setEcosystem` (owner-only, immediate) | None | `NotOwner()` if non-owner |

No mechanism exists to "lock" a principal address permanently (except `sealBySealer` which locks `bufferManager`/`router` only). The owner retains ability to rotate vetoer and guardian indefinitely.
Both `setVetoer()` (`AdminModule.sol`) and `setGuardian()` (`CoreVault.sol`) revert with `SystemSealed()` once the vault is sealed — sealing does lock these principals permanently, alongside `bufferManager`/`router`. Pre-seal, the owner can rotate vetoer and guardian at will.

### 3.3 `roleOf[selector]` Management

Expand Down Expand Up @@ -168,14 +168,9 @@ No mechanism exists to "lock" a principal address permanently (except `sealBySea

| Function | Module | Description |
|----------|--------|-------------|
| `pause` | AdminModule | Set FLAG_PAUSED |
| `unpause` | AdminModule | Clear FLAG_PAUSED |
| `pauseDeposits` | AdminModule | Set FLAG_PAUSED_DEPOSITS |
| `unpauseDeposits` | AdminModule | Clear FLAG_PAUSED_DEPOSITS |
| `pauseWithdrawals` | AdminModule | Set FLAG_PAUSED_WITHDRAWALS |
| `deployToStrategiesWithPlan` | LiquidityOpsModule | Deploy with a caller-supplied allocation plan. Was `ROLE_PUBLIC`; moved to OWNER_OR_GUARDIAN because a public caller could otherwise steer which registered strategies receive capital and in what proportion. |

> Note: `unpauseWithdrawals` is OWNER-only (roleOf = 1). The guardian can pause withdrawals but not unpause them.
> **Correction**: pause functions are NOT `moduleOf`/`roleOf`-routed and do not belong in this table — they are direct functions on `CoreVault` itself (like `setModule`/`freezeRouting`), gated by CoreVault's own `onlyOwner`/`onlyGuardian`/`onlyOwnerOrGuardian` modifiers, not the fallback dispatcher's role system. The full, current pause/breaker table (`pauseAll`, `pauseDepositsOnly`, `pauseWithdrawalsOnly`, `pauseInstantWithdrawalOnly`, `pauseEpochCloseFundOnly`, `pauseQueuedRequestOnly`, `pauseFundedClaimOnly`, `pauseForceExitOnly`, `guardianPause`, `unpauseAll`) with per-function access levels is authoritative in [architecture.md §11.3](architecture.md#113-pause-granularity) and [governance.md §4.2](governance.md#42-pause-functions) — not duplicated here to avoid a third copy drifting out of sync.

### 4.4 PUBLIC Functions (roleOf = 0)

Expand All @@ -184,13 +179,13 @@ Key permissionless functions:
| Function | Module | Notes |
|----------|--------|-------|
| `deposit` | ERC4626Module | Subject to pause checks |
| `requestEpochWithdrawal` | EpochedQueueModule | Subject to pause + `minClaimAmount` floor |
| `requestInstantWithdrawal` | EpochedQueueModule | Same floor; falls back to the queue when the cap or free liquidity blocks it |
| `cancelEpochWithdrawal` | EpochedQueueModule | Claim owner only, and only while the epoch is `Open` |
| `closeCurrentEpoch` | EpochedQueueModule | Callable by anyone once the epoch duration has elapsed (keeper pattern) |
| `fundEpoch` | EpochedQueueModule | Callable by anyone; no-op when the epoch is already funded |
| `claimEpochAssets` / `batchClaimEpochAssets` | EpochedQueueModule | Claim owner only, self-service, no keeper required |
| `syncOldestUnfundedEpoch` | EpochedQueueModule | Cursor maintenance, always callable |
| `requestEpochWithdrawal` | EpochedQueueModule | Subject to `FLAG_QUEUED_REQUEST_PAUSED` (new-request breaker, owner-only) + `minClaimAmount` floor |
| `requestInstantWithdrawal` | EpochedQueueModule | Same floor; falls back to the queue (subject to the same `FLAG_QUEUED_REQUEST_PAUSED` breaker) when the cap, free liquidity, or `FLAG_INSTANT_WITHDRAWAL_PAUSED` blocks instant settlement — never reverts outright for a paused instant breaker alone |
| `cancelEpochWithdrawal` | EpochedQueueModule | Claim owner only, and only while the epoch is `Open` — never pause-gated, by design (review §20) |
| `closeCurrentEpoch` | EpochedQueueModule | Callable by anyone once the epoch duration has elapsed (keeper pattern); subject to `FLAG_EPOCH_CLOSE_FUND_PAUSED` |
| `fundEpoch` | EpochedQueueModule | Callable by anyone; no-op when the epoch is already funded; subject to `FLAG_EPOCH_CLOSE_FUND_PAUSED` |
| `claimEpochAssets` / `batchClaimEpochAssets` | EpochedQueueModule | Claim owner only, self-service, no keeper required; subject to `FLAG_FUNDED_CLAIM_PAUSED` (owner-only breaker, never Guardian — review §20) |
| `syncOldestUnfundedEpoch` | EpochedQueueModule | Cursor maintenance; subject to `FLAG_EPOCH_CLOSE_FUND_PAUSED` |
| `acceptOwnership` | AdminModule | Must be `pendingOwner` (checked internally) |
| `markMatured` | FixedMaturityModule | Any address, once maturityTs reached |
| `markFundingFailed` | FixedMaturityModule | Any address, once deadline + net < min |
Expand Down Expand Up @@ -333,12 +328,21 @@ Risk: if newFixedMaturityModule has a reentrancy bug or is malicious,
```
Scenario: Current guardian key suspected compromised.

PRE-SEAL ONLY — both setters revert with SystemSealed() once sealed:

owner calls setVetoer(0xNewSecure) ← same call for vetoer rotation
owner calls (guardian setter)(0xNewGuardian) ← replace guardian
owner calls setGuardian(0xNewGuardian) ← replace guardian

Both changes are immediate (no timelock).
Both changes are immediate (no timelock) while unsealed.
Old guardian key immediately loses all pause authority.
No delay — enables fast rotation in incident response.

POST-SEAL: rotation is permanently unavailable. A compromised guardian
post-seal can still be neutralized operationally (it can only ever trip
pauseInstantWithdrawalOnly/pauseEpochCloseFundOnly/guardianPause — see
§9.4/architecture.md §11.3 — and cannot clear its own breakers), but the
key itself cannot be replaced. This is the tradeoff sealing makes deliberately:
see docs/architecture.md §10 for the immutability rationale.
```

### 9.4 Per-Function Role Inspection at Deploy
Expand Down
59 changes: 41 additions & 18 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,17 +117,20 @@ The following functions are NOT routed via the module dispatch — they are impl
| `maxRedeem(address)` | Always returns 0 (queued protocol) |
| `previewDeposit(uint256)` | Fee-aware shares preview |
| `previewMint(uint256)` | Fee-aware assets-in preview |
| `owner()`, `guardian()` | Role reads |
| `paused()`, `pausedDeposits()`, `pausedWithdrawals()` | State reads |
| `owner()`, `guardian()`, `recoveryGate()` | Role reads |
| `paused()`, `pausedDeposits()`, `pausedWithdrawals()`, `pausedInstantWithdrawal()`, `pausedQueuedRequest()`, `pausedEpochCloseFund()`, `pausedFundedClaim()`, `pausedForceExit()` | State reads — see §11.3 |
| `moduleOf(bytes4)`, `roleOf(bytes4)` | Routing table reads |
| `setModule()`, `setModulesBatch()` | Routing table writes (onlyOwner) |
| `setModule()`, `setModulesBatch()` | Routing table writes (onlyOwner, blocked once `FLAG_ROUTING_FROZEN` is set) |
| `freezeRouting()` | Freeze routing table (irreversible) |
| `setSelectorRegistry()` | One-shot registry binding |
| `pauseAll()`, `unpauseAll()`, `guardianPause()` | Pause control |
| `setSelectorRegistry()` | One-shot registry binding (onlyOwner, blocked post-seal) |
| `setGuardian()` | Update guardian address (onlyOwner, blocked post-seal — matches `AdminModule.setVetoer()`'s existing `_requireNotSealed()` guard; **not** the `AdminModule.setEcosystem()`-era table entry that used to appear under §4 AdminModule in modules.md — this is a direct CoreVault function) |
| `setRecoveryGate()` | One-shot Emergency Module Recovery gate binding (onlyOwner, blocked post-seal — see [recovery.md](recovery.md)) |
| `recoverModuleGroup()` | Emergency Module Recovery entry point (onlyRecoveryGate only — see [recovery.md](recovery.md)) |
| `pauseAll()`, `unpauseAll()`, `pauseDepositsOnly()`, `pauseWithdrawalsOnly()`, `pauseInstantWithdrawalOnly()`, `pauseEpochCloseFundOnly()`, `pauseQueuedRequestOnly()`, `pauseFundedClaimOnly()`, `pauseForceExitOnly()`, `guardianPause()` | Pause control — full breaker table in §11.3 |
| `beginOwnerTransfer()`, `acceptOwnerTransfer()` | Ownership transfer |
| `processorMint()`, `processorBurn()`, `processorTransfer()`, `processorSpendAllowance()` | Module callbacks for share accounting |
| `authorizeModule()`, `isModuleAuthorized()` | Module authorization for processor functions |
| `setAuthorizedSealer()`, `sealBySealer()` | System sealing |
| `authorizeModule()`, `isModuleAuthorized()` | Module authorization for processor functions (onlyOwner, blocked post-seal) |
| `setAuthorizedSealer()`, `sealBySealer()` | System sealing (onlyOwner, blocked post-seal) |
| `payRewardShares()` | Dedicated reward payout (via RewardsPayoutManager) — transfers from a pre-funded rewards treasury, never mints; non-dilutive by construction |

---
Expand Down Expand Up @@ -243,8 +246,15 @@ Each library exposes a `layout()` function that returns a storage pointer at the
| 10 | `FLAG_DEAD_DEPOSIT_DONE` | Dead deposit seeded (inflation attack hardening) |
| 11 | `FLAG_FEES_INITIALIZED` | Fees initialized via `setInitialFees()` |
| 12 | `FLAG_PERF_INITIALIZED` | Performance fee initialized via `setInitialPerfParams()` |
| 13 | `FLAG_INSTANT_WITHDRAWAL_PAUSED` | Instant settlement only paused (queued exits unaffected) |
| 14 | `FLAG_QUEUED_REQUEST_PAUSED` | New queued-exit requests only paused (cancelling existing ones unaffected) |
| 15 | `FLAG_EPOCH_CLOSE_FUND_PAUSED` | Epoch close/fund/crystallize only paused |
| 16 | `FLAG_FUNDED_CLAIM_PAUSED` | Funded-claim settlement only paused |
| 17 | `FLAG_FORCE_EXIT_PAUSED` | Force exit only paused — the only flag that can gate it; see §4.3 |

Source: `src/core/storage/CoreStorage.sol:24-36`.
Source: `src/core/storage/CoreStorage.sol:24-41`.

Bits 13-17 (review §20/§21, "Recommended Withdrawal Circuit Breakers" / "Required Pause Matrix") replace the previous all-or-nothing behavior of `FLAG_PAUSED_WITHDRAWALS` on `EpochedQueueModule` — before their introduction, that flag was not read anywhere in `EpochedQueueModule.sol` at all, so `guardianPause()`/`pauseWithdrawalsOnly()` had zero effect on the queue. See §11.3.

---

Expand Down Expand Up @@ -635,22 +645,34 @@ Source: `src/core/CoreVault.sol:394-407`.

### 11.2 Guardian

The guardian is a separate address with limited pause capability. The guardian can pause the vault via `guardianPause()`, subject to a cooldown enforced by `IParamsProvider.guardianPauseCooldown()`. Default cooldown: 7 days (`src/core/CoreVault.sol:459-468`).
The guardian is a separate address with limited pause capability, matching review §3.3 ("fast to restrict, slow to restore"): guardians cannot unpause, modify routing, or reach the exceptional owner-only breakers (§11.3) — these require owner.

`guardianPause()` is the guardian's single rapid action, subject to a cooldown enforced by `IParamsProvider.guardianPauseCooldown()` (default 7 days). It sets three flags together: `FLAG_PAUSED` (deposits), `FLAG_INSTANT_WITHDRAWAL_PAUSED`, and `FLAG_EPOCH_CLOSE_FUND_PAUSED` — the two withdrawal breakers review §20 approves for Guardian use. It deliberately does **not** reach queued-request creation, funded claims, or force exit (`src/core/CoreVault.sol:453-469`).

Guardians cannot unpause or modify routing — these require owner.
The guardian may also trip `pauseInstantWithdrawalOnly()` and `pauseEpochCloseFundOnly()` individually (both `onlyOwnerOrGuardian`), for a narrower response than the combined `guardianPause()`.

### 11.3 Pause Granularity

Three pause levels are available (all set by owner, all stored in `packedFlags`):
`pauseAll()`/`unpauseAll()`/`pauseDepositsOnly()`/`pauseWithdrawalsOnly()` remain owner-only. Five additional breakers (review §20/§21) give targeted, owner- or guardian-scoped control over the withdrawal surface instead of one all-or-nothing flag:

| Function | Pauses | Flag |
|---|---|---|
| `pauseAll()` | All operations | `FLAG_PAUSED` |
| `pauseDepositsOnly(true)` | Deposits only | `FLAG_PAUSED_DEPOSITS` |
| `pauseWithdrawalsOnly(true)` | Withdrawals only | `FLAG_PAUSED_WITHDRAWALS` |
| `guardianPause()` | All (emergency) | `FLAG_PAUSED` |
| Function | Pauses | Flag | Who |
|---|---|---|---|
| `pauseAll()` | Deposits (only — see §11.2) | `FLAG_PAUSED` | Owner |
| `pauseDepositsOnly(true)` | Deposits only | `FLAG_PAUSED_DEPOSITS` | Owner |
| `pauseWithdrawalsOnly(true)` | Instant settlement + epoch close/fund (aggregate) | `FLAG_PAUSED_WITHDRAWALS` | Owner |
| `pauseInstantWithdrawalOnly(true)` | Instant settlement only (falls back to queue, does not revert) | `FLAG_INSTANT_WITHDRAWAL_PAUSED` | Owner or Guardian |
| `pauseEpochCloseFundOnly(true)` | `closeCurrentEpoch`/`fundEpoch`/`endEpochCrystallize`/`syncOldestUnfundedEpoch` | `FLAG_EPOCH_CLOSE_FUND_PAUSED` | Owner or Guardian |
| `pauseQueuedRequestOnly(true)` | NEW queued-exit requests only (cancelling an existing one is unaffected) | `FLAG_QUEUED_REQUEST_PAUSED` | Owner only, exceptional |
| `pauseFundedClaimOnly(true)` | `claimEpochAssets`/`batchClaimEpochAssets` only | `FLAG_FUNDED_CLAIM_PAUSED` | Owner only, exceptional |
| `pauseForceExitOnly(true)` | `forceWithdraw`/`forceWithdrawAll` only | `FLAG_FORCE_EXIT_PAUSED` | Owner only, dedicated |
| `guardianPause()` | Deposits + instant settlement + epoch close/fund | `FLAG_PAUSED` + `FLAG_INSTANT_WITHDRAWAL_PAUSED` + `FLAG_EPOCH_CLOSE_FUND_PAUSED` | Guardian |

Two rules follow directly from review §20 and are enforced structurally, not just by convention:

- **Force exit is read by exactly one flag, `FLAG_FORCE_EXIT_PAUSED`.** It ignores `FLAG_PAUSED`, `FLAG_PAUSED_WITHDRAWALS`, and every other breaker — `pauseAll()`/`guardianPause()`/`pauseWithdrawalsOnly()` can never block it as a side effect.
- **Queued-request creation and funded claims ignore `FLAG_PAUSED_WITHDRAWALS`.** Exit intent must stay recordable while settlement is paused (review §19), and funded claims must never be blockable by any general administrative flag (review §20) — only their own dedicated, owner-only breaker reaches them.

`unpauseAll()` clears all three flags atomically. Source: `src/core/CoreVault.sol:425-454`.
`unpauseAll()` clears all eight flags atomically. Source: `src/core/CoreVault.sol:410-540`, `src/core/modules/EpochedQueueModule.sol` (queue-side checks), `src/core/modules/ERC4626Module.sol` (force-exit check). Full per-breaker test coverage: `test/invariants/Withdrawal_PauseMatrix_Invariants.t.sol`.

---

Expand Down Expand Up @@ -839,6 +861,7 @@ In `FixedMaturity/Active` state, `markMatured()` is callable by anyone once `blo
- Exit engine + fee policy: cluster 01a.2 (pending)
- Queue mechanics: cluster 01a.2 (pending)
- Deployment guide: [deployment.md](deployment.md)
- Emergency Module Recovery (post-seal): [recovery.md](recovery.md)

---

Expand Down
8 changes: 6 additions & 2 deletions docs/force-exit.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,9 @@ struct Pull {

```
1. _checkForceExitAllowed()
2. _requireNotPaused()
2. _notPausedForceExit() — checks ONLY FLAG_FORCE_EXIT_PAUSED (its own dedicated
breaker; never FLAG_PAUSED/FLAG_PAUSED_WITHDRAWALS — review §20, force exit must
never be blocked as a side effect of a generic emergency pause)
3. _ensureFreshWarmNav() — mandatory NAV freshness (hard revert if stale + refresh fails)
4. baseShares = _previewWithdraw(assets) — convertToShares(assets)
5. feeShares = mulBpsUp(baseShares, witBps + forceExitPenaltyBps [+ preMaturityBps])
Expand Down Expand Up @@ -200,7 +202,9 @@ No plan — `forceWithdrawAll` pulls all caller shares and sources liquidity aut

```
1. _checkForceExitAllowed()
2. _requireNotPaused()
2. _notPausedForceExit() — checks ONLY FLAG_FORCE_EXIT_PAUSED (its own dedicated
breaker; never FLAG_PAUSED/FLAG_PAUSED_WITHDRAWALS — review §20, force exit must
never be blocked as a side effect of a generic emergency pause)
3. _ensureFreshWarmNav()
4. shares = balanceOf(msg.sender) — ALL caller shares
5. if shares == 0: revert ZeroShares()
Expand Down
Loading
Loading