Version: 1.0.0 | Branch: reorg/runbook-docs-consolidate-01a.3 | Commit: see footer
- Overview
- Role Definitions
- Role Assignments
- Role Capabilities — Per-Function Mapping
- CoreStorage Role Fields
- Module Authorization (
isAuthorizedModule) - Invariants
- Events
- Examples
- Edge Cases
- Glossary
Multyr Core uses a hybrid access control model:
-
Named principals stored in
CoreStorage.Layout:owner,vetoer,guardian. No role-constants library is used in production — roles are checked against storage fields directly. -
Per-function role dispatch:
CoreStorage.Layout.roleOf[selector]maps each function's 4-byte selector to one of four numeric access levels. This mapping is set at deployment and can only be changed by the owner. -
Module authorization:
CoreStorage.Layout.isAuthorizedModule[addr]controls which contracts may callprocessorMintandprocessorBurn— the internal share minting/burning functions used by the queue settlement and force exit paths.
graph TD
subgraph "Named Principals (CoreStorage fields)"
Owner["owner\n(full governance)"]
Vetoer["vetoer\n(revoke-only)"]
Guardian["guardian\n(pause-only)"]
end
subgraph "Per-Function Dispatch"
RoleOf["roleOf[selector]\n0=PUBLIC\n1=OWNER\n2=GUARDIAN\n3=OWNER_OR_GUARDIAN"]
end
subgraph "Module Authorization"
IsAuth["isAuthorizedModule[addr]\n→ processorMint/Burn"]
end
Owner --> RoleOf
Vetoer --> RoleOf
Guardian --> RoleOf
Owner -->|"setIsAuthorizedModule()"| IsAuth
No role library is active in production. src/core/mixins/Roles.sol:10 (pragma 0.8.24) defines a governor address + rolesFrozen boolean pattern but is NOT imported by any active module. See §11 for the discrepancy.
The active CoreVault has four effective access levels, checked via CoreStorage.Layout.roleOf[selector]:
| Level | Constant | Who passes | Notes |
|---|---|---|---|
| 0 | ROLE_PUBLIC |
Any address | No check performed |
| 1 | ROLE_OWNER |
msg.sender == core.owner |
Full governance authority |
| 2 | ROLE_GUARDIAN |
msg.sender == core.guardian |
Emergency pause authority |
| 3 | ROLE_OWNER_OR_GUARDIAN |
owner OR guardian | Flexible pause actions |
Role levels are stored as uint8. The check at the module dispatch layer reads roleOf[msg.sig] and validates msg.sender against the stored principal.
src/core/mixins/Roles.sol:10 (pragma 0.8.24) defines:
address public governor;
bool public rolesFrozen;
bytes32 public constant GUARDIAN_ROLE = keccak256("GUARDIAN_ROLE");
bytes32 public constant KEEPER_ROLE = keccak256("KEEPER_ROLE");
bytes32 public constant PARAM_ROLE = keccak256("PARAM_ROLE");This contract uses a governor address + OpenZeppelin AccessControl-style bytes32 role constants. It is NOT imported by AdminModule, EpochedQueueModule, ERC4626Module, or any active module. The production role system is the roleOf[selector] mapping described above.
All three named principals are set during deployment:
| Principal | Initial setter | Function | Notes |
|---|---|---|---|
owner |
Deployer | Constructor / transferOwnership flow |
Deployer is initial owner |
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 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.
| Principal | Change mechanism | Timelock? | Revert if misused |
|---|---|---|---|
owner |
2-step: transferOwnership + acceptOwnership |
None (immediate once accepted) | Pending owner must accept |
vetoer |
setVetoer(addr) (owner-only, immediate) |
None | NotOwner() if non-owner |
guardian |
Direct setter or setEcosystem (owner-only, immediate) |
None | NotOwner() if non-owner |
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.
roleOf[selector] is populated via a selector registry contract (CoreStorage.Layout.selectorRegistry) at deployment and can be updated by the owner. Changing a function's role level changes its access control permanently for that function.
| Function | Module | Description |
|---|---|---|
submitFeeParams |
AdminModule | Timelock submission for fee params |
acceptFeeParams |
AdminModule | Accept after delay |
revokeFeeParams |
AdminModule | Also callable by vetoer |
submitPerfParams |
AdminModule | Timelock submission for perf params |
acceptPerfParams |
AdminModule | Accept after delay |
revokePerfParams |
AdminModule | Also callable by vetoer |
submitMinDelay |
AdminModule | Timelock submission for paramMinDelay |
acceptMinDelay |
AdminModule | Accept after delay |
revokeMinDelay |
AdminModule | Also callable by vetoer |
setParams |
AdminModule | IParamsProvider address |
setFeeCollector |
AdminModule | Fee recipient |
setVetoer |
AdminModule | Vetoer address |
setHealthRegistry |
AdminModule | Strategy health registry |
setIncentives |
AdminModule | Incentives contract |
setIncentivesEngine |
AdminModule | Incentives engine |
setRewardsPayoutManager |
AdminModule | Rewards manager |
setRewardsTreasury |
AdminModule | Pre-funded share balance payRewardShares() draws from |
setRebalancePolicy |
AdminModule | V10 rebalance policy |
setRebalanceGuard |
AdminModule | V10 rebalance guard |
setExecutionMemory |
AdminModule | Execution memory recorder |
setStrictExecutionMemory |
AdminModule | Bool setter |
setEcosystem |
AdminModule | Batch component init |
setInitialFees |
AdminModule | One-shot pre-seal fee bootstrap |
setInitialPerfParams |
AdminModule | One-shot pre-seal perf bootstrap |
seedDeadDeposit |
AdminModule | Inflation hardening (one-shot) |
freezeParams |
AdminModule | Permanent FLAG_PARAMS_FROZEN |
enableComponentsTimelock |
AdminModule | FLAG_COMPONENTS_TIMELOCKED |
transferOwnership |
AdminModule | Step 1 of 2-step transfer |
setVaultModeFixedMaturity |
FixedMaturityModule | Enter FM mode |
configureFixedMaturity |
FixedMaturityModule | Set FM parameters |
startFixedMaturityCycle |
FixedMaturityModule | Funding → Starting |
activateFixedMaturityCycle |
FixedMaturityModule | Starting → Active |
closeFixedMaturityCycle |
FixedMaturityModule | Matured → Closed |
deployToStrategies, realizeForQueue, realizeForReserveAndOps, rebalanceStrategies |
LiquidityOpsModule | Correction: these are ROLE_PUBLIC (keeper-triggered, allocation computed on-chain), not OWNER-only — see §4.4 |
| Function | Module | Description |
|---|---|---|
| (none currently defined as guardian-exclusive) | — | Guardian authority is expressed via OWNER_OR_GUARDIAN |
| Function | Module | Description |
|---|---|---|
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. |
Correction: pause functions are NOT
moduleOf/roleOf-routed and do not belong in this table — they are direct functions onCoreVaultitself (likesetModule/freezeRouting), gated by CoreVault's ownonlyOwner/onlyGuardian/onlyOwnerOrGuardianmodifiers, 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 and governance.md §4.2 — not duplicated here to avoid a third copy drifting out of sync.
Key permissionless functions:
| Function | Module | Notes |
|---|---|---|
deposit |
ERC4626Module | Subject to pause checks |
requestEpochWithdrawal |
EpochedQueueModule | Subject to FLAG_QUEUED_REQUEST_PAUSED (new-request breaker, owner-only); deposit lock and valid NAV required; no withdrawal minimum |
requestInstantWithdrawal |
EpochedQueueModule | Requires deposit lock and valid NAV; 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 |
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) |
keeperSettleClaims |
EpochedQueueModule | Permissionless batch payment to recorded claim owners; subject to the funded-claim breaker. Automatic settlement supplements owner self-claim. |
rollCapEpochIfNeeded |
EpochedQueueModule | Permissionless cap rollover; state-changing entrypoints also roll before asset/supply mutations. |
fundedOutstandingClaimCount |
EpochedQueueModule | Public view for outstanding claims in Funded epochs, including zero recovery. |
fundedEpochCount |
EpochedQueueModule | Public view for the number of Closed-to-Funded transitions; only increases. |
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 |
recallFixedTermCapital |
FixedMaturityModule | Matured state only |
refundClaim |
FixedMaturityModule | FundingFailed state only |
canDeploy |
LiquidityOpsModule | View function |
canRebalance |
LiquidityOpsModule | View function |
deployToStrategies |
LiquidityOpsModule | Deploy surplus; allocation computed on-chain (keeper-triggered) |
realizeForQueue |
LiquidityOpsModule | Realize liquidity for queue settle |
realizeForReserveAndOps |
LiquidityOpsModule | Realize for hot buffer reserve |
rebalanceStrategies |
LiquidityOpsModule | V10 rebalance execution |
totalAssets |
CoreVault shell | Shareholder NAV: saturating gross assets minus outstanding liabilities |
All role-related state is stored in CoreStorage.Layout (EIP-7201 namespaced slot 0xff7b491291207fbb51df1ab8f042e8ee7f087c9a7e4a083e1a2dbbddb742ef00):
struct Layout {
// Named principals
address owner; // Full governance authority
address pendingOwner; // Staging: 2-step ownership transfer
address vetoer; // Revoke pending params only
address guardian; // Emergency pause only
// Per-function role dispatch
mapping(bytes4 => uint8) roleOf; // selector → 0/1/2/3
// Module dispatch
mapping(bytes4 => address) moduleOf; // selector → implementation module
// Module authorization for mint/burn
mapping(address => bool) isAuthorizedModule;
// Selector registry
address selectorRegistry;
// Authorization state
bytes32 pendingSealHash;
address authorizedSealer;
uint256 packedFlags; // bit 0=PAUSED, bit 8=COMPONENTS_TIMELOCKED, bit 9=SEALED, ...
// ... other fields
}Source: src/core/storage/CoreStorage.sol:38.
CoreStorage.Layout.isAuthorizedModule[addr] is a mapping from contract address to bool. When true, that contract may call processorMint(address, uint256) and processorBurn(address, uint256) — the internal share issuance and destruction functions.
These functions bypass normal ERC-20 transfer logic and directly adjust share balances. They are used by:
- EpochedQueueModule — burns net shares and transfers exit fee shares at request, records and settles liabilities, and mints performance fee shares on crystallization
- FixedMaturityModule — mints/burns during lifecycle transitions (e.g.,
_applyFinalPerformanceFee)
Access check:
if (!CoreStorage.layout().isAuthorizedModule[msg.sender]) revert NotAuthorizedModule();The owner manages this mapping via setIsAuthorizedModule(address module, bool authorized). Any address granted this flag can mint or burn shares without restriction — it is the most privileged internal capability in the system. Granting it to untrusted contracts is a critical security risk.
Source: src/core/storage/CoreStorage.sol:38, src/core/modules/AdminModule.sol:73.
| ID | Invariant | Source |
|---|---|---|
| AC1 | roleOf[selector] ∈ {0, 1, 2, 3} for all registered selectors |
CoreStorage uint8 encoding |
| AC2 | owner != address(0) after deploy; transferOwnership to zero is unsafe (no guard in CoreStorage) |
Deployment responsibility |
| AC3 | pendingOwner is cleared to address(0) after acceptOwnership |
AdminModule |
| AC4 | isAuthorizedModule[addr] = true implies addr can mint/burn shares arbitrarily |
AdminModule guard |
| AC5 | Guardian can pause but not unpause withdrawals | roleOf[unpauseWithdrawals] = 1 (OWNER) |
| AC6 | Vetoer has no positive governance power — revoke-only | revoke functions only |
| AC7 | roleOf[selector] changes require owner action |
AdminModule, selectorRegistry |
| AC8 | After FLAG_SYSTEM_SEALED, selectorRegistry can still be updated — sealing does not lock the role dispatch table |
Design limitation |
| Event | When emitted | Key parameters |
|---|---|---|
OwnershipTransferStarted |
transferOwnership |
previousOwner, newOwner |
OwnershipTransferred |
acceptOwnership |
previousOwner, newOwner |
VetoerUpdated |
setVetoer |
previousVetoer, newVetoer |
GuardianUpdated |
setGuardian / setEcosystem |
previousGuardian, newGuardian |
Paused |
pause() |
account (caller) |
Unpaused |
unpause() |
account (caller) |
DepositsPaused |
pauseDeposits() |
— |
DepositsUnpaused |
unpauseDeposits() |
— |
WithdrawalsPaused |
pauseWithdrawals() |
— |
WithdrawalsUnpaused |
unpauseWithdrawals() |
— |
IsAuthorizedModuleSet |
setIsAuthorizedModule |
module, authorized |
Source: src/core/modules/AdminModule.sol:357.
// Off-chain read: what role does depositWithReferral() require?
bytes4 sel = bytes4(keccak256("depositWithReferral(uint256,address,bytes)"));
uint8 role = CoreStorage.layout().roleOf[sel];
// role = 0 → PUBLIC (any caller)
// role = 1 → OWNER-only
// role = 2 → GUARDIAN-only
// role = 3 → OWNER or GUARDIANScenario: New FixedMaturityModule deployment — needs processorMint/processorBurn.
owner calls setIsAuthorizedModule(address(newFixedMaturityModule), true)
→ isAuthorizedModule[newFixedMaturityModule] = true
newFixedMaturityModule can now call processorMint(user, shares)
→ shares minted to user without ERC-20 approval
Risk: if newFixedMaturityModule has a reentrancy bug or is malicious,
it can mint arbitrary shares. Owner must audit before authorization.
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 setGuardian(0xNewGuardian) ← replace guardian
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.
At deployment, selectorRegistry registers all selectors with their roles:
selector | function | roleOf
---------+---------------------------------+-------
0x6e553f65 | deposit(uint256,address) | 0 (PUBLIC)
0x... | pause() | 3 (OWNER_OR_GUARDIAN)
0x... | submitFeeParams(...) | 1 (OWNER)
0x... | acceptFeeParams() | 1 (OWNER)
0x... | markMatured() | 0 (PUBLIC)
0x... | freezeParams() | 1 (OWNER)
Owner can update any entry via the selectorRegistry (no timelock on role changes).
| Scenario | Behaviour |
|---|---|
roleOf[unknownSelector] == 0 |
Unregistered selector defaults to PUBLIC (0). If the module dispatch routes it, no access check is performed. Deployment must ensure all sensitive selectors are registered. |
owner == vetoer |
Functionally valid — one address holds both roles. Reduces security (no independent veto). Deployment should keep them separate. |
guardian == owner |
Valid but defeats fast-rotation purpose; same key compromise affects both roles. |
isAuthorizedModule[addr] set for an EOA |
EOA would have processorMint/Burn access. Avoid; use only for audited module contracts. |
transferOwnership(address(0)) |
Sets pendingOwner = 0. If acceptOwnership is then called by address(0) (impossible for EOA), vault would be ownerless. In practice only contracts can call from address(0). Guard: deployment must never call transferOwnership(0). |
| Role change on a paused vault | No restriction — owner can update roleOf[selector] even while FLAG_PAUSED is set. |
| Vetoer set to address(0) | Zero address cannot sign transactions — veto capability effectively removed. Owner revokes are still possible. |
FLAG_SYSTEM_SEALED effect on roleOf |
Sealing does NOT freeze the selectorRegistry or roleOf mapping. Role assignments remain mutable by the owner even post-seal. This is a known design gap (AC8 in §7). |
| Term | Definition |
|---|---|
| owner | Primary governance address; CoreStorage.Layout.owner; full authority |
| vetoer | Revoke-only principal; CoreStorage.Layout.vetoer; no positive governance |
| guardian | Pause-only principal; CoreStorage.Layout.guardian; fast circuit breaker |
| pendingOwner | Staging address for 2-step transfer; CoreStorage.Layout.pendingOwner |
| roleOf[selector] | Per-function access level: 0=PUBLIC, 1=OWNER, 2=GUARDIAN, 3=OWNER_OR_GUARDIAN |
| isAuthorizedModule | Mapping of authorized contracts for processorMint/Burn |
| processorMint | Internal share minting bypassing ERC-20 transfers; gated by isAuthorizedModule |
| processorBurn | Internal share burning; gated by isAuthorizedModule |
| selectorRegistry | Contract responsible for registering selector → role mappings |
| ROLE_PUBLIC (0) | No access control check; any address may call |
| ROLE_OWNER (1) | msg.sender == core.owner |
| ROLE_GUARDIAN (2) | msg.sender == core.guardian |
| ROLE_OWNER_OR_GUARDIAN (3) | msg.sender == core.owner OR core.guardian |
| Roles.sol | LEGACY mixin (pragma 0.8.24); governor + rolesFrozen + bytes32 constants; NOT active |
| Ownable2StepMixin.sol | LEGACY mixin (pragma 0.8.24); OZ Ownable2Step wrapper; NOT active |
| Symbol | File | Notes |
|---|---|---|
CoreStorage.Layout.owner |
src/core/storage/CoreStorage.sol:48 |
Primary governance principal |
CoreStorage.Layout.vetoer |
src/core/storage/CoreStorage.sol:46 |
Revoke-only principal |
CoreStorage.Layout.guardian |
src/core/storage/CoreStorage.sol:47 |
Pause-only principal |
CoreStorage.Layout.pendingOwner |
src/core/storage/CoreStorage.sol:49 |
2-step transfer staging |
CoreStorage.Layout.roleOf |
src/core/storage/CoreStorage.sol:80 |
Selector → role mapping |
CoreStorage.Layout.moduleOf |
src/core/storage/CoreStorage.sol:79 |
Selector → module dispatch |
CoreStorage.Layout.isAuthorizedModule |
src/core/storage/CoreStorage.sol:92 |
processorMint/Burn gate |
CoreStorage.Layout.selectorRegistry |
src/core/storage/CoreStorage.sol:83 |
Role registration contract |
CoreStorage.SLOT |
src/core/storage/CoreStorage.sol:16 |
EIP-7201 slot = 0xff7b4912... |
AdminModule.setVetoer |
src/core/modules/AdminModule.sol:357 |
Immediate vetoer update |
AdminModule.setIsAuthorizedModule |
src/core/CoreVault.sol:335 |
Grant/revoke processorMint/Burn |
AdminModule.transferOwnership |
src/core/CoreVault.sol:394 |
Step 1 of 2-step |
AdminModule.acceptOwnership |
src/core/CoreVault.sol:400 |
Step 2 of 2-step |
AdminModule._requireNotPaused |
src/core/CoreVault.sol:107 |
FLAG_PAUSED check |
AdminModule.pause/unpause |
src/core/CoreVault.sol:425 |
OWNER_OR_GUARDIAN |
AdminModule.unpauseWithdrawals |
src/core/CoreVault.sol:446 |
OWNER only |
Roles.sol |
src/core/mixins/Roles.sol:10 |
LEGACY (pragma 0.8.24); NOT active |
Ownable2StepMixin.sol |
src/core/mixins/Ownable2StepMixin.sol:12 |
LEGACY (pragma 0.8.24); NOT active |
Commit SHA: 4a21f2ee (governance.md commit; see git log for current SHA)
Sources read (ADR-015 §5):
| File | Lines | Read at |
|---|---|---|
src/core/storage/CoreStorage.sol:38 |
114L | Full |
src/core/modules/AdminModule.sol:73 |
847L | Full |
src/core/mixins/Roles.sol:10 |
37L | Full |
src/core/mixins/Ownable2StepMixin.sol:12 |
15L | Full |
src/core/modules/FixedMaturityModule.sol:43 |
454L | Full |
src/core/modules/LiquidityOpsModule.sol:32 |
689L | Full |
src/core/modules/BatchGuardrails.sol:20 |
263L | Full |
Discrepancies found (ADR-015 §5):
-
src/core/mixins/Roles.sol:10(pragma 0.8.24): Definesgovernor,rolesFrozen, andbytes32role constants (GUARDIAN_ROLE,KEEPER_ROLE,PARAM_ROLE) using an OpenZeppelin AccessControl-style pattern. This is NOT the active role system. The active system usesCoreStorage.Layout.roleOf[selector]with numeric levels (0-3). -
src/core/mixins/Ownable2StepMixin.sol:12(pragma 0.8.24): Wraps OZOwnable2Stepwith_beginOwnershipTransfer(address). NOT imported by any active module. The active two-step transfer is implemented viaCoreStorage.owner+CoreStorage.pendingOwnerdirectly. -
FLAG_SYSTEM_SEALEDdoes not freezeroleOf: Sealing the system locksbufferManager/routercomponent changes but does NOT freeze theselectorRegistryorroleOf[selector]mapping. An owner with a compromised key could theoretically change function roles after sealing. This is a known design gap documented as invariant AC8. -
BatchGuardrails.sol: A standalone peripheral contract (pragma 0.8.20) with its ownIConfigand oracle dependencies. It validates batch calls but is NOT part ofCoreStorage.moduleOfdispatch. Not a governance actor — included in source read for completeness.
Generated from code — not from existing documentation. Authoritative source: .sol files listed above.
Haircut funding requires valid NAV. Recovery and any deliberate write-down follow the insolvency runbook: extract recoverable assets before disabling a strategy or removing an adapter. Router/buffer ownership and GlobalConfig governance are separate from the permissionless funding and claim entrypoints.