Skip to content

Add ERC-7535 Native Asset Vault as an ERC-4626 extension - #6624

Open
0xAdriaTorralba wants to merge 5 commits into
OpenZeppelin:masterfrom
0xAdriaTorralba:feat/erc7535-erc4626-extension
Open

Add ERC-7535 Native Asset Vault as an ERC-4626 extension#6624
0xAdriaTorralba wants to merge 5 commits into
OpenZeppelin:masterfrom
0xAdriaTorralba:feat/erc7535-erc4626-extension

Conversation

@0xAdriaTorralba

Copy link
Copy Markdown

Fixes #5808

Implements ERC-7535 (Native Asset ERC-4626 Tokenized Vault) as a thin extension of ERC4626, inheriting all share accounting instead of duplicating it.

Approach

The only thing that prevented ERC-7535 from extending ERC4626 was that IERC4626.deposit/mint are nonpayable (Solidity forbids a payable override of a nonpayable function). This PR removes that blocker:

  • IERC4626 / ERC4626: deposit and mint become payable. This changes neither the function selectors nor the ERC-165 interface id, so it is ABI-compatible. A new internal hook _checkPayment(uint256 assets) validates the native value; the default reverts on any non-zero msg.value, so existing ERC-20 vaults keep rejecting native value exactly as the previously non-payable entry points did.
  • ERC4626 already exposed _transferIn / _transferOut virtual seams, so the asset-movement layer was already overridable.

ERC7535 then collapses to a small override set over ERC4626:

  • asset() → ERC-7528 placeholder 0xEeee…EEeE; totalAssets()address(this).balance.
  • _checkPayment → require msg.value to cover the deposit.
  • _transferIn → no-op (value arrived as msg.value); _transferOutAddress.sendValue.
  • _convertToShares/_convertToAssets → price against _pretotalAssets() (totalAssets() - msg.value) so a standalone previewDeposit matches the shares a subsequent deposit mints.
  • receive() (virtual) → revert ERC7535UnsolicitedDeposit.

All share accounting, rounding, the virtual-offset inflation mitigation, the preview/max functions, and the CEI withdraw path are inherited unchanged — no duplication.

Design notes

  • msg.value policy: deposit/mint require msg.value >= assets (resp. the previewed cost); too little reverts with ERC7535InsufficientNativeValue. Excess is kept as a donation rather than refunded — a refund would add an outbound native call inside the deposit flow, reopening the reentrancy / refund-griefing surface the inherited CEI ordering avoids.
  • Inflation attack: same virtual-offset mitigation as ERC4626; force-feeds (SELFDESTRUCT, block rewards) are tracked by the balance-based totalAssets() and handled by the offset math, not by the receive guard.
  • _decimalsOffset ceiling: documented as offset <= 7710 ** offset overflows uint256 beyond that (the uint8 decimals() bound of 237 is not the binding constraint). Pinned with tests on both sides of the 77/78 boundary. (Applies to ERC4626 too.)

Tests

  • Hardhat (143 passing across the ERC4626 + ERC7535 suites): ERC7535 over offsets [0, 6, 18] × empty/donated/populated states; the >= policy (underpay reverts, exact + overpay succeed, overpay donates); unsolicited-transfer rejection; outbound-send failure; the 77/78 boundary; a yield-accrual scenario. Plus a new ERC4626 test asserting a token vault reverts (ERC4626UnexpectedNativeValue) on a non-zero msg.value.
  • Foundry (23 passing): msg.value enforcement, preview==minted under non-trivial state, inflation non-profitability at offset 0, round-trip contraction, force-fed accounting, CEI reentrancy with a malicious receiver, and invariantSolvency / invariantNoValueCreation (5000 runs × 500 calls, 0 reverts).

Local: lint, test:inheritance, test:generation green; docs build resolves the {{ERC7535}} placeholder.

Changeset classification

Both changesets are marked minor. The selector + interface id are unchanged and no in-repo subclass overrides deposit/mint, so this is non-breaking here — but an external subclass that overrides deposit/mint as nonpayable would need a recompile, so a major classification is defensible if maintainers prefer.

PR Checklist

  • Tests
  • Documentation
  • Changeset entry (run npx changeset add)

Implement ERC-7535 as a thin extension of ERC-4626. To enable it, make
IERC4626/ERC4626 `deposit` and `mint` payable (function selectors and the
ERC-165 interface id are unchanged) and route native-value validation through a
new internal `_checkPayment` hook. The default hook reverts on any non-zero
`msg.value`, so ERC-20 vaults keep rejecting native value exactly as before.

