Skip to content

Add ERC-7535 Native Asset Tokenized Vault#245

Open
0xAdriaTorralba wants to merge 3 commits into
OpenZeppelin:masterfrom
0xAdriaTorralba:feat/erc7535-native-vault
Open

Add ERC-7535 Native Asset Tokenized Vault#245
0xAdriaTorralba wants to merge 3 commits into
OpenZeppelin:masterfrom
0xAdriaTorralba:feat/erc7535-native-vault

Conversation

@0xAdriaTorralba

@0xAdriaTorralba 0xAdriaTorralba commented Jun 12, 2026

Copy link
Copy Markdown

This PR adds an implementation of ERC-7535: Native Asset ERC-4626 Tokenized Vault — an adaptation of ERC-4626 in which the underlying asset is the chain's native asset (e.g. Ether) instead of an ERC-20 token. It complements the vault standards already in this repository (ERC4626Fees, ERC7540, IERC7575).

What's included

  • contracts/interfaces/IERC7535.sol — the full ERC-4626 interface surface with payable deposit/mint. It intentionally does not inherit IERC4626: Solidity does not allow a payable function to override a nonpayable one.
  • contracts/token/ERC20/extensions/ERC7535.sol — abstract base implementation mirroring the structure, rounding directions, internal seams and virtual-offset anti-inflation math of the ERC4626 implementation in @openzeppelin/contracts.
  • contracts/mocks/token/ERC7535OffsetMock.sol — mock with configurable decimals offset.
  • Hardhat + Foundry test suites, a documentation guide (docs/modules/ROOT/pages/erc7535.adoc), API doc registrations, and a changelog entry.

Design decisions

  • asset() returns the ERC-7528 native-asset placeholder (0xEeee…EEeE). Per ERC-7528/7535, a vault backed by a wrapped native asset (e.g. WETH9) must use plain ERC-4626 instead; this is documented as an IMPORTANT note.
  • totalAssets() is balance-based (address(this).balance). Because deposit/mint are payable, the in-flight msg.value is already part of the balance when the exchange rate would be computed — naively using totalAssets() would misprice every deposit. The conversion math therefore prices against an internal _pretotalAssets() (= totalAssets() - msg.value), while all public previews use totalAssets() directly. Result: a standalone previewDeposit(assets) returns exactly the shares a subsequent deposit mints.
  • Exact msg.value: deposit(assets, receiver) requires msg.value == assets, and mint(shares, receiver) requires msg.value == previewMint(shares), reverting with typed errors otherwise. ERC-7535 allows ignoring the assets argument, but ignoring it lets a buggy integrator silently desync intent from payment; a refund path would introduce an outbound native call inside the deposit flow (reentrancy / refund-griefing surface). Strict equality keeps the Deposit event provably consistent with the wei that entered the vault. The guide documents why deposit should be preferred over mint for ETH-in flows.
  • Reentrancy: withdrawals/redemptions send value via Address.sendValue (full gas, keeping smart-contract receivers like multisigs and AA wallets compatible) and rely on checks-effects-interactions — allowance spent and shares burned before the outbound transfer — matching ERC4626's approach rather than adding a reentrancy guard.
  • Inflation attack: same mitigation as ERC4626 — virtual shares/assets parameterized by _decimalsOffset(). Note that a native-asset vault can always be force-fed balance (SELFDESTRUCT, block-reward/coinbase payments) bypassing any receive restriction, so the offset math — not balance gating — is the defense. The NatSpec documents that overrides must keep offset <= 77: 10 ** offset overflows uint256 beyond that, which would brick every conversion (the uint8 decimals() bound alone would misleadingly allow up to 237). Regression tests pin both sides of the 77/78 boundary.
  • Unsolicited transfers: receive() reverts with a named error (ERC7535UnsolicitedDeposit) so plain transfers fail loudly instead of silently skewing the exchange rate. It is virtual, so integrators that need plain native-asset payments to land (e.g. a rewards distributor on a chain without beacon-layer withdrawals) can deliberately open it.

Testing

  • Hardhat (67 tests): parameterized over offsets [0, 6, 18] across empty / donated / populated vault states; msg.value mismatch reverts; unsolicited-transfer rejection; outbound-send failure to a reverting receiver; decimals/offset boundaries (237-ceiling overflow of decimals(), 77/78 conversion boundary); yield-accrual scenario adapted from the ERC-4626 suite.
  • Foundry (20 unit/fuzz + 2 invariant tests): preview/actual equivalence under non-trivial state, inflation-attack non-profitability at offset 0, deposit/mint round-trip contraction, force-fed balance accounting, CEI reentrancy tests with a malicious receiver that re-enters during the ETH push, and an invariant suite (invariantSolvency, invariantNoValueCreation) driven by a randomized handler (5000 runs × 500 calls, 0 reverts).

All repo checks pass locally: npm run lint, Hardhat + Foundry suites, test:inheritance, test:pragma, test:generation, and docgen ({{ERC7535}} / {{IERC7535}} placeholders resolve).

PR Checklist

  • Tests
  • Documentation
  • Changelog entry

