Organization
- @steakhouse
Engagement Type
Cantina Solo
Period
-
Repositories
Researchers
Findings
Low Risk
3 findings
2 fixed
1 acknowledged
Informational
8 findings
7 fixed
1 acknowledged
Low Risk3 findings
Timelocked Action Can Be Made Hard to Revoke and Invisible to isGuardianBeingRemoved via Trailing Calldata
Severity
- Severity: Low
Submitted by
Alireza Arjmand
Description
Timelocked actions are keyed by the exact
bytes datainexecutableAt[data]. Since Solidity ignores trailing calldata or dirty bytes for decoding but keeps it inmsg.data, the owner can submit a valid action with extra trailing bytes. That action executes normally, but helper functions that reconstruct canonical calldata cannot find and revoke it.This breaks:
revokeGuardianRemovalandrevokeVaultOwnerChange(they encode clean calldata)isGuardianBeingRemoved(it also encodes clean calldata, so it returns false)
Note: the action is still revokable via the generic
revoke(bytes data)if the exact submitteddata(including the extra bytes) is known.Proof of Concept
function test_ImpossibleRevokeGuardianRemoval() public { supervisor.addGuardian(address(vault), GUARDIAN); bytes memory data = abi.encodePacked(abi.encodeWithSelector(VaultV2Supervisor.removeGuardian.selector, vault, GUARDIAN), hex'11'); // Appending random data at the end of the calldata supervisor.submit(data); vm.startPrank(GUARDIAN); vm.expectRevert(); supervisor.revokeGuardianRemoval(vault, GUARDIAN); vm.stopPrank(); vm.warp(block.timestamp + TIMELOCK); address[] memory guardiansBefore = supervisor.getGuardians(address(vault)); (bool success, ) = address(supervisor).call(data); require(success); address[] memory guardiansAfter = supervisor.getGuardians(address(vault)); vm.assertEq(guardiansAfter.length, guardiansBefore.length - 1); // Number of guardians reduced }Recommendation
Make sure the data that is being received for submission does not include dirty bytes or trailing calldata.
Cantina Managed: The Steakhouse team mitigated this issue by validating, at submission time, that the calldata has the expected length and contains no dirty bytes, using the
_decodeCanonicalTwoAddressCalldatafunction.Guardians Must Revoke Timelocked Actions One-by-One
State
- Fixed
PR #2
Severity
- Severity: Low
Submitted by
Alireza Arjmand
Description
A malicious owner can submit many timelocked actions, forcing guardians to revoke them individually. Since revocation is per exact calldata entry, guardians pay O(n) gas to clean up n scheduled actions, matching the owner’s griefing budget and potentially making defense impractical during an incident.
Recommendation
Add an O(1) circuit breaker, for example a pause mechanism or global “revoke all pending actions” switch (optionally per vault), so guardians can halt execution without needing to revoke every scheduled item individually.
Cantina Managed: Verified the fixes. There is now a single canonical calldata that corresponds to a timelocked action, so guardians only need to revoke one calldata per action if required.
Guardian Tracking Can Be DoSed by Adding Many Vaults
State
- Acknowledged
Severity
- Severity: Low
Submitted by
Alireza Arjmand
Description
An allowlisted vault owner (or the supervisor owner) can call
addGuardianacross a large number of vault addresses, growing_vaultsand_guardians. This can makegetVaults,getOwnedVaults,getNonOwnedVaults, and per-vault guardian enumeration increasingly expensive and potentially unusable for onchain callers.Recommendation
Consider whitelisting vaults instead of vault owners so that number of vaults can't suddenly grow.
Steakhouse: This behavior is intentional, and we accept the associated trade-offs.
Cantina Managed: Acknowledged.
Informational13 findings
Trust Assumptions
State
- New
Severity
- Severity: Informational
Submitted by
Alireza Arjmand
Trust Assumptions
This system relies on several strong trust assumptions around privileged roles and vault configuration.
Guardian Role
The guardian set is fully trusted. If an incorrect or malicious address is added as a guardian, the system can be irreversibly compromised. Guardians are expected to act honestly and defensively. In particular, while the owner may become malicious, guardians are assumed to monitor and intervene where possible to prevent harmful actions.
Owner and Sentinel Control
For vaults owned by the
VaultV2Supervisor, guardians are always able to configure the supervisor as the vault’s sentinel and retain access to revoke functionality. This ensures an emergency control path remains available.For vaults not owned by the
VaultV2Supervisor, this assumption no longer holds. The vault owner can remove the supervisor from the sentinels list, thereby blocking its ability to act as a sentinel or use revoke-related functionality. In such configurations, safety depends entirely on the vault owner’s behavior.Curator Role and Supervisor Control
The curator role on vaults is fully controlled by the supervisor. While curators are not expected to be able to directly steal funds without going through timelocked mechanisms, they can influence vault parameters in ways that materially affect usability.
Specifically, a curator can modify absolute caps and relative caps in a way that renders the vault effectively unusable for users. This is considered within their authority and is therefore a trust assumption rather than a bug.
Additionally, when the contract only has sentinel access, it cannot call
decreaseRelativeCap,decreaseAbsoluteCap, ordeallocate. This limits the blast radius of sentinel-only permissions.Underlying Vault Configuration
All underlying vaults are assumed to be correctly configured according to the intended Morpho Vault V2 design and to have been properly audited. Misconfiguration or unsafe customizations at the underlying vault layer may invalidate the security assumptions of this system.
Scope and Code Overview
State
- New
Severity
- Severity: Informational
Submitted by
Alireza Arjmand
Scope
Spearbit engaged with Steakhouse to review the
vault-v2-supervisorrepository at commit34b2ec2:In-scope files:
.├── src│ └── VaultV2Supervisor.t.sol└── test └── VaultV2Supervisor.t.solCode Overview
VaultV2Supervisoris a governance wrapper intended to own one or more Morpho Vault V2 instances and provide a supervisory control plane with a global timelock and per‑vault guardian registry. It implements two‑step supervisor ownership transfer, a guardian allowlist for vault owners, timelocked scheduling and execution for specific supervisor actions, and a guardian proxy that can forward vault‑levelrevoke(bytes)calls.The supervisor exposes:
- Immediate owner actions to manage vault metadata and roles (e.g., curator, sentinels, name/symbol), set skim recipient on adapters, and manage guardian allowlisting.
- Timelocked actions (
setOwner,removeGuardian) that must be submitted, delayed, and then executed, with the ability for guardians or the owner to revoke pending actions. - Vault tracking helpers (vaults with guardians, owned vs. non‑owned).
- A guardian proxy for Vault V2 timelock revocations, enabled when the supervisor is set as a vault sentinel.
Vault V2 itself distinguishes owner, curator, sentinel, and allocator roles. Owner actions are immediate; curator actions are generally timelocked via the vault’s
submit/timelockedmechanism; sentinel and allocator actions are immediate within their respective permission scopes.Validating Supervisor Use Cases Against Vault V2 Capabilities
State
- New
Severity
- Severity: Informational
Submitted by
Alireza Arjmand
Assuming the system is configured safely and all guardians are trusted, the Supervisor owner’s control path across the Supervisor → Vault V2 stack is as follows.
Supervisor layer (direct control)
- Immediate owner actions:
setCurator,setName,setSymbol,addSentinel,removeSentinel,setSkimRecipient,setAllowedVaultOwner,addGuardian, and two‑steptransferSupervisorOwnership. - Timelocked and guardian‑revocable actions:
setOwner(vault ownership transfer) andremoveGuardian, viasubmit → wait → execute. Guardians or the owner can veto viarevoke.
Vault V2 layer (via supervisor as owner/curator)
- Owner actions (immediate at vault):
setCurator,setIsSentinel,setName,setSymbol. - Since curator/sentinel can be updated at supervisor's will, supervisor does not need to wait behind a timelock for below actions (immediate at vault):
submit(curator),revoke(curator or sentinel),decreaseAbsoluteCap(curator or sentinel),decreaseRelativeCap(curator or sentinel),deallocate(allocator or sentinel but updating sentinel is faster). - Curator actions (vault timelocked via
submit):
setIsAllocator,setReceiveSharesGate,setSendSharesGate,setReceiveAssetsGate,setSendAssetsGate,setAdapterRegistry,addAdapter,removeAdapter,increaseTimelock,decreaseTimelock,abdicate,setPerformanceFee,setManagementFee,setPerformanceFeeRecipient,setManagementFeeRecipient,increaseAbsoluteCap,increaseRelativeCap,setForceDeallocatePenalty. - Allocator/sentinel actions are immediate at vault, but for the supervisor to be able to change the allocator, malicious
setIsAllocatorneeds to wait behind a timelock:
allocate,setLiquidityAdapterAndDataandsetMaxRate.
The allocator role itself is granted only via the timelocked curator actionsetIsAllocator.
Guardian veto path
- Guardians can veto supervisor‑level timelocks (
setOwner,removeGuardian) through the supervisor’srevoke. - Guardians can veto vault‑level timelocks only if the supervisor is a vault sentinel; this is enabled via packing
setSupervisorAsSentinelwithsupervisor.revoke(vault, data)to forward the veto to Vault V2.
Security Review Statement
State
- New
Severity
- Severity: Informational
Submitted by
Alireza Arjmand
In this solo review, Cantina reviewed Steakhouse’s
VaultV2Supervisorcontract. The team was responsive throughout the process and addressed feedback promptly and thoroughly. Based on the scope of this review and the fixes implemented, the codebase is in good condition and suitable for deployment.Immutable Naming Convention Deviation
State
- Fixed
PR #2
Severity
- Severity: Informational
Submitted by
Alireza Arjmand
Description
The immutable variable
timelockdoes not follow Solidity’s recommended naming convention for constants and immutables, which should useUPPER_CASE_WITH_UNDERSCORESas specified in the Solidity style guide: https://docs.soliditylang.org/en/v0.8.10/style-guide.html#constantsRecommendation
Rename
timelocktoTIMELOCKto align with Solidity conventions.Cantina Managed: Verified the fixes.
Missing onlyOwnerOrGuardian Modifier
State
- Fixed
PR #2
Severity
- Severity: Informational
Submitted by
Alireza Arjmand
Description
The contract repeats the same access check inline instead of using a modifier:
_guardians[vault].contains(msg.sender) || msg.sender == ownerThis pattern appears in
revokeand_revokeKnownVault. The latter is called byrevokeGuardianRemovalandrevokeVaultOwnerChange, so placing the check on those external entrypoints would improve readability and separation of concerns.Recommendation
Add an
onlyOwnerOrGuardian(address vault)modifier and apply it to relevant functions, especially the external callers of_revokeKnownVault.Cantina Managed: Verified the fixes.
Non-Descriptive Variable Names in Decoding
State
- Fixed
PR #2
Severity
- Severity: Informational
Submitted by
Alireza Arjmand
Description
In
submit, decoded variables use abbreviated names:(address v, address newO) = abi.decode(...)Short names reduce readability and make the intent less clear, especially in security-sensitive logic.
Recommendation
Use descriptive names such as:
(address _vault, address _newOwner)Cantina Managed: Verified the fixes.
_selectorUsesVault Includes Non-Timelocked Selector
State
- Fixed
PR #2
Severity
- Severity: Informational
Submitted by
Alireza Arjmand
Description
_selectorUsesVaultincludesremoveSentinel, butremoveSentinelis not timelocked and does not go through thesubmitflow. This makes the selector list misleading.Recommendation
Remove
removeSentinelfrom_selectorUsesVault(and any related selector lists used for timelock context).Cantina Managed: Verified the fixes.
Missing Minimum Bound for Timelock
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Alireza Arjmand
Description
The constructor check
require(timelock_ > 0, InvalidTimelock());only ensures the value is nonzero but does not enforce that it is practically safe. Extremely small values could undermine the purpose of a timelock.Recommendation
Introduce a minimum bound constant, e.g.
MINIMUM_TIMELOCK, and validate against it to ensure the configured delay is meaningful.Steakhouse: We don't consider this an issue, as we will only deploy one supervisor per chain, and the value is immutable.
Cantina Managed: Acknowledged. Provided the deployment is properly verified, no further changes are required.
Duplicate Revoke Logic in revoke and _revokeKnownVault
State
- Fixed
PR #2
Severity
- Severity: Informational
Submitted by
Alireza Arjmand
Description
revoke(bytes calldata data)duplicates the same core logic implemented in_revokeKnownVault(permission check, timelock existence check, clearingscheduledNewOwnerwhen relevant, clearingexecutableAt, emitting event). This is unnecessary duplication and slightly increases maintenance risk.Recommendation
Refactor
revoke(bytes)to reuse_revokeKnownVault(or a shared internal helper) so the revoke logic lives in one place.Cantina Managed: Verified the fixes.
Function Order Does Not Follow Solidity Convention
State
- Fixed
PR #2
Severity
- Severity: Informational
Submitted by
Alireza Arjmand
Description
The contract does not follow Solidity’s recommended function ordering, which can make navigation and review harder. The suggested order is documented here: https://docs.soliditylang.org/en/latest/style-guide.html#order-of-functions
Recommendation
Reorder functions to follow Solidity’s standard structure to improve readability and consistency.
Cantina Managed: Verified the fixes.
Cannot Cancel Pending Supervisor Ownership Transfer
State
- Fixed
PR #2
Severity
- Severity: Informational
Submitted by
Alireza Arjmand
Description
Once
transferSupervisorOwnership(newOwner)setspendingSupervisorOwner, there is no direct way for the current owner to cancel it. To “cancel,” the owner must overwrite it by proposing another address, which is clunky and can be error-prone.Recommendation
Add an explicit cancel function (or allow
transferSupervisorOwnership(address(0))) to clearpendingSupervisorOwner.Cantina Managed: Verified the fixes. It is now possible to call
transferSupervisorOwnershipwithaddress(0)as the calldata to cancel an ongoing transfer process.Fuzz Test Added for Submit Workflow
State
- New
Severity
- Severity: Informational
Submitted by
Alireza Arjmand
diff --git a/test/VaultV2Supervisor.t.sol b/test/VaultV2Supervisor.t.solindex 5490a88..4a5158b 100644--- a/test/VaultV2Supervisor.t.sol+++ b/test/VaultV2Supervisor.t.sol@@ -478,6 +478,142 @@ contract VaultV2SupervisorTest is Test { assertFalse(supervisor.isVaultSupervised(address(0xB0B))); } + function testFuzz_SubmitRevokeWorkflow_AlwaysClearsPendingState(uint256 seed, uint8 steps) public {+ steps = uint8(bound(steps, 6, 30));++ address guardianA = address(0xBEE1);+ address guardianB = address(0xBEE2);+ supervisor.addGuardian(address(vault), guardianA);+ supervisor.addGuardian(address(vault), guardianB);++ bytes32 state = keccak256(abi.encode(seed));++ for (uint256 i; i < steps; ++i) {+ state = keccak256(abi.encode(state, i));+ bool ownerWorkflow = (uint256(state) & 1) == 1;+ bool revokeByGuardian = ((uint256(state) >> 1) & 1) == 1;+ bool useSpecializedRevoke = ((uint256(state) >> 2) & 1) == 1;++ if (ownerWorkflow) {+ address candidate = _ownerCandidateFromState(state);+ bytes memory data = abi.encodeWithSelector(VaultV2Supervisor.setOwner.selector, vault, candidate);+ bytes memory oversized = bytes.concat(data, new bytes((uint256(state) % 8) + 1));+ bytes memory dirtyVault = abi.encodePacked(+ VaultV2Supervisor.setOwner.selector,+ _dirtyAddressWord(address(vault)),+ bytes32(uint256(uint160(candidate)))+ );+ bytes memory dirtyOwner = abi.encodePacked(+ VaultV2Supervisor.setOwner.selector,+ bytes32(uint256(uint160(address(vault)))),+ _dirtyAddressWord(candidate)+ );++ vm.expectRevert(VaultV2Supervisor.InvalidAmount.selector);+ supervisor.submit(oversized);+ vm.expectRevert(VaultV2Supervisor.InvalidAmount.selector);+ supervisor.submit(dirtyVault);+ vm.expectRevert(VaultV2Supervisor.InvalidAmount.selector);+ supervisor.submit(dirtyOwner);++ uint256 submitTime = block.timestamp;+ supervisor.submit(data);++ assertEq(supervisor.executableAt(data), submitTime + TIMELOCK);+ assertEq(supervisor.scheduledNewOwner(address(vault)), candidate);+ assertTrue(supervisor.isOwnershipChanging(address(vault)));++ vm.expectRevert(VaultV2Supervisor.DataNotTimelocked.selector);+ supervisor.revoke(oversized);++ if (revokeByGuardian) vm.prank(guardianA);++ if (useSpecializedRevoke) {+ supervisor.revokeVaultOwnerChange(vault);+ } else {+ supervisor.revoke(data);+ }++ assertEq(supervisor.executableAt(data), 0);+ assertEq(supervisor.scheduledNewOwner(address(vault)), address(0));+ assertFalse(supervisor.isOwnershipChanging(address(vault)));++ vm.expectRevert(VaultV2Supervisor.DataNotTimelocked.selector);+ supervisor.setOwner(vault, candidate);+ } else {+ address guardianToRemove = ((uint256(state) >> 3) & 1) == 1 ? guardianA : guardianB;+ bytes memory data =+ abi.encodeWithSelector(VaultV2Supervisor.removeGuardian.selector, vault, guardianToRemove);+ bytes memory oversized = bytes.concat(data, new bytes((uint256(state) % 8) + 1));+ bytes memory dirtyVault = abi.encodePacked(+ VaultV2Supervisor.removeGuardian.selector,+ _dirtyAddressWord(address(vault)),+ bytes32(uint256(uint160(guardianToRemove)))+ );+ bytes memory dirtyGuardian = abi.encodePacked(+ VaultV2Supervisor.removeGuardian.selector,+ bytes32(uint256(uint160(address(vault)))),+ _dirtyAddressWord(guardianToRemove)+ );++ vm.expectRevert(VaultV2Supervisor.InvalidAmount.selector);+ supervisor.submit(oversized);+ vm.expectRevert(VaultV2Supervisor.InvalidAmount.selector);+ supervisor.submit(dirtyVault);+ vm.expectRevert(VaultV2Supervisor.InvalidAmount.selector);+ supervisor.submit(dirtyGuardian);++ uint256 submitTime = block.timestamp;+ supervisor.submit(data);++ assertEq(supervisor.executableAt(data), submitTime + TIMELOCK);+ assertTrue(supervisor.isGuardianBeingRemoved(address(vault), guardianToRemove));++ vm.expectRevert(VaultV2Supervisor.DataNotTimelocked.selector);+ supervisor.revoke(oversized);++ if (revokeByGuardian) vm.prank(guardianA);++ if (useSpecializedRevoke) {+ supervisor.revokeGuardianRemoval(vault, guardianToRemove);+ } else {+ supervisor.revoke(data);+ }++ assertEq(supervisor.executableAt(data), 0);+ assertFalse(supervisor.isGuardianBeingRemoved(address(vault), guardianToRemove));+ assertTrue(_contains(supervisor.getGuardians(address(vault)), guardianToRemove));++ vm.expectRevert(VaultV2Supervisor.DataNotTimelocked.selector);+ supervisor.removeGuardian(vault, guardianToRemove);+ }++ address[] memory guardians = supervisor.getGuardians(address(vault));+ assertEq(guardians.length, 2);+ assertTrue(_contains(guardians, guardianA));+ assertTrue(_contains(guardians, guardianB));++ address[] memory vaults = supervisor.getVaults();+ assertEq(vaults.length, 1);+ assertEq(vaults[0], address(vault));+ assertEq(vault.owner(), address(supervisor));+ }+ }++ function _ownerCandidateFromState(bytes32 state) internal view returns (address candidate) {+ candidate = address(uint160(uint256(state)));+ if (candidate == address(0) || candidate == address(supervisor)) {+ candidate = address(uint160(uint256(keccak256(abi.encode(state, "owner-candidate")))));+ }+ if (candidate == address(0) || candidate == address(supervisor)) {+ candidate = address(0x1111111111111111111111111111111111111111);+ }+ }++ function _dirtyAddressWord(address account) internal pure returns (bytes32) {+ return bytes32(uint256(uint160(account)) | (uint256(1) << 200));+ }+ function _contains(address[] memory list, address account) internal pure returns (bool) { for (uint256 i; i < list.length; ++i) { if (list[i] == account) return true;