Skip to content

Add ERC20Vault and ERC7540 variants#6399

Open
ernestognw wants to merge 20 commits intoOpenZeppelin:masterfrom
ernestognw:feat/erc-7540
Open

Add ERC20Vault and ERC7540 variants#6399
ernestognw wants to merge 20 commits intoOpenZeppelin:masterfrom
ernestognw:feat/erc-7540

Conversation

@ernestognw
Copy link
Copy Markdown
Member

@ernestognw ernestognw commented Mar 9, 2026

Fixes #4761

PR Checklist

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

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Mar 9, 2026

🦋 Changeset detected

Latest commit: f3407fb

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

Copy link
Copy Markdown

@hieronx hieronx left a comment

Choose a reason for hiding this comment

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

Really like the overall setup of the contracts!

Comment on lines +205 to +221
/**
* @dev Performs a transfer in of shares. By default, it takes the shares from the owner.
* Used by {requestRedeem}.
*
* IMPORTANT: If overriding to burn shares immediately (instead of holding them in the vault),
* you must ALSO override {_fulfillRedeem} to use a snapshotted exchange rate, since the
* shares will no longer exist in `totalSupply()` at fulfillment time. Simply overriding
* {_completeSharesIn} to do nothing is not sufficient.
*/
function _lockSharesIn(uint256 shares, address owner) internal virtual {
_update(owner, address(this), shares);
}