Summary by CodeRabbit

  • New Features

    • Added ERC-7535 native-asset vault implementation supporting deposit, mint, withdraw, and redeem operations with native assets.
    • Includes inflation attack mitigation via configurable virtual shares.
    • Provides a reverting receive handler to reject unsolicited native transfers.
  • Documentation

    • Added comprehensive ERC-7535 documentation with integration guidelines.
    • Updated interface and extension references.
  • Tests

    • Added extensive unit, fuzz, and invariant test coverage for vault accounting, reentrancy safety, and edge cases.

@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

This PR adds ERC-7535, a native-asset ERC-4626 tokenized vault pattern. It introduces the IERC7535 interface, an ERC7535 implementation with payable deposit/mint, exact msg.value validation, virtual-offset inflation mitigation, and reentrancy-safe CEI ordering, plus Foundry/Hardhat test suites and comprehensive documentation.

Changes

ERC-7535 Native Asset Vault

Layer / File(s) Summary
IERC7535 Interface Definition
contracts/interfaces/IERC7535.sol, contracts/interfaces/README.adoc, contracts/token/README.adoc
IERC7535 interface defines the native-asset vault API extending IERC20 with conversion, preview, accounting, and payable/non-payable deposit/mint/withdraw/redeem functions; READMEs updated to document the interface and extension.
ERC7535 Contract Scaffold and Metadata
contracts/token/ERC20/extensions/ERC7535.sol (lines 1–143)
ERC7535 contract scaffolding, custom error definitions, and public metadata functions (asset(), decimals(), totalAssets()) for vault identity and balance accounting.
Public Conversions, Limits, and Previews
contracts/token/ERC20/extensions/ERC7535.sol (lines 144–193)
Conversion functions with configurable rounding, max deposit/mint/withdraw/redeem limits, and preview functions for all vault entry/exit operations.
Deposit, Mint, Withdraw, and Redeem Implementation
contracts/token/ERC20/extensions/ERC7535.sol (lines 194–252)
Payable deposit and mint with exact msg.value validation; non-payable withdraw and redeem with allowance enforcement and reentrancy-safe CEI ordering (burn before send).
Internal Conversion Math and Asset Accounting
contracts/token/ERC20/extensions/ERC7535.sol (lines 253–277)
Internal conversion helpers and _pretotalAssets() that excludes in-flight msg.value for consistent preview pricing across payable calls.
Internal Workflows, Hooks, and Receive Guard
contracts/token/ERC20/extensions/ERC7535.sol (lines 278–336)
Internal _deposit and _withdraw workflows, overridable transfer hooks, configurable virtual offset for inflation mitigation, and reverting receive() guard.
Mock Contracts and Test Support
contracts/mocks/token/ERC7535OffsetMock.sol, contracts/mocks/import.sol
ERC7535OffsetMock for testing offset behavior, and EtherReceiverMock import for test infrastructure.
Foundry Test Suite: Unit Tests and Vault Mock
test/token/ERC20/extensions/ERC7535.t.sol (lines 1–217)
ERC7535VaultMock concrete implementation and basic unit tests covering asset identity, decimals arithmetic, totalAssets tracking, and msg.value mismatch validation.
Foundry Test Suite: Correctness and Reentrancy Safety
test/token/ERC20/extensions/ERC7535.t.sol (lines 218–532)
ReentrantReceiver for CEI validation, fuzz tests for conversion correctness and rounding properties, inflation non-profitability, force-fed ETH accounting, and reentrancy protection against over-extraction.
Foundry Test Suite: Invariant Testing
test/token/ERC20/extensions/ERC7535.t.sol (lines 535–649)
RevertingReceiver, ERC7535Handler, and ERC7535InvariantTest asserting solvency (vault balance covers redemption) and value creation bounds (redeemable ≤ deposits + donations).
Hardhat/JavaScript Test Suite
test/token/ERC20/extensions/ERC7535.test.js
Comprehensive JavaScript tests covering asset identity, decimals composition, unsolicited transfer rejection, offset boundary panics, send failures, parameterized flows (offsets 0/6/18), and yield accrual scenarios.
Documentation and Changelog Integration
CHANGELOG.md, docs/modules/ROOT/nav.adoc, docs/modules/ROOT/pages/erc7535.adoc
Changelog entry, navigation link, and ERC-7535 documentation page covering native-asset semantics, inflation attack mitigation, reentrancy behavior, and customization invariants.

Sequence Diagram

sequenceDiagram
  participant User
  participant Vault as ERC7535 Vault
  participant Balance as Native Balance

  User->>Vault: deposit(assets, receiver)<br/>{value: assets}
  activate Vault
    Vault->>Vault: _pretotalAssets()<br/>(excludes msg.value)
    Vault->>Vault: convertToShares(assets)<br/>(floor rounding)
    Vault->>Vault: _deposit(shares, receiver)
    Vault->>Vault: _transferIn()<br/>(default: no-op)
    Vault->>Vault: mint(shares, receiver)
    Vault->>Vault: emit Deposit
  deactivate Vault
  
  User->>Vault: withdraw(assets, receiver, owner)
  activate Vault
    Vault->>Vault: previewWithdraw(assets)<br/>(ceil to shares)
    Vault->>Vault: _withdraw(shares, receiver, owner)
    Vault->>Vault: _approve burn allowance
    Vault->>Vault: burn(shares)
    Vault->>Balance: _transferOut (sendValue)
    Vault->>Vault: emit Withdraw
  deactivate Vault
  
  User->>Vault: receive() {value}
  Vault-->>User: revert ERC7535UnsolicitedDeposit
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Suggested reviewers

  • ernestognw
  • Amxx

