Skip to content

WIP: ERC7540#6399

Draft
ernestognw wants to merge 3 commits intoOpenZeppelin:masterfrom
ernestognw:feat/erc-7540
Draft

WIP: ERC7540#6399
ernestognw wants to merge 3 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

⚠️ No Changeset found

Latest commit: 8788d2b

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a 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

* 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.

uint256 pendingShares = pendingRedeemRequest(requestId, controller);
require(shares <= pendingShares, ERC7540RedeemInsufficientPendingShares(shares, pendingShares));

uint256 assets = convertToAssets(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.

By always using convertToAssets here, I think you limit the ability to add fees specific to one side, i.e. charging an additional fee on redeem fulfillment that does not apply to deposit fulfillment.

Would it make sense to introduce separate _redeemPrice and _depositPrice internal methods, which by default just call convertToAssets?

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, it does make sense! I didn't consider additional fees on redeem or deposit fulfillment where a common step to override with extra logic. The _redeemPrice and _depositPrice functions give developers a clean function to hook on.

_completeSharesIn(shares, controller);
_setClaimableRedeem(controller, claimableAssets + assets, claimableShares + shares);
_setPendingRedeem(controller, pendingShares - 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.

In the Centrifuge implementation we added DepositClaimable and RedeemClaimable events (https://github.com/centrifuge/protocol/blob/882b984111150b664e7a1982f80e0975ecc1975a/src/vaults/interfaces/IAsyncVault.sol#L41). These aren't standardized because not all implementations can emit these on claimable transition, but I think they are very valuable for offchain indexers, so worth considering to add.

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.

I was hesitant on adding an event here because _completeSharesIn (in redeem) and _mint (in deposit) would emit an event themselves and it felt redundant. I guess the DepositClaimable and RedeemClaimable events allow indexing the operator and request id. Is that its main purpose?

To be clear, I like the idea of using both events as in Centrifuge's implementation, but I'm curious on your thoughts about emitting more than 1 event in this function.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

You mean Transfer(from=..., to=address(0)) in _completeSharesIn (in redeem)?

I personally think that's quite opaque. There could be other reasons for burning shares in the implementing contract. So would add a specific event for the fulfillment/claimable state of shares.

Comment on lines +51 to +59
/// @inheritdoc IERC4626
function previewWithdraw(uint256 /* assets */) public view virtual returns (uint256) {
revert ERC7540PreviewNotAvailable();
}

/// @inheritdoc IERC4626
function previewRedeem(uint256 /* shares */) public view virtual returns (uint256) {
revert ERC7540PreviewNotAvailable();
}
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.

Perhaps these should go in ERC7540Redeem instead of here

Comment on lines +42 to +49
function previewDeposit(uint256 /* assets */) public view virtual returns (uint256) {
revert ERC7540PreviewNotAvailable();
}

/// @inheritdoc IERC4626
function previewMint(uint256 /* shares */) public view virtual returns (uint256) {
revert ERC7540PreviewNotAvailable();
}
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.

And these in ERC7540Deposit

Comment on lines +123 to +59
* - MUST return as close to and no fewer than the exact amount of assets that would be deposited in a mint call
* * MUST return as close to and no fewer than the exact amount of assets that would be deposited in a mint call
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

What's going on here?

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 - bullets don't render in the docs, but * do. I just updated accordingly but it's not strictly needed for ERC-7540

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The - generally renders just fine in our system?

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.

True. After we migrated to the new engine it works. This was true for AsciiDoc

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

3 participants