Skip to content

Commit 6cba8d0

Browse files
authored
ERC-4626 Pass fee to hooks (#1452)
* Refactor ERC-4626 component to pass calculated fee value to hooks * Support ERC-4626 changes in mocks * Rename Fee to FeeConfig, run linter * Support fees in shares * Update ERC-4626 mocks, add a mock for fees in shares * Support ERC-4626 fees changes in tests * Support changes in mocks and tests * Fix ERC4626 impl names * Some improvements to ERC4626 mocks * Update ERC4626 doc about fees * Update ERC4626 API reference * Update ERC4626 fees section doc * Update ERC4626 FeeConfigTrait functions in-code doc * Update ERC4626Hooks trait doc, incorporating additional function parameters added * Switch functions order in ERC4626Hooks trait * Minor improvements to ERC4626 mocks * Lint files * Fix doc inconsistencies * Support ERC4626 changes in macros * Lint files * Remove unused imports in ERC4626 mock * Improve doc for FeeConfigTrait functions * Update FeeConfigTrait functions doc in API reference * Document internal preview functions, solve TODOs * Improve ERC4626Hooks and FeeConfigTrait docs * Add changelog entry * Lint files
1 parent 13855ce commit 6cba8d0

File tree

11 files changed

+1155
-462
lines changed

11 files changed

+1155
-462
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2121
### Changed (Breaking)
2222

2323
- `ERC4626Component` now supports alternative asset management strategies (e.g., external vault) via the added `AssetsManagementTrait` (#1454)
24+
- `ERC4626Component` now supports charging fees in shares as well as in assets via the refactored `FeeConfigTrait` (#1452)
25+
- Additional input parameters were added to the `ERC4626HooksTrait` functions (#1452)
2426
- Moved interfaces, ABIs and dispatchers into `openzeppelin_interfaces` (#1463)
2527
- Some structs and types that were defined inside interface files were also moved
2628

docs/modules/ROOT/pages/api/erc20.adoc

Lines changed: 85 additions & 58 deletions
Large diffs are not rendered by default.

docs/modules/ROOT/pages/erc4626.adoc

Lines changed: 121 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -196,8 +196,17 @@ stem:[\delta = 6], stem:[a_0 = 1], stem:[a_1 = 10^5]
196196
[[fees]]
197197
=== Custom behavior: Adding fees to the vault
198198

199-
In ERC4626 vaults, fees can be captured during the deposit/mint and/or during the withdraw/redeem steps.
200-
In both cases, it is essential to remain compliant with the ERC4626 requirements in regard to the preview functions.
199+
:fee_config_trait: xref:/api/erc20.adoc#ERC4626Component-FeeConfigTrait[FeeConfigTrait]
200+
:no_fees_impl: https://github.com/OpenZeppelin/cairo-contracts/tree/main/packages/token/src/erc20/extensions/erc4626/erc4626.cairo#L874[ERC4626DefaultNoFees]
201+
:erc4626_mocks: https://github.com/OpenZeppelin/cairo-contracts/tree/main/packages/test_common/src/mocks/erc4626.cairo[ERC4626 mocks]
202+
203+
In ERC4626 vaults, fees can be captured during deposit/mint and/or withdraw/redeem operations. It is essential to remain
204+
compliant with the ERC4626 requirements regarding the preview functions. Fees are calculated through the {fee_config_trait}
205+
implementation. By default, the ERC4626 component charges no fees. If this is the desired behavior, you can use the default
206+
{no_fees_impl} implementation.
207+
208+
NOTE: Starting from v3.0.0, fees can be charged in either assets or shares. Prior versions only supported fees taken in assets.
209+
See the updated {fee_config_trait} and implementation examples in {erc4626_mocks}.
201210

202211
For example, if calling `deposit(100, receiver)`, the caller should deposit exactly 100 underlying tokens, including fees, and the receiver should receive a number of shares that matches the value returned by `preview_deposit(100)`.
203212
Similarly, `preview_mint` should account for the fees that the user will have to pay on top of share's cost.
@@ -213,29 +222,29 @@ The `Withdraw` event should include the number of shares the user burns (includi
213222
The consequence of this design is that both the `Deposit` and `Withdraw` events will describe two exchange rates.
214223
The spread between the "Buy-in" and the "Exit" prices correspond to the fees taken by the vault.
215224

216-
The following example describes how fees proportional to the deposited/withdrawn amount can be implemented:
225+
The following example describes how fees taken in assets on deposits/withdrawals and in shares on mints/redemptions
226+
proportional to the deposited/withdrawn amount can be implemented:
217227

218228
```cairo
219-
/// The mock contract charges fees in terms of assets, not shares.
220-
/// This means that the fees are calculated based on the amount of assets that are being deposited
221-
/// or withdrawn, and not based on the amount of shares that are being minted or redeemed.
229+
/// The mock contract charges fees in assets on deposits and withdrawals and in shares on mints and
230+
/// redemptions.
222231
/// This is an opinionated design decision for the purpose of testing.
223232
/// DO NOT USE IN PRODUCTION
224233
#[starknet::contract]
234+
#[with_components(ERC20, ERC4626)]
225235
pub mod ERC4626Fees {
226-
use openzeppelin_token::erc20::extensions::erc4626::ERC4626Component;
227-
use openzeppelin_token::erc20::extensions::erc4626::ERC4626Component::FeeConfigTrait;
228-
use openzeppelin_token::erc20::extensions::erc4626::ERC4626Component::InternalTrait as ERC4626InternalTrait;
229-
use openzeppelin_token::erc20::extensions::erc4626::{DefaultConfig, ERC4626DefaultLimits};
230236
use openzeppelin_interfaces::erc20::{IERC20Dispatcher, IERC20DispatcherTrait};
231-
use openzeppelin_token::erc20::{ERC20Component, ERC20HooksEmptyImpl};
237+
use openzeppelin_token::erc20::extensions::erc4626::ERC4626Component::{Fee, FeeConfigTrait};
238+
use openzeppelin_token::erc20::extensions::erc4626::{
239+
DefaultConfig, ERC4626DefaultNoLimits, ERC4626SelfAssetsManagement,
240+
};
241+
use openzeppelin_token::erc20::{DefaultConfig as ERC20DefaultConfig, ERC20HooksEmptyImpl};
232242
use openzeppelin_utils::math;
233243
use openzeppelin_utils::math::Rounding;
234244
use starknet::ContractAddress;
235245
use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};
236246

237-
component!(path: ERC4626Component, storage: erc4626, event: ERC4626Event);
238-
component!(path: ERC20Component, storage: erc20, event: ERC20Event);
247+
const _BASIS_POINT_SCALE: u256 = 10_000;
239248

240249
// ERC4626
241250
#[abi(embed_v0)]
@@ -250,95 +259,14 @@ pub mod ERC4626Fees {
250259
#[abi(embed_v0)]
251260
impl ERC20CamelOnlyImpl = ERC20Component::ERC20CamelOnlyImpl<ContractState>;
252261

253-
impl ERC4626InternalImpl = ERC4626Component::InternalImpl<ContractState>;
254-
impl ERC20InternalImpl = ERC20Component::InternalImpl<ContractState>;
255-
256262
#[storage]
257263
pub struct Storage {
258-
#[substorage(v0)]
259-
pub erc4626: ERC4626Component::Storage,
260-
#[substorage(v0)]
261-
pub erc20: ERC20Component::Storage,
262264
pub entry_fee_basis_point_value: u256,
263265
pub entry_fee_recipient: ContractAddress,
264266
pub exit_fee_basis_point_value: u256,
265267
pub exit_fee_recipient: ContractAddress,
266268
}
267269

268-
#[event]
269-
#[derive(Drop, starknet::Event)]
270-
enum Event {
271-
#[flat]
272-
ERC4626Event: ERC4626Component::Event,
273-
#[flat]
274-
ERC20Event: ERC20Component::Event,
275-
}
276-
277-
const _BASIS_POINT_SCALE: u256 = 10_000;
278-
279-
///
280-
/// Hooks
281-
///
282-
283-
impl ERC4626HooksImpl of ERC4626Component::ERC4626HooksTrait<ContractState> {
284-
fn after_deposit(
285-
ref self: ERC4626Component::ComponentState<ContractState>, assets: u256, shares: u256,
286-
) {
287-
let mut contract_state = self.get_contract_mut();
288-
let entry_basis_points = contract_state.entry_fee_basis_point_value.read();
289-
let fee = contract_state.fee_on_total(assets, entry_basis_points);
290-
let recipient = contract_state.entry_fee_recipient.read();
291-
292-
if fee > 0 && recipient != starknet::get_contract_address() {
293-
contract_state.transfer_fees(recipient, fee);
294-
}
295-
}
296-
297-
fn before_withdraw(
298-
ref self: ERC4626Component::ComponentState<ContractState>, assets: u256, shares: u256,
299-
) {
300-
let mut contract_state = self.get_contract_mut();
301-
let exit_basis_points = contract_state.exit_fee_basis_point_value.read();
302-
let fee = contract_state.fee_on_raw(assets, exit_basis_points);
303-
let recipient = contract_state.exit_fee_recipient.read();
304-
305-
if fee > 0 && recipient != starknet::get_contract_address() {
306-
contract_state.transfer_fees(recipient, fee);
307-
}
308-
}
309-
}
310-
311-
/// Adjust fees
312-
impl AdjustFeesImpl of FeeConfigTrait<ContractState> {
313-
fn adjust_deposit(
314-
self: @ERC4626Component::ComponentState<ContractState>, assets: u256,
315-
) -> u256 {
316-
let contract_state = self.get_contract();
317-
contract_state.remove_fee_from_deposit(assets)
318-
}
319-
320-
fn adjust_mint(
321-
self: @ERC4626Component::ComponentState<ContractState>, assets: u256,
322-
) -> u256 {
323-
let contract_state = self.get_contract();
324-
contract_state.add_fee_to_mint(assets)
325-
}
326-
327-
fn adjust_withdraw(
328-
self: @ERC4626Component::ComponentState<ContractState>, assets: u256,
329-
) -> u256 {
330-
let contract_state = self.get_contract();
331-
contract_state.add_fee_to_withdraw(assets)
332-
}
333-
334-
fn adjust_redeem(
335-
self: @ERC4626Component::ComponentState<ContractState>, assets: u256,
336-
) -> u256 {
337-
let contract_state = self.get_contract();
338-
contract_state.remove_fee_from_redeem(assets)
339-
}
340-
}
341-
342270
#[constructor]
343271
fn constructor(
344272
ref self: ContractState,
@@ -362,50 +290,117 @@ pub mod ERC4626Fees {
362290
self.exit_fee_recipient.write(exit_treasury);
363291
}
364292

365-
#[generate_trait]
366-
pub impl InternalImpl of InternalTrait {
367-
fn transfer_fees(ref self: ContractState, recipient: ContractAddress, fee: u256) {
368-
let asset_address = self.asset();
369-
let asset_dispatcher = IERC20Dispatcher { contract_address: asset_address };
370-
assert(asset_dispatcher.transfer(recipient, fee), 'Fee transfer failed');
293+
/// Hooks
294+
impl ERC4626HooksImpl of ERC4626Component::ERC4626HooksTrait<ContractState> {
295+
fn after_deposit(
296+
ref self: ERC4626Component::ComponentState<ContractState>,
297+
caller: ContractAddress,
298+
receiver: ContractAddress,
299+
assets: u256,
300+
shares: u256,
301+
fee: Option<Fee>,
302+
) {
303+
if let Option::Some(fee) = fee {
304+
let mut contract_state = self.get_contract_mut();
305+
let fee_recipient = contract_state.entry_fee_recipient.read();
306+
match fee {
307+
Fee::Assets(fee) => {
308+
let asset_address = contract_state.asset();
309+
let asset_dispatcher = IERC20Dispatcher { contract_address: asset_address };
310+
assert(
311+
asset_dispatcher.transfer(fee_recipient, fee), 'Fee transfer failed',
312+
);
313+
},
314+
Fee::Shares(fee) => contract_state.erc20.mint(fee_recipient, fee),
315+
};
316+
}
371317
}
372318

373-
fn remove_fee_from_deposit(self: @ContractState, assets: u256) -> u256 {
374-
let fee = self.fee_on_total(assets, self.entry_fee_basis_point_value.read());
375-
assets - fee
319+
fn before_withdraw(
320+
ref self: ERC4626Component::ComponentState<ContractState>,
321+
caller: ContractAddress,
322+
receiver: ContractAddress,
323+
owner: ContractAddress,
324+
assets: u256,
325+
shares: u256,
326+
fee: Option<Fee>,
327+
) {
328+
if let Option::Some(fee) = fee {
329+
let mut contract_state = self.get_contract_mut();
330+
let fee_recipient = contract_state.exit_fee_recipient.read();
331+
match fee {
332+
Fee::Assets(fee) => {
333+
let asset_address = contract_state.asset();
334+
let asset_dispatcher = IERC20Dispatcher { contract_address: asset_address };
335+
assert(
336+
asset_dispatcher.transfer(fee_recipient, fee), 'Fee transfer failed',
337+
);
338+
},
339+
Fee::Shares(fee) => {
340+
if caller != owner {
341+
contract_state.erc20._spend_allowance(owner, caller, fee);
342+
}
343+
contract_state.erc20._transfer(owner, fee_recipient, fee);
344+
},
345+
};
346+
}
376347
}
348+
}
377349

378-
fn add_fee_to_mint(self: @ContractState, assets: u256) -> u256 {
379-
assets + self.fee_on_raw(assets, self.entry_fee_basis_point_value.read())
350+
/// Calculate fees
351+
impl FeeConfigImpl of FeeConfigTrait<ContractState> {
352+
fn calculate_deposit_fee(
353+
self: @ERC4626Component::ComponentState<ContractState>, assets: u256, shares: u256,
354+
) -> Option<Fee> {
355+
let contract_state = self.get_contract();
356+
let fee = fee_on_total(assets, contract_state.entry_fee_basis_point_value.read());
357+
Option::Some(Fee::Assets(fee))
380358
}
381359

382-
fn add_fee_to_withdraw(self: @ContractState, assets: u256) -> u256 {
383-
let fee = self.fee_on_raw(assets, self.exit_fee_basis_point_value.read());
384-
assets + fee
360+
fn calculate_mint_fee(
361+
self: @ERC4626Component::ComponentState<ContractState>, assets: u256, shares: u256,
362+
) -> Option<Fee> {
363+
let contract_state = self.get_contract();
364+
let fee = fee_on_raw(shares, contract_state.entry_fee_basis_point_value.read());
365+
Option::Some(Fee::Shares(fee))
385366
}
386367

387-
fn remove_fee_from_redeem(self: @ContractState, assets: u256) -> u256 {
388-
assets - self.fee_on_total(assets, self.exit_fee_basis_point_value.read())
368+
fn calculate_withdraw_fee(
369+
self: @ERC4626Component::ComponentState<ContractState>, assets: u256, shares: u256,
370+
) -> Option<Fee> {
371+
let contract_state = self.get_contract();
372+
let fee = fee_on_raw(assets, contract_state.exit_fee_basis_point_value.read());
373+
Option::Some(Fee::Assets(fee))
389374
}
390375

391-
///
392-
/// Fee operations
393-
///
394-
395-
/// Calculates the fees that should be added to an amount `assets` that does not already
396-
/// include fees.
397-
/// Used in IERC4626::mint and IERC4626::withdraw operations.
398-
fn fee_on_raw(self: @ContractState, assets: u256, fee_basis_points: u256) -> u256 {
399-
math::u256_mul_div(assets, fee_basis_points, _BASIS_POINT_SCALE, Rounding::Ceil)
376+
fn calculate_redeem_fee(
377+
self: @ERC4626Component::ComponentState<ContractState>, assets: u256, shares: u256,
378+
) -> Option<Fee> {
379+
let contract_state = self.get_contract();
380+
let fee = fee_on_total(shares, contract_state.exit_fee_basis_point_value.read());
381+
Option::Some(Fee::Shares(fee))
400382
}
383+
}
401384

402-
/// Calculates the fee part of an amount `assets` that already includes fees.
403-
/// Used in IERC4626::deposit and IERC4626::redeem operations.
404-
fn fee_on_total(self: @ContractState, assets: u256, fee_basis_points: u256) -> u256 {
405-
math::u256_mul_div(
406-
assets, fee_basis_points, fee_basis_points + _BASIS_POINT_SCALE, Rounding::Ceil,
407-
)
408-
}
385+
/// Calculates the fees that should be added to an amount `assets` that does not already
386+
/// include fees.
387+
/// Used in IERC4626::mint and IERC4626::withdraw operations.
388+
fn fee_on_raw(
389+
assets: u256,
390+
fee_basis_points: u256,
391+
) -> u256 {
392+
math::u256_mul_div(assets, fee_basis_points, _BASIS_POINT_SCALE, Rounding::Ceil)
393+
}
394+
395+
/// Calculates the fee part of an amount `assets` that already includes fees.
396+
/// Used in IERC4626::deposit and IERC4626::redeem operations.
397+
fn fee_on_total(
398+
assets: u256,
399+
fee_basis_points: u256,
400+
) -> u256 {
401+
math::u256_mul_div(
402+
assets, fee_basis_points, fee_basis_points + _BASIS_POINT_SCALE, Rounding::Ceil,
403+
)
409404
}
410405
}
411406
```

packages/macros/src/tests/snapshots/openzeppelin_macros__tests__test_with_components__with_erc4626.snap

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ TokenStream:
99
pub mod ERC4626Mock {
1010
use openzeppelin_token::erc20::ERC20HooksEmptyImpl;
1111
use openzeppelin_token::erc20::extensions::erc4626::{
12-
DefaultConfig, ERC4626DefaultLimits, ERC4626DefaultNoFees, ERC4626HooksEmptyImpl,
12+
DefaultConfig, ERC4626DefaultNoFees, ERC4626DefaultNoLimits, ERC4626EmptyHooks,
13+
ERC4626SelfAssetsManagement,
1314
};
1415
use starknet::ContractAddress;
1516

packages/macros/src/tests/test_with_components.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1258,8 +1258,8 @@ fn test_with_erc4626() {
12581258
#[starknet::contract]
12591259
pub mod ERC4626Mock {
12601260
use openzeppelin_token::erc20::extensions::erc4626::{
1261-
DefaultConfig, ERC4626DefaultLimits, ERC4626DefaultNoFees,
1262-
ERC4626HooksEmptyImpl,
1261+
DefaultConfig, ERC4626DefaultNoLimits, ERC4626DefaultNoFees,
1262+
ERC4626EmptyHooks, ERC4626SelfAssetsManagement,
12631263
};
12641264
use openzeppelin_token::erc20::ERC20HooksEmptyImpl;
12651265
use starknet::ContractAddress;

0 commit comments

Comments
 (0)