ERC7535 overrides asset()/totalAssets() for the native-asset placeholder and
balance, _transferIn/_transferOut for the msg.value / Address.sendValue paths,
the conversion seam to price against the pre-msg.value balance, and _checkPayment
to require msg.value to cover the deposit (any excess is kept as a donation). A
virtual receive() rejects unsolicited plain transfers.
Hardhat and Foundry suites for ERC7535 (msg.value >= policy with overpayment
donation, unsolicited-transfer rejection, the 77/78 decimals-offset boundary,
force-fed-balance accounting, CEI reentrancy, and solvency / no-value-creation
invariants), plus an ERC4626 test pinning that a plain ERC-20 vault still reverts
on a non-zero msg.value to deposit/mint.
Add the ERC-7535 guide page, register it in the nav and the token/ERC20 API
index, and note the new payable deposit/mint + _checkPayment seam in the ERC-4626
guide. Two changesets: the IERC4626/ERC4626 payable change and the ERC7535
addition.
- deposit() now prices shares off msg.value, ignoring the assets argument (EIP-7535 conformance); fixes the deposit{value:v}(0) zero-share trap
- Revert hand-stamped version headers (release tooling manages them)
- Add ERC4626 offset 77/78 conversion-overflow tests + IERC4626 selector/interfaceId lock test
- Note the payable-override breaking change in the changeset
… naming

Parameterize the outer call in _assertReentrancyCEI by kind so Kind.Withdraw
actually enters through withdraw() (previously both kinds redeemed, leaving the
withdraw callback path uncovered), deriving payout and burned shares via the
preview functions instead of assuming a full burn. Rename the reentrant
receiver's reenterShares to reenterAmount and pass an assets amount on the
withdraw path (it was reusing a share amount as the assets argument). In the
Hardhat suite, rename a shares-holding variable that was named assets and pin
the full Withdraw event payload in the zero-address documenting test.
@0xAdriaTorralba
0xAdriaTorralba requested a review from a team as a code owner July 22, 2026 09:03
Copilot AI review requested due to automatic review settings July 22, 2026 09:03
@changeset-bot

changeset-bot Bot commented Jul 22, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9ae681b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
openzeppelin-solidity Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The changes make ERC-4626 deposit and mint payable while preserving selectors and interface identification, with default validation for ERC-20 vaults. They add ERC-7535 native-asset vault support using msg.value, native balance accounting, virtual-offset conversions, native transfers, and unsolicited-transfer rejection. Comprehensive Forge and Hardhat tests cover conversions, accounting, donations, reentrancy, failures, allowances, and invariants. Documentation, navigation, extension listings, and release changesets are updated.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed It clearly names the main change: adding ERC-7535 native asset vault support as an ERC-4626 extension.
Description check ✅ Passed It directly describes the ERC-7535 implementation and related changes in the PR.
Linked Issues check ✅ Passed The PR implements ERC-7535 native asset vault support requested by #5808 and matches the stated goals.
Out of Scope Changes check ✅ Passed The code, tests, docs, and changesets all align with ERC-7535 support; no unrelated changes stand out.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install timed out. The project may have too many dependencies for the sandbox.


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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

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

163-171: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Strengthen the outbound-send-failure reverts with negative balance assertions.

These tests only assert the call reverts. Adding to.not.changeEtherBalances (or a totalSupply/share-balance check like the Forge counterpart in ERC7535.t.sol at Lines 555-557) would prove the reverted withdraw/redeem left the vault and holder balances untouched, catching any partial-state leak.

♻️ Example
     it('withdraw to a receiver whose receive() reverts bubbles the failure', async function () {
-      await expect(this.vault.connect(this.holder).withdraw(ethers.parseEther('1'), this.rejector, this.holder)).to.be
-        .reverted;
+      const tx = this.vault.connect(this.holder).withdraw(ethers.parseEther('1'), this.rejector, this.holder);
+      await expect(tx).to.be.reverted;
+      await expect(tx).to.not.changeEtherBalances([this.vault, this.rejector], [0n, 0n]);
     });

Based on learnings: in tests that exercise revert semantics, use negative balance assertions (e.g., to.not.changeEtherBalances) to verify that no ether balance changes occurred as a result of the reverted transaction.

