Skip to content

feat(utils): MoolahVaultAccount — principal-baselined account for the protocol lisUSD vault position - #229

Merged
razww merged 3 commits into
masterfrom
feat/moolah-vault-account
Aug 20, 2026
Merged

feat(utils): MoolahVaultAccount — principal-baselined account for the protocol lisUSD vault position#229
razww merged 3 commits into
masterfrom
feat/moolah-vault-account

Conversation

@qingyang-lista

@qingyang-lista qingyang-lista commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds MoolahVaultAccount, an upgradeable account contract that holds the protocol's own lisUSD MoolahVault position behind an explicit principal baseline, so a bot can harvest only the surplus above that baseline to whitelisted destinations. Today the position sits directly in the B0c6 multisig, which means every yield sweep is a manual multisig transaction and there is no on-chain record of what part of the position is corpus and what part is yield.

Change type

  • New contract
  • Upgrade (existing proxy)
  • Bug fix
  • Gas optimization
  • Configuration change
  • Migration / deploy script
  • Test
  • Dependency update

Contracts changed

Contract File Type
MoolahVaultAccount src/utils/MoolahVaultAccount.sol new
DeployMoolahVaultAccount script/utils/deploy_moolahVaultAccount.s.sol new
DeployMoolahVaultAccountImpl script/utils/deploy_moolahVaultAccount_impl.s.sol new
MockYieldVault test/utils/mocks/MockYieldVault.sol new (test only)
MoolahVaultAccountTest test/utils/MoolahVaultAccount.t.sol new (test only)
MoolahVaultAccountForkTest test/utils/MoolahVaultAccountFork.t.sol new (test only)

No existing contract is modified. The only non-Solidity change is .github/workflows/unit-tests.yaml, which adds MoolahVaultAccountForkTest to both the --no-match-contract list of the unit job and the --match-contract list of the fork job.

Interface changes

All new — this is a new contract.

Roles

Role Holder at deployment Powers
DEFAULT_ADMIN_ROLE protocol TimeLock _authorizeUpgrade, setPrincipalOwner, MANAGER administration
MANAGER B0c6 multisig principal in/out, principal correction, recipient whitelist, emergency exit, unpause, BOT/PAUSER administration
BOT harvest bot EOA claimYield only
PAUSER pauser Safe 0xEEfe…5Bd8, 1-of-15 pause only

External / public functions

  • initialize(address admin, address manager, address bot, address pauser, address vault, address principalOwner, uint256 principal, address[] yieldRecipients)
  • totalAssets() view returns (uint256)vault.convertToAssets(vault.balanceOf(this))
  • claimableYield() view returns (uint256)max(0, totalAssets() - principal)
  • previewClaim() view returns (uint256 claimable, uint256 withdrawable, uint256 claimableNow)
  • depositPrincipal(uint256 assets)MANAGER, nonReentrant, whenNotPaused
  • withdrawPrincipal(uint256 assets)MANAGER, nonReentrant, whenNotPaused
  • increasePrincipal(uint256 assets)MANAGER; raises the baseline when the position is topped up as shares instead of assets
  • claimYield(Payment[] payments)BOT, nonReentrant, whenNotPaused; one vault.withdraw plus a safeTransfer fan-out
  • emergencyWithdraw() returns (uint256 shares)MANAGER, nonReentrant; transfers all vault shares to principalOwner and zeroes principal
  • getYieldRecipients() view returns (address[]), addYieldRecipient(address), removeYieldRecipient(address)MANAGER
  • setPrincipalOwner(address)DEFAULT_ADMIN_ROLE
  • pause()PAUSER; unpause()MANAGER

Events: PrincipalDeposited, PrincipalWithdrawn, PrincipalIncreased, EmergencyWithdrawn, YieldClaimed, YieldPaid, AddYieldRecipient, RemoveYieldRecipient, SetPrincipalOwner.

Errors: ZeroAddress, ZeroAmount, AlreadySet, NoYieldRecipient, NotYieldRecipient, DuplicateRecipient, RecipientNotFound, ExceedsPrincipal, ExceedsClaimable, ZeroShares, WithdrawShortfall, SharesRemaining.

Storage layout

N/A for collision analysis — new contract, first deployment, no prior implementation to be compatible with.

