fix(utils): MoolahVaultAccount audit follow-up - #230
Conversation
Four fixes from the PR #229 audit. 1. `withdrawPrincipal` is now `whenNotPaused`. A halted contract must not let principal leave piecemeal: while the pause holds, the only sanctioned exit is `emergencyWithdraw`, which takes the whole position back to `principalOwner` in one move and cannot be walked down amount by amount. `emergencyWithdraw` stays outside the pause — the incident that pauses this contract is the reason to call it — and MANAGER holds `unpause`, so this is a speed bump for MANAGER rather than a trap. 2. MANAGER is now the role admin of PAUSER as well as BOT. PAUSER on BSC is a 1-of-15 Safe, so any one of fifteen keys can halt `claimYield` and `depositPrincipal`; revoking a key that is holding the contract down must not wait on a 24h TimeLock proposal. The deploy script asserts both role-admin edges in its post-deploy self-check. 3. `increasePrincipal`'s NatSpec described the launch wrongly. The baseline comes from `initialize`, and at launch B0c6 only transfers its shares in — calling `increasePrincipal` on top of that would count the same corpus twice, and the baseline cannot be lowered again, so `claimableYield()` would sit at 0 for good. The doc now states that and names the two sanctioned ways to move the baseline afterwards: `depositPrincipal` / `withdrawPrincipal`, where the baseline follows the funds in the same call; or a share transfer paired with `increasePrincipal` in ONE transaction, where the MultiSend requirement applies. 4. The implementation deploy script's initializer-lock check was vacuous. It passes the deployer EOA as `_vault`, so `initialize` reverts at `IMoolahVault(_vault).asset()` whether or not the initializer was burned — `require(!initializable)` would have cleared an implementation with no `_disableInitializers()`. It now also asserts the revert data is `Initializable.InvalidInitialization`, which can only come from the burned initializer, before any argument is touched. Tests: 74 unit (2 new, 1 inverted for the pause semantics) and 8 fork tests pass; both deploy scripts simulate on BSC mainnet and pass their self-checks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pull Request ReviewThis Solidity/UUPS smart-contract PR adds pause enforcement to Sensitive ContentNo sensitive content detected. Security Issues🟡 [MEDIUM] PAUSER role administration is relaxed from DEFAULT_ADMIN_ROLE to MANAGER
Generated by Hashdit Bot. This tool can absolutely NOT replace manual audits. |
65ccb2e
into
feat/moolah-vault-account
Summary
Follow-up to
moolah#229from its audit. Four changes, no new contract and no new external function:withdrawPrincipalnow respects the pause, MANAGER becomes the role admin of PAUSER,increasePrincipal's NatSpec is corrected on what the launch sequence actually is, and the implementation deploy script's initializer-lock self-check is made non-vacuous.Targets
feat/moolah-vault-accountrather thanmastersomoolah#229keeps its own review history.Change type
Contracts changed
MoolahVaultAccountsrc/utils/MoolahVaultAccount.solDeployMoolahVaultAccountscript/utils/deploy_moolahVaultAccount.s.solDeployMoolahVaultAccountImplscript/utils/deploy_moolahVaultAccount_impl.s.solMoolahVaultAccountTesttest/utils/MoolahVaultAccount.t.sol1.
withdrawPrincipalis nowwhenNotPausedA halted contract must not let principal leave piecemeal. While the pause holds, the only sanctioned exit is
emergencyWithdraw, which takes the whole position back toprincipalOwnerin one move and cannot be walked down amount by amount.emergencyWithdrawdeliberately stays outside the pause — the incident that pauses this contract is the reason to call it — and MANAGER holdsunpause, so this is a speed bump for MANAGER rather than a trap on the corpus.This inverts the previous stance, and
test_withdrawPrincipal_worksWhenPausedwas inverted totest_withdrawPrincipal_revertsWhenPaused, which also asserts the emergency exit still works while paused and that MANAGER can lift the pause afterwards.2. MANAGER is now the role admin of PAUSER
_setRoleAdmin(PAUSER, MANAGER)joins the existing_setRoleAdmin(BOT, MANAGER).PAUSER at
0xEEfebb1546d88EA0909435DF6f615084DD3c5Bd8is a Safe withgetThreshold() == 1over 15 owners (the PR #229 body describes it as an EOA; it is not). Any one of fifteen keys can therefore haltclaimYieldanddepositPrincipal. Revoking such a key must not wait on a 24-hour TimeLock proposal, which is the same argument that already put BOT's administration with MANAGER.DEFAULT_ADMIN_ROLE (the protocol TimeLock) still administers MANAGER, so nothing is removed from the TimeLock's reach.
The main deploy script's post-deploy self-check now asserts both edges:
3.
increasePrincipalNatSpec corrected — it is not part of the launchThe old NatSpec read as if the launch share transfer had to be paired with
increasePrincipalin one MultiSend. It does not, and doing it would be harmful:initializealready setsprincipalto 28,300,000, so callingincreasePrincipal(28_300_000e18)after the share transfer would count the same corpus twice. The baseline cannot be lowered again — there is no setter,withdrawPrincipalwould revert because the vault cannot deliver 56.6 M, and onlyemergencyWithdraw(which empties the position and forces the whole launch to be redone) or an upgrade recovers it.claimableYield()would read 0 for good.The doc now states that the baseline comes from
initialize, that B0c6 only transfers shares at launch, and that there are exactly two sanctioned ways to move the baseline afterwards:depositPrincipal/withdrawPrincipal— lisUSD in or out, the baseline follows the funds in the same call, nothing to pair up;increasePrincipal, in ONE transaction — this is where the Safe MultiSend requirement applies.No code change; the on-chain behaviour is unchanged and deliberately unguarded, because raising the baseline can only shrink
claimableYield()and a bound on it would break legitimate top-ups after a loss.4. The implementation deploy script's initializer-lock check was vacuous
deploy_moolahVaultAccount_impl.s.solasserted the freshly deployed implementation cannot be initialized, by callinginitializewithdeployerin every address slot. Because_vaultis then an EOA,initializereverts atIMoolahVault(_vault).asset()— sorequire(!initializable)holds whether or not the constructor burned the initializer. An implementation with no_disableInitializers()would have cleared the check.It now also asserts the revert reason:
InvalidInitializationcan only come from theinitializermodifier, which runs before any argument is touched.Verified both directions during the audit: a stub with the same
initializeprologue and no initializer lock reverts with empty returndata, so the new assertion rejects it, while the real implementation reverts withInvalidInitializationand passes.test_implementation_revertsWithInvalidInitializationpins the selector in the unit suite.Interface changes
No signature changes. Behavioural changes:
withdrawPrincipalEnforcedPausewhile pausedgetRoleAdmin(PAUSER)DEFAULT_ADMIN_ROLEMANAGERStorage layout
Unchanged — no state variable added, removed or reordered.
forge inspect MoolahVaultAccount storage-layoutstill reports slots 0-5 asvault,asset,principalOwner,principal,isYieldRecipient,yieldRecipients.Access control
withdrawPrincipalgainswhenNotPaused;emergencyWithdrawdoes not.principalOwner, so this does not put any new fund path within its reach — it only lets it rotate a pause key at multisig speed.setPrincipalOwner, and administration of MANAGER.Risk assessment
withdrawPrincipalis strictly more restricted than before; the emergency exit is untouchedTest plan
forge test --mc MoolahVaultAccountTest→74 passed; 0 failed; 0 skipped(72 before;test_initialize_setsPauserRoleAdminToManagerandtest_implementation_revertsWithInvalidInitializationadded,test_withdrawPrincipal_worksWhenPausedinverted in place)forge test --mc MoolahVaultAccountForkTest→8 passed; 0 failed; 0 skipped(BSC fork at block 116,433,631)npm run check→All matched files use Prettier code style!script/utils/deploy_moolahVaultAccount.s.solsimulates on BSC mainnet without--broadcast,SIMULATION COMPLETE, gas 4,000,742 — the two new role-admin assertions passscript/utils/deploy_moolahVaultAccount_impl.s.solsimulates on BSC mainnet without--broadcast,SIMULATION COMPLETE, gas 2,961,101 — the new selector assertion passesLocal caveat carried over from
moolah#229:test/utils/PositionMigrator.t.solcannot compile in a git worktree because thelib/lista-dao-contracts.gitsubmodule is not materialized there, so local runs used--skip PositionMigrator.t.sol. CI checks out submodules normally and is unaffected.Audit findings not acted on
withdrawPrincipalhas no delivery check (claimYieldre-reads the asset balance,emergencyWithdrawre-reads the share balance,withdrawPrincipaldoes neither). Closed as won't-fix:MoolahVaultis a first-party contract and its logic is trusted, so the scenario requires an adversarial upgrade of the vault by the same protocol TimeLock that already holds this contract's upgrade key.requireself-checks run aftervm.stopBroadcast(), so with--broadcasta wrong constant is already on-chain when a check trips. Accepted: the value is in the dry run, which is always performed first.🤖 Generated with Claude Code