Skip to content

Refactor internal construction of ERC7540Deposit and ERC7540Redeem - #60

Open
Amxx wants to merge 6 commits into
ernestognw:feat/erc-7540from
Amxx:feat/erc-7540-v2
Open

Refactor internal construction of ERC7540Deposit and ERC7540Redeem#60
Amxx wants to merge 6 commits into
ernestognw:feat/erc-7540from
Amxx:feat/erc-7540-v2

Conversation

@Amxx

@Amxx Amxx commented Apr 7, 2026

Copy link
Copy Markdown

ERC7540Deposit

  • do not mint share to the vault on fulfillDeposit. Instead, mint them directly to the recipient on the deposit/mint. This changes the meaning of _totalPendingDepositAssets. IMO This is aligned with the lifecycle documented in the ERC
image
  • remove requestId from the implementation, only supporting the values 0 by default. Supporting more values would require many overrides by the user anyway.

  • split public/internal function to guide users in their customization of the vault.

  • _fulfillDeposit doesn't get exchange rate from a function. Since this rate is likelly to depending on many parameters, it is expected that the public function that will execute the internal fulfill call will itself compute the rate at which the operation is being fulfilled, and will feed that data to the internal function through the args.

ERC7540Redeem

(overall similar changes as above)

  • introduce an totalPendingRedeemShares similar to what exists in ERC7540Deposit to track shares that are burned but have not yet been redeemed.

@changeset-bot

changeset-bot Bot commented Apr 7, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 5c21a7c

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

Comment on lines +85 to +87
_transferIn(owner, assets);
emit DepositRequest(controller, owner, requestId, _msgSender(), assets);
return requestId;

return _requestDeposit(assets, controller, owner);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

This breaks CI. I understand we assume the token is an ERC20 and is trusted, but I believe we should do:

uint256 requestId = _requestDeposit(assets, controller, owner);
_transferIn(owner, assets);
return requestId;

This would cause totalAssets() to be understated but I think it's a safe direction (and already documented in the NatSpec note

@Amxx Amxx Apr 7, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

(I'm guessing you meant CEI as in check-effect-interact, and not CI as in continuous integration)

AFAIK, doing _transferIn at the very begining is fine, in the sens that its like if both operation were batched. CEI would be broken if we did checks before _transferIn, that the _transferIn can break. In this case the only part that is done before the _transferIn is the onlyOperatorOrController part.

But I'd be ok with moving the transfer to the end of the function if you think its better that way.

* otherwise pending assets would be treated as yield for outstanding shares.
*/
function totalSupply() public view virtual override returns (uint256) {
return super.totalSupply() + totalPendingRedeemShares();

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Doesn't this include shares that don't actually exist? I feel that's wrong

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

it includes the share that were burnt during the requestRedeem. The assets corresponding to which are still in the vault. The goal of this is to "trick" convertToAsset/convertToShare to give a correct rate.

But yes, it makes it that the totalSupply is not the sum of all the balances.

Comment on lines -57 to -58
function totalAssets() public view virtual override returns (uint256) {
return super.totalAssets() - totalPendingDepositAssets();

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Between requestDeposit and deposit, totalAssets is inflated while totalSupply hasn't changed. The feels bad because:

  • convertToAssets(shares) returns a higher value for existing shareholders
  • Any operation reading the exchange rate during this window sees an artificially high share price
  • If the vault has a synchronous redeem path (e.g., it only uses ERC7540Deposit for async deposits but keeps sync redeems), an attacker could deposit a large amount via requestDeposit to inflate the rate, then immediately redeem existing shares at the inflated price

It also diverges from the production implementations we've researched. All of them exclude pending assets from totalAssets

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

This function was moved, not removed.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Note: I 100% agree this function is needed. The totalSupply override in ERC7540Redeem is basically the counterpart to this for handling shares being burnt before the assets are moved out.

_totalPendingDepositAssets += assets;

emit DepositRequest(controller, owner, 0, _msgSender(), assets);
return 0;

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Although I agree customizing the requestId will still require multiple overrides, the previous implementation is more clear imo: override _requestId + override storage getters/setters. If we go this route, then implementing epoch-based vaults becomes even more difficult. A developer will need to add back all of the requestId handling logic.

From the research we worked in the past weeks, all epoch-based implementations do use the requestId to identify the epoch (lagoon, cove, amphor). Removing _depositRequestId and _redeemRequestId them forces epoch implementations to rebuild the entire thing from scratch rather than extending from it.

if (owner != sender && !isOperator(owner, sender)) {
_spendAllowance(owner, sender, shares);
}
_burn(owner, shares);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Related to https://github.com/ernestognw/openzeppelin-contracts/pull/60/changes#r3044978238

It also diverges from the implementations researched:

  • BeefySonic, MagmaV2, Tangle: shares transferred to vault, burned at fulfillment
  • Centrifuge: shares transferred to an escrow/vault contract
  • All epoch-based implementations: shares held during the epoch, settled at epoch close

Comment thread contracts/token/ERC20/extensions/ERC7540Redeem.sol Outdated
Comment thread contracts/token/ERC20/extensions/ERC7540Redeem.sol Outdated
Co-authored-by: Hadrien Croubois <hadrien.croubois@gmail.com>
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