For future upgrades: the contract inherits UUPSUpgradeable, AccessControlEnumerableUpgradeable, PausableUpgradeable and ReentrancyGuardUpgradeable from openzeppelin-contracts-upgradeable v5.2.0, all of which use ERC-7201 namespaced storage (e.g. AccessControlEnumerableStorageLocation = 0xc1f6…2000), so no parent occupies a sequential slot and no __gap is required. This contract's own variables occupy slots 0-5 in declaration order:

0  vault             IMoolahVault
1  asset             IERC20
2  principalOwner    address
3  principal         uint256
4  isYieldRecipient  mapping(address => bool)
5  yieldRecipients   address[]  (private)

New variables in a future implementation must be appended after yieldRecipients.

Access control

New contract, so every gate is new. Points a reviewer should check deliberately:

  • _authorizeUpgrade is onlyRole(DEFAULT_ADMIN_ROLE) and empty; the upgrade key is the protocol TimeLock, not the multisig.
  • emergencyWithdraw is onlyRole(MANAGER), not DEFAULT_ADMIN_ROLE. A 24-hour TimeLock-gated exit is not an emergency exit, and the funds are principalOwner's own corpus. The destination is pinned to principalOwner and cannot be passed in; setPrincipalOwner remains DEFAULT_ADMIN_ROLE-only, so MANAGER cannot redirect the exit. DEFAULT_ADMIN keeps the upgrade key and MANAGER role administration, so it can self-grant if B0c6 is unavailable.
  • emergencyWithdraw is not whenNotPaused — the incident that pauses this contract is the reason to call it. withdrawPrincipal is whenNotPaused: a halted contract must not let principal leave piecemeal, so while the pause holds the emergency exit is the only route out, and it cannot be walked down amount by amount. MANAGER holds unpause, so this is a speed bump for MANAGER rather than a trap.
  • MANAGER administers BOT and PAUSER, so a leaked bot key or a pauser key holding the contract down can be rotated at multisig speed instead of waiting on a 24-hour TimeLock proposal. DEFAULT_ADMIN_ROLE still administers MANAGER, so nothing leaves the TimeLock's reach.
  • claimYield can only pay whitelisted destinations, and the whitelist is MANAGER-only.
  • The constructor calls _disableInitializers(), so the implementation itself can never be initialized.

Risk assessment

Area Risk Note
Storage collision 🟢 None New contract, first deployment. Parents use ERC-7201 namespaced storage (OZ upgradeable v5.2.0); own variables occupy slots 0-5 with room to append.
Fund safety 🟡 Low The contract will custody ≈28.5 M lisUSD from day one. Corpus can only leave to principalOwner; yield can only leave to whitelisted destinations, capped by claimableYield(). principal is an explicit baseline that never floats with NAV, so a NAV drop cannot be harvested as yield. initialize sets the baseline at deployment, so the launch share transfer needs nothing else — increasePrincipal is NOT part of the launch, and calling it on top would count the same corpus twice against a baseline that cannot be lowered again. Residual operational risk applies to LATER share top-ups only: there the share transfer and increasePrincipal must ride in ONE Safe MultiSend, or the whole transferred position reads as claimable yield in between. Documented in NatSpec on increasePrincipal.
Access control 🟡 Low Four bare-name roles, one holder each at deployment, deployer holds none. MANAGER can force the position back to principalOwner at will but cannot change that destination. Upgrade authority sits with the TimeLock.
External call safety 🟢 None Only calls the lisUSD MoolahVault and SafeERC20 transfers of lisUSD / vault shares. All state-changing entrypoints are nonReentrant. principal is zeroed before the share transfer in emergencyWithdraw, and the post-transfer balance is re-read (SharesRemaining) because SafeERC20 checks the return value, not the effect.

Deployment-time front-running is closed structurally, which is the direct answer to the 2026-05-25 lisAster proxy hijack (root cause: a non-atomic deploy script that left a front-runnable window between deployment and initialization / ownership transfer):

  1. initialize calldata rides in the ERC1967Proxy constructor, so no block exists in which the proxy is deployed but uninitialized.
  2. The implementation burns its initializer in its constructor; the _impl script asserts this with a local call.
  3. Roles go straight to their final holders in initialize — no grantRole / revokeRole window, and the deployer never holds a role.
  4. abi.encodeCall type-checks the initializer arguments at compile time, and the post-broadcast require block asserts the resulting state.