🤖 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 163 - 171,
Strengthen the revert assertions in the `withdraw` and `redeem` tests by also
verifying that the failed transactions leave balances unchanged. Wrap each call
with `to.not.changeEtherBalances` for the vault and holder, or assert unchanged
`totalSupply` and holder share balance as in the Forge counterpart, while
preserving the existing reverted expectation.

Source: Learnings

🤖 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 `@docs/modules/ROOT/pages/erc7535.adoc`:
- Line 63: Update the ERC7535 receive() behavior description to state that plain
.transfer reverts, while .send and call{value: ...}("") return failure unless
the caller checks and propagates the result. Preserve the explanation that the
failure is decodable and prevents unsolicited deposits.
- Line 5: Update the ERC7535 overview paragraph to avoid claiming that only
asset-movement seams differ from ERC4626; explicitly include payment-validation
and in-flight accounting/conversion behavior among the changed extension points
while preserving the listed inherited behavior.

---

Nitpick comments:
In `@test/token/ERC20/extensions/ERC7535.test.js`:
- Around line 163-171: Strengthen the revert assertions in the `withdraw` and
`redeem` tests by also verifying that the failed transactions leave balances
unchanged. Wrap each call with `to.not.changeEtherBalances` for the vault and
holder, or assert unchanged `totalSupply` and holder share balance as in the
Forge counterpart, while preserving the existing reverted expectation.
🪄 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: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 34712e00-32d2-418f-89bc-a7a449ffa337

📥 Commits

Reviewing files that changed from the base of the PR and between b4f9524 and 9ae681b.

📒 Files selected for processing (13)
  • .changeset/native-harbors-drift.md
  • .changeset/quiet-vaults-sail.md
  • contracts/interfaces/IERC4626.sol
  • contracts/mocks/token/ERC7535OffsetMock.sol
  • contracts/token/ERC20/README.adoc
  • contracts/token/ERC20/extensions/ERC4626.sol
  • contracts/token/ERC20/extensions/ERC7535.sol
  • docs/modules/ROOT/nav.adoc
  • docs/modules/ROOT/pages/erc4626.adoc
  • docs/modules/ROOT/pages/erc7535.adoc
  • test/token/ERC20/extensions/ERC4626.test.js
  • test/token/ERC20/extensions/ERC7535.t.sol
  • test/token/ERC20/extensions/ERC7535.test.js


https://eips.ethereum.org/EIPS/eip-7535[ERC-7535] is an adaptation of xref:erc4626.adoc[ERC-4626] in which the underlying asset of the vault is the chain's native asset (e.g. Ether) instead of an ERC-20 token. It keeps the ERC-4626 share-accounting model, rounding rules, and interface, so the same mental model, security considerations, and customizations apply, with a few native-asset specific differences.

`ERC7535` is implemented as a thin extension of `ERC4626`: share accounting, rounding, the virtual-shares inflation mitigation, the preview functions, and the checks-effects-interactions withdraw path are all inherited. Only the asset-movement seams change.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Avoid understating the extension points.

The document says only asset-movement seams change, but ERC7535 also changes payment validation and in-flight accounting/conversion behavior. Replace this with wording that includes payment and accounting seams, or remove “only.”

🤖 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 `@docs/modules/ROOT/pages/erc7535.adoc` at line 5, Update the ERC7535 overview
paragraph to avoid claiming that only asset-movement seams differ from ERC4626;
explicitly include payment-validation and in-flight accounting/conversion
behavior among the changed extension points while preserving the listed
inherited behavior.


== Plain native-asset transfers

Value must enter the vault through the standardized `deposit` or `mint` entry points so it is matched with newly issued shares. To make accidental misuse fail loudly, `ERC7535` implements a `receive()` function that reverts with the named error `ERC7535UnsolicitedDeposit`: plain `.transfer`, `.send` or `call{value: x}("")` to the vault revert with a reason wallets and UIs can decode, instead of being silently absorbed as a donation that would skew the next deposit's exchange rate.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Correct the .send and low-level call behavior description.

A reverting receive() causes .transfer to revert, but .send and call{value: ...}("") return failure to the caller unless that caller explicitly checks and propagates it. Clarify the statement so integrators do not assume those low-level operations automatically revert.

🤖 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 `@docs/modules/ROOT/pages/erc7535.adoc` at line 63, Update the ERC7535
receive() behavior description to state that plain .transfer reverts, while
.send and call{value: ...}("") return failure unless the caller checks and
propagates the result. Preserve the explanation that the failure is decodable
and prevents unsolicited deposits.

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.

Add support for ERC-7535: Native Asset ERC-4626 Tokenized Vault

2 participants