Skip to content

feat: add Merkl adapter#171

Open
BaptistG wants to merge 2 commits into
mainfrom
feat/MerklAdapter
Open

feat: add Merkl adapter#171
BaptistG wants to merge 2 commits into
mainfrom
feat/MerklAdapter

Conversation

@BaptistG

@BaptistG BaptistG commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

What's NOT from VaultV2

  • The base. VaultV2 is a ~1000-line from-scratch vault with its own accounting, virtual shares, transient storage, adapters, gates, timelocks. I built on OpenZeppelin's ERC4626Upgradeable instead. None of the deposit/withdraw/conversion/share-math is VaultV2 code — it's OZ's, plus my overrides.
  • The integration glue is entirely mine and untested by anyone: routing funds into the underlying 4626 (_deposit/_withdraw), the totalAssets() override reading convertToAssets, calling _accrueFee() then super.*, and the lastTotalAssets = totalAssets() bookkeeping. This is exactly where bugs live.
  • _accrueFee() is a re-derivation, not a copy. VaultV2 uses transient storage (firstTotalAssets) to accrue at most once per tx, plus a maxRate interest cap and per-recipient share gating. I dropped all of that. The fee-share formula looks similar because both mirror the standard 4626 conversion, but I wrote it against OZ's _decimalsOffset() model — different from VaultV2's virtual-shares constant.
  • So "is it just their audited code?" — no. VaultV2's audits do not transfer to this.

The specific risks an auditor needs to look at

  • totalAssets() uses underlyingVault.convertToAssets(...). That's a spot price. If the underlying vault's share price can be manipulated within a block (donation, flash-loan-driven rate moves), interest accrual and conversions can be gamed. VaultV2's maxRate cap exists partly to blunt this; I removed it.
  • Inflation/donation attack on first deposit. I left OZ's default _decimalsOffset() = 0 (only +1 virtual share). For a fresh vault that's the known-weak setting. Worth a larger offset or a seed deposit.
  • Double-layered rounding (adapter shares ↔ adapter assets ↔ underlying shares ↔ underlying assets). Each mulDiv rounds; the directions need to be provably in the vault's favor so the last withdrawer can't be bricked or a rounding drain accumulate.
  • Reentrancy / ERC-4626 quirks of the specific underlying you point at — fee-on-deposit, withdraw fees, ERC-777-style callbacks, non-standard rounding. "Compatible with any ERC4626" is a strong claim; in practice each integration should be checked.
  • Upgradeability: it's a UUPS-style proxy without an _authorizeUpgrade / UUPSUpgradeable — meaning as written it's not actually upgradeable (no upgrade entrypoint), which may or may not be what you intend. Storage layout for future upgrades also unreviewed.

Copilot AI review requested due to automatic review settings June 23, 2026 09:25

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds a new MerklAdapter ERC4626 wrapper that forwards assets into an underlying ERC4626 vault while accruing performance (on interest) and management (time-based) fees via share dilution to a feeRecipient.

Changes:

  • Introduces MerklAdapter.sol as an upgradeable ERC4626 wrapper around an underlying ERC4626 vault.
  • Implements fee accrual (_accrueFee) that mints adapter shares to a fee recipient based on interest earned and elapsed time.
  • Adds owner-governed setters for performance fee, management fee, and fee recipient.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +187 to +188
underlyingVault.withdraw(assets, receiver, address(this));
lastTotalAssets = totalAssets();
// ================================= GOVERNANCE =================================

/// @notice Updates the performance fee (capped at 100%). Accrues outstanding fees first.
function setPerformanceFee(uint256 _performanceFee) external onlyOwner {
}

/// @notice Updates the management fee (per-second rate). Accrues outstanding fees first.
function setManagementFee(uint256 _managementFee) external onlyOwner {
}

/// @notice Updates the recipient of both fees. Accrues outstanding fees to the old recipient first.
function setFeeRecipient(address _feeRecipient) external onlyOwner {
Comment on lines +136 to +140
function totalAssets() public view override returns (uint256) {
return
underlyingVault.convertToAssets(underlyingVault.balanceOf(address(this))) +
IERC20Upgradeable(asset()).balanceOf(address(this));
}
Comment on lines +199 to +231
function _accrueFee() internal {
uint256 newTotalAssets = totalAssets();
uint256 elapsed = block.timestamp - lastUpdate;

uint256 feeAssets;
if (newTotalAssets > lastTotalAssets && performanceFee != 0) {
uint256 interest = newTotalAssets - lastTotalAssets;
feeAssets = interest.mulDiv(performanceFee, BASE_18);
}
if (elapsed != 0 && managementFee != 0) {
feeAssets += newTotalAssets.mulDiv(managementFee * elapsed, BASE_18);
}
// Fees can never exceed the assets actually under management.
if (feeAssets > newTotalAssets) feeAssets = newTotalAssets;

uint256 feeShares;
if (feeAssets != 0) {
uint256 assetsAfterFees = newTotalAssets - feeAssets;
// Mirrors ERC4626 `_convertToShares` but against the post-fee asset base, so that the minted
// shares are worth `feeAssets` once they dilute the existing supply.
feeShares = feeAssets.mulDiv(
totalSupply() + 10 ** _decimalsOffset(),
assetsAfterFees + 1,
MathUpgradeable.Rounding.Down
);
if (feeShares != 0) _mint(feeRecipient, feeShares);
}

lastTotalAssets = newTotalAssets;
lastUpdate = block.timestamp;

emit FeesAccrued(feeShares, newTotalAssets);
}
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