Deployment

  • Chain: BSC mainnet (chain id 56).
  • Scripts: script/utils/deploy_moolahVaultAccount.s.sol:DeployMoolahVaultAccount deploys implementation plus initialized ERC1967Proxy in one run; script/utils/deploy_moolahVaultAccount_impl.s.sol:DeployMoolahVaultAccountImpl deploys an implementation only, for later TimeLock-driven upgrades (it must never touch the proxy).
  • Env vars: no new ones. PRIVATE_KEY via the existing DeployBase._deployerKey(); BSC_RPC for the fork tests.
  • Dry run (no --broadcast), both scripts SIMULATION COMPLETE:
deploy_moolahVaultAccount        gas 3,957,380  ≈ 0.000197869 BNB
deploy_moolahVaultAccount_impl   gas 2,949,857  ≈ 0.00014749285 BNB

The post-broadcast self-checks run inside the simulation: ERC-1967 implementation slot, all four role holders, member count 1 per role, deployer holds neither DEFAULT_ADMIN nor MANAGER, vault / principalOwner / principal, and two seeded yield recipients.

Not in this PR: the two operational steps that follow deployment — whitelisting the proxy on the vault through the Lending TimeLock (24-hour scheduleexecute), and moving ≈28.27 M shares out of B0c6. The share transfer is the whole of that step: initialize has already set principal to 28,300,000, so increasePrincipal must NOT be called at launch. An audit is planned before that share transfer.

Test plan

  • forge test --mc MoolahVaultAccountTest74 passed; 0 failed; 0 skipped
  • forge test --mc MoolahVaultAccountForkTest8 passed; 0 failed; 0 skipped (BSC fork at block 116,433,631)
  • npm run checkAll matched files use Prettier code style!
  • Both deploy scripts simulate against BSC mainnet without --broadcast and pass their own require self-checks
  • Fork tests assert the real vault's behaviour with exact selectors: ErrorsLib.NotEnoughLiquidity on an over-liquidity withdrawal, NotWhiteList on an un-whitelisted deposit, and that a share transfer into this contract needs no whitelist
  • Fork test confirms emergencyWithdraw moves shares at any liquidity level — a share transfer never walks the withdraw queue, so the exit is not bounded by vault liquidity

Local caveat, unrelated to this PR: test/utils/PositionMigrator.t.sol cannot compile in a git worktree because the lib/lista-dao-contracts.git submodule is not materialized there, so local runs used --skip PositionMigrator.t.sol. CI checks out submodules normally and is unaffected.

@hashdit-bot

hashdit-bot Bot commented Aug 17, 2026

Copy link
Copy Markdown

Pull Request Review

This Solidity PR adds a new UUPS-upgradeable MoolahVaultAccount that tracks a fixed lisUSD principal baseline, permits role-gated principal management and emergency exits, and allows a bot to distribute only surplus yield to manager-approved recipients. It also adds atomic deployment scripts, extensive unit and BSC fork tests, a mock ERC-4626 vault, and updates CI to run the new fork test separately.

Sensitive Content

No sensitive content detected.

Security Issues

No serious security issues detected.


Generated by Hashdit Bot. This tool can absolutely NOT replace manual audits.

@hashdit-bot

hashdit-bot Bot commented Aug 17, 2026

Copy link
Copy Markdown

Pull Request Review

This PR adds a new UUPS-upgradeable Solidity account for custodying the protocol’s lisUSD MoolahVault position, tracking a fixed principal baseline, harvesting surplus yield to manager-approved recipients, and supporting role-gated principal and emergency withdrawals. It also adds atomic deployment scripts, extensive unit and BSC fork tests with a mock yield vault, and updates CI to run the new fork test in the fork-test job.

Sensitive Content

No sensitive content detected.

Security Issues

No serious security issues detected.


Generated by Hashdit Bot. This tool can absolutely NOT replace manual audits.

… protocol lisUSD vault position

Holds the protocol's own lisUSD MoolahVault position behind an explicit `principal`
baseline so a bot can harvest only the surplus above it to whitelisted destinations.
Today the position sits directly in the B0c6 multisig: every yield sweep is a manual
multisig transaction and nothing on-chain separates corpus from yield.