Poem

🐰 A native vault hops into place,
ERC-7535 joins the race!
Deposits payable, with strict value care,
Withdrawals safe—no reentrancy snare,
Inflation attacks? Offset deflates the fright,
Another vault paradigm, shining so bright! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Add ERC-7535 Native Asset Tokenized Vault' clearly and accurately summarizes the main change: implementing ERC-7535 support by adding the interface, abstract implementation, mocks, tests, and documentation.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@0xAdriaTorralba 0xAdriaTorralba marked this pull request as ready for review June 12, 2026 10:44
@0xAdriaTorralba 0xAdriaTorralba requested a review from a team as a code owner June 12, 2026 10:44
Copilot AI review requested due to automatic review settings June 12, 2026 10:44

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

This PR introduces ERC-7535 support to the codebase by adding a native-asset (e.g., ETH) tokenized vault interface and an ERC-4626-like base implementation, plus accompanying documentation and test suites (Hardhat + Foundry).

Changes:

  • Added IERC7535 (ERC-7535 interface surface with payable deposit/mint) and ERC7535 (native-asset vault base implementation with pre-msg.value accounting).
  • Added mocks and test coverage (Hardhat behavioral suite + Foundry unit/fuzz + invariants).
  • Added docs, API doc registrations, navigation entry, and a changelog entry.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
test/token/ERC20/extensions/ERC7535.test.js Hardhat test suite for ERC-7535 behavior (msg.value enforcement, offset bounds, send failure, etc.).
test/token/ERC20/extensions/ERC7535.t.sol Foundry unit/fuzz + invariant tests for ERC-7535 (preview equivalence, CEI reentrancy, solvency invariants).
docs/modules/ROOT/pages/erc7535.adoc New documentation guide describing ERC-7535 semantics and differences from ERC-4626.
docs/modules/ROOT/nav.adoc Adds ERC-7535 to the docs navigation under Tokens.
contracts/token/README.adoc Registers ERC7535 for token docs and API placeholder expansion.
contracts/token/ERC20/extensions/ERC7535.sol Core ERC-7535 abstract implementation (native-asset vault logic + virtual-offset math + CEI withdrawals).
contracts/mocks/token/ERC7535OffsetMock.sol Mock vault with configurable decimals offset for testing.
contracts/mocks/import.sol Imports EtherReceiverMock so it’s available for tests exercising ETH send failures.
contracts/interfaces/README.adoc Registers IERC7535 in the interfaces documentation and docgen placeholders.
contracts/interfaces/IERC7535.sol New ERC-7535 interface definition.
CHANGELOG.md Adds changelog entry for IERC7535/ERC7535 introduction.

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

@@ -0,0 +1,229 @@
// SPDX-License-Identifier: MIT

pragma solidity >=0.6.2;
Comment on lines +106 to +108
/**
* @dev Mints `shares` Vault shares to `receiver` by depositing exactly `msg.value` of the native asset.
*
Comment on lines +20 to +22
* This extension allows the minting and burning of "shares" (represented using the ERC-20 inheritance) in exchange for
* the native asset through standardized {deposit}, {mint}, {redeem} and {burn} workflows. This contract extends the
* ERC-20 standard. Any additional extensions included along it would affect the "shares" token represented by this

pragma solidity ^0.8.24;

import {IERC20, IERC20Metadata, ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
Comment on lines +402 to +407
it('withdraw with approval', async function () {
const assets = await this.vault.previewWithdraw(parseAsset(1n));

await expect(this.vault.connect(this.other).withdraw(parseAsset(1n), this.recipient, this.holder))
.to.be.revertedWithCustomError(this.vault, 'ERC20InsufficientAllowance')
.withArgs(this.other, 0n, assets);
Comment on lines +36 to +51
ERC7535VaultMock public vault;
Kind public kind;
uint256 public reenterShares; // shares the receiver tries to re-redeem during the callback

bool public reentered;
bool public reentryReverted;

// State snapshot observed *inside* the reentrant callback (i.e. mid-`_withdraw`, after the burn).
uint256 public observedSelfBalance;
uint256 public observedTotalSupply;

function setup(ERC7535VaultMock vault_, Kind kind_, uint256 reenterShares_) external {
vault = vault_;
kind = kind_;
reenterShares = reenterShares_;
}
Comment on lines +71 to +77
function reenter() external {
if (kind == Kind.Withdraw) {
vault.withdraw(reenterShares, address(this), address(this));
} else if (kind == Kind.Redeem) {
vault.redeem(reenterShares, address(this), address(this));
}
}
Comment on lines +383 to +389
// The attacker will try to re-redeem its FULL share balance again during the payout callback.
r.setup(vault, kind, attackerShares);

uint256 vaultBalBefore = address(vault).balance;
uint256 totalSupplyBefore = vault.totalSupply();
uint256 expectedPayout = vault.previewRedeem(attackerShares);

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 4

🧹 Nitpick comments (1)
test/token/ERC20/extensions/ERC7535.test.js (1)

172-176: ⚡ Quick win

Pin the exact Withdraw payload in the zero-address documenting test.

This case is meant to lock in current behavior, but it only checks that some Withdraw event fired. As written, a regression in the emitted receiver/owner/assets/shares would still pass.

Suggested assertion
       const value = ethers.parseEther('1');
       const tx = this.vault.connect(this.holder).withdraw(value, ethers.ZeroAddress, this.holder);
       await expect(tx).to.changeEtherBalances([this.vault, ethers.ZeroAddress], [-value, value]);
-      await expect(tx).to.emit(this.vault, 'Withdraw');
+      await expect(tx)
+        .to.emit(this.vault, 'Withdraw')
+        .withArgs(this.holder, ethers.ZeroAddress, this.holder, value, value);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/token/ERC20/extensions/ERC7535.test.js` around lines 172 - 176, Update
the test that calls this.vault.connect(this.holder).withdraw(...) to assert the
exact Withdraw event payload: ensure the expectation includes
withArgs(ethers.ZeroAddress, <holder address>, <assets expected>, <shares
expected>) (e.g. await expect(tx).to.emit(this.vault,
'Withdraw').withArgs(ethers.ZeroAddress, await this.holder.getAddress(), value,
value)); reference the withdraw call and the 'Withdraw' event on this.vault and
replace the loose .to.emit check with the .withArgs assertion that pins
receiver, owner, assets, and shares.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@contracts/token/ERC20/extensions/ERC7535.sol`:
- Around line 20-22: The NatSpec public API text incorrectly lists the exit
workflows as {redeem} and {burn}; update the comment to reference {withdraw}
instead of {burn} so the docs match the actual external functions ({deposit},
{mint}, {redeem}, {withdraw}) exposed by this contract (look for the ERC7535
contract/public API description and replace `{burn}` with `{withdraw}`).
- Around line 135-252: The NatSpec header for ERC7535 incorrectly references
{burn} even though the public exit API uses withdraw/redeem; update the
contract/interface documentation on ERC7535 (the top-of-file/header block for
the ERC7535 contract) to replace or remove the `{burn}` reference and instead
reference the public exit functions `withdraw` and `redeem` (or simply document
the exit API as withdraw/redeem), keeping all function names like deposit, mint,
withdraw, redeem, previewWithdraw, previewRedeem consistent with IERC7535.

In `@test/token/ERC20/extensions/ERC7535.t.sol`:
- Around line 370-391: The test helper _assertReentrancyCEI always calls
vault.redeem(...) for the outer payout, so the Kind.Withdraw case never
exercises the vault.withdraw entrypoint; update _assertReentrancyCEI to branch
on ReentrantReceiver.Kind and call vault.withdraw(expectedPayout, address(r),
address(r)) when kind == ReentrantReceiver.Kind.Withdraw, otherwise keep the
existing vault.redeem(attackerShares, address(r), address(r)) call; adjust any
local variables used (e.g., expectedPayout vs attackerShares) so the correct
argument is passed for each branch.

In `@test/token/ERC20/extensions/ERC7535.test.js`:
- Line 194: The tests currently approve ethers.MaxUint256 via $_approve and only
assert authorization; change the delegated withdraw/redeem tests to approve a
finite amount equal to the shares being withdrawn (use the same shares value
used in the call), then after the delegated call assert the allowance was
reduced to zero (or attempt a second delegated call and assert it reverts with
ERC20InsufficientAllowance) to verify ERC7535._withdraw calls
_spendAllowance(owner, caller, shares). Also tighten the receiver == address(0)
test by asserting the Withdraw event's full arguments (owner,
receiver==address(0), shares, amount) rather than only checking ETH balance and
that the event emitted.

---

Nitpick comments:
In `@test/token/ERC20/extensions/ERC7535.test.js`:
- Around line 172-176: Update the test that calls
this.vault.connect(this.holder).withdraw(...) to assert the exact Withdraw event
payload: ensure the expectation includes withArgs(ethers.ZeroAddress, <holder
address>, <assets expected>, <shares expected>) (e.g. await
expect(tx).to.emit(this.vault, 'Withdraw').withArgs(ethers.ZeroAddress, await
this.holder.getAddress(), value, value)); reference the withdraw call and the
'Withdraw' event on this.vault and replace the loose .to.emit check with the
.withArgs assertion that pins receiver, owner, assets, and shares.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5e6fbbc1-af83-4b2c-89a1-2a3d3cc04e5e

📥 Commits

Reviewing files that changed from the base of the PR and between f7e5f08 and 0312e3d.

📒 Files selected for processing (11)
  • CHANGELOG.md
  • contracts/interfaces/IERC7535.sol
  • contracts/interfaces/README.adoc
  • contracts/mocks/import.sol
  • contracts/mocks/token/ERC7535OffsetMock.sol
  • contracts/token/ERC20/extensions/ERC7535.sol
  • contracts/token/README.adoc
  • docs/modules/ROOT/nav.adoc
  • docs/modules/ROOT/pages/erc7535.adoc
  • test/token/ERC20/extensions/ERC7535.t.sol
  • test/token/ERC20/extensions/ERC7535.test.js

Comment on lines +20 to +22
* This extension allows the minting and burning of "shares" (represented using the ERC-20 inheritance) in exchange for
* the native asset through standardized {deposit}, {mint}, {redeem} and {burn} workflows. This contract extends the
* ERC-20 standard. Any additional extensions included along it would affect the "shares" token represented by this

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Replace {burn} with {withdraw} in the public API description.

The NatSpec says the standardized exit workflows are {redeem} and {burn}, but this contract exposes {withdraw}/{redeem}. That mismatch will leak into generated docs and misdescribe the external surface.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contracts/token/ERC20/extensions/ERC7535.sol` around lines 20 - 22, The
NatSpec public API text incorrectly lists the exit workflows as {redeem} and
{burn}; update the comment to reference {withdraw} instead of {burn} so the docs
match the actual external functions ({deposit}, {mint}, {redeem}, {withdraw})
exposed by this contract (look for the ERC7535 contract/public API description
and replace `{burn}` with `{withdraw}`).

Comment on lines +135 to +252
function asset() public view virtual returns (address) {
return NATIVE_ASSET;
}

/// @inheritdoc IERC7535
function totalAssets() public view virtual returns (uint256) {
return address(this).balance;
}

/// @inheritdoc IERC7535
function convertToShares(uint256 assets) public view virtual returns (uint256) {
return _convertToShares(assets, Math.Rounding.Floor);
}

/// @inheritdoc IERC7535
function convertToAssets(uint256 shares) public view virtual returns (uint256) {
return _convertToAssets(shares, Math.Rounding.Floor);
}

/// @inheritdoc IERC7535
function maxDeposit(address) public view virtual returns (uint256) {
return type(uint256).max;
}

/// @inheritdoc IERC7535
function maxMint(address) public view virtual returns (uint256) {
return type(uint256).max;
}

/// @inheritdoc IERC7535
function maxWithdraw(address owner) public view virtual returns (uint256) {
return previewRedeem(maxRedeem(owner));
}

/// @inheritdoc IERC7535
function maxRedeem(address owner) public view virtual returns (uint256) {
return balanceOf(owner);
}

/// @inheritdoc IERC7535
function previewDeposit(uint256 assets) public view virtual returns (uint256) {
return _convertToShares(assets, Math.Rounding.Floor);
}

/// @inheritdoc IERC7535
function previewMint(uint256 shares) public view virtual returns (uint256) {
return _convertToAssets(shares, Math.Rounding.Ceil);
}

/// @inheritdoc IERC7535
function previewWithdraw(uint256 assets) public view virtual returns (uint256) {
return _convertToShares(assets, Math.Rounding.Ceil);
}

/// @inheritdoc IERC7535
function previewRedeem(uint256 shares) public view virtual returns (uint256) {
return _convertToAssets(shares, Math.Rounding.Floor);
}

/// @inheritdoc IERC7535
function deposit(uint256 assets, address receiver) public payable virtual returns (uint256) {
if (msg.value != assets) {
revert ERC7535UnexpectedDepositValue(msg.value, assets);
}

uint256 maxAssets = maxDeposit(receiver);
if (assets > maxAssets) {
revert ERC7535ExceededMaxDeposit(receiver, assets, maxAssets);
}

uint256 shares = _convertToShares(assets, Math.Rounding.Floor);
_deposit(_msgSender(), receiver, assets, shares);

return shares;
}

/// @inheritdoc IERC7535
function mint(uint256 shares, address receiver) public payable virtual returns (uint256) {
uint256 maxShares = maxMint(receiver);
if (shares > maxShares) {
revert ERC7535ExceededMaxMint(receiver, shares, maxShares);
}

uint256 assets = _convertToAssets(shares, Math.Rounding.Ceil);
if (msg.value != assets) {
revert ERC7535UnexpectedMintValue(msg.value, assets);
}

_deposit(_msgSender(), receiver, assets, shares);

return assets;
}

/// @inheritdoc IERC7535
function withdraw(uint256 assets, address receiver, address owner) public virtual returns (uint256) {
uint256 maxAssets = maxWithdraw(owner);
if (assets > maxAssets) {
revert ERC7535ExceededMaxWithdraw(owner, assets, maxAssets);
}

uint256 shares = previewWithdraw(assets);
_withdraw(_msgSender(), receiver, owner, assets, shares);

return shares;
}

/// @inheritdoc IERC7535
function redeem(uint256 shares, address receiver, address owner) public virtual returns (uint256) {
uint256 maxShares = maxRedeem(owner);
if (shares > maxShares) {
revert ERC7535ExceededMaxRedeem(owner, shares, maxShares);
}

uint256 assets = previewRedeem(shares);
_withdraw(_msgSender(), receiver, owner, assets, shares);

return assets;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

Does Solidity 0.8.x require the override specifier on a contract function that implements a function declared in an inherited interface?

💡 Result:

In Solidity 0.8.x, the requirement for the override specifier depends on the specific minor version and the inheritance structure: - Starting from Solidity 0.8.8, the override keyword is not required when implementing a function declared in a single interface [1][2]. - In versions prior to 0.8.8, the override keyword was mandatory for all interface function implementations [2][3]. - Regardless of the version, if a contract inherits the same function from multiple bases (e.g., multiple interfaces or a mix of contracts and interfaces) that define the same function, the override keyword is required, and you must explicitly specify the base contracts in the override specifier, such as override(Base1, Base2) [1][4][5]. In summary, for Solidity versions 0.8.8 and later, you only need the override specifier when the inheritance hierarchy creates ambiguity that requires explicit resolution [1][2].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate pragma + contract declaration
FILE="contracts/token/ERC20/extensions/ERC7535.sol"
if [ ! -f "$FILE" ]; then
  echo "Missing file: $FILE" >&2
  exit 1
fi

echo "---- $FILE: header (first 80 lines) ----"
sed -n '1,90p' "$FILE" | cat -n

echo "---- $FILE: contract declaration / inheritance ----"
rg -n "contract\s+ERC7535" "$FILE" && rg -n "is\s+IERC7535|is\s+.*IERC7535" "$FILE" || true

echo "---- $FILE: function signatures in 130-260 ----"
sed -n '120,270p' "$FILE" | cat -n

# Find IERC7535 interface to confirm exact function signatures and whether override is expected
# Search for interface IERC7535 in repo
echo "---- Searching for IERC7535 ----"
fd -t f "IERC7535.sol" . || true
rg -n "interface\s+IERC7535" . || true

Repository: OpenZeppelin/openzeppelin-community-contracts

Length of output: 13812


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read IERC7535 interface around the relevant function declarations
FILE="contracts/interfaces/IERC7535.sol"
echo "---- $FILE (first 220 lines) ----"
sed -n '1,220p' "$FILE" | cat -n

echo "---- Search for function declarations in IERC7535 for deployer/public API ----"
rg -n "function\s+(asset|totalAssets|convertToShares|convertToAssets|maxDeposit|maxMint|maxWithdraw|maxRedeem|previewDeposit|previewMint|previewWithdraw|previewRedeem|deposit|mint|withdraw|redeem|burn)\b" "$FILE"

# Search for burn in ERC7535
FILE2="contracts/token/ERC20/extensions/ERC7535.sol"
echo "---- Search for burn in $FILE2 ----"
rg -n "\bfunction\s+burn\b|\b burn\(" "$FILE2" || true

Repository: OpenZeppelin/openzeppelin-community-contracts

Length of output: 14908


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect rest of IERC7535 around the "burn" section
FILE="contracts/interfaces/IERC7535.sol"
echo "---- Tail of $FILE ----"
sed -n '200,280p' "$FILE" | cat -n

# Exact search for burn in the interface
echo "---- Search burn in IERC7535 ----"
rg -n "\bfunction\s+burn\b" "$FILE" || true

# Search burn usage/definition in ERC7535
FILE2="contracts/token/ERC20/extensions/ERC7535.sol"
echo "---- Search burn in ERC7535 ----"
rg -n "\bfunction\s+burn\b|_burn\(" "$FILE2" || true

Repository: OpenZeppelin/openzeppelin-community-contracts

Length of output: 2146


🌐 Web query:

Solidity 0.8.24 requirement override keyword implementing interface function: is override optional when inheriting from interface only? wording "override keyword is optional" and rules for multiple bases

💡 Result:

In Solidity 0.8.24, the override keyword is optional when implementing a function that comes from a single interface [1]. Key rules regarding this behavior and multiple inheritance are: 1. Optionality: Starting from Solidity 0.8.8, if a function implementation overrides only a single interface function, the override keyword is not required [2][1]. 2. Mandatory Override: The override keyword remains mandatory when overriding a function defined in a contract (base class), or when a function is defined in multiple base contracts [2][3]. 3. Multiple Bases: When a contract inherits the same function from multiple base contracts, you must use the override keyword and explicitly specify the base contracts being overridden [2][3]. The syntax is override(Base1, Base2,...), where you list all base contracts that define the same function and have not yet been overridden by another base contract along the inheritance path [2][3]. 4. Interface Functions: While functions in interfaces are implicitly virtual, they do not require the override keyword unless they are subject to the multiple inheritance ambiguity rules mentioned above [2][1].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="contracts/token/ERC20/extensions/ERC7535.sol"
rg -n "\{burn\}" "$FILE" || true
rg -n "\bburn\b" "$FILE" | head -n 20

Repository: OpenZeppelin/openzeppelin-community-contracts

Length of output: 329


Fix incorrect NatSpec {burn} reference in ERC7535 docs

Solidity ^0.8.24 doesn’t require override for these IERC7535-only implementations, so the missing override isn’t a build blocker; however, ERC7535’s header NatSpec references {burn} even though the public exit API is withdraw/redeem (no burn exists in IERC7535), so update the docs accordingly (around lines 20-22).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@contracts/token/ERC20/extensions/ERC7535.sol` around lines 135 - 252, The
NatSpec header for ERC7535 incorrectly references {burn} even though the public
exit API uses withdraw/redeem; update the contract/interface documentation on
ERC7535 (the top-of-file/header block for the ERC7535 contract) to replace or
remove the `{burn}` reference and instead reference the public exit functions
`withdraw` and `redeem` (or simply document the exit API as withdraw/redeem),
keeping all function names like deposit, mint, withdraw, redeem,
previewWithdraw, previewRedeem consistent with IERC7535.

Comment on lines +370 to +391
function _assertReentrancyCEI(ReentrantReceiver.Kind kind) internal {
ReentrantReceiver r = new ReentrantReceiver();

// Seed a second, honest depositor so the vault holds extra ETH the attacker could try to steal.
uint256 otherDeposit = 10 ether;
vm.deal(other, otherDeposit);
vm.prank(other);
uint256 otherShares = vault.deposit{value: otherDeposit}(otherDeposit, other);

// Attacker becomes a share owner.
uint256 attackerDeposit = 5 ether;
uint256 attackerShares = _fundReceiver(r, attackerDeposit);

// The attacker will try to re-redeem its FULL share balance again during the payout callback.
r.setup(vault, kind, attackerShares);

uint256 vaultBalBefore = address(vault).balance;
uint256 totalSupplyBefore = vault.totalSupply();
uint256 expectedPayout = vault.previewRedeem(attackerShares);

vm.prank(address(r));
vault.redeem(attackerShares, address(r), address(r));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

testReentrancyWithdrawCannotOverDrain never hits withdraw.

_assertReentrancyCEI always uses vault.redeem(...) for the outer payout, so the Kind.Withdraw case only changes the nested reentrant call. That leaves the withdraw entrypoint’s own asset-to-share path uncovered in this callback scenario.

Suggested fix
     function _assertReentrancyCEI(ReentrantReceiver.Kind kind) internal {
         ReentrantReceiver r = new ReentrantReceiver();
@@
         uint256 vaultBalBefore = address(vault).balance;
         uint256 totalSupplyBefore = vault.totalSupply();
         uint256 expectedPayout = vault.previewRedeem(attackerShares);

         vm.prank(address(r));
-        vault.redeem(attackerShares, address(r), address(r));
+        if (kind == ReentrantReceiver.Kind.Withdraw) {
+            vault.withdraw(expectedPayout, address(r), address(r));
+        } else {
+            vault.redeem(attackerShares, address(r), address(r));
+        }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function _assertReentrancyCEI(ReentrantReceiver.Kind kind) internal {
ReentrantReceiver r = new ReentrantReceiver();
// Seed a second, honest depositor so the vault holds extra ETH the attacker could try to steal.
uint256 otherDeposit = 10 ether;
vm.deal(other, otherDeposit);
vm.prank(other);
uint256 otherShares = vault.deposit{value: otherDeposit}(otherDeposit, other);
// Attacker becomes a share owner.
uint256 attackerDeposit = 5 ether;
uint256 attackerShares = _fundReceiver(r, attackerDeposit);
// The attacker will try to re-redeem its FULL share balance again during the payout callback.
r.setup(vault, kind, attackerShares);
uint256 vaultBalBefore = address(vault).balance;
uint256 totalSupplyBefore = vault.totalSupply();
uint256 expectedPayout = vault.previewRedeem(attackerShares);
vm.prank(address(r));
vault.redeem(attackerShares, address(r), address(r));
function _assertReentrancyCEI(ReentrantReceiver.Kind kind) internal {
ReentrantReceiver r = new ReentrantReceiver();
// Seed a second, honest depositor so the vault holds extra ETH the attacker could try to steal.
uint256 otherDeposit = 10 ether;
vm.deal(other, otherDeposit);
vm.prank(other);
uint256 otherShares = vault.deposit{value: otherDeposit}(otherDeposit, other);
// Attacker becomes a share owner.
uint256 attackerDeposit = 5 ether;
uint256 attackerShares = _fundReceiver(r, attackerDeposit);
// The attacker will try to re-redeem its FULL share balance again during the payout callback.
r.setup(vault, kind, attackerShares);
uint256 vaultBalBefore = address(vault).balance;
uint256 totalSupplyBefore = vault.totalSupply();
uint256 expectedPayout = vault.previewRedeem(attackerShares);
vm.prank(address(r));
if (kind == ReentrantReceiver.Kind.Withdraw) {
vault.withdraw(expectedPayout, address(r), address(r));
} else {
vault.redeem(attackerShares, address(r), address(r));
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/token/ERC20/extensions/ERC7535.t.sol` around lines 370 - 391, The test
helper _assertReentrancyCEI always calls vault.redeem(...) for the outer payout,
so the Kind.Withdraw case never exercises the vault.withdraw entrypoint; update
_assertReentrancyCEI to branch on ReentrantReceiver.Kind and call
vault.withdraw(expectedPayout, address(r), address(r)) when kind ==
ReentrantReceiver.Kind.Withdraw, otherwise keep the existing
vault.redeem(attackerShares, address(r), address(r)) call; adjust any local
variables used (e.g., expectedPayout vs attackerShares) so the correct argument
is passed for each branch.

// Fund the holder with plenty of native asset.
await setBalance(this.holder.address, ethers.MaxUint256 / 2n);
// Approve spender over holder's shares for third-party withdraw/redeem paths.
await vault.$_approve(this.holder, this.spender, ethers.MaxUint256);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Test setup and approval-based exit cases =="
rg -n -C2 '\$_approve|withdraw with approval|redeem with approval|MaxUint256' test/token/ERC20/extensions/ERC7535.test.js

echo
echo "== ERC20 allowance spending logic =="
fd -i 'ERC20.sol' . | while read -r file; do
  echo "-- $file"
  rg -n -C3 '_spendAllowance|type\(uint256\)\.max|MaxUint256' "$file" || true
done

Repository: OpenZeppelin/openzeppelin-community-contracts

Length of output: 3178


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Locate ERC7535 implementation files =="
rg -n --hidden --no-ignore -S "contract .*ERC7535|ERC7535" contracts test | head -n 50

echo
echo "== Find delegated withdraw/redeem functions in ERC7535 =="
rg -n "withdraw\\(|redeem\\(|previewWithdraw\\(|previewRedeem\\(|_spendAllowance|_allowance|allowance\\(" contracts | head -n 80

echo
echo "== Inspect the test assertions around withdraw/redeem with approval =="
sed -n '380,460p' test/token/ERC20/extensions/ERC7535.test.js

Repository: OpenZeppelin/openzeppelin-community-contracts

Length of output: 14175


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== ERC7535.sol: locate _withdraw allowance spending =="
sed -n '260,330p' contracts/token/ERC20/extensions/ERC7535.sol

echo
echo "== ERC20.sol: locate _spendAllowance implementation =="
rg -n "function _spendAllowance" contracts/token/ERC20 -S
# Print the first matching implementation with a bit of context
file=$(rg -l "function _spendAllowance" contracts/token/ERC20 | head -n 1)
echo "-- $file"
sed -n '1,260p' "$file" | sed -n '/function _spendAllowance/,+60p'

Repository: OpenZeppelin/openzeppelin-community-contracts

Length of output: 3549


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== ERC7535.test.js: find zero-address receiver assertions =="
rg -n "ZeroAddress|address\\(0\\)" test/token/ERC20/extensions/ERC7535.test.js

echo
echo "== ERC7535.test.js: inspect around the first zero-address receiver-related block =="
# Show around the first match (if any)
first=$(rg -n "ZeroAddress|address\\(0\\)" test/token/ERC20/extensions/ERC7535.test.js | head -n 1 | cut -d: -f1)
start=$(( first-40 ))
end=$(( first+60 ))
sed -n "${start},${end}p" test/token/ERC20/extensions/ERC7535.test.js

Repository: OpenZeppelin/openzeppelin-community-contracts

Length of output: 6526


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== ERC7535.test.js: inspect beforeEach + approval setup =="
sed -n '150,230p' test/token/ERC20/extensions/ERC7535.test.js

echo
echo "== ERC7535.test.js: inspect withdraw with approval / redeem with approval tests =="
sed -n '390,460p' test/token/ERC20/extensions/ERC7535.test.js

Repository: OpenZeppelin/openzeppelin-community-contracts

Length of output: 7660


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Find ERC20.sol in this repo =="
fd -i "erc20.sol" contracts | head -n 50
fd -i "ERC20.sol" contracts | head -n 50

echo
echo "== Locate _spendAllowance implementation (ERC20 base) =="
# Search in token/ERC20 area first
rg -n "function _spendAllowance\\(" contracts/token/ERC20 -S

echo
echo "== Show the implementation with surrounding context =="
file=$(rg -l "function _spendAllowance\\(" contracts/token/ERC20 -S | head -n 1)
echo "-- $file"
sed -n '1,260p' "$file" | sed -n '/function _spendAllowance\\(/,+80p'

Repository: OpenZeppelin/openzeppelin-community-contracts

Length of output: 275


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Search whole contracts tree for _spendAllowance implementation =="
rg -n "function _spendAllowance\\(" contracts -S || true

echo
echo "== If none in contracts/, search repo root for _spendAllowance =="
rg -n "function _spendAllowance\\(" . -S || true

Repository: OpenZeppelin/openzeppelin-community-contracts

Length of output: 225


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Search for _spendAllowance references anywhere =="
rg -n "_spendAllowance" . -S || true

echo
echo "== Search for ERC20InsufficientAllowance error =="
rg -n "ERC20InsufficientAllowance" . -S || true

echo
echo "== List ERC20-related Solidity files (tokens) =="
ls -R contracts/token | head -n 200

Repository: OpenZeppelin/openzeppelin-community-contracts

Length of output: 1829


Strengthen delegated withdraw/redeem tests to assert allowance is actually spent (and tighten the zero-address Withdraw event assertions)

  • In test/token/ERC20/extensions/ERC7535.test.js, the shared setup approves ethers.MaxUint256, and both withdraw with approval / redeem with approval only assert authorization (revert/no-revert). They don’t assert that allowance was reduced/exhausted after the delegated call, even though ERC7535._withdraw calls _spendAllowance(owner, caller, shares) before burning shares and transferring out native assets. Approve a finite amount (the required shares) and assert post-call allowance (or that a second call reverts with ERC20InsufficientAllowance).
    ($_approve at ~194; tests at ~402-410 and ~434-440)
  • The receiver == address(0) (documenting test) only checks the ETH balance change and that Withdraw is emitted; it doesn’t assert Withdraw event arguments, leaving the documented semantics under-specified.
    (receiver==address(0) test around ~160-176)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/token/ERC20/extensions/ERC7535.test.js` at line 194, The tests currently
approve ethers.MaxUint256 via $_approve and only assert authorization; change
the delegated withdraw/redeem tests to approve a finite amount equal to the
shares being withdrawn (use the same shares value used in the call), then after
the delegated call assert the allowance was reduced to zero (or attempt a second
delegated call and assert it reverts with ERC20InsufficientAllowance) to verify
ERC7535._withdraw calls _spendAllowance(owner, caller, shares). Also tighten the
receiver == address(0) test by asserting the Withdraw event's full arguments
(owner, receiver==address(0), shares, amount) rather than only checking ETH
balance and that the event emitted.

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