diff --git a/.gitmodules b/.gitmodules index a074a2c2..3acf41a0 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,12 +1,12 @@ [submodule "lib/forge-std"] path = lib/forge-std url = https://github.com/foundry-rs/forge-std -[submodule "lib/ethereum-vault-connector"] - path = lib/ethereum-vault-connector - url = https://github.com/euler-xyz/ethereum-vault-connector [submodule "lib/permit2"] path = lib/permit2 url = https://github.com/Uniswap/permit2 [submodule "lib/openzeppelin-contracts"] path = lib/openzeppelin-contracts url = https://github.com/OpenZeppelin/openzeppelin-contracts +[submodule "lib/ethereum-vault-connector"] + path = lib/ethereum-vault-connector + url = https://github.com/euler-xyz/ethereum-vault-connector diff --git a/docs/specs.md b/docs/specs.md index f48bce45..4c7c7a66 100644 --- a/docs/specs.md +++ b/docs/specs.md @@ -1,8 +1,8 @@ |Requirement ID |Title |Description | |------|--------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| |EVK-60|Beacon Proxy |The [beacon proxy](https://docs.openzeppelin.com/contracts/4.x/api/proxy#beacon) is a proxy for which the beacon contract is the `msg.sender` address which constructs it. The constructor allows to pass up to 128 bytes of `trailingData` that must be appended to any `delegatecall` performed by the proxy. | -|EVK-11|DToken |DToken is a contract deployed on EVault contract initialization. It implements a subset of `ERC20` view functions that are related to the vault debt.

Functions implemented:

`name` - returns the name of the associated `EVault` preceded with "Debt token of "

`symbol` - returns the symbol of the associated `EVault` preceded with "d"

`decimals` - returns the decimals of the associated `EVault`

`totalSupply` - returns the total borrows of the associated `EVault`

`balanceOf` - returns individual debt of the user in the associated `EVault`

`allowance` - always returns 0

`asset` - returns the asset of the associated `EVault`

`emitTransfer` - emits the `Transfer` event. Callable only by the associated `EVault`

**DToken contract blocks calls to all the other public ERC20 functions.**