Accounting: `totalAssets()` reads `vault.convertToAssets(vault.balanceOf(this))` so
accrued-but-unminted fee shares net out; `claimableYield()` is `max(0, totalAssets() -
principal)` and clamps at 0 because vault NAV is not monotonic. `principal` never floats
with NAV — it moves only through permissioned calls.

Roles: DEFAULT_ADMIN_ROLE (protocol TimeLock) holds the upgrade key, `setPrincipalOwner`
and role administration; MANAGER (B0c6) moves principal, corrects the baseline, owns the
recipient whitelist and can force the position out; BOT calls `claimYield` only; PAUSER
calls `pause` only. No exit lets its caller name a destination.

`emergencyWithdraw` transfers the vault shares themselves to `principalOwner` rather than
redeeming them, so it never walks the withdraw queue and is not bounded by vault
liquidity — `withdrawPrincipal` and `claimYield` can both revert NotEnoughLiquidity()
while it still succeeds.

Deployment is atomic: the `initialize` calldata rides in the `ERC1967Proxy` constructor,
the implementation burns its initializer, roles go straight to their final holders, and
the deployer never holds a role. That closes the window whose absence the 2026-05-25
lisAster proxy hijack depended on.

Tests: 72 unit tests against a mock vault with liquidity, whitelist, NAV and
withdraw-shortfall levers; 8 fork tests against the live lisUSD vault at block
116,433,631 asserting exact selectors. Both deploy scripts simulate on BSC mainnet and
pass their own post-broadcast self-checks.
@qingyang-lista
qingyang-lista force-pushed the feat/moolah-vault-account branch from e48a382 to a3390ee Compare August 17, 2026 18:10
@hashdit-bot

hashdit-bot Bot commented Aug 17, 2026

Copy link
Copy Markdown

Pull Request Review

This Solidity PR adds a new UUPS-upgradeable MoolahVaultAccount that tracks a fixed lisUSD vault principal, permits role-gated principal operations, and lets a bot distribute only surplus yield to manager-approved recipients. It also adds atomic proxy deployment scripts, extensive unit and BSC fork tests, a vault mock, and CI routing for the new fork test.

Sensitive Content

No sensitive content detected.

Security Issues

🟠 [HIGH] Direct share top-ups can be harvested as yield before the principal baseline is updated

File: src/utils/MoolahVaultAccount.sol
Vault shares can be transferred into the account independently of increasePrincipal(uint256). Between those transactions, their entire asset value is included in claimableYield(), allowing BOT to withdraw principal as apparent yield to a whitelisted recipient. Requiring an off-chain MultiSend reduces the operational risk but does not enforce the accounting invariant on-chain; a split, delayed, or partially executed operation can misallocate the transferred corpus. Additionally, the deployment script already initializes principal to 28.3M, so the PR description's instruction to bundle the initial share migration with another increasePrincipal call appears to double-count that initial baseline.
Recommendation: Add an atomic manager-only entry point that pulls a specified number of vault shares with transferFrom and raises the associated principal baseline in the same transaction, validating the received share balance. Alternatively, require the account to be paused before direct-share accounting changes and ensure claimYield cannot run until reconciliation completes. Clarify the initial migration procedure so it does not call increasePrincipal when the 28.3M baseline was already seeded during initialization.


Generated by Hashdit Bot. This tool can absolutely NOT replace manual audits.

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>
…-followup

fix(utils): MoolahVaultAccount audit follow-up
@hashdit-bot

hashdit-bot Bot commented Aug 18, 2026

Copy link
Copy Markdown

Pull Request Review

This Solidity/Foundry PR adds a new UUPS-upgradeable MoolahVaultAccount that tracks a fixed lisUSD principal baseline, permits role-gated principal management and emergency withdrawal, and allows a bot to distribute only surplus yield to manager-approved recipients. It also adds atomic deployment and implementation-upgrade scripts, extensive unit and BSC fork tests with a mock ERC-4626 vault, and updates CI to run the new fork test in the fork-test job.

Sensitive Content

No sensitive content detected.

Security Issues

No serious security issues detected.


Generated by Hashdit Bot. This tool can absolutely NOT replace manual audits.

@razww razww left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@razww
razww merged commit 78e7638 into master Aug 20, 2026
9 of 11 checks passed
@qingyang-lista
qingyang-lista deleted the feat/moolah-vault-account branch August 20, 2026 09:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants