diff --git a/docs/antora.yml b/docs/antora.yml index 215db3caf..fd0edf862 100644 --- a/docs/antora.yml +++ b/docs/antora.yml @@ -5,4 +5,4 @@ nav: - modules/ROOT/nav.adoc asciidoc: attributes: - page-sidebar-collapse-default: 'Access,Accounts,Finance,Governance,Introspection,Security,ERC20,ERC721,ERC1155,Upgrades,Universal Deployer Contract' + page-sidebar-collapse-default: 'Access,Accounts,Finance,Governance,Introspection,Security,ERC20,ERC721,ERC1155,ERC4626,Upgrades,Universal Deployer Contract' diff --git a/docs/modules/ROOT/images/erc4626-attack-3a.png b/docs/modules/ROOT/images/erc4626-attack-3a.png new file mode 100644 index 000000000..4cb52237d Binary files /dev/null and b/docs/modules/ROOT/images/erc4626-attack-3a.png differ diff --git a/docs/modules/ROOT/images/erc4626-attack-3b.png b/docs/modules/ROOT/images/erc4626-attack-3b.png new file mode 100644 index 000000000..3dc5256b5 Binary files /dev/null and b/docs/modules/ROOT/images/erc4626-attack-3b.png differ diff --git a/docs/modules/ROOT/images/erc4626-attack-6.png b/docs/modules/ROOT/images/erc4626-attack-6.png new file mode 100644 index 000000000..1587fb5c1 Binary files /dev/null and b/docs/modules/ROOT/images/erc4626-attack-6.png differ diff --git a/docs/modules/ROOT/images/erc4626-attack.png b/docs/modules/ROOT/images/erc4626-attack.png new file mode 100644 index 000000000..dc059b228 Binary files /dev/null and b/docs/modules/ROOT/images/erc4626-attack.png differ diff --git a/docs/modules/ROOT/images/erc4626-deposit.png b/docs/modules/ROOT/images/erc4626-deposit.png new file mode 100644 index 000000000..b6c75e679 Binary files /dev/null and b/docs/modules/ROOT/images/erc4626-deposit.png differ diff --git a/docs/modules/ROOT/images/erc4626-mint.png b/docs/modules/ROOT/images/erc4626-mint.png new file mode 100644 index 000000000..f89ab9007 Binary files /dev/null and b/docs/modules/ROOT/images/erc4626-mint.png differ diff --git a/docs/modules/ROOT/images/erc4626-rate-linear.png b/docs/modules/ROOT/images/erc4626-rate-linear.png new file mode 100644 index 000000000..09e8045e1 Binary files /dev/null and b/docs/modules/ROOT/images/erc4626-rate-linear.png differ diff --git a/docs/modules/ROOT/images/erc4626-rate-loglog.png b/docs/modules/ROOT/images/erc4626-rate-loglog.png new file mode 100644 index 000000000..4eb19efea Binary files /dev/null and b/docs/modules/ROOT/images/erc4626-rate-loglog.png differ diff --git a/docs/modules/ROOT/images/erc4626-rate-loglogext.png b/docs/modules/ROOT/images/erc4626-rate-loglogext.png new file mode 100644 index 000000000..127bc7f2f Binary files /dev/null and b/docs/modules/ROOT/images/erc4626-rate-loglogext.png differ diff --git a/docs/modules/ROOT/nav.adoc b/docs/modules/ROOT/nav.adoc index f464a2239..29d8bc67e 100644 --- a/docs/modules/ROOT/nav.adoc +++ b/docs/modules/ROOT/nav.adoc @@ -40,6 +40,7 @@ *** xref:/erc20.adoc[ERC20] **** xref:/guides/erc20-supply.adoc[Creating Supply] **** xref:/guides/erc20-permit.adoc[ERC20Permit] +**** xref:/guides/erc4626.adoc[ERC4626] **** xref:/api/erc20.adoc[API Reference] *** xref:erc721.adoc[ERC721] **** xref:/api/erc721.adoc[API Reference] diff --git a/docs/modules/ROOT/pages/api/erc20.adoc b/docs/modules/ROOT/pages/api/erc20.adoc index 14d88400c..ff8ee4c20 100644 --- a/docs/modules/ROOT/pages/api/erc20.adoc +++ b/docs/modules/ROOT/pages/api/erc20.adoc @@ -6,6 +6,8 @@ :snip-12: https://github.com/starknet-io/SNIPs/blob/main/SNIPS/snip-12.md[SNIP12] :snip12-metadata: xref:api/utilities.adoc#snip12[SNIP12Metadata] :eip-2612: https://eips.ethereum.org/EIPS/eip-2612[EIP-2612] +:eip4626: https://eips.ethereum.org/EIPS/eip-4626[EIP-4626] + = ERC20 @@ -523,7 +525,7 @@ See <>. use openzeppelin_token::erc20::interface::IERC20Permit; ``` -Interface of the ERC20Permit standard to support gasless token approvals as defined in {eip-2612}. +Interface of the ERC20Permit standard to support gasless token approvals as defined in {eip-2612}. [.contract-index] .Functions @@ -553,9 +555,851 @@ whenever a signature for `permit` call is generated. [[IERC20Permit-DOMAIN_SEPARATOR]] ==== `[.contract-item-name]#++DOMAIN_SEPARATOR++#++() → felt252++` [.item-kind]#external# -Returns the domain separator used in generating a message hash for `permit` signature. +Returns the domain separator used in generating a message hash for `permit` signature. The domain hashing logic follows the {snip-12} standard. +[.contract] +[[IERC4626]] +=== `++IERC4626++` link:https://github.com/OpenZeppelin/cairo-contracts/blob/release-v0.20.0/packages/token/src/erc20/extensions/erc4626/interface.cairo[{github-icon},role=heading-link] + +[.hljs-theme-dark] +```cairo +use openzeppelin_token::erc20::extensions::erc4626::interface::IERC4626; +``` + +Interface of the IERC4626 standard as defined in {eip4626}. + +[.contract-index] +.Functions +-- +* xref:#IERC4626-asset[`++asset()++`] +* xref:#IERC4626-total_assets[`++total_assets()++`] +* xref:#IERC4626-convert_to_shares[`++convert_to_shares(assets)++`] +* xref:#IERC4626-convert_to_assets[`++convert_to_assets(shares)++`] +* xref:#IERC4626-max_deposit[`++max_deposit(receiver)++`] +* xref:#IERC4626-preview_deposit[`++preview_deposit(assets)++`] +* xref:#IERC4626-deposit[`++deposit(assets, receiver)++`] +* xref:#IERC4626-max_mint[`++max_mint(receiver)++`] +* xref:#IERC4626-preview_mint[`++preview_mint(shares)++`] +* xref:#IERC4626-mint[`++mint(shares, receiver)++`] +* xref:#IERC4626-max_withdraw[`++max_withdraw(owner)++`] +* xref:#IERC4626-preview_withdraw[`++preview_withdraw(assets)++`] +* xref:#IERC4626-withdraw[`++withdraw(assets, receiver, owner)++`] +* xref:#IERC4626-max_redeem[`++max_redeem(owner)++`] +* xref:#IERC4626-preview_redeem[`++preview_redeem(shares)++`] +* xref:#IERC4626-redeem[`++redeem(shares, receiver, owner)++`] +-- + +[.contract-index] +.Events +-- +* xref:#IERC4626-Deposit[`++Deposit(sender, owner, assets, shares)++`] +* xref:#IERC4626-Withdraw[`++Withdraw(sender, receiver, owner, assets, shares)++`] +-- + +[#IERC4626-Functions] +==== Functions + +[.contract-item] +[[IERC4626-asset]] +==== `[.contract-item-name]#++asset++#++() → ContractAddress++` [.item-kind]#external# + +Returns the address of the underlying token used for the Vault for accounting, depositing, +and withdrawing. + +MUST be an ERC20 token contract. + +MUST NOT panic. + +[.contract-item] +[[IERC4626-total_assets]] +==== `[.contract-item-name]#++total_assets++#++() → u256++` [.item-kind]#external# + +Returns the total amount of the underlying asset that is “managed” by Vault. + +SHOULD include any compounding that occurs from yield. + +MUST be inclusive of any fees that are charged against assets in the Vault. + +MUST NOT panic. + +[.contract-item] +[[IERC4626-convert_to_shares]] +==== `[.contract-item-name]#++convert_to_shares++#++(assets) → u256++` [.item-kind]#external# + +Returns the amount of shares that the Vault would exchange for the amount of `assets` +provided irrespective of slippage or fees. + +MUST NOT be inclusive of any fees that are charged against assets in the Vault. + +MUST NOT show any variations depending on the caller. + +MUST NOT reflect slippage or other on-chain conditions, when performing the actual exchange. + +MUST NOT panic unless due to integer overflow caused by an unreasonably large input. + +MUST round down towards 0. + +NOTE: This calculation MAY NOT reflect the "per-user" price-per-share, and instead should +reflect the "average-user's" price-per-share, meaning what the average user should expect to +see when exchanging to and from. + +[.contract-item] +[[IERC4626-convert_to_assets]] +==== `[.contract-item-name]#++convert_to_assets++#++(shares) → u256++` [.item-kind]#external# + +Returns the amount of assets that the Vault would exchange for the amount of `shares` +provided irrespective of slippage or fees. + +MUST NOT be inclusive of any fees that are charged against assets in the Vault. + +MUST NOT show any variations depending on the caller. + +MUST NOT reflect slippage or other on-chain conditions, when performing the actual exchange. + +MUST NOT panic unless due to integer overflow caused by an unreasonably large input. + +MUST round down towards 0. + +NOTE: This calculation MAY NOT reflect the “per-user” price-per-share, and instead +should reflect the “average-user’s” price-per-share, meaning what the average user +should expect to see when exchanging to and from. + +[.contract-item] +[[IERC4626-max_deposit]] +==== `[.contract-item-name]#++max_deposit++#++(receiver: ContractAddress) → u256++` [.item-kind]#external# + +Returns the maximum amount of the underlying asset that can be deposited into the Vault for +`receiver`, through a deposit call. + +MUST return a limited value if receiver is subject to some deposit limit. + +MUST return 2 ** 256 - 1 if there is no limit on the maximum amount of assets that may be +deposited. + +MUST NOT panic. + +[.contract-item] +[[IERC4626-preview_deposit]] +==== `[.contract-item-name]#++preview_deposit++#++(assets: u256) → u256++` [.item-kind]#external# + +Allows an on-chain or off-chain user to simulate the effects of their deposit at the current +block, given current on-chain conditions. + +MUST return as close to and no more than the exact amount of Vault shares that would be +minted in a deposit call in the same transaction i.e. <> should return the same or more +shares as `preview_deposit` if called in the same transaction. + +MUST NOT account for deposit limits like those returned from <> and should always +act as though the deposit would be accepted, regardless if the user has enough tokens +approved, etc. + +MUST be inclusive of deposit fees. Integrators should be aware of the existence of deposit +fees. + +MUST NOT panic. + +NOTE: Any unfavorable discrepancy between <> and `preview_deposit` +SHOULD be considered slippage in share price or some other type of condition, meaning the +depositor will lose assets by depositing. + +[.contract-item] +[[IERC4626-deposit]] +==== `[.contract-item-name]#++deposit++#++(assets: u256, receiver: ContractAddress) → u256++` [.item-kind]#external# + +Mints Vault shares to `receiver` by depositing exactly amount of `assets`. + +MUST emit the <> event. + +MAY support an additional flow in which the underlying tokens are owned by the Vault +contract before the deposit execution, and are accounted for during deposit. + +MUST panic if all of assets cannot be deposited (due to deposit limit being reached, +slippage, the user not approving enough underlying tokens to the Vault contract, etc). + +NOTE: Most implementations will require pre-approval of the Vault with the Vault’s +underlying asset token. + +[.contract-item] +[[IERC4626-max_mint]] +==== `[.contract-item-name]#++max_mint++#++(receiver: ContractAddress) → u256++` [.item-kind]#external# + +Returns the maximum amount of the Vault shares that can be minted for the receiver, through +a mint call. + +MUST return a limited value if receiver is subject to some mint limit. + +MUST return 2 ** 256 - 1 if there is no limit on the maximum amount of shares that may be +minted. + +MUST NOT panic. + +[.contract-item] +[[IERC4626-preview_mint]] +==== `[.contract-item-name]#++preview_mint++#++(shares: u256) → u256++` [.item-kind]#external# + +Allows an on-chain or off-chain user to simulate the effects of their mint at the current +block, given current on-chain conditions. + +MUST return as close to and no fewer than the exact amount of assets that would be deposited +in a `mint` call in the same transaction. I.e. <> should return the same or fewer assets +as `preview_mint` if called in the same transaction. + +MUST NOT account for mint limits like those returned from <> and should always act +as though the mint would be accepted, regardless if the user has enough tokens approved, +etc. + +MUST be inclusive of deposit fees. Integrators should be aware of the existence of deposit +fees. + +MUST NOT panic. + +NOTE: Any unfavorable discrepancy between <> and +`preview_mint` SHOULD be considered slippage in share price or some other type of condition, +meaning the depositor will lose assets by minting. + +[.contract-item] +[[IERC4626-mint]] +==== `[.contract-item-name]#++mint++#++(shares: u256, receiver: ContractAddress) → u256++` [.item-kind]#external# + +Mints exactly shares Vault shares to receiver by depositing amount of underlying tokens. + +MUST emit the <> event. + +MAY support an additional flow in which the underlying tokens are owned by the Vault +contract before the mint execution, and are accounted for during mint. + +MUST panic if all of shares cannot be minted (due to deposit limit being reached, slippage, +the user not approving enough underlying tokens to the Vault contract, etc). + +NOTE: Most implementations will require pre-approval of the Vault with the Vault’s +underlying asset token. + +[.contract-item] +[[IERC4626-max_withdraw]] +==== `[.contract-item-name]#++max_withdraw++#++(owner: ContractAddress) → u256++` [.item-kind]#external# + +Returns the maximum amount of the underlying asset that can be withdrawn from the owner +balance in the Vault, through a withdraw call. + +MUST return a limited value if owner is subject to some withdrawal limit or timelock. + +MUST NOT panic. + +[.contract-item] +[[IERC4626-preview_withdraw]] +==== `[.contract-item-name]#++preview_withdraw++#++(assets: u256) → u256++` [.item-kind]#external# + +Allows an on-chain or off-chain user to simulate the effects of their withdrawal at the +current block, given current on-chain conditions. + +MUST return as close to and no fewer than the exact amount of Vault shares that would be +burned in a withdraw call in the same transaction i.e. <> should return the same or +fewer shares as `preview_withdraw` if called in the same transaction. + +MUST NOT account for withdrawal limits like those returned from <> and should +always act as though the withdrawal would be accepted, regardless if the user has enough +shares, etc. + +MUST be inclusive of withdrawal fees. Integrators should be aware of the existence of +withdrawal fees. + +MUST not panic. + +NOTE: Any unfavorable discrepancy between <> and `preview_withdraw` +SHOULD be considered slippage in share price or some other type of condition, meaning the +depositor will lose assets by depositing. + +[.contract-item] +[[IERC4626-withdraw]] +==== `[.contract-item-name]#++withdraw++#++(assets: u256, receiver: ContractAddress, owner: ContractAddress) → u256++` [.item-kind]#external# + +Burns shares from owner and sends exactly assets of underlying tokens to receiver. + +MUST emit the <> event. + +MAY support an additional flow in which the underlying tokens are owned by the Vault +contract before the withdraw execution, and are accounted for during withdraw. + +MUST revert if all of assets cannot be withdrawn (due to withdrawal limit being reached, +slippage, the owner not having enough shares, etc). + +NOTE: Some implementations will require pre-requesting to the Vault before a withdrawal +may be performed. +Those methods should be performed separately. + +[.contract-item] +[[IERC4626-max_redeem]] +==== `[.contract-item-name]#++max_redeem++#++(owner: ContractAddress) → u256++` [.item-kind]#external# + +Returns the maximum amount of Vault shares that can be redeemed from the owner balance in +the Vault, through a redeem call. + +MUST return a limited value if owner is subject to some withdrawal limit or timelock. + +MUST return `ERC20::balance_of(owner)` if `owner` is not subject to any withdrawal limit or +timelock. + +MUST NOT panic. + +[.contract-item] +[[IERC4626-preview_redeem]] +==== `[.contract-item-name]#++preview_redeem++#++(shares: u256) → u256++` [.item-kind]#external# + +Allows an on-chain or off-chain user to simulate the effects of their redeemption at the +current block, given current on-chain conditions. + +MUST return as close to and no more than the exact amount of assets that would be withdrawn +in a redeem call in the same transaction i.e. <> should return the same or more assets +as preview_redeem if called in the same transaction. + +MUST NOT account for redemption limits like those returned from <> and should always +act as though the redemption would be accepted, regardless if the user has enough shares, +etc. + +MUST be inclusive of withdrawal fees. Integrators should be aware of the existence of +withdrawal fees. + +MUST NOT panic. + +NOTE: Any unfavorable discrepancy between <> and `preview_redeem` SHOULD be +considered slippage in share price or some other type of condition, meaning the depositor +will lose assets by redeeming. + +[.contract-item] +[[IERC4626-redeem]] +==== `[.contract-item-name]#++redeem++#++(shares: u256, receiver: ContractAddress, owner: ContractAddress) → u256++` [.item-kind]#external# + +Burns exactly shares from owner and sends assets of underlying tokens to receiver. + +MUST emit the <> event. + +MAY support an additional flow in which the underlying tokens are owned by the Vault +contract before the redeem execution, and are accounted for during redeem. + +MUST revert if all of shares cannot be redeemed (due to withdrawal limit being reached, +slippage, the owner not having enough shares, etc). + +NOTE: Some implementations will require pre-requesting to the Vault before a withdrawal may be performed. +Those methods should be performed separately. + +[#IERC4626-Events] +==== Events + +[.contract-item] +[[IERC4626-Deposit]] +==== `[.contract-item-name]#++Deposit++#++(sender: ContractAddress, owner: ContractAddress, assets: u256, shares: u256)++` [.item-kind]#event# + +Emitted when `sender` exchanges `assets` for `shares` and transfers those +`shares` to `owner`. + +[.contract-item] +[[IERC4626-Withdraw]] +==== `[.contract-item-name]#++Withdraw++#++(sender: ContractAddress, receiver: ContractAddress, owner: ContractAddress, assets: u256, shares: u256)++` [.item-kind]#event# + +Emitted when `sender` exchanges `shares`, owned by `owner`, for `assets` and transfers +those `assets` to `receiver`. + +[.contract] +[[ERC4626Component]] +=== `++ERC4626Component++` link:https://github.com/OpenZeppelin/cairo-contracts/blob/release-v0.20.0-rc.0/packages/token/src/erc20/extensions/erc4626/interface.cairo#L19[{github-icon},role=heading-link] + +[.hljs-theme-dark] +```cairo +use openzeppelin_token::erc20::extensions::erc4626::ERC4626Component; +``` + +Extension of ERC20 that implements the <> interface which allows the minting and burning of "shares" in exchange for an underlying "asset." +The component leverages traits to configure fees, limits, and decimals. + +[.contract-index] +.{immutable-config} +-- +.constants +* xref:#ERC4626Component-IC-UNDERLYING_DECIMALS[`++UNDERLYING_DECIMALS++`] +* xref:#ERC4626Component-IC-DECIMALS_OFFSET[`++DECIMALS_OFFSET++`] + +.functions +* xref:#ERC4626Component-IC-validate[`++validate()++`] +-- + +[.contract-index] +.Hooks +-- +[.sub-index#ERC4626Component-FeeConfigTrait] +.FeeConfigTrait +* xref:#ERC4626Component-adjust_deposit[`++adjust_deposit(self, assets)++`] +* xref:#ERC4626Component-adjust_mint[`++adjust_mint(self, assets)++`] +* xref:#ERC4626Component-adjust_withdraw[`++adjust_withdraw(self, assets)++`] +* xref:#ERC4626Component-adjust_redeem[`++adjust_redeem(self, assets)++`] + +[.sub-index#ERC4626Component-LimitConfigTrait] +.LimitConfigTrait +* xref:#ERC4626Component-deposit_limit[`++deposit_limit(self, receiver)++`] +* xref:#ERC4626Component-mint_limit[`++mint_limit(self, receiver)++`] +* xref:#ERC4626Component-withdraw_limit[`++withdraw_limit(self, owner)++`] +* xref:#ERC4626Component-redeem_limit[`++redeem_limit(self, owner)++`] + +[.sub-index#ERC4626Component-ERC4626HooksTrait] +.ERC4626HooksTrait +* xref:#ERC4626Component-before_withdraw[`++before_withdraw(self, assets, shares)++`] +* xref:#ERC4626Component-after_deposit[`++after_deposit(self, assets, shares)++`] +-- + +[.contract-index#ERC4626Component-Embeddable-Impls] +.Embeddable Implementations +-- + +.ERC4626Impl +* xref:#ERC4626Component-asset[`++asset(self)++`] +* xref:#ERC4626Component-total_assets[`++total_assets(self)++`] +* xref:#ERC4626Component-convert_to_shares[`++convert_to_shares(self, assets)++`] +* xref:#ERC4626Component-convert_to_assets[`++convert_to_assets(self, shares)++`] +* xref:#ERC4626Component-max_deposit[`++max_deposit(self, receiver)++`] +* xref:#ERC4626Component-preview_deposit[`++preview_deposit(self, assets)++`] +* xref:#ERC4626Component-deposit[`++deposit(self, assets, receiver)++`] +* xref:#ERC4626Component-max_mint[`++max_mint(self, receiver)++`] +* xref:#ERC4626Component-preview_mint[`++preview_mint(self, shares)++`] +* xref:#ERC4626Component-mint[`++mint(self, shares, receiver)++`] +* xref:#ERC4626Component-max_withdraw[`++max_withdraw(self, owner)++`] +* xref:#ERC4626Component-preview_withdraw[`++preview_withdraw(self, assets)++`] +* xref:#ERC4626Component-withdraw[`++withdraw(self, assets, receiver, owner)++`] +* xref:#ERC4626Component-max_redeem[`++max_redeem(self, owner)++`] +* xref:#ERC4626Component-preview_redeem[`++preview_redeem(self, shares)++`] +* xref:#ERC4626Component-redeem[`++redeem(self, shares, receiver, owner)++`] + +.ERC20Impl +* xref:#ERC20Component-total_supply[`++total_supply(self)++`] +* xref:#ERC20Component-balance_of[`++balance_of(self, account)++`] +* xref:#ERC20Component-allowance[`++allowance(self, owner, spender)++`] +* xref:#ERC20Component-transfer[`++transfer(self, recipient, amount)++`] +* xref:#ERC20Component-transfer_from[`++transfer_from(self, sender, recipient, amount)++`] +* xref:#ERC20Component-approve[`++approve(self, spender, amount)++`] + +.ERC4626MetadataImpl +* xref:#ERC4626Component-name[`++name(self)++`] +* xref:#ERC4626Component-symbol[`++symbol(self)++`] +* xref:#ERC4626Component-decimals[`++decimals(self)++`] + +-- + +[.contract-index] +.Internal functions +-- +.InternalImpl +* xref:#ERC4626Component-initializer[`++initializer(self, asset_address)++`] +* xref:#ERC4626Component-_deposit[`++_deposit(self, caller, receiver, assets, shares)++`] +* xref:#ERC4626Component-_withdraw[`++_withdraw(self, caller, receiver, owner, assets, shares)++`] +* xref:#ERC4626Component-_convert_to_shares[`++_convert_to_shares(self, assets, rounding)++`] +* xref:#ERC4626Component-_convert_to_assets[`++_convert_to_assets(self, shares, rounding)++`] +-- + +[#ERC4626Component-Immutable-Config] +==== Immutable Config + +[.contract-item] +[[ERC4626Component-IC-UNDERLYING_DECIMALS]] +==== `[.contract-item-name]#++UNDERLYING_DECIMALS:++#++ u128++` [.item-kind]#constant# + +Should match the underlying asset's decimals. +The default value is `18`. + +[.contract-item] +[[ERC4626Component-IC-DECIMALS_OFFSET]] +==== `[.contract-item-name]#++DECIMALS_OFFSET:++#++ u128++` [.item-kind]#constant# + +Corresponds to the representational offset between `UNDERLYING_DECIMALS` and the vault decimals. +The greater the offset, the more expensive it is for attackers to execute an inflation attack. + +[.contract-item] +[[ERC4626Component-IC-validate]] +==== `[.contract-item-name]#++validate++#++()++` [.item-kind]#internal# + +Validates the given implementation of the contract's configuration. + +Requirements: + +- `UNDERLYING_DECIMALS` + `DECIMALS_OFFSET` cannot exceed 255 (max u8). + +NOTE: This function is called by the contract's initializer. + +[#ERC4626Component-Hooks] +==== Hooks + +Hooks are functions which implementations can extend the functionality of the component source code. +Every contract using ERC4626Component is expected to provide an implementation of the ERC4626HooksTrait. +For basic token contracts, an empty implementation with no logic must be provided. + +TIP: You can use `openzeppelin_token::erc20::extensions::erc4626::ERC4626HooksEmptyImpl` which is already available as part of the library for this purpose. + +==== FeeConfigTrait + +:mock-ex: xref:https://github.com/OpenZeppelin/cairo-contracts/tree/main/packages/test_common/src/mocks/erc4626.cairo[ERC4626FeesMock example] + +Adjustments for fees expected to be defined at the contract level. +Defaults to no entry or exit fees. + +NOTE: The FeeConfigTrait hooks directly into the preview methods of the ERC4626 component. +The preview methods must return as close to the exact amount of shares or assets as possible if the actual (previewed) operation occurred in the same transaction (according to {eip4626} spec). +All operations use their corresponding preview method as the value of assets or shares being moved. +Therefore, adjusting an operation's assets in FeeConfigTrait consequently adjusts the assets (or assets to be converted into shares) in both the preview operation and the actual operation. + +NOTE: To transfer fees, this trait needs to be coordinated with `ERC4626Component::ERC4626Hooks`. +See the {mock-ex}. + +[.contract-item] +[[ERC4626Component-adjust_deposit]] +==== `[.contract-item-name]#++adjust_deposit++#++(ref self: ContractState, assets: u256, shares: u256)++` [.item-kind]#hook# + +Adjusts deposits within <> to account for entry fees. +Entry fees should be transferred in the <> hook. + +[.contract-item] +[[ERC4626Component-adjust_mint]] +==== `[.contract-item-name]#++adjust_mint++#++(ref self: ContractState, assets: u256, shares: u256)++` [.item-kind]#hook# + +Adjusts deposits within <> to account for entry fees. +Entry fees should be transferred in the <> hook. + +[.contract-item] +[[ERC4626Component-adjust_withdraw]] +==== `[.contract-item-name]#++adjust_withdraw++#++(ref self: ContractState, assets: u256, shares: u256)++` [.item-kind]#hook# + +Adjusts withdraws within <> to account for exit fees. +Exit fees should be transferred in the <> hook. + +[.contract-item] +[[ERC4626Component-adjust_redeem]] +==== `[.contract-item-name]#++adjust_redeem++#++(ref self: ContractState, assets: u256, shares: u256)++` [.item-kind]#hook# + +Adjusts withdraws within <> to account for exit fees. +Exit fees should be transferred in the <> hook. + +==== LimitConfigTrait + +:mock-ex: xref:https://github.com/OpenZeppelin/cairo-contracts/tree/main/packages/test_common/src/mocks/erc4626.cairo[ERC4626FeesMock example] + +Sets limits to the target exchange type and is expected to be defined at the contract +level. These limits correspond directly to the `max_` i.e. `deposit_limit` -> `max_deposit`. + +NOTE: The {eip4626} spec states that the `max_` methods must take into account all +global and user-specific limits. +If an operation is disabled (even temporarily), the corresponding limit MUST be `0` +and MUST NOT panic. + +[.contract-item] +[[ERC4626Component-deposit_limit]] +==== `[.contract-item-name]#++deposit_limit++#++(ref self: ContractState, receiver: ContractAddress) -> Option++` [.item-kind]#hook# + +The max deposit allowed. +Defaults (`Option::None`) to 2 ** 256 - 1. + +[.contract-item] +[[ERC4626Component-mint_limit]] +==== `[.contract-item-name]#++mint_limit++#++(ref self: ContractState, receiver: ContractAddress) -> Option++` [.item-kind]#hook# + +The max mint allowed. +Defaults (`Option::None`) to 2 ** 256 - 1. + +[.contract-item] +[[ERC4626Component-withdraw_limit]] +==== `[.contract-item-name]#++withdraw_limit++#++(ref self: ContractState, owner: ContractAddress) -> Option++` [.item-kind]#hook# + +The max withdraw allowed. +Defaults (`Option::None`) to the full asset balance of `owner` converted from shares. + +[.contract-item] +[[ERC4626Component-redeem_limit]] +==== `[.contract-item-name]#++redeem_limit++#++(ref self: ContractState, owner: ContractAddress) -> Option++` [.item-kind]#hook# + +The max redeem allowed. +Defaults (`Option::None`) to the full asset balance of `owner`. + +==== ERC4626HooksTrait + +:mock-ex: xref:https://github.com/OpenZeppelin/cairo-contracts/tree/main/packages/test_common/src/mocks/erc4626.cairo[mock example] + +Allows contracts to hook logic into deposit and withdraw transactions. +This is where contracts can transfer fees. + +NOTE: ERC4626 preview methods must be inclusive of any entry or exit fees. +The `AdjustFeesTrait` will adjust these values accordingly; therefore, +fees must be set in the `AdjustFeesTrait` if the using contract enforces entry or exit fees. +See the {mock-ex}. + +[.contract-item] +[[ERC4626Component-before_withdraw]] +==== `[.contract-item-name]#++before_withdraw++#++(ref self: ContractState, assets: u256, shares: u256)++` [.item-kind]#hook# + +Hooks into xref:#ERC4626Component-_withdraw[_withdraw]. +Executes logic before burning shares and transferring assets. + +[.contract-item] +[[ERC4626Component-after_deposit]] +==== `[.contract-item-name]#++after_deposit++#++(ref self: ContractState, assets: u256, shares: u256)++` [.item-kind]#hook# + +Hooks into xref:#ERC4626Component-_deposit[_deposit]. +Executes logic after transferring assets and minting shares. + +==== Embeddable functions + +[.contract-item] +[[ERC4626Component-asset]] +==== `[.contract-item-name]#++asset++#++(self: @ContractState) → ContractAddress++` [.item-kind]#external# + +Returns the address of the underlying token used for the Vault for accounting, depositing, and withdrawing. + +[.contract-item] +[[ERC4626Component-total_assets]] +==== `[.contract-item-name]#++total_assets++#++(self: @ContractState) → u256++` [.item-kind]#external# + +Returns the total amount of the underlying asset that is “managed” by Vault. + +[.contract-item] +[[ERC4626Component-convert_to_shares]] +==== `[.contract-item-name]#++convert_to_shares++#++(self: @ContractState, assets: u256) → u256++` [.item-kind]#external# + +Returns the amount of shares that the Vault would exchange for the amount of assets provided irrespective of slippage or fees. + +NOTE: As per the {eip4626} spec, this may panic _only_ if there's an overflow from an unreasonably large input. + +[.contract-item] +[[ERC4626Component-convert_to_assets]] +==== `[.contract-item-name]#++convert_to_assets++#++(self: @ContractState, shares: u256) → u256++` [.item-kind]#external# + +Returns the amount of assets that the Vault would exchange for the amount of shares provided irrespective of slippage or fees. + +NOTE: As per the {eip4626} spec, this may panic _only_ if there's an overflow from an unreasonably large input. + +[.contract-item] +[[ERC4626Component-max_deposit]] +==== `[.contract-item-name]#++max_deposit++#++(self: @ContractState, receiver: ContractAddress) → u256++` [.item-kind]#external# + +Returns the maximum amount of the underlying asset that can be deposited into the Vault for the `receiver`, through a <> call. + +The default max deposit value is 2 ** 256 - 1. + +This can be changed in the implementing contract by defining custom logic in +<>. + +[.contract-item] +[[ERC4626Component-preview_deposit]] +==== `[.contract-item-name]#++preview_deposit++#++(self: @ContractState, assets: u256) → u256++` [.item-kind]#external# + +Allows an on-chain or off-chain user to simulate the effects of their deposit at the +current block, given current on-chain conditions. + +The default deposit preview value is the full amount of shares. +This can be changed to account for fees, for example, in the implementing contract by +defining custom logic in <>. + +NOTE: This method must be inclusive of entry fees to be compliant with the {eip4626} spec. + +[.contract-item] +[[ERC4626Component-deposit]] +==== `[.contract-item-name]#++deposit++#++(ref self: ContractState, assets: u256, receiver: ContractAddress) → u256++` [.item-kind]#external# + +Mints Vault shares to `receiver` by depositing exactly `assets` of underlying tokens. +Returns the amount of newly-minted shares. + +Requirements: + +- `assets` is less than or equal to the max deposit amount for `receiver`. + +Emits a <> event. + +[.contract-item] +[[ERC4626Component-max_mint]] +==== `[.contract-item-name]#++max_mint++#++(self: @ContractState, receiver: ContractAddress) → u256++` [.item-kind]#external# + +Returns the maximum amount of the Vault shares that can be minted for `receiver` through +a <> call. + +The default max mint value is 2 ** 256 - 1. + +This can be changed in the implementing contract by defining custom logic in <>. + +[.contract-item] +[[ERC4626Component-preview_mint]] +==== `[.contract-item-name]#++preview_mint++#++(self: @ContractState, shares: u256) → u256++` [.item-kind]#external# + +Allows an on-chain or off-chain user to simulate the effects of their mint at the +current block, given current on-chain conditions. + +The default mint preview value is the full amount of assets. +This can be changed to account for fees, for example, in the implementing contract by +defining custom logic in <>. + +NOTE: This method must be inclusive of entry fees to be compliant with the {eip4626} spec. + +[.contract-item] +[[ERC4626Component-mint]] +==== `[.contract-item-name]#++mint++#++(self: @ContractState, shares: u256, receiver: ContractAddress) → u256++` [.item-kind]#external# + +Mints exactly Vault `shares` to `receiver` by depositing amount of underlying tokens. +Returns the amount deposited assets. + +Requirements: + +- `shares` is less than or equal to the max shares amount for `receiver`. + +Emits a <> event. + +[.contract-item] +[[ERC4626Component-max_withdraw]] +==== `[.contract-item-name]#++max_withdraw++#++(self: @ContractState, owner: ContractAddress) → u256++` [.item-kind]#external# + +Returns the maximum amount of the underlying asset that can be withdrawn from the owner +balance in the Vault, through a <> call. + +The default max withdraw value is the full balance of assets for `owner` (converted from shares). +This can be changed in the implementing contract by defining custom logic in <>. + +NOTE: With customized limits, the maximum withdraw amount will either be the custom limit itself +or ``owner``'s total asset balance, whichever value is less. + +[.contract-item] +[[ERC4626Component-preview_withdraw]] +==== `[.contract-item-name]#++preview_withdraw++#++(self: @ContractState, assets: u256) → u256++` [.item-kind]#external# + +Allows an on-chain or off-chain user to simulate the effects of their withdrawal at the +current block, given current on-chain conditions. + +The default withdraw preview value is the full amount of shares. +This can be changed to account for fees, for example, in the implementing contract by defining custom logic in <>. + +NOTE: This method must be inclusive of exit fees to be compliant with the {eip4626} spec. + +[.contract-item] +[[ERC4626Component-withdraw]] +==== `[.contract-item-name]#++withdraw++#++(self: @ContractState, assets: u256, receiver: ContractAddress, owner: ContractAddress) → u256++` [.item-kind]#external# + +Burns shares from `owner` and sends exactly `assets` of underlying tokens to `receiver`. + +Requirements: + +- `assets` is less than or equal to the max withdraw amount of `owner`. + +Emits a <> event. + +[.contract-item] +[[ERC4626Component-max_redeem]] +==== `[.contract-item-name]#++max_redeem++#++(self: @ContractState, owner: ContractAddress) → u256++` [.item-kind]#external# + +Returns the maximum amount of Vault shares that can be redeemed from the owner balance +in the Vault, through a <> call. + +The default max redeem value is the full balance of assets for `owner`. +This can be changed in the implementing contract by defining custom logic in <>. + +NOTE: With customized limits, the maximum redeem amount will either be the custom limit itself +or ``owner``'s total asset balance, whichever value is less. + +[.contract-item] +[[ERC4626Component-preview_redeem]] +==== `[.contract-item-name]#++preview_redeem++#++(self: @ContractState, shares: u256) → u256++` [.item-kind]#external# + +Allows an on-chain or off-chain user to simulate the effects of their redeemption at the +current block, given current on-chain conditions. + +The default redeem preview value is the full amount of assets. +This can be changed to account for fees, for example, in the implementing contract by defining custom logic in <>. + +NOTE: This method must be inclusive of exit fees to be compliant with the {eip4626} spec. + +[.contract-item] +[[ERC4626Component-redeem]] +==== `[.contract-item-name]#++redeem++#++(self: @ContractState, shares: u256, receiver: ContractAddress, owner: ContractAddress) → u256++` [.item-kind]#external# + +Burns exactly `shares` from `owner` and sends assets of underlying tokens to `receiver`. + +Requirements: + +- `shares` is less than or equal to the max redeem amount of `owner`. + +Emits a <> event. + +[.contract-item] +[[ERC4626Component-name]] +==== `[.contract-item-name]#++name++#++(self: @ContractState) → ByteArray++` [.item-kind]#external# + +Returns the name of the token. + +[.contract-item] +[[ERC4626Component-symbol]] +==== `[.contract-item-name]#++symbol++#++(self: @ContractState) → ByteArray++` [.item-kind]#external# + +Returns the ticker symbol of the token, usually a shorter version of the name. + +[.contract-item] +[[ERC4626Component-decimals]] +==== `[.contract-item-name]#++decimals++#++(self: @ContractState) → u8++` [.item-kind]#external# + +Returns the cumulative number of decimals which includes both `UNDERLYING_DECIMALS` and `OFFSET_DECIMALS`. +Both of which must be defined in the <> inside the implementing contract. + +==== Internal functions + +[.contract-item] +[[ERC4626Component-initializer]] +==== `[.contract-item-name]#++initializer++#++(ref self: ContractState, asset_address: ContractAddress)++` [.item-kind]#internal# + +Validates the <> constants and sets the `asset_address` to the vault. +This should be set in the contract's constructor. + +Requirements: + +- `asset_address` cannot be the zero address. + +[.contract-item] +[[ERC4626Component-_deposit]] +==== `[.contract-item-name]#++_deposit++#++(ref self: ContractState, caller: ContractAddress, receiver: ContractAddress, assets: u256, shares: u256)++` [.item-kind]#internal# + +Internal logic for <> and <>. + +Transfers `assets` from `caller` to the Vault contract then mints `shares` to +`receiver`. +Fees can be transferred in the `ERC4626Hooks::after_deposit` hook which is executed +after assets are transferred and shares are minted. + +Requirements: + +- <> must return true. + +Emits two <> events (`ERC20::mint` and `ERC20::transfer_from`). + +Emits a <> event. + +[.contract-item] +[[ERC4626Component-_withdraw]] +==== `[.contract-item-name]#++_withdraw++#++(ref self: ContractState, caller: ContractAddress, receiver: ContractAddress, owner: ContractAddress, assets: u256, shares: u256)++` [.item-kind]#internal# + +Internal logic for <> and <>. + +Burns `shares` from `owner` and then transfers `assets` to `receiver`. +Fees can be transferred in the `ERC4626Hooks::before_withdraw` hook which is executed +before shares are burned and assets are transferred. + +Requirements: + +- <> must return true. + +Emits two <> events (`ERC20::burn` and `ERC20::transfer`). + +Emits a <> event. + +[.contract-item] +[[ERC4626Component-_convert_to_shares]] +==== `[.contract-item-name]#++_convert_to_shares++#++(self: @ContractState, assets: u256, rounding: Rounding) -> u256++` [.item-kind]#internal# + +Internal conversion function (from assets to shares) with support for `rounding` direction. + +[.contract-item] +[[ERC4626Component-_convert_to_assets]] +==== `[.contract-item-name]#++_convert_to_assets++#++(self: @ContractState, shares: u256, rounding: Rounding) -> u256++` [.item-kind]#internal# + +Internal conversion function (from shares to assets) with support for `rounding` direction. + == Presets [.contract] @@ -622,4 +1466,4 @@ Upgrades the contract to a new implementation given by `new_class_hash`. Requirements: - The caller is the contract owner. -- `new_class_hash` cannot be zero. \ No newline at end of file +- `new_class_hash` cannot be zero. diff --git a/docs/modules/ROOT/pages/guides/erc4626.adoc b/docs/modules/ROOT/pages/guides/erc4626.adoc new file mode 100644 index 000000000..955cc717b --- /dev/null +++ b/docs/modules/ROOT/pages/guides/erc4626.adoc @@ -0,0 +1,469 @@ += ERC4626 +:stem: latexmath + +https://eips.ethereum.org/EIPS/eip-4626[ERC4626] is an extension of xref:erc20.adoc[ERC20] that proposes a standard interface for token vaults. This standard interface can be used by widely different contracts (including lending markets, aggregators, and intrinsically interest bearing tokens), which brings a number of subtleties. Navigating these potential issues is essential to implementing a compliant and composable token vault. + +We provide a base component of ERC4626 which is designed to allow developers to easily re-configure the vault's behavior, using traits and hooks, while staying compliant. In this guide, we will discuss some security considerations that affect ERC4626. We will also discuss common customizations of the vault. + +[[inflation-attack]] +== Security concern: Inflation attack + +=== Visualizing the vault + +In exchange for the assets deposited into an ERC4626 vault, a user receives shares. These shares can later be burned to redeem the corresponding underlying assets. The number of shares a user gets depends on the amount of assets they put in and on the exchange rate of the vault. This exchange rate is defined by the current liquidity held by the vault. + +- If a vault has 100 tokens to back 200 shares, then each share is worth 0.5 assets. +- If a vault has 200 tokens to back 100 shares, then each share is worth 2.0 assets. + +In other words, the exchange rate can be defined as the slope of the line that passes through the origin and the current number of assets and shares in the vault. Deposits and withdrawals move the vault in this line. + +image::erc4626-rate-linear.png[Exchange rates in linear scale] + +When plotted in log-log scale, the rate is defined similarly, but appears differently (because the point (0,0) is infinitely far away). Rates are represented by "diagonal" lines with different offsets. + +image::erc4626-rate-loglog.png[Exchange rates in logarithmic scale] + +In such a representation, widely different rates can be clearly visible in the same graph. This wouldn't be the case in linear scale. + +image::erc4626-rate-loglogext.png[More exchange rates in logarithmic scale] + +=== The attack + +When depositing tokens, the number of shares a user gets is rounded towards zero. This rounding takes away value from the user in favor of the vault (i.e. in favor of all the current shareholders). This rounding is often negligible because of the amount at stake. If you deposit 1e9 shares worth of tokens, the rounding will have you lose at most 0.0000001% of your deposit. However if you deposit 10 shares worth of tokens, you could lose 10% of your deposit. Even worse, if you deposit <1 share worth of tokens, then you get 0 shares, and you basically made a donation. + +For a given amount of assets, the more shares you receive the safer you are. If you want to limit your losses to at most 1%, you need to receive at least 100 shares. + +image::erc4626-deposit.png[Depositing assets] + +In the figure we can see that for a given deposit of 500 assets, the number of shares we get and the corresponding rounding losses depend on the exchange rate. If the exchange rate is that of the orange curve, we are getting less than a share, so we lose 100% of our deposit. However, if the exchange rate is that of the green curve, we get 5000 shares, which limits our rounding losses to at most 0.02%. + +image::erc4626-mint.png[Minting shares] + +Symmetrically, if we focus on limiting our losses to a maximum of 0.5%, we need to get at least 200 shares. With the green exchange rate that requires just 20 tokens, but with the orange rate that requires 200000 tokens. + +We can clearly see that the blue and green curves correspond to vaults that are safer than the yellow and orange curves. + +The idea of an inflation attack is that an attacker can donate assets to the vault to move the rate curve to the right, and make the vault unsafe. + +image::erc4626-attack.png[Inflation attack without protection] + +Figure 6 shows how an attacker can manipulate the rate of an empty vault. First the attacker must deposit a small amount of tokens (1 token) and follow up with a donation of 1e5 tokens directly to the vault to move the exchange rate "right". This puts the vault in a state where any deposit smaller than 1e5 would be completely lost to the vault. Given that the attacker is the only shareholder (from their donation), the attacker would steal all the tokens deposited. + +An attacker would typically wait for a user to do the first deposit into the vault, and would frontrun that operation with the attack described above. The risk is low, and the size of the "donation" required to manipulate the vault is equivalent to the size of the deposit that is being attacked. + +In math that gives: + +- stem:[a_0] the attacker deposit +- stem:[a_1] the attacker donation +- stem:[u] the user deposit + +[%header,cols=4*] +|=== +| +| Assets +| Shares +| Rate + +| initial +| stem:[0] +| stem:[0] +| - + +| after attacker's deposit +| stem:[a_0] +| stem:[a_0] +| stem:[1] + +| after attacker's donation +| stem:[a_0+a_1] +| stem:[a_0] +| stem:[\frac{a_0}{a_0+a_1}] +|=== + +This means a deposit of stem:[u] will give stem:[\frac{u \times a_0}{a_0 + a_1}] shares. + +For the attacker to dilute that deposit to 0 shares, causing the user to lose all its deposit, it must ensure that + +[stem] +++++ +\frac{u \times a_0}{a_0+a_1} < 1 \iff u < 1 + \frac{a_1}{a_0} +++++ + +Using stem:[a_0 = 1] and stem:[a_1 = u] is enough. So the attacker only needs stem:[u+1] assets to perform a successful attack. + +It is easy to generalize the above results to scenarios where the attacker is going after a smaller fraction of the user's deposit. In order to target stem:[\frac{u}{n}], the user needs to suffer rounding of a similar fraction, which means the user must receive at most stem:[n] shares. This results in: + +[stem] +++++ +\frac{u \times a_0}{a_0+a_1} < n \iff \frac{u}{n} < 1 + \frac{a_1}{a_0} +++++ + +In this scenario, the attack is stem:[n] times less powerful (in how much it is stealing) and costs stem:[n] times less to execute. In both cases, the amount of funds the attacker needs to commit is equivalent to its potential earnings. + +=== Defending with a virtual offset + +The defense we propose is based on the approach used in link:https://github.com/boringcrypto/YieldBox[YieldBox]. It consists of two parts: + +- Use an offset between the "precision" of the representation of shares and assets. Said otherwise, we use more decimal places to represent the shares than the underlying token does to represent the assets. +- Include virtual shares and virtual assets in the exchange rate computation. These virtual assets enforce the conversion rate when the vault is empty. + +These two parts work together in enforcing the security of the vault. First, the increased precision corresponds to a high rate, which we saw is safer as it reduces the rounding error when computing the amount of shares. Second, the virtual assets and shares (in addition to simplifying a lot of the computations) capture part of the donation, making it unprofitable for a developer to perform an attack. + +Following the previous math definitions, we have: + +- stem:[\delta] the vault offset +- stem:[a_0] the attacker deposit +- stem:[a_1] the attacker donation +- stem:[u] the user deposit + +[%header,cols=4*] +|=== +| +| Assets +| Shares +| Rate + +| initial +| stem:[1] +| stem:[10^\delta] +| stem:[10^\delta] + +| after attacker's deposit +| stem:[1+a_0] +| stem:[10^\delta \times (1+a_0)] +| stem:[10^\delta] + +| after attacker's donation +| stem:[1+a_0+a_1] +| stem:[10^\delta \times (1+a_0)] +| stem:[10^\delta \times \frac{1+a_0}{1+a_0+a_1}] +|=== + +One important thing to note is that the attacker only owns a fraction stem:[\frac{a_0}{1 + a_0}] of the shares, so when doing the donation, he will only be able to recover that fraction stem:[\frac{a_1 \times a_0}{1 + a_0}] of the donation. The remaining stem:[\frac{a_1}{1+a_0}] are captured by the vault. + +[stem] +++++ +\mathit{loss} = \frac{a_1}{1+a_0} +++++ + +When the user deposits stem:[u], he receives + +[stem] +++++ +10^\delta \times u \times \frac{1+a_0}{1+a_0+a_1} +++++ + +For the attacker to dilute that deposit to 0 shares, causing the user to lose all its deposit, it must ensure that + +[stem] +++++ +10^\delta \times u \times \frac{1+a_0}{1+a_0+a_1} < 1 +++++ + +[stem] +++++ +\iff 10^\delta \times u < \frac{1+a_0+a_1}{1+a_0} +++++ + +[stem] +++++ +\iff 10^\delta \times u < 1 + \frac{a_1}{1+a_0} +++++ + +[stem] +++++ +\iff 10^\delta \times u \le \mathit{loss} +++++ + +- If the offset is 0, the attacker loss is at least equal to the user's deposit. +- If the offset is greater than 0, the attacker will have to suffer losses that are orders of magnitude bigger than the amount of value that can hypothetically be stolen from the user. + +This shows that even with an offset of 0, the virtual shares and assets make this attack non profitable for the attacker. Bigger offsets increase the security even further by making any attack on the user extremely wasteful. + +The following figure shows how the offset impacts the initial rate and limits the ability of an attacker with limited funds to inflate it effectively. + +image::erc4626-attack-3a.png[Inflation attack without offset=3] +stem:[\delta = 3], stem:[a_0 = 1], stem:[a_1 = 10^5] + +image::erc4626-attack-3b.png[Inflation attack without offset=3 and an attacker deposit that limits its losses] +stem:[\delta = 3], stem:[a_0 = 100], stem:[a_1 = 10^5] + +image::erc4626-attack-6.png[Inflation attack without offset=6] +stem:[\delta = 6], stem:[a_0 = 1], stem:[a_1 = 10^5] + +== Usage + +[[fees]] +=== Custom behavior: Adding fees to the vault + +In ERC4626 vaults, fees can be captured during the deposit/mint and/or during the withdraw/redeem steps. +In both cases, it is essential to remain compliant with the ERC4626 requirements in regard to the preview functions. + +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)`. +Similarly, `preview_mint` should account for the fees that the user will have to pay on top of share's cost. + +As for the `Deposit` event, while this is less clear in the EIP spec itself, +there seems to be consensus that it should include the number of assets paid for by the user, including the fees. + +On the other hand, when withdrawing assets, the number given by the user should correspond to what the user receives. +Any fees should be added to the quote (in shares) performed by `preview_withdraw`. + +The `Withdraw` event should include the number of shares the user burns (including fees) and the number of assets the user actually receives (after fees are deducted). + +The consequence of this design is that both the `Deposit` and `Withdraw` events will describe two exchange rates. +The spread between the "Buy-in" and the "Exit" prices correspond to the fees taken by the vault. + +The following example describes how fees proportional to the deposited/withdrawn amount can be implemented: + +```cairo +/// The mock contract charges fees in terms of assets, not shares. +/// This means that the fees are calculated based on the amount of assets that are being deposited +/// or withdrawn, and not based on the amount of shares that are being minted or redeemed. +/// This is an opinionated design decision for the purpose of testing. +/// DO NOT USE IN PRODUCTION +#[starknet::contract] +pub mod ERC4626Fees { + use openzeppelin_token::erc20::extensions::erc4626::ERC4626Component; + use openzeppelin_token::erc20::extensions::erc4626::ERC4626Component::FeeConfigTrait; + use openzeppelin_token::erc20::extensions::erc4626::ERC4626Component::InternalTrait as ERC4626InternalTrait; + use openzeppelin_token::erc20::extensions::erc4626::{DefaultConfig, ERC4626DefaultLimits}; + use openzeppelin_token::erc20::interface::{IERC20Dispatcher, IERC20DispatcherTrait}; + use openzeppelin_token::erc20::{ERC20Component, ERC20HooksEmptyImpl}; + use openzeppelin_utils::math; + use openzeppelin_utils::math::Rounding; + use starknet::ContractAddress; + use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess}; + + component!(path: ERC4626Component, storage: erc4626, event: ERC4626Event); + component!(path: ERC20Component, storage: erc20, event: ERC20Event); + + // ERC4626 + #[abi(embed_v0)] + impl ERC4626ComponentImpl = ERC4626Component::ERC4626Impl; + // ERC4626MetadataImpl is a custom impl of IERC20Metadata + #[abi(embed_v0)] + impl ERC4626MetadataImpl = ERC4626Component::ERC4626MetadataImpl; + + // ERC20 + #[abi(embed_v0)] + impl ERC20Impl = ERC20Component::ERC20Impl; + #[abi(embed_v0)] + impl ERC20CamelOnlyImpl = ERC20Component::ERC20CamelOnlyImpl; + + impl ERC4626InternalImpl = ERC4626Component::InternalImpl; + impl ERC20InternalImpl = ERC20Component::InternalImpl; + + #[storage] + pub struct Storage { + #[substorage(v0)] + pub erc4626: ERC4626Component::Storage, + #[substorage(v0)] + pub erc20: ERC20Component::Storage, + pub entry_fee_basis_point_value: u256, + pub entry_fee_recipient: ContractAddress, + pub exit_fee_basis_point_value: u256, + pub exit_fee_recipient: ContractAddress, + } + + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + #[flat] + ERC4626Event: ERC4626Component::Event, + #[flat] + ERC20Event: ERC20Component::Event, + } + + const _BASIS_POINT_SCALE: u256 = 10_000; + + /// Hooks + impl ERC4626HooksEmptyImpl of ERC4626Component::ERC4626HooksTrait { + fn after_deposit( + ref self: ERC4626Component::ComponentState, assets: u256, shares: u256, + ) { + let mut contract_state = self.get_contract_mut(); + let entry_basis_points = contract_state.entry_fee_basis_point_value.read(); + let fee = contract_state.fee_on_total(assets, entry_basis_points); + let recipient = contract_state.entry_fee_recipient.read(); + + if (fee > 0 && recipient != starknet::get_contract_address()) { + contract_state.transfer_fees(recipient, fee); + } + } + + fn before_withdraw( + ref self: ERC4626Component::ComponentState, assets: u256, shares: u256, + ) { + let mut contract_state = self.get_contract_mut(); + let exit_basis_points = contract_state.exit_fee_basis_point_value.read(); + let fee = contract_state.fee_on_raw(assets, exit_basis_points); + let recipient = contract_state.exit_fee_recipient.read(); + + if (fee > 0 && recipient != starknet::get_contract_address()) { + contract_state.transfer_fees(recipient, fee); + } + } + } + + /// Adjust fees + impl AdjustFeesImpl of FeeConfigTrait { + fn adjust_deposit( + self: @ERC4626Component::ComponentState, assets: u256, + ) -> u256 { + let contract_state = self.get_contract(); + contract_state.remove_fee_from_deposit(assets) + } + + fn adjust_mint( + self: @ERC4626Component::ComponentState, assets: u256, + ) -> u256 { + let contract_state = ERC4626Component::HasComponent::get_contract(self); + contract_state.add_fee_to_mint(assets) + } + + fn adjust_withdraw( + self: @ERC4626Component::ComponentState, assets: u256, + ) -> u256 { + let contract_state = ERC4626Component::HasComponent::get_contract(self); + contract_state.add_fee_to_withdraw(assets) + } + + fn adjust_redeem( + self: @ERC4626Component::ComponentState, assets: u256, + ) -> u256 { + let contract_state = ERC4626Component::HasComponent::get_contract(self); + contract_state.remove_fee_from_redeem(assets) + } + } + + #[constructor] + fn constructor( + ref self: ContractState, + name: ByteArray, + symbol: ByteArray, + underlying_asset: ContractAddress, + initial_supply: u256, + recipient: ContractAddress, + entry_fee: u256, + entry_treasury: ContractAddress, + exit_fee: u256, + exit_treasury: ContractAddress, + ) { + self.erc20.initializer(name, symbol); + self.erc20.mint(recipient, initial_supply); + self.erc4626.initializer(underlying_asset); + + self.entry_fee_basis_point_value.write(entry_fee); + self.entry_fee_recipient.write(entry_treasury); + self.exit_fee_basis_point_value.write(exit_fee); + self.exit_fee_recipient.write(exit_treasury); + } + + #[generate_trait] + pub impl InternalImpl of InternalTrait { + fn transfer_fees(ref self: ContractState, recipient: ContractAddress, fee: u256) { + let asset_address = self.asset(); + let asset_dispatcher = IERC20Dispatcher { contract_address: asset_address }; + assert(asset_dispatcher.transfer(recipient, fee), 'Fee transfer failed'); + } + + fn remove_fee_from_deposit(self: @ContractState, assets: u256) -> u256 { + let fee = self.fee_on_total(assets, self.entry_fee_basis_point_value.read()); + assets - fee + } + + fn add_fee_to_mint(self: @ContractState, assets: u256) -> u256 { + assets + self.fee_on_raw(assets, self.entry_fee_basis_point_value.read()) + } + + fn add_fee_to_withdraw(self: @ContractState, assets: u256) -> u256 { + let fee = self.fee_on_raw(assets, self.exit_fee_basis_point_value.read()); + assets + fee + } + + fn remove_fee_from_redeem(self: @ContractState, assets: u256) -> u256 { + assets - self.fee_on_total(assets, self.exit_fee_basis_point_value.read()) + } + + /// + /// Fee operations + /// + + /// Calculates the fees that should be added to an amount `assets` that does not already + /// include fees. + /// Used in IERC4626::mint and IERC4626::withdraw operations. + fn fee_on_raw(self: @ContractState, assets: u256, fee_basis_points: u256) -> u256 { + math::u256_mul_div(assets, fee_basis_points, _BASIS_POINT_SCALE, Rounding::Ceil) + } + + /// Calculates the fee part of an amount `assets` that already includes fees. + /// Used in IERC4626::deposit and IERC4626::redeem operations. + fn fee_on_total(self: @ContractState, assets: u256, fee_basis_points: u256) -> u256 { + math::u256_mul_div( + assets, fee_basis_points, fee_basis_points + _BASIS_POINT_SCALE, Rounding::Ceil, + ) + } + } +} +``` + +== Interface + +:erc4626-component: xref:/api/erc20.adoc#ERC4626Component[ERC4626Component] +:ierc4626: xref:/api/erc20.adoc#IERC4626[IERC4626] +:ierc20: xref:/api/erc20.adoc#IERC20[IERC20] +:ierc20-metadata: xref:/api/erc20.adoc#IERC20Metadata[IERC20Metadata] + +The following interface represents the full ABI of the Contracts for Cairo {erc4626-component}. +The full interface includes the {ierc4626}, {ierc20}, and {ierc20-metadata} interfaces. +Note that implementing the IERC20Metadata interface is a requirement of IERC4626. + +[,cairo] +---- +#[starknet::interface] +pub trait ERC4626ABI { + // IERC4626 + fn asset() -> ContractAddress; + fn total_assets() -> u256; + fn convert_to_shares(assets: u256) -> u256; + fn convert_to_assets(shares: u256) -> u256; + fn max_deposit(receiver: ContractAddress) -> u256; + fn preview_deposit(assets: u256) -> u256; + fn deposit(assets: u256, receiver: ContractAddress) -> u256; + fn max_mint(receiver: ContractAddress) -> u256; + fn preview_mint(shares: u256) -> u256; + fn mint(shares: u256, receiver: ContractAddress) -> u256; + fn max_withdraw(owner: ContractAddress) -> u256; + fn preview_withdraw(assets: u256) -> u256; + fn withdraw( + assets: u256, receiver: ContractAddress, owner: ContractAddress, + ) -> u256; + fn max_redeem(owner: ContractAddress) -> u256; + fn preview_redeem(shares: u256) -> u256; + fn redeem( + shares: u256, receiver: ContractAddress, owner: ContractAddress, + ) -> u256; + + // IERC20 + fn total_supply() -> u256; + fn balance_of(account: ContractAddress) -> u256; + fn allowance(owner: ContractAddress, spender: ContractAddress) -> u256; + fn transfer(recipient: ContractAddress, amount: u256) -> bool; + fn transfer_from( + sender: ContractAddress, recipient: ContractAddress, amount: u256, + ) -> bool; + fn approve(spender: ContractAddress, amount: u256) -> bool; + + // IERC20Metadata + fn name() -> ByteArray; + fn symbol() -> ByteArray; + fn decimals() -> u8; + + // IERC20CamelOnly + fn totalSupply() -> u256; + fn balanceOf(account: ContractAddress) -> u256; + fn transferFrom( + sender: ContractAddress, recipient: ContractAddress, amount: u256, + ) -> bool; +} +----