diff --git a/.gitmodules b/.gitmodules index 888d42dcd..665e0dd74 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "lib/forge-std"] path = lib/forge-std url = https://github.com/foundry-rs/forge-std +[submodule "lib/erc4626-tests"] + path = lib/erc4626-tests + url = https://github.com/a16z/erc4626-tests diff --git a/foundry.lock b/foundry.lock index 313f592b1..a0f8c7ad4 100644 --- a/foundry.lock +++ b/foundry.lock @@ -1,4 +1,10 @@ { + "lib/erc4626-tests": { + "tag": { + "name": "v0.1.1", + "rev": "232ff9ba8194e406967f52ecc5cb52ed764209e9" + } + }, "lib/forge-std": { "rev": "60acb7aaadcce2d68e52986a0a66fe79f07d138f" } diff --git a/lib/erc4626-tests b/lib/erc4626-tests new file mode 160000 index 000000000..232ff9ba8 --- /dev/null +++ b/lib/erc4626-tests @@ -0,0 +1 @@ +Subproject commit 232ff9ba8194e406967f52ecc5cb52ed764209e9 diff --git a/remappings.txt b/remappings.txt new file mode 100644 index 000000000..a74b4d07a --- /dev/null +++ b/remappings.txt @@ -0,0 +1,2 @@ +erc4626-tests/=lib/erc4626-tests/ +forge-std/=lib/forge-std/src/ diff --git a/snapshots/VaultSpoke.Operations.json b/snapshots/VaultSpoke.Operations.json new file mode 100644 index 000000000..d3f65801d --- /dev/null +++ b/snapshots/VaultSpoke.Operations.json @@ -0,0 +1,17 @@ +{ + "deposit": "117356", + "depositWithSig": "151404", + "mint": "117438", + "mintWithSig": "151540", + "permit": "63205", + "redeem: on behalf, full": "98149", + "redeem: on behalf, partial": "122149", + "redeem: self, full": "97291", + "redeem: self, partial": "116491", + "redeemWithSig": "149844", + "withdraw: on behalf, full": "98259", + "withdraw: on behalf, partial": "122259", + "withdraw: self, full": "97401", + "withdraw: self, partial": "116601", + "withdrawWithSig": "149898" +} \ No newline at end of file diff --git a/src/dependencies/openzeppelin-upgradeable/ERC20Upgradeable.sol b/src/dependencies/openzeppelin-upgradeable/ERC20Upgradeable.sol new file mode 100644 index 000000000..0b4e6ec50 --- /dev/null +++ b/src/dependencies/openzeppelin-upgradeable/ERC20Upgradeable.sol @@ -0,0 +1,342 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v5.4.0) (token/ERC20/ERC20.sol) + +pragma solidity ^0.8.20; + +import {IERC20} from '../openzeppelin/IERC20.sol'; +import {IERC20Metadata} from '../openzeppelin/IERC20Metadata.sol'; +import {ContextUpgradeable} from './ContextUpgradeable.sol'; +import {IERC20Errors} from '../openzeppelin/IERC20Errors.sol'; +import {Initializable} from './Initializable.sol'; + +/** + * @dev Implementation of the {IERC20} interface. + * + * This implementation is agnostic to the way tokens are created. This means + * that a supply mechanism has to be added in a derived contract using {_mint}. + * + * TIP: For a detailed writeup see our guide + * https://forum.openzeppelin.com/t/how-to-implement-erc20-supply-mechanisms/226[How + * to implement supply mechanisms]. + * + * The default value of {decimals} is 18. To change this, you should override + * this function so it returns a different value. + * + * We have followed general OpenZeppelin Contracts guidelines: functions revert + * instead returning `false` on failure. This behavior is nonetheless + * conventional and does not conflict with the expectations of ERC-20 + * applications. + */ +abstract contract ERC20Upgradeable is + Initializable, + ContextUpgradeable, + IERC20, + IERC20Metadata, + IERC20Errors +{ + /// @custom:storage-location erc7201:openzeppelin.storage.ERC20 + struct ERC20Storage { + mapping(address account => uint256) _balances; + mapping(address account => mapping(address spender => uint256)) _allowances; + uint256 _totalSupply; + string _name; + string _symbol; + } + + // keccak256(abi.encode(uint256(keccak256("openzeppelin.storage.ERC20")) - 1)) & ~bytes32(uint256(0xff)) + bytes32 private constant ERC20StorageLocation = + 0x52c63247e1f47db19d5ce0460030c497f067ca4cebf71ba98eeadabe20bace00; + + function _getERC20Storage() private pure returns (ERC20Storage storage $) { + assembly { + $.slot := ERC20StorageLocation + } + } + + /** + * @dev Sets the values for {name} and {symbol}. + * + * Both values are immutable: they can only be set once during construction. + */ + function __ERC20_init(string memory name_, string memory symbol_) internal onlyInitializing { + __ERC20_init_unchained(name_, symbol_); + } + + function __ERC20_init_unchained( + string memory name_, + string memory symbol_ + ) internal onlyInitializing { + ERC20Storage storage $ = _getERC20Storage(); + $._name = name_; + $._symbol = symbol_; + } + + /** + * @dev Returns the name of the token. + */ + function name() public view virtual returns (string memory) { + ERC20Storage storage $ = _getERC20Storage(); + return $._name; + } + + /** + * @dev Returns the symbol of the token, usually a shorter version of the + * name. + */ + function symbol() public view virtual returns (string memory) { + ERC20Storage storage $ = _getERC20Storage(); + return $._symbol; + } + + /** + * @dev Returns the number of decimals used to get its user representation. + * For example, if `decimals` equals `2`, a balance of `505` tokens should + * be displayed to a user as `5.05` (`505 / 10 ** 2`). + * + * Tokens usually opt for a value of 18, imitating the relationship between + * Ether and Wei. This is the default value returned by this function, unless + * it's overridden. + * + * NOTE: This information is only used for _display_ purposes: it in + * no way affects any of the arithmetic of the contract, including + * {IERC20-balanceOf} and {IERC20-transfer}. + */ + function decimals() public view virtual returns (uint8) { + return 18; + } + + /// @inheritdoc IERC20 + function totalSupply() public view virtual returns (uint256) { + ERC20Storage storage $ = _getERC20Storage(); + return $._totalSupply; + } + + /// @inheritdoc IERC20 + function balanceOf(address account) public view virtual returns (uint256) { + ERC20Storage storage $ = _getERC20Storage(); + return $._balances[account]; + } + + /** + * @dev See {IERC20-transfer}. + * + * Requirements: + * + * - `to` cannot be the zero address. + * - the caller must have a balance of at least `value`. + */ + function transfer(address to, uint256 value) public virtual returns (bool) { + address owner = _msgSender(); + _transfer(owner, to, value); + return true; + } + + /// @inheritdoc IERC20 + function allowance(address owner, address spender) public view virtual returns (uint256) { + ERC20Storage storage $ = _getERC20Storage(); + return $._allowances[owner][spender]; + } + + /** + * @dev See {IERC20-approve}. + * + * NOTE: If `value` is the maximum `uint256`, the allowance is not updated on + * `transferFrom`. This is semantically equivalent to an infinite approval. + * + * Requirements: + * + * - `spender` cannot be the zero address. + */ + function approve(address spender, uint256 value) public virtual returns (bool) { + address owner = _msgSender(); + _approve(owner, spender, value); + return true; + } + + /** + * @dev See {IERC20-transferFrom}. + * + * Skips emitting an {Approval} event indicating an allowance update. This is not + * required by the ERC. See {xref-ERC20-_approve-address-address-uint256-bool-}[_approve]. + * + * NOTE: Does not update the allowance if the current allowance + * is the maximum `uint256`. + * + * Requirements: + * + * - `from` and `to` cannot be the zero address. + * - `from` must have a balance of at least `value`. + * - the caller must have allowance for ``from``'s tokens of at least + * `value`. + */ + function transferFrom(address from, address to, uint256 value) public virtual returns (bool) { + address spender = _msgSender(); + _spendAllowance(from, spender, value); + _transfer(from, to, value); + return true; + } + + /** + * @dev Moves a `value` amount of tokens from `from` to `to`. + * + * This internal function is equivalent to {transfer}, and can be used to + * e.g. implement automatic token fees, slashing mechanisms, etc. + * + * Emits a {Transfer} event. + * + * NOTE: This function is not virtual, {_update} should be overridden instead. + */ + function _transfer(address from, address to, uint256 value) internal { + if (from == address(0)) { + revert ERC20InvalidSender(address(0)); + } + if (to == address(0)) { + revert ERC20InvalidReceiver(address(0)); + } + _update(from, to, value); + } + + /** + * @dev 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. + * + * Emits a {Transfer} event. + */ + function _update(address from, address to, uint256 value) internal virtual { + ERC20Storage storage $ = _getERC20Storage(); + if (from == address(0)) { + // Overflow check required: The rest of the code assumes that totalSupply never overflows + $._totalSupply += value; + } else { + uint256 fromBalance = $._balances[from]; + if (fromBalance < value) { + revert ERC20InsufficientBalance(from, fromBalance, value); + } + unchecked { + // Overflow not possible: value <= fromBalance <= totalSupply. + $._balances[from] = fromBalance - value; + } + } + + if (to == address(0)) { + unchecked { + // Overflow not possible: value <= totalSupply or value <= fromBalance <= totalSupply. + $._totalSupply -= value; + } + } else { + unchecked { + // Overflow not possible: balance + value is at most totalSupply, which we know fits into a uint256. + $._balances[to] += value; + } + } + + emit Transfer(from, to, value); + } + + /** + * @dev Creates a `value` amount of tokens and assigns them to `account`, by transferring it from address(0). + * Relies on the `_update` mechanism + * + * Emits a {Transfer} event with `from` set to the zero address. + * + * NOTE: This function is not virtual, {_update} should be overridden instead. + */ + function _mint(address account, uint256 value) internal { + if (account == address(0)) { + revert ERC20InvalidReceiver(address(0)); + } + _update(address(0), account, value); + } + + /** + * @dev Destroys a `value` amount of tokens from `account`, lowering the total supply. + * Relies on the `_update` mechanism. + * + * Emits a {Transfer} event with `to` set to the zero address. + * + * NOTE: This function is not virtual, {_update} should be overridden instead + */ + function _burn(address account, uint256 value) internal { + if (account == address(0)) { + revert ERC20InvalidSender(address(0)); + } + _update(account, address(0), value); + } + + /** + * @dev Sets `value` as the allowance of `spender` over the `owner`'s tokens. + * + * This internal function is equivalent to `approve`, and can be used to + * e.g. set automatic allowances for certain subsystems, etc. + * + * Emits an {Approval} event. + * + * Requirements: + * + * - `owner` cannot be the zero address. + * - `spender` cannot be the zero address. + * + * Overrides to this logic should be done to the variant with an additional `bool emitEvent` argument. + */ + function _approve(address owner, address spender, uint256 value) internal { + _approve(owner, spender, value, true); + } + + /** + * @dev Variant of {_approve} with an optional flag to enable or disable the {Approval} event. + * + * By default (when calling {_approve}) the flag is set to true. On the other hand, approval changes made by + * `_spendAllowance` during the `transferFrom` operation sets the flag to false. This saves gas by not emitting any + * `Approval` event during `transferFrom` operations. + * + * Anyone who wishes to continue emitting `Approval` events on the `transferFrom` operation can force the flag to + * true using the following override: + * + * ```solidity + * function _approve(address owner, address spender, uint256 value, bool) internal virtual override { + * super._approve(owner, spender, value, true); + * } + * ``` + * + * Requirements are the same as {_approve}. + */ + function _approve( + address owner, + address spender, + uint256 value, + bool emitEvent + ) internal virtual { + ERC20Storage storage $ = _getERC20Storage(); + if (owner == address(0)) { + revert ERC20InvalidApprover(address(0)); + } + if (spender == address(0)) { + revert ERC20InvalidSpender(address(0)); + } + $._allowances[owner][spender] = value; + if (emitEvent) { + emit Approval(owner, spender, value); + } + } + + /** + * @dev Updates `owner`'s allowance for `spender` based on spent `value`. + * + * Does not update the allowance value in case of infinite allowance. + * Revert if not enough allowance is available. + * + * Does not emit an {Approval} event. + */ + function _spendAllowance(address owner, address spender, uint256 value) internal virtual { + uint256 currentAllowance = allowance(owner, spender); + if (currentAllowance < type(uint256).max) { + if (currentAllowance < value) { + revert ERC20InsufficientAllowance(spender, currentAllowance, value); + } + unchecked { + _approve(owner, spender, currentAllowance - value, false); + } + } + } +} diff --git a/src/dependencies/openzeppelin/IERC2612.sol b/src/dependencies/openzeppelin/IERC2612.sol new file mode 100644 index 000000000..9c8194840 --- /dev/null +++ b/src/dependencies/openzeppelin/IERC2612.sol @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v5.4.0) (interfaces/IERC2612.sol) + +pragma solidity >=0.6.2; + +import {IERC20Permit} from './IERC20Permit.sol'; + +interface IERC2612 is IERC20Permit {} diff --git a/src/dependencies/openzeppelin/IERC4626.sol b/src/dependencies/openzeppelin/IERC4626.sol new file mode 100644 index 000000000..3b4b5d00d --- /dev/null +++ b/src/dependencies/openzeppelin/IERC4626.sol @@ -0,0 +1,238 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v5.4.0) (interfaces/IERC4626.sol) + +pragma solidity >=0.6.2; + +import {IERC20} from './IERC20.sol'; +import {IERC20Metadata} from './IERC20Metadata.sol'; + +/** + * @dev Interface of the ERC-4626 "Tokenized Vault Standard", as defined in + * https://eips.ethereum.org/EIPS/eip-4626[ERC-4626]. + */ +interface IERC4626 is IERC20, IERC20Metadata { + event Deposit(address indexed sender, address indexed owner, uint256 assets, uint256 shares); + + event Withdraw( + address indexed sender, + address indexed receiver, + address indexed owner, + uint256 assets, + uint256 shares + ); + + /** + * @dev Returns the address of the underlying token used for the Vault for accounting, depositing, and withdrawing. + * + * - MUST be an ERC-20 token contract. + * - MUST NOT revert. + */ + function asset() external view returns (address assetTokenAddress); + + /** + * @dev Returns the total amount of the underlying asset that is “managed” by Vault. + * + * - SHOULD include any compounding that occurs from yield. + * - MUST be inclusive of any fees that are charged against assets in the Vault. + * - MUST NOT revert. + */ + function totalAssets() external view returns (uint256 totalManagedAssets); + + /** + * @dev Returns the amount of shares that the Vault would exchange for the amount of assets provided, in an ideal + * scenario where all the conditions are met. + * + * - MUST NOT be inclusive of any fees that are charged against assets in the Vault. + * - MUST NOT show any variations depending on the caller. + * - MUST NOT reflect slippage or other on-chain conditions, when performing the actual exchange. + * - MUST NOT revert. + * + * NOTE: This calculation MAY NOT reflect the “per-user” price-per-share, and instead should reflect the + * “average-user’s” price-per-share, meaning what the average user should expect to see when exchanging to and + * from. + */ + function convertToShares(uint256 assets) external view returns (uint256 shares); + + /** + * @dev Returns the amount of assets that the Vault would exchange for the amount of shares provided, in an ideal + * scenario where all the conditions are met. + * + * - MUST NOT be inclusive of any fees that are charged against assets in the Vault. + * - MUST NOT show any variations depending on the caller. + * - MUST NOT reflect slippage or other on-chain conditions, when performing the actual exchange. + * - MUST NOT revert. + * + * NOTE: This calculation MAY NOT reflect the “per-user” price-per-share, and instead should reflect the + * “average-user’s” price-per-share, meaning what the average user should expect to see when exchanging to and + * from. + */ + function convertToAssets(uint256 shares) external view returns (uint256 assets); + + /** + * @dev Returns the maximum amount of the underlying asset that can be deposited into the Vault for the receiver, + * through a deposit call. + * + * - MUST return a limited value if receiver is subject to some deposit limit. + * - MUST return 2 ** 256 - 1 if there is no limit on the maximum amount of assets that may be deposited. + * - MUST NOT revert. + */ + function maxDeposit(address receiver) external view returns (uint256 maxAssets); + + /** + * @dev Allows an on-chain or off-chain user to simulate the effects of their deposit at the current block, given + * current on-chain conditions. + * + * - MUST return as close to and no more than the exact amount of Vault shares that would be minted in a deposit + * call in the same transaction. I.e. deposit should return the same or more shares as previewDeposit if called + * in the same transaction. + * - MUST NOT account for deposit limits like those returned from maxDeposit and should always act as though the + * deposit would be accepted, regardless if the user has enough tokens approved, etc. + * - MUST be inclusive of deposit fees. Integrators should be aware of the existence of deposit fees. + * - MUST NOT revert. + * + * NOTE: any unfavorable discrepancy between convertToShares and previewDeposit SHOULD be considered slippage in + * share price or some other type of condition, meaning the depositor will lose assets by depositing. + */ + function previewDeposit(uint256 assets) external view returns (uint256 shares); + + /** + * @dev Mints shares Vault shares to receiver by depositing exactly amount of underlying tokens. + * + * - MUST emit the Deposit event. + * - MAY support an additional flow in which the underlying tokens are owned by the Vault contract before the + * deposit execution, and are accounted for during deposit. + * - MUST revert if all of assets cannot be deposited (due to deposit limit being reached, slippage, the user not + * approving enough underlying tokens to the Vault contract, etc). + * + * NOTE: most implementations will require pre-approval of the Vault with the Vault’s underlying asset token. + */ + function deposit(uint256 assets, address receiver) external returns (uint256 shares); + + /** + * @dev Returns the maximum amount of the Vault shares that can be minted for the receiver, through a mint call. + * - MUST return a limited value if receiver is subject to some mint limit. + * - MUST return 2 ** 256 - 1 if there is no limit on the maximum amount of shares that may be minted. + * - MUST NOT revert. + */ + function maxMint(address receiver) external view returns (uint256 maxShares); + + /** + * @dev Allows an on-chain or off-chain user to simulate the effects of their mint at the current block, given + * current on-chain conditions. + * + * - MUST return as close to and no fewer than the exact amount of assets that would be deposited in a mint call + * in the same transaction. I.e. mint should return the same or fewer assets as previewMint if called in the + * same transaction. + * - MUST NOT account for mint limits like those returned from maxMint and should always act as though the mint + * would be accepted, regardless if the user has enough tokens approved, etc. + * - MUST be inclusive of deposit fees. Integrators should be aware of the existence of deposit fees. + * - MUST NOT revert. + * + * NOTE: any unfavorable discrepancy between convertToAssets and previewMint SHOULD be considered slippage in + * share price or some other type of condition, meaning the depositor will lose assets by minting. + */ + function previewMint(uint256 shares) external view returns (uint256 assets); + + /** + * @dev Mints exactly shares Vault shares to receiver by depositing amount of underlying tokens. + * + * - MUST emit the Deposit event. + * - MAY support an additional flow in which the underlying tokens are owned by the Vault contract before the mint + * execution, and are accounted for during mint. + * - MUST revert if all of shares cannot be minted (due to deposit limit being reached, slippage, the user not + * approving enough underlying tokens to the Vault contract, etc). + * + * NOTE: most implementations will require pre-approval of the Vault with the Vault’s underlying asset token. + */ + function mint(uint256 shares, address receiver) external returns (uint256 assets); + + /** + * @dev Returns the maximum amount of the underlying asset that can be withdrawn from the owner balance in the + * Vault, through a withdraw call. + * + * - MUST return a limited value if owner is subject to some withdrawal limit or timelock. + * - MUST NOT revert. + */ + function maxWithdraw(address owner) external view returns (uint256 maxAssets); + + /** + * @dev Allows an on-chain or off-chain user to simulate the effects of their withdrawal at the current block, + * given current on-chain conditions. + * + * - MUST return as close to and no fewer than the exact amount of Vault shares that would be burned in a withdraw + * call in the same transaction. I.e. withdraw should return the same or fewer shares as previewWithdraw if + * called + * in the same transaction. + * - MUST NOT account for withdrawal limits like those returned from maxWithdraw and should always act as though + * the withdrawal would be accepted, regardless if the user has enough shares, etc. + * - MUST be inclusive of withdrawal fees. Integrators should be aware of the existence of withdrawal fees. + * - MUST NOT revert. + * + * NOTE: any unfavorable discrepancy between convertToShares and previewWithdraw SHOULD be considered slippage in + * share price or some other type of condition, meaning the depositor will lose assets by depositing. + */ + function previewWithdraw(uint256 assets) external view returns (uint256 shares); + + /** + * @dev Burns shares from owner and sends exactly assets of underlying tokens to receiver. + * + * - MUST emit the Withdraw event. + * - MAY support an additional flow in which the underlying tokens are owned by the Vault contract before the + * withdraw execution, and are accounted for during withdraw. + * - MUST revert if all of assets cannot be withdrawn (due to withdrawal limit being reached, slippage, the owner + * not having enough shares, etc). + * + * Note that some implementations will require pre-requesting to the Vault before a withdrawal may be performed. + * Those methods should be performed separately. + */ + function withdraw( + uint256 assets, + address receiver, + address owner + ) external returns (uint256 shares); + + /** + * @dev Returns the maximum amount of Vault shares that can be redeemed from the owner balance in the Vault, + * through a redeem call. + * + * - MUST return a limited value if owner is subject to some withdrawal limit or timelock. + * - MUST return balanceOf(owner) if owner is not subject to any withdrawal limit or timelock. + * - MUST NOT revert. + */ + function maxRedeem(address owner) external view returns (uint256 maxShares); + + /** + * @dev Allows an on-chain or off-chain user to simulate the effects of their redemption at the current block, + * given current on-chain conditions. + * + * - MUST return as close to and no more than the exact amount of assets that would be withdrawn in a redeem call + * in the same transaction. I.e. redeem should return the same or more assets as previewRedeem if called in the + * same transaction. + * - MUST NOT account for redemption limits like those returned from maxRedeem and should always act as though the + * redemption would be accepted, regardless if the user has enough shares, etc. + * - MUST be inclusive of withdrawal fees. Integrators should be aware of the existence of withdrawal fees. + * - MUST NOT revert. + * + * NOTE: any unfavorable discrepancy between convertToAssets and previewRedeem SHOULD be considered slippage in + * share price or some other type of condition, meaning the depositor will lose assets by redeeming. + */ + function previewRedeem(uint256 shares) external view returns (uint256 assets); + + /** + * @dev Burns exactly shares from owner and sends assets of underlying tokens to receiver. + * + * - MUST emit the Withdraw event. + * - MAY support an additional flow in which the underlying tokens are owned by the Vault contract before the + * redeem execution, and are accounted for during redeem. + * - MUST revert if all of shares cannot be redeemed (due to withdrawal limit being reached, slippage, the owner + * not having enough shares, etc). + * + * NOTE: some implementations will require pre-requesting to the Vault before a withdrawal may be performed. + * Those methods should be performed separately. + */ + function redeem( + uint256 shares, + address receiver, + address owner + ) external returns (uint256 assets); +} diff --git a/src/position-manager/libraries/EIP712Hash.sol b/src/libraries/EIP712Hash.sol similarity index 64% rename from src/position-manager/libraries/EIP712Hash.sol rename to src/libraries/EIP712Hash.sol index 060bd4575..43e13b100 100644 --- a/src/position-manager/libraries/EIP712Hash.sol +++ b/src/libraries/EIP712Hash.sol @@ -8,6 +8,10 @@ import {EIP712Types} from 'src/libraries/types/EIP712Types.sol'; /// @author Aave Labs /// @notice Helper methods to hash EIP712 typed data structs. library EIP712Hash { + bytes32 public constant PERMIT_TYPEHASH = + // keccak256('Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)') + 0x6e71edae12b1b97f4d1f60370fef10105fa2faae0126114a169c64845d6126c9; + bytes32 public constant SUPPLY_TYPEHASH = // keccak256('Supply(address spoke,uint256 reserveId,uint256 amount,address onBehalfOf,uint256 nonce,uint256 deadline)') 0xe85497eb293c001e8483fe105efadd1d50aa0dadfc0570b27058031dfceab2e6; @@ -36,6 +40,22 @@ library EIP712Hash { // keccak256('UpdateUserDynamicConfig(address spoke,address user,uint256 nonce,uint256 deadline)') 0xba177b1f5b5e1e709f62c19f03c97988c57752ba561de58f383ebee4e8d0a71c; + bytes32 public constant VAULT_DEPOSIT_TYPEHASH = + // keccak256('VaultDeposit(address depositor,uint256 assets,address receiver,uint256 nonce,uint256 deadline)') + 0x8e93b8e8149376c7ae7fb14ab6815d5cab2d1f72a9284c1dd9c9110ef06d1b75; + + bytes32 public constant VAULT_MINT_TYPEHASH = + // keccak256('VaultMint(address depositor,uint256 shares,address receiver,uint256 nonce,uint256 deadline)') + 0xc9777aa8e2687ff2ee6bf1c3cd14300a96bd425d4d1cb69e1155f5b8ecdf05d2; + + bytes32 public constant VAULT_WITHDRAW_TYPEHASH = + // keccak256('VaultWithdraw(address owner,uint256 assets,address receiver,uint256 nonce,uint256 deadline)') + 0x8575f76be3d57d8fc8f537e04c7e5bea275ef41afb95c3dc53b43d4fc2e43545; + + bytes32 public constant VAULT_REDEEM_TYPEHASH = + // keccak256('VaultRedeem(address owner,uint256 shares,address receiver,uint256 nonce,uint256 deadline)') + 0x78b72753239783411f44a6ae16b7cc070aa270bf9328e0afd1ea709e5e6ab4ea; + function hash(EIP712Types.Supply calldata params) internal pure returns (bytes32) { return keccak256( @@ -138,4 +158,60 @@ library EIP712Hash { ) ); } + + function hash(EIP712Types.VaultDeposit calldata params) internal pure returns (bytes32) { + return + keccak256( + abi.encode( + VAULT_DEPOSIT_TYPEHASH, + params.depositor, + params.assets, + params.receiver, + params.nonce, + params.deadline + ) + ); + } + + function hash(EIP712Types.VaultMint calldata params) internal pure returns (bytes32) { + return + keccak256( + abi.encode( + VAULT_MINT_TYPEHASH, + params.depositor, + params.shares, + params.receiver, + params.nonce, + params.deadline + ) + ); + } + + function hash(EIP712Types.VaultWithdraw calldata params) internal pure returns (bytes32) { + return + keccak256( + abi.encode( + VAULT_WITHDRAW_TYPEHASH, + params.owner, + params.assets, + params.receiver, + params.nonce, + params.deadline + ) + ); + } + + function hash(EIP712Types.VaultRedeem calldata params) internal pure returns (bytes32) { + return + keccak256( + abi.encode( + VAULT_REDEEM_TYPEHASH, + params.owner, + params.shares, + params.receiver, + params.nonce, + params.deadline + ) + ); + } } diff --git a/src/libraries/math/MathUtils.sol b/src/libraries/math/MathUtils.sol index 1d99a1d76..6bdbbf48f 100644 --- a/src/libraries/math/MathUtils.sol +++ b/src/libraries/math/MathUtils.sol @@ -34,6 +34,13 @@ library MathUtils { } } + /// @notice Returns the saturating subtraction a - b. + function zeroFloorSub(uint256 a, uint256 b) internal pure returns (uint256 c) { + assembly ('memory-safe') { + c := mul(sub(a, b), gt(a, b)) + } + } + /// @notice Returns the sum of an unsigned and signed integer. /// @dev Reverts on underflow. function add(uint256 a, int256 b) internal pure returns (uint256) { diff --git a/src/libraries/types/EIP712Types.sol b/src/libraries/types/EIP712Types.sol index e647206a0..58c26ba9e 100644 --- a/src/libraries/types/EIP712Types.sol +++ b/src/libraries/types/EIP712Types.sol @@ -80,4 +80,36 @@ library EIP712Types { uint256 nonce; uint256 deadline; } + + struct VaultDeposit { + address depositor; + uint256 assets; + address receiver; + uint256 nonce; + uint256 deadline; + } + + struct VaultMint { + address depositor; + uint256 shares; + address receiver; + uint256 nonce; + uint256 deadline; + } + + struct VaultWithdraw { + address owner; + uint256 assets; + address receiver; + uint256 nonce; + uint256 deadline; + } + + struct VaultRedeem { + address owner; + uint256 shares; + address receiver; + uint256 nonce; + uint256 deadline; + } } diff --git a/src/position-manager/SignatureGateway.sol b/src/position-manager/SignatureGateway.sol index de2194201..a784dc93e 100644 --- a/src/position-manager/SignatureGateway.sol +++ b/src/position-manager/SignatureGateway.sol @@ -5,12 +5,12 @@ pragma solidity 0.8.28; import {SignatureChecker} from 'src/dependencies/openzeppelin/SignatureChecker.sol'; import {SafeERC20, IERC20} from 'src/dependencies/openzeppelin/SafeERC20.sol'; import {IERC20Permit} from 'src/dependencies/openzeppelin/IERC20Permit.sol'; -import {EIP712} from 'src/dependencies/solady/EIP712.sol'; +import {EIP712Hash, EIP712Types} from 'src/libraries/EIP712Hash.sol'; import {MathUtils} from 'src/libraries/math/MathUtils.sol'; +import {GatewayBase} from 'src/position-manager/GatewayBase.sol'; +import {EIP712} from 'src/dependencies/solady/EIP712.sol'; import {NoncesKeyed} from 'src/utils/NoncesKeyed.sol'; import {Multicall} from 'src/utils/Multicall.sol'; -import {EIP712Hash, EIP712Types} from 'src/position-manager/libraries/EIP712Hash.sol'; -import {GatewayBase} from 'src/position-manager/GatewayBase.sol'; import {ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; import {ISignatureGateway} from 'src/position-manager/interfaces/ISignatureGateway.sol'; diff --git a/src/position-manager/interfaces/ISignatureGateway.sol b/src/position-manager/interfaces/ISignatureGateway.sol index d7ae8d6cd..cb1813b74 100644 --- a/src/position-manager/interfaces/ISignatureGateway.sol +++ b/src/position-manager/interfaces/ISignatureGateway.sol @@ -18,7 +18,7 @@ interface ISignatureGateway is IMulticall, INoncesKeyed, IGatewayBase { /// @dev Supplied assets are pulled from `onBehalfOf`, prior approval to this gateway is required. /// @dev Uses keyed-nonces where for each key's namespace nonce is consumed sequentially. /// @param params The structured supply parameters. - /// @param signature The signed bytes for the intent. + /// @param signature The EIP712-typed signed bytes for the intent. /// @return The amount of shares supplied. /// @return The amount of assets supplied. function supplyWithSig( @@ -31,7 +31,7 @@ interface ISignatureGateway is IMulticall, INoncesKeyed, IGatewayBase { /// @dev Withdrawn assets are pushed to `onBehalfOf`. /// @dev Uses keyed-nonces where for each key's namespace nonce is consumed sequentially. /// @param params The structured withdraw parameters. - /// @param signature The signed bytes for the intent. + /// @param signature The EIP712-typed signed bytes for the intent. /// @return The amount of shares withdrawn. /// @return The amount of assets withdrawn. function withdrawWithSig( @@ -43,7 +43,7 @@ interface ISignatureGateway is IMulticall, INoncesKeyed, IGatewayBase { /// @dev Borrowed assets are pushed to `onBehalfOf`. /// @dev Uses keyed-nonces where for each key's namespace nonce is consumed sequentially. /// @param params The structured borrow parameters. - /// @param signature The signed bytes for the intent. + /// @param signature The EIP712-typed signed bytes for the intent. /// @return The amount of shares borrowed. /// @return The amount of assets borrowed. function borrowWithSig( @@ -56,7 +56,7 @@ interface ISignatureGateway is IMulticall, INoncesKeyed, IGatewayBase { /// @dev Providing an amount greater than the user's current debt indicates a request to repay the maximum possible amount. /// @dev Uses keyed-nonces where for each key's namespace nonce is consumed sequentially. /// @param params The structured repay parameters. - /// @param signature The signed bytes for the intent. + /// @param signature The EIP712-typed signed bytes for the intent. /// @return The amount of shares repaid. /// @return The amount of assets repaid. function repayWithSig( @@ -67,7 +67,7 @@ interface ISignatureGateway is IMulticall, INoncesKeyed, IGatewayBase { /// @notice Facilitates `setUsingAsCollateral` action on the specified registered `spoke` with a typed signature from `onBehalfOf`. /// @dev Uses keyed-nonces where for each key's namespace nonce is consumed sequentially. /// @param params The structured setUsingAsCollateral parameters. - /// @param signature The signed bytes for the intent. + /// @param signature The EIP712-typed signed bytes for the intent. function setUsingAsCollateralWithSig( EIP712Types.SetUsingAsCollateral calldata params, bytes calldata signature @@ -76,7 +76,7 @@ interface ISignatureGateway is IMulticall, INoncesKeyed, IGatewayBase { /// @notice Facilitates `updateUserRiskPremium` action on the specified registered `spoke` with a typed signature from `user`. /// @dev Uses keyed-nonces where for each key's namespace nonce is consumed sequentially. /// @param params The structured updateUserRiskPremium parameters. - /// @param signature The signed bytes for the intent. + /// @param signature The EIP712-typed signed bytes for the intent. function updateUserRiskPremiumWithSig( EIP712Types.UpdateUserRiskPremium calldata params, bytes calldata signature @@ -85,7 +85,7 @@ interface ISignatureGateway is IMulticall, INoncesKeyed, IGatewayBase { /// @notice Facilitates `updateUserDynamicConfig` action on the specified registered `spoke` with a typed signature from `user`. /// @dev Uses keyed-nonces where for each key's namespace nonce is consumed sequentially. /// @param params The structured updateUserDynamicConfig parameters. - /// @param signature The signed bytes for the intent. + /// @param signature The EIP712-typed signed bytes for the intent. function updateUserDynamicConfigWithSig( EIP712Types.UpdateUserDynamicConfig calldata params, bytes calldata signature @@ -97,7 +97,7 @@ interface ISignatureGateway is IMulticall, INoncesKeyed, IGatewayBase { /// @dev The given data is passed to the `spoke` for the signature to be verified. /// @param spoke The address of the spoke. /// @param params The structured setSelfAsUserPositionManager parameters. - /// @param signature The signed bytes for the intent. + /// @param signature The EIP712-typed signed bytes for the intent. function setSelfAsUserPositionManagerWithSig( address spoke, EIP712Types.SetUserPositionManager calldata params, diff --git a/src/spoke/VaultSpoke.sol b/src/spoke/VaultSpoke.sol new file mode 100644 index 000000000..4d9b6e05e --- /dev/null +++ b/src/spoke/VaultSpoke.sol @@ -0,0 +1,442 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity 0.8.28; + +import {ERC20Upgradeable} from 'src/dependencies/openzeppelin-upgradeable/ERC20Upgradeable.sol'; +import {SafeERC20, IERC20} from 'src/dependencies/openzeppelin/SafeERC20.sol'; +import {IERC20Permit} from 'src/dependencies/openzeppelin/IERC20Permit.sol'; +import {IERC4626, IERC20Metadata} from 'src/dependencies/openzeppelin/IERC4626.sol'; +import {SignatureChecker, ECDSA} from 'src/dependencies/openzeppelin/SignatureChecker.sol'; +import {EIP712} from 'src/dependencies/solady/EIP712.sol'; +import {MathUtils} from 'src/libraries/math/MathUtils.sol'; +import {EIP712Hash, EIP712Types} from 'src/libraries/EIP712Hash.sol'; +import {NoncesKeyed} from 'src/utils/NoncesKeyed.sol'; +import {IHub} from 'src/hub/interfaces/IHub.sol'; +import {IVaultSpoke} from 'src/spoke/interfaces/IVaultSpoke.sol'; + +/// @title VaultSpoke +/// @author Aave Labs +/// @notice ERC4626 compliant vault for hub's listed asset position management. +/// @dev Connects to one listed asset, only responsible for tokenizing positions. +/// @dev Share price accounting is maintained solely on the Hub. +abstract contract VaultSpoke is IVaultSpoke, ERC20Upgradeable, NoncesKeyed, EIP712 { + using SafeERC20 for IERC20; + using MathUtils for uint256; + using EIP712Hash for *; + + IHub internal immutable HUB; + uint256 internal immutable ASSET_ID; + address internal immutable ASSET; + uint8 internal immutable DECIMALS; + uint256 internal immutable ASSET_UNITS; + + /// @inheritdoc IVaultSpoke + uint40 public immutable MAX_ALLOWED_SPOKE_CAP; + + /// @inheritdoc IVaultSpoke + uint192 public constant PERMIT_NONCE_KEY = 0; + + constructor(address hub_, uint256 assetId_) { + require(assetId_ < IHub(hub_).getAssetCount()); + HUB = IHub(hub_); + ASSET_ID = assetId_; + (ASSET, DECIMALS) = HUB.getAssetUnderlyingAndDecimals(ASSET_ID); + ASSET_UNITS = MathUtils.uncheckedExp(10, DECIMALS); + MAX_ALLOWED_SPOKE_CAP = HUB.MAX_ALLOWED_SPOKE_CAP(); + } + + /// @dev To be overridden by the inheriting VaultSpokeInstance contract. + function initialize(string memory shareName, string memory shareSymbol) external virtual; + + /// @dev Sets the vault share token's ERC20 name and symbol. Must be called at first initialization. + function __VaultSpoke_init( + string memory shareName, + string memory shareSymbol + ) internal onlyInitializing { + __ERC20_init(shareName, shareSymbol); + } + + /// @inheritdoc IERC4626 + function deposit(uint256 assets, address receiver) public override returns (uint256) { + return _executeDeposit({depositor: msg.sender, receiver: receiver, assets: assets}); + } + + /// @inheritdoc IERC4626 + function mint(uint256 shares, address receiver) public override returns (uint256) { + return _executeMint({depositor: msg.sender, receiver: receiver, shares: shares}); + } + + /// @inheritdoc IERC4626 + function withdraw( + uint256 assets, + address receiver, + address owner + ) public override returns (uint256) { + return _executeWithdraw({caller: msg.sender, receiver: receiver, owner: owner, assets: assets}); + } + + /// @inheritdoc IERC4626 + function redeem( + uint256 shares, + address receiver, + address owner + ) public override returns (uint256) { + return _executeRedeem({caller: msg.sender, receiver: receiver, owner: owner, shares: shares}); + } + + /// @inheritdoc IVaultSpoke + function depositWithSig( + EIP712Types.VaultDeposit calldata params, + bytes calldata signature + ) external returns (uint256) { + require(block.timestamp <= params.deadline, InvalidSignature()); + bytes32 digest = _hashTypedData(params.hash()); + require( + SignatureChecker.isValidSignatureNow(params.depositor, digest, signature), + InvalidSignature() + ); + _useCheckedNonce(params.depositor, params.nonce); + return + _executeDeposit({ + depositor: params.depositor, + receiver: params.receiver, + assets: params.assets + }); + } + + /// @inheritdoc IVaultSpoke + function mintWithSig( + EIP712Types.VaultMint calldata params, + bytes calldata signature + ) external returns (uint256) { + require(block.timestamp <= params.deadline, InvalidSignature()); + bytes32 digest = _hashTypedData(params.hash()); + require( + SignatureChecker.isValidSignatureNow(params.depositor, digest, signature), + InvalidSignature() + ); + _useCheckedNonce(params.depositor, params.nonce); + return + _executeMint({depositor: params.depositor, receiver: params.receiver, shares: params.shares}); + } + + /// @inheritdoc IVaultSpoke + function withdrawWithSig( + EIP712Types.VaultWithdraw calldata params, + bytes calldata signature + ) external returns (uint256) { + require(block.timestamp <= params.deadline, InvalidSignature()); + bytes32 digest = _hashTypedData(params.hash()); + require( + SignatureChecker.isValidSignatureNow(params.owner, digest, signature), + InvalidSignature() + ); + _useCheckedNonce(params.owner, params.nonce); + return + _executeWithdraw({ + caller: params.owner, + receiver: params.receiver, + owner: params.owner, + assets: params.assets + }); + } + + /// @inheritdoc IVaultSpoke + function redeemWithSig( + EIP712Types.VaultRedeem calldata params, + bytes calldata signature + ) external returns (uint256) { + require(block.timestamp <= params.deadline, InvalidSignature()); + bytes32 digest = _hashTypedData(params.hash()); + require( + SignatureChecker.isValidSignatureNow(params.owner, digest, signature), + InvalidSignature() + ); + _useCheckedNonce(params.owner, params.nonce); + return + _executeRedeem({ + caller: params.owner, + receiver: params.receiver, + owner: params.owner, + shares: params.shares + }); + } + + /// @inheritdoc IVaultSpoke + function depositWithPermit( + uint256 assets, + address receiver, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) external returns (uint256) { + try + IERC20Permit(ASSET).permit({ + owner: msg.sender, + spender: address(this), + value: assets, + deadline: deadline, + v: v, + r: r, + s: s + }) + {} catch {} + return _executeDeposit({depositor: msg.sender, receiver: receiver, assets: assets}); + } + + /// @inheritdoc IERC20Permit + function permit( + address owner, + address spender, + uint256 value, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) external { + require(block.timestamp <= deadline, InvalidSignature()); + bytes32 digest = _hashTypedData( + keccak256( + abi.encode( + EIP712Hash.PERMIT_TYPEHASH, + owner, + spender, + value, + _useNonce({owner: owner, key: PERMIT_NONCE_KEY}), + deadline + ) + ) + ); + require(owner == ECDSA.recover({hash: digest, v: v, r: r, s: s}), InvalidSignature()); + _approve({owner: owner, spender: spender, value: value}); + } + + /// @inheritdoc IVaultSpoke + function renounceAllowance(address owner) external override { + if (allowance({owner: owner, spender: msg.sender}) == 0) { + return; + } + _approve({owner: owner, spender: msg.sender, value: 0}); + } + + /// @inheritdoc IERC4626 + function previewDeposit(uint256 assets) public view virtual returns (uint256) { + return HUB.previewAddByAssets(ASSET_ID, assets); + } + + /// @inheritdoc IERC4626 + function previewMint(uint256 shares) public view virtual returns (uint256) { + return HUB.previewAddByShares(ASSET_ID, shares); + } + + /// @inheritdoc IERC4626 + function previewWithdraw(uint256 assets) public view virtual returns (uint256) { + return HUB.previewRemoveByAssets(ASSET_ID, assets); + } + + /// @inheritdoc IERC4626 + function previewRedeem(uint256 shares) public view virtual returns (uint256) { + return HUB.previewRemoveByShares(ASSET_ID, shares); + } + + /// @inheritdoc IERC4626 + function convertToShares(uint256 assets) external view returns (uint256) { + return previewDeposit(assets); + } + + /// @inheritdoc IERC4626 + function convertToAssets(uint256 shares) external view returns (uint256) { + return previewRedeem(shares); + } + + /// @inheritdoc IERC4626 + function maxDeposit(address) public view returns (uint256) { + IHub.SpokeConfig memory config = HUB.getSpokeConfig(ASSET_ID, address(this)); + if (!config.active || config.paused) { + return 0; + } + if (config.addCap == MAX_ALLOWED_SPOKE_CAP) { + return type(uint256).max; + } + uint256 allowed = config.addCap * ASSET_UNITS; + uint256 balance = totalAssets(); + return allowed.zeroFloorSub(balance); + } + + /// @inheritdoc IERC4626 + function maxMint(address receiver) public view returns (uint256) { + uint256 maxAssets = maxDeposit(receiver); + return maxAssets == type(uint256).max ? type(uint256).max : previewDeposit(maxAssets); + } + + /// @inheritdoc IERC4626 + function maxWithdraw(address owner) public view returns (uint256) { + uint256 maxRemovableAssets = _maxRemovableAssets(); + uint256 balance = previewRedeem(balanceOf(owner)); + return balance.min(maxRemovableAssets); + } + + /// @inheritdoc IERC4626 + function maxRedeem(address owner) public view returns (uint256) { + uint256 maxRemovableShares = previewWithdraw(_maxRemovableAssets()); + uint256 balance = balanceOf(owner); + return balance.min(maxRemovableShares); + } + + /// @inheritdoc IERC4626 + function totalAssets() public view virtual returns (uint256) { + return previewRedeem(totalSupply()); + } + + /// @inheritdoc IVaultSpoke + function hub() public view returns (address) { + return address(HUB); + } + + /// @inheritdoc IVaultSpoke + function assetId() public view returns (uint256) { + return ASSET_ID; + } + + /// @inheritdoc IERC4626 + function asset() public view returns (address) { + return ASSET; + } + + /// @inheritdoc IERC20Metadata + function decimals() public view override(ERC20Upgradeable, IERC20Metadata) returns (uint8) { + return DECIMALS; + } + + /// @inheritdoc IERC20Permit + function nonces(address owner) public view returns (uint256) { + return nonces({owner: owner, key: PERMIT_NONCE_KEY}); + } + + /// @inheritdoc IERC20Permit + function DOMAIN_SEPARATOR() public view returns (bytes32) { + return _domainSeparator(); + } + + /// @inheritdoc IVaultSpoke + function DEPOSIT_TYPEHASH() external pure returns (bytes32) { + return EIP712Hash.VAULT_DEPOSIT_TYPEHASH; + } + + /// @inheritdoc IVaultSpoke + function MINT_TYPEHASH() external pure returns (bytes32) { + return EIP712Hash.VAULT_MINT_TYPEHASH; + } + + /// @inheritdoc IVaultSpoke + function WITHDRAW_TYPEHASH() external pure returns (bytes32) { + return EIP712Hash.VAULT_WITHDRAW_TYPEHASH; + } + + /// @inheritdoc IVaultSpoke + function REDEEM_TYPEHASH() external pure returns (bytes32) { + return EIP712Hash.VAULT_REDEEM_TYPEHASH; + } + + /// @inheritdoc IVaultSpoke + function PERMIT_TYPEHASH() external pure returns (bytes32) { + return EIP712Hash.PERMIT_TYPEHASH; + } + + function _executeDeposit( + address depositor, + address receiver, + uint256 assets + ) internal returns (uint256) { + uint256 maxAssets = maxDeposit(receiver); + require(assets <= maxAssets, MaxDepositExceeded(maxAssets, assets)); + uint256 shares = previewDeposit(assets); + _deposit({caller: depositor, receiver: receiver, assets: assets, shares: shares}); + return shares; + } + + function _executeMint( + address depositor, + address receiver, + uint256 shares + ) internal returns (uint256) { + uint256 maxShares = maxMint(receiver); + require(shares <= maxShares, MaxMintExceeded(maxShares, shares)); + uint256 assets = previewMint(shares); + _deposit({caller: depositor, receiver: receiver, assets: assets, shares: shares}); + return assets; + } + + function _executeWithdraw( + address caller, + address receiver, + address owner, + uint256 assets + ) internal returns (uint256) { + uint256 maxAssets = maxWithdraw(owner); + require(assets <= maxAssets, MaxWithdrawExceeded(maxAssets, assets)); + uint256 shares = previewWithdraw(assets); + _withdraw({caller: caller, receiver: receiver, owner: owner, assets: assets, shares: shares}); + return shares; + } + + function _executeRedeem( + address caller, + address receiver, + address owner, + uint256 shares + ) internal returns (uint256) { + uint256 maxShares = maxRedeem(owner); + require(shares <= maxShares, MaxRedeemExceeded(maxShares, shares)); + uint256 assets = previewRedeem(shares); + _withdraw({caller: caller, receiver: receiver, owner: owner, assets: assets, shares: shares}); + return assets; + } + + /// @dev Does not check `hub.add(assets)` returns exactly `shares`; it must be the exact return value of `previewAddByShares` or vice versa for `assets`. + function _deposit( + address caller, + address receiver, + uint256 assets, + uint256 shares + ) internal virtual { + IERC20(ASSET).safeTransferFrom(caller, address(HUB), assets); + HUB.add(ASSET_ID, assets); + _mint(receiver, shares); + emit Deposit({sender: caller, owner: receiver, assets: assets, shares: shares}); + } + + function _withdraw( + address caller, + address receiver, + address owner, + uint256 assets, + uint256 shares + ) internal virtual { + if (caller != owner) { + _spendAllowance({owner: owner, spender: caller, value: shares}); + } + HUB.remove(ASSET_ID, assets, receiver); + _burn(owner, shares); + emit Withdraw({ + sender: caller, + receiver: receiver, + owner: owner, + assets: assets, + shares: shares + }); + } + + function _maxRemovableAssets() internal view returns (uint256) { + IHub.SpokeConfig memory config = HUB.getSpokeConfig(ASSET_ID, address(this)); + if (!config.active || config.paused) { + return 0; + } + return HUB.getAssetLiquidity(ASSET_ID); + } + + function _domainNameAndVersion() internal pure override returns (string memory, string memory) { + return ('Vault Spoke', '1'); + } +} diff --git a/src/spoke/instances/VaultSpokeInstance.sol b/src/spoke/instances/VaultSpokeInstance.sol new file mode 100644 index 000000000..81895f023 --- /dev/null +++ b/src/spoke/instances/VaultSpokeInstance.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity 0.8.28; + +import {VaultSpoke} from 'src/spoke/VaultSpoke.sol'; + +/// @title VaultSpokeInstance +/// @author Aave Labs +/// @notice Implementation contract for the VaultSpoke. +contract VaultSpokeInstance is VaultSpoke { + uint64 public constant SPOKE_REVISION = 1; + + /// @dev Constructor. + /// @param hub_ The address of the hub. + /// @param assetId_ The identifier of the asset. + constructor(address hub_, uint256 assetId_) VaultSpoke(hub_, assetId_) { + _disableInitializers(); + } + + /// @notice Initializer. + /// @param shareName The ERC20 name of the share issued by this vault. + /// @param shareSymbol The ERC20 symbol of the share issued by this vault. + function initialize( + string memory shareName, + string memory shareSymbol + ) external override reinitializer(SPOKE_REVISION) { + __VaultSpoke_init(shareName, shareSymbol); + } +} diff --git a/src/spoke/interfaces/IVaultSpoke.sol b/src/spoke/interfaces/IVaultSpoke.sol new file mode 100644 index 000000000..2c79262b9 --- /dev/null +++ b/src/spoke/interfaces/IVaultSpoke.sol @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import {IERC4626} from 'src/dependencies/openzeppelin/IERC4626.sol'; +import {IERC2612} from 'src/dependencies/openzeppelin/IERC2612.sol'; +import {EIP712Types} from 'src/libraries/types/EIP712Types.sol'; +import {INoncesKeyed} from 'src/interfaces/INoncesKeyed.sol'; + +/// @title IVaultSpoke +/// @author Aave Labs +interface IVaultSpoke is IERC4626, IERC2612, INoncesKeyed { + /// @notice Thrown when the given signature is invalid. + error InvalidSignature(); + + /// @notice Thrown when the maximum deposit limit is exceeded. + error MaxDepositExceeded(uint256 maxDeposit, uint256 requestedAssets); + + /// @notice Thrown when the maximum mint limit is exceeded. + error MaxMintExceeded(uint256 maxMint, uint256 requestedShares); + + /// @notice Thrown when the maximum withdraw limit is exceeded. + error MaxWithdrawExceeded(uint256 maxWithdraw, uint256 requestedAssets); + + /// @notice Thrown when the maximum redeem limit is exceeded. + error MaxRedeemExceeded(uint256 maxRedeem, uint256 requestedShares); + + /// @notice Deposits assets into the vault with a signature. + /// @dev Uses keyed-nonces where for each key's namespace nonce is consumed sequentially. + /// @param params The parameters for the deposit. + /// @param signature The EIP712-typed signed bytes for the deposit. + /// @return The amount of shares minted. + function depositWithSig( + EIP712Types.VaultDeposit calldata params, + bytes calldata signature + ) external returns (uint256); + + /// @notice Mints shares of the vault with a signature. + /// @dev Uses keyed-nonces where for each key's namespace nonce is consumed sequentially. + /// @param params The parameters for the mint. + /// @param signature The EIP712-typed signed bytes for the mint. + /// @return The amount of assets deposited. + function mintWithSig( + EIP712Types.VaultMint calldata params, + bytes calldata signature + ) external returns (uint256); + + /// @notice Withdraws assets from the vault with a signature. + /// @dev Uses keyed-nonces where for each key's namespace nonce is consumed sequentially. + /// @param params The parameters for the withdraw. + /// @param signature The EIP712-typed signed bytes for the withdraw. + /// @return The amount of shares burnt. + function withdrawWithSig( + EIP712Types.VaultWithdraw calldata params, + bytes calldata signature + ) external returns (uint256); + + /// @notice Redeems shares from the vault with a signature. + /// @dev Uses keyed-nonces where for each key's namespace nonce is consumed sequentially. + /// @param params The parameters for the redeem. + /// @param signature The EIP712-typed signed bytes for the redeem. + /// @return The amount of assets burnt. + function redeemWithSig( + EIP712Types.VaultRedeem calldata params, + bytes calldata signature + ) external returns (uint256); + + /// @notice Deposits assets into the vault with an underlying asset ERC2612 typed permit. + /// @param assets The amount of assets to deposit. + /// @param receiver The receiver of the shares. + /// @param deadline The deadline of the permit. + /// @param v The v value of the permit. + /// @param r The r value of the permit. + /// @param s The s value of the permit. + /// @return The amount of shares minted. + function depositWithPermit( + uint256 assets, + address receiver, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) external returns (uint256); + + /// @notice Resets the allowance of an owner for the caller. + /// @param owner The owner of the allowance to renounce. + function renounceAllowance(address owner) external; + + /// @notice Returns the address of the associated Hub. + function hub() external view returns (address); + + /// @notice Returns the identifier of the associated asset. + function assetId() external view returns (uint256); + + /// @notice Returns the maximum allowed spoke cap. + function MAX_ALLOWED_SPOKE_CAP() external view returns (uint40); + + /// @notice Returns the nonce key for the share token permit EIP-712 typed signatures. + /// @dev Share token permits nonces are always set at this specific key namespace. + /// Once the 2 ^ 64 - 1 nonces are used, the nonce at this namespace will overflow and reset to 0; unexpired permits can be replayed then. + function PERMIT_NONCE_KEY() external pure returns (uint192); + + /// @notice Returns the type hash for the deposit intent. + function DEPOSIT_TYPEHASH() external pure returns (bytes32); + + /// @notice Returns the type hash for the mint intent. + function MINT_TYPEHASH() external pure returns (bytes32); + + /// @notice Returns the type hash for the withdraw intent. + function WITHDRAW_TYPEHASH() external pure returns (bytes32); + + /// @notice Returns the type hash for the redeem intent. + function REDEEM_TYPEHASH() external pure returns (bytes32); + + /// @notice Returns the type hash for the share token permit intent. + function PERMIT_TYPEHASH() external pure returns (bytes32); +} diff --git a/src/utils/NoncesKeyed.sol b/src/utils/NoncesKeyed.sol index efd8afe51..6ddc04e8e 100644 --- a/src/utils/NoncesKeyed.sol +++ b/src/utils/NoncesKeyed.sol @@ -5,7 +5,7 @@ pragma solidity ^0.8.20; import {INoncesKeyed} from 'src/interfaces/INoncesKeyed.sol'; /// @notice Provides tracking nonces for addresses. Supports key-ed nonces, where nonces will only increment for each key. -/// @author Modified from OpenZeppelin https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v5.2.0/contracts/utils/NoncesKeyed.sol +/// @author Modified from OpenZeppelin https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v5.4.0/contracts/utils/NoncesKeyed.sol /// @dev Follows the https://eips.ethereum.org/EIPS/eip-4337#semi-abstracted-nonce-support[ERC-4337's semi-abstracted nonce system]. contract NoncesKeyed is INoncesKeyed { mapping(address owner => mapping(uint192 key => uint64 nonce)) private _nonces; @@ -16,7 +16,7 @@ contract NoncesKeyed is INoncesKeyed { } /// @inheritdoc INoncesKeyed - function nonces(address owner, uint192 key) external view returns (uint256) { + function nonces(address owner, uint192 key) public view returns (uint256) { return _pack(key, _nonces[owner][key]); } diff --git a/tests/Base.t.sol b/tests/Base.t.sol index 424bc3683..59233c2a6 100644 --- a/tests/Base.t.sol +++ b/tests/Base.t.sol @@ -12,11 +12,13 @@ import {console2 as console} from 'forge-std/console2.sol'; // dependencies import {AggregatorV3Interface} from 'src/dependencies/chainlink/AggregatorV3Interface.sol'; import {TransparentUpgradeableProxy, ITransparentUpgradeableProxy} from 'src/dependencies/openzeppelin/TransparentUpgradeableProxy.sol'; +import {ProxyAdmin} from 'src/dependencies/openzeppelin/ProxyAdmin.sol'; import {IERC20Metadata} from 'src/dependencies/openzeppelin/IERC20Metadata.sol'; import {SafeCast} from 'src/dependencies/openzeppelin/SafeCast.sol'; import {IERC20Errors} from 'src/dependencies/openzeppelin/IERC20Errors.sol'; import {IERC20} from 'src/dependencies/openzeppelin/IERC20.sol'; import {IERC5267} from 'src/dependencies/openzeppelin/IERC5267.sol'; +import {IERC4626} from 'src/dependencies/openzeppelin/IERC4626.sol'; import {AccessManager} from 'src/dependencies/openzeppelin/AccessManager.sol'; import {IAccessManager} from 'src/dependencies/openzeppelin/IAccessManager.sol'; import {IAccessManaged} from 'src/dependencies/openzeppelin/IAccessManaged.sol'; @@ -59,6 +61,9 @@ import {ReserveFlags, ReserveFlagsMap} from 'src/spoke/libraries/ReserveFlagsMap import {LiquidationLogic} from 'src/spoke/libraries/LiquidationLogic.sol'; import {KeyValueList} from 'src/spoke/libraries/KeyValueList.sol'; +import {VaultSpoke, IVaultSpoke} from 'src/spoke/VaultSpoke.sol'; +import {VaultSpokeInstance} from 'src/spoke/instances/VaultSpokeInstance.sol'; + // position manager import {GatewayBase, IGatewayBase} from 'src/position-manager/GatewayBase.sol'; import {NativeTokenGateway, INativeTokenGateway} from 'src/position-manager/NativeTokenGateway.sol'; @@ -94,6 +99,8 @@ abstract contract Base is Test { 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103; bytes32 internal constant IMPLEMENTATION_SLOT = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; + bytes32 internal constant INITIALIZABLE_SLOT = + 0xf0c57e16840df040f15088dc2f81fe391c3923bec73e23a9662efc9c229c6a00; uint256 internal constant MAX_SUPPLY_AMOUNT = 1e30; uint256 internal constant MIN_TOKEN_DECIMALS_SUPPORTED = 6; @@ -138,10 +145,19 @@ abstract contract Base is Test { AssetInterestRateStrategy internal irStrategy; IAccessManager internal accessManager; - address internal alice = makeAddr('alice'); - address internal bob = makeAddr('bob'); - address internal carol = makeAddr('carol'); - address internal derl = makeAddr('derl'); + string internal constant ALICE = 'alice'; + string internal constant BOB = 'bob'; + string internal constant CAROL = 'carol'; + string internal constant DERL = 'derl'; + + address internal alice = makeAddr(ALICE); + uint256 internal alicePk = makeKey(ALICE); + address internal bob = makeAddr(BOB); + uint256 internal bobPk = makeKey(BOB); + address internal carol = makeAddr(CAROL); + uint256 internal carolPk = makeKey(CAROL); + address internal derl = makeAddr(DERL); + uint256 internal derlPk = makeKey(DERL); address internal ADMIN = makeAddr('ADMIN'); address internal HUB_ADMIN = makeAddr('HUB_ADMIN'); @@ -271,6 +287,11 @@ abstract contract Base is Test { return address(uint160(uint256(slotData))); } + function _getProxyInitializedVersion(address proxy) internal view returns (uint64) { + bytes32 slotData = vm.load(proxy, INITIALIZABLE_SLOT); + return uint64(uint256(slotData) & ((1 << 64) - 1)); + } + function deployFixtures() internal virtual { vm.startPrank(ADMIN); accessManager = IAccessManager(address(new AccessManagerEnumerable(ADMIN))); @@ -1192,7 +1213,7 @@ abstract contract Base is Test { return spokeInfo[spoke].usdz.reserveId; } - function _updateSpokePaused( + function updateSpokePaused( IHub hub, uint256 assetId, address spoke, @@ -1220,6 +1241,20 @@ abstract contract Base is Test { assertEq(hub.getSpokeConfig(assetId, spoke), spokeConfig); } + function updateAddCap( + IHub hub, + uint256 assetId, + address spoke, + uint40 newAddCap + ) internal pausePrank { + IHub.SpokeConfig memory spokeConfig = hub.getSpokeConfig(assetId, spoke); + spokeConfig.addCap = newAddCap; + vm.prank(HUB_ADMIN); + hub.updateSpokeConfig(assetId, spoke, spokeConfig); + + assertEq(hub.getSpokeConfig(assetId, spoke), spokeConfig); + } + function updateDrawCap( IHub hub, uint256 assetId, @@ -2245,6 +2280,51 @@ abstract contract Base is Test { return (spoke, oracle); } + function _deployVaultSpoke( + IHub hub, + uint256 assetId, + string memory shareName, + string memory shareSymbol, + address proxyAdminOwner + ) internal pausePrank returns (IVaultSpoke) { + address vaultSpokeImpl = address(new VaultSpokeInstance(address(hub), assetId)); + IVaultSpoke vaultSpoke = IVaultSpoke( + _proxify( + makeAddr('deployer'), + vaultSpokeImpl, + proxyAdminOwner, + abi.encodeCall(VaultSpokeInstance.initialize, (shareName, shareSymbol)) + ) + ); + return vaultSpoke; + } + + function _registerVaultSpoke(IHub hub, uint256 assetId, IVaultSpoke vaultSpoke) internal { + return + _registerVaultSpoke( + hub, + assetId, + vaultSpoke, + IHub.SpokeConfig({ + addCap: type(uint40).max, + drawCap: 0, + riskPremiumThreshold: 0, + active: true, + paused: false + }) + ); + } + + function _registerVaultSpoke( + IHub hub, + uint256 assetId, + IVaultSpoke vaultSpoke, + IHub.SpokeConfig memory config + ) internal pausePrank { + vm.prank(ADMIN); + hub.addSpoke(assetId, address(vaultSpoke), config); + } + function _getDefaultReserveConfig( uint24 collateralRisk ) internal pure returns (ISpoke.ReserveConfig memory) { @@ -2809,6 +2889,11 @@ abstract contract Base is Test { return _packNonce(key, nonce); } + function _getRandomNonceAtKey(uint192 key) internal returns (uint256) { + uint64 nonce = _randomNonce(); + return _packNonce(key, nonce); + } + function _assertNonceIncrement( INoncesKeyed verifier, address who, @@ -2820,6 +2905,16 @@ abstract contract Base is Test { assertEq(verifier.nonces(who, nonceKey), _packNonce(nonceKey, nonce)); } + function _assertEntityHasNoBalanceOrAllowance( + IERC20 underlying, + address entity, + address user + ) internal { + assertEq(underlying.balanceOf(entity), 0); + assertEq(underlying.allowance({owner: user, spender: entity}), 0); + assertEq(underlying.allowance({owner: entity, spender: vm.randomAddress()}), 0); + } + /// @dev Pack key and nonce into a keyNonce function _packNonce(uint192 key, uint64 nonce) internal pure returns (uint256) { return (uint256(key) << 64) | nonce; @@ -2868,4 +2963,14 @@ abstract contract Base is Test { hub.getAsset(assetId).realizedFees + _calcUnrealizedFees(hub, assetId); } + + function _sign(uint256 pk, bytes32 digest) internal pure returns (bytes memory) { + (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, digest); + return abi.encodePacked(r, s, v); + } + + function makeKey(string memory name) internal returns (uint256) { + (, uint256 key) = makeAddrAndKey(name); + return key; + } } diff --git a/tests/Utils.sol b/tests/Utils.sol index d02fb097c..a7f0cd852 100644 --- a/tests/Utils.sol +++ b/tests/Utils.sol @@ -3,11 +3,14 @@ pragma solidity ^0.8.0; import {Vm} from 'forge-std/Vm.sol'; -import {IERC20} from 'src/dependencies/openzeppelin/IERC20.sol'; +import {SafeERC20, IERC20} from 'src/dependencies/openzeppelin/SafeERC20.sol'; import {IHub, IHubBase} from 'src/hub/interfaces/IHub.sol'; import {ISpokeBase, ISpoke} from 'src/spoke/interfaces/ISpoke.sol'; +import {IVaultSpoke} from 'src/spoke/interfaces/IVaultSpoke.sol'; library Utils { + using SafeERC20 for *; + Vm internal constant vm = Vm(address(uint160(uint256(keccak256('hevm cheat code'))))); // hub @@ -215,10 +218,13 @@ library Utils { _approve(IERC20(hub.getAsset(assetId).underlying), owner, caller, amount); } + function approve(IVaultSpoke vault, address owner, uint256 amount) internal { + _approve(IERC20(vault.asset()), owner, address(vault), amount); + } + function _approve(IERC20 underlying, address owner, address spender, uint256 amount) private { vm.startPrank(owner); - underlying.approve(spender, 0); - underlying.approve(spender, amount); + underlying.forceApprove(spender, amount); vm.stopPrank(); } diff --git a/tests/gas/Spoke.Operations.gas.t.sol b/tests/gas/Spoke.Operations.gas.t.sol index aa6d2b8d9..6fc6b9fb4 100644 --- a/tests/gas/Spoke.Operations.gas.t.sol +++ b/tests/gas/Spoke.Operations.gas.t.sol @@ -227,7 +227,6 @@ contract SpokeOperations_Gas_Tests is SpokeBase { // supplyWithPermit (dai) tokenList.dai.approve(address(spoke), 0); - (, uint256 bobPk) = makeAddrAndKey('bob'); EIP712Types.Permit memory permit = EIP712Types.Permit({ owner: bob, spender: address(spoke), @@ -267,7 +266,6 @@ contract SpokeOperations_Gas_Tests is SpokeBase { // supplyWithPermitAndEnableCollateral (wbtc) calls = new bytes[](3); tokenList.wbtc.approve(address(spoke), 0); - (, bobPk) = makeAddrAndKey('bob'); permit = EIP712Types.Permit({ owner: bob, spender: address(spoke), diff --git a/tests/gas/VaultSpoke.Operations.gas.t.sol b/tests/gas/VaultSpoke.Operations.gas.t.sol new file mode 100644 index 000000000..89573a759 --- /dev/null +++ b/tests/gas/VaultSpoke.Operations.gas.t.sol @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import 'tests/unit/VaultSpoke/VaultSpoke.Base.t.sol'; + +/// forge-config: default.isolate = true +contract VaultSpokeOperations_Gas_Tests is VaultSpokeBaseTest { + string internal constant NAMESPACE = 'VaultSpoke.Operations'; + IVaultSpoke internal vault; + + function setUp() public virtual override { + super.setUp(); + vault = daiVault; + Utils.approve(vault, alice, 2100e18); + vm.prank(alice); + vault.deposit(100e18, alice); + } + + function test_deposit() public { + vm.prank(alice); + vault.deposit(1000e18, alice); + vm.snapshotGasLastCall(NAMESPACE, 'deposit'); + } + + function test_mint() public { + uint256 shares = vault.previewMint(1000e18); + vm.prank(alice); + vault.mint(shares, alice); + vm.snapshotGasLastCall(NAMESPACE, 'mint'); + } + + function test_withdraw() public { + vm.startPrank(alice); + vault.deposit(1000e18, alice); + vault.withdraw(500e18, alice, alice); + vm.snapshotGasLastCall(NAMESPACE, 'withdraw: self, partial'); + + uint256 balance = vault.maxWithdraw(alice); + vault.withdraw(balance, alice, alice); + vm.snapshotGasLastCall(NAMESPACE, 'withdraw: self, full'); + + vault.deposit(1000e18, alice); + vault.approve(bob, 1000e18); + vm.stopPrank(); + + vm.startPrank(bob); + vault.withdraw(500e18, bob, alice); + vm.snapshotGasLastCall(NAMESPACE, 'withdraw: on behalf, partial'); + + balance = vault.maxWithdraw(alice); + vault.withdraw(balance, bob, alice); + vm.snapshotGasLastCall(NAMESPACE, 'withdraw: on behalf, full'); + vm.stopPrank(); + } + + function test_redeem() public { + vm.startPrank(alice); + vault.deposit(1000e18, alice); + uint256 shares = vault.balanceOf(alice); + vault.redeem(shares / 2, alice, alice); + vm.snapshotGasLastCall(NAMESPACE, 'redeem: self, partial'); + + shares = vault.maxRedeem(alice); + vault.redeem(shares, alice, alice); + vm.snapshotGasLastCall(NAMESPACE, 'redeem: self, full'); + + vault.deposit(1000e18, alice); + vault.approve(bob, 1000e18); + vm.stopPrank(); + + vm.startPrank(bob); + shares = vault.balanceOf(alice); + vault.redeem(shares / 2, bob, alice); + vm.snapshotGasLastCall(NAMESPACE, 'redeem: on behalf, partial'); + + shares = vault.maxRedeem(alice); + vault.redeem(shares, bob, alice); + vm.snapshotGasLastCall(NAMESPACE, 'redeem: on behalf, full'); + vm.stopPrank(); + } + + function test_depositWithSig() public { + EIP712Types.VaultDeposit memory p = _depositData(vault, alice, _warpBeforeRandomDeadline()); + p.nonce = _burnRandomNoncesAtKey(vault, p.depositor); + bytes memory signature = _sign(alicePk, _getTypedDataHash(vault, p)); + Utils.approve(vault, alice, p.assets); + + vm.prank(vm.randomAddress()); + vault.depositWithSig(p, signature); + vm.snapshotGasLastCall(NAMESPACE, 'depositWithSig'); + } + + function test_mintWithSig() public { + EIP712Types.VaultMint memory p = _mintData(vault, alice, _warpBeforeRandomDeadline()); + p.nonce = _burnRandomNoncesAtKey(vault, p.depositor); + bytes memory signature = _sign(alicePk, _getTypedDataHash(vault, p)); + Utils.approve(vault, alice, p.shares); + + vm.prank(vm.randomAddress()); + vault.mintWithSig(p, signature); + vm.snapshotGasLastCall(NAMESPACE, 'mintWithSig'); + } + + function test_withdrawWithSig() public { + EIP712Types.VaultWithdraw memory p = _withdrawData(vault, alice, _warpBeforeRandomDeadline()); + p.nonce = _burnRandomNoncesAtKey(vault, p.owner); + bytes memory signature = _sign(alicePk, _getTypedDataHash(vault, p)); + Utils.approve(vault, alice, p.assets); + vm.prank(alice); + vault.deposit(p.assets, alice); + + vm.prank(vm.randomAddress()); + vault.withdrawWithSig(p, signature); + vm.snapshotGasLastCall(NAMESPACE, 'withdrawWithSig'); + } + + function test_redeemWithSig() public { + EIP712Types.VaultRedeem memory p = _redeemData(vault, alice, _warpBeforeRandomDeadline()); + p.nonce = _burnRandomNoncesAtKey(vault, p.owner); + bytes memory signature = _sign(alicePk, _getTypedDataHash(vault, p)); + Utils.approve(vault, alice, p.shares); + vm.prank(alice); + vault.mint(p.shares, alice); + + vm.prank(vm.randomAddress()); + vault.redeemWithSig(p, signature); + vm.snapshotGasLastCall(NAMESPACE, 'redeemWithSig'); + } + + function test_permit() public { + EIP712Types.Permit memory p = _permitData(vault, alice, _warpBeforeRandomDeadline()); + p.nonce = _burnRandomNoncesAtKey(vault, p.owner, vault.PERMIT_NONCE_KEY()); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(alicePk, _getTypedDataHash(vault, p)); + + vm.expectEmit(address(vault)); + emit IERC20.Approval(p.owner, p.spender, p.value); + vm.prank(vm.randomAddress()); + vault.permit(p.owner, p.spender, p.value, p.deadline, v, r, s); + vm.snapshotGasLastCall(NAMESPACE, 'permit'); + + assertEq(vault.allowance(p.owner, p.spender), p.value); + } +} diff --git a/tests/mocks/JsonBindings.sol b/tests/mocks/JsonBindings.sol index fad61c114..dba43e856 100644 --- a/tests/mocks/JsonBindings.sol +++ b/tests/mocks/JsonBindings.sol @@ -1,4 +1,5 @@ // Automatically generated by forge bind-json. + pragma solidity >=0.6.2 <0.9.0; pragma experimental ABIEncoderV2; @@ -56,6 +57,14 @@ library JsonBindings { string constant schema_UpdateUserRiskPremium = "UpdateUserRiskPremium(address spoke,address user,uint256 nonce,uint256 deadline)"; // prettier-ignore string constant schema_UpdateUserDynamicConfig = "UpdateUserDynamicConfig(address spoke,address user,uint256 nonce,uint256 deadline)"; + // prettier-ignore + string constant schema_VaultDeposit = "VaultDeposit(address depositor,uint256 assets,address receiver,uint256 nonce,uint256 deadline)"; + // prettier-ignore + string constant schema_VaultMint = "VaultMint(address depositor,uint256 shares,address receiver,uint256 nonce,uint256 deadline)"; + // prettier-ignore + string constant schema_VaultWithdraw = "VaultWithdraw(address owner,uint256 assets,address receiver,uint256 nonce,uint256 deadline)"; + // prettier-ignore + string constant schema_VaultRedeem = "VaultRedeem(address owner,uint256 shares,address receiver,uint256 nonce,uint256 deadline)"; function serialize( EIP712Types.SetUserPositionManager memory value @@ -396,4 +405,147 @@ library JsonBindings { (EIP712Types.UpdateUserDynamicConfig[]) ); } + + function serialize(EIP712Types.VaultDeposit memory value) internal pure returns (string memory) { + return vm.serializeJsonType(schema_VaultDeposit, abi.encode(value)); + } + + function serialize( + EIP712Types.VaultDeposit memory value, + string memory objectKey, + string memory valueKey + ) internal returns (string memory) { + return vm.serializeJsonType(objectKey, valueKey, schema_VaultDeposit, abi.encode(value)); + } + + function deserializeVaultDeposit( + string memory json + ) public pure returns (EIP712Types.VaultDeposit memory) { + return abi.decode(vm.parseJsonType(json, schema_VaultDeposit), (EIP712Types.VaultDeposit)); + } + + function deserializeVaultDeposit( + string memory json, + string memory path + ) public pure returns (EIP712Types.VaultDeposit memory) { + return + abi.decode(vm.parseJsonType(json, path, schema_VaultDeposit), (EIP712Types.VaultDeposit)); + } + + function deserializeVaultDepositArray( + string memory json, + string memory path + ) public pure returns (EIP712Types.VaultDeposit[] memory) { + return + abi.decode( + vm.parseJsonTypeArray(json, path, schema_VaultDeposit), + (EIP712Types.VaultDeposit[]) + ); + } + + function serialize(EIP712Types.VaultMint memory value) internal pure returns (string memory) { + return vm.serializeJsonType(schema_VaultMint, abi.encode(value)); + } + + function serialize( + EIP712Types.VaultMint memory value, + string memory objectKey, + string memory valueKey + ) internal returns (string memory) { + return vm.serializeJsonType(objectKey, valueKey, schema_VaultMint, abi.encode(value)); + } + + function deserializeVaultMint( + string memory json + ) public pure returns (EIP712Types.VaultMint memory) { + return abi.decode(vm.parseJsonType(json, schema_VaultMint), (EIP712Types.VaultMint)); + } + + function deserializeVaultMint( + string memory json, + string memory path + ) public pure returns (EIP712Types.VaultMint memory) { + return abi.decode(vm.parseJsonType(json, path, schema_VaultMint), (EIP712Types.VaultMint)); + } + + function deserializeVaultMintArray( + string memory json, + string memory path + ) public pure returns (EIP712Types.VaultMint[] memory) { + return + abi.decode(vm.parseJsonTypeArray(json, path, schema_VaultMint), (EIP712Types.VaultMint[])); + } + + function serialize(EIP712Types.VaultWithdraw memory value) internal pure returns (string memory) { + return vm.serializeJsonType(schema_VaultWithdraw, abi.encode(value)); + } + + function serialize( + EIP712Types.VaultWithdraw memory value, + string memory objectKey, + string memory valueKey + ) internal returns (string memory) { + return vm.serializeJsonType(objectKey, valueKey, schema_VaultWithdraw, abi.encode(value)); + } + + function deserializeVaultWithdraw( + string memory json + ) public pure returns (EIP712Types.VaultWithdraw memory) { + return abi.decode(vm.parseJsonType(json, schema_VaultWithdraw), (EIP712Types.VaultWithdraw)); + } + + function deserializeVaultWithdraw( + string memory json, + string memory path + ) public pure returns (EIP712Types.VaultWithdraw memory) { + return + abi.decode(vm.parseJsonType(json, path, schema_VaultWithdraw), (EIP712Types.VaultWithdraw)); + } + + function deserializeVaultWithdrawArray( + string memory json, + string memory path + ) public pure returns (EIP712Types.VaultWithdraw[] memory) { + return + abi.decode( + vm.parseJsonTypeArray(json, path, schema_VaultWithdraw), + (EIP712Types.VaultWithdraw[]) + ); + } + + function serialize(EIP712Types.VaultRedeem memory value) internal pure returns (string memory) { + return vm.serializeJsonType(schema_VaultRedeem, abi.encode(value)); + } + + function serialize( + EIP712Types.VaultRedeem memory value, + string memory objectKey, + string memory valueKey + ) internal returns (string memory) { + return vm.serializeJsonType(objectKey, valueKey, schema_VaultRedeem, abi.encode(value)); + } + + function deserializeVaultRedeem( + string memory json + ) public pure returns (EIP712Types.VaultRedeem memory) { + return abi.decode(vm.parseJsonType(json, schema_VaultRedeem), (EIP712Types.VaultRedeem)); + } + + function deserializeVaultRedeem( + string memory json, + string memory path + ) public pure returns (EIP712Types.VaultRedeem memory) { + return abi.decode(vm.parseJsonType(json, path, schema_VaultRedeem), (EIP712Types.VaultRedeem)); + } + + function deserializeVaultRedeemArray( + string memory json, + string memory path + ) public pure returns (EIP712Types.VaultRedeem[] memory) { + return + abi.decode( + vm.parseJsonTypeArray(json, path, schema_VaultRedeem), + (EIP712Types.VaultRedeem[]) + ); + } } diff --git a/tests/mocks/MockVaultSpokeInstance.sol b/tests/mocks/MockVaultSpokeInstance.sol new file mode 100644 index 000000000..226424793 --- /dev/null +++ b/tests/mocks/MockVaultSpokeInstance.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import {VaultSpoke} from 'src/spoke/VaultSpoke.sol'; + +contract MockVaultSpokeInstance is VaultSpoke { + bool public constant IS_TEST = true; + + uint64 public immutable SPOKE_REVISION; + + /** + * @dev Constructor. + * @dev It sets the vault spoke revision and disables the initializers. + * @param spokeRevision_ The revision of the vault spoke contract. + * @param hub_ The address of the hub. + * @param assetId_ The ID of the asset. + */ + constructor(uint64 spokeRevision_, address hub_, uint256 assetId_) VaultSpoke(hub_, assetId_) { + SPOKE_REVISION = spokeRevision_; + _disableInitializers(); + } + + /// @inheritdoc VaultSpoke + function initialize( + string memory shareName, + string memory shareSymbol + ) external override reinitializer(SPOKE_REVISION) { + __VaultSpoke_init(shareName, shareSymbol); + } +} diff --git a/tests/unit/Hub/Hub.Add.t.sol b/tests/unit/Hub/Hub.Add.t.sol index 0a462710b..3c23d68d3 100644 --- a/tests/unit/Hub/Hub.Add.t.sol +++ b/tests/unit/Hub/Hub.Add.t.sol @@ -66,7 +66,7 @@ contract HubAddTest is HubBase { } function test_add_revertsWith_SpokePaused() public { - _updateSpokePaused(hub1, daiAssetId, address(spoke1), true); + updateSpokePaused(hub1, daiAssetId, address(spoke1), true); vm.startPrank(address(spoke1)); tokenList.dai.transferFrom(alice, address(hub1), 100e18); diff --git a/tests/unit/Hub/Hub.Draw.t.sol b/tests/unit/Hub/Hub.Draw.t.sol index 3771bef6f..3e8bfc0bc 100644 --- a/tests/unit/Hub/Hub.Draw.t.sol +++ b/tests/unit/Hub/Hub.Draw.t.sol @@ -158,7 +158,7 @@ contract HubDrawTest is HubBase { } function test_draw_revertsWith_SpokePaused() public { - _updateSpokePaused(hub1, daiAssetId, address(spoke1), true); + updateSpokePaused(hub1, daiAssetId, address(spoke1), true); vm.expectRevert(IHub.SpokePaused.selector); vm.prank(address(spoke1)); hub1.draw(daiAssetId, 100e18, alice); diff --git a/tests/unit/Hub/Hub.EliminateDeficit.t.sol b/tests/unit/Hub/Hub.EliminateDeficit.t.sol index dfb253b6a..341535563 100644 --- a/tests/unit/Hub/Hub.EliminateDeficit.t.sol +++ b/tests/unit/Hub/Hub.EliminateDeficit.t.sol @@ -61,7 +61,7 @@ contract HubEliminateDeficitTest is HubBase { Utils.add(hub1, _assetId, _callerSpoke, _deficitAmountRay.fromRayUp() + 1, alice); updateSpokeActive(hub1, _assetId, _callerSpoke, true); - _updateSpokePaused(hub1, _assetId, _callerSpoke, true); + updateSpokePaused(hub1, _assetId, _callerSpoke, true); vm.prank(_callerSpoke); hub1.eliminateDeficit(_assetId, _deficitAmountRay.fromRayUp(), _coveredSpoke); diff --git a/tests/unit/Hub/Hub.RefreshPremium.t.sol b/tests/unit/Hub/Hub.RefreshPremium.t.sol index 1b9405b21..a506908c7 100644 --- a/tests/unit/Hub/Hub.RefreshPremium.t.sol +++ b/tests/unit/Hub/Hub.RefreshPremium.t.sol @@ -167,7 +167,7 @@ contract HubRefreshPremiumTest is HubBase { /// @dev paused but active spokes are allowed to refresh premium function test_refreshPremium_pausedSpokesAllowed() public { updateSpokeActive(hub1, daiAssetId, address(spoke1), true); - _updateSpokePaused(hub1, daiAssetId, address(spoke1), true); + updateSpokePaused(hub1, daiAssetId, address(spoke1), true); vm.expectEmit(address(hub1)); emit IHubBase.RefreshPremium(daiAssetId, address(spoke1), ZERO_PREMIUM_DELTA); diff --git a/tests/unit/Hub/Hub.Remove.t.sol b/tests/unit/Hub/Hub.Remove.t.sol index 328c76f46..ba9990148 100644 --- a/tests/unit/Hub/Hub.Remove.t.sol +++ b/tests/unit/Hub/Hub.Remove.t.sol @@ -508,7 +508,7 @@ contract HubRemoveTest is HubBase { } function test_remove_revertsWith_SpokePaused() public { - _updateSpokePaused(hub1, daiAssetId, address(spoke1), true); + updateSpokePaused(hub1, daiAssetId, address(spoke1), true); vm.expectRevert(IHub.SpokePaused.selector); vm.prank(address(spoke1)); hub1.remove(daiAssetId, 100e18, alice); diff --git a/tests/unit/Hub/Hub.Restore.t.sol b/tests/unit/Hub/Hub.Restore.t.sol index 996c0a64e..05092bf02 100644 --- a/tests/unit/Hub/Hub.Restore.t.sol +++ b/tests/unit/Hub/Hub.Restore.t.sol @@ -139,7 +139,7 @@ contract HubRestoreTest is HubBase { } function test_restore_revertsWith_SpokePaused() public { - _updateSpokePaused(hub1, daiAssetId, address(spoke1), true); + updateSpokePaused(hub1, daiAssetId, address(spoke1), true); IHubBase.PremiumDelta memory premiumDelta = _getExpectedPremiumDelta( spoke1, diff --git a/tests/unit/MathUtils.t.sol b/tests/unit/MathUtils.t.sol index f80662d67..a9db54c7e 100644 --- a/tests/unit/MathUtils.t.sol +++ b/tests/unit/MathUtils.t.sol @@ -111,6 +111,10 @@ contract MathUtilsTest is Base { MathUtils.add(UINT256_MAX, 1); } + function test_zeroFloorSub(uint256 a, uint256 b) public pure { + assertEq(MathUtils.zeroFloorSub(a, b), a < b ? 0 : a - b); + } + function test_uncheckedAdd(uint256 a, uint256 b) public pure { uint256 result = MathUtils.uncheckedAdd(a, b); assertEq(result, b <= UINT256_MAX - a ? a + b : a - (UINT256_MAX - b) - 1); diff --git a/tests/unit/Spoke/Spoke.SetUserPositionManagerWithSig.t.sol b/tests/unit/Spoke/Spoke.SetUserPositionManagerWithSig.t.sol index 3ff896f34..f58bdc184 100644 --- a/tests/unit/Spoke/Spoke.SetUserPositionManagerWithSig.t.sol +++ b/tests/unit/Spoke/Spoke.SetUserPositionManagerWithSig.t.sol @@ -82,7 +82,6 @@ contract SpokeSetUserPositionManagerWithSigTest is SpokeBase { function test_setUserPositionManagerWithSig_revertsWith_InvalidSignature_dueTo_ExpiredDeadline() public { - (, uint256 alicePk) = makeAddrAndKey('alice'); uint256 deadline = _warpAfterRandomDeadline(); EIP712Types.SetUserPositionManager memory params = _setUserPositionManagerData(alice, deadline); @@ -189,7 +188,6 @@ contract SpokeSetUserPositionManagerWithSigTest is SpokeBase { function test_setUserPositionManagerWithSig_ERC1271_revertsWith_InvalidSignature_dueTo_ExpiredDeadline() public { - (, uint256 alicePk) = makeAddrAndKey('alice'); MockERC1271Wallet smartWallet = new MockERC1271Wallet(alice); EIP712Types.SetUserPositionManager memory params = _setUserPositionManagerData( address(smartWallet), @@ -218,7 +216,6 @@ contract SpokeSetUserPositionManagerWithSigTest is SpokeBase { function test_setUserPositionManagerWithSig_ERC1271_revertsWith_InvalidSignature_dueTo_InvalidHash() public { - (, uint256 alicePk) = makeAddrAndKey('alice'); address maliciousManager = makeAddr('maliciousManager'); MockERC1271Wallet smartWallet = new MockERC1271Wallet(alice); vm.prank(SPOKE_ADMIN); @@ -258,7 +255,6 @@ contract SpokeSetUserPositionManagerWithSigTest is SpokeBase { function test_setUserPositionManagerWithSig_ERC1271_revertsWith_InvalidAccountNonce( bytes32 ) public { - (, uint256 alicePk) = makeAddrAndKey('alice'); MockERC1271Wallet smartWallet = new MockERC1271Wallet(alice); uint256 deadline = _warpBeforeRandomDeadline(); diff --git a/tests/unit/Spoke/Spoke.Upgradeable.t.sol b/tests/unit/Spoke/Spoke.Upgradeable.t.sol index 65fc3c3c8..d612e6279 100644 --- a/tests/unit/Spoke/Spoke.Upgradeable.t.sol +++ b/tests/unit/Spoke/Spoke.Upgradeable.t.sol @@ -5,9 +5,6 @@ pragma solidity ^0.8.0; import 'tests/unit/Spoke/SpokeBase.t.sol'; contract SpokeUpgradeableTest is SpokeBase { - bytes32 internal constant INITIALIZABLE_STORAGE = - 0xf0c57e16840df040f15088dc2f81fe391c3923bec73e23a9662efc9c229c6a00; - address internal proxyAdminOwner = makeAddr('proxyAdminOwner'); address internal oracle = makeAddr('AaveOracle'); @@ -200,11 +197,6 @@ contract SpokeUpgradeableTest is SpokeBase { ); } - function _getProxyInitializedVersion(address proxy) internal view returns (uint64) { - bytes32 slotData = vm.load(proxy, INITIALIZABLE_STORAGE); - return uint64(uint256(slotData) & ((1 << 64) - 1)); - } - function _getInitializeCalldata(address manager) internal pure returns (bytes memory) { return abi.encodeCall(Spoke.initialize, manager); } diff --git a/tests/unit/VaultSpoke/VaultSpoke.Base.t.sol b/tests/unit/VaultSpoke/VaultSpoke.Base.t.sol new file mode 100644 index 000000000..33229f0f9 --- /dev/null +++ b/tests/unit/VaultSpoke/VaultSpoke.Base.t.sol @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import 'tests/Base.t.sol'; + +contract VaultSpokeBaseTest is Base { + IVaultSpoke public daiVault; + string public constant SHARE_NAME = 'Core Hub DAI'; + string public constant SHARE_SYMBOL = 'chDAI'; + + function setUp() public virtual override { + deployFixtures(); + initEnvironment(); + daiVault = _deployVaultSpoke(hub1, daiAssetId, SHARE_NAME, SHARE_SYMBOL, ADMIN); + _registerVaultSpoke(hub1, daiAssetId, daiVault); + } + + function _depositData( + IVaultSpoke vault, + address who, + uint256 deadline + ) internal returns (EIP712Types.VaultDeposit memory) { + return + EIP712Types.VaultDeposit({ + depositor: who, + assets: vm.randomUint(1, MAX_SUPPLY_AMOUNT), + receiver: vm.randomAddress(), + nonce: vault.nonces(who, _randomNonceKey()), + deadline: deadline + }); + } + + function _mintData( + IVaultSpoke vault, + address who, + uint256 deadline + ) internal returns (EIP712Types.VaultMint memory) { + return + EIP712Types.VaultMint({ + depositor: who, + shares: vm.randomUint(1, MAX_SUPPLY_AMOUNT), + receiver: vm.randomAddress(), + nonce: vault.nonces(who, _randomNonceKey()), + deadline: deadline + }); + } + + function _withdrawData( + IVaultSpoke vault, + address who, + uint256 deadline + ) internal returns (EIP712Types.VaultWithdraw memory) { + return + EIP712Types.VaultWithdraw({ + owner: who, + assets: vm.randomUint(1, MAX_SUPPLY_AMOUNT), + receiver: vm.randomAddress(), + nonce: vault.nonces(who, _randomNonceKey()), + deadline: deadline + }); + } + + function _redeemData( + IVaultSpoke vault, + address who, + uint256 deadline + ) internal returns (EIP712Types.VaultRedeem memory) { + return + EIP712Types.VaultRedeem({ + owner: who, + shares: vm.randomUint(1, MAX_SUPPLY_AMOUNT), + receiver: vm.randomAddress(), + nonce: vault.nonces(who, _randomNonceKey()), + deadline: deadline + }); + } + + function _permitData( + IVaultSpoke vault, + address who, + uint256 deadline + ) internal returns (EIP712Types.Permit memory) { + return + EIP712Types.Permit({ + owner: who, + spender: address(vault), + value: vm.randomUint(1, MAX_SUPPLY_AMOUNT), + deadline: deadline, + nonce: vault.nonces(who, vault.PERMIT_NONCE_KEY()) // can only use permit nonce key namespace + }); + } + + function _getTypedDataHash( + IVaultSpoke vault, + EIP712Types.VaultDeposit memory params + ) internal view returns (bytes32) { + return _typedDataHash(vault, vm.eip712HashStruct('VaultDeposit', abi.encode(params))); + } + + function _getTypedDataHash( + IVaultSpoke vault, + EIP712Types.VaultMint memory params + ) internal view returns (bytes32) { + return _typedDataHash(vault, vm.eip712HashStruct('VaultMint', abi.encode(params))); + } + + function _getTypedDataHash( + IVaultSpoke vault, + EIP712Types.VaultWithdraw memory params + ) internal view returns (bytes32) { + return _typedDataHash(vault, vm.eip712HashStruct('VaultWithdraw', abi.encode(params))); + } + + function _getTypedDataHash( + IVaultSpoke vault, + EIP712Types.VaultRedeem memory params + ) internal view returns (bytes32) { + return _typedDataHash(vault, vm.eip712HashStruct('VaultRedeem', abi.encode(params))); + } + + function _getTypedDataHash( + IVaultSpoke vault, + EIP712Types.Permit memory params + ) internal view returns (bytes32) { + return _typedDataHash(vault, vm.eip712HashStruct('Permit', abi.encode(params))); + } + + function _typedDataHash(IVaultSpoke vault, bytes32 typeHash) internal view returns (bytes32) { + return keccak256(abi.encodePacked('\x19\x01', vault.DOMAIN_SEPARATOR(), typeHash)); + } + + function _assertVaultHasNoBalanceOrAllowance(IVaultSpoke vault, address who) internal { + _assertEntityHasNoBalanceOrAllowance({ + underlying: IERC20(vault.asset()), + entity: address(vault), + user: who + }); + } +} + +contract VaultSpokeInitTest is VaultSpokeBaseTest { + function test_constructor_reverts_when_invalid_setup() public { + uint256 invalidAssetId = vm.randomUint(hub1.getAssetCount(), UINT256_MAX); + vm.expectRevert(); + new VaultSpokeInstance(address(hub1), invalidAssetId); + + vm.expectRevert(); + new VaultSpokeInstance(address(0), vm.randomUint()); + } + + function test_constructor_asset_correctly_set() public { + uint256 assetId = vm.randomUint(0, hub1.getAssetCount() - 1); + VaultSpokeInstance instance = new VaultSpokeInstance(address(hub1), assetId); + assertEq(instance.asset(), hub1.getAsset(assetId).underlying); + assertEq(instance.decimals(), hub1.getAsset(assetId).decimals); + } + + function test_setUp() public { + assertEq(daiVault.name(), SHARE_NAME); + assertEq(daiVault.symbol(), SHARE_SYMBOL); + assertEq(daiVault.decimals(), tokenList.dai.decimals()); + + assertEq(daiVault.asset(), address(tokenList.dai)); + assertEq(daiVault.assetId(), daiAssetId); + assertEq(daiVault.hub(), address(hub1)); + + assertEq(daiVault.PERMIT_NONCE_KEY(), 0); + + assertEq(daiVault.totalAssets(), 0); + assertEq(daiVault.totalSupply(), 0); + assertEq(daiVault.balanceOf(vm.randomAddress()), 0); + } + + function test_configuration() public view { + ProxyAdmin proxyAdmin = ProxyAdmin(_getProxyAdminAddress(address(daiVault))); + assertEq(proxyAdmin.owner(), ADMIN); + assertEq(proxyAdmin.UPGRADE_INTERFACE_VERSION(), '5.0.0'); + assertEq( + _getProxyInitializedVersion(address(daiVault)), + VaultSpokeInstance(address(daiVault)).SPOKE_REVISION() + ); + address implementation = _getImplementationAddress(address(daiVault)); + assertEq(_getProxyInitializedVersion(implementation), type(uint64).max); + } +} diff --git a/tests/unit/VaultSpoke/VaultSpoke.Constants.t.sol b/tests/unit/VaultSpoke/VaultSpoke.Constants.t.sol new file mode 100644 index 000000000..ced201ba9 --- /dev/null +++ b/tests/unit/VaultSpoke/VaultSpoke.Constants.t.sol @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import 'tests/unit/VaultSpoke/VaultSpoke.Base.t.sol'; + +contract VaultSpokeConstantsTest is VaultSpokeBaseTest { + function test_eip712Domain() public { + IVaultSpoke instance = _deployVaultSpoke(hub1, daiAssetId, 'Core Hub DAI', 'chDAI', ADMIN); + ( + bytes1 fields, + string memory name, + string memory version, + uint256 chainId, + address verifyingContract, + bytes32 salt, + uint256[] memory extensions + ) = IERC5267(address(instance)).eip712Domain(); + + assertEq(fields, bytes1(0x0f)); + assertEq(name, 'Vault Spoke'); + assertEq(version, '1'); + assertEq(chainId, block.chainid); + assertEq(verifyingContract, address(instance)); + assertEq(salt, bytes32(0)); + assertEq(extensions.length, 0); + } + + function test_DOMAIN_SEPARATOR() public { + IVaultSpoke instance = _deployVaultSpoke(hub1, daiAssetId, 'Core Hub DAI', 'chDAI', ADMIN); + bytes32 expectedDomainSeparator = keccak256( + abi.encode( + keccak256( + 'EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)' + ), + keccak256('Vault Spoke'), + keccak256('1'), + block.chainid, + address(instance) + ) + ); + assertEq(instance.DOMAIN_SEPARATOR(), expectedDomainSeparator); + } + + function test_deposit_typeHash() public view { + assertEq(daiVault.DEPOSIT_TYPEHASH(), vm.eip712HashType('VaultDeposit')); + assertEq( + daiVault.DEPOSIT_TYPEHASH(), + keccak256( + 'VaultDeposit(address depositor,uint256 assets,address receiver,uint256 nonce,uint256 deadline)' + ) + ); + } + + function test_mint_typeHash() public view { + assertEq(daiVault.MINT_TYPEHASH(), vm.eip712HashType('VaultMint')); + assertEq( + daiVault.MINT_TYPEHASH(), + keccak256( + 'VaultMint(address depositor,uint256 shares,address receiver,uint256 nonce,uint256 deadline)' + ) + ); + } + + function test_withdraw_typeHash() public view { + assertEq(daiVault.WITHDRAW_TYPEHASH(), vm.eip712HashType('VaultWithdraw')); + assertEq( + daiVault.WITHDRAW_TYPEHASH(), + keccak256( + 'VaultWithdraw(address owner,uint256 assets,address receiver,uint256 nonce,uint256 deadline)' + ) + ); + } + + function test_redeem_typeHash() public view { + assertEq(daiVault.REDEEM_TYPEHASH(), vm.eip712HashType('VaultRedeem')); + assertEq( + daiVault.REDEEM_TYPEHASH(), + keccak256( + 'VaultRedeem(address owner,uint256 shares,address receiver,uint256 nonce,uint256 deadline)' + ) + ); + } + + function test_permit_typeHash() public view { + assertEq(daiVault.PERMIT_TYPEHASH(), vm.eip712HashType('Permit')); + assertEq( + daiVault.PERMIT_TYPEHASH(), + keccak256( + 'Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)' + ) + ); + } +} diff --git a/tests/unit/VaultSpoke/VaultSpoke.DepositWithPermit.t.sol b/tests/unit/VaultSpoke/VaultSpoke.DepositWithPermit.t.sol new file mode 100644 index 000000000..ae19591f6 --- /dev/null +++ b/tests/unit/VaultSpoke/VaultSpoke.DepositWithPermit.t.sol @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import 'tests/unit/VaultSpoke/VaultSpoke.Base.t.sol'; + +contract VaultSpokeDepositWithPermitTest is VaultSpokeBaseTest { + IVaultSpoke public vault; + TestnetERC20 public asset; + + function setUp() public virtual override { + super.setUp(); + vault = daiVault; + asset = TestnetERC20(vault.asset()); + } + + function test_depositWithPermit_forwards_correct_call() public { + address owner = vm.randomAddress(); + address receiver = vm.randomAddress(); + address spender = address(vault); + uint256 maxAssets = vault.maxDeposit(receiver); + uint256 value = maxAssets == type(uint256).max + ? vm.randomUint(1, MAX_SUPPLY_AMOUNT) + : vm.randomUint(1, maxAssets); + uint256 deadline = vm.randomUint(); + uint8 v = uint8(vm.randomUint()); + bytes32 r = bytes32(vm.randomUint()); + bytes32 s = bytes32(vm.randomUint()); + + asset.mint(owner, value); + vm.prank(owner); + asset.approve(address(vault), value); + + vm.expectCall( + address(asset), + abi.encodeCall(TestnetERC20.permit, (owner, spender, value, deadline, v, r, s)), + 1 + ); + vm.prank(owner); + vault.depositWithPermit(value, receiver, deadline, v, r, s); + } + + function test_depositWithPermit_ignores_permit_reverts() public { + vm.mockCallRevert(address(asset), TestnetERC20.permit.selector, vm.randomBytes(64)); + + address owner = vm.randomAddress(); + address receiver = vm.randomAddress(); + uint256 maxAssets = vault.maxDeposit(receiver); + uint256 assets = maxAssets == type(uint256).max + ? vm.randomUint(1, MAX_SUPPLY_AMOUNT) + : vm.randomUint(1, maxAssets); + + asset.mint(owner, assets); + vm.prank(owner); + asset.approve(address(vault), assets); + + vm.prank(owner); + vault.depositWithPermit( + assets, + receiver, + vm.randomUint(), + uint8(vm.randomUint()), + bytes32(vm.randomUint()), + bytes32(vm.randomUint()) + ); + } + + function test_depositWithPermit() public { + (address user, uint256 userPk) = makeAddrAndKey('user'); + address receiver = vm.randomAddress(); + uint256 maxAssets = vault.maxDeposit(receiver); + uint256 assets = maxAssets == type(uint256).max + ? vm.randomUint(1, MAX_SUPPLY_AMOUNT) + : vm.randomUint(1, maxAssets); + + asset.mint(user, assets); + assertEq(asset.allowance(user, address(vault)), 0); + + EIP712Types.Permit memory params = EIP712Types.Permit({ + owner: user, + spender: address(vault), + value: assets, + deadline: _warpBeforeRandomDeadline(), + nonce: asset.nonces(user) + }); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(userPk, _getTypedDataHash(asset, params)); + + uint256 expectedShares = IHub(vault.hub()).previewAddByAssets(vault.assetId(), assets); + + vm.expectEmit(address(asset)); + emit IERC20.Approval(user, address(vault), params.value); + + vm.expectEmit(address(vault)); + emit IERC4626.Deposit(user, receiver, assets, expectedShares); + + vm.prank(user); + uint256 shares = vault.depositWithPermit(assets, receiver, params.deadline, v, r, s); + + assertEq(shares, expectedShares); + assertEq(asset.allowance(user, address(vault)), 0); + assertEq(vault.balanceOf(receiver), expectedShares); + } + + function test_depositWithPermit_works_with_existing_allowance() public { + address user = vm.randomAddress(); + address receiver = vm.randomAddress(); + uint256 maxAssets = vault.maxDeposit(receiver); + uint256 assets = maxAssets == type(uint256).max + ? vm.randomUint(1, MAX_SUPPLY_AMOUNT) + : vm.randomUint(1, maxAssets); + + asset.mint(user, assets); + + vm.prank(user); + asset.approve(address(vault), assets); + + vm.prank(user); + uint256 shares = vault.depositWithPermit( + assets, + receiver, + vm.randomUint(), + uint8(vm.randomUint()), + bytes32(vm.randomUint()), + bytes32(vm.randomUint()) + ); + + uint256 expectedShares = IHub(vault.hub()).previewAddByAssets(vault.assetId(), assets); + assertEq(shares, expectedShares); + assertEq(vault.balanceOf(receiver), expectedShares); + assertEq(asset.allowance(user, address(vault)), 0); + } +} diff --git a/tests/unit/VaultSpoke/VaultSpoke.ERC4626Compliance.t.sol b/tests/unit/VaultSpoke/VaultSpoke.ERC4626Compliance.t.sol new file mode 100644 index 000000000..314be5b6d --- /dev/null +++ b/tests/unit/VaultSpoke/VaultSpoke.ERC4626Compliance.t.sol @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import 'tests/unit/VaultSpoke/VaultSpoke.Base.t.sol'; +import {ERC4626Test} from 'lib/erc4626-tests/ERC4626.test.sol'; + +contract VaultSpokeERC4626ComplianceTest is VaultSpokeBaseTest, ERC4626Test { + function setUp() public override(VaultSpokeBaseTest, ERC4626Test) { + VaultSpokeBaseTest.setUp(); + updateLiquidityFee(IHub(daiVault.hub()), daiVault.assetId(), 0); + + _underlying_ = daiVault.asset(); + _vault_ = address(daiVault); + + _delta_ = 0; // maximum approximation error size to be passed to assertApproxEqAbs, 0 implies the vault follows the preferred rounding directions as per spec security considerations + _vaultMayBeEmpty = true; // fuzz inputs that empties the vault are considered; inflation protection is through virtual shares on hub + _unlimitedAmount = false; // fuzz inputs are restricted to the currently available amount from the caller + } + + function setUpYield(Init memory init) public override { + if (init.yield > 0) { + init.yield = bound(init.yield, 1, int(MAX_SUPPLY_AMOUNT)); + IHub hub = IHub(IVaultSpoke(_vault_).hub()); + uint256 assetId = IVaultSpoke(_vault_).assetId(); + uint256 gain = uint(init.yield); + + TestnetERC20(IVaultSpoke(_vault_).asset()).mint(address(hub), gain); + vm.startPrank(address(spoke2)); + hub.add(assetId, gain); + _mockInterestRateBps(100_00); // 100% interest rate + hub.draw(assetId, gain, address(spoke2)); + skip(365 days); + tokenList.dai.transfer(address(hub), gain); + hub.restore(assetId, gain, IHubBase.PremiumDelta(0, 0, 0)); + vm.stopPrank(); + } + } +} diff --git a/tests/unit/VaultSpoke/VaultSpoke.MaxGetters.t.sol b/tests/unit/VaultSpoke/VaultSpoke.MaxGetters.t.sol new file mode 100644 index 000000000..73a87051d --- /dev/null +++ b/tests/unit/VaultSpoke/VaultSpoke.MaxGetters.t.sol @@ -0,0 +1,202 @@ +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import 'tests/unit/VaultSpoke/VaultSpoke.Base.t.sol'; + +abstract contract VaultSpokeMaxGettersReturnZeroTest is VaultSpokeBaseTest { + IVaultSpoke public vault; + + function setUp() public virtual override { + super.setUp(); + vault = daiVault; + updateAddCap(IHub(vault.hub()), vault.assetId(), address(vault), 0); + } + + function _isVaultActiveOrNotPaused() internal view returns (bool) { + IHub.SpokeConfig memory config = IHub(vault.hub()).getSpokeConfig( + vault.assetId(), + address(vault) + ); + return config.active && !config.paused; + } + + modifier setUpPreconditions() { + if (_isVaultActiveOrNotPaused()) { + vm.expectCall( + vault.hub(), + abi.encodeCall(IHub.getSpokeConfig, (vault.assetId(), address(vault))), + 1 + ); + } else { + vm.etch(vault.hub(), new bytes(0)); + vm.expectRevert(); + } + _; + } + + function test_maxDeposit_returns_zero() public setUpPreconditions { + uint256 maxDeposit = vault.maxDeposit(vm.randomAddress()); + assertEq(maxDeposit, 0); + } + + function test_maxMint_returns_zero() public setUpPreconditions { + uint256 maxMint = vault.maxMint(vm.randomAddress()); + assertEq(maxMint, 0); + } + + function test_maxWithdraw_returns_zero() public setUpPreconditions { + uint256 maxWithdraw = vault.maxWithdraw(vm.randomAddress()); + assertEq(maxWithdraw, 0); + } + + function test_maxRedeem_returns_zero() public setUpPreconditions { + uint256 maxRedeem = vault.maxRedeem(vm.randomAddress()); + assertEq(maxRedeem, 0); + } +} + +contract VaultSpokeMaxGettersTest_Active_NotPaused is VaultSpokeMaxGettersReturnZeroTest {} + +contract VaultSpokeMaxGettersTest_Active_Paused is VaultSpokeMaxGettersReturnZeroTest { + function setUp() public override { + super.setUp(); + updateSpokePaused(IHub(vault.hub()), vault.assetId(), address(vault), true); + } +} + +contract VaultSpokeMaxGettersTest_NotActive_NotPaused is VaultSpokeMaxGettersReturnZeroTest { + function setUp() public override { + super.setUp(); + updateSpokeActive(IHub(vault.hub()), vault.assetId(), address(vault), false); + } +} + +contract VaultSpokeMaxGettersTest_NotActive_Paused is VaultSpokeMaxGettersReturnZeroTest { + function setUp() public override { + super.setUp(); + updateSpokeActive(IHub(vault.hub()), vault.assetId(), address(vault), false); + updateSpokePaused(IHub(vault.hub()), vault.assetId(), address(vault), true); + } +} + +// @dev vault spoke is active & not paused from here onwards + +contract VaultSpokeDepositMintGettersMaxCapTest is VaultSpokeBaseTest { + IVaultSpoke public vault; + + function setUp() public virtual override { + super.setUp(); + vault = daiVault; + + assertEq( + IHub(vault.hub()).getSpokeConfig(vault.assetId(), address(vault)).addCap, + IHub(vault.hub()).MAX_ALLOWED_SPOKE_CAP() + ); + } + + function maxSuppliableAssets() public view returns (uint256) { + IHub hub = IHub(vault.hub()); + uint256 addCap = hub.getSpokeConfig(vault.assetId(), address(vault)).addCap; + if (addCap == hub.MAX_ALLOWED_SPOKE_CAP()) { + return type(uint256).max; + } + uint256 addCapWithDecimals = addCap * MathUtils.uncheckedExp(10, vault.decimals()); + uint256 balance = hub.getSpokeAddedAssets(vault.assetId(), address(vault)); + return addCapWithDecimals > balance ? addCapWithDecimals - balance : 0; + } + + function test_maxDeposit() public { + uint256 maxDeposit = vault.maxDeposit(vm.randomAddress()); + assertEq(maxDeposit, maxSuppliableAssets()); + } + + function test_maxMint() public { + uint256 maxMint = vault.maxMint(vm.randomAddress()); + uint256 maxAssets = maxSuppliableAssets(); + uint256 maxSuppliableShares = maxAssets == type(uint256).max + ? type(uint256).max + : IHub(vault.hub()).previewAddByAssets(vault.assetId(), maxAssets); + assertEq(maxMint, maxSuppliableShares); + } +} + +contract VaultSpokeDepositMintGettersEmptyLiquidityVariableCapTest is + VaultSpokeDepositMintGettersMaxCapTest +{ + using SafeCast for uint256; + + function setUp() public virtual override { + super.setUp(); + updateAddCap( + IHub(vault.hub()), + vault.assetId(), + address(vault), + vm.randomUint(1, vault.MAX_ALLOWED_SPOKE_CAP()).toUint40() + ); + } +} + +contract VaultSpokeDepositMintGettersNonEmptyLiquidityVariableCapTest is + VaultSpokeDepositMintGettersEmptyLiquidityVariableCapTest +{ + using MathUtils for uint256; + + function setUp() public virtual override { + super.setUp(); + uint256 amount = vm.randomUint(1, maxSuppliableAssets().min(MAX_SUPPLY_AMOUNT)); + deal(vault.asset(), address(this), amount); + Utils.approve(vault, address(this), amount); + vault.deposit(amount, address(this)); + } +} + +contract VaultSpokeDepositMintGettersNonEmptyLiquidityMaxCapTest is + VaultSpokeDepositMintGettersNonEmptyLiquidityVariableCapTest +{ + function setUp() public virtual override { + super.setUp(); + updateAddCap(IHub(vault.hub()), vault.assetId(), address(vault), vault.MAX_ALLOWED_SPOKE_CAP()); + } +} + +contract VaultSpokeWithdrawRedeemGettersReturnMaxTest is + VaultSpokeDepositMintGettersNonEmptyLiquidityMaxCapTest +{ + using MathUtils for uint256; + + function setUp() public virtual override { + super.setUp(); + deal(vault.asset(), address(this), MAX_SUPPLY_AMOUNT); + Utils.approve(vault, address(this), MAX_SUPPLY_AMOUNT); + } + + function availableAssets() public view returns (uint256) { + return IHub(vault.hub()).getAssetLiquidity(vault.assetId()); + } + + function availableShares() public view returns (uint256) { + return IHub(vault.hub()).previewAddByAssets(vault.assetId(), availableAssets()); + } + + function test_maxWithdraw() public { + vault.deposit(vm.randomUint(0, MAX_SUPPLY_AMOUNT), address(this)); + uint256 balanceAmount = IHub(vault.hub()).previewRemoveByShares( + vault.assetId(), + vault.balanceOf(address(this)) + ); + + uint256 maxWithdraw = vault.maxWithdraw(address(this)); + assertEq(maxWithdraw, availableAssets().min(balanceAmount)); + } + + function test_maxRedeem() public { + vault.deposit(vm.randomUint(0, MAX_SUPPLY_AMOUNT), address(this)); + uint256 maxRedeemableShares = IHub(vault.hub()).previewRemoveByAssets( + vault.assetId(), + availableAssets().min(vault.balanceOf(address(this))) + ); + + uint256 maxRedeem = vault.maxRedeem(address(this)); + assertEq(maxRedeem, maxRedeemableShares); + } +} diff --git a/tests/unit/VaultSpoke/VaultSpoke.Permit.t.sol b/tests/unit/VaultSpoke/VaultSpoke.Permit.t.sol new file mode 100644 index 000000000..aad530ed4 --- /dev/null +++ b/tests/unit/VaultSpoke/VaultSpoke.Permit.t.sol @@ -0,0 +1,136 @@ +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import 'tests/unit/VaultSpoke/VaultSpoke.Base.t.sol'; + +contract VaultSpokePermitTest is VaultSpokeBaseTest { + IVaultSpoke public vault; + + function setUp() public virtual override { + super.setUp(); + vault = daiVault; + } + + function test_nonces_uses_permit_nonce_key_namespace(bytes32) public { + vm.setArbitraryStorage(address(vault)); + uint192 key = vault.PERMIT_NONCE_KEY(); + + address user = vm.randomAddress(); + assertEq(vault.nonces(user), vault.nonces(user, key)); + + uint256 keyNonce = vault.nonces(user); + (uint192 unpackedKey, ) = _unpackNonce(keyNonce); + assertEq(unpackedKey, key); + } + + function test_permit() public { + EIP712Types.Permit memory p = _permitData(vault, alice, _warpBeforeRandomDeadline()); + p.nonce = _burnRandomNoncesAtKey(vault, p.owner, vault.PERMIT_NONCE_KEY()); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(alicePk, _getTypedDataHash(vault, p)); + + vm.expectEmit(address(vault)); + emit IERC20.Approval(p.owner, p.spender, p.value); + vm.prank(vm.randomAddress()); + vault.permit(p.owner, p.spender, p.value, p.deadline, v, r, s); + + assertEq(vault.allowance(p.owner, p.spender), p.value); + } + + function test_permit_revertsWith_InvalidSignature_dueTo_ExpiredDeadline() public { + EIP712Types.Permit memory p = _permitData(vault, alice, _warpAfterRandomDeadline()); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(alicePk, _getTypedDataHash(vault, p)); + + vm.expectRevert(IVaultSpoke.InvalidSignature.selector); + vm.prank(vm.randomAddress()); + vault.permit(p.owner, p.spender, p.value, p.deadline, v, r, s); + } + + function test_permit_revertsWith_InvalidSignature_dueTo_InvalidSigner() public { + (address randomUser, uint256 randomUserPk) = makeAddrAndKey(string(vm.randomBytes(32))); + address owner = vm.randomAddress(); + while (owner == randomUser) owner = vm.randomAddress(); + + EIP712Types.Permit memory p = _permitData(vault, owner, _warpBeforeRandomDeadline()); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(randomUserPk, _getTypedDataHash(vault, p)); + + vm.expectRevert(IVaultSpoke.InvalidSignature.selector); + vm.prank(vm.randomAddress()); + vault.permit(p.owner, p.spender, p.value, p.deadline, v, r, s); + } + + function test_permit_revertsWith_InvalidAddress_dueTo_ZeroAddressOwner() public { + EIP712Types.Permit memory p = _permitData(vault, address(0), _warpBeforeRandomDeadline()); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(alicePk, _getTypedDataHash(vault, p)); + + vm.expectRevert(IVaultSpoke.InvalidSignature.selector); + vm.prank(vm.randomAddress()); + vault.permit(p.owner, p.spender, p.value, p.deadline, v, r, s); + } + + // @dev Any nonce used at arbitrary namespace will revert with InvalidSignature. + function test_permit_revertsWith_InvalidSignature_dueTo_invalid_nonce_at_arbitrary_namespace( + bytes32 + ) public { + EIP712Types.Permit memory p = _permitData(vault, alice, _warpBeforeRandomDeadline()); + uint192 nonceKey = _randomNonceKey(); + while (nonceKey == vault.PERMIT_NONCE_KEY()) nonceKey = _randomNonceKey(); + + p.nonce = _getRandomNonceAtKey(nonceKey); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(alicePk, _getTypedDataHash(vault, p)); + + vm.expectRevert(IVaultSpoke.InvalidSignature.selector); + vm.prank(vm.randomAddress()); + vault.permit(p.owner, p.spender, p.value, p.deadline, v, r, s); + } + + function test_permit_revertsWith_InvalidSignature_dueTo_invalid_nonce_at_permit_key_namespace( + bytes32 + ) public { + EIP712Types.Permit memory p = _permitData(vault, alice, _warpBeforeRandomDeadline()); + uint192 nonceKey = vault.PERMIT_NONCE_KEY(); + + p.nonce = _getRandomInvalidNonceAtKey(vault, p.owner, nonceKey); + + (uint8 v, bytes32 r, bytes32 s) = vm.sign(alicePk, _getTypedDataHash(vault, p)); + + vm.expectRevert(IVaultSpoke.InvalidSignature.selector); + vm.prank(vm.randomAddress()); + vault.permit(p.owner, p.spender, p.value, p.deadline, v, r, s); + } + + function test_renounceAllowance() public { + address owner = vm.randomAddress(); + address spender = vm.randomAddress(); + uint256 amount = vm.randomUint(); + + vm.prank(owner); + vault.approve(spender, amount); + + assertEq(vault.allowance(owner, spender), amount); + + vm.expectEmit(address(vault)); + emit IERC20.Approval(owner, spender, 0); + vm.prank(spender); + vault.renounceAllowance(owner); + + assertEq(vault.allowance(owner, spender), 0); + } + + function test_renounceAllowance_noop() public { + address owner = vm.randomAddress(); + address spender = vm.randomAddress(); + + vm.prank(owner); + vault.approve(spender, 0); + + vm.record(); + vm.recordLogs(); + vm.prank(spender); + vault.renounceAllowance(owner); + + assertEq(vm.getRecordedLogs().length, 0); + (, bytes32[] memory writeSlots) = vm.accesses(address(vault)); + assertEq(writeSlots.length, 0); + } +} diff --git a/tests/unit/VaultSpoke/VaultSpoke.Reverts.InsufficientAllowance.t.sol b/tests/unit/VaultSpoke/VaultSpoke.Reverts.InsufficientAllowance.t.sol new file mode 100644 index 000000000..e01bd81a9 --- /dev/null +++ b/tests/unit/VaultSpoke/VaultSpoke.Reverts.InsufficientAllowance.t.sol @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import 'tests/unit/VaultSpoke/VaultSpoke.Base.t.sol'; + +contract VaultSpokeWithSigInsufficientAllowanceTest is VaultSpokeBaseTest { + IVaultSpoke public vault; + + function setUp() public virtual override { + super.setUp(); + vault = daiVault; + } + + function test_deposit_revertsWith_ERC20InsufficientAllowance() public { + (uint256 amount, uint256 allowance) = _setArbitraryAllowance(); + vm.expectRevert( + abi.encodeWithSelector( + IERC20Errors.ERC20InsufficientAllowance.selector, + address(vault), + allowance, + amount + ) + ); + vm.prank(alice); + vault.deposit(amount, alice); + } + + function test_mint_revertsWith_ERC20InsufficientAllowance() public { + (uint256 amount, uint256 allowance) = _setArbitraryAllowance(); + uint256 shares = vault.previewMint(amount); + + vm.expectRevert( + abi.encodeWithSelector( + IERC20Errors.ERC20InsufficientAllowance.selector, + address(vault), + allowance, + amount + ) + ); + vm.prank(alice); + vault.mint(shares, alice); + } + + function test_depositWithSig_revertsWith_ERC20InsufficientAllowance() public { + (uint256 amount, uint256 allowance) = _setArbitraryAllowance(); + uint256 deadline = _warpBeforeRandomDeadline(); + + EIP712Types.VaultDeposit memory p = _depositData(vault, alice, deadline); + p.assets = amount; + p.nonce = _burnRandomNoncesAtKey(vault, p.depositor); + bytes memory signature = _sign(alicePk, _getTypedDataHash(vault, p)); + + vm.expectRevert( + abi.encodeWithSelector( + IERC20Errors.ERC20InsufficientAllowance.selector, + address(vault), + allowance, + p.assets + ) + ); + vm.prank(vm.randomAddress()); + vault.depositWithSig(p, signature); + } + + function test_mintWithSig_revertsWith_ERC20InsufficientAllowance() public { + (uint256 amount, uint256 allowance) = _setArbitraryAllowance(); + uint256 deadline = _warpBeforeRandomDeadline(); + + EIP712Types.VaultMint memory p = _mintData(vault, alice, deadline); + p.shares = vault.previewMint(amount); + p.nonce = _burnRandomNoncesAtKey(vault, p.depositor); + bytes memory signature = _sign(alicePk, _getTypedDataHash(vault, p)); + + uint256 neededAssets = IHub(vault.hub()).previewAddByShares(vault.assetId(), p.shares); + + vm.expectRevert( + abi.encodeWithSelector( + IERC20Errors.ERC20InsufficientAllowance.selector, + address(vault), + allowance, + neededAssets + ) + ); + vm.prank(vm.randomAddress()); + vault.mintWithSig(p, signature); + } + + function _setArbitraryAllowance() internal returns (uint256, uint256) { + uint256 amount = vm.randomUint(1, MAX_SUPPLY_AMOUNT); + uint256 allowance = vm.randomUint(0, amount - 1); + Utils.approve(vault, alice, allowance); + + return (amount, allowance); + } +} diff --git a/tests/unit/VaultSpoke/VaultSpoke.Upgradeable.t.sol b/tests/unit/VaultSpoke/VaultSpoke.Upgradeable.t.sol new file mode 100644 index 000000000..c08b89a66 --- /dev/null +++ b/tests/unit/VaultSpoke/VaultSpoke.Upgradeable.t.sol @@ -0,0 +1,170 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import 'tests/unit/VaultSpoke/VaultSpoke.Base.t.sol'; +import {MockVaultSpokeInstance} from 'tests/mocks/MockVaultSpokeInstance.sol'; + +contract VaultSpokeUpgradeableTest is VaultSpokeBaseTest { + address internal proxyAdminOwner = makeAddr('proxyAdminOwner'); + + function test_implementation_constructor_fuzz(uint64 revision) public { + address vaultImplAddress = vm.computeCreateAddress(address(this), vm.getNonce(address(this))); + vm.expectEmit(vaultImplAddress); + emit Initializable.Initialized(type(uint64).max); + + VaultSpokeInstance vaultImpl = _deployMockVaultSpokeInstance(revision); + + assertEq(address(vaultImpl), vaultImplAddress); + assertEq(vaultImpl.SPOKE_REVISION(), revision); + assertEq(_getProxyInitializedVersion(vaultImplAddress), type(uint64).max); + + vm.expectRevert(Initializable.InvalidInitialization.selector); + vaultImpl.initialize(SHARE_NAME, SHARE_SYMBOL); + } + + function test_proxy_constructor_fuzz(uint64 revision) public { + revision = uint64(bound(revision, 1, type(uint64).max)); + + VaultSpokeInstance vaultImpl = _deployMockVaultSpokeInstance(revision); + address vaultProxyAddress = vm.computeCreateAddress(address(this), vm.getNonce(address(this))); + address proxyAdminAddress = vm.computeCreateAddress(vaultProxyAddress, 1); + + vm.expectEmit(vaultProxyAddress); + emit IERC1967.Upgraded(address(vaultImpl)); + vm.expectEmit(vaultProxyAddress); + emit Initializable.Initialized(revision); + vm.expectEmit(proxyAdminAddress); + emit Ownable.OwnershipTransferred(address(0), proxyAdminOwner); + vm.expectEmit(vaultProxyAddress); + emit IERC1967.AdminChanged(address(0), proxyAdminAddress); + IVaultSpoke vaultProxy = IVaultSpoke( + address( + new TransparentUpgradeableProxy( + address(vaultImpl), + proxyAdminOwner, + abi.encodeCall(VaultSpokeInstance.initialize, (SHARE_NAME, SHARE_SYMBOL)) + ) + ) + ); + + assertEq(address(vaultProxy), vaultProxyAddress); + assertEq(_getProxyAdminAddress(address(vaultProxy)), proxyAdminAddress); + assertEq(_getImplementationAddress(address(vaultProxy)), address(vaultImpl)); + + assertEq(_getProxyInitializedVersion(address(vaultProxy)), revision); + assertEq(vaultProxy.name(), SHARE_NAME); + assertEq(vaultProxy.symbol(), SHARE_SYMBOL); + } + + function test_proxy_reinitialization_fuzz(uint64 initialRevision) public { + initialRevision = uint64(bound(initialRevision, 1, type(uint64).max - 1)); + VaultSpokeInstance vaultImpl = _deployMockVaultSpokeInstance(initialRevision); + ITransparentUpgradeableProxy vaultProxy = ITransparentUpgradeableProxy( + address( + new TransparentUpgradeableProxy( + address(vaultImpl), + proxyAdminOwner, + abi.encodeCall(VaultSpokeInstance.initialize, (SHARE_NAME, SHARE_SYMBOL)) + ) + ) + ); + + string memory originalName = IVaultSpoke(address(vaultProxy)).name(); + + uint64 secondRevision = uint64(vm.randomUint(initialRevision + 1, type(uint64).max)); + VaultSpokeInstance vaultImpl2 = _deployMockVaultSpokeInstance(secondRevision); + + string memory newShareName = 'New Share Name'; + string memory newShareSymbol = 'New Share Symbol'; + vm.expectEmit(address(vaultProxy)); + emit Initializable.Initialized(secondRevision); + vm.recordLogs(); + vm.prank(_getProxyAdminAddress(address(vaultProxy))); + vaultProxy.upgradeToAndCall( + address(vaultImpl2), + _getInitializeCalldata(newShareName, newShareSymbol) + ); + + assertEq(IVaultSpoke(address(vaultProxy)).name(), newShareName); + assertEq(IVaultSpoke(address(vaultProxy)).symbol(), newShareSymbol); + assertNotEq(IVaultSpoke(address(vaultProxy)).name(), originalName); + } + + function test_proxy_constructor_revertsWith_InvalidInitialization_ZeroRevision() public { + VaultSpokeInstance vaultImpl = _deployMockVaultSpokeInstance(0); + + vm.expectRevert(Initializable.InvalidInitialization.selector); + new TransparentUpgradeableProxy( + address(vaultImpl), + proxyAdminOwner, + abi.encodeCall(VaultSpokeInstance.initialize, (SHARE_NAME, SHARE_SYMBOL)) + ); + } + + function test_proxy_constructor_fuzz_revertsWith_InvalidInitialization( + uint64 initialRevision + ) public { + initialRevision = uint64(bound(initialRevision, 1, type(uint64).max)); + + VaultSpokeInstance vaultImpl = _deployMockVaultSpokeInstance(initialRevision); + ITransparentUpgradeableProxy vaultProxy = ITransparentUpgradeableProxy( + address( + new TransparentUpgradeableProxy( + address(vaultImpl), + proxyAdminOwner, + _getInitializeCalldata(SHARE_NAME, SHARE_SYMBOL) + ) + ) + ); + + vm.expectRevert(Initializable.InvalidInitialization.selector); + vm.prank(_getProxyAdminAddress(address(vaultProxy))); + vaultProxy.upgradeToAndCall( + address(vaultImpl), + _getInitializeCalldata(SHARE_NAME, SHARE_SYMBOL) + ); + + uint64 secondRevision = uint64(vm.randomUint(0, initialRevision - 1)); + VaultSpokeInstance vaultImpl2 = _deployMockVaultSpokeInstance(secondRevision); + vm.expectRevert(Initializable.InvalidInitialization.selector); + vm.prank(_getProxyAdminAddress(address(vaultProxy))); + vaultProxy.upgradeToAndCall( + address(vaultImpl2), + _getInitializeCalldata(SHARE_NAME, SHARE_SYMBOL) + ); + } + + function test_proxy_reinitialization_revertsWith_CallerNotProxyAdmin() public { + VaultSpokeInstance vaultImpl = _deployMockVaultSpokeInstance(1); + ITransparentUpgradeableProxy vaultProxy = ITransparentUpgradeableProxy( + address( + new TransparentUpgradeableProxy( + address(vaultImpl), + proxyAdminOwner, + _getInitializeCalldata(SHARE_NAME, SHARE_SYMBOL) + ) + ) + ); + + VaultSpokeInstance vaultImpl2 = _deployMockVaultSpokeInstance(2); + vm.expectRevert(); + vm.prank(makeUser()); + vaultProxy.upgradeToAndCall( + address(vaultImpl2), + _getInitializeCalldata(SHARE_NAME, SHARE_SYMBOL) + ); + } + + function _getInitializeCalldata( + string memory shareName, + string memory shareSymbol + ) internal pure returns (bytes memory) { + return abi.encodeCall(VaultSpokeInstance.initialize, (shareName, shareSymbol)); + } + + function _deployMockVaultSpokeInstance(uint64 revision) internal returns (VaultSpokeInstance) { + return + VaultSpokeInstance(address(new MockVaultSpokeInstance(revision, address(hub1), daiAssetId))); + } +} diff --git a/tests/unit/VaultSpoke/VaultSpoke.WithSig.Reverts.InvalidSignature.t.sol b/tests/unit/VaultSpoke/VaultSpoke.WithSig.Reverts.InvalidSignature.t.sol new file mode 100644 index 000000000..910ca76e6 --- /dev/null +++ b/tests/unit/VaultSpoke/VaultSpoke.WithSig.Reverts.InvalidSignature.t.sol @@ -0,0 +1,162 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import 'tests/unit/VaultSpoke/VaultSpoke.Base.t.sol'; + +contract VaultSpokeWithSigInvalidSignatureTest is VaultSpokeBaseTest { + IVaultSpoke public vault; + + function setUp() public virtual override { + super.setUp(); + vault = daiVault; + } + + function test_depositWithSig_revertsWith_InvalidSignature_dueTo_ExpiredDeadline() public { + EIP712Types.VaultDeposit memory p = _depositData(vault, alice, _warpAfterRandomDeadline()); + bytes memory signature = _sign(alicePk, _getTypedDataHash(vault, p)); + + vm.expectRevert(IVaultSpoke.InvalidSignature.selector); + vm.prank(vm.randomAddress()); + vault.depositWithSig(p, signature); + } + + function test_mintWithSig_revertsWith_InvalidSignature_dueTo_ExpiredDeadline() public { + EIP712Types.VaultMint memory p = _mintData(vault, alice, _warpAfterRandomDeadline()); + bytes memory signature = _sign(alicePk, _getTypedDataHash(vault, p)); + + vm.expectRevert(IVaultSpoke.InvalidSignature.selector); + vm.prank(vm.randomAddress()); + vault.mintWithSig(p, signature); + } + + function test_withdrawWithSig_revertsWith_InvalidSignature_dueTo_ExpiredDeadline() public { + EIP712Types.VaultWithdraw memory p = _withdrawData(vault, alice, _warpAfterRandomDeadline()); + bytes memory signature = _sign(alicePk, _getTypedDataHash(vault, p)); + + vm.expectRevert(IVaultSpoke.InvalidSignature.selector); + vm.prank(vm.randomAddress()); + vault.withdrawWithSig(p, signature); + } + + function test_redeemWithSig_revertsWith_InvalidSignature_dueTo_ExpiredDeadline() public { + EIP712Types.VaultRedeem memory p = _redeemData(vault, alice, _warpAfterRandomDeadline()); + bytes memory signature = _sign(alicePk, _getTypedDataHash(vault, p)); + + vm.expectRevert(IVaultSpoke.InvalidSignature.selector); + vm.prank(vm.randomAddress()); + vault.redeemWithSig(p, signature); + } + + function test_depositWithSig_revertsWith_InvalidSignature_dueTo_InvalidSigner() public { + (address randomUser, uint256 randomUserPk) = makeAddrAndKey(string(vm.randomBytes(32))); + address depositor = vm.randomAddress(); + while (depositor == randomUser) depositor = vm.randomAddress(); + + EIP712Types.VaultDeposit memory p = _depositData(vault, depositor, _warpAfterRandomDeadline()); + bytes memory signature = _sign(randomUserPk, _getTypedDataHash(vault, p)); + + vm.expectRevert(IVaultSpoke.InvalidSignature.selector); + vm.prank(vm.randomAddress()); + vault.depositWithSig(p, signature); + } + + function test_mintWithSig_revertsWith_InvalidSignature_dueTo_InvalidSigner() public { + (address randomUser, uint256 randomUserPk) = makeAddrAndKey(string(vm.randomBytes(32))); + address depositor = vm.randomAddress(); + while (depositor == randomUser) depositor = vm.randomAddress(); + + EIP712Types.VaultMint memory p = _mintData(vault, depositor, _warpAfterRandomDeadline()); + bytes memory signature = _sign(randomUserPk, _getTypedDataHash(vault, p)); + + vm.expectRevert(IVaultSpoke.InvalidSignature.selector); + vm.prank(vm.randomAddress()); + vault.mintWithSig(p, signature); + } + + function test_withdrawWithSig_revertsWith_InvalidSignature_dueTo_InvalidSigner() public { + (address randomUser, uint256 randomUserPk) = makeAddrAndKey(string(vm.randomBytes(32))); + address owner = vm.randomAddress(); + while (owner == randomUser) owner = vm.randomAddress(); + + EIP712Types.VaultWithdraw memory p = _withdrawData(vault, owner, _warpAfterRandomDeadline()); + bytes memory signature = _sign(randomUserPk, _getTypedDataHash(vault, p)); + + vm.expectRevert(IVaultSpoke.InvalidSignature.selector); + vm.prank(vm.randomAddress()); + vault.withdrawWithSig(p, signature); + } + + function test_redeemWithSig_revertsWith_InvalidSignature_dueTo_InvalidSigner() public { + (address randomUser, uint256 randomUserPk) = makeAddrAndKey(string(vm.randomBytes(32))); + address owner = vm.randomAddress(); + while (owner == randomUser) owner = vm.randomAddress(); + + EIP712Types.VaultRedeem memory p = _redeemData(vault, owner, _warpAfterRandomDeadline()); + bytes memory signature = _sign(randomUserPk, _getTypedDataHash(vault, p)); + + vm.expectRevert(IVaultSpoke.InvalidSignature.selector); + vm.prank(vm.randomAddress()); + vault.redeemWithSig(p, signature); + } + + function test_depositWithSig_revertsWith_InvalidAccountNonce(bytes32) public { + EIP712Types.VaultDeposit memory p = _depositData(vault, alice, _warpBeforeRandomDeadline()); + uint192 nonceKey = _randomNonceKey(); + uint256 currentNonce = _burnRandomNoncesAtKey(vault, p.depositor, nonceKey); + p.nonce = _getRandomInvalidNonceAtKey(vault, p.depositor, nonceKey); + + bytes memory signature = _sign(alicePk, _getTypedDataHash(vault, p)); + + vm.expectRevert( + abi.encodeWithSelector(INoncesKeyed.InvalidAccountNonce.selector, p.depositor, currentNonce) + ); + vm.prank(vm.randomAddress()); + vault.depositWithSig(p, signature); + } + + function test_mintWithSig_revertsWith_InvalidAccountNonce(bytes32) public { + EIP712Types.VaultMint memory p = _mintData(vault, alice, _warpBeforeRandomDeadline()); + uint192 nonceKey = _randomNonceKey(); + uint256 currentNonce = _burnRandomNoncesAtKey(vault, p.depositor, nonceKey); + p.nonce = _getRandomInvalidNonceAtKey(vault, p.depositor, nonceKey); + + bytes memory signature = _sign(alicePk, _getTypedDataHash(vault, p)); + + vm.expectRevert( + abi.encodeWithSelector(INoncesKeyed.InvalidAccountNonce.selector, p.depositor, currentNonce) + ); + vm.prank(vm.randomAddress()); + vault.mintWithSig(p, signature); + } + + function test_withdrawWithSig_revertsWith_InvalidAccountNonce(bytes32) public { + EIP712Types.VaultWithdraw memory p = _withdrawData(vault, alice, _warpBeforeRandomDeadline()); + uint192 nonceKey = _randomNonceKey(); + uint256 currentNonce = _burnRandomNoncesAtKey(vault, p.owner, nonceKey); + p.nonce = _getRandomInvalidNonceAtKey(vault, p.owner, nonceKey); + + bytes memory signature = _sign(alicePk, _getTypedDataHash(vault, p)); + + vm.expectRevert( + abi.encodeWithSelector(INoncesKeyed.InvalidAccountNonce.selector, p.owner, currentNonce) + ); + vm.prank(vm.randomAddress()); + vault.withdrawWithSig(p, signature); + } + + function test_redeemWithSig_revertsWith_InvalidAccountNonce(bytes32) public { + EIP712Types.VaultRedeem memory p = _redeemData(vault, alice, _warpBeforeRandomDeadline()); + uint192 nonceKey = _randomNonceKey(); + uint256 currentNonce = _burnRandomNoncesAtKey(vault, p.owner, nonceKey); + p.nonce = _getRandomInvalidNonceAtKey(vault, p.owner, nonceKey); + + bytes memory signature = _sign(alicePk, _getTypedDataHash(vault, p)); + + vm.expectRevert( + abi.encodeWithSelector(INoncesKeyed.InvalidAccountNonce.selector, p.owner, currentNonce) + ); + vm.prank(vm.randomAddress()); + vault.redeemWithSig(p, signature); + } +} diff --git a/tests/unit/VaultSpoke/VaultSpoke.WithSig.t.sol b/tests/unit/VaultSpoke/VaultSpoke.WithSig.t.sol new file mode 100644 index 000000000..97e074414 --- /dev/null +++ b/tests/unit/VaultSpoke/VaultSpoke.WithSig.t.sol @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import 'tests/unit/VaultSpoke/VaultSpoke.Base.t.sol'; + +contract VaultSpokeWithSigTest is VaultSpokeBaseTest { + using SafeCast for *; + + IVaultSpoke public vault; + + function setUp() public virtual override { + super.setUp(); + vault = daiVault; + } + + function test_useNonce_monotonic(bytes32) public { + vm.setArbitraryStorage(address(vault)); + address user = vm.randomAddress(); + uint192 nonceKey = vm.randomUint(0, type(uint192).max).toUint192(); + + (, uint64 nonce) = _unpackNonce(vault.nonces(user, nonceKey)); + + vm.prank(user); + vault.useNonce(nonceKey); + + // prettier-ignore + unchecked { ++nonce; } + assertEq(vault.nonces(user, nonceKey), _packNonce(nonceKey, nonce)); + } + + function test_depositWithSig() public { + EIP712Types.VaultDeposit memory p = _depositData(vault, alice, _warpBeforeRandomDeadline()); + p.nonce = _burnRandomNoncesAtKey(vault, p.depositor); + bytes memory signature = _sign(alicePk, _getTypedDataHash(vault, p)); + Utils.approve(vault, alice, p.assets); + + uint256 shares = IHub(vault.hub()).previewAddByAssets(vault.assetId(), p.assets); + + vm.expectEmit(address(vault)); + emit IERC4626.Deposit(p.depositor, p.receiver, p.assets, shares); + + vm.prank(vm.randomAddress()); + uint256 returnShares = vault.depositWithSig(p, signature); + + assertEq(returnShares, shares); + _assertNonceIncrement(vault, alice, p.nonce); + _assertVaultHasNoBalanceOrAllowance(vault, alice); + } + + function test_mintWithSig() public { + EIP712Types.VaultMint memory p = _mintData(vault, alice, _warpBeforeRandomDeadline()); + p.nonce = _burnRandomNoncesAtKey(vault, p.depositor); + bytes memory signature = _sign(alicePk, _getTypedDataHash(vault, p)); + Utils.approve(vault, alice, p.shares); + + uint256 assets = IHub(vault.hub()).previewAddByShares(vault.assetId(), p.shares); + + vm.expectEmit(address(vault)); + emit IERC4626.Deposit(p.depositor, p.receiver, p.shares, assets); + + vm.prank(vm.randomAddress()); + uint256 returnAssets = vault.mintWithSig(p, signature); + + assertEq(returnAssets, assets); + _assertNonceIncrement(vault, alice, p.nonce); + _assertVaultHasNoBalanceOrAllowance(vault, alice); + } + + function test_withdrawWithSig() public { + EIP712Types.VaultWithdraw memory p = _withdrawData(vault, alice, _warpBeforeRandomDeadline()); + p.nonce = _burnRandomNoncesAtKey(vault, p.owner); + bytes memory signature = _sign(alicePk, _getTypedDataHash(vault, p)); + Utils.approve(vault, alice, p.assets); + vm.prank(alice); + vault.deposit(p.assets, alice); + + uint256 shares = IHub(vault.hub()).previewAddByAssets(vault.assetId(), p.assets); + + vm.expectEmit(address(vault)); + emit IERC4626.Withdraw(p.owner, p.receiver, p.owner, p.assets, shares); + + vm.prank(vm.randomAddress()); + uint256 returnShares = vault.withdrawWithSig(p, signature); + + assertEq(returnShares, shares); + _assertNonceIncrement(vault, alice, p.nonce); + _assertVaultHasNoBalanceOrAllowance(vault, alice); + } + + function test_redeemWithSig() public { + EIP712Types.VaultRedeem memory p = _redeemData(vault, alice, _warpBeforeRandomDeadline()); + p.nonce = _burnRandomNoncesAtKey(vault, p.owner); + bytes memory signature = _sign(alicePk, _getTypedDataHash(vault, p)); + Utils.approve(vault, alice, p.shares); + vm.prank(alice); + vault.mint(p.shares, alice); + + uint256 assets = IHub(vault.hub()).previewAddByShares(vault.assetId(), p.shares); + + vm.expectEmit(address(vault)); + emit IERC4626.Withdraw(p.owner, p.receiver, p.owner, p.shares, assets); + + vm.prank(vm.randomAddress()); + uint256 returnAssets = vault.redeemWithSig(p, signature); + + assertEq(returnAssets, assets); + _assertNonceIncrement(vault, alice, p.nonce); + _assertVaultHasNoBalanceOrAllowance(vault, alice); + } +} diff --git a/tests/unit/misc/EIP712Hash.t.sol b/tests/unit/misc/EIP712Hash.t.sol index 2a5b29148..4973f5949 100644 --- a/tests/unit/misc/EIP712Hash.t.sol +++ b/tests/unit/misc/EIP712Hash.t.sol @@ -4,12 +4,18 @@ pragma solidity ^0.8.0; import {Test} from 'forge-std/Test.sol'; -import {EIP712Hash, EIP712Types} from 'src/position-manager/libraries/EIP712Hash.sol'; +import {EIP712Hash, EIP712Types} from 'src/libraries/EIP712Hash.sol'; contract EIP712HashTest is Test { using EIP712Hash for *; function test_constants() public pure { + assertEq( + EIP712Hash.PERMIT_TYPEHASH, + keccak256( + 'Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)' + ) + ); assertEq( EIP712Hash.SUPPLY_TYPEHASH, keccak256( @@ -50,69 +56,51 @@ contract EIP712HashTest is Test { 'UpdateUserDynamicConfig(address spoke,address user,uint256 nonce,uint256 deadline)' ) ); - } - - function test_hash_supply_fuzz(EIP712Types.Supply calldata params) public pure { - bytes32 expectedHash = keccak256( - abi.encode( - EIP712Hash.SUPPLY_TYPEHASH, - params.spoke, - params.reserveId, - params.amount, - params.onBehalfOf, - params.nonce, - params.deadline + assertEq( + EIP712Hash.VAULT_DEPOSIT_TYPEHASH, + keccak256( + 'VaultDeposit(address depositor,uint256 assets,address receiver,uint256 nonce,uint256 deadline)' + ) + ); + assertEq( + EIP712Hash.VAULT_MINT_TYPEHASH, + keccak256( + 'VaultMint(address depositor,uint256 shares,address receiver,uint256 nonce,uint256 deadline)' ) ); + assertEq( + EIP712Hash.VAULT_WITHDRAW_TYPEHASH, + keccak256( + 'VaultWithdraw(address owner,uint256 assets,address receiver,uint256 nonce,uint256 deadline)' + ) + ); + assertEq( + EIP712Hash.VAULT_REDEEM_TYPEHASH, + keccak256( + 'VaultRedeem(address owner,uint256 shares,address receiver,uint256 nonce,uint256 deadline)' + ) + ); + } + + // @dev all struct params should be hashed & placed in the same order as the typehash + function test_hash_supply_fuzz(EIP712Types.Supply calldata params) public pure { + bytes32 expectedHash = keccak256(abi.encode(EIP712Hash.SUPPLY_TYPEHASH, params)); assertEq(params.hash(), expectedHash); } function test_hash_withdraw_fuzz(EIP712Types.Withdraw calldata params) public pure { - bytes32 expectedHash = keccak256( - abi.encode( - EIP712Hash.WITHDRAW_TYPEHASH, - params.spoke, - params.reserveId, - params.amount, - params.onBehalfOf, - params.nonce, - params.deadline - ) - ); - + bytes32 expectedHash = keccak256(abi.encode(EIP712Hash.WITHDRAW_TYPEHASH, params)); assertEq(params.hash(), expectedHash); } function test_hash_borrow_fuzz(EIP712Types.Borrow calldata params) public pure { - bytes32 expectedHash = keccak256( - abi.encode( - EIP712Hash.BORROW_TYPEHASH, - params.spoke, - params.reserveId, - params.amount, - params.onBehalfOf, - params.nonce, - params.deadline - ) - ); - + bytes32 expectedHash = keccak256(abi.encode(EIP712Hash.BORROW_TYPEHASH, params)); assertEq(params.hash(), expectedHash); } function test_hash_repay_fuzz(EIP712Types.Repay calldata params) public pure { - bytes32 expectedHash = keccak256( - abi.encode( - EIP712Hash.REPAY_TYPEHASH, - params.spoke, - params.reserveId, - params.amount, - params.onBehalfOf, - params.nonce, - params.deadline - ) - ); - + bytes32 expectedHash = keccak256(abi.encode(EIP712Hash.REPAY_TYPEHASH, params)); assertEq(params.hash(), expectedHash); } @@ -120,17 +108,8 @@ contract EIP712HashTest is Test { EIP712Types.SetUsingAsCollateral calldata params ) public pure { bytes32 expectedHash = keccak256( - abi.encode( - EIP712Hash.SET_USING_AS_COLLATERAL_TYPEHASH, - params.spoke, - params.reserveId, - params.useAsCollateral, - params.onBehalfOf, - params.nonce, - params.deadline - ) + abi.encode(EIP712Hash.SET_USING_AS_COLLATERAL_TYPEHASH, params) ); - assertEq(params.hash(), expectedHash); } @@ -138,15 +117,8 @@ contract EIP712HashTest is Test { EIP712Types.UpdateUserRiskPremium calldata params ) public pure { bytes32 expectedHash = keccak256( - abi.encode( - EIP712Hash.UPDATE_USER_RISK_PREMIUM_TYPEHASH, - params.spoke, - params.user, - params.nonce, - params.deadline - ) + abi.encode(EIP712Hash.UPDATE_USER_RISK_PREMIUM_TYPEHASH, params) ); - assertEq(params.hash(), expectedHash); } @@ -154,15 +126,28 @@ contract EIP712HashTest is Test { EIP712Types.UpdateUserDynamicConfig calldata params ) public pure { bytes32 expectedHash = keccak256( - abi.encode( - EIP712Hash.UPDATE_USER_DYNAMIC_CONFIG_TYPEHASH, - params.spoke, - params.user, - params.nonce, - params.deadline - ) + abi.encode(EIP712Hash.UPDATE_USER_DYNAMIC_CONFIG_TYPEHASH, params) ); + assertEq(params.hash(), expectedHash); + } + + function test_hash_vaultDeposit_fuzz(EIP712Types.VaultDeposit calldata params) public pure { + bytes32 expectedHash = keccak256(abi.encode(EIP712Hash.VAULT_DEPOSIT_TYPEHASH, params)); + assertEq(params.hash(), expectedHash); + } + + function test_hash_vaultMint_fuzz(EIP712Types.VaultMint calldata params) public pure { + bytes32 expectedHash = keccak256(abi.encode(EIP712Hash.VAULT_MINT_TYPEHASH, params)); + assertEq(params.hash(), expectedHash); + } + + function test_hash_vaultWithdraw_fuzz(EIP712Types.VaultWithdraw calldata params) public pure { + bytes32 expectedHash = keccak256(abi.encode(EIP712Hash.VAULT_WITHDRAW_TYPEHASH, params)); + assertEq(params.hash(), expectedHash); + } + function test_hash_vaultRedeem_fuzz(EIP712Types.VaultRedeem calldata params) public pure { + bytes32 expectedHash = keccak256(abi.encode(EIP712Hash.VAULT_REDEEM_TYPEHASH, params)); assertEq(params.hash(), expectedHash); } } diff --git a/tests/unit/misc/SignatureGateway/SignatureGateway.Base.t.sol b/tests/unit/misc/SignatureGateway/SignatureGateway.Base.t.sol index 7fad83c12..63bdfdcaf 100644 --- a/tests/unit/misc/SignatureGateway/SignatureGateway.Base.t.sol +++ b/tests/unit/misc/SignatureGateway/SignatureGateway.Base.t.sol @@ -6,23 +6,16 @@ import 'tests/unit/Spoke/SpokeBase.t.sol'; contract SignatureGatewayBaseTest is SpokeBase { ISignatureGateway public gateway; - uint256 public alicePk; function setUp() public virtual override { deployFixtures(); initEnvironment(); gateway = ISignatureGateway(new SignatureGateway(ADMIN)); - (alice, alicePk) = makeAddrAndKey('alice'); vm.prank(address(ADMIN)); gateway.registerSpoke(address(spoke1), true); } - function _sign(uint256 pk, bytes32 digest) internal pure returns (bytes memory) { - (uint8 v, bytes32 r, bytes32 s) = vm.sign(pk, digest); - return abi.encodePacked(r, s, v); - } - function _supplyData( ISpoke spoke, address who, @@ -194,11 +187,13 @@ contract SignatureGatewayBaseTest is SpokeBase { ISpoke spoke, ISignatureGateway _gateway, address who - ) internal view { + ) internal { for (uint256 reserveId; reserveId < spoke.getReserveCount(); ++reserveId) { - IERC20 underlying = _underlying(spoke, reserveId); - assertEq(underlying.balanceOf(address(_gateway)), 0); - assertEq(underlying.allowance({owner: who, spender: address(_gateway)}), 0); + _assertEntityHasNoBalanceOrAllowance({ + underlying: _underlying(spoke, reserveId), + entity: address(_gateway), + user: who + }); } }