/// @dev Performs a fulfillment of a redeem request. By default, it burns the shares. Used by {_fulfillRedeem}.
function _completeSharesIn(uint256 shares, address /* controller */) internal virtual {
_burn(address(this), shares);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This is neat, nice design @ernestognw !

Comment on lines +53 to +55
/// @inheritdoc IERC20Vault
function asset() public view virtual returns (address);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

My suggestion would be to just add the share() function here.

It will instantly make all ERC-4626 vaults also ERC-7575 compatible.

Suggested change
/// @inheritdoc IERC20Vault
function asset() public view virtual returns (address);
/// @inheritdoc IERC20Vault
function asset() public view virtual returns (address);
/// @inheritdoc IERC20Vault
function share() public view virtual returns (address) {
return address(this);
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yes, this is a good idea. I'm still debating whether it makes sense because the function might suggest a developer that if they override it with another token address, then all the share transfer logic will be externalized, when in reality, they'd need to override other functions.

I'd prefer adding the share() function once we start with 7575

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I'd prefer adding the share() function once we start with 7575

We should already add the share() function because otherwise the vaults are not ERC7540 compliant. ERC7540 mandates that each vault MUST be ERC-7575 too.

It could be added inside the 7540 contracts rather than the ERC20Vault contract though, will leave that with you!

* Users should only approve operators they fully trust with both their assets and shares.
* ====
*/
abstract contract ERC7540Operator is ERC165, ERC20Vault, IERC7540Operator {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I wonder if you could have a abstract contract OperatorControl which implements setOperator, isOperator, and supportsInterface, which is then used by both ERC7540 as well as ERC6909. The inspiration for this feature set came from ERC6909 and it's almost a one to one match.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Do you mean using ERC-7741? I think it's not a bad idea and it would abstract away the interface from ERC-6909 as well.

@arr00, do you have any thoughts on 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.

I didn't mean adding full ERC-7741. Rather just moving setOperator, isOperator and supportsInterface to an abstract contract that is reused by ERC7540 and ERC6909 implementations.

Comment thread contracts/token/ERC20/extensions/ERC7540Redeem.sol Outdated
Comment thread contracts/token/ERC20/extensions/ERC7540Redeem.sol
Comment thread contracts/token/ERC20/extensions/ERC7540.sol
Comment thread contracts/token/ERC20/extensions/ERC7540.sol Outdated
Comment thread contracts/interfaces/IERC4626.sol Outdated
@ernestognw ernestognw changed the title WIP: ERC7540 Add ERC20Vault, ERC7540Redeem, ERC7540Deposit and ERC7540Operator Mar 30, 2026
@ernestognw ernestognw changed the title Add ERC20Vault, ERC7540Redeem, ERC7540Deposit and ERC7540Operator Add ERC20Vault and ERC7540 variants Mar 30, 2026
@ernestognw
Copy link
Copy Markdown
Member Author

ernestognw commented Mar 31, 2026

I've been taking a look to Slither's issue:

Detector: arbitrary-send-erc20
ERC20Vault._transferIn(address,uint256) (contracts/token/ERC20/extensions/ERC20Vault.sol#147-149) uses arbitrary from in transferFrom: SafeERC20.safeTransferFrom(IERC20(asset()),from,address(this),assets) (contracts/token/ERC20/extensions/ERC20Vault.sol#148)
Reference: https://github.com/crytic/slither/wiki/Detector-Documentation#arbitrary-from-in-transferfrom
INFO:Slither:. analyzed (429 contracts with 46 detectors), 1 result(s) found

It seems a false positive caused by the refactoring splitting the code across two contracts in the inheritance hierarchy, and there are no public entry points within ERC20Vault itself that constrain from to msg.sender. It's exactly the same pattern we had in ERC-4626 so I'm not worried and I just annotated the line in bb84e50.

Now we're just missing tests and we should be ready to go

@ernestognw ernestognw marked this pull request as ready for review March 31, 2026 13:48
@ernestognw ernestognw requested a review from a team as a code owner March 31, 2026 13:48
@ernestognw ernestognw marked this pull request as draft March 31, 2026 13:49
@ernestognw ernestognw marked this pull request as ready for review March 31, 2026 15:06
@ernestognw ernestognw requested review from arr00 and hieronx March 31, 2026 15:20
@ernestognw
Copy link
Copy Markdown
Member Author

Ready for review @hieronx, if you want to take another look 😄

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 31, 2026

Walkthrough

This pull request introduces ERC-7540 (Asynchronous ERC-4626 Tokenized Vaults) support to the OpenZeppelin Solidity library. It extracts core vault mechanics from ERC4626 into a new base contract ERC20Vault, implements the complete ERC-7540 specification via ERC7540Operator, ERC7540Deposit, and ERC7540Redeem contracts, and refactors ERC4626 to extend ERC20Vault. A new IERC20Vault interface abstracts common vault functionality. SafeERC20 gains a tryGetDecimals helper. The change includes multiple changeset entries documenting new features and a breaking change to ERC4626.maxWithdraw behavior, along with comprehensive test coverage.

Possibly related PRs

Suggested labels

release-cycle

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

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.
Description check ❓ Inconclusive The PR description is mostly a template with only a reference to issue #4761 and incomplete checklist; it lacks substantive detail about the changes being introduced. Provide a summary of the key changes: ERC20Vault extraction, ERC4626 refactoring, and ERC7540 implementation with async deposit/redeem flows.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main changes: introducing ERC20Vault as a base vault contract and ERC7540 as a new async vault variant.
Linked Issues check ✅ Passed The PR implements ERC-7540 as requested in issue #4761, including all async vault components (ERC7540Operator, ERC7540Deposit, ERC7540Redeem) with comprehensive test coverage.
Out of Scope Changes check ✅ Passed All changes are scoped to ERC-7540 implementation and necessary refactoring; ERC20Vault extraction and ERC4626 changes support the main objective without introducing unrelated functionality.

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

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

Warning

Review ran into problems

🔥 Problems

Timed out fetching pipeline failures after 30000ms


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

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (1)
contracts/token/ERC20/extensions/ERC7540Operator.sol (1)

38-55: _underlyingDecimals is write-only in this contract.

If decimals caching is intentionally needed here, wire it into an exposed/overridden decimals path; otherwise remove it (and related constructor work) to avoid misleading dead state.

♻️ Minimal cleanup option
 import {IERC7540Operator} from "../../../interfaces/IERC7540.sol";
 import {ERC165} from "../../../utils/introspection/ERC165.sol";
 import {IERC20} from "../IERC20.sol";
-import {SafeERC20} from "../utils/SafeERC20.sol";
-import {Math} from "../../../utils/math/Math.sol";
@@
     IERC20 private immutable _asset;
-    uint8 private immutable _underlyingDecimals;
     mapping(address => mapping(address => bool)) private _isOperator;
@@
     constructor(IERC20 asset_) {
-        (bool success, uint8 assetDecimals) = SafeERC20.tryGetDecimals(address(asset_));
-        _underlyingDecimals = uint8(Math.ternary(success, assetDecimals, 18));
         _asset = asset_;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/token/ERC20/extensions/ERC7540Operator.sol` around lines 38 - 55,
The contract writes to _underlyingDecimals in the constructor but never reads or
exposes it, creating dead state; either remove the field and the
SafeERC20.tryGetDecimals logic from the constructor, or wire the cached value
into an exposed/overridden decimals accessor (e.g., implement or override a
decimals() function to return _underlyingDecimals). Locate the constructor and
the _underlyingDecimals declaration and either delete both the field and the
tryGetDecimals call (clean removal), or implement/override decimals() to return
_underlyingDecimals so the cached value is used.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@CHANGELOG.md`:
- Line 3: Change the "### Breaking changes" heading to the correct level (likely
"## Breaking changes") so the markdown header sequence is consistent (avoid
jumping from H1 to H3) and satisfies MD001 lint; update the header text in
CHANGELOG.md accordingly.

In `@contracts/token/ERC20/extensions/ERC7540Deposit.sol`:
- Around line 112-118: The code updates pending accounting (_setPendingDeposit,
pendingDepositRequest, and increments _totalPendingDepositAssets) before
performing the external token transfer via _transferIn, which allows reentrancy
to observe inconsistent totalAssets() and may underflow; fix by reordering: call
_transferIn(owner, assets) first, then compute requestId via
_depositRequestId(controller), call _setPendingDeposit(controller, assets +
pendingDepositRequest(requestId, controller)), increment
_totalPendingDepositAssets, and finally emit DepositRequest(controller, owner,
requestId, _msgSender(), assets) so pending state is recorded only after the
tokens have been successfully transferred.

In `@contracts/token/ERC20/extensions/ERC7540Operator.sol`:
- Around line 34-35: Update the error documentation for ERC7540InvalidOperator
to correctly reference the controller: change the NatSpec comment "The operator
is not the caller or an operator of the operator" to something like "The
operator is not the caller or an operator of the controller" so the comment
matches the error signature ERC7540InvalidOperator(address controller, address
operator) and the actual semantics.

In `@contracts/token/ERC20/extensions/ERC7540Redeem.sol`:
- Around line 194-205: The fulfill flow in _fulfillRedeem causes claimable
assets to remain included in totalAssets() so subsequent
_redeemPrice/convertToAssets overvalues shares; introduce a global
claimableRedeemAssets reserve (tracked and exposed via
claimableRedeemRequestAssets and updated by _setClaimableRedeem) and subtract it
from any totalAssets() used for pricing (or modify convertToAssets/_redeemPrice
to use totalAssets() - claimableRedeemAssets), update _fulfillRedeem to
increment the global reserve when making assets claimable (via
_setClaimableRedeem) and ensure burning in _completeSharesIn does not leave
assets double-counted, and update any other functions that calculate
pending/convert amounts to exclude the new claimableRedeemAssets reserve.
- Around line 98-108: The requestRedeem path currently only permits owner or
operator via the onlyOperatorOrController guard and never honors ERC‑20
allowance; modify requestRedeem so callers are allowed if they are the
controller, an owner-approved operator, or an ERC‑20 approved spender by
replacing the external-onlyOperatorOrController enforcement with an inline
check: if _msgSender() is not owner and not an operator, call
_spendAllowance(owner, _msgSender(), shares) to consume the allowance before
proceeding to _lockSharesIn; keep using _redeemRequestId, pendingRedeemRequest,
_setPendingRedeem and emit RedeemRequest as before.

---

Nitpick comments:
In `@contracts/token/ERC20/extensions/ERC7540Operator.sol`:
- Around line 38-55: The contract writes to _underlyingDecimals in the
constructor but never reads or exposes it, creating dead state; either remove
the field and the SafeERC20.tryGetDecimals logic from the constructor, or wire
the cached value into an exposed/overridden decimals accessor (e.g., implement
or override a decimals() function to return _underlyingDecimals). Locate the
constructor and the _underlyingDecimals declaration and either delete both the
field and the tryGetDecimals call (clean removal), or implement/override
decimals() to return _underlyingDecimals so the cached value is used.
🪄 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: 05ea2fef-227f-4055-b1e6-8e4db9a99250

📥 Commits

Reviewing files that changed from the base of the PR and between 9cfdccd and 3bb0cd3.

📒 Files selected for processing (23)
  • .changeset/better-bananas-learn.md
  • .changeset/chatty-jeans-sing.md
  • .changeset/fast-beers-dream.md
  • .changeset/moody-chicken-laugh.md
  • .changeset/shiny-goats-dress.md
  • CHANGELOG.md
  • contracts/interfaces/IERC4626.sol
  • contracts/interfaces/IERC7540.sol
  • contracts/mocks/token/ERC4626LimitsMock.sol
  • contracts/token/ERC20/extensions/ERC20Vault.sol
  • contracts/token/ERC20/extensions/ERC4626.sol
  • contracts/token/ERC20/extensions/ERC7540.sol
  • contracts/token/ERC20/extensions/ERC7540Deposit.sol
  • contracts/token/ERC20/extensions/ERC7540Operator.sol
  • contracts/token/ERC20/extensions/ERC7540Redeem.sol
  • contracts/token/ERC20/extensions/IERC20Vault.sol
  • contracts/token/ERC20/utils/SafeERC20.sol
  • test/token/ERC20/extensions/ERC7540.behavior.js
  • test/token/ERC20/extensions/ERC7540.test.js
  • test/token/ERC20/extensions/ERC7540Deposit.test.js
  • test/token/ERC20/extensions/ERC7540Operator.test.js
  • test/token/ERC20/extensions/ERC7540Redeem.test.js
  • test/utils/introspection/SupportsInterface.behavior.js

Comment thread CHANGELOG.md
Comment thread contracts/token/ERC20/extensions/ERC7540Deposit.sol
Comment thread contracts/token/ERC20/extensions/ERC7540Operator.sol Outdated
Comment on lines +98 to +108
function requestRedeem(
uint256 shares,
address controller,
address owner
) public virtual onlyOperatorOrController(owner, _msgSender()) returns (uint256) {
uint256 requestId = _redeemRequestId(controller);
_setPendingRedeem(controller, shares + pendingRedeemRequest(requestId, controller));

// Must revert with ERC20InsufficientBalance if there's not enough balance.
_lockSharesIn(shares, owner);
emit RedeemRequest(controller, owner, requestId, _msgSender(), shares);
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot Mar 31, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Honor ERC-20 allowance-based requestRedeem calls.

This path only accepts owner or an owner-approved operator and then moves shares with _update, so a third party that only has delegated share allowance can never open the request. ERC-7540 explicitly allows a non-owner requestRedeem to be authorized either by ERC-20 share approval or by owner-set operator approval. (eips.ethereum.org)

A small inline check that lets operators bypass allowance but falls back to _spendAllowance(owner, _msgSender(), shares) for ordinary approved spenders would cover the missing branch.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@contracts/token/ERC20/extensions/ERC7540Redeem.sol` around lines 98 - 108,
The requestRedeem path currently only permits owner or operator via the
onlyOperatorOrController guard and never honors ERC‑20 allowance; modify
requestRedeem so callers are allowed if they are the controller, an
owner-approved operator, or an ERC‑20 approved spender by replacing the
external-onlyOperatorOrController enforcement with an inline check: if
_msgSender() is not owner and not an operator, call _spendAllowance(owner,
_msgSender(), shares) to consume the allowance before proceeding to
_lockSharesIn; keep using _redeemRequestId, pendingRedeemRequest,
_setPendingRedeem and emit RedeemRequest as before.

✅ Confirmed as addressed by @ernestognw

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The current implementation intentionally uses onlyOperatorOrController for requestRedeem, which only allows owner or ERC-7540 operator authorization. However, you're right that the ERC-7540 spec requires support for ERC-20 share allowance as an alternative authorization path:

Redeem Request approval of shares for a msg.sender NOT equal to owner may come either from ERC-20 approval over the shares of owner or if the owner has approved the msg.sender as an operator. This MUST be consistent with similar behaviour pointed out in ERC-6909 [...]

We've updated requestRedeem to replace the onlyOperatorOrController modifier with an inline three-way check: if the caller is not the owner and not an operator, _spendAllowance(owner, sender, shares) is called. This follows the ERC-6909 semantics referenced in the spec — operators bypass allowance, infinite approvals are not deducted, and finite approvals are consumed.

We also updated the IERC7540Redeem interface NatSpec to align with the spec language.

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.

@ernestognw, thanks for the detailed explanation and the fix. The three-way inline check (owner → operator → _spendAllowance fallback) correctly implements the ERC-7540 spec's authorization requirement and stays consistent with the ERC-6909 semantics referenced in the standard. Updating the IERC7540Redeem NatSpec to mirror the spec language is a nice touch as well.


✏️ Learnings added
Learnt from: ernestognw
Repo: OpenZeppelin/openzeppelin-contracts PR: 6399
File: contracts/token/ERC20/extensions/ERC7540Redeem.sol:0-0
Timestamp: 2026-04-01T13:36:48.338Z
Learning: In `contracts/token/ERC20/extensions/ERC7540Redeem.sol` (OpenZeppelin openzeppelin-contracts, Solidity), `requestRedeem` uses an inline three-way authorization check instead of the `onlyOperatorOrController` modifier: (1) caller is the owner — no allowance consumed; (2) caller is an ERC-7540 operator approved by the owner — no allowance consumed; (3) otherwise — `_spendAllowance(owner, sender, shares)` is called to consume ERC-20 share allowance. This follows ERC-6909 semantics as required by the ERC-7540 spec: operators bypass allowance, infinite approvals are not deducted, and finite approvals are consumed.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: ernestognw
Repo: OpenZeppelin/openzeppelin-contracts PR: 6399
File: contracts/token/ERC20/extensions/ERC7540Redeem.sol:200-212
Timestamp: 2026-04-01T10:12:10.174Z
Learning: In `contracts/token/ERC20/extensions/ERC7540Redeem.sol` (OpenZeppelin openzeppelin-contracts, Solidity), claimable redeem assets intentionally remain included in `totalAssets()` after `_fulfillRedeem` burns shares via `_completeSharesIn`. When shares are burned, `totalSupply()` decreases while `totalAssets()` is unchanged, which raises the assets-per-share rate — this is by design and consistent with standard ERC-4626 accounting. This is asymmetric with the deposit side, where pending assets *must* be excluded from `totalAssets()` to prevent existing shareholders from extracting value from depositors (assets arrive before shares are minted). The vault operator controls when `_fulfillRedeem` is called and can override `_redeemPrice` to use a snapshotted exchange rate for batch fulfillments.

Learnt from: ernestognw
Repo: OpenZeppelin/openzeppelin-contracts PR: 6399
File: contracts/token/ERC20/extensions/ERC7540Deposit.sol:122-128
Timestamp: 2026-04-01T10:28:11.839Z
Learning: In `contracts/token/ERC20/extensions/ERC7540Deposit.sol` (OpenZeppelin openzeppelin-contracts, Solidity), the `requestDeposit` function intentionally updates pending accounting (`_setPendingDeposit`, `_totalPendingDepositAssets`) BEFORE calling `_transferIn` — this is correct CEI (Checks-Effects-Interactions). Suggesting to reorder so that `_transferIn` comes first would break CEI and is strictly worse. The narrow read-only reentrancy window (ERC-777 hooks observing a temporarily understated `totalAssets()` mid-transfer) is addressed via documentation rather than code reordering.

Learnt from: ernestognw
Repo: OpenZeppelin/openzeppelin-contracts PR: 5904
File: contracts/crosschain/README.adoc:1-1
Timestamp: 2025-08-28T15:48:30.716Z
Learning: ernestognw prefers "Cross chain" without hyphenation rather than "Cross-chain" in documentation titles.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@hieronx In your original ERC-7540 design proposal, requestRedeem used onlyOperatorOrController without an ERC-20 allowance path. The spec says authorization "may come either from ERC-20 approval over the shares of owner or if the owner has approved the msg.sender as an operator," and we're debating whether both paths should be supported or if operator-only is sufficient.

I went ahead and added the ERC-20 allowance fallback (with ERC-6909 semantics: operators bypass allowance, finite approvals are consumed) in 8dd0895. Would appreciate your thoughts on whether this aligns with the spec's intent or if operator-only was the intended design.

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.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I hadn't added this to the original proposal yet because of the increased complexity, but the intention of the ERC-7540 spec was exactly supporting the ERC-20 allowance fallback as well.

In hindsight I think we overcomplicated this and should have left it out, but it is how the spec is now written and finalized.

Comment thread contracts/token/ERC20/extensions/ERC7540Redeem.sol
@ernestognw ernestognw mentioned this pull request Mar 31, 2026
3 tasks
@ernestognw
Copy link
Copy Markdown
Member Author

This PR moves the _asset and _underlyingDecimals state variables from ERC4626 into the new ERC20Vault base contract. When the transpiler generates the upgradeable version, it derives the ERC-7201 namespace ID from the contract name, so ERC20Vault would get erc7201:openzeppelin.storage.ERC20Vault instead of the existing erc7201:openzeppelin.storage.ERC4626. This produces a completely different storage slot, which would break backwards compatibility for any deployed proxy upgrading from the current ERC4626Upgradeable.

To fix this, I've opened a PR on the transpiler adding support for a @custom:oz-transpile-namespace annotation that overrides the namespace ID: OpenZeppelin/openzeppelin-transpiler#189. Once merged, we'll annotate ERC20Vault with:

/// @custom:oz-transpile-namespace ERC4626
abstract contract ERC20Vault is ERC20, IERC20Vault {

This will make the transpiler generate the storage struct at erc7201:openzeppelin.storage.ERC4626 (slot 0x0773e532dfede91f04b12a73d3d2acd361424f41f76b4fb79f090161e36b4e00), preserving backward compatibility with the existing ERC4626Upgradeable.

This PR is blocked on the transpiler update.

*
* Emits an {OperatorSet} event if the approval status changes.
*/
function _setOperator(address controller, address operator, bool approved) internal {
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.

Small note, the ERC states that the setOperator function MUST emit the OperatorSet event. This implementation does not emit the event if the status was not modified. While skipping the event doesn't change observability of the operators, it is not exactly following the specs.

Copy link
Copy Markdown
Member Author

@ernestognw ernestognw Apr 3, 2026

Choose a reason for hiding this comment

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

True! The spec also says:

MAY be logged when the operator status is set to the same status it was before the current call.

I would prefer to follow this statement rather than always emit. It's true both overlap

*/
function _setOperator(address controller, address operator, bool approved) internal {
if (_isOperator[controller][operator] != approved) {
_isOperator[controller][operator] = approved;
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.

I'm not sure the check is really worth it.

  • The (cold) sload costs 2100 gas.
  • If we decide to not write (because the write is not necessary) we will end up paying 2100gas + the cost of the if.
  • If we realize we do have to do the write, we have to pay the write (20k if false -> true, 2900 + 4800 refund if true -> false)

If we skip the check, and just write directly to storage:

  • if true -> true or false -> false, the gas cost is 2200 (compared to 2100 + if logic)
  • if false -> true: 22100, which is the same as previous, expect we don't have to pay for the if, so actually marginally cheaper
  • if true -> false: 5000 (+ 4800 refund), which is the same as previous, expect we don't have to pay for the if, so actually marginally cheaper

So basically, the sload + allows the user saving 100 gas (minus the cost of the if) on no-ops, but costs the user the cost of the if on none-no-op operations (which I would argue are the one we should optimise).

If we follow the specs and always emit the event, I think we should not keep the if just for the sake of the sstore part.

* See {IERC7540Deposit-deposit}.
*/
function deposit(uint256 assets, address receiver) public virtual returns (uint256 shares) {
return deposit(assets, receiver, receiver);
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.

I believe that to comply with 4262, the token should be taken from the caller's "balance", and not from the receiver. So IMO this should probably be

Suggested change
return deposit(assets, receiver, receiver);
return deposit(assets, receiver, msg.sender);

_setClaimableDeposit(
controller,
requestAssets - assets,
Math.ternary(requestShares > sharesUp, requestShares - sharesUp, 0)
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.

Suggested change
Math.ternary(requestShares > sharesUp, requestShares - sharesUp, 0)
Math.saturatingSub(requestShares, sharesUp)

address receiver,
address controller
) public virtual onlyOperatorOrController(controller, _msgSender()) returns (uint256) {
uint256 requestId = _depositRequestId(controller);
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.

What would happen here if _depositRequestId doesn't return the same thing as was computed in the requestDeposit ? _depositRequestId is view virtual, so we have to consider it could be anything.

Do you know how is the requestId is used in existing 7540 implementations ?

}

/// @dev Returns the request ID for the given assets, controller, and owner
function _depositRequestId(address /* controller */) internal view virtual returns (uint256) {
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.

the arguments don't match the natspec documentation

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.

Implement ERC-7540

4 participants