[https://docs.euler.finance/euler-vault-kit-white-paper/#dtoken](https://docs.euler.finance/euler-vault-kit-white-paper/#dtoken) | -|EVK-9 |Dispatch |Implements helper functions and modifiers used for calls dispatching.

For code organisation purposes, and also to comfortably stay below code size limits at full optimisation levels, the component implementation contracts are organised into modules. The primary entry point contract `EVault` serves as a dispatcher that determines which module should be invoked. `EVault` inherits from *all* the modules, although functions that are overridden by a dispatching routine are considered dead code by the Solidity compiler and are not compiled in. In addition to being included in the `EVault` dispatcher contract, modules are also deployed separately so that they can be invoked with `delegatecall`.

This pattern allows functions to be handled in the following ways:

* Implemented directly: No external module is invoked. This is the most gas efficient, and is especially important for view methods that are called frequently by other contracts.
* `use(MODULE_XXX)`: The indicated module is invoked with `delegatecall`
* `useView(MODULE_XXX)`: The implementation contract uses `staticcall` to call `viewDelegate()` on itself, which then does a `delegatecall` to the indicated module.

In order to implement a function directly, it is sufficient to have no mention of it in the dispatcher. However, for documentation and consistency, the code overrides with a function that calls the corresponding module's function with `super()`. This wrapper function is inlined away by the compiler.

To delegate a function to a module, the code overwrites the function signature in the dispatcher with either the `use` or `useView` modifier and an empty code block as implementation. The empty code block removes the function, while the modifier causes the router to `delegatecall` into the module.

Modules are static, meaning they cannot be upgraded. Code upgrades require deploying a new implementation which refers to the new module, and then updating the implementation storage slot in the factory. Only upgradeable instances will be affected.

[**`delegatecall` into view functions**​](https://docs.euler.finance/euler-vault-kit-white-paper/#delegatecall-into-view-functions)

Solidity doesn't allow for view functions to `delegatecall`. To be able to remove view functions from the dispatcher codebase and `delegatecall` them instead into the modules, the dispatching mechanism uses the `useView` modifier. This modifier makes the view function `staticcall` back into the dispatcher to a `viewDelegate` function which is `non-payable`. This `viewDelegate` function can now `delegatecall` into the implementation of the view function in the module.

The issue, background and a proposed patch to the Solidity compiler described in [this solc issue](https://github.com/ethereum/solidity/issues/14577).

[**Gas vs Code-Size Tradeoff**​](https://docs.euler.finance/euler-vault-kit-white-paper/#gas-vs-code-size-tradeoff)

The top level module dispatch system in `EVault` serves as a dispatcher where you can mix and match where the code should physically be located - in the contract itself, or `delegatecall`ed to one of the modules. The routing of specific functions to specific modules is hardcoded into the dispatcher.

In order to decide if a function should be implemented directly or delegated to a module, its gas-importance and code size should be evaluated. Functions that are frequently called on-chain will benefit from being implemented directly to avoid the `delegatecall` overhead. This is especially important for `view` functions because of the `viewDelegate()` overhead. On the other hand, large functions should be delegated to ensure that the `EVault` dispatcher can fit within the 24 Kb code-size limit,

[**`callThroughEVC`**​](https://docs.euler.finance/euler-vault-kit-white-paper/#callthroughevc)

The `EVault` dispatcher makes use of another modifier called `callThroughEVC`. This modifier has two execution flows. Either it executes the function normally (dispatched or directly) if the call comes from the EVC, or calls into the EVC's `call()` method with the existing calldata prepended with the address of the dispatcher, `msg.sender`, and `msg.value`, which then calls back into the Vault for normal execution.

[https://docs.euler.finance/euler-vault-kit-white-paper/#static-modules](https://docs.euler.finance/euler-vault-kit-white-paper/#static-modules)| +|EVK-11|DToken |DToken is a contract deployed on EVault contract initialization. It implements a subset of `ERC20` view functions that are related to the vault debt.

Functions implemented:

`name` - returns the name of the associated `EVault` preceded with "Debt token of "

`symbol` - returns the symbol of the associated `EVault` followed by "-DEBT"

`decimals` - returns the decimals of the associated `EVault`

`totalSupply` - returns the total borrows of the associated `EVault`

`balanceOf` - returns individual debt of the user in the associated `EVault`

`allowance` - always returns 0

`asset` - returns the asset of the associated `EVault`

`emitTransfer` - emits the `Transfer` event. Callable only by the associated `EVault`

**DToken contract blocks calls to all the other public ERC20 functions.**

[https://docs.euler.finance/euler-vault-kit-white-paper/#dtoken](https://docs.euler.finance/euler-vault-kit-white-paper/#dtoken) | +|EVK-9 |Dispatch |Implements helper functions and modifiers used for calls dispatching.

For code organisation purposes, and also to comfortably stay below code size limits at full optimisation levels, the component implementation contracts are organised into modules. The primary entry point contract `EVault` serves as a dispatcher that determines which module should be invoked. `EVault` inherits from *all* the modules, although functions that are overridden by a dispatching routine are considered dead code by the Solidity compiler and are not compiled in. In addition to being included in the `EVault` dispatcher contract, modules are also deployed separately so that they can be invoked with `delegatecall`.

This pattern allows functions to be handled in the following ways:

* Implemented directly: No external module is invoked. This is the most gas efficient, and is especially important for view methods that are called frequently by other contracts.
* `use(MODULE_XXX)`: The indicated module is invoked with `delegatecall`
* `useView(MODULE_XXX)`: The implementation contract uses `staticcall` to call `viewDelegate()` on itself, which then does a `delegatecall` to the indicated module.

In order to implement a function directly, it is sufficient to have no mention of it in the dispatcher. However, for documentation and consistency, the code overrides with a function that calls the corresponding module's function with `super()`. This wrapper function is inlined away by the compiler.

To delegate a function to a module, the code overwrites the function signature in the dispatcher with either the `use` or `useView` modifier and an empty code block as implementation. The empty code block removes the function, while the modifier causes the router to `delegatecall` into the module.

Modules are static, meaning they cannot be upgraded. Code upgrades require deploying a new implementation which refers to the new module, and then updating the implementation storage slot in the factory. Only upgradeable instances will be affected.

[**`delegatecall` into view functions**​](https://docs.euler.finance/euler-vault-kit-white-paper/#delegatecall-into-view-functions)

Solidity doesn't allow for view functions to `delegatecall`. To be able to remove view functions from the dispatcher codebase and `delegatecall` them instead into the modules, the dispatching mechanism uses the `useView` modifier. This modifier makes the view function `staticcall` back into the dispatcher to a `viewDelegate` function which is `payable` for gas optimization. This `viewDelegate` function can now `delegatecall` into the implementation of the view function in the module.

The issue, background and a proposed patch to the Solidity compiler described in [this solc issue](https://github.com/ethereum/solidity/issues/14577).

[**Gas vs Code-Size Tradeoff**​](https://docs.euler.finance/euler-vault-kit-white-paper/#gas-vs-code-size-tradeoff)

The top level module dispatch system in `EVault` serves as a dispatcher where you can mix and match where the code should physically be located - in the contract itself, or `delegatecall`ed to one of the modules. The routing of specific functions to specific modules is hardcoded into the dispatcher.

In order to decide if a function should be implemented directly or delegated to a module, its gas-importance and code size should be evaluated. Functions that are frequently called on-chain will benefit from being implemented directly to avoid the `delegatecall` overhead. This is especially important for `view` functions because of the `viewDelegate()` overhead. On the other hand, large functions should be delegated to ensure that the `EVault` dispatcher can fit within the 24 Kb code-size limit,

[**`callThroughEVC`**​](https://docs.euler.finance/euler-vault-kit-white-paper/#callthroughevc)

The `EVault` dispatcher makes use of another modifier called `callThroughEVC`. This modifier has two execution flows. Either it executes the function normally (dispatched or directly) if the call comes from the EVC, or calls into the EVC's `call()` method with the existing calldata prepended with the address of the dispatcher, `msg.sender`, and `msg.value`, which then calls back into the Vault for normal execution.

[https://docs.euler.finance/euler-vault-kit-white-paper/#static-modules](https://docs.euler.finance/euler-vault-kit-white-paper/#static-modules)| |EVK-35|Dispatch `callThroughEVC` |`callThroughEVC` is a modifier which:

* if `msg.sender` is the EVC, executes the body of the function * if `msg.sender` is not the EVC, invokes the EVC `call` function with the following arguments:

* `targetContract` must be equal to `address(this)`

* `onBehalfOfAccount` must be equal to `msg.sender`

* `value` must be equal to the `msg.value`

* `data` must be equal to the `msg.data`

`callThroughEVC` reverts if the call to the EVC is unsuccessful.

The expectation of the call to the EVC is that the EVC will call back into this contract and execute the function, on which `callThroughEVC` modifier was used, within the EVC checks deferred context.

[https://evc.wtf/docs/concepts/internals/call](https://evc.wtf/docs/concepts/internals/call)

[https://evc.wtf/docs/concepts/internals/checks-deferrable-call](https://evc.wtf/docs/concepts/internals/checks-deferrable-call) | |EVK-34|Dispatch `useView` |`useView` is a modifier which `staticcall`s into `function viewDelegate()` on this contract (`address(this)`). The calldata is as follows:

```bash viewDelegate.selector + module address + (msg.data with the proxy metadata stripped at the end) + caller address ```

`useView` reverts if `staticcall` is unsuccessful.

`viewDelegate` is a function which `delegatecall`s into the `module` address provided by `useView` with as per current `msg.data`.

`viewDelegate` reverts if not self-called OR if `delegatecall` is unsuccessful. | |EVK-33|Dispatch `use` |`use` is a modifier which `delegatecall`s into provided `module` address as per current `msg.data`. `use` reverts if `delegatecall` is unsuccessful. | @@ -16,7 +16,8 @@ |EVK-71|Miscellaneous Hooked Operations |Some of the operation types of the vault may be configured to call the hook target when being performed thanks to which the vault supports a limited hooking functionality. When operation is performed, the vault must check if the corresponding operation is set in hooked ops.

If the corresponding operation is set in the hooked ops AND the hook target configured is a compatible smart contract, the hook target is `call`ed using the same `msg.data` as was provided to the vault, along with the EVC-authenticated caller appended as trailing calldata. If the call is successful, the execution of the operation must carry on as usual. If the call is unsuccessful, the operation must revert with the hook target error bubbled up.

If the corresponding operation is set in the hooked ops AND the hook target configured is address zero, then the vault operation fails unconditionally as it is considered disabled.

In addition to user-invokable functions, the operation than can also be hooked is `checkVaultStatus`. The hook target will be invoked when the EVC calls `checkVaultStatus` on the vault, which is typically at the end of any batch that has interacted with the vault. This hook can be used to reject operations that violate "post-condition" properties of the vault.

[https://docs.euler.finance/euler-vault-kit-white-paper/#hooks](https://docs.euler.finance/euler-vault-kit-white-paper/#hooks) | |EVK-65|Miscellaneous Interest and Fees Accrual |Any state-modifying operation that affects the vault balances and liabilities, must operate on the up to date vault state. In order to keep the vault state up to date, when loading the cache, the vault state is updated considering the following:

* the interest is accrued since the last update time using the current interest rate
* the fees are cut from the accrued interest as per the interest fee parameter
* new vault shares are minted to grant ownership over the fees to the virtual temporary fee holder account

[https://docs.euler.finance/euler-vault-kit-white-paper/#interest](https://docs.euler.finance/euler-vault-kit-white-paper/#interest) | |EVK-69|Miscellaneous LTV Ramping |Vaults must implement LTV ramping allowing the governor to change the LTV of the collateral without putting outstanding loans into immediate violation.

When using the `setLTV` function, the governor can specify the target LTV and the ramp duration. When an account wants to take on a new liability or modify its existing position, the account health must be evaluated using the target LTV value configured. For the purpose of liquidations however, the account health must be evaluated by calculating the current LTV, assuming it changes linearly from the original LTV stored to the target LTV configured in the configured ramp duration time.

[https://docs.euler.finance/euler-vault-kit-white-paper/#ltv-ramping](https://docs.euler.finance/euler-vault-kit-white-paper/#ltv-ramping) | -|EVK-74|Miscellaneous Pull Assets |Whenever the underlying assets are being pulled into the vault, the operation must try to transfer the assets from the specified account to the vault directly. If the direct transfer fails and `permit2` contract address is configured, the operation must try to transfer the assets from the specified account to the vault using `permit2` contract. | +|EVK-74|Miscellaneous Pull Assets |Whenever the underlying assets are being pulled into the vault, if `permit2` contract address is configured and the transferred amount fits `uint160`, the operation must try to transfer the assets from the specified account to the vault using `permit2` contract. If the transfer using `permit2` contract fails or is not possible, the operation must try to transfer the assets from the specified account to the vault directly. + | |EVK-73|Miscellaneous Push Assets |Whenever the underlying assets are being pushed out of the vault, the operation must revert if:

* the receiver address is `address(0)`, OR
* if asset receiver validation enabled AND the receiver address is a known virtual account | |EVK-63|Miscellaneous Re-entrancy |Each state-modifying function must be re-entrancy protected.

Each static function that reads regions of the state that may be unsafe to access during an ongoing operation on the vault must be read-only re-entrancy protected.

The installed hook target contract address can bypass the vault's read-only reentrancy protection. This means that hook functions can call view methods on the vault during their operation. However, hooks cannot perform state changing operations because of the normal reentrancy lock. | |EVK-68|Miscellaneous Vault Status Checks and Snapshots |Any state-modifying function of the vault which loads the vault cache must schedule the vault status check. This is in order not only to potentially check whether the vault status is valid at the end of the call frame, but also to update the interest rate.

Before the vault status check is scheduled, if the supply cap or the borrow cap can be exceeded (any of them is not defined as max possible value), a snapshot of the initial state of the vault must be created which includes:

* the total amount of assets held by the vault
* the total amount of liabilities issued by the vault

If the status of any of the vaults for which the vault status check has been scheduled is invalid, the call frame in which checks are performed must revert.

[https://evc.wtf/docs/concepts/internals/vault-status-checks](https://evc.wtf/docs/concepts/internals/vault-status-checks) | @@ -33,7 +34,6 @@ |EVK-54|Module Borrowing `repay` |If operation enabled, `repay` removes exactly `amount` of underlying tokens as liability for the `receiver` and transfers the underlying tokens from the authenticated account.

This operation is always called through the EVC.

This operation schedules the vault status check.

This operation affects:

* liability balance of the authenticated account
* total liability balance
* total balance of the underlying assets held by the vault | |EVK-58|Module Borrowing `touch` |If operation enabled, `touch` updates the vault state.

This operation is always called through the EVC.

This operation schedules the vault status check. | |EVK-2 |Module Governance |Implements the functions allowing the governor to configure the vault.
The vault uses EVC authentication for the governor, which means that governor actions can be batched together and simulated. However, the vault does not accept advanced EVC authentication methods like sub-accounts, operators or `controlCollateral`.

*Context:*

*Immediately after creation, the factory will call* `initialize` *on the proxy, passing in the creator's address as a parameter. The vault will set its governor to the creator's address. This governor can invoke methods that modify the configuration of the vault.*

*At this point, the creator should configure the vault as desired and then decide if the vault is to be governed or not.*

* *If so, the creator retains the governor role or transfers it to another address.*
* *If not, then the ownership is revoked by setting the governor to* `address(0)`*. No more governance changes can happen on this vault and it is considered finalized.*

*If limited governance is desired, the creator can transfer ownership to a smart contract that can only invoke a sub-set of governance methods, perhaps with only certain parameters, or under certain conditions.*

*Using the same code-base and factories, the Euler Vault Kit allows construction of both managed and unmanaged lending products. Managed vaults are intended to be long-lived and are therefore suitable for passive deposits. If market conditions change, an active governor can reconfigure the vault to optimize or protect users. Alternatively, unmanaged vaults are configured statically, and the users themselves (or a higher-level contract) must actively monitor for risks/opportunities and shift their deposits and positions to new vaults as necessary.*

[https://docs.euler.finance/euler-vault-kit-white-paper/#governed-vs-finalised](https://docs.euler.finance/euler-vault-kit-white-paper/#governed-vs-finalised) | -|EVK-25|Module Governance `clearLTV` |`clearLTV` allows the current governor to clear the LTV config for a given collateral keeping the storage slot initialized. | |EVK-18|Module Governance `convertFees` |If operation enabled, `convertFees` allows anyone to split accrued vault fees and transfer them to the governor-specified fee receiver and Protocol Config-specified fee receiver. The accrued vault fees are split proportionally as per Protocol Config-specified fee share that cannot exceed `MAX_PROTOCOL_FEE_SHARE`. Immediately after the fees are split, the amount of fees accrued by the vault must be set to 0.

This operation affects:

* shares balance of the governor-specified fee receiver (if it's configured)
* shares balance of the Protocol Config-specified fee receiver
* shares balance of the accumulated fees

[https://docs.euler.finance/euler-vault-kit-white-paper/#fees](https://docs.euler.finance/euler-vault-kit-white-paper/#fees) | |EVK-28|Module Governance `setCaps` |`setCaps` allows the current governor to set a supply cap and a borrow cap as per the following specification:

*Supply cap and borrow cap are 16-bit decimal floating point values:*

*\* The least significant 6 bits are the exponent*

*\* The most significant 10 bits are the mantissa, scaled by 100*

*\* The special value of 0 means limit is not set*

*\* This is so that uninitialised storage implies no limit*

*\* For an actual cap value of 0, use a zero mantissa and non-zero exponent*

When converted to assets, the supply cap cannot exceed `2 * MAX_SANE_AMOUNT` and the borrow cap cannot exceed `MAX_SANE_AMOUNT`.

[https://docs.euler.finance/euler-vault-kit-white-paper/#supply-and-borrow-caps](https://docs.euler.finance/euler-vault-kit-white-paper/#supply-and-borrow-caps) | |EVK-72|Module Governance `setConfigFlags` |`setConfigFlags` allows the vault governor to specify the additional configuration of the vault which refers to:

* debt socialization on liquidation
* asset receiver validation whenever the assets are pushed out of the vault | @@ -72,8 +72,8 @@ |EVK-13|Protocol Config |The Protocol Config contract is the representative of the DAO's interests in the ecosystem. What vaults allow this contract to control is strictly limited.

Functions implemented that are called by the vaults:

`isValidInterestFee` - determines whether the value of the interest fee is allowed; vaults only invoke this if the governor attempts to set an interest fee outside of the range <`GUARANTEED_INTEREST_FEE_MIN`, `GUARANTEED_INTEREST_FEE_MAX`>

`protocolFeeConfig` - called when fees are converted; it returns the following the recipient address for the DAO's share of the fees and the fraction of the interest fees that should be sent to the DAO

[https://docs.euler.finance/euler-vault-kit-white-paper/#protocolconfig](https://docs.euler.finance/euler-vault-kit-white-paper/#protocolconfig) | |EVK-83|Sequence Registry |The Sequence Registry contract provides an interface for reserving sequence IDs. This contract maintains sequence counters associated with opaque designator strings. Each counter starts at 1. Anybody can reserve a sequence ID by calling `reserveSeqIdfunction`. The only guarantee provided is that no two reservations for the same designator will get the same ID. | |EVK-75|Synthetic Asset Vault |Synthetic asset vault is a special configuration of a vault which uses hooks to disable `mint`, `redeem`, `skim` and `repayWithShares` operations. The `deposit` operation is also disabled for all the callers except the underlying synth asset address itself, disallowing deposits from normal users.

Instead of a utilization based interest rate model, synthetic vaults use a reactive interest rate model which adjusts the interest rate based on the trading price of the synthetic asset. This mechanism, the peg stability module and the savings rate module aim to keep the synthetic asset pegged as tight as possible to the peg asset. The price feed used when borrowing from the synthetic vault is the asset which the synthetic asset is pegged to, creating a CDP based synthetic asset. | -|EVK-81|Synthetic Asset Vault `ERC20Collateral` |`ERC20Collateral` is an `ERC20`-compatible token with the EVC support which allows it to be used as collateral in other vault.

`ERC20Collateral` tokens comply with the following specification:

* Whenever the contract is called via the EVC (`msg.sender == EVC`), the current on behalf of account is fetched from the EVC and used as an authenticated caller address. This ensures liquidations can be processed via `EVC.controlCollateral` function
* The account status check is requested after every operation that can potentially negatively affect the account health (i.e. whenever the tokens are transferred out of the account). This ensures the account is never unhealthy after the operation | -|EVK-77|Synthetic Asset Vault `ESynth` |`ESynth` is an `ERC20`-compatible token with the EVC support which, thanks to relying on the EVC authentication and requesting the account status checks on token transfers and burns, allows it to be used as collateral in other vault. It is meant to be used as an underlying asset of the synthetic asset vault.

`ESynth` tokens comply with the following specification:

**ERC20Collateral**

`ESynth` inherits from `ERC20Collateral` making it compliant with the `ERC20Collateral` specification.

**Minting**

The owner of the contract can set a minting capacity by calling `setCapacity(address minter, uint128 capacity)` for any address which allows them to mint the synthetic asset up to the defined amount. Minters mint by calling `mint(address account, uint256 amount)`.

**Burning**

Any address can burn the synthetic from another address given they have the allowance to do so. The owner is exempt from this restriction when burning from the `ESynth` contract itself. When one burns from an address and they have a previously minted amount, the minted amount is reduced by the burned amount, freeing up their minting capacity. Burning can be done by calling `burn(address account, uint256 amount)`. Account status check is requested for the account from which the assets are burned.

**Allocating to the synthetic asset vault**

The owner can allocate assets held by the asset contract itself to a synthetic asset vault created specifically for this asset by calling `allocate(address vault, uint256 amount)`. This serves as a protocol deposit to the vault. Any allocation needs to be first minted by a minter or owner into the synthetic asset contract. The vault into which the assets are allocated must be integrated with the same EVC instance as the `ESynth` contract itself. On allocation, the vault is added to the addresses whose balances are ignored when calculating the `totalSupply`.

**Deallocating from the synthetic asset vault**

The owner can deallocate synthetic assets from the vault by calling `deallocate(address vault, uint256 amount)`. This serves as a protocol withdraw from the synthetic asset vault. Assets deallocated from the vault will be transferred into the synthetic asset contract itself and can in turn be burned by the owner.

**Total supply adjustments**

Since the protocol deposits into synthetic asset vaults are not backed by any collateral and are not in circulation, they are excluded from the `totalSupply` calculation. After calling `allocate()`, target vaults are automatically excluded. Additional addresses whose balances should be ignored can be managed by the owner by calling `addIgnoredForTotalSupply(address account)` and `removeIgnoredForTotalSupply(address account)`. | -|EVK-79|Synthetic Asset Vault `EulerSavingsRate` |`EulerSavingsRate` is a `ERC4626`-compatible vault which allows users to deposit the underlying asset and receive interest in the form of the same underlying asset. On withdraw, redeem and transfers, the account status checks must be requested for the account which health might be negatively affected. Thanks to that, the shares of the `EulerSavingsRate`vault might be used as collateral by other EVC-compatible vaults.

Account balances must be tracked internally in a donation attack resistant manner.

Anyone can transfer the underlying asset into the vault and call `gulp` which distributes those directly transferred assets to the shareholders over the `INTEREST_SMEAR` period. Accrued interest must adjust the exchange rate accordingly.

On `gulp`, any interest which has not been yet distributed is smeared for an additional `INTEREST_SMEAR` period. In theory, this means that interest could be smeared indefinitely by continuously calling `gulp`. In practice, it is expected that the interest will keep accruing, negating any negative side effects which may come from the smearing mechanism. | +|EVK-81|Synthetic Asset Vault `ERC20EVKCompatible` |`ERC20EVKCompatible` is an `ERC20`-compatible token with the EVC support.

Whenever the contract is called via the EVC (`msg.sender == EVC`), the current on behalf of account is fetched from the EVC and used as an authenticated caller address. This ensures the `ERC20EVKCompatible` is compatible with the EVC authentication system. | +|EVK-77|Synthetic Asset Vault `ESynth` |`ESynth` is an `ERC20`-compatible token with the EVC support. It is meant to be used as an underlying asset of the synthetic asset vault.

`ESynth` tokens comply with the following specification:

**ERC20EVCCompatible**

`ESynth` inherits from `ERC20EVCCompatible` making it compliant with the `ERC20EVCCompatible` specification.

**Minting**

The owner of the contract can set a minting capacity by calling `setCapacity(address minter, uint128 capacity)` for any address which allows them to mint the synthetic asset up to the defined amount. Minters mint by calling `mint(address account, uint256 amount)`.

**Burning**

Any address can burn the synthetic from another address given they have the allowance to do so. The owner is exempt from this restriction when burning from the `ESynth` contract itself. When one burns from an address and they have a previously minted amount, the minted amount is reduced by the burned amount, freeing up their minting capacity. Burning can be done by calling `burn(address account, uint256 amount)`.

**Allocating to the synthetic asset vault**

The owner can allocate assets held by the asset contract itself to a synthetic asset vault created specifically for this asset by calling `allocate(address vault, uint256 amount)`. This serves as a protocol deposit to the vault. Any allocation needs to be first minted by a minter or owner into the synthetic asset contract. The vault into which the assets are allocated must be integrated with the same EVC instance as the `ESynth` contract itself. On allocation, the vault is added to the addresses whose balances are ignored when calculating the `totalSupply`.

**Deallocating from the synthetic asset vault**

The owner can deallocate synthetic assets from the vault by calling `deallocate(address vault, uint256 amount)`. This serves as a protocol withdraw from the synthetic asset vault. Assets deallocated from the vault will be transferred into the synthetic asset contract itself and can in turn be burned by the owner.

**Total supply adjustments**

Since the protocol deposits into synthetic asset vaults are not backed by any collateral and are not in circulation, they are excluded from the `totalSupply` calculation. After calling `allocate()`, target vaults are automatically excluded. Additional addresses whose balances should be ignored can be managed by the owner by calling `addIgnoredForTotalSupply(address account)` and `removeIgnoredForTotalSupply(address account)`. | +|EVK-79|Synthetic Asset Vault `EulerSavingsRate` |`EulerSavingsRate` is a `ERC4626`-compatible vault with the EVC support which allows users to deposit the underlying asset and receive interest in the form of the same underlying asset.

Account balances must be tracked internally in a donation attack resistant manner.

Anyone can transfer the underlying asset into the vault and call `gulp` which distributes those directly transferred assets to the shareholders over the `INTEREST_SMEAR` period. Accrued interest must adjust the exchange rate accordingly.

On `gulp`, any interest which has not been yet distributed is smeared for an additional `INTEREST_SMEAR` period. In theory, this means that interest could be smeared indefinitely by continuously calling `gulp`. In practice, it is expected that the interest will keep accruing, negating any negative side effects which may come from the smearing mechanism. | |EVK-78|Synthetic Asset Vault `IRMSynth` |Synthetic asset vaults use a different interest rate model than the standard vaults. The `IRMSynth` interest rate model is a simple reactive rate model which adjusts the interest rate up when it trades below the `targetQuote` and down when it trades above or at the `targetQuote`.

**Parameters**

* `targetQuote` price being targeted by the IRM
* `MAX_RATE` maximum rate charged
* `BASE_RATE` minimum and starting rate for the IRM
* `ADJUST_FACTOR` factor by which the previous rate is adjusted per `ADJUST_INTERVAL`
* `ADJUST_INTERVAL` time that needs to pass before the rate can be adjusted again

**Algorithm**

1. If the `ADJUST_INTERVAL` did not pass, the previous rate must be returned 2. If the oracle configured returns a zero quote, the previous rate must be returned 3. If the synthetic asset trades below the `targetQuote`, the rate must be raised by the factor of `ADJUST_FACTOR` 4. If the synthetic asset is trading at the `targetQuote` or below, the rate must be lowered by the factor of `ADJUST_FACTOR` 5. Minimum `BASE_RATE` must be enforced 6. Maximum `MAX_RATE` must be enforced 7. The updated rate the timestamp of the last update must be stored | |EVK-80|Synthetic Asset Vault `PegStabilityModule` |The `PegStabilityModule` is granted minting rights on the `ESynth` and must allow slippage-free conversion from and to the underlying asset as per configured `conversionPrice`. On deployment, the fee for swaps to synthetic asset and to underlying asset are defined. These fees must accrue to the `PegStabilityModule` contract and can not be withdrawn, serving as a permanent reserve to support the peg. Swapping to the synthetic asset is possible up to the minting cap granted for the `PegStabilityModule` in the `ESynth`. Swapping to the underlying asset is possible up to the amount of the underlying asset held by the `PegStabilityModule`. | diff --git a/docs/whitepaper.md b/docs/whitepaper.md index 3e970df3..4d732dc8 100644 --- a/docs/whitepaper.md +++ b/docs/whitepaper.md @@ -560,7 +560,7 @@ In essence, a liquidation is equivalent to a stop-loss order. As long as you set **This section is still a work-in-progress and is subject to change** -Since the EVK is a *kit*, it attempts to be maximally flexible and doesn't enforce policy decisions on vault creators. This means that it is possible to create vaults with insecure or malicious configurations. Furthermore, an otherwise secure vault may be insecure because it accapts an [insecure collateral as collateral](#untrusted-collaterals) (or a collateral vault itself accepts insecure collateral, etc, recursively). +Since the EVK is a *kit*, it attempts to be maximally flexible and doesn't enforce policy decisions on vault creators. This means that it is possible to create vaults with insecure or malicious configurations. Furthermore, an otherwise secure vault may be insecure because it accepts an [insecure collateral as collateral](#untrusted-collaterals) (or a collateral vault itself accepts insecure collateral, etc, recursively). Perspectives provide a mechanism for validating properties of a vault using on-chain verifiable logic. A perspective is any contract that implements the following interface: diff --git a/lib/ethereum-vault-connector b/lib/ethereum-vault-connector index 0229f62f..5c73f731 160000 --- a/lib/ethereum-vault-connector +++ b/lib/ethereum-vault-connector @@ -1 +1 @@ -Subproject commit 0229f62f92856201e1f33bee9e59daf68938ba34 +Subproject commit 5c73f731ddb0d6bd1c35baf177954a317d608922 diff --git a/src/EVault/Dispatch.sol b/src/EVault/Dispatch.sol index ec148e69..7baaa177 100644 --- a/src/EVault/Dispatch.sol +++ b/src/EVault/Dispatch.sol @@ -60,6 +60,9 @@ abstract contract Dispatch is address governance; } + /// @notice EVault's constructor + /// @dev It is highly recommended to deploy fresh modules for every new EVault deployment. Particular care must + /// also be taken to ensure the modules are deployed with the exact same values of the `Integrations` struct. constructor(Integrations memory integrations, DeployedModules memory modules) Base(integrations) { MODULE_INITIALIZE = AddressUtils.checkContract(modules.initialize); MODULE_TOKEN = AddressUtils.checkContract(modules.token); diff --git a/src/EVault/EVault.sol b/src/EVault/EVault.sol index d1149c27..bcd5d92c 100644 --- a/src/EVault/EVault.sol +++ b/src/EVault/EVault.sol @@ -83,7 +83,7 @@ contract EVault is Dispatch { function creator() public view virtual override useView(MODULE_VAULT) returns (address) {} - function deposit(uint256 amount, address receiver) public virtual override callThroughEVC returns (uint256) { return super.deposit(amount, receiver); } + function deposit(uint256 amount, address receiver) public virtual override callThroughEVC use(MODULE_VAULT) returns (uint256) {} function mint(uint256 amount, address receiver) public virtual override callThroughEVC use(MODULE_VAULT) returns (uint256) {} @@ -122,7 +122,7 @@ contract EVault is Dispatch { function repayWithShares(uint256 amount, address receiver) public virtual override callThroughEVC use(MODULE_BORROWING) returns (uint256 shares, uint256 debt) {} - function pullDebt(uint256 amount, address from) public virtual override callThroughEVC use(MODULE_BORROWING) returns (uint256) {} + function pullDebt(uint256 amount, address from) public virtual override callThroughEVC use(MODULE_BORROWING) {} function flashLoan(uint256 amount, bytes calldata data) public virtual override use(MODULE_BORROWING) {} @@ -151,7 +151,7 @@ contract EVault is Dispatch { function disableController() public virtual override use(MODULE_RISKMANAGER) {} - function checkAccountStatus(address account, address[] calldata collaterals) public virtual override returns (bytes4) { return super.checkAccountStatus(account, collaterals); } + function checkAccountStatus(address account, address[] calldata collaterals) public view virtual override returns (bytes4) { return super.checkAccountStatus(account, collaterals); } function checkVaultStatus() public virtual override returns (bytes4) { return super.checkVaultStatus(); } @@ -227,8 +227,6 @@ contract EVault is Dispatch { function setLTV(address collateral, uint16 borrowLTV, uint16 liquidationLTV, uint32 rampDuration) public virtual override use(MODULE_GOVERNANCE) {} - function clearLTV(address collateral) public virtual override use(MODULE_GOVERNANCE) {} - function setMaxLiquidationDiscount(uint16 newDiscount) public virtual override use(MODULE_GOVERNANCE) {} function setLiquidationCoolOffTime(uint16 newCoolOffTime) public virtual override use(MODULE_GOVERNANCE) {} diff --git a/src/EVault/IEVault.sol b/src/EVault/IEVault.sol index 9ed1f988..b3bbebb0 100644 --- a/src/EVault/IEVault.sol +++ b/src/EVault/IEVault.sol @@ -245,13 +245,16 @@ interface IBorrowing { /// @return shares Amount of shares burned /// @return debt Amount of debt removed in assets /// @dev Equivalent to withdrawing and repaying, but no assets are needed to be present in the vault + /// @dev Contrary to a regular `repay`, if account is unhealthy, the repay amount must bring the account back to + /// health, or the operation will revert during account status check function repayWithShares(uint256 amount, address receiver) external returns (uint256 shares, uint256 debt); /// @notice Take over debt from another account /// @param amount Amount of debt in asset units (use max uint256 for all the account's debt) /// @param from Account to pull the debt from - /// @return Amount of debt pulled in asset units. - function pullDebt(uint256 amount, address from) external returns (uint256); + /// @dev Due to internal debt precision accounting, the liability reported on either or both accounts after + /// calling `pullDebt` may not match the `amount` requested precisely + function pullDebt(uint256 amount, address from) external; /// @notice Request a flash-loan. A onFlashLoan() callback in msg.sender will be invoked, which must repay the loan /// to the main Euler address prior to returning. @@ -284,9 +287,11 @@ interface ILiquidation { /// @param violator Address that may be in collateral violation /// @param collateral Collateral which is to be seized /// @param repayAssets The amount of underlying debt to be transferred from violator to sender, in asset units (use - /// max uint256 to repay the maximum possible amount). + /// max uint256 to repay the maximum possible amount). Meant as slippage check together with `minYieldBalance` /// @param minYieldBalance The minimum acceptable amount of collateral to be transferred from violator to sender, in - /// collateral balance units (shares for vaults) + /// collateral balance units (shares for vaults). Meant as slippage check together with `repayAssets` + /// @dev If `repayAssets` is set to max uint256 it is assumed the caller will perform their own slippage checks to + /// make sure they are not taking on too much debt. This option is mainly meant for smart contract liquidators function liquidate(address violator, address collateral, uint256 repayAssets, uint256 minYieldBalance) external; } @@ -325,7 +330,7 @@ interface IRiskManager is IEVCVault { /// @return magicValue Must return the bytes4 magic value 0xb168c58f (which is a selector of this function) when /// account status is valid, or revert otherwise. /// @dev Only callable by EVC during status checks - function checkAccountStatus(address account, address[] calldata collaterals) external returns (bytes4); + function checkAccountStatus(address account, address[] calldata collaterals) external view returns (bytes4); /// @notice Checks the status of the vault and reverts if caps are exceeded /// @return magicValue Must return the bytes4 magic value 0x4b3d1223 (which is a selector of this function) when @@ -381,7 +386,7 @@ interface IGovernance { function protocolConfigAddress() external view returns (address); /// @notice Retrieves the protocol fee share - /// @return A percentage share of fees accrued belonging to the protocol. In wad scale (1e18) + /// @return A percentage share of fees accrued belonging to the protocol, in 1e4 scale function protocolFeeShare() external view returns (uint256); /// @notice Retrieves the address which will receive protocol's fees @@ -431,6 +436,9 @@ interface IGovernance { /// @notice Retrieves the maximum liquidation discount /// @return The maximum liquidation discount in 1e4 scale + /// @dev The default value, which is zero, is deliberately bad, as it means there would be no incentive to liquidate + /// unhealthy users. The vault creator must take care to properly select the limit, given the underlying and + /// collaterals used. function maxLiquidationDiscount() external view returns (uint16); /// @notice Retrieves liquidation cool-off time, which must elapse after successful account status check before @@ -483,11 +491,6 @@ interface IGovernance { /// @param rampDuration Ramp duration in seconds function setLTV(address collateral, uint16 borrowLTV, uint16 liquidationLTV, uint32 rampDuration) external; - /// @notice Completely clears LTV configuratrion, signalling the collateral is not considered safe to liquidate - /// anymore - /// @param collateral Address of the collateral - function clearLTV(address collateral) external; - /// @notice Set a new maximum liquidation discount /// @param newDiscount New maximum liquidation discount in 1e4 scale /// @dev If the discount is zero (the default), the liquidators will not be incentivized to liquidate unhealthy @@ -503,12 +506,16 @@ interface IGovernance { /// @notice Set a new interest rate model contract /// @param newModel The new IRM address + /// @dev If the new model reverts, perhaps due to governor error, the vault will silently use a zero interest + /// rate. Governor should make sure the new interest rates are computed as expected. function setInterestRateModel(address newModel) external; /// @notice Set a new hook target and a new bitmap indicating which operations should call the hook target. - /// Operations are defined in Constants.sol - /// @param newHookTarget The new hook target address + /// Operations are defined in Constants.sol. + /// @param newHookTarget The new hook target address. Use address(0) to simply disable hooked operations /// @param newHookedOps Bitmask with the new hooked operations + /// @dev All operations are initially disabled in a newly created vault. The vault creator must set their + /// own configuration to make the vault usable function setHookConfig(address newHookTarget, uint32 newHookedOps) external; /// @notice Set new bitmap indicating which config flags should be enabled. Flags are defined in Constants.sol diff --git a/src/EVault/modules/Borrowing.sol b/src/EVault/modules/Borrowing.sol index 4d318724..0f4217f1 100644 --- a/src/EVault/modules/Borrowing.sol +++ b/src/EVault/modules/Borrowing.sol @@ -128,19 +128,17 @@ abstract contract BorrowingModule is IBorrowing, AssetTransfers, BalanceUtils, L } /// @inheritdoc IBorrowing - function pullDebt(uint256 amount, address from) public virtual nonReentrant returns (uint256) { + function pullDebt(uint256 amount, address from) public virtual nonReentrant { (VaultCache memory vaultCache, address account) = initOperation(OP_PULL_DEBT, CHECKACCOUNT_CALLER); if (from == account) revert E_SelfTransfer(); Assets assets = amount == type(uint256).max ? getCurrentOwed(vaultCache, from).toAssetsUp() : amount.toAssets(); - if (assets.isZero()) return 0; + if (assets.isZero()) return; transferBorrow(vaultCache, from, account, assets); emit PullDebt(from, account, assets.toUint()); - - return assets.toUint(); } /// @inheritdoc IBorrowing diff --git a/src/EVault/modules/Governance.sol b/src/EVault/modules/Governance.sol index ed017351..5c728edb 100644 --- a/src/EVault/modules/Governance.sol +++ b/src/EVault/modules/Governance.sol @@ -56,9 +56,9 @@ abstract contract GovernanceModule is IGovernance, BalanceUtils, BorrowUtils, LT uint16 liquidationLTV, uint16 initialLiquidationLTV, uint48 targetTimestamp, - uint32 rampDuration, - bool initialized + uint32 rampDuration ); + /// @notice Set an interest rate model contract address /// @param newInterestRateModel Address of the new IRM event GovSetInterestRateModel(address newInterestRateModel); @@ -122,7 +122,11 @@ abstract contract GovernanceModule is IGovernance, BalanceUtils, BorrowUtils, LT /// @inheritdoc IGovernance function protocolFeeShare() public view virtual reentrantOK returns (uint256) { + if (vaultStorage.feeReceiver == address(0)) return CONFIG_SCALE; + (, uint256 protocolShare) = protocolConfig.protocolFeeConfig(address(this)); + if (protocolShare > MAX_PROTOCOL_FEE_SHARE) return MAX_PROTOCOL_FEE_SHARE; + return protocolShare; } @@ -261,16 +265,19 @@ abstract contract GovernanceModule is IGovernance, BalanceUtils, BorrowUtils, LT } /// @inheritdoc IGovernance - /// @dev When the collateral asset is no longer deemed suitable to sustain debt (and not because of code issues, see - /// `clearLTV`), its LTV setting can be set to 0. Setting a zero liquidation LTV also enforces a zero borrowing LTV - /// (`newBorrowLTV <= newLiquidationLTV`). In such cases, the collateral becomes immediately ineffective for new - /// borrows. However, for liquidation purposes, the LTV can be ramped down over a period of time (`rampDuration`). - /// This ramping helps users avoid hard liquidations with maximum discounts and gives them a chance to close their - /// positions in an orderly fashion. The choice of `rampDuration` depends on market conditions assessed by the - /// governor. They may decide to forgo the ramp entirely by setting the duration to zero, presumably in light of - /// extreme market conditions, where ramping would pose a threat to the vault's solvency. In any case, when the - /// liquidation LTV reaches its target of 0, this asset will no longer support the debt, but it will still be - /// possible to liquidate it at a discount and use the proceeds to repay an unhealthy loan. + /// @dev When the collateral asset is no longer deemed suitable to sustain debt, its LTV setting can be set to 0. + /// Setting a zero liquidation LTV also enforces a zero borrowing LTV (`newBorrowLTV <= newLiquidationLTV`). + /// In such cases, the collateral becomes immediately ineffective for new borrows. However, for liquidation + /// purposes, the LTV can be ramped down over a period of time (`rampDuration`). This ramping helps users avoid hard + /// liquidations with maximum discounts and gives them a chance to close their positions in an orderly fashion. + /// The choice of `rampDuration` depends on market conditions assessed by the governor. They may decide to forgo + /// the ramp entirely by setting the duration to zero, presumably in light of extreme market conditions, where + /// ramping would pose a threat to the vault's solvency. In any case, when the liquidation LTV reaches its target + /// of 0, this asset will no longer support the debt, but it will still be possible to liquidate it at a discount + /// and use the proceeds to repay an unhealthy loan. + /// Setting the LTV to zero will not be sufficient if the collateral is found to be unsafe to call liquidation on, + /// either due to a bug or a code upgrade that allows its transfer function to make arbitrary external calls. + /// In such cases, pausing the vault and conducting an orderly wind-down is recommended. function setLTV(address collateral, uint16 borrowLTV, uint16 liquidationLTV, uint32 rampDuration) public virtual @@ -295,7 +302,13 @@ abstract contract GovernanceModule is IGovernance, BalanceUtils, BorrowUtils, LT vaultStorage.ltvLookup[collateral] = newLTV; - if (!currentLTV.initialized) vaultStorage.ltvList.push(collateral); + if (!currentLTV.isRecognizedCollateral()) vaultStorage.ltvList.push(collateral); + + if (!newLiquidationLTV.isZero()) { + // Ensure that this collateral can be priced by the configured oracle + (, IPriceOracle _oracle, address _unitOfAccount) = ProxyUtils.metadata(); + _oracle.getQuote(1e18, collateral, _unitOfAccount); + } emit GovSetLTV( collateral, @@ -303,24 +316,15 @@ abstract contract GovernanceModule is IGovernance, BalanceUtils, BorrowUtils, LT newLTV.liquidationLTV.toUint16(), newLTV.initialLiquidationLTV.toUint16(), newLTV.targetTimestamp, - newLTV.rampDuration, - !currentLTV.initialized + newLTV.rampDuration ); } - /// @inheritdoc IGovernance - /// @dev When LTV configuration is cleared, attempt to liquidate the collateral will revert. - /// Clearing should only be executed when the collateral is found to be unsafe to liquidate, - /// because e.g. it does external calls on transfer, which would be a critical security threat. - function clearLTV(address collateral) public virtual nonReentrant governorOnly { - uint16 originalLTV = getLTV(collateral, true).toUint16(); - vaultStorage.ltvLookup[collateral].clear(); - - emit GovSetLTV(collateral, 0, 0, originalLTV, 0, 0, false); - } - /// @inheritdoc IGovernance function setMaxLiquidationDiscount(uint16 newDiscount) public virtual nonReentrant governorOnly { + // Discount equal 1e4 would cause division by zero error during liquidation + if (newDiscount == CONFIG_SCALE) revert E_BadMaxLiquidationDiscount(); + vaultStorage.maxLiquidationDiscount = newDiscount.toConfigAmount(); emit GovSetMaxLiquidationDiscount(newDiscount); } diff --git a/src/EVault/modules/Initialize.sol b/src/EVault/modules/Initialize.sol index 00c40255..bbab093c 100644 --- a/src/EVault/modules/Initialize.sol +++ b/src/EVault/modules/Initialize.sol @@ -37,7 +37,6 @@ abstract contract InitializeModule is IInitialize, BorrowUtils { (IERC20 asset,,) = ProxyUtils.metadata(); // Make sure the asset is a contract. Token transfers using a library will not revert if address has no code. AddressUtils.checkContract(address(asset)); - // Other constraints on values should be enforced by product line // Create sidecar DToken @@ -49,6 +48,8 @@ abstract contract InitializeModule is IInitialize, BorrowUtils { vaultStorage.interestAccumulator = INITIAL_INTEREST_ACCUMULATOR; vaultStorage.interestFee = DEFAULT_INTEREST_FEE.toConfigAmount(); vaultStorage.creator = vaultStorage.governorAdmin = proxyCreator; + // all operations are initially disabled + vaultStorage.hookedOps = Flags.wrap(OP_MAX_VALUE - 1); { string memory underlyingSymbol = getTokenSymbol(address(asset)); diff --git a/src/EVault/modules/Liquidation.sol b/src/EVault/modules/Liquidation.sol index 07d4ca0e..589428fc 100644 --- a/src/EVault/modules/Liquidation.sol +++ b/src/EVault/modules/Liquidation.sol @@ -16,6 +16,11 @@ import "../shared/types/Types.sol"; abstract contract LiquidationModule is ILiquidation, BalanceUtils, LiquidityUtils { using TypesLib for uint256; + // Minimum debt value, before liquidation, which enables debt socialization. + // With small debt positions, when vault is nearly empty, rounding in the pricing oracle + // may have an outsized impact on vault's shares to assets exchange rate after debt socialization. + uint256 constant MIN_SOCIALIZATION_LIABILITY_VALUE = 1e6; + struct LiquidationCache { address liquidator; address violator; @@ -24,6 +29,7 @@ abstract contract LiquidationModule is ILiquidation, BalanceUtils, LiquidityUtil Assets liability; Assets repay; uint256 yieldBalance; + uint256 liabilityValue; } /// @inheritdoc ILiquidation @@ -118,16 +124,19 @@ abstract contract LiquidationModule is ILiquidation, BalanceUtils, LiquidityUtil { // Check account health - (uint256 collateralAdjustedValue, uint256 liabilityValue) = + uint256 collateralAdjustedValue; + (collateralAdjustedValue, liqCache.liabilityValue) = calculateLiquidity(vaultCache, liqCache.violator, liqCache.collaterals, true); // no violation - if (collateralAdjustedValue > liabilityValue) return liqCache; + if (collateralAdjustedValue > liqCache.liabilityValue || liqCache.liabilityValue == 0) { + return liqCache; + } // Compute discount // discountFactor = health score = 1 - discount - uint256 discountFactor = collateralAdjustedValue * 1e18 / liabilityValue; + uint256 discountFactor = collateralAdjustedValue * 1e18 / liqCache.liabilityValue; { uint256 minDiscountFactor; unchecked { @@ -154,7 +163,7 @@ abstract contract LiquidationModule is ILiquidation, BalanceUtils, LiquidityUtil return liqCache; } - uint256 maxRepayValue = liabilityValue; + uint256 maxRepayValue = liqCache.liabilityValue; uint256 maxYieldValue = maxRepayValue * 1e18 / discountFactor; // Limit yield to borrower's available collateral, and reduce repay if necessary. This can happen when @@ -167,7 +176,7 @@ abstract contract LiquidationModule is ILiquidation, BalanceUtils, LiquidityUtil maxYieldValue = collateralValue; } - liqCache.repay = (maxRepayValue * liqCache.liability.toUint() / liabilityValue).toAssets(); + liqCache.repay = (maxRepayValue * liqCache.liability.toUint() / liqCache.liabilityValue).toAssets(); liqCache.yieldBalance = maxYieldValue * collateralBalance / collateralValue; return liqCache; @@ -211,7 +220,8 @@ abstract contract LiquidationModule is ILiquidation, BalanceUtils, LiquidityUtil // Handle debt socialization if ( - vaultCache.configFlags.isNotSet(CFG_DONT_SOCIALIZE_DEBT) && liqCache.liability > liqCache.repay + liqCache.liabilityValue >= MIN_SOCIALIZATION_LIABILITY_VALUE + && vaultCache.configFlags.isNotSet(CFG_DONT_SOCIALIZE_DEBT) && liqCache.liability > liqCache.repay && checkNoCollateral(liqCache.violator, liqCache.collaterals) ) { Assets owedRemaining = liqCache.liability.subUnchecked(liqCache.repay); diff --git a/src/EVault/modules/RiskManager.sol b/src/EVault/modules/RiskManager.sol index 20b269b8..4f78db4d 100644 --- a/src/EVault/modules/RiskManager.sol +++ b/src/EVault/modules/RiskManager.sol @@ -68,6 +68,7 @@ abstract contract RiskManagerModule is IRiskManager, LiquidityUtils { /// `disableController`), but they don't change the vault's storage. function checkAccountStatus(address account, address[] calldata collaterals) public + view virtual reentrantOK onlyEVCChecks @@ -108,6 +109,8 @@ abstract contract RiskManagerModule is IRiskManager, LiquidityUtils { // Borrows are rounded down, because total assets could increase during repays. // This could happen when repaid user debt is rounded up to assets and used to increase cash, // while totalBorrows would be adjusted by only the exact debt, less than the increase in cash. + // If multiple accounts need to repay while the supply cap is exceeded they should do so in + // separate batches. uint256 supply = vaultCache.cash.toUint() + vaultCache.totalBorrows.toAssetsDown().toUint(); if (supply > vaultCache.supplyCap && supply > prevSupply) revert E_SupplyCapExceeded(); diff --git a/src/EVault/modules/Token.sol b/src/EVault/modules/Token.sol index 3aec5ed3..fab2fbbc 100644 --- a/src/EVault/modules/Token.sol +++ b/src/EVault/modules/Token.sol @@ -18,12 +18,12 @@ abstract contract TokenModule is IToken, BalanceUtils { /// @inheritdoc IERC20 function name() public view virtual reentrantOK returns (string memory) { - return bytes(vaultStorage.name).length > 0 ? vaultStorage.name : "Unnamed Euler Vault"; + return vaultStorage.name; } /// @inheritdoc IERC20 function symbol() public view virtual reentrantOK returns (string memory) { - return bytes(vaultStorage.symbol).length > 0 ? vaultStorage.symbol : "UNKNOWN"; + return vaultStorage.symbol; } /// @inheritdoc IERC20 diff --git a/src/EVault/shared/AssetTransfers.sol b/src/EVault/shared/AssetTransfers.sol index 83a269d0..095b1305 100644 --- a/src/EVault/shared/AssetTransfers.sol +++ b/src/EVault/shared/AssetTransfers.sol @@ -20,15 +20,16 @@ abstract contract AssetTransfers is Base { vaultStorage.cash = vaultCache.cash = vaultCache.cash + amount; } - /// @dev If the `CFG_EVC_COMPATIBLE_ASSET` flag is set, the function will protect users from mistakenly sending - /// funds to the EVC sub-accounts. Functions that push tokens out (`withdraw`, `redeem`, `borrow`) accept a - /// `receiver` argument. If the user sets one of their sub-accounts (not the owner) as the receiver, funds would be - /// lost because a regular asset doesn't support the EVC's sub-accounts. The private key to a sub-account (not the - /// owner) is not known, so the user would not be able to move the funds out. The function will make a best effort - /// to prevent this by checking if the receiver of the token is recognized by EVC as a non-owner sub-account. In - /// other words, if there is an account registered in EVC as the owner for the intended receiver, the transfer will - /// be prevented. However, there is no guarantee that EVC will have the owner registered. If the asset itself is - /// compatible with EVC, it is safe to not set the flag and send the asset to a non-owner sub-account. + /// @dev If the `CFG_EVC_COMPATIBLE_ASSET` flag is not set (default), the function will protect users from + /// mistakenly sending funds to the EVC sub-accounts. Functions that push tokens out (`withdraw`, `redeem`, + /// `borrow`) accept a `receiver` argument. If the user sets one of their sub-accounts (not the owner) as the + /// receiver, funds would be lost because a regular asset doesn't support the EVC's sub-accounts. The private key to + /// a sub-account (not the owner) is not known, so the user would not be able to move the funds out. The function + /// will make a best effort to prevent this by checking if the receiver of the token is recognized by EVC as a + /// non-owner sub-account. In other words, if there is an account registered in EVC as the owner for the intended + /// receiver, the transfer will be prevented. However, there is no guarantee that EVC will have the owner + /// registered. If the asset itself is compatible with EVC, it is safe to set the flag and send the asset to a + /// non-owner sub-account. function pushAssets(VaultCache memory vaultCache, address to, Assets amount) internal virtual { if ( to == address(0) diff --git a/src/EVault/shared/BalanceUtils.sol b/src/EVault/shared/BalanceUtils.sol index 1c6105e8..7f68706d 100644 --- a/src/EVault/shared/BalanceUtils.sol +++ b/src/EVault/shared/BalanceUtils.sol @@ -80,6 +80,10 @@ abstract contract BalanceUtils is Base { Shares newFromBalance = origFromBalance.subUnchecked(amount); user.setBalance(newFromBalance); + if (fromBalanceForwarderEnabled) { + balanceTracker.balanceTrackerHook(from, newFromBalance.toUint(), isControlCollateralInProgress()); + } + // update to user = vaultStorage.users[to]; @@ -89,11 +93,7 @@ abstract contract BalanceUtils is Base { Shares newToBalance = origToBalance + amount; user.setBalance(newToBalance); - if (fromBalanceForwarderEnabled) { - balanceTracker.balanceTrackerHook(from, newFromBalance.toUint(), isControlCollateralInProgress()); - } - - if (toBalanceForwarderEnabled && from != to) { + if (toBalanceForwarderEnabled) { balanceTracker.balanceTrackerHook(to, newToBalance.toUint(), false); } } @@ -104,7 +104,7 @@ abstract contract BalanceUtils is Base { // Allowance function setAllowance(address owner, address spender, uint256 amount) internal { - if (spender == owner) revert E_SelfApproval(); + if (spender == owner) return; vaultStorage.users[owner].eTokenAllowance[spender] = amount; emit Approval(owner, spender, amount); diff --git a/src/EVault/shared/BorrowUtils.sol b/src/EVault/shared/BorrowUtils.sol index 69329ad0..e60faf14 100644 --- a/src/EVault/shared/BorrowUtils.sol +++ b/src/EVault/shared/BorrowUtils.sol @@ -95,13 +95,17 @@ abstract contract BorrowUtils is Base { toOwed = toOwed + amount; setUserBorrow(vaultCache, to, toOwed); - logRepay(from, assets, fromOwedPrev.toAssetsUp(), fromOwed.toAssetsUp()); + // with small fractional debt amounts the interest calculation could be negative in `logRepay` + Assets fromPrevAssets = fromOwedPrev.toAssetsUp(); + Assets fromAssets = fromOwed.toAssetsUp(); + Assets repayAssets = fromPrevAssets > assets + fromAssets ? fromPrevAssets.subUnchecked(fromAssets) : assets; + logRepay(from, repayAssets, fromPrevAssets, fromAssets); // with small fractional debt amounts the interest calculation could be negative in `logBorrow` Assets toPrevAssets = toOwedPrev.toAssetsUp(); Assets toAssets = toOwed.toAssetsUp(); - if (assets + toPrevAssets > toAssets) assets = toAssets - toPrevAssets; - logBorrow(to, assets, toPrevAssets, toAssets); + Assets borrowAssets = assets + toPrevAssets > toAssets ? toAssets.subUnchecked(toPrevAssets) : assets; + logBorrow(to, borrowAssets, toPrevAssets, toAssets); } function computeInterestRate(VaultCache memory vaultCache) internal virtual returns (uint256) { diff --git a/src/EVault/shared/Constants.sol b/src/EVault/shared/Constants.sol index ac206290..b0fcbbc2 100644 --- a/src/EVault/shared/Constants.sol +++ b/src/EVault/shared/Constants.sol @@ -11,8 +11,8 @@ uint256 constant MAX_SANE_AMOUNT = type(uint112).max; // Last 31 bits are zeros to ensure max debt rounded up equals max sane amount. uint256 constant MAX_SANE_DEBT_AMOUNT = uint256(MAX_SANE_AMOUNT) << INTERNAL_DEBT_PRECISION_SHIFT; // proxy trailing calldata length in bytes. -// Three addresses, 20 bytes each: vault underlying asset, oracle and unit of account. -uint256 constant PROXY_METADATA_LENGTH = 60; +// Three addresses, 20 bytes each: vault underlying asset, oracle and unit of account + 4 empty bytes. +uint256 constant PROXY_METADATA_LENGTH = 64; // gregorian calendar uint256 constant SECONDS_PER_YEAR = 365.2425 * 86400; // max interest rate accepted from IRM. 1,000,000% APY: floor(((1000000 / 100 + 1)**(1/(86400*365.2425)) - 1) * 1e27) diff --git a/src/EVault/shared/Errors.sol b/src/EVault/shared/Errors.sol index a4bf6de8..e4c63e34 100644 --- a/src/EVault/shared/Errors.sol +++ b/src/EVault/shared/Errors.sol @@ -9,7 +9,6 @@ pragma solidity ^0.8.0; contract Errors { error E_Initialized(); error E_ProxyMetadata(); - error E_SelfApproval(); error E_SelfTransfer(); error E_InsufficientAllowance(); error E_InsufficientCash(); @@ -53,6 +52,7 @@ contract Errors { error E_BadAssetReceiver(); error E_BadSharesOwner(); error E_BadSharesReceiver(); + error E_BadMaxLiquidationDiscount(); error E_LTVBorrow(); error E_LTVLiquidation(); error E_NotHookTarget(); diff --git a/src/EVault/shared/LiquidityUtils.sol b/src/EVault/shared/LiquidityUtils.sol index 1de279dd..e6b68e28 100644 --- a/src/EVault/shared/LiquidityUtils.sol +++ b/src/EVault/shared/LiquidityUtils.sol @@ -30,9 +30,9 @@ abstract contract LiquidityUtils is BorrowUtils, LTVUtils { liabilityValue = getLiabilityValue(vaultCache, account, vaultStorage.users[account].getOwed(), liquidation); } - // Check that the value of the collateral, adjusted for borrowing LTV, is equal or greater than the liability value. - // Since this function uses bid/ask prices, it should only be used within the account status check, and not - // for determining whether an account can be liquidated (which uses mid-point prices). + // Check that there is no liability, or the value of the collateral, adjusted for borrowing LTV, is greater than the + // liability value. Since this function uses bid/ask prices, it should only be used within the account status check, + // and not for determining whether an account can be liquidated (which uses mid-point prices). function checkLiquidity(VaultCache memory vaultCache, address account, address[] memory collaterals) internal view diff --git a/src/EVault/shared/lib/SafeERC20Lib.sol b/src/EVault/shared/lib/SafeERC20Lib.sol index d1b99af6..49161cc3 100644 --- a/src/EVault/shared/lib/SafeERC20Lib.sol +++ b/src/EVault/shared/lib/SafeERC20Lib.sol @@ -11,8 +11,7 @@ import {IPermit2} from "../../../interfaces/IPermit2.sol"; /// @author Euler Labs (https://www.eulerlabs.com/) /// @notice The library provides helpers for ERC20 transfers, including Permit2 support library SafeERC20Lib { - error E_TransferFromFailed(bytes errorTransferFrom, bytes errorPermit2); - error E_Permit2AmountOverflow(); + error E_TransferFromFailed(bytes errorPermit2, bytes errorTransferFrom); // If no code exists under the token address, the function will succeed. EVault ensures this is not the case in // `initialize`. @@ -26,18 +25,21 @@ library SafeERC20Lib { } function safeTransferFrom(IERC20 token, address from, address to, uint256 value, address permit2) internal { - (bool success, bytes memory tryData) = trySafeTransferFrom(token, from, to, value); - bytes memory fallbackData; - if (!success && permit2 != address(0)) { - if (value > type(uint160).max) { - revert E_TransferFromFailed(tryData, abi.encodePacked(E_Permit2AmountOverflow.selector)); - } - // it's now safe to down-cast value to uint160 - (success, fallbackData) = + bool success; + bytes memory permit2Data; + bytes memory transferData; + + if (permit2 != address(0) && value <= type(uint160).max) { + // it's safe to down-cast value to uint160 + (success, permit2Data) = permit2.call(abi.encodeCall(IPermit2.transferFrom, (from, to, uint160(value), address(token)))); } - if (!success) revert E_TransferFromFailed(tryData, fallbackData); + if (!success) { + (success, transferData) = trySafeTransferFrom(token, from, to, value); + } + + if (!success) revert E_TransferFromFailed(permit2Data, transferData); } // If no code exists under the token address, the function will succeed. EVault ensures this is not the case in diff --git a/src/EVault/shared/types/LTVConfig.sol b/src/EVault/shared/types/LTVConfig.sol index a1a3c73d..9e1e53e5 100644 --- a/src/EVault/shared/types/LTVConfig.sol +++ b/src/EVault/shared/types/LTVConfig.sol @@ -7,7 +7,7 @@ import {ConfigAmount} from "./Types.sol"; /// @title LTVConfig /// @notice This packed struct is used to store LTV configuration of a collateral struct LTVConfig { - // Packed slot: 2 + 2 + 2 + 6 + 4 + 1 = 17 + // Packed slot: 2 + 2 + 2 + 6 + 4 = 16 // The value of borrow LTV for originating positions ConfigAmount borrowLTV; // The value of fully converged liquidation LTV @@ -18,8 +18,6 @@ struct LTVConfig { uint48 targetTimestamp; // The time it takes for the liquidation LTV to converge from the initial value to the fully converged value uint32 rampDuration; - // A flag indicating the LTV configuration was initialized for the collateral - bool initialized; } /// @title LTVConfigLib @@ -68,16 +66,6 @@ library LTVConfigLib { newLTV.initialLiquidationLTV = self.getLTV(true); newLTV.targetTimestamp = uint48(block.timestamp + rampDuration); newLTV.rampDuration = rampDuration; - newLTV.initialized = true; - } - - // When LTV is cleared, the collateral can't be liquidated, as it's deemed unsafe - function clear(LTVConfig storage self) internal { - self.borrowLTV = ConfigAmount.wrap(0); - self.liquidationLTV = ConfigAmount.wrap(0); - self.initialLiquidationLTV = ConfigAmount.wrap(0); - self.targetTimestamp = 0; - self.rampDuration = 0; } } diff --git a/src/GenericFactory/GenericFactory.sol b/src/GenericFactory/GenericFactory.sol index 43946010..7fa3597a 100644 --- a/src/GenericFactory/GenericFactory.sol +++ b/src/GenericFactory/GenericFactory.sol @@ -123,12 +123,15 @@ contract GenericFactory is MetaProxyDeployer { if (desiredImplementation == address(0) || desiredImplementation != _implementation) revert E_Implementation(); + // The provided trailing data is prefixed with 4 zero bytes to avoid potential selector clashing in case the + // proxy is called with empty calldata. + bytes memory prefixTrailingData = abi.encodePacked(bytes4(0), trailingData); address proxy; if (upgradeable) { - proxy = address(new BeaconProxy(trailingData)); + proxy = address(new BeaconProxy(prefixTrailingData)); } else { - proxy = deployMetaProxy(desiredImplementation, trailingData); + proxy = deployMetaProxy(desiredImplementation, prefixTrailingData); } proxyLookup[proxy] = @@ -149,7 +152,7 @@ contract GenericFactory is MetaProxyDeployer { /// @param newImplementation Address of the new implementation contract /// @dev Upgrades all existing BeaconProxies to the new logic immediately function setImplementation(address newImplementation) external nonReentrant adminOnly { - if (newImplementation == address(0)) revert E_BadAddress(); + if (newImplementation.code.length == 0) revert E_BadAddress(); implementation = newImplementation; emit SetImplementation(newImplementation); } diff --git a/src/InterestRateModels/IRMLinearKink.sol b/src/InterestRateModels/IRMLinearKink.sol index 3c5b2357..fb3cab74 100644 --- a/src/InterestRateModels/IRMLinearKink.sol +++ b/src/InterestRateModels/IRMLinearKink.sol @@ -19,7 +19,7 @@ contract IRMLinearKink is IIRM { /// @notice Utilization at which the slope of the interest rate function changes. In type(uint32).max scale. uint256 public immutable kink; - constructor(uint256 baseRate_, uint256 slope1_, uint256 slope2_, uint256 kink_) { + constructor(uint256 baseRate_, uint256 slope1_, uint256 slope2_, uint32 kink_) { baseRate = baseRate_; slope1 = slope1_; slope2 = slope2_; diff --git a/src/SequenceRegistry/SequenceRegistry.sol b/src/SequenceRegistry/SequenceRegistry.sol index 2a9cca1c..6a3bb754 100644 --- a/src/SequenceRegistry/SequenceRegistry.sol +++ b/src/SequenceRegistry/SequenceRegistry.sol @@ -19,7 +19,7 @@ contract SequenceRegistry is ISequenceRegistry { /// @param designator The opaque designator string /// @param id The reserved ID, which is unique per designator /// @param caller The msg.sender who reserved the ID - event SequenceIdReserved(string indexed designator, uint256 indexed id, address indexed caller); + event SequenceIdReserved(string designator, uint256 indexed id, address indexed caller); /// @inheritdoc ISequenceRegistry function reserveSeqId(string calldata designator) external returns (uint256) { diff --git a/src/Synths/ERC20Collateral.sol b/src/Synths/ERC20Collateral.sol deleted file mode 100644 index d7901961..00000000 --- a/src/Synths/ERC20Collateral.sol +++ /dev/null @@ -1,74 +0,0 @@ -// SPDX-License-Identifier: GPL-2.0-or-later - -pragma solidity ^0.8.0; - -import {ERC20, Context} from "openzeppelin-contracts/token/ERC20/ERC20.sol"; -import {ERC20Permit} from "openzeppelin-contracts/token/ERC20/extensions/ERC20Permit.sol"; -import {ReentrancyGuard} from "openzeppelin-contracts/utils/ReentrancyGuard.sol"; -import {IEVC, EVCUtil} from "ethereum-vault-connector/utils/EVCUtil.sol"; - -/// @title ERC20Collateral -/// @custom:security-contact security@euler.xyz -/// @author Euler Labs (https://www.eulerlabs.com/) -/// @notice ERC20Collateral is an ERC20-compatible token with the EVC support which allows it to be used as collateral -/// in other vaults. -abstract contract ERC20Collateral is EVCUtil, ERC20Permit, ReentrancyGuard { - constructor(IEVC _evc_, string memory _name_, string memory _symbol_) - EVCUtil(address(_evc_)) - ERC20(_name_, _symbol_) - ERC20Permit(_name_) - {} - - /// @notice Transfers a certain amount of tokens to a recipient. - /// @dev Overriden to add reentrancy protection. - /// @param to The recipient of the transfer. - /// @param amount The amount shares to transfer. - /// @return A boolean indicating whether the transfer was successful. - function transfer(address to, uint256 amount) public virtual override nonReentrant returns (bool) { - return super.transfer(to, amount); - } - - /// @notice Transfers a certain amount of tokens from a sender to a recipient. - /// @dev Overriden to add reentrancy protection. - /// @param from The sender of the transfer. - /// @param to The recipient of the transfer. - /// @param amount The amount of shares to transfer. - /// @return A boolean indicating whether the transfer was successful. - function transferFrom(address from, address to, uint256 amount) - public - virtual - override - nonReentrant - returns (bool) - { - return super.transferFrom(from, to, amount); - } - - /// @notice Transfers a `value` amount of tokens from `from` to `to`, or alternatively mints (or burns) if `from` - /// (or `to`) is the zero address. All customizations to transfers, mints, and burns should be done by overriding - /// this function. - /// @dev Overriden to require account status checks on transfers from non-zero addresses. The account status check - /// must be required on any operation that reduces user's balance. Note that the user balance cannot be modified - // outside of this function as the account status check must always be requested after the balance is modified which - // is ensured by this function. If any user balance modifications are done outside of this function, the contract - // must be modified to request the account status check appropriately. - /// @param from The address from which tokens are transferred or burned. - /// @param to The address to which tokens are transferred or minted. - /// @param value The amount of tokens to transfer, mint, or burn. - function _update(address from, address to, uint256 value) internal virtual override { - super._update(from, to, value); - - if (from != address(0)) { - evc.requireAccountStatusCheck(from); - } - } - - /// @notice Retrieves the message sender in the context of the EVC. - /// @dev Overriden due to the conflict with the Context definition. - /// @dev This function returns the account on behalf of which the current operation is being performed, which is - /// either msg.sender or the account authenticated by the EVC. - /// @return The address of the message sender. - function _msgSender() internal view virtual override (EVCUtil, Context) returns (address) { - return EVCUtil._msgSender(); - } -} diff --git a/src/Synths/ERC20EVCCompatible.sol b/src/Synths/ERC20EVCCompatible.sol new file mode 100644 index 00000000..c0b678c9 --- /dev/null +++ b/src/Synths/ERC20EVCCompatible.sol @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.8.0; + +import {ERC20, Context} from "openzeppelin-contracts/token/ERC20/ERC20.sol"; +import {ERC20Permit} from "openzeppelin-contracts/token/ERC20/extensions/ERC20Permit.sol"; +import {EVCUtil} from "ethereum-vault-connector/utils/EVCUtil.sol"; + +/// @title ERC20EVCCompatible +/// @custom:security-contact security@euler.xyz +/// @author Euler Labs (https://www.eulerlabs.com/) +/// @notice ERC20EVCCompatible is an ERC20-compatible token with the EVC support. +abstract contract ERC20EVCCompatible is EVCUtil, ERC20Permit { + constructor(address _evc_, string memory _name_, string memory _symbol_) + EVCUtil(_evc_) + ERC20(_name_, _symbol_) + ERC20Permit(_name_) + {} + + /// @notice Retrieves the message sender in the context of the EVC. + /// @dev Overridden due to the conflict with the Context definition. + /// @dev This function returns the account on behalf of which the current operation is being performed, which is + /// either msg.sender or the account authenticated by the EVC. + /// @return The address of the message sender. + function _msgSender() internal view virtual override (EVCUtil, Context) returns (address) { + return EVCUtil._msgSender(); + } +} diff --git a/src/Synths/ESynth.sol b/src/Synths/ESynth.sol index 5ab4e219..cece73cf 100644 --- a/src/Synths/ESynth.sol +++ b/src/Synths/ESynth.sol @@ -4,17 +4,15 @@ pragma solidity ^0.8.0; import {Ownable} from "openzeppelin-contracts/access/Ownable.sol"; import {EnumerableSet} from "openzeppelin-contracts/utils/structs/EnumerableSet.sol"; -import {IEVC, EVCUtil} from "ethereum-vault-connector/utils/EVCUtil.sol"; -import {ERC20Collateral, ERC20, Context} from "./ERC20Collateral.sol"; +import {ERC20EVCCompatible, Context} from "./ERC20EVCCompatible.sol"; import {IEVault} from "../EVault/IEVault.sol"; /// @title ESynth /// @custom:security-contact security@euler.xyz /// @author Euler Labs (https://www.eulerlabs.com/) -/// @notice ESynth is an ERC20-compatible token with the EVC support which, thanks to relying on the EVC authentication -/// and requesting the account status checks on token transfers and burns, allows it to be used as collateral in other -/// vault. It is meant to be used as an underlying asset of the synthetic asset vault. -contract ESynth is ERC20Collateral, Ownable { +/// @notice ESynth is an ERC20-compatible token with the EVC support. It is meant to be used as an underlying asset of +/// the synthetic asset vault. +contract ESynth is ERC20EVCCompatible, Ownable { using EnumerableSet for EnumerableSet.AddressSet; struct MinterData { @@ -22,24 +20,31 @@ contract ESynth is ERC20Collateral, Ownable { uint128 minted; } + /// @notice contains the minting capacity and minted amount for each minter. mapping(address => MinterData) public minters; + /// @notice contains the list of addresses to ignore for the total supply. EnumerableSet.AddressSet internal ignoredForTotalSupply; + /// @notice Emitted when the minting capacity for a minter is set. + /// @param minter The address of the minter. + /// @param capacity The capacity set for the minter. event MinterCapacitySet(address indexed minter, uint256 capacity); error E_CapacityReached(); error E_NotEVCCompatible(); - constructor(IEVC evc_, string memory name_, string memory symbol_) - ERC20Collateral(evc_, name_, symbol_) + constructor(address evc_, string memory name_, string memory symbol_) + ERC20EVCCompatible(evc_, name_, symbol_) Ownable(msg.sender) - {} + { + ignoredForTotalSupply.add(address(this)); + } /// @notice Sets the minting capacity for a minter. /// @dev Can only be called by the owner of the contract. /// @param minter The address of the minter to set the capacity for. /// @param capacity The capacity to set for the minter. - function setCapacity(address minter, uint128 capacity) external onlyOwner { + function setCapacity(address minter, uint128 capacity) external onlyEVCAccountOwner onlyOwner { minters[minter].capacity = capacity; emit MinterCapacitySet(minter, capacity); } @@ -47,7 +52,7 @@ contract ESynth is ERC20Collateral, Ownable { /// @notice Mints a certain amount of tokens to the account. /// @param account The account to mint the tokens to. /// @param amount The amount of tokens to mint. - function mint(address account, uint256 amount) external nonReentrant { + function mint(address account, uint256 amount) external { address sender = _msgSender(); MinterData memory minterCache = minters[sender]; @@ -73,7 +78,7 @@ contract ESynth is ERC20Collateral, Ownable { /// have an allowance for the sender. /// @param burnFrom The account to burn the tokens from. /// @param amount The amount of tokens to burn. - function burn(address burnFrom, uint256 amount) external nonReentrant { + function burn(address burnFrom, uint256 amount) external { address sender = _msgSender(); MinterData memory minterCache = minters[sender]; @@ -101,7 +106,7 @@ contract ESynth is ERC20Collateral, Ownable { /// @dev Adds the vault to the list of accounts to ignore for the total supply. /// @param vault The vault to deposit the cash in. /// @param amount The amount of cash to deposit. - function allocate(address vault, uint256 amount) external onlyOwner { + function allocate(address vault, uint256 amount) external onlyEVCAccountOwner onlyOwner { if (IEVault(vault).EVC() != address(evc)) { revert E_NotEVCCompatible(); } @@ -113,17 +118,17 @@ contract ESynth is ERC20Collateral, Ownable { /// @notice Withdraw cash from the attached vault to this contract. /// @param vault The vault to withdraw the cash from. /// @param amount The amount of cash to withdraw. - function deallocate(address vault, uint256 amount) external onlyOwner { + function deallocate(address vault, uint256 amount) external onlyEVCAccountOwner onlyOwner { IEVault(vault).withdraw(amount, address(this), address(this)); } /// @notice Retrieves the message sender in the context of the EVC. - /// @dev Overriden due to the conflict with the Context definition. + /// @dev Overridden due to the conflict with the Context definition. /// @dev This function returns the account on behalf of which the current operation is being performed, which is /// either msg.sender or the account authenticated by the EVC. - /// @return The address of the message sender. - function _msgSender() internal view virtual override (ERC20Collateral, Context) returns (address) { - return ERC20Collateral._msgSender(); + /// @return msgSender The address of the message sender. + function _msgSender() internal view virtual override (ERC20EVCCompatible, Context) returns (address msgSender) { + return ERC20EVCCompatible._msgSender(); } // -------- TotalSupply Management -------- @@ -131,37 +136,43 @@ contract ESynth is ERC20Collateral, Ownable { /// @notice Adds an account to the list of accounts to ignore for the total supply. /// @param account The account to add to the list. /// @return success True when the account was not on the list and was added. False otherwise. - function addIgnoredForTotalSupply(address account) external onlyOwner returns (bool success) { + function addIgnoredForTotalSupply(address account) external onlyEVCAccountOwner onlyOwner returns (bool success) { return ignoredForTotalSupply.add(account); } /// @notice Removes an account from the list of accounts to ignore for the total supply. /// @param account The account to remove from the list. /// @return success True when the account was on the list and was removed. False otherwise. - function removeIgnoredForTotalSupply(address account) external onlyOwner returns (bool success) { + function removeIgnoredForTotalSupply(address account) + external + onlyEVCAccountOwner + onlyOwner + returns (bool success) + { return ignoredForTotalSupply.remove(account); } /// @notice Checks if an account is ignored for the total supply. /// @param account The account to check. - function isIgnoredForTotalSupply(address account) public view returns (bool) { + /// @return isIgnored True if the account is ignored for the total supply. False otherwise. + function isIgnoredForTotalSupply(address account) external view returns (bool isIgnored) { return ignoredForTotalSupply.contains(account); } /// @notice Retrieves all the accounts ignored for the total supply. - /// @return The list of accounts ignored for the total supply. - function getAllIgnoredForTotalSupply() public view returns (address[] memory) { + /// @return accounts List of accounts ignored for the total supply. + function getAllIgnoredForTotalSupply() external view returns (address[] memory accounts) { return ignoredForTotalSupply.values(); } /// @notice Retrieves the total supply of the token. - /// @dev Overriden to exclude the ignored accounts from the total supply. - /// @return The total supply of the token. - function totalSupply() public view override returns (uint256) { - uint256 total = super.totalSupply(); + /// @dev Overridden to exclude the ignored accounts from the total supply. + /// @return total Total supply of the token. + function totalSupply() public view override returns (uint256 total) { + total = super.totalSupply(); uint256 ignoredLength = ignoredForTotalSupply.length(); // cache for efficiency - for (uint256 i = 0; i < ignoredLength; i++) { + for (uint256 i = 0; i < ignoredLength; ++i) { total -= balanceOf(ignoredForTotalSupply.at(i)); } return total; diff --git a/src/Synths/EulerSavingsRate.sol b/src/Synths/EulerSavingsRate.sol index 30cf87bf..da03ee51 100644 --- a/src/Synths/EulerSavingsRate.sol +++ b/src/Synths/EulerSavingsRate.sol @@ -6,16 +6,13 @@ import {Math} from "openzeppelin-contracts/utils/math/Math.sol"; import {IERC20} from "openzeppelin-contracts/token/ERC20/IERC20.sol"; import {ERC20} from "openzeppelin-contracts/token/ERC20/ERC20.sol"; import {ERC4626} from "openzeppelin-contracts/token/ERC20/extensions/ERC4626.sol"; -import {IEVC} from "ethereum-vault-connector/interfaces/IEthereumVaultConnector.sol"; import {EVCUtil} from "ethereum-vault-connector/utils/EVCUtil.sol"; /// @title EulerSavingsRate /// @custom:security-contact security@euler.xyz /// @author Euler Labs (https://www.eulerlabs.com/) -/// @notice EulerSavingsRate is a ERC4626-compatible vault which allows users to deposit the underlying asset and -/// receive interest in the form of the same underlying asset. On withdraw, redeem and transfers, the account status -/// checks must be requested for the account which health might be negatively affected. Thanks to that, the shares of -/// the EulerSavingsRate vault might be used as collateral by other EVC-compatible vaults. +/// @notice EulerSavingsRate is a ERC4626-compatible vault with the EVC support which allows users to deposit the +/// underlying asset and receive interest in the form of the same underlying asset. /// @dev Do NOT use with fee on transfer tokens /// @dev Do NOT use with rebasing tokens contract EulerSavingsRate is EVCUtil, ERC4626 { @@ -24,7 +21,11 @@ contract EulerSavingsRate is EVCUtil, ERC4626 { uint8 internal constant UNLOCKED = 1; uint8 internal constant LOCKED = 2; + /// @notice The virtual amount added to total shares and total assets. uint256 internal constant VIRTUAL_AMOUNT = 1e6; + /// @notice At least 10 times the virtual amount of shares should exist for gulp to be enabled + uint256 internal constant MIN_SHARES_FOR_GULP = VIRTUAL_AMOUNT * 10; + uint256 public constant INTEREST_SMEAR = 2 weeks; struct ESRSlot { @@ -34,18 +35,15 @@ contract EulerSavingsRate is EVCUtil, ERC4626 { uint8 locked; } + /// @notice Multiple state variables stored in a single storage slot. ESRSlot internal esrSlot; + /// @notice The total assets accounted for in the vault. uint256 internal _totalAssets; error Reentrancy(); - /// @notice Modifier to require an account status check on the EVC. - /// @dev Calls `requireAccountStatusCheck` function from EVC for the specified account after the function body. - /// @param account The address of the account to check. - modifier requireAccountStatusCheck(address account) { - _; - evc.requireAccountStatusCheck(account); - } + event Gulped(uint256 gulped, uint256 interestLeft); + event InterestUpdated(uint256 interestAccrued, uint256 interestLeft); modifier nonReentrant() { if (esrSlot.locked == LOCKED) revert Reentrancy(); @@ -55,8 +53,8 @@ contract EulerSavingsRate is EVCUtil, ERC4626 { esrSlot.locked = UNLOCKED; } - constructor(IEVC _evc, address _asset, string memory _name, string memory _symbol) - EVCUtil(address(_evc)) + constructor(address _evc, address _asset, string memory _name, string memory _symbol) + EVCUtil(_evc) ERC4626(IERC20(_asset)) ERC20(_name, _symbol) { @@ -69,63 +67,6 @@ contract EulerSavingsRate is EVCUtil, ERC4626 { return _totalAssets + interestAccrued(); } - function maxRedeem(address owner) public view override returns (uint256) { - // If account has borrows, withdrawal might be reverted by the controller during account status checks. - // The vault has no way to verify or enforce the behaviour of the controller, which the account owner - // has enabled. It will therefore assume that all of the assets would be witheld by the controller and - // under-estimate the return amount to zero. - // Integrators who handle borrowing should implement custom logic to work with the particular controllers - // they want to support. - if (evc.getControllers(owner).length > 0) { - return 0; - } - - return super.maxRedeem(owner); - } - - function maxWithdraw(address owner) public view override returns (uint256) { - // If account has borrows, withdrawal might be reverted by the controller during account status checks. - // The vault has no way to verify or enforce the behaviour of the controller, which the account owner - // has enabled. It will therefore assume that all of the assets would be witheld by the controller and - // under-estimate the return amount to zero. - // Integrators who handle borrowing should implement custom logic to work with the particular controllers - // they want to support. - if (evc.getControllers(owner).length > 0) { - return 0; - } - - return super.maxWithdraw(owner); - } - - /// @notice Transfers a certain amount of tokens to a recipient. - /// @param to The recipient of the transfer. - /// @param amount The amount shares to transfer. - /// @return A boolean indicating whether the transfer was successful. - function transfer(address to, uint256 amount) - public - override (ERC20, IERC20) - nonReentrant - requireAccountStatusCheck(_msgSender()) - returns (bool) - { - return super.transfer(to, amount); - } - - /// @notice Transfers a certain amount of tokens from a sender to a recipient. - /// @param from The sender of the transfer. - /// @param to The recipient of the transfer. - /// @param amount The amount of shares to transfer. - /// @return A boolean indicating whether the transfer was successful. - function transferFrom(address from, address to, uint256 amount) - public - override (ERC20, IERC20) - nonReentrant - requireAccountStatusCheck(from) - returns (bool) - { - return super.transferFrom(from, to, amount); - } - /// @notice Deposits a certain amount of assets to the vault. /// @param assets The amount of assets to deposit. /// @param receiver The recipient of the shares. @@ -137,38 +78,30 @@ contract EulerSavingsRate is EVCUtil, ERC4626 { /// @notice Mints a certain amount of shares to the account. /// @param shares The amount of assets to mint. /// @param receiver The account to mint the shares to. - /// @return The amount of assets spend. + /// @return The amount of assets spent. function mint(uint256 shares, address receiver) public override nonReentrant returns (uint256) { return super.mint(shares, receiver); } - /// @notice Deposits a certain amount of assets to the vault. - /// @param assets The amount of assets to deposit. - /// @param receiver The recipient of the shares. - /// @return The amount of shares minted. - function withdraw(uint256 assets, address receiver, address owner) - public - override - nonReentrant - requireAccountStatusCheck(owner) - returns (uint256) - { + /// @notice Withdraws a certain amount of assets from the vault. + /// @dev Overwritten to update the accrued interest and update _totalAssets. + /// @param assets The amount of assets to withdraw. + /// @param receiver The recipient of the assets. + /// @param owner The holder of shares to burn. + /// @return The amount of shares burned. + function withdraw(uint256 assets, address receiver, address owner) public override nonReentrant returns (uint256) { // Move interest to totalAssets updateInterestAndReturnESRSlotCache(); return super.withdraw(assets, receiver, owner); } /// @notice Redeems a certain amount of shares for assets. + /// @dev Overwritten to update the accrued interest and update _totalAssets. /// @param shares The amount of shares to redeem. /// @param receiver The recipient of the assets. + /// @param owner The account from which the shares are redeemed. /// @return The amount of assets redeemed. - function redeem(uint256 shares, address receiver, address owner) - public - override - nonReentrant - requireAccountStatusCheck(owner) - returns (uint256) - { + function redeem(uint256 shares, address receiver, address owner) public override nonReentrant returns (uint256) { // Move interest to totalAssets updateInterestAndReturnESRSlotCache(); return super.redeem(shares, receiver, owner); @@ -183,8 +116,8 @@ contract EulerSavingsRate is EVCUtil, ERC4626 { } function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal override { - _totalAssets = _totalAssets + assets; super._deposit(caller, receiver, assets, shares); + _totalAssets = _totalAssets + assets; } function _withdraw(address caller, address receiver, address owner, uint256 assets, uint256 shares) @@ -199,17 +132,23 @@ contract EulerSavingsRate is EVCUtil, ERC4626 { function gulp() public nonReentrant { ESRSlot memory esrSlotCache = updateInterestAndReturnESRSlotCache(); + // Do not gulp if total supply is too low + if (totalSupply() < MIN_SHARES_FOR_GULP) return; + uint256 assetBalance = IERC20(asset()).balanceOf(address(this)); uint256 toGulp = assetBalance - _totalAssets - esrSlotCache.interestLeft; uint256 maxGulp = type(uint168).max - esrSlotCache.interestLeft; if (toGulp > maxGulp) toGulp = maxGulp; // cap interest, allowing the vault to function + esrSlotCache.lastInterestUpdate = uint40(block.timestamp); esrSlotCache.interestSmearEnd = uint40(block.timestamp + INTEREST_SMEAR); esrSlotCache.interestLeft += uint168(toGulp); // toGulp <= maxGulp <= max uint168 // write esrSlotCache back to storage in a single SSTORE esrSlot = esrSlotCache; + + emit Gulped(toGulp, esrSlotCache.interestLeft); } /// @notice Updates the interest and returns the ESR storage slot cache. @@ -218,18 +157,23 @@ contract EulerSavingsRate is EVCUtil, ERC4626 { ESRSlot memory esrSlotCache = esrSlot; uint256 accruedInterest = interestAccruedFromCache(esrSlotCache); - // it's safe to down-cast because the accrued interest is a fraction of interest left - esrSlotCache.interestLeft -= uint168(accruedInterest); - esrSlotCache.lastInterestUpdate = uint40(block.timestamp); - // write esrSlotCache back to storage in a single SSTORE - esrSlot = esrSlotCache; - // Move interest accrued to totalAssets - _totalAssets = _totalAssets + accruedInterest; + if (accruedInterest > 0) { + // it's safe to down-cast because the accrued interest is a fraction of interest left + esrSlotCache.interestLeft -= uint168(accruedInterest); + esrSlotCache.lastInterestUpdate = uint40(block.timestamp); + // write esrSlotCache back to storage in a single SSTORE + esrSlot = esrSlotCache; + // Move interest accrued to totalAssets + _totalAssets = _totalAssets + accruedInterest; + + emit InterestUpdated(accruedInterest, esrSlotCache.interestLeft); + } return esrSlotCache; } /// @notice Returns the amount of interest accrued. + /// @return The amount of interest accrued. function interestAccrued() public view returns (uint256) { return interestAccruedFromCache(esrSlot); } @@ -253,6 +197,7 @@ contract EulerSavingsRate is EVCUtil, ERC4626 { } /// @notice Returns the ESR storage slot as a struct. + /// @return The ESR storage slot as a struct. function getESRSlot() public view returns (ESRSlot memory) { return esrSlot; } diff --git a/src/Synths/IRMSynth.sol b/src/Synths/IRMSynth.sol index f4963eda..2bb6d9c9 100644 --- a/src/Synths/IRMSynth.sol +++ b/src/Synths/IRMSynth.sol @@ -2,8 +2,8 @@ pragma solidity ^0.8.0; -import "../InterestRateModels/IIRM.sol"; -import "../interfaces/IPriceOracle.sol"; +import {IIRM} from "../InterestRateModels/IIRM.sol"; +import {IPriceOracle} from "../interfaces/IPriceOracle.sol"; import {IERC20} from "../EVault/IEVault.sol"; /// @title IRMSynth @@ -20,10 +20,15 @@ contract IRMSynth is IIRM { uint216 public constant ADJUST_ONE = 1.0e18; uint216 public constant ADJUST_INTERVAL = 1 hours; + /// @notice The address of the synthetic asset. address public immutable synth; + /// @notice The address of the reference asset. address public immutable referenceAsset; + /// @notice The address of the oracle. IPriceOracle public immutable oracle; + /// @notice The target quote which the IRM will try to maintain. uint256 public immutable targetQuote; + /// @notice The amount of the quote asset to use for the quote. uint256 public immutable quoteAmount; struct IRMData { @@ -36,7 +41,9 @@ contract IRMSynth is IIRM { error E_ZeroAddress(); error E_InvalidQuote(); - constructor(address synth_, address referenceAsset_, address oracle_, uint256 targetQuoute_) { + event InterestUpdated(uint256 rate); + + constructor(address synth_, address referenceAsset_, address oracle_, uint256 targetQuote_) { if (synth_ == address(0) || referenceAsset_ == address(0) || oracle_ == address(0)) { revert E_ZeroAddress(); } @@ -44,7 +51,7 @@ contract IRMSynth is IIRM { synth = synth_; referenceAsset = referenceAsset_; oracle = IPriceOracle(oracle_); - targetQuote = targetQuoute_; + targetQuote = targetQuote_; quoteAmount = 10 ** IERC20(synth_).decimals(); // Refusing to proceed with worthless asset @@ -54,21 +61,26 @@ contract IRMSynth is IIRM { } irmStorage = IRMData({lastUpdated: uint40(block.timestamp), lastRate: BASE_RATE}); + + emit InterestUpdated(BASE_RATE); } + /// @notice Computes the interest rate and updates the storage if necessary. + /// @return The interest rate. function computeInterestRate(address, uint256, uint256) external override returns (uint256) { - IRMData memory irmCache = irmStorage; - (uint216 rate, bool updated) = _computeRate(irmCache); + (uint216 rate, bool updated) = _computeRate(irmStorage); if (updated) { irmStorage = IRMData({lastUpdated: uint40(block.timestamp), lastRate: rate}); + emit InterestUpdated(rate); } return rate; } - function computeInterestRateView(address, uint256, uint256) external view override returns (uint256) { - (uint216 rate,) = _computeRate(irmStorage); + /// @return rate The new interest rate + function computeInterestRateView(address, uint256, uint256) external view override returns (uint256 rate) { + (rate,) = _computeRate(irmStorage); return rate; } @@ -89,7 +101,7 @@ contract IRMSynth is IIRM { // If the quote is less than the target, increase the rate rate = rate * ADJUST_FACTOR / ADJUST_ONE; } else { - // If the quote is greater than the target, decrease the rate + // If the quote is greater than or equal to the target, decrease the rate rate = rate * ADJUST_ONE / ADJUST_FACTOR; } @@ -103,6 +115,8 @@ contract IRMSynth is IIRM { return (rate, updated); } + /// @notice Retrieves the packed IRM data as a struct. + /// @return The IRM data. function getIRMData() external view returns (IRMData memory) { return irmStorage; } diff --git a/src/Synths/PegStabilityModule.sol b/src/Synths/PegStabilityModule.sol index adc64b18..f5ec5d3f 100644 --- a/src/Synths/PegStabilityModule.sol +++ b/src/Synths/PegStabilityModule.sol @@ -2,64 +2,78 @@ pragma solidity ^0.8.0; -import {EVCUtil, IEVC} from "ethereum-vault-connector/utils/EVCUtil.sol"; +import {EVCUtil} from "ethereum-vault-connector/utils/EVCUtil.sol"; import {IERC20} from "openzeppelin-contracts/token/ERC20/IERC20.sol"; import {SafeERC20} from "openzeppelin-contracts/token/ERC20/utils/SafeERC20.sol"; +import {Math} from "openzeppelin-contracts/utils/math/Math.sol"; import {ESynth} from "./ESynth.sol"; /// @title PegStabilityModule /// @custom:security-contact security@euler.xyz /// @author Euler Labs (https://www.eulerlabs.com/) /// @notice The PegStabilityModule is granted minting rights on the ESynth and must allow slippage-free conversion from -/// and to the underlying asset as per configured conversionPrice. On deployment, the fee for swaps to synthetic asset +/// and to the underlying asset as per configured CONVERSION_PRICE. On deployment, the fee for swaps to synthetic asset /// and to underlying asset are defined. These fees must accrue to the PegStabilityModule contract and can not be /// withdrawn, serving as a permanent reserve to support the peg. Swapping to the synthetic asset is possible up to the /// minting cap granted for the PegStabilityModule in the ESynth. Swapping to the underlying asset is possible up to the /// amount of the underlying asset held by the PegStabilityModule. contract PegStabilityModule is EVCUtil { using SafeERC20 for IERC20; + using Math for uint256; uint256 public constant BPS_SCALE = 100_00; uint256 public constant PRICE_SCALE = 1e18; + /// @notice The synthetic asset. ESynth public immutable synth; + /// @notice The underlying asset. IERC20 public immutable underlying; - uint256 public immutable conversionPrice; // 1e18 = 1 SYNTH == 1 UNDERLYING, 0.01e18 = 1 SYNTH == 0.01 UNDERLYING + /// @notice The fee for swapping to the underlying asset in basis points. uint256 public immutable TO_UNDERLYING_FEE; + /// @notice The fee for swapping to the synthetic asset in basis points. uint256 public immutable TO_SYNTH_FEE; + /// @notice The conversion price between the synthetic and underlying asset. + uint256 public immutable CONVERSION_PRICE; error E_ZeroAddress(); error E_FeeExceedsBPS(); + error E_ZeroConversionPrice(); /// @param _evc The address of the EVC. /// @param _synth The address of the synthetic asset. /// @param _underlying The address of the underlying asset. - /// @param toUnderlyingFeeBPS The fee for swapping to the underlying asset in basis points. eg: 100 = 1% - /// @param toSynthFeeBPS The fee for swapping to the synthetic asset in basis points. eg: 100 = 1% + /// @param _toUnderlyingFeeBPS The fee for swapping to the underlying asset in basis points. eg: 100 = 1% + /// @param _toSynthFeeBPS The fee for swapping to the synthetic asset in basis points. eg: 100 = 1% /// @param _conversionPrice The conversion price between the synthetic and underlying asset. - /// eg: 1e18 = 1 SYNTH == 1 UNDERLYING, 0.01e18 = 1 SYNTH == 0.01 UNDERLYING + /// @dev _conversionPrice = 10**underlyingDecimals corresponds to 1:1 peg + /// @dev if underlying is 18 decimals, _conversionPrice = 1e18 corresponds to 1:1 peg + /// @dev if underlying is 6 decimals, _conversionPrice = 1e6 corresponds to 1:1 peg constructor( address _evc, address _synth, address _underlying, - uint256 toUnderlyingFeeBPS, - uint256 toSynthFeeBPS, + uint256 _toUnderlyingFeeBPS, + uint256 _toSynthFeeBPS, uint256 _conversionPrice ) EVCUtil(_evc) { - if (toUnderlyingFeeBPS >= BPS_SCALE || toSynthFeeBPS >= BPS_SCALE) { + if (_synth == address(0) || _underlying == address(0)) { + revert E_ZeroAddress(); + } + + if (_toUnderlyingFeeBPS >= BPS_SCALE || _toSynthFeeBPS >= BPS_SCALE) { revert E_FeeExceedsBPS(); } - if (_evc == address(0) || _synth == address(0) || _underlying == address(0)) { - revert E_ZeroAddress(); + if (_conversionPrice == 0) { + revert E_ZeroConversionPrice(); } synth = ESynth(_synth); underlying = IERC20(_underlying); - TO_UNDERLYING_FEE = toUnderlyingFeeBPS; - TO_SYNTH_FEE = toSynthFeeBPS; - conversionPrice = _conversionPrice; + TO_UNDERLYING_FEE = _toUnderlyingFeeBPS; + TO_SYNTH_FEE = _toSynthFeeBPS; + CONVERSION_PRICE = _conversionPrice; } /// @notice Swaps the given amount of synth to underlying given an input amount of synth. @@ -130,27 +144,33 @@ contract PegStabilityModule is EVCUtil { /// @param amountIn The amount of synth to swap. /// @return The amount of underlying received. function quoteToUnderlyingGivenIn(uint256 amountIn) public view returns (uint256) { - return amountIn * (BPS_SCALE - TO_UNDERLYING_FEE) * conversionPrice / BPS_SCALE / PRICE_SCALE; + return amountIn.mulDiv( + (BPS_SCALE - TO_UNDERLYING_FEE) * CONVERSION_PRICE, BPS_SCALE * PRICE_SCALE, Math.Rounding.Floor + ); } /// @notice Quotes the amount of underlying given an output amount of synth. /// @param amountOut The amount of underlying to receive. /// @return The amount of synth swapped. function quoteToUnderlyingGivenOut(uint256 amountOut) public view returns (uint256) { - return amountOut * BPS_SCALE * PRICE_SCALE / (BPS_SCALE - TO_UNDERLYING_FEE) / conversionPrice; + return amountOut.mulDiv( + BPS_SCALE * PRICE_SCALE, (BPS_SCALE - TO_UNDERLYING_FEE) * CONVERSION_PRICE, Math.Rounding.Ceil + ); } /// @notice Quotes the amount of synth given an input amount of underlying. /// @param amountIn The amount of underlying to swap. /// @return The amount of synth received. function quoteToSynthGivenIn(uint256 amountIn) public view returns (uint256) { - return amountIn * (BPS_SCALE - TO_SYNTH_FEE) * PRICE_SCALE / BPS_SCALE / conversionPrice; + return + amountIn.mulDiv((BPS_SCALE - TO_SYNTH_FEE) * PRICE_SCALE, BPS_SCALE * CONVERSION_PRICE, Math.Rounding.Floor); } /// @notice Quotes the amount of synth given an output amount of underlying. /// @param amountOut The amount of synth to receive. /// @return The amount of underlying swapped. function quoteToSynthGivenOut(uint256 amountOut) public view returns (uint256) { - return amountOut * BPS_SCALE * conversionPrice / (BPS_SCALE - TO_SYNTH_FEE) / PRICE_SCALE; + return + amountOut.mulDiv(BPS_SCALE * CONVERSION_PRICE, (BPS_SCALE - TO_SYNTH_FEE) * PRICE_SCALE, Math.Rounding.Ceil); } } diff --git a/test/invariants/Setup.t.sol b/test/invariants/Setup.t.sol index 69bfed5d..5be5bc9c 100644 --- a/test/invariants/Setup.t.sol +++ b/test/invariants/Setup.t.sol @@ -103,12 +103,14 @@ contract Setup is BaseTest { eTST = EVaultExtended( factory.createProxy(address(0), true, abi.encodePacked(address(assetTST), address(oracle), unitOfAccount)) ); + eTST.setHookConfig(address(0), 0); eTST.setInterestRateModel(address(new IRMTestDefault())); vaults.push(address(eTST)); eTST2 = EVaultExtended( factory.createProxy(address(0), true, abi.encodePacked(address(assetTST2), address(oracle), unitOfAccount)) ); + eTST2.setHookConfig(address(0), 0); eTST2.setInterestRateModel(address(new IRMTestDefault())); vaults.push(address(eTST2)); } diff --git a/test/invariants/handlers/modules/GovernanceModuleHandler.t.sol b/test/invariants/handlers/modules/GovernanceModuleHandler.t.sol index d0921a5e..bf0bf044 100644 --- a/test/invariants/handlers/modules/GovernanceModuleHandler.t.sol +++ b/test/invariants/handlers/modules/GovernanceModuleHandler.t.sol @@ -44,14 +44,6 @@ contract GovernanceModuleHandler is BaseHandler { assert(true); } - function clearLTV(uint256 i) external { - address collateral = _getRandomBaseAsset(i); - - eTST.clearLTV(collateral); - - assert(true); - } - function setInterestFee(uint16 interestFee) external { eTST.setInterestFee(interestFee); diff --git a/test/mocks/MockPriceOracle.sol b/test/mocks/MockPriceOracle.sol index e5c41b2d..757e8aaf 100644 --- a/test/mocks/MockPriceOracle.sol +++ b/test/mocks/MockPriceOracle.sol @@ -53,8 +53,18 @@ contract MockPriceOracle { } function calculateQuote(address base, uint256 amount, uint256 p) internal view returns (uint256) { - (bool success,) = base.staticcall(abi.encodeCall(IERC4626.asset, ())); - if (base.code.length > 0 && success) amount = IEVault(base).convertToAssets(amount); + // While base is a vault (for the purpose of the mock, if it implements asset()), then call + // convertToAssets() to price its shares. This is similar to how EulerRouter implements + // "resolved" vaults. + + while (base.code.length > 0) { + (bool success, bytes memory data) = base.staticcall(abi.encodeCall(IERC4626.asset, ())); + if (!success) break; + + (address asset) = abi.decode(data, (address)); + amount = IEVault(base).convertToAssets(amount); + base = asset; + } return amount * p / 1e18; } diff --git a/test/unit/esr/ESR.Fuzz.t.sol b/test/unit/esr/ESR.Fuzz.t.sol index 713b6e80..e3db0554 100644 --- a/test/unit/esr/ESR.Fuzz.t.sol +++ b/test/unit/esr/ESR.Fuzz.t.sol @@ -14,11 +14,18 @@ contract ESRFuzzTest is ESRTest { //totalAssets should be equal to the balance after SMEAR has passed function invariant_totalAssetsShouldBeEqualToBalanceAfterSMEAR() public { - vm.assume(asset.balanceOf(address(esr)) <= type(uint168).max); - if (asset.balanceOf(address(esr)) == 0) return; + if (asset.totalSupply() > type(uint248).max) return; + if (asset.balanceOf(address(esr)) == 0 || asset.balanceOf(address(esr)) > type(uint168).max - 1e7) return; + + // min deposit requirement before gulp + doDeposit(user, 1e7); + + uint256 balance = asset.balanceOf(address(esr)); + esr.gulp(); skip(esr.INTEREST_SMEAR()); // make sure smear has passed - assertEq(esr.totalAssets(), asset.balanceOf(address(esr))); + + assertEq(esr.totalAssets(), balance); } function testFuzz_interestAccrued_under_uint168(uint256 interestAmount, uint256 depositAmount, uint256 timePassed) @@ -38,6 +45,7 @@ contract ESRFuzzTest is ESRTest { // this tests shows that when you have a very small deposit and a very large interestAmount minted to the contract function testFuzz_gulp_under_uint168(uint256 interestAmount, uint256 depositAmount) public { + uint256 MIN_SHARES_FOR_GULP = 10 * 1e6; depositAmount = bound(depositAmount, 0, type(uint112).max); interestAmount = bound(interestAmount, 0, type(uint256).max - depositAmount); // this makes sure that the mint // won't cause overflow @@ -49,10 +57,50 @@ contract ESRFuzzTest is ESRTest { EulerSavingsRate.ESRSlot memory esrSlot = esr.updateInterestAndReturnESRSlotCache(); - if (interestAmount <= type(uint168).max) { - assertEq(esrSlot.interestLeft, interestAmount); + if (depositAmount >= MIN_SHARES_FOR_GULP) { + if (interestAmount <= type(uint168).max) { + assertEq(esrSlot.interestLeft, interestAmount); + } else { + assertEq(esrSlot.interestLeft, type(uint168).max); + } + } else { + assertEq(esrSlot.interestLeft, 0); + } + } + + function testFuzz_conditionalAccruedInterestUpdate(uint32 interestAmount) public { + // min deposit requirement before gulp + doDeposit(user, 1e7); + + // mint some interest to be distributed + asset.mint(address(esr), interestAmount); + + uint256 balance = asset.balanceOf(address(esr)); + uint256 totalAssets = esr.totalAssets(); + + esr.gulp(); + skip(1); + + if (interestAmount < esr.INTEREST_SMEAR()) { + assertEq(esr.totalAssets(), totalAssets); + assertEq(esr.totalAssets() + interestAmount, balance); + } else { + uint256 accruedInterest = interestAmount / esr.INTEREST_SMEAR(); + assertEq(esr.totalAssets() + interestAmount - accruedInterest, balance); + vm.expectEmit(); + emit EulerSavingsRate.InterestUpdated(accruedInterest, interestAmount - accruedInterest); + } + + vm.recordLogs(); + esr.gulp(); + + Vm.Log[] memory logs = vm.getRecordedLogs(); + if (interestAmount < esr.INTEREST_SMEAR()) { + assertEq(logs.length, 1); + assertNotEq(logs[0].topics[0], EulerSavingsRate.InterestUpdated.selector); } else { - assertEq(esrSlot.interestLeft, type(uint168).max); + assertEq(logs.length, 2); + assertEq(logs[0].topics[0], EulerSavingsRate.InterestUpdated.selector); } } diff --git a/test/unit/esr/ESR.General.t.sol b/test/unit/esr/ESR.General.t.sol index b7f371cd..0bb9bc41 100644 --- a/test/unit/esr/ESR.General.t.sol +++ b/test/unit/esr/ESR.General.t.sol @@ -151,41 +151,4 @@ contract ESRGeneralTest is ESRTest { uint256 balanceOfAddress1 = esr.balanceOf(address(1)); assertEq(balanceOfAddress1, balanceOfUser); } - - // test the result of maxWithdraw when no controller is set - function test_MaxWithdrawNoControllerSet() public { - uint256 depositAmount = 100e18; - doDeposit(user, depositAmount); - uint256 maxWithdraw = esr.maxWithdraw(user); - assertEq(maxWithdraw, depositAmount); - } - - // test the result of maxWithdraw when controller is set - function test_maxWithdrawControllerSet() public { - uint256 depositAmount = 100e18; - doDeposit(user, depositAmount); - vm.prank(user); - evc.enableController(address(user), address(statusCheck)); - uint256 maxWithdraw = esr.maxWithdraw(user); - assertEq(maxWithdraw, 0); - } - - // test the result of maxRedeem when no controller is set - function test_maxRedeemNoControllerSet() public { - uint256 depositAmount = 100e18; - doDeposit(user, depositAmount); - uint256 shares = esr.balanceOf(user); - uint256 maxRedeem = esr.maxRedeem(user); - assertEq(maxRedeem, shares); - } - - // test the result of maxRedeem when controller is set - function test_maxRedeemControllerSet() public { - uint256 depositAmount = 100e18; - doDeposit(user, depositAmount); - vm.prank(user); - evc.enableController(address(user), address(statusCheck)); - uint256 maxRedeem = esr.maxRedeem(user); - assertEq(maxRedeem, 0); - } } diff --git a/test/unit/esr/ESR.Gulp.t.sol b/test/unit/esr/ESR.Gulp.t.sol index 65ee26d5..ba4aae06 100644 --- a/test/unit/esr/ESR.Gulp.t.sol +++ b/test/unit/esr/ESR.Gulp.t.sol @@ -82,4 +82,19 @@ contract ESRGulpTest is ESRTest { assertEq(esrSlot.lastInterestUpdate, block.timestamp); assertEq(esrSlot.interestSmearEnd, block.timestamp + esr.INTEREST_SMEAR()); } + + function testGulpBelowMinSharesForGulp() public { + uint256 depositAmount = 1337; + doDeposit(user, depositAmount); + + uint256 interestAmount = 10e18; + // Mint interest directly into the contract + asset.mint(address(esr), interestAmount); + esr.gulp(); + skip(esr.INTEREST_SMEAR()); + + EulerSavingsRate.ESRSlot memory esrSlot = esr.getESRSlot(); + assertEq(esr.totalAssets(), depositAmount); + assertEq(esrSlot.interestLeft, 0); + } } diff --git a/test/unit/esr/lib/ESRTest.sol b/test/unit/esr/lib/ESRTest.sol index 8ae9c6fa..74dd1a29 100644 --- a/test/unit/esr/lib/ESRTest.sol +++ b/test/unit/esr/lib/ESRTest.sol @@ -20,7 +20,7 @@ contract ESRTest is Test { function setUp() public virtual { asset = new MockToken(); evc = new EVC(); - esr = new EulerSavingsRate(evc, address(asset), NAME, SYMBOL); + esr = new EulerSavingsRate(address(evc), address(asset), NAME, SYMBOL); // Set a non zero timestamp vm.warp(420); diff --git a/test/unit/esvault/ESVaultTestBase.t.sol b/test/unit/esvault/ESVaultTestBase.t.sol index 32b935b2..6afeb762 100644 --- a/test/unit/esvault/ESVaultTestBase.t.sol +++ b/test/unit/esvault/ESVaultTestBase.t.sol @@ -15,9 +15,9 @@ contract ESVaultTestBase is EVaultTestBase { function setUp() public virtual override { super.setUp(); - assetTSTAsSynth = ESynth(address(new ESynth(evc, "Test Synth", "TST"))); + assetTSTAsSynth = ESynth(address(new ESynth(address(evc), "Test Synth", "TST"))); assetTST = TestERC20(address(assetTSTAsSynth)); - assetTST2AsSynth = ESynth(address(new ESynth(evc, "Test Synth 2", "TST2"))); + assetTST2AsSynth = ESynth(address(new ESynth(address(evc), "Test Synth 2", "TST2"))); assetTST2 = TestERC20(address(assetTST2AsSynth)); eTST = createSynthEVault(address(assetTST)); diff --git a/test/unit/esynth/ESynth.totalSupply.t.sol b/test/unit/esynth/ESynth.totalSupply.t.sol index 65c2e61c..ac55e343 100644 --- a/test/unit/esynth/ESynth.totalSupply.t.sol +++ b/test/unit/esynth/ESynth.totalSupply.t.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.13; import {Test} from "forge-std/Test.sol"; -import {ESynth, IEVC, Ownable} from "../../../src/Synths/ESynth.sol"; +import {ESynth, Ownable} from "../../../src/Synths/ESynth.sol"; contract ESynthTotalSupplyTest is Test { ESynth synth; @@ -13,7 +13,7 @@ contract ESynthTotalSupplyTest is Test { function setUp() public { vm.startPrank(owner); - synth = new ESynth(IEVC(makeAddr("evc")), "TestSynth", "TS"); + synth = new ESynth(makeAddr("evc"), "TestSynth", "TS"); synth.setCapacity(owner, 1000000e18); vm.stopPrank(); } @@ -28,8 +28,9 @@ contract ESynthTotalSupplyTest is Test { bool success = synth.addIgnoredForTotalSupply(ignored1); address[] memory ignored = synth.getAllIgnoredForTotalSupply(); - assertEq(ignored.length, 1); - assertEq(ignored[0], ignored1); + assertEq(ignored.length, 2); + assertEq(ignored[0], address(synth)); + assertEq(ignored[1], ignored1); assertTrue(success); } @@ -40,8 +41,9 @@ contract ESynthTotalSupplyTest is Test { vm.stopPrank(); address[] memory ignored = synth.getAllIgnoredForTotalSupply(); - assertEq(ignored.length, 1); - assertEq(ignored[0], ignored1); + assertEq(ignored.length, 2); + assertEq(ignored[0], address(synth)); + assertEq(ignored[1], ignored1); assertFalse(success); } @@ -57,7 +59,8 @@ contract ESynthTotalSupplyTest is Test { vm.stopPrank(); address[] memory ignored = synth.getAllIgnoredForTotalSupply(); - assertEq(ignored.length, 0); + assertEq(ignored[0], address(synth)); + assertEq(ignored.length, 1); assertTrue(success); } @@ -67,29 +70,32 @@ contract ESynthTotalSupplyTest is Test { vm.stopPrank(); address[] memory ignored = synth.getAllIgnoredForTotalSupply(); - assertEq(ignored.length, 0); + assertEq(ignored[0], address(synth)); + assertEq(ignored.length, 1); assertFalse(success); } - function test_totalSupply_nothingIgnored() public { + function test_totalSupply_nothingIgnoredExceptSynth() public { vm.startPrank(owner); - synth.mint(ignored1, 100); - synth.mint(ignored2, 200); - synth.mint(ignored3, 300); + synth.mint(address(synth), 100); + synth.mint(ignored1, 200); + synth.mint(ignored2, 300); + synth.mint(ignored3, 400); vm.stopPrank(); - assertEq(synth.totalSupply(), 600); + assertEq(synth.totalSupply(), 900); } function test_TotalSupplyAddresses_ignored() public { vm.startPrank(owner); - synth.mint(ignored1, 100); - synth.mint(ignored2, 200); - synth.mint(ignored3, 300); + synth.mint(address(synth), 100); + synth.mint(ignored1, 200); + synth.mint(ignored2, 300); + synth.mint(ignored3, 400); synth.addIgnoredForTotalSupply(ignored1); synth.addIgnoredForTotalSupply(ignored2); vm.stopPrank(); - assertEq(synth.totalSupply(), 300); + assertEq(synth.totalSupply(), 400); } } diff --git a/test/unit/esynth/ESynthGeneral.t.sol b/test/unit/esynth/ESynthGeneral.t.sol index 7c81306a..793e52d9 100644 --- a/test/unit/esynth/ESynthGeneral.t.sol +++ b/test/unit/esynth/ESynthGeneral.t.sol @@ -130,4 +130,48 @@ contract ESynthGeneralTest is ESynthTest { vm.expectRevert(ESynth.E_NotEVCCompatible.selector); esynth.allocate(address(wrongEVC), amount); } + + function test_GovernanceModifiers(address owner, uint8 id, address nonOwner, uint128 amount) public { + vm.assume(owner != address(0) && owner != address(evc)); + vm.assume(!evc.haveCommonOwner(owner, nonOwner) && nonOwner != address(evc)); + vm.assume(id != 0); + + vm.prank(owner); + esynth = ESynth(address(new ESynth(address(evc), "Test Synth", "TST"))); + + // succeeds if called directly by an owner + vm.prank(owner); + esynth.setCapacity(address(this), amount); + + // fails if called by a non-owner + vm.prank(nonOwner); + vm.expectRevert(); + esynth.setCapacity(address(this), amount); + + // succeeds if called by an owner through the EVC + vm.prank(owner); + evc.call(address(esynth), owner, 0, abi.encodeCall(ESynth.setCapacity, (address(this), amount))); + + // fails if called by non-owner through the EVC + vm.prank(nonOwner); + vm.expectRevert(); + evc.call(address(esynth), nonOwner, 0, abi.encodeCall(ESynth.setCapacity, (address(this), amount))); + + // fails if called by a sub-account of an owner through the EVC + vm.prank(owner); + vm.expectRevert(); + evc.call( + address(esynth), + address(uint160(owner) ^ id), + 0, + abi.encodeCall(ESynth.setCapacity, (address(this), amount)) + ); + + // fails if called by the owner operator through the EVC + vm.prank(owner); + evc.setAccountOperator(owner, nonOwner, true); + vm.prank(nonOwner); + vm.expectRevert(); + evc.call(address(esynth), owner, 0, abi.encodeCall(ESynth.setCapacity, (address(this), amount))); + } } diff --git a/test/unit/esynth/lib/ESynthTest.sol b/test/unit/esynth/lib/ESynthTest.sol index 37e87135..4c32d8f7 100644 --- a/test/unit/esynth/lib/ESynthTest.sol +++ b/test/unit/esynth/lib/ESynthTest.sol @@ -19,7 +19,7 @@ contract ESynthTest is EVaultTestBase { user1 = vm.addr(1001); user2 = vm.addr(1002); - esynth = ESynth(address(new ESynth(evc, "Test Synth", "TST"))); + esynth = ESynth(address(new ESynth(address(evc), "Test Synth", "TST"))); assetTST = TestERC20(address(esynth)); eTST = createSynthEVault(address(assetTST)); diff --git a/test/unit/evault/EVaultTestBase.t.sol b/test/unit/evault/EVaultTestBase.t.sol index ed537c2f..f413c7c1 100644 --- a/test/unit/evault/EVaultTestBase.t.sol +++ b/test/unit/evault/EVaultTestBase.t.sol @@ -133,6 +133,7 @@ contract EVaultTestBase is AssertionsCustomTypes, Test, DeployPermit2 { eTST = IEVault( factory.createProxy(address(0), true, abi.encodePacked(address(assetTST), address(oracle), unitOfAccount)) ); + eTST.setHookConfig(address(0), 0); eTST.setInterestRateModel(address(new IRMTestDefault())); eTST.setMaxLiquidationDiscount(0.2e4); eTST.setFeeReceiver(feeReceiver); @@ -140,6 +141,7 @@ contract EVaultTestBase is AssertionsCustomTypes, Test, DeployPermit2 { eTST2 = IEVault( factory.createProxy(address(0), true, abi.encodePacked(address(assetTST2), address(oracle), unitOfAccount)) ); + eTST2.setHookConfig(address(0), 0); eTST2.setInterestRateModel(address(new IRMTestDefault())); eTST2.setMaxLiquidationDiscount(0.2e4); eTST2.setFeeReceiver(feeReceiver); @@ -152,6 +154,7 @@ contract EVaultTestBase is AssertionsCustomTypes, Test, DeployPermit2 { IEVault v = IEVault( factory.createProxy(address(0), true, abi.encodePacked(address(asset), address(oracle), unitOfAccount)) ); + v.setHookConfig(address(0), 0); v.setInterestRateModel(address(new IRMTestDefault())); v.setInterestFee(1e4); diff --git a/test/unit/evault/modules/Governance/governorOnly.t.sol b/test/unit/evault/modules/Governance/governorOnly.t.sol index 6f85d49e..553d5264 100644 --- a/test/unit/evault/modules/Governance/governorOnly.t.sol +++ b/test/unit/evault/modules/Governance/governorOnly.t.sol @@ -30,7 +30,6 @@ contract GovernanceTest_GovernorOnly is EVaultTestBase { function test_GovernorAdmin() public { eTST.setFeeReceiver(address(0)); eTST.setLTV(address(0), 0, 0, 0); - eTST.clearLTV(address(0)); eTST.setMaxLiquidationDiscount(0); eTST.setLiquidationCoolOffTime(0); eTST.setInterestRateModel(address(0)); @@ -46,7 +45,6 @@ contract GovernanceTest_GovernorOnly is EVaultTestBase { evc.call(address(eTST), address(this), 0, abi.encodeCall(eTST.setFeeReceiver, address(0))); evc.call(address(eTST), address(this), 0, abi.encodeCall(eTST.setLTV, (address(0), 0, 0, 0))); - evc.call(address(eTST), address(this), 0, abi.encodeCall(eTST.clearLTV, address(0))); evc.call(address(eTST), address(this), 0, abi.encodeCall(eTST.setMaxLiquidationDiscount, 0)); evc.call(address(eTST), address(this), 0, abi.encodeCall(eTST.setLiquidationCoolOffTime, 0)); evc.call(address(eTST), address(this), 0, abi.encodeCall(eTST.setInterestRateModel, address(0))); @@ -68,8 +66,6 @@ contract GovernanceTest_GovernorOnly is EVaultTestBase { vm.expectRevert(Errors.E_Unauthorized.selector); eTST.setLTV(address(0), 0, 0, 0); vm.expectRevert(Errors.E_Unauthorized.selector); - eTST.clearLTV(address(0)); - vm.expectRevert(Errors.E_Unauthorized.selector); eTST.setMaxLiquidationDiscount(0); vm.expectRevert(Errors.E_Unauthorized.selector); eTST.setLiquidationCoolOffTime(0); @@ -96,8 +92,6 @@ contract GovernanceTest_GovernorOnly is EVaultTestBase { vm.expectRevert(Errors.E_Unauthorized.selector); evc.call(address(eTST), subAccount, 0, abi.encodeCall(eTST.setLTV, (address(0), 0, 0, 0))); vm.expectRevert(Errors.E_Unauthorized.selector); - evc.call(address(eTST), subAccount, 0, abi.encodeCall(eTST.clearLTV, address(0))); - vm.expectRevert(Errors.E_Unauthorized.selector); evc.call(address(eTST), subAccount, 0, abi.encodeCall(eTST.setMaxLiquidationDiscount, 0)); vm.expectRevert(Errors.E_Unauthorized.selector); evc.call(address(eTST), subAccount, 0, abi.encodeCall(eTST.setLiquidationCoolOffTime, 0)); diff --git a/test/unit/evault/modules/Governance/hookedOps.t.sol b/test/unit/evault/modules/Governance/hookedOps.t.sol index d0eb481f..489faa0e 100644 --- a/test/unit/evault/modules/Governance/hookedOps.t.sol +++ b/test/unit/evault/modules/Governance/hookedOps.t.sol @@ -69,7 +69,7 @@ contract Governance_HookedOps is EVaultTestBase { } function getHookCalldata(bytes memory data, address sender) internal view returns (bytes memory) { - data = abi.encodePacked(data, eTST.asset(), eTST.oracle(), eTST.unitOfAccount()); + data = abi.encodePacked(data, bytes4(0), eTST.asset(), eTST.oracle(), eTST.unitOfAccount()); if (sender != address(0)) data = abi.encodePacked(data, sender); diff --git a/test/unit/evault/modules/Governance/views.t.sol b/test/unit/evault/modules/Governance/views.t.sol index a6150fbe..3c91798f 100644 --- a/test/unit/evault/modules/Governance/views.t.sol +++ b/test/unit/evault/modules/Governance/views.t.sol @@ -6,12 +6,25 @@ import {EVaultTestBase} from "../../EVaultTestBase.t.sol"; contract Governance_views is EVaultTestBase { function test_protocolFeeShare() public { + assertEq(eTST.feeReceiver(), feeReceiver); assertEq(eTST.protocolFeeShare(), 0.1e4); - startHoax(admin); + vm.prank(admin); protocolConfig.setProtocolFeeShare(0.4e4); - assertEq(eTST.protocolFeeShare(), 0.4e4); + + vm.prank(admin); + protocolConfig.setProtocolFeeShare(0.8e4); + assertEq(eTST.protocolFeeShare(), 0.5e4); + + eTST.setFeeReceiver(address(0)); + assertEq(eTST.feeReceiver(), address(0)); + assertEq(eTST.protocolFeeShare(), 1e4); + + vm.prank(admin); + protocolConfig.setProtocolFeeShare(0.4e4); + assertEq(eTST.feeReceiver(), address(0)); + assertEq(eTST.protocolFeeShare(), 1e4); } function test_protocolFeeReceiver() public { diff --git a/test/unit/evault/modules/Initialize/errors.t.sol b/test/unit/evault/modules/Initialize/errors.t.sol index f1a807ed..18424e8e 100644 --- a/test/unit/evault/modules/Initialize/errors.t.sol +++ b/test/unit/evault/modules/Initialize/errors.t.sol @@ -29,7 +29,7 @@ contract InitializeTests is EVaultTestBase, MetaProxyDeployer { } function test_asset_is_a_contract() public { - bytes memory trailingData = abi.encodePacked(address(0), address(1), address(2)); + bytes memory trailingData = abi.encodePacked(bytes4(0), address(0), address(1), address(2)); address proxy = deployMetaProxy(address(new Initialize(integrations)), trailingData); vm.expectRevert(Errors.E_BadAddress.selector); diff --git a/test/unit/evault/modules/Liquidation/full.t.sol b/test/unit/evault/modules/Liquidation/full.t.sol index 3ec6e028..06843286 100644 --- a/test/unit/evault/modules/Liquidation/full.t.sol +++ b/test/unit/evault/modules/Liquidation/full.t.sol @@ -11,6 +11,7 @@ import {IEVC} from "ethereum-vault-connector/interfaces/IEthereumVaultConnector. import {TestERC20} from "../../../../mocks/TestERC20.sol"; import {IRMTestFixed} from "../../../../mocks/IRMTestFixed.sol"; import {IRMTestZero} from "../../../../mocks/IRMTestZero.sol"; +import {IRMMax} from "../../../../mocks/IRMMax.sol"; import "forge-std/Test.sol"; @@ -50,16 +51,19 @@ contract VaultLiquidation_Test is EVaultTestBase { eWETH = IEVault( factory.createProxy(address(0), true, abi.encodePacked(address(assetWETH), address(oracle), unitOfAccount)) ); + eWETH.setHookConfig(address(0), 0); eWETH.setInterestRateModel(address(new IRMTestZero())); eTST3 = IEVault( factory.createProxy(address(0), true, abi.encodePacked(address(assetTST3), address(oracle), unitOfAccount)) ); + eTST3.setHookConfig(address(0), 0); eTST3.setInterestRateModel(address(new IRMTestZero())); eTST4 = IEVault( factory.createProxy(address(0), true, abi.encodePacked(address(assetTST4), address(oracle), unitOfAccount)) ); + eTST4.setHookConfig(address(0), 0); eTST4.setInterestRateModel(address(new IRMTestZero())); eTST.setLTV(address(eWETH), 0.3e4, 0.3e4, 0); @@ -625,7 +629,7 @@ contract VaultLiquidation_Test is EVaultTestBase { assertEq(eTST2.balanceOf(borrower), 0); } - function test_debtSocialization() public { + function test_debtSocialization_basic() public { // set up liquidator to support the debt startHoax(lender); evc.enableController(lender, address(eTST)); @@ -701,6 +705,130 @@ contract VaultLiquidation_Test is EVaultTestBase { assertEq(eTST.balanceOf(lender), maxYield); } + function test_debtSocialization_minLiabilityValue() public { + // set up liquidator to support the debt + startHoax(lender); + evc.enableController(lender, address(eTST)); + evc.enableCollateral(lender, address(eTST3)); + evc.enableCollateral(lender, address(eTST2)); + + startHoax(address(this)); + eTST.setLTV(address(eTST3), 0.95e4, 0.95e4, 0); + eTST.setLTV(address(eTST2), 0.99e4, 0.99e4, 0); + + startHoax(borrower); + eTST2.redeem(type(uint256).max, borrower, borrower); + eTST2.deposit(2.7e6, borrower); + + evc.enableController(borrower, address(eTST)); + eTST.borrow(0.45e6, borrower); + + startHoax(bystander); + evc.enableController(bystander, address(eTST)); + eTST.borrow(1e6, bystander); + + uint256 collateralValue; + uint256 liabilityValue; + + vm.stopPrank(); + eTST.setInterestRateModel(address(new IRMMax())); + + // withdraw remaining deposit to bump utilization + startHoax(lender); + eTST.redeem(eTST.maxRedeem(lender), lender, lender); + + uint256 snapshot = vm.snapshot(); + + (collateralValue, liabilityValue) = eTST.accountLiquidity(borrower, true); + + // just below min socialization liability value + assertEq(liabilityValue, 0.99e6); + + uint256 prevTotalBorrows = eTST.totalBorrows(); + assertEq(prevTotalBorrows, 1.45e6); + + // collateral price falls + oracle.setPrice(address(assetTST2), unitOfAccount, 0.3e18); + + (uint256 maxRepay, uint256 maxYield) = eTST.checkLiquidation(lender, borrower, address(eTST2)); + + // full collateral is liquidatable, bad debt will remain + assertEq(maxYield, 2.7e6); + + address[] memory collaterals = evc.getCollaterals(borrower); + assertEq(collaterals.length, 1); + + eTST.liquidate(borrower, address(eTST2), maxRepay, 0); + + (collateralValue, liabilityValue) = eTST.accountLiquidity(borrower, true); + assertEq(collateralValue, 0); + // non socialized bad debt remains + assertEq(liabilityValue, 0.3339e6); + + // total borrows unchanged + assertEq(eTST.totalBorrows(), prevTotalBorrows); + + // wait for remaining bad debt to increase above the socialization limit + + skip(50 days); + + (collateralValue, liabilityValue) = eTST.accountLiquidity(borrower, true); + + // value above max + assertGt(liabilityValue, 1e6); + + uint256 borrowerPrevDebt = eTST.debtOf(borrower); + uint256 lenderPrevDebt = eTST.debtOf(lender); + prevTotalBorrows = eTST.totalBorrows(); + + (maxRepay, maxYield) = eTST.checkLiquidation(lender, borrower, address(eTST2)); + // both repay and yield are zero, but socialization can happen + assertEq(maxRepay, 0); + assertEq(maxYield, 0); + + eTST.liquidate(borrower, address(eTST2), type(uint256).max, 0); + + // no new debt for liquidator + assertEq(eTST.debtOf(lender), lenderPrevDebt); + // bad debt is removed and socialized + assertEq(eTST.debtOf(borrower), 0); + assertEq(eTST.totalBorrows(), prevTotalBorrows - borrowerPrevDebt); + + // if debt value was originally above the cap, socialization would happen in the first liquidation + + vm.revertTo(snapshot); + + skip(3 days); + + (collateralValue, liabilityValue) = eTST.accountLiquidity(borrower, true); + + // just above min socialization liability value + assertEq(liabilityValue, 1.067803e6); + + prevTotalBorrows = eTST.totalBorrows(); + borrowerPrevDebt = eTST.debtOf(borrower); + + // collateral price falls + oracle.setPrice(address(assetTST2), unitOfAccount, 0.3e18); + + (maxRepay, maxYield) = eTST.checkLiquidation(lender, borrower, address(eTST2)); + + // full collateral is liquidatable, bad debt will remain + assertEq(maxYield, 2.7e6); + + eTST.liquidate(borrower, address(eTST2), maxRepay, 0); + + (collateralValue, liabilityValue) = eTST.accountLiquidity(borrower, true); + assertEq(collateralValue, 0); + // debt is socialized + assertEq(liabilityValue, 0); + assertEq(eTST.debtOf(borrower), 0); + + // total borrows lowered + assertLt(eTST.totalBorrows(), prevTotalBorrows); + assertApproxEqAbs(eTST.totalBorrows(), prevTotalBorrows - (borrowerPrevDebt - maxRepay), 1); + } + function test_zeroCollateralWorth() public { // set up liquidator to support the debt startHoax(lender); @@ -820,6 +948,57 @@ contract VaultLiquidation_Test is EVaultTestBase { assertEq(eTST.totalBorrows(), 0); } + function test_zeroLiabilityWorth() public { + // set up liquidator to support the debt + startHoax(lender); + evc.enableController(lender, address(eTST)); + evc.enableCollateral(lender, address(eTST3)); + evc.enableCollateral(lender, address(eTST2)); + + startHoax(borrower); + evc.enableCollateral(borrower, address(eTST3)); + evc.enableController(borrower, address(eTST)); + eTST.borrow(5e18, borrower); + + startHoax(address(this)); + eTST.setLTV(address(eTST3), 0.95e4, 0.95e4, 0); + + // liability is worthless now (could be a result of rounding down in real scenario) + oracle.setPrice(address(assetTST), unitOfAccount, 0); + + (uint256 collateralValue, uint256 liabilityValue) = eTST.accountLiquidity(borrower, false); + assertEq(liabilityValue, 0); + assertGt(collateralValue, 0); + + (uint256 maxRepay, uint256 maxYield) = eTST.checkLiquidation(lender, borrower, address(eTST2)); + assertEq(maxRepay, 0); + assertEq(maxYield, 0); + + // now collateral is worthless + oracle.setPrice(address(assetTST2), unitOfAccount, 0); + + (collateralValue, liabilityValue) = eTST.accountLiquidity(borrower, false); + // both values zero now + assertEq(liabilityValue, 0); + assertEq(collateralValue, 0); + + (maxRepay, maxYield) = eTST.checkLiquidation(lender, borrower, address(eTST2)); + assertEq(maxRepay, 0); + assertEq(maxYield, 0); + + uint256 debtBefore = eTST.debtOf(borrower); + uint256 balanceBefore = eTST2.balanceOf(borrower); + + // liquidation is a no-op + startHoax(lender); + vm.expectEmit(); + emit Events.Liquidate(lender, borrower, address(eTST2), 0, 0); + eTST.liquidate(borrower, address(eTST2), type(uint256).max, 0); + + assertEq(eTST.debtOf(borrower), debtBefore); + assertEq(eTST2.balanceOf(borrower), balanceBefore); + } + function test_zeroLTVCollateral() public { // set up liquidator to support the debt startHoax(lender); @@ -917,16 +1096,15 @@ contract VaultLiquidation_Test is EVaultTestBase { eTST.liquidate(borrower, address(eTST2), 0, 0); - //violator - assertEq(eTST.debtOf(borrower), 0); + // violator assertEq(eTST2.balanceOf(borrower), 0); + // debt is not socialized because it's under MIN_SOCIALIZATION_LIABILITY_VALUE + assertEq(eTST.debtOf(borrower), 2); + assertEq(eTST.totalBorrows(), 2); // liquidator: assertEq(eTST.debtOf(lender), 0); assertEq(eTST2.balanceOf(lender), 45); - - // total borrows - assertEq(eTST.totalBorrows(), 0); } // yield value converted to balance rounds down to 0. equivalent to pullDebt @@ -1395,6 +1573,7 @@ contract VaultLiquidation_Test is EVaultTestBase { address(0), true, abi.encodePacked(address(assetTST), address(oracle), address(assetTST)) ) ); + eTSTx.setHookConfig(address(0), 0); eTSTx.setLTV(address(eTST2), 0.95e4, 0.95e4, 0); eTSTx.setMaxLiquidationDiscount(0.2e4); @@ -1655,6 +1834,11 @@ contract VaultLiquidation_Test is EVaultTestBase { vm.expectRevert(Errors.E_ConfigAmountTooLargeToEncode.selector); eTST.setMaxLiquidationDiscount(1e4 + 1); + // bad + startHoax(address(this)); + vm.expectRevert(Errors.E_BadMaxLiquidationDiscount.selector); + eTST.setMaxLiquidationDiscount(1e4); + // set ok eTST.setMaxLiquidationDiscount(0.111e4); assertEq(0.111e4, eTST.maxLiquidationDiscount()); diff --git a/test/unit/evault/modules/Token/actions.t.sol b/test/unit/evault/modules/Token/actions.t.sol index 48e1cfbd..c23e7007 100644 --- a/test/unit/evault/modules/Token/actions.t.sol +++ b/test/unit/evault/modules/Token/actions.t.sol @@ -305,9 +305,10 @@ contract ERC20Test_Actions is EVaultTestBase { } function test_Approve_RevertsWhen_SelfApproval(uint256 allowance) public { - vm.expectRevert(Errors.E_SelfApproval.selector); vm.prank(alice); eTST.approve(alice, allowance); + // no-op + assertEq(eTST.allowance(alice, alice), 0); } function test_Approve_RevertsWhen_EVCOnBehalfOfAccountNotAuthenticated(uint256 allowance) public { @@ -323,8 +324,7 @@ contract ERC20Test_Actions is EVaultTestBase { assertEq(eTST.allowance(alice, alice), 0); startHoax(alice); - // revert on self-approve of eVault - vm.expectRevert(Errors.E_SelfApproval.selector); + // no-op eTST.approve(alice, 10); assertEq(eTST.allowance(alice, alice), 0); @@ -337,8 +337,7 @@ contract ERC20Test_Actions is EVaultTestBase { assertEq(eTST.allowance(alice, alice), 0); startHoax(alice); - // revert on self-approve of eVault - vm.expectRevert(Errors.E_SelfApproval.selector); + // no-op eTST.approve(alice, 0); assertEq(eTST.allowance(alice, alice), 0); @@ -351,8 +350,7 @@ contract ERC20Test_Actions is EVaultTestBase { assertEq(eTST.allowance(alice, alice), 0); startHoax(alice); - // revert on self-approve of eVault - vm.expectRevert(Errors.E_SelfApproval.selector); + // no-op eTST.approve(alice, type(uint256).max); assertEq(eTST.allowance(alice, alice), 0); diff --git a/test/unit/evault/modules/Vault/amountLimits.t.sol b/test/unit/evault/modules/Vault/amountLimits.t.sol index 6a49f00f..cbbddf6a 100644 --- a/test/unit/evault/modules/Vault/amountLimits.t.sol +++ b/test/unit/evault/modules/Vault/amountLimits.t.sol @@ -25,6 +25,7 @@ contract VaultTest_AmountLimits is EVaultTestBase { eTST3 = IEVault( factory.createProxy(address(0), true, abi.encodePacked(address(assetTST3), address(oracle), unitOfAccount)) ); + eTST3.setHookConfig(address(0), 0); assetTST.mint(user1, type(uint256).max / 2); startHoax(user1); diff --git a/test/unit/evault/modules/Vault/balancesNoInterest.t.sol b/test/unit/evault/modules/Vault/balancesNoInterest.t.sol index 0f26b06e..b149d379 100644 --- a/test/unit/evault/modules/Vault/balancesNoInterest.t.sol +++ b/test/unit/evault/modules/Vault/balancesNoInterest.t.sol @@ -63,8 +63,8 @@ contract VaultTest_BalancesNoInterest is EVaultTestBase { vm.expectRevert( abi.encodeWithSelector( SafeERC20Lib.E_TransferFromFailed.selector, - abi.encodeWithSignature("Error(string)", "ERC20: transfer amount exceeds balance"), - abi.encodeWithSelector(IAllowanceTransfer.AllowanceExpired.selector, 0) + abi.encodeWithSelector(IAllowanceTransfer.AllowanceExpired.selector, 0), + abi.encodeWithSignature("Error(string)", "ERC20: transfer amount exceeds balance") ) ); eTST.deposit(1, user1); diff --git a/test/unit/evault/modules/Vault/batch.t.sol b/test/unit/evault/modules/Vault/batch.t.sol index 701075d9..ca766a0b 100644 --- a/test/unit/evault/modules/Vault/batch.t.sol +++ b/test/unit/evault/modules/Vault/batch.t.sol @@ -30,6 +30,7 @@ contract VaultTest_Batch is EVaultTestBase { eTST3 = IEVault( factory.createProxy(address(0), true, abi.encodePacked(address(assetTST3), address(oracle), unitOfAccount)) ); + eTST3.setHookConfig(address(0), 0); startHoax(address(this)); eTST.setInterestRateModel(address(new IRMTestZero())); diff --git a/test/unit/evault/modules/Vault/borrow.t.sol b/test/unit/evault/modules/Vault/borrow.t.sol index 04125b1c..d5093ae9 100644 --- a/test/unit/evault/modules/Vault/borrow.t.sol +++ b/test/unit/evault/modules/Vault/borrow.t.sol @@ -179,8 +179,8 @@ contract VaultTest_Borrow is EVaultTestBase { vm.expectRevert( abi.encodeWithSelector( SafeERC20Lib.E_TransferFromFailed.selector, - abi.encodeWithSignature("Error(string)", "ERC20: transfer amount exceeds allowance"), - abi.encodeWithSelector(IAllowanceTransfer.AllowanceExpired.selector, 0) + abi.encodeWithSelector(IAllowanceTransfer.AllowanceExpired.selector, 0), + abi.encodeWithSignature("Error(string)", "ERC20: transfer amount exceeds allowance") ) ); eTST.repay(type(uint256).max, borrower); @@ -679,6 +679,50 @@ contract VaultTest_Borrow is EVaultTestBase { assertEq(eTST.debtOf(borrower), 0); } + function test_repayLogsTransferDebt() external { + eTST.setInterestRateModel(address(new IRMTestFixed())); + + startHoax(borrower); + + evc.enableController(borrower, address(eTST)); + evc.enableCollateral(borrower, address(eTST2)); + assetTST.approve(address(eTST), type(uint256).max); + assetTST.mint(borrower, 1000e18); + + eTST.borrow(1, borrower); + + assetTST2.transfer(borrower2, type(uint256).max / 2); + + startHoax(borrower2); + + assetTST2.approve(address(eTST2), type(uint256).max); + eTST2.deposit(10e18, borrower2); + + evc.enableController(borrower2, address(eTST)); + evc.enableCollateral(borrower2, address(eTST2)); + assetTST.approve(address(eTST), type(uint256).max); + assetTST.mint(borrower2, 1000e18); + + skip(10 days); + + // a little interest accrued (0.3%) + assertEq(owedTo1e5(eTST.debtOfExact(borrower)), 1.00274e5); + + // record interest in storage + startHoax(borrower); + eTST.borrow(1, borrower); + + // now borrower in LogRepay would receive amount = 2, prevOwed = 3, owed = 0. + // Amount is adjusted to 3 and interest accrued is 0, so no event is emitted + startHoax(borrower2); + vm.recordLogs(); + vm.expectEmit(); + emit Events.Repay(borrower, 3); + eTST.pullDebt(2, borrower); + + assertEq(vm.getRecordedLogs().length, 11); // InterestAccrued would be the 12th event + } + function test_borrowLogsTransferDebt() external { eTST.setInterestRateModel(address(new IRMTestFixed())); diff --git a/test/unit/evault/modules/Vault/borrowIsolation.t.sol b/test/unit/evault/modules/Vault/borrowIsolation.t.sol index 28e9efa9..ca3ef168 100644 --- a/test/unit/evault/modules/Vault/borrowIsolation.t.sol +++ b/test/unit/evault/modules/Vault/borrowIsolation.t.sol @@ -29,6 +29,7 @@ contract VaultTest_BorrowIsolation is EVaultTestBase { eTST3 = IEVault( factory.createProxy(address(0), true, abi.encodePacked(address(assetTST3), address(oracle), unitOfAccount)) ); + eTST3.setHookConfig(address(0), 0); startHoax(address(this)); eTST.setInterestRateModel(address(new IRMTestZero())); diff --git a/test/unit/evault/modules/Vault/caps.t.sol b/test/unit/evault/modules/Vault/caps.t.sol index d24cfecf..6fd16be9 100644 --- a/test/unit/evault/modules/Vault/caps.t.sol +++ b/test/unit/evault/modules/Vault/caps.t.sol @@ -32,6 +32,7 @@ contract VaultTest_Caps is EVaultTestBase { eTST3 = IEVault( factory.createProxy(address(0), true, abi.encodePacked(address(assetTST3), address(oracle), unitOfAccount)) ); + eTST3.setHookConfig(address(0), 0); eTST.setLTV(address(eTST2), 0.3e4, 0.3e4, 0); eTST.setLTV(address(eTST3), 1e4, 1e4, 0); diff --git a/test/unit/evault/modules/Vault/conversion.t.sol b/test/unit/evault/modules/Vault/conversion.t.sol index d7015113..66f369cf 100644 --- a/test/unit/evault/modules/Vault/conversion.t.sol +++ b/test/unit/evault/modules/Vault/conversion.t.sol @@ -46,6 +46,7 @@ contract VaultTest_Conversion is EVaultTestBase { eTST0 = EVaultHarness( factory.createProxy(address(0), true, abi.encodePacked(address(assetTST), address(oracle), unitOfAccount)) ); + eTST0.setHookConfig(address(0), 0); eTST0.setInterestRateModel(address(new IRMTestDefault())); } diff --git a/test/unit/evault/modules/Vault/decimals.t.sol b/test/unit/evault/modules/Vault/decimals.t.sol index d71be668..ad52c0a2 100644 --- a/test/unit/evault/modules/Vault/decimals.t.sol +++ b/test/unit/evault/modules/Vault/decimals.t.sol @@ -33,11 +33,13 @@ contract VaultTest_Decimals is EVaultTestBase { eTST3 = IEVault( factory.createProxy(address(0), true, abi.encodePacked(address(assetTST3), address(oracle), unitOfAccount)) ); + eTST3.setHookConfig(address(0), 0); assetTST4 = new TestERC20("Test TST 4", "TST4", 0, false); eTST4 = IEVault( factory.createProxy(address(0), true, abi.encodePacked(address(assetTST4), address(oracle), unitOfAccount)) ); + eTST4.setHookConfig(address(0), 0); startHoax(address(this)); eTST.setInterestRateModel(address(new IRMTestZero())); diff --git a/test/unit/evault/modules/Vault/deposit.t.sol b/test/unit/evault/modules/Vault/deposit.t.sol index f4cdd67b..ec4291d7 100644 --- a/test/unit/evault/modules/Vault/deposit.t.sol +++ b/test/unit/evault/modules/Vault/deposit.t.sol @@ -243,8 +243,8 @@ contract VaultTest_Deposit is EVaultTestBase { vm.expectRevert( abi.encodeWithSelector( SafeERC20Lib.E_TransferFromFailed.selector, - abi.encodeWithSignature("Error(string)", "ERC20: transfer amount exceeds allowance"), - abi.encodeWithSelector(IAllowanceTransfer.AllowanceExpired.selector, 0) + abi.encodeWithSelector(IAllowanceTransfer.AllowanceExpired.selector, 0), + abi.encodeWithSignature("Error(string)", "ERC20: transfer amount exceeds allowance") ) ); eTST.deposit(amount, user); @@ -275,8 +275,8 @@ contract VaultTest_Deposit is EVaultTestBase { vm.expectRevert( abi.encodeWithSelector( SafeERC20Lib.E_TransferFromFailed.selector, - abi.encodeWithSignature("Error(string)", "ERC20: transfer amount exceeds allowance"), - abi.encodeWithSelector(IAllowanceTransfer.AllowanceExpired.selector, 0) + abi.encodeWithSelector(IAllowanceTransfer.AllowanceExpired.selector, 0), + abi.encodeWithSignature("Error(string)", "ERC20: transfer amount exceeds allowance") ) ); eTST.deposit(amount, user); @@ -339,8 +339,8 @@ contract VaultTest_Deposit is EVaultTestBase { vm.expectRevert( abi.encodeWithSelector( SafeERC20Lib.E_TransferFromFailed.selector, - abi.encodeWithSignature("Error(string)", "ERC20: transfer amount exceeds allowance"), - abi.encodeWithSelector(InsufficientAllowance.selector, amount - 1) + abi.encodeWithSelector(InsufficientAllowance.selector, amount - 1), + abi.encodeWithSignature("Error(string)", "ERC20: transfer amount exceeds allowance") ) ); evc.batch(items); @@ -352,8 +352,8 @@ contract VaultTest_Deposit is EVaultTestBase { vm.expectRevert( abi.encodeWithSelector( SafeERC20Lib.E_TransferFromFailed.selector, - abi.encodeWithSignature("Error(string)", "ERC20: transfer amount exceeds allowance"), - abi.encodeWithSelector(IAllowanceTransfer.AllowanceExpired.selector, 1) + abi.encodeWithSelector(IAllowanceTransfer.AllowanceExpired.selector, 1), + abi.encodeWithSignature("Error(string)", "ERC20: transfer amount exceeds allowance") ) ); eTST.deposit(amount, user); diff --git a/test/unit/evault/modules/Vault/liquidity.t.sol b/test/unit/evault/modules/Vault/liquidity.t.sol index c7347a74..321e2959 100644 --- a/test/unit/evault/modules/Vault/liquidity.t.sol +++ b/test/unit/evault/modules/Vault/liquidity.t.sol @@ -33,6 +33,7 @@ contract VaultTest_Liquidity is EVaultTestBase { eTST3 = IEVault( factory.createProxy(address(0), true, abi.encodePacked(address(assetTST3), address(oracle), unitOfAccount)) ); + eTST3.setHookConfig(address(0), 0); startHoax(address(this)); eTST.setInterestRateModel(address(new IRMTestZero())); diff --git a/test/unit/evault/modules/Vault/ltv.t.sol b/test/unit/evault/modules/Vault/ltv.t.sol index 1e2fdd5e..7e5bccd9 100644 --- a/test/unit/evault/modules/Vault/ltv.t.sol +++ b/test/unit/evault/modules/Vault/ltv.t.sol @@ -109,36 +109,6 @@ contract VaultTest_LTV is EVaultTestBase { eTST.setLTV(address(eTST2), 1e4 + 1, 1e4 + 1, 0); } - function test_clearLtv() public { - eTST.setLTV(address(eTST2), 0.5e4, 0.5e4, 0); - - startHoax(borrower); - evc.enableCollateral(borrower, address(eTST2)); - evc.enableController(borrower, address(eTST)); - vm.stopPrank(); - - // No borrow, liquidation is a no-op - (uint256 maxRepay, uint256 maxYield) = eTST.checkLiquidation(depositor, borrower, address(eTST2)); - assertEq(maxRepay, 0); - assertEq(maxYield, 0); - - // setting LTV to 0 doesn't change anything yet - eTST.setLTV(address(eTST2), 0, 0, 0); - - (maxRepay, maxYield) = eTST.checkLiquidation(depositor, borrower, address(eTST2)); - assertEq(maxRepay, 0); - assertEq(maxYield, 0); - - // collateral without LTV - vm.expectRevert(Errors.E_BadCollateral.selector); - eTST.checkLiquidation(depositor, borrower, address(eTST)); - - // same error after clearing LTV - eTST.clearLTV(address(eTST2)); - vm.expectRevert(Errors.E_BadCollateral.selector); - eTST.checkLiquidation(depositor, borrower, address(eTST2)); - } - function test_ltvList() public { assertEq(eTST.LTVList().length, 0); diff --git a/test/unit/evault/modules/Vault/maliciousToken.t.sol b/test/unit/evault/modules/Vault/maliciousToken.t.sol index d2d96dd7..c1de5f38 100644 --- a/test/unit/evault/modules/Vault/maliciousToken.t.sol +++ b/test/unit/evault/modules/Vault/maliciousToken.t.sol @@ -91,8 +91,8 @@ contract VaultTest_MaliciousToken is EVaultTestBase { vm.expectRevert( abi.encodeWithSelector( SafeERC20Lib.E_TransferFromFailed.selector, - abi.encodeWithSignature("Error(string)", "revert behaviour"), - abi.encodeWithSelector(IAllowanceTransfer.AllowanceExpired.selector, 0) + abi.encodeWithSelector(IAllowanceTransfer.AllowanceExpired.selector, 0), + abi.encodeWithSignature("Error(string)", "revert behaviour") ) ); eTST2.repay(1e18, user3); @@ -103,8 +103,8 @@ contract VaultTest_MaliciousToken is EVaultTestBase { vm.expectRevert( abi.encodeWithSelector( SafeERC20Lib.E_TransferFromFailed.selector, - abi.encodeWithSignature("Error(string)", "revert behaviour"), - abi.encodeWithSelector(IAllowanceTransfer.AllowanceExpired.selector, 0) + abi.encodeWithSelector(IAllowanceTransfer.AllowanceExpired.selector, 0), + abi.encodeWithSignature("Error(string)", "revert behaviour") ) ); startHoax(user1); @@ -120,8 +120,8 @@ contract VaultTest_MaliciousToken is EVaultTestBase { vm.expectRevert( abi.encodeWithSelector( SafeERC20Lib.E_TransferFromFailed.selector, - abi.encodeWithSignature("E_Reentrancy()"), - abi.encodeWithSelector(IAllowanceTransfer.AllowanceExpired.selector, 0) + abi.encodeWithSelector(IAllowanceTransfer.AllowanceExpired.selector, 0), + abi.encodeWithSignature("E_Reentrancy()") ) ); eTST.deposit(1e18, user1); @@ -135,8 +135,8 @@ contract VaultTest_MaliciousToken is EVaultTestBase { vm.expectRevert( abi.encodeWithSelector( SafeERC20Lib.E_TransferFromFailed.selector, - abi.encodeWithSignature("E_Reentrancy()"), - abi.encodeWithSelector(IAllowanceTransfer.AllowanceExpired.selector, 0) + abi.encodeWithSelector(IAllowanceTransfer.AllowanceExpired.selector, 0), + abi.encodeWithSignature("E_Reentrancy()") ) ); eTST.deposit(1e18, user1); diff --git a/test/unit/evault/modules/Vault/nested.t.sol b/test/unit/evault/modules/Vault/nested.t.sol index a9405e08..c648fc72 100644 --- a/test/unit/evault/modules/Vault/nested.t.sol +++ b/test/unit/evault/modules/Vault/nested.t.sol @@ -29,6 +29,7 @@ contract VaultTest_Nested is EVaultTestBase { eTSTNested = IEVault( factory.createProxy(address(0), true, abi.encodePacked(address(eTST), address(oracle), unitOfAccount)) ); + eTSTNested.setHookConfig(address(0), 0); eTSTNested.setInterestRateModel(address(new IRMTestDefault())); depositor = makeAddr("depositor"); @@ -143,6 +144,7 @@ contract VaultTest_Nested is EVaultTestBase { eTSTDoubleNested = IEVault( factory.createProxy(address(0), true, abi.encodePacked(address(eTSTNested), address(oracle), unitOfAccount)) ); + eTSTDoubleNested.setHookConfig(address(0), 0); eTSTDoubleNested.setInterestRateModel(address(new IRMTestDefault())); eTSTDoubleNested.setLTV(address(eTST2), 0.9e4, 0.9e4, 0); diff --git a/test/unit/evault/modules/Vault/pullDebt.t.sol b/test/unit/evault/modules/Vault/pullDebt.t.sol index 4c3ae1a0..b5e27fa7 100644 --- a/test/unit/evault/modules/Vault/pullDebt.t.sol +++ b/test/unit/evault/modules/Vault/pullDebt.t.sol @@ -33,9 +33,11 @@ contract VaultTest_PullDebt is EVaultTestBase { eTST3 = IEVault( factory.createProxy(address(0), true, abi.encodePacked(address(assetTST3), address(oracle), unitOfAccount)) ); + eTST3.setHookConfig(address(0), 0); eTST4 = IEVault( factory.createProxy(address(0), true, abi.encodePacked(address(assetTST4), address(oracle), unitOfAccount)) ); + eTST4.setHookConfig(address(0), 0); eTST.setInterestRateModel(address(new IRMTestZero())); eTST4.setInterestRateModel(address(new IRMTestZero())); diff --git a/test/unit/evault/modules/Vault/repayWithShares.sol b/test/unit/evault/modules/Vault/repayWithShares.sol index 753a604e..17e7330b 100644 --- a/test/unit/evault/modules/Vault/repayWithShares.sol +++ b/test/unit/evault/modules/Vault/repayWithShares.sol @@ -30,6 +30,7 @@ contract VaultTest_RepayWithShares is EVaultTestBase { eTST3 = IEVault( factory.createProxy(address(0), true, abi.encodePacked(address(assetTST3), address(oracle), unitOfAccount)) ); + eTST3.setHookConfig(address(0), 0); startHoax(address(this)); eTST.setInterestRateModel(address(new IRMTestZero())); diff --git a/test/unit/evault/modules/Vault/selfCollateral.t.sol b/test/unit/evault/modules/Vault/selfCollateral.t.sol new file mode 100644 index 00000000..ebb777d0 --- /dev/null +++ b/test/unit/evault/modules/Vault/selfCollateral.t.sol @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: GPL-2.0-or-later + +pragma solidity ^0.8.0; + +import {EVaultTestBase, IEVault, IRMTestDefault} from "../../EVaultTestBase.t.sol"; + +import "../../../../../src/EVault/shared/types/Types.sol"; +import "../../../../../src/EVault/shared/Constants.sol"; + +contract VaultTest_SelfCollateral is EVaultTestBase { + using TypesLib for uint256; + + IEVault public eeTST; + + function setUp() public override { + super.setUp(); + + eeTST = IEVault( + factory.createProxy(address(0), true, abi.encodePacked(address(eTST), address(oracle), unitOfAccount)) + ); + eeTST.setInterestRateModel(address(new IRMTestDefault())); + + oracle.setPrice(address(assetTST), unitOfAccount, 1e18); + oracle.setPrice(address(eTST), unitOfAccount, 1e18); + oracle.setPrice(address(eeTST), unitOfAccount, 1e18); + } + + function test_selfCollateralDisallowed() public { + vm.expectRevert(Errors.E_InvalidLTVAsset.selector); + eTST.setLTV(address(eTST), 0.9e4, 0.9e4, 0); + + vm.expectRevert(Errors.E_Reentrancy.selector); + eTST.setLTV(address(eeTST), 0.9e4, 0.9e4, 0); + } +} diff --git a/test/unit/evault/modules/Vault/withdraw.t.sol b/test/unit/evault/modules/Vault/withdraw.t.sol index 0cef846c..dc861fba 100644 --- a/test/unit/evault/modules/Vault/withdraw.t.sol +++ b/test/unit/evault/modules/Vault/withdraw.t.sol @@ -37,6 +37,7 @@ contract VaultTest_Withdraw is EVaultTestBase { eTST3 = IEVault( factory.createProxy(address(0), true, abi.encodePacked(address(assetTST3), address(oracle), unitOfAccount)) ); + eTST3.setHookConfig(address(0), 0); eTST.setInterestRateModel(address(new IRMTestZero())); eTST2.setInterestRateModel(address(new IRMTestZero())); diff --git a/test/unit/evault/shared/EVCClient.t.sol b/test/unit/evault/shared/EVCClient.t.sol index e6af00c7..1968bfbd 100644 --- a/test/unit/evault/shared/EVCClient.t.sol +++ b/test/unit/evault/shared/EVCClient.t.sol @@ -56,6 +56,7 @@ contract EVCClientUnitTest is EVaultTestBase { eTST3 = IEVault( factory.createProxy(address(0), true, abi.encodePacked(address(assetTST3), address(oracle), unitOfAccount)) ); + eTST3.setHookConfig(address(0), 0); eTST3.setInterestRateModel(address(new IRMTestDefault())); } @@ -171,6 +172,7 @@ contract EVCClientUnitTest is EVaultTestBase { IEVault v = IEVault( factory.createProxy(address(0), true, abi.encodePacked(address(assetTST2), address(oracle), unitOfAccount)) ); + v.setHookConfig(address(0), 0); v.setInterestRateModel(address(new IRMTestDefault())); return address(v); diff --git a/test/unit/evault/shared/Reentrancy.t.sol b/test/unit/evault/shared/Reentrancy.t.sol index c6202332..8a8dbf5a 100644 --- a/test/unit/evault/shared/Reentrancy.t.sol +++ b/test/unit/evault/shared/Reentrancy.t.sol @@ -170,9 +170,6 @@ contract MockHookTarget is Test, IHookTarget { uint32(bound(amount2, 0, type(uint32).max)) ); - vm.expectRevert(Errors.E_Reentrancy.selector); - eTST.clearLTV(account1); - vm.expectRevert(Errors.E_Reentrancy.selector); eTST.setInterestRateModel(account1); @@ -212,6 +209,7 @@ contract ReentrancyTest is EVaultTestBase { eTST = IEVault( factory.createProxy(address(0), true, abi.encodePacked(address(assetTST), address(oracle), unitOfAccount)) ); + eTST.setHookConfig(address(0), 0); vm.assume(sender != address(0) && sender != address(eTST)); @@ -406,9 +404,6 @@ contract ReentrancyTest is EVaultTestBase { uint32(bound(amount2, 0, type(uint32).max)) ); - vm.expectRevert(Errors.E_Reentrancy.selector); - eTST.clearLTV(account1); - vm.expectRevert(Errors.E_Reentrancy.selector); eTST.setInterestRateModel(account1); diff --git a/test/unit/factory/GenericFactory.t.sol b/test/unit/factory/GenericFactory.t.sol index 21d5a23d..aeb80a07 100644 --- a/test/unit/factory/GenericFactory.t.sol +++ b/test/unit/factory/GenericFactory.t.sol @@ -42,12 +42,26 @@ contract FactoryTest is Test { function test_setImplementationSimple() public { vm.prank(upgradeAdmin); - factory.setImplementation(address(1)); - assertEq(factory.implementation(), address(1)); + vm.expectRevert(GenericFactory.E_BadAddress.selector); + factory.setImplementation(address(0)); + + vm.prank(upgradeAdmin); + vm.expectRevert(GenericFactory.E_BadAddress.selector); + factory.setImplementation(address(1e6)); + + vm.etch(address(1e6), address(this).code); + vm.prank(upgradeAdmin); + factory.setImplementation(address(1e6)); + assertEq(factory.implementation(), address(1e6)); + + vm.prank(upgradeAdmin); + vm.expectRevert(GenericFactory.E_BadAddress.selector); + factory.setImplementation(address(2e6)); + vm.etch(address(2e6), address(this).code); vm.prank(upgradeAdmin); - factory.setImplementation(address(2)); - assertEq(factory.implementation(), address(2)); + factory.setImplementation(address(2e6)); + assertEq(factory.implementation(), address(2e6)); } function test_activateVaultDefaultImplementation() public { @@ -231,10 +245,10 @@ contract FactoryTest is Test { function test_Event_SetEVaultImplementation() public { vm.expectEmit(true, false, false, false); - emit GenericFactory.SetImplementation(address(1)); + emit GenericFactory.SetImplementation(address(this)); vm.prank(upgradeAdmin); - factory.setImplementation(address(1)); + factory.setImplementation(address(this)); } function test_Event_SetUpgradeAdmin() public { @@ -318,6 +332,7 @@ contract FactoryTest is Test { // Create and install mock eVault impl MockEVault mockEvaultImpl = new MockEVault(address(factory), address(1)); + MockEVault mockEvaultImplOther = new MockEVault(address(factory), address(2)); vm.prank(upgradeAdmin); factory.setImplementation(address(mockEvaultImpl)); @@ -335,7 +350,7 @@ contract FactoryTest is Test { // Change eVault impl vm.prank(upgradeAdmin); - factory.setImplementation(address(1)); + factory.setImplementation(address(mockEvaultImplOther)); config = factory.getProxyConfig(address(eVaultNonUpg)); assertNotEq(config.implementation, factory.implementation()); diff --git a/test/unit/pegStabilityModules/PSM.t.sol b/test/unit/pegStabilityModules/PSM.t.sol index 6bf3fb4d..aea6ab8d 100644 --- a/test/unit/pegStabilityModules/PSM.t.sol +++ b/test/unit/pegStabilityModules/PSM.t.sol @@ -5,7 +5,7 @@ pragma solidity ^0.8.0; import "forge-std/Test.sol"; import {PegStabilityModule, EVCUtil} from "../../../src/Synths/PegStabilityModule.sol"; -import {ESynth, IEVC} from "../../../src/Synths/ESynth.sol"; +import {ESynth} from "../../../src/Synths/ESynth.sol"; import {TestERC20} from "../../mocks/TestERC20.sol"; import {EthereumVaultConnector} from "ethereum-vault-connector/EthereumVaultConnector.sol"; import {EVCUtil} from "ethereum-vault-connector/utils/EVCUtil.sol"; @@ -22,7 +22,7 @@ contract PSMTest is Test { PegStabilityModule public psm; - IEVC public evc; + EthereumVaultConnector public evc; address public owner = makeAddr("owner"); address public wallet1 = makeAddr("wallet1"); @@ -32,52 +32,73 @@ contract PSMTest is Test { // Deploy EVC evc = new EthereumVaultConnector(); - // Deploy underlying - underlying = new TestERC20("TestUnderlying", "TUNDERLYING", 18, false); - // Deploy synth vm.prank(owner); - synth = new ESynth(evc, "TestSynth", "TSYNTH"); + synth = new ESynth(address(evc), "TestSynth", "TSYNTH"); + + // Deploy underlying + underlying = new TestERC20("TestUnderlying", "TUNDERLYING", 18, false); // Deploy PSM vm.prank(owner); psm = new PegStabilityModule( address(evc), address(synth), address(underlying), TO_UNDERLYING_FEE, TO_SYNTH_FEE, CONVERSION_PRICE ); + } + + function fuzzSetUp( + uint8 underlyingDecimals, + uint256 _toUnderlyingFeeBPS, + uint256 _toSynthFeeBPS, + uint256 _conversionPrice + ) internal { + // Redeploy underlying + underlying = new TestERC20("TestUnderlying", "TUNDERLYING", underlyingDecimals, false); + + // Redeploy PSM + vm.prank(owner); + psm = new PegStabilityModule( + address(evc), address(synth), address(underlying), _toUnderlyingFeeBPS, _toSynthFeeBPS, _conversionPrice + ); // Give PSM and wallets some underlying - underlying.mint(address(psm), 100e18); - underlying.mint(wallet1, 100e18); - underlying.mint(wallet2, 100e18); + uint128 amount = uint128(1e6 * 10 ** underlyingDecimals); + underlying.mint(address(psm), amount); + underlying.mint(wallet1, amount); + underlying.mint(wallet2, amount); // Approve PSM to spend underlying vm.prank(wallet1); - underlying.approve(address(psm), 100e18); + underlying.approve(address(psm), type(uint256).max); vm.prank(wallet2); - underlying.approve(address(psm), 100e18); + underlying.approve(address(psm), type(uint256).max); // Set PSM as minter + amount = 1e6 * 10 ** 18; vm.prank(owner); - synth.setCapacity(address(psm), 100e18); + synth.setCapacity(address(psm), amount); // Mint some synth to wallets vm.startPrank(owner); - synth.setCapacity(owner, 200e18); - synth.mint(wallet1, 100e18); - synth.mint(wallet2, 100e18); + synth.setCapacity(owner, uint128(2 * amount)); + synth.mint(wallet1, amount); + synth.mint(wallet2, amount); vm.stopPrank(); // Set approvals for PSM vm.prank(wallet1); - synth.approve(address(psm), 100e18); + synth.approve(address(psm), type(uint256).max); vm.prank(wallet2); - synth.approve(address(psm), 100e18); + synth.approve(address(psm), type(uint256).max); } function testConstructor() public view { + assertEq(address(psm.EVC()), address(evc)); assertEq(address(psm.synth()), address(synth)); + assertEq(address(psm.underlying()), address(underlying)); assertEq(psm.TO_UNDERLYING_FEE(), TO_UNDERLYING_FEE); assertEq(psm.TO_SYNTH_FEE(), TO_SYNTH_FEE); + assertEq(psm.CONVERSION_PRICE(), CONVERSION_PRICE); } function testConstructorToUnderlyingFeeExceedsBPS() public { @@ -115,9 +136,21 @@ contract PSMTest is Test { ); } - function testSwapToUnderlyingGivenIn() public { - uint256 amountIn = 10e18; - uint256 expectedAmountOut = amountIn * (BPS_SCALE - TO_UNDERLYING_FEE) / BPS_SCALE; + function testConstructorZeroConversionPrice() public { + vm.expectRevert(PegStabilityModule.E_ZeroConversionPrice.selector); + new PegStabilityModule(address(evc), address(synth), address(underlying), TO_UNDERLYING_FEE, TO_SYNTH_FEE, 0); + } + + function testSwapToUnderlyingGivenIn(uint8 underlyingDecimals, uint256 fee, uint256 amountInNoDecimals) public { + underlyingDecimals = uint8(bound(underlyingDecimals, 6, 18)); + fee = bound(fee, 0, BPS_SCALE - 1); + amountInNoDecimals = bound(amountInNoDecimals, 1, 100); + fuzzSetUp(underlyingDecimals, fee, 0, 10 ** underlyingDecimals); + + uint256 amountIn = amountInNoDecimals * 10 ** 18; + uint256 expectedAmountOut = amountInNoDecimals * 10 ** underlyingDecimals * (BPS_SCALE - fee) / BPS_SCALE; + + assertEq(psm.quoteToUnderlyingGivenIn(amountIn), expectedAmountOut); uint256 swapperSynthBalanceBefore = synth.balanceOf(wallet1); uint256 receiverBalanceBefore = underlying.balanceOf(wallet2); @@ -135,9 +168,17 @@ contract PSMTest is Test { assertEq(psmUnderlyingBalanceAfter, psmUnderlyingBalanceBefore - expectedAmountOut); } - function testSwapToUnderlyingGivenOut() public { - uint256 amountOut = 10e18; - uint256 expectedAmountIn = amountOut * BPS_SCALE / (BPS_SCALE - TO_UNDERLYING_FEE); + function testSwapToUnderlyingGivenOut(uint8 underlyingDecimals, uint256 fee, uint256 amountOutNoDecimals) public { + underlyingDecimals = uint8(bound(underlyingDecimals, 6, 18)); + fee = bound(fee, 0, BPS_SCALE - 1); + amountOutNoDecimals = bound(amountOutNoDecimals, 1, 100); + fuzzSetUp(underlyingDecimals, fee, 0, 10 ** underlyingDecimals); + + uint256 amountOut = amountOutNoDecimals * 10 ** underlyingDecimals; + uint256 expectedAmountIn = + (amountOutNoDecimals * 10 ** 18 * BPS_SCALE + BPS_SCALE - fee - 1) / (BPS_SCALE - fee); + + assertEq(psm.quoteToUnderlyingGivenOut(amountOut), expectedAmountIn); uint256 swapperSynthBalanceBefore = synth.balanceOf(wallet1); uint256 receiverBalanceBefore = underlying.balanceOf(wallet2); @@ -155,9 +196,16 @@ contract PSMTest is Test { assertEq(psmUnderlyingBalanceAfter, psmUnderlyingBalanceBefore - amountOut); } - function testSwapToSynthGivenIn() public { - uint256 amountIn = 10e18; - uint256 expectedAmountOut = amountIn * (BPS_SCALE - TO_SYNTH_FEE) / BPS_SCALE; + function testSwapToSynthGivenIn(uint8 underlyingDecimals, uint256 fee, uint256 amountInNoDecimals) public { + underlyingDecimals = uint8(bound(underlyingDecimals, 6, 18)); + fee = bound(fee, 0, BPS_SCALE - 1); + amountInNoDecimals = bound(amountInNoDecimals, 1, 100); + fuzzSetUp(underlyingDecimals, 0, fee, 10 ** underlyingDecimals); + + uint256 amountIn = amountInNoDecimals * 10 ** underlyingDecimals; + uint256 expectedAmountOut = amountInNoDecimals * 10 ** 18 * (BPS_SCALE - fee) / BPS_SCALE; + + assertEq(psm.quoteToSynthGivenIn(amountIn), expectedAmountOut); uint256 swapperUnderlyingBalanceBefore = underlying.balanceOf(wallet1); uint256 receiverSynthBalanceBefore = synth.balanceOf(wallet2); @@ -175,9 +223,17 @@ contract PSMTest is Test { assertEq(psmUnderlyingBalanceAfter, psmUnderlyingBalanceBefore + amountIn); } - function testSwapToSynthGivenOut() public { - uint256 amountOut = 10e18; - uint256 expectedAmountIn = amountOut * BPS_SCALE / (BPS_SCALE - TO_SYNTH_FEE); + function testSwapToSynthGivenOut(uint8 underlyingDecimals, uint256 fee, uint256 amountOutNoDecimals) public { + underlyingDecimals = uint8(bound(underlyingDecimals, 6, 18)); + fee = bound(fee, 0, BPS_SCALE - 1); + amountOutNoDecimals = bound(amountOutNoDecimals, 1, 100); + fuzzSetUp(underlyingDecimals, 0, fee, 10 ** underlyingDecimals); + + uint256 amountOut = amountOutNoDecimals * 10 ** 18; + uint256 expectedAmountIn = + (amountOutNoDecimals * 10 ** underlyingDecimals * BPS_SCALE + BPS_SCALE - fee - 1) / (BPS_SCALE - fee); + + assertEq(psm.quoteToSynthGivenOut(amountOut), expectedAmountIn); uint256 swapperUnderlyingBalanceBefore = underlying.balanceOf(wallet1); uint256 receiverSynthBalanceBefore = synth.balanceOf(wallet2); @@ -195,66 +251,35 @@ contract PSMTest is Test { assertEq(psmUnderlyingBalanceAfter, psmUnderlyingBalanceBefore + expectedAmountIn); } - // Test quotes - function testQuoteToUnderlyingGivenIn() public view { - uint256 amountIn = 10e18; - uint256 expectedAmountOut = amountIn * (BPS_SCALE - TO_UNDERLYING_FEE) / BPS_SCALE; - - uint256 amountOut = psm.quoteToUnderlyingGivenIn(amountIn); - - assertEq(amountOut, expectedAmountOut); - } - - function testQuoteToUnderlyingGivenOut() public view { - uint256 amountOut = 10e18; - uint256 expectedAmountIn = amountOut * BPS_SCALE / (BPS_SCALE - TO_UNDERLYING_FEE); + function testSanityPriceConversions(uint8 underlyingDecimals, uint256 amount, uint256 multiplier) public { + underlyingDecimals = uint8(bound(underlyingDecimals, 6, 18)); + amount = bound(amount, 1, 100); + multiplier = bound(multiplier, 1, 10000); + fuzzSetUp(underlyingDecimals, 0, 0, 10 ** underlyingDecimals * multiplier / 100); - uint256 amountIn = psm.quoteToUnderlyingGivenOut(amountOut); + uint256 synthAmount = amount * 10 ** 18; + uint256 underlyingAmount = amount * 10 ** underlyingDecimals * multiplier / 100; - assertEq(amountIn, expectedAmountIn); + assertEq(psm.quoteToSynthGivenIn(underlyingAmount), synthAmount); + assertEq(psm.quoteToSynthGivenOut(synthAmount), underlyingAmount); + assertEq(psm.quoteToUnderlyingGivenIn(synthAmount), underlyingAmount); + assertEq(psm.quoteToUnderlyingGivenOut(underlyingAmount), synthAmount); } - function testQuoteToSynthGivenIn() public view { - uint256 amountIn = 10e18; - uint256 expectedAmountOut = amountIn * (BPS_SCALE - TO_SYNTH_FEE) / BPS_SCALE; - - uint256 amountOut = psm.quoteToSynthGivenIn(amountIn); - - assertEq(amountOut, expectedAmountOut); + function testRoundingPriceConversionsEqualDecimals() public view { + assertEq(psm.quoteToSynthGivenIn(1), 0); + assertEq(psm.quoteToSynthGivenOut(1), 2); + assertEq(psm.quoteToUnderlyingGivenIn(1), 0); + assertEq(psm.quoteToUnderlyingGivenOut(1), 2); } - function testQuoteToSynthGivenOut() public view { - uint256 amountOut = 10e18; - uint256 expectedAmountIn = amountOut * BPS_SCALE / (BPS_SCALE - TO_SYNTH_FEE); - - uint256 amountIn = psm.quoteToSynthGivenOut(amountOut); - - assertEq(amountIn, expectedAmountIn); - } - - function testSanityPriceConversionToSynth() public { - uint256 price = 0.25e18; - - uint256 synthAmount = 1e18; - uint256 underlyingAmount = 0.25e18; - - PegStabilityModule psmNoFee = - new PegStabilityModule(address(evc), address(synth), address(underlying), 0, 0, price); - - assertEq(psmNoFee.quoteToSynthGivenIn(underlyingAmount), synthAmount); - assertEq(psmNoFee.quoteToSynthGivenOut(synthAmount), underlyingAmount); - } - - function testSanityPriceConversionToUnderlying() public { - uint256 price = 0.25e18; - - uint256 synthAmount = 1e18; - uint256 underlyingAmount = 0.25e18; - - PegStabilityModule psmNoFee = - new PegStabilityModule(address(evc), address(synth), address(underlying), 0, 0, price); + function testRoundingPriceConversionsDiffDecimals(uint8 underlyingDecimals) public { + underlyingDecimals = uint8(bound(underlyingDecimals, 6, 17)); + fuzzSetUp(underlyingDecimals, 0, 0, 10 ** underlyingDecimals); - assertEq(psmNoFee.quoteToUnderlyingGivenIn(synthAmount), underlyingAmount); - assertEq(psmNoFee.quoteToUnderlyingGivenOut(underlyingAmount), synthAmount); + assertEq(psm.quoteToSynthGivenIn(1), 10 ** (18 - underlyingDecimals)); + assertEq(psm.quoteToSynthGivenOut(1), 1); + assertEq(psm.quoteToUnderlyingGivenIn(1), 0); + assertEq(psm.quoteToUnderlyingGivenOut(1), 10 ** (18 - underlyingDecimals)); } }