From ec72f566dec9839bfb74f717368a63b42effbc01 Mon Sep 17 00:00:00 2001 From: DhairyaSethi <55102840+DhairyaSethi@users.noreply.github.com> Date: Wed, 26 Nov 2025 15:07:25 -0300 Subject: [PATCH 01/17] feat: init simple vault spoke --- src/dependencies/openzeppelin/ERC4626.sol | 301 +++++++++++++++++++++ src/dependencies/openzeppelin/IERC4626.sol | 238 ++++++++++++++++ src/spoke/VaultSpoke.sol | 139 ++++++++++ src/spoke/interfaces/IVaultSpoke.sol | 18 ++ 4 files changed, 696 insertions(+) create mode 100644 src/dependencies/openzeppelin/ERC4626.sol create mode 100644 src/dependencies/openzeppelin/IERC4626.sol create mode 100644 src/spoke/VaultSpoke.sol create mode 100644 src/spoke/interfaces/IVaultSpoke.sol diff --git a/src/dependencies/openzeppelin/ERC4626.sol b/src/dependencies/openzeppelin/ERC4626.sol new file mode 100644 index 000000000..9b4335011 --- /dev/null +++ b/src/dependencies/openzeppelin/ERC4626.sol @@ -0,0 +1,301 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v5.4.0) (token/ERC20/extensions/ERC4626.sol) + +pragma solidity ^0.8.20; + +import {IERC20, IERC20Metadata, ERC20} from './ERC20.sol'; +import {SafeERC20} from './SafeERC20.sol'; +import {IERC4626} from './IERC4626.sol'; +import {Math} from './Math.sol'; + +/** + * @dev Implementation of the ERC-4626 "Tokenized Vault Standard" as defined in + * https://eips.ethereum.org/EIPS/eip-4626[ERC-4626]. + * + * This extension allows the minting and burning of "shares" (represented using the ERC-20 inheritance) in exchange for + * underlying "assets" through standardized {deposit}, {mint}, {redeem} and {burn} workflows. This contract extends + * the ERC-20 standard. Any additional extensions included along it would affect the "shares" token represented by this + * contract and not the "assets" token which is an independent contract. + * + * [CAUTION] + * ==== + * In empty (or nearly empty) ERC-4626 vaults, deposits are at high risk of being stolen through frontrunning + * with a "donation" to the vault that inflates the price of a share. This is variously known as a donation or inflation + * attack and is essentially a problem of slippage. Vault deployers can protect against this attack by making an initial + * deposit of a non-trivial amount of the asset, such that price manipulation becomes infeasible. Withdrawals may + * similarly be affected by slippage. Users can protect against this attack as well as unexpected slippage in general by + * verifying the amount received is as expected, using a wrapper that performs these checks such as + * https://github.com/fei-protocol/ERC4626#erc4626router-and-base[ERC4626Router]. + * + * Since v4.9, this implementation introduces configurable virtual assets and shares to help developers mitigate that risk. + * The `_decimalsOffset()` corresponds to an offset in the decimal representation between the underlying asset's decimals + * and the vault decimals. This offset also determines the rate of virtual shares to virtual assets in the vault, which + * itself determines the initial exchange rate. While not fully preventing the attack, analysis shows that the default + * offset (0) makes it non-profitable even if an attacker is able to capture value from multiple user deposits, as a result + * of the value being captured by the virtual shares (out of the attacker's donation) matching the attacker's expected gains. + * With a larger offset, the attack becomes orders of magnitude more expensive than it is profitable. More details about the + * underlying math can be found xref:ROOT:erc4626.adoc#inflation-attack[here]. + * + * The drawback of this approach is that the virtual shares do capture (a very small) part of the value being accrued + * to the vault. Also, if the vault experiences losses, the users try to exit the vault, the virtual shares and assets + * will cause the first user to exit to experience reduced losses in detriment to the last users that will experience + * bigger losses. Developers willing to revert back to the pre-v4.9 behavior just need to override the + * `_convertToShares` and `_convertToAssets` functions. + * + * To learn more, check out our xref:ROOT:erc4626.adoc[ERC-4626 guide]. + * ==== + */ +abstract contract ERC4626 is ERC20, IERC4626 { + using Math for uint256; + + IERC20 private immutable _asset; + uint8 private immutable _underlyingDecimals; + + /** + * @dev Attempted to deposit more assets than the max amount for `receiver`. + */ + error ERC4626ExceededMaxDeposit(address receiver, uint256 assets, uint256 max); + + /** + * @dev Attempted to mint more shares than the max amount for `receiver`. + */ + error ERC4626ExceededMaxMint(address receiver, uint256 shares, uint256 max); + + /** + * @dev Attempted to withdraw more assets than the max amount for `receiver`. + */ + error ERC4626ExceededMaxWithdraw(address owner, uint256 assets, uint256 max); + + /** + * @dev Attempted to redeem more shares than the max amount for `receiver`. + */ + error ERC4626ExceededMaxRedeem(address owner, uint256 shares, uint256 max); + + /** + * @dev Set the underlying asset contract. This must be an ERC20-compatible contract (ERC-20 or ERC-777). + */ + constructor(IERC20 asset_) { + (bool success, uint8 assetDecimals) = _tryGetAssetDecimals(asset_); + _underlyingDecimals = success ? assetDecimals : 18; + _asset = asset_; + } + + /** + * @dev Attempts to fetch the asset decimals. A return value of false indicates that the attempt failed in some way. + */ + function _tryGetAssetDecimals(IERC20 asset_) private view returns (bool ok, uint8 assetDecimals) { + (bool success, bytes memory encodedDecimals) = address(asset_).staticcall( + abi.encodeCall(IERC20Metadata.decimals, ()) + ); + if (success && encodedDecimals.length >= 32) { + uint256 returnedDecimals = abi.decode(encodedDecimals, (uint256)); + if (returnedDecimals <= type(uint8).max) { + return (true, uint8(returnedDecimals)); + } + } + return (false, 0); + } + + /** + * @dev Decimals are computed by adding the decimal offset on top of the underlying asset's decimals. This + * "original" value is cached during construction of the vault contract. If this read operation fails (e.g., the + * asset has not been created yet), a default of 18 is used to represent the underlying asset's decimals. + * + * See {IERC20Metadata-decimals}. + */ + function decimals() public view virtual override(IERC20Metadata, ERC20) returns (uint8) { + return _underlyingDecimals + _decimalsOffset(); + } + + /// @inheritdoc IERC4626 + function asset() public view virtual returns (address) { + return address(_asset); + } + + /// @inheritdoc IERC4626 + function totalAssets() public view virtual returns (uint256) { + return IERC20(asset()).balanceOf(address(this)); + } + + /// @inheritdoc IERC4626 + function convertToShares(uint256 assets) public view virtual returns (uint256) { + return _convertToShares(assets, Math.Rounding.Floor); + } + + /// @inheritdoc IERC4626 + function convertToAssets(uint256 shares) public view virtual returns (uint256) { + return _convertToAssets(shares, Math.Rounding.Floor); + } + + /// @inheritdoc IERC4626 + function maxDeposit(address) public view virtual returns (uint256) { + return type(uint256).max; + } + + /// @inheritdoc IERC4626 + function maxMint(address) public view virtual returns (uint256) { + return type(uint256).max; + } + + /// @inheritdoc IERC4626 + function maxWithdraw(address owner) public view virtual returns (uint256) { + return _convertToAssets(balanceOf(owner), Math.Rounding.Floor); + } + + /// @inheritdoc IERC4626 + function maxRedeem(address owner) public view virtual returns (uint256) { + return balanceOf(owner); + } + + /// @inheritdoc IERC4626 + function previewDeposit(uint256 assets) public view virtual returns (uint256) { + return _convertToShares(assets, Math.Rounding.Floor); + } + + /// @inheritdoc IERC4626 + function previewMint(uint256 shares) public view virtual returns (uint256) { + return _convertToAssets(shares, Math.Rounding.Ceil); + } + + /// @inheritdoc IERC4626 + function previewWithdraw(uint256 assets) public view virtual returns (uint256) { + return _convertToShares(assets, Math.Rounding.Ceil); + } + + /// @inheritdoc IERC4626 + function previewRedeem(uint256 shares) public view virtual returns (uint256) { + return _convertToAssets(shares, Math.Rounding.Floor); + } + + /// @inheritdoc IERC4626 + function deposit(uint256 assets, address receiver) public virtual returns (uint256) { + uint256 maxAssets = maxDeposit(receiver); + if (assets > maxAssets) { + revert ERC4626ExceededMaxDeposit(receiver, assets, maxAssets); + } + + uint256 shares = previewDeposit(assets); + _deposit(_msgSender(), receiver, assets, shares); + + return shares; + } + + /// @inheritdoc IERC4626 + function mint(uint256 shares, address receiver) public virtual returns (uint256) { + uint256 maxShares = maxMint(receiver); + if (shares > maxShares) { + revert ERC4626ExceededMaxMint(receiver, shares, maxShares); + } + + uint256 assets = previewMint(shares); + _deposit(_msgSender(), receiver, assets, shares); + + return assets; + } + + /// @inheritdoc IERC4626 + function withdraw( + uint256 assets, + address receiver, + address owner + ) public virtual returns (uint256) { + uint256 maxAssets = maxWithdraw(owner); + if (assets > maxAssets) { + revert ERC4626ExceededMaxWithdraw(owner, assets, maxAssets); + } + + uint256 shares = previewWithdraw(assets); + _withdraw(_msgSender(), receiver, owner, assets, shares); + + return shares; + } + + /// @inheritdoc IERC4626 + function redeem( + uint256 shares, + address receiver, + address owner + ) public virtual returns (uint256) { + uint256 maxShares = maxRedeem(owner); + if (shares > maxShares) { + revert ERC4626ExceededMaxRedeem(owner, shares, maxShares); + } + + uint256 assets = previewRedeem(shares); + _withdraw(_msgSender(), receiver, owner, assets, shares); + + return assets; + } + + /** + * @dev Internal conversion function (from assets to shares) with support for rounding direction. + */ + function _convertToShares( + uint256 assets, + Math.Rounding rounding + ) internal view virtual returns (uint256) { + return assets.mulDiv(totalSupply() + 10 ** _decimalsOffset(), totalAssets() + 1, rounding); + } + + /** + * @dev Internal conversion function (from shares to assets) with support for rounding direction. + */ + function _convertToAssets( + uint256 shares, + Math.Rounding rounding + ) internal view virtual returns (uint256) { + return shares.mulDiv(totalAssets() + 1, totalSupply() + 10 ** _decimalsOffset(), rounding); + } + + /** + * @dev Deposit/mint common workflow. + */ + function _deposit( + address caller, + address receiver, + uint256 assets, + uint256 shares + ) internal virtual { + // If asset() is ERC-777, `transferFrom` can trigger a reentrancy BEFORE the transfer happens through the + // `tokensToSend` hook. On the other hand, the `tokenReceived` hook, that is triggered after the transfer, + // calls the vault, which is assumed not malicious. + // + // Conclusion: we need to do the transfer before we mint so that any reentrancy would happen before the + // assets are transferred and before the shares are minted, which is a valid state. + // slither-disable-next-line reentrancy-no-eth + SafeERC20.safeTransferFrom(IERC20(asset()), caller, address(this), assets); + _mint(receiver, shares); + + emit Deposit(caller, receiver, assets, shares); + } + + /** + * @dev Withdraw/redeem common workflow. + */ + function _withdraw( + address caller, + address receiver, + address owner, + uint256 assets, + uint256 shares + ) internal virtual { + if (caller != owner) { + _spendAllowance(owner, caller, shares); + } + + // If asset() is ERC-777, `transfer` can trigger a reentrancy AFTER the transfer happens through the + // `tokensReceived` hook. On the other hand, the `tokensToSend` hook, that is triggered before the transfer, + // calls the vault, which is assumed not malicious. + // + // Conclusion: we need to do the transfer after the burn so that any reentrancy would happen after the + // shares are burned and after the assets are transferred, which is a valid state. + _burn(owner, shares); + SafeERC20.safeTransfer(IERC20(asset()), receiver, assets); + + emit Withdraw(caller, receiver, owner, assets, shares); + } + + function _decimalsOffset() internal view virtual returns (uint8) { + return 0; + } +} 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/spoke/VaultSpoke.sol b/src/spoke/VaultSpoke.sol new file mode 100644 index 000000000..d60720715 --- /dev/null +++ b/src/spoke/VaultSpoke.sol @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity 0.8.28; + +import {ERC4626, ERC20, IERC20Metadata, IERC4626, Math} from 'src/dependencies/openzeppelin/ERC4626.sol'; +import {IERC20Permit} from 'src/dependencies/openzeppelin/IERC20Permit.sol'; +import {IHub} from 'src/hub/interfaces/IHub.sol'; +import {IVaultSpoke} from 'src/spoke/interfaces/IVaultSpoke.sol'; +import {SafeTransferLib} from 'src/dependencies/solady/SafeTransferLib.sol'; +import {MathUtils} from 'src/libraries/math/MathUtils.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, share price is maintained by the Hub. +contract VaultSpoke is IVaultSpoke, ERC4626 { + using SafeTransferLib for address; + + IHub public immutable HUB; + uint256 public immutable ASSET_ID; + + constructor( + address hub_, + uint256 assetId_, + address underlying_ + ) + ERC4626(IERC20Metadata(underlying_)) + ERC20( + string.concat('Vault Spoke (', IERC20Metadata(underlying_).name(), ')'), + string.concat('v', IERC20Metadata(underlying_).symbol()) + ) + { + HUB = IHub(hub_); + ASSET_ID = assetId_; + require(HUB.getAsset(ASSET_ID).underlying == underlying_, InvalidAddress()); // hub zero addr check covered + } + + function depositWithPermit( + uint256 assets, + address receiver, + uint256 deadline, + uint8 permitV, + bytes32 permitR, + bytes32 permitS + ) external returns (uint256) { + try + IERC20Permit(asset()).permit({ + owner: msg.sender, // deposit only mints for caller + spender: address(this), + value: assets, + deadline: deadline, + v: permitV, + r: permitR, + s: permitS + }) + {} catch {} + return deposit(assets, receiver); + } + + function totalAssets() public view override returns (uint256) { + // does not revert since `ASSET_ID` existence is checked on construction + return HUB.previewRemoveByShares(ASSET_ID, totalSupply()); + } + + function maxDeposit(address) public view override returns (uint256) { + IHub.SpokeConfig memory config = HUB.getSpokeConfig(ASSET_ID, address(this)); + if (config.active == false) { + return 0; + } + uint256 allowed = config.addCap * MathUtils.uncheckedExp(10, decimals()); + uint256 balance = totalAssets(); + return Math.ternary(allowed > balance, allowed - balance, 0); + } + + function maxMint(address owner) public view override returns (uint256) { + return _convertToShares(maxDeposit(owner), Math.Rounding.Floor); + } + + function maxWithdraw(address owner) public view override returns (uint256) { + return _convertToAssets(maxRedeem(owner), Math.Rounding.Floor); + } + + function maxRedeem(address owner) public view override returns (uint256) { + IHub.SpokeConfig memory config = HUB.getSpokeConfig(ASSET_ID, address(this)); + if (config.active == false) { + return 0; + } + return balanceOf(owner); + } + + function _deposit( + address caller, + address receiver, + uint256 assets, + uint256 shares + ) internal override { + asset().safeTransferFrom(caller, address(HUB), assets); + HUB.add(ASSET_ID, assets); + _mint(receiver, shares); + emit Deposit(caller, receiver, assets, shares); + } + + function _withdraw( + address caller, + address receiver, + address owner, + uint256 assets, + uint256 shares + ) internal override { + if (caller != owner) { + _spendAllowance(owner, caller, shares); + } + HUB.remove(ASSET_ID, assets, receiver); + _burn(owner, shares); + emit Withdraw(caller, receiver, owner, assets, shares); + } + + // @dev Share price is maintained on the Hub. + function _convertToShares( + uint256 assets, + Math.Rounding rounding + ) internal view override returns (uint256) { + if (rounding == Math.Rounding.Ceil) { + return HUB.previewRemoveByAssets(ASSET_ID, assets); + } + return HUB.previewAddByAssets(ASSET_ID, assets); + } + + // @dev Share price is maintained on the Hub. + function _convertToAssets( + uint256 shares, + Math.Rounding rounding + ) internal view override returns (uint256) { + if (rounding == Math.Rounding.Ceil) { + return HUB.previewAddByShares(ASSET_ID, shares); + } + return HUB.previewRemoveByShares(ASSET_ID, shares); + } +} diff --git a/src/spoke/interfaces/IVaultSpoke.sol b/src/spoke/interfaces/IVaultSpoke.sol new file mode 100644 index 000000000..fe270ef9d --- /dev/null +++ b/src/spoke/interfaces/IVaultSpoke.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import {IHub} from 'src/hub/interfaces/IHub.sol'; + +/// @title IVaultSpoke +/// @author Aave Labs +interface IVaultSpoke { + /// @notice Thrown when the given address is invalid. + error InvalidAddress(); + + /// @notice Returns the address of the associated Hub. + function HUB() external view returns (IHub); + + /// @notice Returns the identifier of the associated asset. + function ASSET_ID() external view returns (uint256); +} From 72d57d09486b2e2b3ae5ce54917446d07a20af08 Mon Sep 17 00:00:00 2001 From: DhairyaSethi <55102840+DhairyaSethi@users.noreply.github.com> Date: Mon, 1 Dec 2025 17:47:35 -0500 Subject: [PATCH 02/17] feat: upgradeable, *withSig, permit, rft to omit oz 4626 --- .../ERC20Upgradeable.sol | 342 ++++++++++++++++ src/dependencies/openzeppelin/IERC2612.sol | 8 + .../libraries/EIP712Hash.sol | 76 ++++ src/libraries/math/MathUtils.sol | 7 + src/libraries/types/EIP712Types.sol | 32 ++ src/position-manager/SignatureGateway.sol | 2 +- src/spoke/VaultSpoke.sol | 378 ++++++++++++++---- src/spoke/instances/VaultSpokeInstance.sol | 26 ++ src/spoke/interfaces/IVaultSpoke.sol | 53 ++- src/utils/NoncesKeyed.sol | 2 +- 10 files changed, 853 insertions(+), 73 deletions(-) create mode 100644 src/dependencies/openzeppelin-upgradeable/ERC20Upgradeable.sol create mode 100644 src/dependencies/openzeppelin/IERC2612.sol rename src/{position-manager => }/libraries/EIP712Hash.sol (64%) create mode 100644 src/spoke/instances/VaultSpokeInstance.sol 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/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 e0777a5aa..5a8153261 100644 --- a/src/position-manager/SignatureGateway.sol +++ b/src/position-manager/SignatureGateway.sol @@ -9,7 +9,7 @@ import {EIP712} from 'src/dependencies/solady/EIP712.sol'; import {MathUtils} from 'src/libraries/math/MathUtils.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 {EIP712Hash, EIP712Types} from 'src/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/spoke/VaultSpoke.sol b/src/spoke/VaultSpoke.sol index d60720715..3b790e64c 100644 --- a/src/spoke/VaultSpoke.sol +++ b/src/spoke/VaultSpoke.sol @@ -2,46 +2,166 @@ // Copyright (c) 2025 Aave Labs pragma solidity 0.8.28; -import {ERC4626, ERC20, IERC20Metadata, IERC4626, Math} from 'src/dependencies/openzeppelin/ERC4626.sol'; +import {ERC20Upgradeable} from 'src/dependencies/openzeppelin-upgradeable/ERC20Upgradeable.sol'; import {IERC20Permit} from 'src/dependencies/openzeppelin/IERC20Permit.sol'; +import {IERC4626, IERC20Metadata} from 'src/dependencies/openzeppelin/IERC4626.sol'; import {IHub} from 'src/hub/interfaces/IHub.sol'; import {IVaultSpoke} from 'src/spoke/interfaces/IVaultSpoke.sol'; import {SafeTransferLib} from 'src/dependencies/solady/SafeTransferLib.sol'; import {MathUtils} from 'src/libraries/math/MathUtils.sol'; +import {EIP712Hash, EIP712Types} from 'src/libraries/EIP712Hash.sol'; +import {SignatureChecker, ECDSA} from 'src/dependencies/openzeppelin/SignatureChecker.sol'; +import {EIP712} from 'src/dependencies/solady/EIP712.sol'; +import {NoncesKeyed} from 'src/utils/NoncesKeyed.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, share price is maintained by the Hub. -contract VaultSpoke is IVaultSpoke, ERC4626 { +/// @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 SafeTransferLib for address; + using MathUtils for uint256; + using EIP712Hash for *; - IHub public immutable HUB; - uint256 public immutable ASSET_ID; - - constructor( - address hub_, - uint256 assetId_, - address underlying_ - ) - ERC4626(IERC20Metadata(underlying_)) - ERC20( - string.concat('Vault Spoke (', IERC20Metadata(underlying_).name(), ')'), - string.concat('v', IERC20Metadata(underlying_).symbol()) - ) - { - HUB = IHub(hub_); - ASSET_ID = assetId_; - require(HUB.getAsset(ASSET_ID).underlying == underlying_, InvalidAddress()); // hub zero addr check covered + IHub internal immutable _HUB; + uint256 internal immutable _ASSET_ID; + address internal immutable _ASSET; + uint8 internal immutable _DECIMALS; + uint40 internal immutable _MAX_ALLOWED_SPOKE_CAP; + uint192 internal constant _PERMIT_NONCE_KEY = 0; + + constructor(address hub_, uint256 assetId_) { + _HUB = IHub(hub_); + _ASSET_ID = assetId_; + require(_ASSET_ID < _HUB.getAssetCount()); + _MAX_ALLOWED_SPOKE_CAP = _HUB.MAX_ALLOWED_SPOKE_CAP(); + (_ASSET, _DECIMALS) = _HUB.getAssetUnderlyingAndDecimals(_ASSET_ID); + } + + function initialize(string memory prefix) external virtual; + + function __VaultSpoke_init(string memory prefix) internal onlyInitializing { + __ERC20_init( + string.concat(prefix, IERC20Metadata(_ASSET).name()), + string.concat('s', IERC20Metadata(_ASSET).symbol()) + ); + } + + /// @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 + ) public 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 + ) public 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 + ) public 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: msg.sender, + receiver: params.receiver, + owner: params.owner, + assets: params.assets + }); + } + + /// @inheritdoc IVaultSpoke + function redeemWithSig( + EIP712Types.VaultRedeem calldata params, + bytes memory signature + ) public 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: msg.sender, + receiver: params.receiver, + owner: params.owner, + shares: params.shares + }); } + /// @inheritdoc IVaultSpoke function depositWithPermit( uint256 assets, address receiver, uint256 deadline, - uint8 permitV, - bytes32 permitR, - bytes32 permitS + uint8 v, + bytes32 r, + bytes32 s ) external returns (uint256) { try IERC20Permit(asset()).permit({ @@ -49,53 +169,193 @@ contract VaultSpoke is IVaultSpoke, ERC4626 { spender: address(this), value: assets, deadline: deadline, - v: permitV, - r: permitR, - s: permitS + v: v, + r: r, + s: s }) {} catch {} return deposit(assets, receiver); } - function totalAssets() public view override returns (uint256) { - // does not revert since `ASSET_ID` existence is checked on construction - return HUB.previewRemoveByShares(ASSET_ID, totalSupply()); + /// @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 IERC4626 + function previewDeposit(uint256 assets) public view virtual returns (uint256) { + return hub().previewAddByAssets(assetId(), assets); + } + + /// @inheritdoc IERC4626 + function previewMint(uint256 shares) public view virtual returns (uint256) { + return hub().previewAddByShares(assetId(), shares); + } + + /// @inheritdoc IERC4626 + function previewWithdraw(uint256 assets) public view virtual returns (uint256) { + return hub().previewRemoveByAssets(assetId(), assets); + } + + /// @inheritdoc IERC4626 + function previewRedeem(uint256 shares) public view virtual returns (uint256) { + return hub().previewRemoveByShares(assetId(), shares); + } + + /// @inheritdoc IERC4626 + function convertToShares(uint256 assets) public view returns (uint256) { + return previewDeposit(assets); + } + + /// @inheritdoc IERC4626 + function convertToAssets(uint256 shares) public view returns (uint256) { + return previewMint(shares); } - function maxDeposit(address) public view override returns (uint256) { - IHub.SpokeConfig memory config = HUB.getSpokeConfig(ASSET_ID, address(this)); - if (config.active == false) { + /// @inheritdoc IERC4626 + function maxDeposit(address) public view returns (uint256) { + IHub.SpokeConfig memory config = hub().getSpokeConfig(assetId(), address(this)); + if (!config.active || config.paused) { return 0; } + if (config.addCap == _MAX_ALLOWED_SPOKE_CAP) { + return type(uint256).max; + } uint256 allowed = config.addCap * MathUtils.uncheckedExp(10, decimals()); uint256 balance = totalAssets(); - return Math.ternary(allowed > balance, allowed - balance, 0); + return allowed.zeroFloorSub(balance); } - function maxMint(address owner) public view override returns (uint256) { - return _convertToShares(maxDeposit(owner), Math.Rounding.Floor); + /// @inheritdoc IERC4626 + function maxMint(address owner) public view returns (uint256) { + uint256 maxAssets = maxDeposit(owner); + return maxAssets == type(uint256).max ? type(uint256).max : previewDeposit(maxAssets); } - function maxWithdraw(address owner) public view override returns (uint256) { - return _convertToAssets(maxRedeem(owner), Math.Rounding.Floor); + /// @inheritdoc IERC4626 + function maxWithdraw(address owner) public view returns (uint256) { + return previewRedeem(maxRedeem(owner)); } - function maxRedeem(address owner) public view override returns (uint256) { - IHub.SpokeConfig memory config = HUB.getSpokeConfig(ASSET_ID, address(this)); - if (config.active == false) { + /// @inheritdoc IERC4626 + function maxRedeem(address owner) public view returns (uint256) { + IHub.SpokeConfig memory config = hub().getSpokeConfig(assetId(), address(this)); + if (!config.active || config.paused) { return 0; } return balanceOf(owner); } - function _deposit( + /// @inheritdoc IERC4626 + function totalAssets() public view virtual returns (uint256) { + return previewRedeem(totalSupply()); + } + + /// @inheritdoc IVaultSpoke + function hub() public view returns (IHub) { + return _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 DOMAIN_SEPARATOR() public view returns (bytes32) { + return _domainSeparator(); + } + + /// @inheritdoc IERC20Permit + function nonces(address owner) public view returns (uint256) { + return nonces({owner: owner, key: _PERMIT_NONCE_KEY}); + } + + 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(depositor, receiver, assets, 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(depositor, receiver, assets, shares); + return assets; + } + + function _executeWithdraw( address caller, address receiver, - uint256 assets, + address owner, + uint256 assets + ) internal returns (uint256) { + uint256 maxAssets = maxWithdraw(owner); + require(assets <= maxAssets, MaxWithdrawExceeded(maxAssets, assets)); + uint256 shares = previewWithdraw(assets); + _withdraw(caller, receiver, owner, assets, shares); + return shares; + } + + function _executeRedeem( + address caller, + address receiver, + address owner, uint256 shares - ) internal override { - asset().safeTransferFrom(caller, address(HUB), assets); - HUB.add(ASSET_ID, assets); + ) internal returns (uint256) { + uint256 maxShares = maxRedeem(owner); + require(shares <= maxShares, MaxRedeemExceeded(maxShares, shares)); + uint256 assets = previewRedeem(shares); + _withdraw(caller, receiver, owner, assets, shares); + return assets; + } + + function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal { + asset().safeTransferFrom(caller, address(hub()), assets); + hub().add(assetId(), assets); _mint(receiver, shares); emit Deposit(caller, receiver, assets, shares); } @@ -106,34 +366,16 @@ contract VaultSpoke is IVaultSpoke, ERC4626 { address owner, uint256 assets, uint256 shares - ) internal override { + ) internal virtual { if (caller != owner) { - _spendAllowance(owner, caller, shares); + _spendAllowance({owner: owner, spender: caller, value: shares}); } - HUB.remove(ASSET_ID, assets, receiver); + hub().remove(assetId(), assets, receiver); _burn(owner, shares); emit Withdraw(caller, receiver, owner, assets, shares); } - // @dev Share price is maintained on the Hub. - function _convertToShares( - uint256 assets, - Math.Rounding rounding - ) internal view override returns (uint256) { - if (rounding == Math.Rounding.Ceil) { - return HUB.previewRemoveByAssets(ASSET_ID, assets); - } - return HUB.previewAddByAssets(ASSET_ID, assets); - } - - // @dev Share price is maintained on the Hub. - function _convertToAssets( - uint256 shares, - Math.Rounding rounding - ) internal view override returns (uint256) { - if (rounding == Math.Rounding.Ceil) { - return HUB.previewAddByShares(ASSET_ID, shares); - } - return HUB.previewRemoveByShares(ASSET_ID, shares); + 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..e59d56b6d --- /dev/null +++ b/src/spoke/instances/VaultSpokeInstance.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity 0.8.28; + +import {VaultSpoke} from 'src/spoke/VaultSpoke.sol'; +import {IERC20Metadata, IERC20} from 'src/dependencies/openzeppelin/IERC20Metadata.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 ID of the asset. + constructor(address hub_, uint256 assetId_) VaultSpoke(hub_, assetId_) { + _disableInitializers(); + } + + /// @notice Initializer. + function initialize(string memory prefix) external override reinitializer(SPOKE_REVISION) { + // todo: upgrade validation that hub/assetId remains unchanged? + __VaultSpoke_init(prefix); + } +} diff --git a/src/spoke/interfaces/IVaultSpoke.sol b/src/spoke/interfaces/IVaultSpoke.sol index fe270ef9d..41ad46abb 100644 --- a/src/spoke/interfaces/IVaultSpoke.sol +++ b/src/spoke/interfaces/IVaultSpoke.sol @@ -3,16 +3,63 @@ pragma solidity ^0.8.0; import {IHub} from 'src/hub/interfaces/IHub.sol'; +import {IERC4626} from 'src/dependencies/openzeppelin/IERC4626.sol'; +import {IERC2612} from 'src/dependencies/openzeppelin/IERC2612.sol'; +import {EIP712Types} from 'src/libraries/types/EIP712Types.sol'; /// @title IVaultSpoke /// @author Aave Labs -interface IVaultSpoke { +interface IVaultSpoke is IERC4626, IERC2612 { + /// @notice Thrown when the given signature is invalid. + error InvalidSignature(); + /// @notice Thrown when the given address is invalid. error InvalidAddress(); + /// @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); + + function depositWithSig( + EIP712Types.VaultDeposit calldata params, + bytes calldata signature + ) external returns (uint256 shares); + + function mintWithSig( + EIP712Types.VaultMint calldata params, + bytes calldata signature + ) external returns (uint256 assets); + + function withdrawWithSig( + EIP712Types.VaultWithdraw calldata params, + bytes calldata signature + ) external returns (uint256 shares); + + function redeemWithSig( + EIP712Types.VaultRedeem calldata params, + bytes calldata signature + ) external returns (uint256 assets); + + function depositWithPermit( + uint256 assets, + address receiver, + uint256 deadline, + uint8 v, + bytes32 r, + bytes32 s + ) external returns (uint256 shares); + /// @notice Returns the address of the associated Hub. - function HUB() external view returns (IHub); + function hub() external view returns (IHub); /// @notice Returns the identifier of the associated asset. - function ASSET_ID() external view returns (uint256); + function assetId() external view returns (uint256); } diff --git a/src/utils/NoncesKeyed.sol b/src/utils/NoncesKeyed.sol index efd8afe51..544245ddd 100644 --- a/src/utils/NoncesKeyed.sol +++ b/src/utils/NoncesKeyed.sol @@ -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]); } From 64fab6163484afb1fb2b53566f6504594b09866b Mon Sep 17 00:00:00 2001 From: DhairyaSethi <55102840+DhairyaSethi@users.noreply.github.com> Date: Mon, 1 Dec 2025 17:48:38 -0500 Subject: [PATCH 03/17] chore: rm unused --- src/dependencies/openzeppelin/ERC4626.sol | 301 ---------------------- 1 file changed, 301 deletions(-) delete mode 100644 src/dependencies/openzeppelin/ERC4626.sol diff --git a/src/dependencies/openzeppelin/ERC4626.sol b/src/dependencies/openzeppelin/ERC4626.sol deleted file mode 100644 index 9b4335011..000000000 --- a/src/dependencies/openzeppelin/ERC4626.sol +++ /dev/null @@ -1,301 +0,0 @@ -// SPDX-License-Identifier: MIT -// OpenZeppelin Contracts (last updated v5.4.0) (token/ERC20/extensions/ERC4626.sol) - -pragma solidity ^0.8.20; - -import {IERC20, IERC20Metadata, ERC20} from './ERC20.sol'; -import {SafeERC20} from './SafeERC20.sol'; -import {IERC4626} from './IERC4626.sol'; -import {Math} from './Math.sol'; - -/** - * @dev Implementation of the ERC-4626 "Tokenized Vault Standard" as defined in - * https://eips.ethereum.org/EIPS/eip-4626[ERC-4626]. - * - * This extension allows the minting and burning of "shares" (represented using the ERC-20 inheritance) in exchange for - * underlying "assets" through standardized {deposit}, {mint}, {redeem} and {burn} workflows. This contract extends - * the ERC-20 standard. Any additional extensions included along it would affect the "shares" token represented by this - * contract and not the "assets" token which is an independent contract. - * - * [CAUTION] - * ==== - * In empty (or nearly empty) ERC-4626 vaults, deposits are at high risk of being stolen through frontrunning - * with a "donation" to the vault that inflates the price of a share. This is variously known as a donation or inflation - * attack and is essentially a problem of slippage. Vault deployers can protect against this attack by making an initial - * deposit of a non-trivial amount of the asset, such that price manipulation becomes infeasible. Withdrawals may - * similarly be affected by slippage. Users can protect against this attack as well as unexpected slippage in general by - * verifying the amount received is as expected, using a wrapper that performs these checks such as - * https://github.com/fei-protocol/ERC4626#erc4626router-and-base[ERC4626Router]. - * - * Since v4.9, this implementation introduces configurable virtual assets and shares to help developers mitigate that risk. - * The `_decimalsOffset()` corresponds to an offset in the decimal representation between the underlying asset's decimals - * and the vault decimals. This offset also determines the rate of virtual shares to virtual assets in the vault, which - * itself determines the initial exchange rate. While not fully preventing the attack, analysis shows that the default - * offset (0) makes it non-profitable even if an attacker is able to capture value from multiple user deposits, as a result - * of the value being captured by the virtual shares (out of the attacker's donation) matching the attacker's expected gains. - * With a larger offset, the attack becomes orders of magnitude more expensive than it is profitable. More details about the - * underlying math can be found xref:ROOT:erc4626.adoc#inflation-attack[here]. - * - * The drawback of this approach is that the virtual shares do capture (a very small) part of the value being accrued - * to the vault. Also, if the vault experiences losses, the users try to exit the vault, the virtual shares and assets - * will cause the first user to exit to experience reduced losses in detriment to the last users that will experience - * bigger losses. Developers willing to revert back to the pre-v4.9 behavior just need to override the - * `_convertToShares` and `_convertToAssets` functions. - * - * To learn more, check out our xref:ROOT:erc4626.adoc[ERC-4626 guide]. - * ==== - */ -abstract contract ERC4626 is ERC20, IERC4626 { - using Math for uint256; - - IERC20 private immutable _asset; - uint8 private immutable _underlyingDecimals; - - /** - * @dev Attempted to deposit more assets than the max amount for `receiver`. - */ - error ERC4626ExceededMaxDeposit(address receiver, uint256 assets, uint256 max); - - /** - * @dev Attempted to mint more shares than the max amount for `receiver`. - */ - error ERC4626ExceededMaxMint(address receiver, uint256 shares, uint256 max); - - /** - * @dev Attempted to withdraw more assets than the max amount for `receiver`. - */ - error ERC4626ExceededMaxWithdraw(address owner, uint256 assets, uint256 max); - - /** - * @dev Attempted to redeem more shares than the max amount for `receiver`. - */ - error ERC4626ExceededMaxRedeem(address owner, uint256 shares, uint256 max); - - /** - * @dev Set the underlying asset contract. This must be an ERC20-compatible contract (ERC-20 or ERC-777). - */ - constructor(IERC20 asset_) { - (bool success, uint8 assetDecimals) = _tryGetAssetDecimals(asset_); - _underlyingDecimals = success ? assetDecimals : 18; - _asset = asset_; - } - - /** - * @dev Attempts to fetch the asset decimals. A return value of false indicates that the attempt failed in some way. - */ - function _tryGetAssetDecimals(IERC20 asset_) private view returns (bool ok, uint8 assetDecimals) { - (bool success, bytes memory encodedDecimals) = address(asset_).staticcall( - abi.encodeCall(IERC20Metadata.decimals, ()) - ); - if (success && encodedDecimals.length >= 32) { - uint256 returnedDecimals = abi.decode(encodedDecimals, (uint256)); - if (returnedDecimals <= type(uint8).max) { - return (true, uint8(returnedDecimals)); - } - } - return (false, 0); - } - - /** - * @dev Decimals are computed by adding the decimal offset on top of the underlying asset's decimals. This - * "original" value is cached during construction of the vault contract. If this read operation fails (e.g., the - * asset has not been created yet), a default of 18 is used to represent the underlying asset's decimals. - * - * See {IERC20Metadata-decimals}. - */ - function decimals() public view virtual override(IERC20Metadata, ERC20) returns (uint8) { - return _underlyingDecimals + _decimalsOffset(); - } - - /// @inheritdoc IERC4626 - function asset() public view virtual returns (address) { - return address(_asset); - } - - /// @inheritdoc IERC4626 - function totalAssets() public view virtual returns (uint256) { - return IERC20(asset()).balanceOf(address(this)); - } - - /// @inheritdoc IERC4626 - function convertToShares(uint256 assets) public view virtual returns (uint256) { - return _convertToShares(assets, Math.Rounding.Floor); - } - - /// @inheritdoc IERC4626 - function convertToAssets(uint256 shares) public view virtual returns (uint256) { - return _convertToAssets(shares, Math.Rounding.Floor); - } - - /// @inheritdoc IERC4626 - function maxDeposit(address) public view virtual returns (uint256) { - return type(uint256).max; - } - - /// @inheritdoc IERC4626 - function maxMint(address) public view virtual returns (uint256) { - return type(uint256).max; - } - - /// @inheritdoc IERC4626 - function maxWithdraw(address owner) public view virtual returns (uint256) { - return _convertToAssets(balanceOf(owner), Math.Rounding.Floor); - } - - /// @inheritdoc IERC4626 - function maxRedeem(address owner) public view virtual returns (uint256) { - return balanceOf(owner); - } - - /// @inheritdoc IERC4626 - function previewDeposit(uint256 assets) public view virtual returns (uint256) { - return _convertToShares(assets, Math.Rounding.Floor); - } - - /// @inheritdoc IERC4626 - function previewMint(uint256 shares) public view virtual returns (uint256) { - return _convertToAssets(shares, Math.Rounding.Ceil); - } - - /// @inheritdoc IERC4626 - function previewWithdraw(uint256 assets) public view virtual returns (uint256) { - return _convertToShares(assets, Math.Rounding.Ceil); - } - - /// @inheritdoc IERC4626 - function previewRedeem(uint256 shares) public view virtual returns (uint256) { - return _convertToAssets(shares, Math.Rounding.Floor); - } - - /// @inheritdoc IERC4626 - function deposit(uint256 assets, address receiver) public virtual returns (uint256) { - uint256 maxAssets = maxDeposit(receiver); - if (assets > maxAssets) { - revert ERC4626ExceededMaxDeposit(receiver, assets, maxAssets); - } - - uint256 shares = previewDeposit(assets); - _deposit(_msgSender(), receiver, assets, shares); - - return shares; - } - - /// @inheritdoc IERC4626 - function mint(uint256 shares, address receiver) public virtual returns (uint256) { - uint256 maxShares = maxMint(receiver); - if (shares > maxShares) { - revert ERC4626ExceededMaxMint(receiver, shares, maxShares); - } - - uint256 assets = previewMint(shares); - _deposit(_msgSender(), receiver, assets, shares); - - return assets; - } - - /// @inheritdoc IERC4626 - function withdraw( - uint256 assets, - address receiver, - address owner - ) public virtual returns (uint256) { - uint256 maxAssets = maxWithdraw(owner); - if (assets > maxAssets) { - revert ERC4626ExceededMaxWithdraw(owner, assets, maxAssets); - } - - uint256 shares = previewWithdraw(assets); - _withdraw(_msgSender(), receiver, owner, assets, shares); - - return shares; - } - - /// @inheritdoc IERC4626 - function redeem( - uint256 shares, - address receiver, - address owner - ) public virtual returns (uint256) { - uint256 maxShares = maxRedeem(owner); - if (shares > maxShares) { - revert ERC4626ExceededMaxRedeem(owner, shares, maxShares); - } - - uint256 assets = previewRedeem(shares); - _withdraw(_msgSender(), receiver, owner, assets, shares); - - return assets; - } - - /** - * @dev Internal conversion function (from assets to shares) with support for rounding direction. - */ - function _convertToShares( - uint256 assets, - Math.Rounding rounding - ) internal view virtual returns (uint256) { - return assets.mulDiv(totalSupply() + 10 ** _decimalsOffset(), totalAssets() + 1, rounding); - } - - /** - * @dev Internal conversion function (from shares to assets) with support for rounding direction. - */ - function _convertToAssets( - uint256 shares, - Math.Rounding rounding - ) internal view virtual returns (uint256) { - return shares.mulDiv(totalAssets() + 1, totalSupply() + 10 ** _decimalsOffset(), rounding); - } - - /** - * @dev Deposit/mint common workflow. - */ - function _deposit( - address caller, - address receiver, - uint256 assets, - uint256 shares - ) internal virtual { - // If asset() is ERC-777, `transferFrom` can trigger a reentrancy BEFORE the transfer happens through the - // `tokensToSend` hook. On the other hand, the `tokenReceived` hook, that is triggered after the transfer, - // calls the vault, which is assumed not malicious. - // - // Conclusion: we need to do the transfer before we mint so that any reentrancy would happen before the - // assets are transferred and before the shares are minted, which is a valid state. - // slither-disable-next-line reentrancy-no-eth - SafeERC20.safeTransferFrom(IERC20(asset()), caller, address(this), assets); - _mint(receiver, shares); - - emit Deposit(caller, receiver, assets, shares); - } - - /** - * @dev Withdraw/redeem common workflow. - */ - function _withdraw( - address caller, - address receiver, - address owner, - uint256 assets, - uint256 shares - ) internal virtual { - if (caller != owner) { - _spendAllowance(owner, caller, shares); - } - - // If asset() is ERC-777, `transfer` can trigger a reentrancy AFTER the transfer happens through the - // `tokensReceived` hook. On the other hand, the `tokensToSend` hook, that is triggered before the transfer, - // calls the vault, which is assumed not malicious. - // - // Conclusion: we need to do the transfer after the burn so that any reentrancy would happen after the - // shares are burned and after the assets are transferred, which is a valid state. - _burn(owner, shares); - SafeERC20.safeTransfer(IERC20(asset()), receiver, assets); - - emit Withdraw(caller, receiver, owner, assets, shares); - } - - function _decimalsOffset() internal view virtual returns (uint8) { - return 0; - } -} From 1dbe0de586553e9afd69739b342591ed5519c2d2 Mon Sep 17 00:00:00 2001 From: DhairyaSethi <55102840+DhairyaSethi@users.noreply.github.com> Date: Mon, 1 Dec 2025 17:56:55 -0500 Subject: [PATCH 04/17] chore: reluctantly use oz safe transfer --- src/spoke/VaultSpoke.sol | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/spoke/VaultSpoke.sol b/src/spoke/VaultSpoke.sol index 3b790e64c..04205a2aa 100644 --- a/src/spoke/VaultSpoke.sol +++ b/src/spoke/VaultSpoke.sol @@ -7,7 +7,7 @@ import {IERC20Permit} from 'src/dependencies/openzeppelin/IERC20Permit.sol'; import {IERC4626, IERC20Metadata} from 'src/dependencies/openzeppelin/IERC4626.sol'; import {IHub} from 'src/hub/interfaces/IHub.sol'; import {IVaultSpoke} from 'src/spoke/interfaces/IVaultSpoke.sol'; -import {SafeTransferLib} from 'src/dependencies/solady/SafeTransferLib.sol'; +import {SafeERC20, IERC20} from 'src/dependencies/openzeppelin/SafeERC20.sol'; import {MathUtils} from 'src/libraries/math/MathUtils.sol'; import {EIP712Hash, EIP712Types} from 'src/libraries/EIP712Hash.sol'; import {SignatureChecker, ECDSA} from 'src/dependencies/openzeppelin/SignatureChecker.sol'; @@ -20,7 +20,7 @@ import {NoncesKeyed} from 'src/utils/NoncesKeyed.sol'; /// @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 SafeTransferLib for address; + using SafeERC20 for IERC20; using MathUtils for uint256; using EIP712Hash for *; @@ -354,7 +354,7 @@ abstract contract VaultSpoke is IVaultSpoke, ERC20Upgradeable, NoncesKeyed, EIP7 } function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal { - asset().safeTransferFrom(caller, address(hub()), assets); + IERC20(asset()).safeTransferFrom(caller, address(hub()), assets); hub().add(assetId(), assets); _mint(receiver, shares); emit Deposit(caller, receiver, assets, shares); From c4f62a18f9730f38a7b1c9e89438d6ce0b2fadb6 Mon Sep 17 00:00:00 2001 From: DhairyaSethi <55102840+DhairyaSethi@users.noreply.github.com> Date: Mon, 1 Dec 2025 17:58:37 -0500 Subject: [PATCH 05/17] chore: reorder imports --- src/spoke/VaultSpoke.sol | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/spoke/VaultSpoke.sol b/src/spoke/VaultSpoke.sol index 04205a2aa..0fdb36447 100644 --- a/src/spoke/VaultSpoke.sol +++ b/src/spoke/VaultSpoke.sol @@ -3,17 +3,18 @@ pragma solidity 0.8.28; import {ERC20Upgradeable} from 'src/dependencies/openzeppelin-upgradeable/ERC20Upgradeable.sol'; -import {IERC20Permit} from 'src/dependencies/openzeppelin/IERC20Permit.sol'; -import {IERC4626, IERC20Metadata} from 'src/dependencies/openzeppelin/IERC4626.sol'; -import {IHub} from 'src/hub/interfaces/IHub.sol'; -import {IVaultSpoke} from 'src/spoke/interfaces/IVaultSpoke.sol'; import {SafeERC20, IERC20} from 'src/dependencies/openzeppelin/SafeERC20.sol'; -import {MathUtils} from 'src/libraries/math/MathUtils.sol'; -import {EIP712Hash, EIP712Types} from 'src/libraries/EIP712Hash.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 {IERC4626, IERC20Metadata} from 'src/dependencies/openzeppelin/IERC4626.sol'; +import {IERC20Permit} from 'src/dependencies/openzeppelin/IERC20Permit.sol'; +import {IVaultSpoke} from 'src/spoke/interfaces/IVaultSpoke.sol'; +import {IHub} from 'src/hub/interfaces/IHub.sol'; + /// @title VaultSpoke /// @author Aave Labs /// @notice ERC4626 compliant vault for hub's listed asset position management. From 7132cd22a157a2b60fadd5a3e761852979fabaf4 Mon Sep 17 00:00:00 2001 From: DhairyaSethi <55102840+DhairyaSethi@users.noreply.github.com> Date: Mon, 1 Dec 2025 19:10:54 -0500 Subject: [PATCH 06/17] fix: compile --- tests/unit/misc/EIP712Hash.t.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/misc/EIP712Hash.t.sol b/tests/unit/misc/EIP712Hash.t.sol index 2a5b29148..a938a596e 100644 --- a/tests/unit/misc/EIP712Hash.t.sol +++ b/tests/unit/misc/EIP712Hash.t.sol @@ -4,7 +4,7 @@ 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 *; From 00e56e6f666174aaf4dddd507f2866a991cfa9f1 Mon Sep 17 00:00:00 2001 From: DhairyaSethi <55102840+DhairyaSethi@users.noreply.github.com> Date: Mon, 1 Dec 2025 19:11:14 -0500 Subject: [PATCH 07/17] chore: natspec discussion pt --- src/spoke/VaultSpoke.sol | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/spoke/VaultSpoke.sol b/src/spoke/VaultSpoke.sol index 0fdb36447..ae327fda9 100644 --- a/src/spoke/VaultSpoke.sol +++ b/src/spoke/VaultSpoke.sol @@ -354,7 +354,13 @@ abstract contract VaultSpoke is IVaultSpoke, ERC20Upgradeable, NoncesKeyed, EIP7 return assets; } - function _deposit(address caller, address receiver, uint256 assets, uint256 shares) internal { + /// @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(assetId(), assets); _mint(receiver, shares); From 43c56e6a1cc4f2bf6cc65a5158a163c8060cbb46 Mon Sep 17 00:00:00 2001 From: DhairyaSethi <55102840+DhairyaSethi@users.noreply.github.com> Date: Mon, 1 Dec 2025 19:27:28 -0500 Subject: [PATCH 08/17] chore: use `h` share symbol prefix --- src/spoke/VaultSpoke.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/spoke/VaultSpoke.sol b/src/spoke/VaultSpoke.sol index ae327fda9..86920da87 100644 --- a/src/spoke/VaultSpoke.sol +++ b/src/spoke/VaultSpoke.sol @@ -45,7 +45,7 @@ abstract contract VaultSpoke is IVaultSpoke, ERC20Upgradeable, NoncesKeyed, EIP7 function __VaultSpoke_init(string memory prefix) internal onlyInitializing { __ERC20_init( string.concat(prefix, IERC20Metadata(_ASSET).name()), - string.concat('s', IERC20Metadata(_ASSET).symbol()) + string.concat('h', IERC20Metadata(_ASSET).symbol()) ); } From 383f2db93eb5427f8a5a226b386d25f4f7ebbbd5 Mon Sep 17 00:00:00 2001 From: DhairyaSethi <55102840+DhairyaSethi@users.noreply.github.com> Date: Fri, 12 Dec 2025 18:45:03 +0530 Subject: [PATCH 09/17] chore: natspec --- .gitmodules | 3 ++ lib/erc4626-tests | 1 + src/spoke/interfaces/IVaultSpoke.sol | 68 +++++++++++++++++++++++----- 3 files changed, 61 insertions(+), 11 deletions(-) create mode 160000 lib/erc4626-tests 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/lib/erc4626-tests b/lib/erc4626-tests new file mode 160000 index 000000000..ac485460e --- /dev/null +++ b/lib/erc4626-tests @@ -0,0 +1 @@ +Subproject commit ac485460e014f22807c1ff687e0b4dc3af96ee40 diff --git a/src/spoke/interfaces/IVaultSpoke.sol b/src/spoke/interfaces/IVaultSpoke.sol index 41ad46abb..99f049433 100644 --- a/src/spoke/interfaces/IVaultSpoke.sol +++ b/src/spoke/interfaces/IVaultSpoke.sol @@ -2,20 +2,17 @@ // Copyright (c) 2025 Aave Labs pragma solidity ^0.8.0; -import {IHub} from 'src/hub/interfaces/IHub.sol'; 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 { +interface IVaultSpoke is IERC4626, IERC2612, INoncesKeyed { /// @notice Thrown when the given signature is invalid. error InvalidSignature(); - /// @notice Thrown when the given address is invalid. - error InvalidAddress(); - /// @notice Thrown when the maximum deposit limit is exceeded. error MaxDepositExceeded(uint256 maxDeposit, uint256 requestedAssets); @@ -28,26 +25,50 @@ interface IVaultSpoke is IERC4626, IERC2612 { /// @notice Thrown when the maximum redeem limit is exceeded. error MaxRedeemExceeded(uint256 maxRedeem, uint256 requestedShares); + /// @notice Deposits assets into the vault with a signature. + /// @param params The parameters for the deposit. + /// @param signature The signature of the deposit. + /// @return The amount of shares minted. function depositWithSig( EIP712Types.VaultDeposit calldata params, bytes calldata signature - ) external returns (uint256 shares); + ) external returns (uint256); + /// @notice Mints shares into the vault with a signature. + /// @param params The parameters for the mint. + /// @param signature The signature of the mint. + /// @return The amount of assets deposited. function mintWithSig( EIP712Types.VaultMint calldata params, bytes calldata signature - ) external returns (uint256 assets); + ) external returns (uint256); + /// @notice Withdraws assets from the vault with a signature. + /// @param params The parameters for the withdraw. + /// @param signature The signature of the withdraw. + /// @return The amount of shares withdrawn. function withdrawWithSig( EIP712Types.VaultWithdraw calldata params, bytes calldata signature - ) external returns (uint256 shares); + ) external returns (uint256); + /// @notice Redeems shares from the vault with a signature. + /// @param params The parameters for the redeem. + /// @param signature The signature of the redeem. + /// @return The amount of assets withdrawn. function redeemWithSig( EIP712Types.VaultRedeem calldata params, bytes calldata signature - ) external returns (uint256 assets); + ) external returns (uint256); + /// @notice Deposits assets into the vault with an underlying asset 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, @@ -55,11 +76,36 @@ interface IVaultSpoke is IERC4626, IERC2612 { uint8 v, bytes32 r, bytes32 s - ) external returns (uint256 shares); + ) 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 (IHub); + 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 signatures. + 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); } From d025ab53d8026e4bd2526942222d35498abbb661 Mon Sep 17 00:00:00 2001 From: DhairyaSethi <55102840+DhairyaSethi@users.noreply.github.com> Date: Fri, 12 Dec 2025 18:45:45 +0530 Subject: [PATCH 10/17] fix: maxWithdrawRedeem impl to account for liquidity, feat: renounceAllowance --- src/spoke/VaultSpoke.sol | 111 ++++++++++++++++++++++++++++----------- 1 file changed, 81 insertions(+), 30 deletions(-) diff --git a/src/spoke/VaultSpoke.sol b/src/spoke/VaultSpoke.sol index 86920da87..e52896460 100644 --- a/src/spoke/VaultSpoke.sol +++ b/src/spoke/VaultSpoke.sol @@ -44,7 +44,7 @@ abstract contract VaultSpoke is IVaultSpoke, ERC20Upgradeable, NoncesKeyed, EIP7 function __VaultSpoke_init(string memory prefix) internal onlyInitializing { __ERC20_init( - string.concat(prefix, IERC20Metadata(_ASSET).name()), + string.concat(prefix, ' ', IERC20Metadata(_ASSET).name()), string.concat('h', IERC20Metadata(_ASSET).symbol()) ); } @@ -81,7 +81,7 @@ abstract contract VaultSpoke is IVaultSpoke, ERC20Upgradeable, NoncesKeyed, EIP7 function depositWithSig( EIP712Types.VaultDeposit calldata params, bytes calldata signature - ) public returns (uint256) { + ) external returns (uint256) { require(block.timestamp <= params.deadline, InvalidSignature()); bytes32 digest = _hashTypedData(params.hash()); require( @@ -101,7 +101,7 @@ abstract contract VaultSpoke is IVaultSpoke, ERC20Upgradeable, NoncesKeyed, EIP7 function mintWithSig( EIP712Types.VaultMint calldata params, bytes calldata signature - ) public returns (uint256) { + ) external returns (uint256) { require(block.timestamp <= params.deadline, InvalidSignature()); bytes32 digest = _hashTypedData(params.hash()); require( @@ -117,7 +117,7 @@ abstract contract VaultSpoke is IVaultSpoke, ERC20Upgradeable, NoncesKeyed, EIP7 function withdrawWithSig( EIP712Types.VaultWithdraw calldata params, bytes calldata signature - ) public returns (uint256) { + ) external returns (uint256) { require(block.timestamp <= params.deadline, InvalidSignature()); bytes32 digest = _hashTypedData(params.hash()); require( @@ -127,7 +127,7 @@ abstract contract VaultSpoke is IVaultSpoke, ERC20Upgradeable, NoncesKeyed, EIP7 _useCheckedNonce(params.owner, params.nonce); return _executeWithdraw({ - caller: msg.sender, + caller: params.owner, receiver: params.receiver, owner: params.owner, assets: params.assets @@ -137,8 +137,8 @@ abstract contract VaultSpoke is IVaultSpoke, ERC20Upgradeable, NoncesKeyed, EIP7 /// @inheritdoc IVaultSpoke function redeemWithSig( EIP712Types.VaultRedeem calldata params, - bytes memory signature - ) public returns (uint256) { + bytes calldata signature + ) external returns (uint256) { require(block.timestamp <= params.deadline, InvalidSignature()); bytes32 digest = _hashTypedData(params.hash()); require( @@ -148,7 +148,7 @@ abstract contract VaultSpoke is IVaultSpoke, ERC20Upgradeable, NoncesKeyed, EIP7 _useCheckedNonce(params.owner, params.nonce); return _executeRedeem({ - caller: msg.sender, + caller: params.owner, receiver: params.receiver, owner: params.owner, shares: params.shares @@ -165,7 +165,7 @@ abstract contract VaultSpoke is IVaultSpoke, ERC20Upgradeable, NoncesKeyed, EIP7 bytes32 s ) external returns (uint256) { try - IERC20Permit(asset()).permit({ + IERC20Permit(_ASSET).permit({ owner: msg.sender, // deposit only mints for caller spender: address(this), value: assets, @@ -205,39 +205,47 @@ abstract contract VaultSpoke is IVaultSpoke, ERC20Upgradeable, NoncesKeyed, EIP7 _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(assetId(), assets); + return _HUB.previewAddByAssets(_ASSET_ID, assets); } /// @inheritdoc IERC4626 function previewMint(uint256 shares) public view virtual returns (uint256) { - return hub().previewAddByShares(assetId(), shares); + return _HUB.previewAddByShares(_ASSET_ID, shares); } /// @inheritdoc IERC4626 function previewWithdraw(uint256 assets) public view virtual returns (uint256) { - return hub().previewRemoveByAssets(assetId(), assets); + return _HUB.previewRemoveByAssets(_ASSET_ID, assets); } /// @inheritdoc IERC4626 function previewRedeem(uint256 shares) public view virtual returns (uint256) { - return hub().previewRemoveByShares(assetId(), shares); + return _HUB.previewRemoveByShares(_ASSET_ID, shares); } /// @inheritdoc IERC4626 - function convertToShares(uint256 assets) public view returns (uint256) { + function convertToShares(uint256 assets) external view returns (uint256) { return previewDeposit(assets); } /// @inheritdoc IERC4626 - function convertToAssets(uint256 shares) public view returns (uint256) { + function convertToAssets(uint256 shares) external view returns (uint256) { return previewMint(shares); } /// @inheritdoc IERC4626 function maxDeposit(address) public view returns (uint256) { - IHub.SpokeConfig memory config = hub().getSpokeConfig(assetId(), address(this)); + IHub.SpokeConfig memory config = _HUB.getSpokeConfig(_ASSET_ID, address(this)); if (!config.active || config.paused) { return 0; } @@ -257,16 +265,16 @@ abstract contract VaultSpoke is IVaultSpoke, ERC20Upgradeable, NoncesKeyed, EIP7 /// @inheritdoc IERC4626 function maxWithdraw(address owner) public view returns (uint256) { - return previewRedeem(maxRedeem(owner)); + uint256 maxRemovableAssets = _maxRemovableAssets(); + uint256 balance = previewRedeem(balanceOf(owner)); + return balance.min(maxRemovableAssets); } /// @inheritdoc IERC4626 function maxRedeem(address owner) public view returns (uint256) { - IHub.SpokeConfig memory config = hub().getSpokeConfig(assetId(), address(this)); - if (!config.active || config.paused) { - return 0; - } - return balanceOf(owner); + uint256 maxRemovableShares = previewWithdraw(_maxRemovableAssets()); + uint256 balance = balanceOf(owner); + return balance.min(maxRemovableShares); } /// @inheritdoc IERC4626 @@ -275,8 +283,8 @@ abstract contract VaultSpoke is IVaultSpoke, ERC20Upgradeable, NoncesKeyed, EIP7 } /// @inheritdoc IVaultSpoke - function hub() public view returns (IHub) { - return _HUB; + function hub() public view returns (address) { + return address(_HUB); } /// @inheritdoc IVaultSpoke @@ -294,14 +302,49 @@ abstract contract VaultSpoke is IVaultSpoke, ERC20Upgradeable, NoncesKeyed, EIP7 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 IERC20Permit - function nonces(address owner) public view returns (uint256) { - return nonces({owner: owner, key: _PERMIT_NONCE_KEY}); + /// @inheritdoc IVaultSpoke + function MAX_ALLOWED_SPOKE_CAP() external view returns (uint40) { + return _MAX_ALLOWED_SPOKE_CAP; + } + + /// @inheritdoc IVaultSpoke + function PERMIT_NONCE_KEY() external pure returns (uint192) { + return _PERMIT_NONCE_KEY; + } + + /// @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( @@ -361,8 +404,8 @@ abstract contract VaultSpoke is IVaultSpoke, ERC20Upgradeable, NoncesKeyed, EIP7 uint256 assets, uint256 shares ) internal virtual { - IERC20(asset()).safeTransferFrom(caller, address(hub()), assets); - hub().add(assetId(), assets); + IERC20(_ASSET).safeTransferFrom(caller, address(_HUB), assets); + _HUB.add(_ASSET_ID, assets); _mint(receiver, shares); emit Deposit(caller, receiver, assets, shares); } @@ -377,11 +420,19 @@ abstract contract VaultSpoke is IVaultSpoke, ERC20Upgradeable, NoncesKeyed, EIP7 if (caller != owner) { _spendAllowance({owner: owner, spender: caller, value: shares}); } - hub().remove(assetId(), assets, receiver); + _HUB.remove(_ASSET_ID, assets, receiver); _burn(owner, shares); emit Withdraw(caller, receiver, owner, assets, 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'); } From 24e9e9e57c4d153d3d3c6439f3d65a79c8b39e60 Mon Sep 17 00:00:00 2001 From: DhairyaSethi <55102840+DhairyaSethi@users.noreply.github.com> Date: Fri, 12 Dec 2025 18:46:16 +0530 Subject: [PATCH 11/17] chore: rm unused --- src/spoke/instances/VaultSpokeInstance.sol | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/spoke/instances/VaultSpokeInstance.sol b/src/spoke/instances/VaultSpokeInstance.sol index e59d56b6d..693e9212e 100644 --- a/src/spoke/instances/VaultSpokeInstance.sol +++ b/src/spoke/instances/VaultSpokeInstance.sol @@ -3,7 +3,6 @@ pragma solidity 0.8.28; import {VaultSpoke} from 'src/spoke/VaultSpoke.sol'; -import {IERC20Metadata, IERC20} from 'src/dependencies/openzeppelin/IERC20Metadata.sol'; /// @title VaultSpokeInstance /// @author Aave Labs @@ -20,7 +19,6 @@ contract VaultSpokeInstance is VaultSpoke { /// @notice Initializer. function initialize(string memory prefix) external override reinitializer(SPOKE_REVISION) { - // todo: upgrade validation that hub/assetId remains unchanged? __VaultSpoke_init(prefix); } } From 572a4b66bd62d1554b4aa9b21e04dcf2843db5ef Mon Sep 17 00:00:00 2001 From: DhairyaSethi <55102840+DhairyaSethi@users.noreply.github.com> Date: Fri, 12 Dec 2025 18:48:31 +0530 Subject: [PATCH 12/17] feat: tests --- foundry.lock | 6 + lib/erc4626-tests | 2 +- remappings.txt | 2 + snapshots/VaultSpoke.Operations.json | 16 ++ tests/Base.t.sol | 113 +++++++++- tests/Utils.sol | 12 +- tests/gas/VaultSpoke.Operations.gas.t.sol | 157 ++++++++++++++ tests/mocks/JsonBindings.sol | 152 +++++++++++++ tests/mocks/MockVaultSpokeInstance.sol | 28 +++ tests/unit/Hub/Hub.Add.t.sol | 2 +- tests/unit/Hub/Hub.Draw.t.sol | 2 +- tests/unit/Hub/Hub.EliminateDeficit.t.sol | 2 +- tests/unit/Hub/Hub.RefreshPremium.t.sol | 2 +- tests/unit/Hub/Hub.Remove.t.sol | 2 +- tests/unit/Hub/Hub.Restore.t.sol | 2 +- tests/unit/Spoke/Spoke.Upgradeable.t.sol | 8 - tests/unit/VaultSpoke/VaultSpoke.Base.t.sol | 186 ++++++++++++++++ .../VaultSpoke/VaultSpoke.Constants.t.sol | 94 ++++++++ .../VaultSpoke.DepositWithPermit.t.sol | 165 ++++++++++++++ .../VaultSpoke.ERC4626Compliance.t.sol | 40 ++++ .../VaultSpoke/VaultSpoke.MaxGetters.t.sol | 203 ++++++++++++++++++ tests/unit/VaultSpoke/VaultSpoke.Permit.t.sol | 136 ++++++++++++ ...tSpoke.Reverts.InsufficientAllowance.t.sol | 97 +++++++++ .../VaultSpoke/VaultSpoke.Upgradeable.t.sol | 158 ++++++++++++++ ...oke.WithSig.Reverts.InvalidSignature.t.sol | 163 ++++++++++++++ .../unit/VaultSpoke/VaultSpoke.WithSig.t.sol | 112 ++++++++++ tests/unit/misc/EIP712Hash.t.sol | 131 +++++------ .../SignatureGateway.Base.t.sol | 17 +- 28 files changed, 1903 insertions(+), 107 deletions(-) create mode 100644 remappings.txt create mode 100644 snapshots/VaultSpoke.Operations.json create mode 100644 tests/gas/VaultSpoke.Operations.gas.t.sol create mode 100644 tests/mocks/MockVaultSpokeInstance.sol create mode 100644 tests/unit/VaultSpoke/VaultSpoke.Base.t.sol create mode 100644 tests/unit/VaultSpoke/VaultSpoke.Constants.t.sol create mode 100644 tests/unit/VaultSpoke/VaultSpoke.DepositWithPermit.t.sol create mode 100644 tests/unit/VaultSpoke/VaultSpoke.ERC4626Compliance.t.sol create mode 100644 tests/unit/VaultSpoke/VaultSpoke.MaxGetters.t.sol create mode 100644 tests/unit/VaultSpoke/VaultSpoke.Permit.t.sol create mode 100644 tests/unit/VaultSpoke/VaultSpoke.Reverts.InsufficientAllowance.t.sol create mode 100644 tests/unit/VaultSpoke/VaultSpoke.Upgradeable.t.sol create mode 100644 tests/unit/VaultSpoke/VaultSpoke.WithSig.Reverts.InvalidSignature.t.sol create mode 100644 tests/unit/VaultSpoke/VaultSpoke.WithSig.t.sol 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 index ac485460e..232ff9ba8 160000 --- a/lib/erc4626-tests +++ b/lib/erc4626-tests @@ -1 +1 @@ -Subproject commit ac485460e014f22807c1ff687e0b4dc3af96ee40 +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..f7bad1123 --- /dev/null +++ b/snapshots/VaultSpoke.Operations.json @@ -0,0 +1,16 @@ +{ + "deposit": "117334", + "depositWithSig": "151404", + "mint": "117416", + "mintWithSig": "151518", + "redeem: on behalf, full": "98259", + "redeem: on behalf, partial": "122259", + "redeem: self, full": "97401", + "redeem: self, partial": "116601", + "redeemWithSig": "149844", + "withdraw: on behalf, full": "98237", + "withdraw: on behalf, partial": "122237", + "withdraw: self, full": "97379", + "withdraw: self, partial": "116579", + "withdrawWithSig": "149876" +} \ No newline at end of file diff --git a/tests/Base.t.sol b/tests/Base.t.sol index 424bc3683..f62e8c7b0 100644 --- a/tests/Base.t.sol +++ b/tests/Base.t.sol @@ -12,6 +12,7 @@ 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'; @@ -59,6 +60,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 +98,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 +144,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 +286,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 +1212,7 @@ abstract contract Base is Test { return spokeInfo[spoke].usdz.reserveId; } - function _updateSpokePaused( + function updateSpokePaused( IHub hub, uint256 assetId, address spoke, @@ -1220,6 +1240,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 +2279,50 @@ abstract contract Base is Test { return (spoke, oracle); } + function _deployVaultSpoke( + IHub hub, + uint256 assetId, + string memory prefix, + 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, (prefix)) + ) + ); + return vaultSpoke; + } + + function _configureVaultSpoke(IVaultSpoke vaultSpoke, IHub hub, uint256 assetId) internal { + return + _configureVaultSpoke( + vaultSpoke, + hub, + assetId, + IHub.SpokeConfig({ + addCap: type(uint40).max, + drawCap: 0, + riskPremiumThreshold: 0, + active: true, + paused: false + }) + ); + } + + function _configureVaultSpoke( + IVaultSpoke vaultSpoke, + IHub hub, + uint256 assetId, + 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 +2887,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 +2903,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 +2961,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/VaultSpoke.Operations.gas.t.sol b/tests/gas/VaultSpoke.Operations.gas.t.sol new file mode 100644 index 000000000..768d92440 --- /dev/null +++ b/tests/gas/VaultSpoke.Operations.gas.t.sol @@ -0,0 +1,157 @@ +// 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 { + // (address owner, uint256 ownerPk) = makeAddrAndKey('owner'); + // vm.label(owner, 'owner'); + // address spender = makeAddr('spender'); + + // EIP712Types.Permit memory permit = EIP712Types.Permit({ + // owner: owner, + // spender: spender, + // value: 1000e18, + // nonce: vaultDai.nonces(owner), + // deadline: vm.getBlockTimestamp() + 1 days + // }); + // bytes32 digest = _getTypedDataHash(vaultDai, permit); + // (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPk, digest); + + // vaultDai.permit(owner, spender, permit.value, permit.deadline, v, r, s); + // vm.snapshotGasLastCall(NAMESPACE, 'permit: first'); + + // permit.nonce = vaultDai.nonces(owner); + // permit.value = 2000e18; + // digest = _getTypedDataHash(vaultDai, permit); + // (v, r, s) = vm.sign(ownerPk, digest); + + // vaultDai.permit(owner, spender, permit.value, permit.deadline, v, r, s); + // vm.snapshotGasLastCall(NAMESPACE, 'permit: second action'); + // } +} 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..7526a60bf --- /dev/null +++ b/tests/mocks/MockVaultSpokeInstance.sol @@ -0,0 +1,28 @@ +// 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 prefix) external override reinitializer(SPOKE_REVISION) { + __VaultSpoke_init(prefix); + } +} 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/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..93fa1063f --- /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 PREFIX = 'Core Hub'; + + function setUp() public virtual override { + deployFixtures(); + initEnvironment(); + daiVault = _deployVaultSpoke(hub1, daiAssetId, PREFIX, ADMIN); + _configureVaultSpoke(daiVault, hub1, daiAssetId); + } + + 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(), string.concat(PREFIX, ' DAI')); + assertEq(daiVault.symbol(), string.concat('h', 'DAI')); + 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.MAX_ALLOWED_SPOKE_CAP(), hub1.MAX_ALLOWED_SPOKE_CAP()); + + 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..e4c8c026d --- /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, PREFIX, 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, PREFIX, 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..2969f2429 --- /dev/null +++ b/tests/unit/VaultSpoke/VaultSpoke.DepositWithPermit.t.sol @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import 'tests/unit/VaultSpoke/VaultSpoke.Base.t.sol'; +import {IERC4626} from 'src/dependencies/openzeppelin/IERC4626.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_deposit_executes_after_permit() 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 balanceBefore = vault.balanceOf(receiver); + uint256 assetBalanceBefore = asset.balanceOf(user); + + vm.prank(user); + vault.depositWithPermit(assets, receiver, params.deadline, v, r, s); + + assertGt(vault.balanceOf(receiver), balanceBefore); + assertEq(asset.balanceOf(user), assetBalanceBefore - assets); + assertEq(asset.allowance(user, address(vault)), 0); + } + + 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); + } +} diff --git a/tests/unit/VaultSpoke/VaultSpoke.ERC4626Compliance.t.sol b/tests/unit/VaultSpoke/VaultSpoke.ERC4626Compliance.t.sol new file mode 100644 index 000000000..a4a69176a --- /dev/null +++ b/tests/unit/VaultSpoke/VaultSpoke.ERC4626Compliance.t.sol @@ -0,0 +1,40 @@ +// 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(); + + _underlying_ = daiVault.asset(); + _vault_ = address(daiVault); + + _delta_ = 0; + _vaultMayBeEmpty = true; // inflation protection through virtual shares on hub + _unlimitedAmount = false; + } + + function setUpYield(Init memory init) public override { + if (init.yield > 0) { + init.yield = bound(init.yield, 1, int(MAX_SUPPLY_AMOUNT)); + uint gain = uint(init.yield); + uint owedBefore = hub1.getAssetTotalOwed(daiAssetId); + + tokenList.dai.mint(address(hub1), gain); + vm.startPrank(address(spoke2)); + hub1.add(daiAssetId, gain); + _mockInterestRateBps(100_00); // 100% interest rate + hub1.draw(daiAssetId, gain, address(spoke2)); + skip(365 days); + tokenList.dai.transfer(address(hub1), gain); + hub1.restore(daiAssetId, gain, IHubBase.PremiumDelta(0, 0, 0)); + vm.stopPrank(); + + uint owedAfter = hub1.getAssetTotalOwed(daiAssetId); + assertApproxEqAbs(owedAfter, owedBefore + gain, 1); + } + } +} diff --git a/tests/unit/VaultSpoke/VaultSpoke.MaxGetters.t.sol b/tests/unit/VaultSpoke/VaultSpoke.MaxGetters.t.sol new file mode 100644 index 000000000..dfc605ffc --- /dev/null +++ b/tests/unit/VaultSpoke/VaultSpoke.MaxGetters.t.sol @@ -0,0 +1,203 @@ +// 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() public 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.add(IHubBase(vault.hub()), vault.assetId(), address(vault), amount, address(this)); + 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..6fcd14741 --- /dev/null +++ b/tests/unit/VaultSpoke/VaultSpoke.Reverts.InsufficientAllowance.t.sol @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import 'tests/unit/VaultSpoke/VaultSpoke.Base.t.sol'; +import {IERC4626} from 'src/dependencies/openzeppelin/IERC4626.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..32a2aeb90 --- /dev/null +++ b/tests/unit/VaultSpoke/VaultSpoke.Upgradeable.t.sol @@ -0,0 +1,158 @@ +// 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(PREFIX); + } + + 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, (PREFIX)) + ) + ) + ); + + assertEq(address(vaultProxy), vaultProxyAddress); + assertEq(_getProxyAdminAddress(address(vaultProxy)), proxyAdminAddress); + assertEq(_getImplementationAddress(address(vaultProxy)), address(vaultImpl)); + + assertEq(_getProxyInitializedVersion(address(vaultProxy)), revision); + assertEq(vaultProxy.name(), string.concat(PREFIX, ' ', tokenList.dai.name())); + assertEq(vaultProxy.symbol(), string.concat('h', tokenList.dai.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, (PREFIX)) + ) + ) + ); + + string memory originalName = IVaultSpoke(address(vaultProxy)).name(); + + uint64 secondRevision = uint64(vm.randomUint(initialRevision + 1, type(uint64).max)); + VaultSpokeInstance vaultImpl2 = _deployMockVaultSpokeInstance(secondRevision); + + string memory newPrefix = 'New Prefix'; + vm.expectEmit(address(vaultProxy)); + emit Initializable.Initialized(secondRevision); + vm.recordLogs(); + vm.prank(_getProxyAdminAddress(address(vaultProxy))); + vaultProxy.upgradeToAndCall(address(vaultImpl2), _getInitializeCalldata(newPrefix)); + + assertEq( + IVaultSpoke(address(vaultProxy)).name(), + string.concat(newPrefix, ' ', tokenList.dai.name()) + ); + assertEq(IVaultSpoke(address(vaultProxy)).symbol(), string.concat('h', tokenList.dai.symbol())); + assertNotEq(IVaultSpoke(address(vaultProxy)).name(), originalName); + // Symbol doesn't change with prefix - it's always 'h' + asset.symbol() + } + + 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, (PREFIX)) + ); + } + + 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(PREFIX) + ) + ) + ); + + vm.expectRevert(Initializable.InvalidInitialization.selector); + vm.prank(_getProxyAdminAddress(address(vaultProxy))); + vaultProxy.upgradeToAndCall(address(vaultImpl), _getInitializeCalldata(PREFIX)); + + 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(PREFIX)); + } + + function test_proxy_reinitialization_revertsWith_CallerNotProxyAdmin() public { + VaultSpokeInstance vaultImpl = _deployMockVaultSpokeInstance(1); + ITransparentUpgradeableProxy vaultProxy = ITransparentUpgradeableProxy( + address( + new TransparentUpgradeableProxy( + address(vaultImpl), + proxyAdminOwner, + _getInitializeCalldata(PREFIX) + ) + ) + ); + + VaultSpokeInstance vaultImpl2 = _deployMockVaultSpokeInstance(2); + vm.expectRevert(); + vm.prank(makeUser()); + vaultProxy.upgradeToAndCall(address(vaultImpl2), _getInitializeCalldata(PREFIX)); + } + + function _getInitializeCalldata(string memory prefix) internal pure returns (bytes memory) { + return abi.encodeCall(VaultSpokeInstance.initialize, prefix); + } + + 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..ac8b0b868 --- /dev/null +++ b/tests/unit/VaultSpoke/VaultSpoke.WithSig.Reverts.InvalidSignature.t.sol @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import 'tests/unit/VaultSpoke/VaultSpoke.Base.t.sol'; +import {IERC4626} from 'src/dependencies/openzeppelin/IERC4626.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..fa0783daa --- /dev/null +++ b/tests/unit/VaultSpoke/VaultSpoke.WithSig.t.sol @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: UNLICENSED +// Copyright (c) 2025 Aave Labs +pragma solidity ^0.8.0; + +import 'tests/unit/VaultSpoke/VaultSpoke.Base.t.sol'; +import {IERC4626} from 'src/dependencies/openzeppelin/IERC4626.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 a938a596e..4973f5949 100644 --- a/tests/unit/misc/EIP712Hash.t.sol +++ b/tests/unit/misc/EIP712Hash.t.sol @@ -10,6 +10,12 @@ 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 + }); } } From e0fc16cf2df6b935e5579051a05591a7d7bb30af Mon Sep 17 00:00:00 2001 From: DhairyaSethi <55102840+DhairyaSethi@users.noreply.github.com> Date: Fri, 12 Dec 2025 19:00:23 +0530 Subject: [PATCH 13/17] chore: explicit share symbol --- src/spoke/VaultSpoke.sol | 12 ++--- src/spoke/instances/VaultSpokeInstance.sol | 9 ++-- tests/Base.t.sol | 5 +- tests/mocks/MockVaultSpokeInstance.sol | 7 ++- tests/unit/VaultSpoke/VaultSpoke.Base.t.sol | 9 ++-- .../VaultSpoke/VaultSpoke.Constants.t.sol | 4 +- .../VaultSpoke/VaultSpoke.Upgradeable.t.sol | 54 +++++++++++-------- 7 files changed, 60 insertions(+), 40 deletions(-) diff --git a/src/spoke/VaultSpoke.sol b/src/spoke/VaultSpoke.sol index e52896460..8c9fa4b56 100644 --- a/src/spoke/VaultSpoke.sol +++ b/src/spoke/VaultSpoke.sol @@ -40,13 +40,13 @@ abstract contract VaultSpoke is IVaultSpoke, ERC20Upgradeable, NoncesKeyed, EIP7 (_ASSET, _DECIMALS) = _HUB.getAssetUnderlyingAndDecimals(_ASSET_ID); } - function initialize(string memory prefix) external virtual; + function initialize(string memory shareName, string memory shareSymbol) external virtual; - function __VaultSpoke_init(string memory prefix) internal onlyInitializing { - __ERC20_init( - string.concat(prefix, ' ', IERC20Metadata(_ASSET).name()), - string.concat('h', IERC20Metadata(_ASSET).symbol()) - ); + function __VaultSpoke_init( + string memory shareName, + string memory shareSymbol + ) internal onlyInitializing { + __ERC20_init(shareName, shareSymbol); } /// @inheritdoc IERC4626 diff --git a/src/spoke/instances/VaultSpokeInstance.sol b/src/spoke/instances/VaultSpokeInstance.sol index 693e9212e..aad7d218b 100644 --- a/src/spoke/instances/VaultSpokeInstance.sol +++ b/src/spoke/instances/VaultSpokeInstance.sol @@ -12,13 +12,16 @@ contract VaultSpokeInstance is VaultSpoke { /// @dev Constructor. /// @param hub_ The address of the hub. - /// @param assetId_ The ID of the asset. + /// @param assetId_ The identifier of the asset. constructor(address hub_, uint256 assetId_) VaultSpoke(hub_, assetId_) { _disableInitializers(); } /// @notice Initializer. - function initialize(string memory prefix) external override reinitializer(SPOKE_REVISION) { - __VaultSpoke_init(prefix); + function initialize( + string memory shareName, + string memory shareSymbol + ) external override reinitializer(SPOKE_REVISION) { + __VaultSpoke_init(shareName, shareSymbol); } } diff --git a/tests/Base.t.sol b/tests/Base.t.sol index f62e8c7b0..e841de0b2 100644 --- a/tests/Base.t.sol +++ b/tests/Base.t.sol @@ -2282,7 +2282,8 @@ abstract contract Base is Test { function _deployVaultSpoke( IHub hub, uint256 assetId, - string memory prefix, + string memory shareName, + string memory shareSymbol, address proxyAdminOwner ) internal pausePrank returns (IVaultSpoke) { address vaultSpokeImpl = address(new VaultSpokeInstance(address(hub), assetId)); @@ -2291,7 +2292,7 @@ abstract contract Base is Test { makeAddr('deployer'), vaultSpokeImpl, proxyAdminOwner, - abi.encodeCall(VaultSpokeInstance.initialize, (prefix)) + abi.encodeCall(VaultSpokeInstance.initialize, (shareName, shareSymbol)) ) ); return vaultSpoke; diff --git a/tests/mocks/MockVaultSpokeInstance.sol b/tests/mocks/MockVaultSpokeInstance.sol index 7526a60bf..226424793 100644 --- a/tests/mocks/MockVaultSpokeInstance.sol +++ b/tests/mocks/MockVaultSpokeInstance.sol @@ -22,7 +22,10 @@ contract MockVaultSpokeInstance is VaultSpoke { } /// @inheritdoc VaultSpoke - function initialize(string memory prefix) external override reinitializer(SPOKE_REVISION) { - __VaultSpoke_init(prefix); + function initialize( + string memory shareName, + string memory shareSymbol + ) external override reinitializer(SPOKE_REVISION) { + __VaultSpoke_init(shareName, shareSymbol); } } diff --git a/tests/unit/VaultSpoke/VaultSpoke.Base.t.sol b/tests/unit/VaultSpoke/VaultSpoke.Base.t.sol index 93fa1063f..11b5758a2 100644 --- a/tests/unit/VaultSpoke/VaultSpoke.Base.t.sol +++ b/tests/unit/VaultSpoke/VaultSpoke.Base.t.sol @@ -6,12 +6,13 @@ import 'tests/Base.t.sol'; contract VaultSpokeBaseTest is Base { IVaultSpoke public daiVault; - string public constant PREFIX = 'Core Hub'; + 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, PREFIX, ADMIN); + daiVault = _deployVaultSpoke(hub1, daiAssetId, SHARE_NAME, SHARE_SYMBOL, ADMIN); _configureVaultSpoke(daiVault, hub1, daiAssetId); } @@ -156,8 +157,8 @@ contract VaultSpokeInitTest is VaultSpokeBaseTest { } function test_setUp() public { - assertEq(daiVault.name(), string.concat(PREFIX, ' DAI')); - assertEq(daiVault.symbol(), string.concat('h', 'DAI')); + assertEq(daiVault.name(), SHARE_NAME); + assertEq(daiVault.symbol(), SHARE_SYMBOL); assertEq(daiVault.decimals(), tokenList.dai.decimals()); assertEq(daiVault.asset(), address(tokenList.dai)); diff --git a/tests/unit/VaultSpoke/VaultSpoke.Constants.t.sol b/tests/unit/VaultSpoke/VaultSpoke.Constants.t.sol index e4c8c026d..ced201ba9 100644 --- a/tests/unit/VaultSpoke/VaultSpoke.Constants.t.sol +++ b/tests/unit/VaultSpoke/VaultSpoke.Constants.t.sol @@ -6,7 +6,7 @@ import 'tests/unit/VaultSpoke/VaultSpoke.Base.t.sol'; contract VaultSpokeConstantsTest is VaultSpokeBaseTest { function test_eip712Domain() public { - IVaultSpoke instance = _deployVaultSpoke(hub1, daiAssetId, PREFIX, ADMIN); + IVaultSpoke instance = _deployVaultSpoke(hub1, daiAssetId, 'Core Hub DAI', 'chDAI', ADMIN); ( bytes1 fields, string memory name, @@ -27,7 +27,7 @@ contract VaultSpokeConstantsTest is VaultSpokeBaseTest { } function test_DOMAIN_SEPARATOR() public { - IVaultSpoke instance = _deployVaultSpoke(hub1, daiAssetId, PREFIX, ADMIN); + IVaultSpoke instance = _deployVaultSpoke(hub1, daiAssetId, 'Core Hub DAI', 'chDAI', ADMIN); bytes32 expectedDomainSeparator = keccak256( abi.encode( keccak256( diff --git a/tests/unit/VaultSpoke/VaultSpoke.Upgradeable.t.sol b/tests/unit/VaultSpoke/VaultSpoke.Upgradeable.t.sol index 32a2aeb90..c08b89a66 100644 --- a/tests/unit/VaultSpoke/VaultSpoke.Upgradeable.t.sol +++ b/tests/unit/VaultSpoke/VaultSpoke.Upgradeable.t.sol @@ -20,7 +20,7 @@ contract VaultSpokeUpgradeableTest is VaultSpokeBaseTest { assertEq(_getProxyInitializedVersion(vaultImplAddress), type(uint64).max); vm.expectRevert(Initializable.InvalidInitialization.selector); - vaultImpl.initialize(PREFIX); + vaultImpl.initialize(SHARE_NAME, SHARE_SYMBOL); } function test_proxy_constructor_fuzz(uint64 revision) public { @@ -43,7 +43,7 @@ contract VaultSpokeUpgradeableTest is VaultSpokeBaseTest { new TransparentUpgradeableProxy( address(vaultImpl), proxyAdminOwner, - abi.encodeCall(VaultSpokeInstance.initialize, (PREFIX)) + abi.encodeCall(VaultSpokeInstance.initialize, (SHARE_NAME, SHARE_SYMBOL)) ) ) ); @@ -53,8 +53,8 @@ contract VaultSpokeUpgradeableTest is VaultSpokeBaseTest { assertEq(_getImplementationAddress(address(vaultProxy)), address(vaultImpl)); assertEq(_getProxyInitializedVersion(address(vaultProxy)), revision); - assertEq(vaultProxy.name(), string.concat(PREFIX, ' ', tokenList.dai.name())); - assertEq(vaultProxy.symbol(), string.concat('h', tokenList.dai.symbol())); + assertEq(vaultProxy.name(), SHARE_NAME); + assertEq(vaultProxy.symbol(), SHARE_SYMBOL); } function test_proxy_reinitialization_fuzz(uint64 initialRevision) public { @@ -65,7 +65,7 @@ contract VaultSpokeUpgradeableTest is VaultSpokeBaseTest { new TransparentUpgradeableProxy( address(vaultImpl), proxyAdminOwner, - abi.encodeCall(VaultSpokeInstance.initialize, (PREFIX)) + abi.encodeCall(VaultSpokeInstance.initialize, (SHARE_NAME, SHARE_SYMBOL)) ) ) ); @@ -75,20 +75,20 @@ contract VaultSpokeUpgradeableTest is VaultSpokeBaseTest { uint64 secondRevision = uint64(vm.randomUint(initialRevision + 1, type(uint64).max)); VaultSpokeInstance vaultImpl2 = _deployMockVaultSpokeInstance(secondRevision); - string memory newPrefix = 'New Prefix'; + 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(newPrefix)); - - assertEq( - IVaultSpoke(address(vaultProxy)).name(), - string.concat(newPrefix, ' ', tokenList.dai.name()) + vaultProxy.upgradeToAndCall( + address(vaultImpl2), + _getInitializeCalldata(newShareName, newShareSymbol) ); - assertEq(IVaultSpoke(address(vaultProxy)).symbol(), string.concat('h', tokenList.dai.symbol())); + + assertEq(IVaultSpoke(address(vaultProxy)).name(), newShareName); + assertEq(IVaultSpoke(address(vaultProxy)).symbol(), newShareSymbol); assertNotEq(IVaultSpoke(address(vaultProxy)).name(), originalName); - // Symbol doesn't change with prefix - it's always 'h' + asset.symbol() } function test_proxy_constructor_revertsWith_InvalidInitialization_ZeroRevision() public { @@ -98,7 +98,7 @@ contract VaultSpokeUpgradeableTest is VaultSpokeBaseTest { new TransparentUpgradeableProxy( address(vaultImpl), proxyAdminOwner, - abi.encodeCall(VaultSpokeInstance.initialize, (PREFIX)) + abi.encodeCall(VaultSpokeInstance.initialize, (SHARE_NAME, SHARE_SYMBOL)) ); } @@ -113,20 +113,26 @@ contract VaultSpokeUpgradeableTest is VaultSpokeBaseTest { new TransparentUpgradeableProxy( address(vaultImpl), proxyAdminOwner, - _getInitializeCalldata(PREFIX) + _getInitializeCalldata(SHARE_NAME, SHARE_SYMBOL) ) ) ); vm.expectRevert(Initializable.InvalidInitialization.selector); vm.prank(_getProxyAdminAddress(address(vaultProxy))); - vaultProxy.upgradeToAndCall(address(vaultImpl), _getInitializeCalldata(PREFIX)); + 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(PREFIX)); + vaultProxy.upgradeToAndCall( + address(vaultImpl2), + _getInitializeCalldata(SHARE_NAME, SHARE_SYMBOL) + ); } function test_proxy_reinitialization_revertsWith_CallerNotProxyAdmin() public { @@ -136,7 +142,7 @@ contract VaultSpokeUpgradeableTest is VaultSpokeBaseTest { new TransparentUpgradeableProxy( address(vaultImpl), proxyAdminOwner, - _getInitializeCalldata(PREFIX) + _getInitializeCalldata(SHARE_NAME, SHARE_SYMBOL) ) ) ); @@ -144,11 +150,17 @@ contract VaultSpokeUpgradeableTest is VaultSpokeBaseTest { VaultSpokeInstance vaultImpl2 = _deployMockVaultSpokeInstance(2); vm.expectRevert(); vm.prank(makeUser()); - vaultProxy.upgradeToAndCall(address(vaultImpl2), _getInitializeCalldata(PREFIX)); + vaultProxy.upgradeToAndCall( + address(vaultImpl2), + _getInitializeCalldata(SHARE_NAME, SHARE_SYMBOL) + ); } - function _getInitializeCalldata(string memory prefix) internal pure returns (bytes memory) { - return abi.encodeCall(VaultSpokeInstance.initialize, prefix); + 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) { From fc109a8161b1d8dc2c39226dcbad32d500cb0651 Mon Sep 17 00:00:00 2001 From: DhairyaSethi <55102840+DhairyaSethi@users.noreply.github.com> Date: Fri, 12 Dec 2025 19:08:21 +0530 Subject: [PATCH 14/17] chore: fix permit gas test --- snapshots/VaultSpoke.Operations.json | 25 ++++++++------- src/spoke/interfaces/IVaultSpoke.sol | 1 + tests/gas/VaultSpoke.Operations.gas.t.sol | 39 ++++++++--------------- 3 files changed, 27 insertions(+), 38 deletions(-) diff --git a/snapshots/VaultSpoke.Operations.json b/snapshots/VaultSpoke.Operations.json index f7bad1123..d3f65801d 100644 --- a/snapshots/VaultSpoke.Operations.json +++ b/snapshots/VaultSpoke.Operations.json @@ -1,16 +1,17 @@ { - "deposit": "117334", + "deposit": "117356", "depositWithSig": "151404", - "mint": "117416", - "mintWithSig": "151518", - "redeem: on behalf, full": "98259", - "redeem: on behalf, partial": "122259", - "redeem: self, full": "97401", - "redeem: self, partial": "116601", + "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": "98237", - "withdraw: on behalf, partial": "122237", - "withdraw: self, full": "97379", - "withdraw: self, partial": "116579", - "withdrawWithSig": "149876" + "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/spoke/interfaces/IVaultSpoke.sol b/src/spoke/interfaces/IVaultSpoke.sol index 99f049433..2e72c215b 100644 --- a/src/spoke/interfaces/IVaultSpoke.sol +++ b/src/spoke/interfaces/IVaultSpoke.sol @@ -92,6 +92,7 @@ interface IVaultSpoke is IERC4626, IERC2612, INoncesKeyed { function MAX_ALLOWED_SPOKE_CAP() external view returns (uint40); /// @notice Returns the nonce key for the share token permit signatures. + /// @dev Share token permits nonces are always set at this specific key namespace. function PERMIT_NONCE_KEY() external pure returns (uint192); /// @notice Returns the type hash for the deposit intent. diff --git a/tests/gas/VaultSpoke.Operations.gas.t.sol b/tests/gas/VaultSpoke.Operations.gas.t.sol index 768d92440..89573a759 100644 --- a/tests/gas/VaultSpoke.Operations.gas.t.sol +++ b/tests/gas/VaultSpoke.Operations.gas.t.sol @@ -128,30 +128,17 @@ contract VaultSpokeOperations_Gas_Tests is VaultSpokeBaseTest { vm.snapshotGasLastCall(NAMESPACE, 'redeemWithSig'); } - // function test_permit() public { - // (address owner, uint256 ownerPk) = makeAddrAndKey('owner'); - // vm.label(owner, 'owner'); - // address spender = makeAddr('spender'); - - // EIP712Types.Permit memory permit = EIP712Types.Permit({ - // owner: owner, - // spender: spender, - // value: 1000e18, - // nonce: vaultDai.nonces(owner), - // deadline: vm.getBlockTimestamp() + 1 days - // }); - // bytes32 digest = _getTypedDataHash(vaultDai, permit); - // (uint8 v, bytes32 r, bytes32 s) = vm.sign(ownerPk, digest); - - // vaultDai.permit(owner, spender, permit.value, permit.deadline, v, r, s); - // vm.snapshotGasLastCall(NAMESPACE, 'permit: first'); - - // permit.nonce = vaultDai.nonces(owner); - // permit.value = 2000e18; - // digest = _getTypedDataHash(vaultDai, permit); - // (v, r, s) = vm.sign(ownerPk, digest); - - // vaultDai.permit(owner, spender, permit.value, permit.deadline, v, r, s); - // vm.snapshotGasLastCall(NAMESPACE, 'permit: second action'); - // } + 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); + } } From 36c4cb59111661c41b2e5325ba011c777f4e9325 Mon Sep 17 00:00:00 2001 From: DhairyaSethi <55102840+DhairyaSethi@users.noreply.github.com> Date: Tue, 16 Dec 2025 08:51:58 +0530 Subject: [PATCH 15/17] chore: dont use underscore for internals for now --- src/spoke/VaultSpoke.sol | 72 +++++++++---------- src/spoke/interfaces/IVaultSpoke.sol | 3 +- src/utils/NoncesKeyed.sol | 2 +- tests/unit/MathUtils.t.sol | 4 ++ .../VaultSpoke.ERC4626Compliance.t.sol | 19 +++-- 5 files changed, 49 insertions(+), 51 deletions(-) diff --git a/src/spoke/VaultSpoke.sol b/src/spoke/VaultSpoke.sol index 8c9fa4b56..ed40f2470 100644 --- a/src/spoke/VaultSpoke.sol +++ b/src/spoke/VaultSpoke.sol @@ -25,19 +25,23 @@ abstract contract VaultSpoke is IVaultSpoke, ERC20Upgradeable, NoncesKeyed, EIP7 using MathUtils for uint256; using EIP712Hash for *; - IHub internal immutable _HUB; - uint256 internal immutable _ASSET_ID; - address internal immutable _ASSET; - uint8 internal immutable _DECIMALS; - uint40 internal immutable _MAX_ALLOWED_SPOKE_CAP; - uint192 internal constant _PERMIT_NONCE_KEY = 0; + IHub internal immutable HUB; + uint256 internal immutable ASSET_ID; + address internal immutable ASSET; + uint8 internal immutable DECIMALS; + + /// @inheritdoc IVaultSpoke + uint40 public immutable MAX_ALLOWED_SPOKE_CAP; + + /// @inheritdoc IVaultSpoke + uint192 public constant PERMIT_NONCE_KEY = 0; constructor(address hub_, uint256 assetId_) { - _HUB = IHub(hub_); - _ASSET_ID = assetId_; - require(_ASSET_ID < _HUB.getAssetCount()); - _MAX_ALLOWED_SPOKE_CAP = _HUB.MAX_ALLOWED_SPOKE_CAP(); - (_ASSET, _DECIMALS) = _HUB.getAssetUnderlyingAndDecimals(_ASSET_ID); + HUB = IHub(hub_); + ASSET_ID = assetId_; + require(ASSET_ID < HUB.getAssetCount()); + MAX_ALLOWED_SPOKE_CAP = HUB.MAX_ALLOWED_SPOKE_CAP(); + (ASSET, DECIMALS) = HUB.getAssetUnderlyingAndDecimals(ASSET_ID); } function initialize(string memory shareName, string memory shareSymbol) external virtual; @@ -165,7 +169,7 @@ abstract contract VaultSpoke is IVaultSpoke, ERC20Upgradeable, NoncesKeyed, EIP7 bytes32 s ) external returns (uint256) { try - IERC20Permit(_ASSET).permit({ + IERC20Permit(ASSET).permit({ owner: msg.sender, // deposit only mints for caller spender: address(this), value: assets, @@ -196,7 +200,7 @@ abstract contract VaultSpoke is IVaultSpoke, ERC20Upgradeable, NoncesKeyed, EIP7 owner, spender, value, - _useNonce({owner: owner, key: _PERMIT_NONCE_KEY}), + _useNonce({owner: owner, key: PERMIT_NONCE_KEY}), deadline ) ) @@ -215,22 +219,22 @@ abstract contract VaultSpoke is IVaultSpoke, ERC20Upgradeable, NoncesKeyed, EIP7 /// @inheritdoc IERC4626 function previewDeposit(uint256 assets) public view virtual returns (uint256) { - return _HUB.previewAddByAssets(_ASSET_ID, assets); + return HUB.previewAddByAssets(ASSET_ID, assets); } /// @inheritdoc IERC4626 function previewMint(uint256 shares) public view virtual returns (uint256) { - return _HUB.previewAddByShares(_ASSET_ID, shares); + return HUB.previewAddByShares(ASSET_ID, shares); } /// @inheritdoc IERC4626 function previewWithdraw(uint256 assets) public view virtual returns (uint256) { - return _HUB.previewRemoveByAssets(_ASSET_ID, assets); + return HUB.previewRemoveByAssets(ASSET_ID, assets); } /// @inheritdoc IERC4626 function previewRedeem(uint256 shares) public view virtual returns (uint256) { - return _HUB.previewRemoveByShares(_ASSET_ID, shares); + return HUB.previewRemoveByShares(ASSET_ID, shares); } /// @inheritdoc IERC4626 @@ -245,11 +249,11 @@ abstract contract VaultSpoke is IVaultSpoke, ERC20Upgradeable, NoncesKeyed, EIP7 /// @inheritdoc IERC4626 function maxDeposit(address) public view returns (uint256) { - IHub.SpokeConfig memory config = _HUB.getSpokeConfig(_ASSET_ID, address(this)); + IHub.SpokeConfig memory config = HUB.getSpokeConfig(ASSET_ID, address(this)); if (!config.active || config.paused) { return 0; } - if (config.addCap == _MAX_ALLOWED_SPOKE_CAP) { + if (config.addCap == MAX_ALLOWED_SPOKE_CAP) { return type(uint256).max; } uint256 allowed = config.addCap * MathUtils.uncheckedExp(10, decimals()); @@ -284,27 +288,27 @@ abstract contract VaultSpoke is IVaultSpoke, ERC20Upgradeable, NoncesKeyed, EIP7 /// @inheritdoc IVaultSpoke function hub() public view returns (address) { - return address(_HUB); + return address(HUB); } /// @inheritdoc IVaultSpoke function assetId() public view returns (uint256) { - return _ASSET_ID; + return ASSET_ID; } /// @inheritdoc IERC4626 function asset() public view returns (address) { - return _ASSET; + return ASSET; } /// @inheritdoc IERC20Metadata function decimals() public view override(ERC20Upgradeable, IERC20Metadata) returns (uint8) { - return _DECIMALS; + return DECIMALS; } /// @inheritdoc IERC20Permit function nonces(address owner) public view returns (uint256) { - return nonces({owner: owner, key: _PERMIT_NONCE_KEY}); + return nonces({owner: owner, key: PERMIT_NONCE_KEY}); } /// @inheritdoc IERC20Permit @@ -312,16 +316,6 @@ abstract contract VaultSpoke is IVaultSpoke, ERC20Upgradeable, NoncesKeyed, EIP7 return _domainSeparator(); } - /// @inheritdoc IVaultSpoke - function MAX_ALLOWED_SPOKE_CAP() external view returns (uint40) { - return _MAX_ALLOWED_SPOKE_CAP; - } - - /// @inheritdoc IVaultSpoke - function PERMIT_NONCE_KEY() external pure returns (uint192) { - return _PERMIT_NONCE_KEY; - } - /// @inheritdoc IVaultSpoke function DEPOSIT_TYPEHASH() external pure returns (bytes32) { return EIP712Hash.VAULT_DEPOSIT_TYPEHASH; @@ -404,8 +398,8 @@ abstract contract VaultSpoke is IVaultSpoke, ERC20Upgradeable, NoncesKeyed, EIP7 uint256 assets, uint256 shares ) internal virtual { - IERC20(_ASSET).safeTransferFrom(caller, address(_HUB), assets); - _HUB.add(_ASSET_ID, assets); + IERC20(ASSET).safeTransferFrom(caller, address(HUB), assets); + HUB.add(ASSET_ID, assets); _mint(receiver, shares); emit Deposit(caller, receiver, assets, shares); } @@ -420,17 +414,17 @@ abstract contract VaultSpoke is IVaultSpoke, ERC20Upgradeable, NoncesKeyed, EIP7 if (caller != owner) { _spendAllowance({owner: owner, spender: caller, value: shares}); } - _HUB.remove(_ASSET_ID, assets, receiver); + HUB.remove(ASSET_ID, assets, receiver); _burn(owner, shares); emit Withdraw(caller, receiver, owner, assets, shares); } function _maxRemovableAssets() internal view returns (uint256) { - IHub.SpokeConfig memory config = _HUB.getSpokeConfig(_ASSET_ID, address(this)); + IHub.SpokeConfig memory config = HUB.getSpokeConfig(ASSET_ID, address(this)); if (!config.active || config.paused) { return 0; } - return _HUB.getAssetLiquidity(_ASSET_ID); + return HUB.getAssetLiquidity(ASSET_ID); } function _domainNameAndVersion() internal pure override returns (string memory, string memory) { diff --git a/src/spoke/interfaces/IVaultSpoke.sol b/src/spoke/interfaces/IVaultSpoke.sol index 2e72c215b..e7e14ea8e 100644 --- a/src/spoke/interfaces/IVaultSpoke.sol +++ b/src/spoke/interfaces/IVaultSpoke.sol @@ -91,8 +91,9 @@ interface IVaultSpoke is IERC4626, IERC2612, INoncesKeyed { /// @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 signatures. + /// @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. diff --git a/src/utils/NoncesKeyed.sol b/src/utils/NoncesKeyed.sol index 544245ddd..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; 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/VaultSpoke/VaultSpoke.ERC4626Compliance.t.sol b/tests/unit/VaultSpoke/VaultSpoke.ERC4626Compliance.t.sol index a4a69176a..2e120c683 100644 --- a/tests/unit/VaultSpoke/VaultSpoke.ERC4626Compliance.t.sol +++ b/tests/unit/VaultSpoke/VaultSpoke.ERC4626Compliance.t.sol @@ -8,6 +8,7 @@ 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); @@ -20,21 +21,19 @@ contract VaultSpokeERC4626ComplianceTest is VaultSpokeBaseTest, ERC4626Test { function setUpYield(Init memory init) public override { if (init.yield > 0) { init.yield = bound(init.yield, 1, int(MAX_SUPPLY_AMOUNT)); - uint gain = uint(init.yield); - uint owedBefore = hub1.getAssetTotalOwed(daiAssetId); + IHub hub = IHub(IVaultSpoke(_vault_).hub()); + uint256 assetId = IVaultSpoke(_vault_).assetId(); + uint256 gain = uint(init.yield); - tokenList.dai.mint(address(hub1), gain); + TestnetERC20(IVaultSpoke(_vault_).asset()).mint(address(hub), gain); vm.startPrank(address(spoke2)); - hub1.add(daiAssetId, gain); + hub.add(assetId, gain); _mockInterestRateBps(100_00); // 100% interest rate - hub1.draw(daiAssetId, gain, address(spoke2)); + hub.draw(assetId, gain, address(spoke2)); skip(365 days); - tokenList.dai.transfer(address(hub1), gain); - hub1.restore(daiAssetId, gain, IHubBase.PremiumDelta(0, 0, 0)); + tokenList.dai.transfer(address(hub), gain); + hub.restore(assetId, gain, IHubBase.PremiumDelta(0, 0, 0)); vm.stopPrank(); - - uint owedAfter = hub1.getAssetTotalOwed(daiAssetId); - assertApproxEqAbs(owedAfter, owedBefore + gain, 1); } } } From 7ada2bffe34c4f36fd94c6ace46b3de93c69df48 Mon Sep 17 00:00:00 2001 From: DhairyaSethi <55102840+DhairyaSethi@users.noreply.github.com> Date: Tue, 16 Dec 2025 16:31:35 +0530 Subject: [PATCH 16/17] chore: fix natspec and import order --- src/position-manager/SignatureGateway.sol | 6 +++--- .../interfaces/ISignatureGateway.sol | 16 ++++++++-------- src/spoke/VaultSpoke.sol | 13 +++++++------ src/spoke/instances/VaultSpokeInstance.sol | 2 ++ src/spoke/interfaces/IVaultSpoke.sol | 16 ++++++++++------ 5 files changed, 30 insertions(+), 23 deletions(-) diff --git a/src/position-manager/SignatureGateway.sol b/src/position-manager/SignatureGateway.sol index 1aedd988d..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/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 index ed40f2470..5a6af98ab 100644 --- a/src/spoke/VaultSpoke.sol +++ b/src/spoke/VaultSpoke.sol @@ -4,16 +4,15 @@ 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 {IERC4626, IERC20Metadata} from 'src/dependencies/openzeppelin/IERC4626.sol'; -import {IERC20Permit} from 'src/dependencies/openzeppelin/IERC20Permit.sol'; -import {IVaultSpoke} from 'src/spoke/interfaces/IVaultSpoke.sol'; import {IHub} from 'src/hub/interfaces/IHub.sol'; +import {IVaultSpoke} from 'src/spoke/interfaces/IVaultSpoke.sol'; /// @title VaultSpoke /// @author Aave Labs @@ -37,15 +36,17 @@ abstract contract VaultSpoke is IVaultSpoke, ERC20Upgradeable, NoncesKeyed, EIP7 uint192 public constant PERMIT_NONCE_KEY = 0; constructor(address hub_, uint256 assetId_) { + require(assetId_ < IHub(hub_).getAssetCount()); HUB = IHub(hub_); ASSET_ID = assetId_; - require(ASSET_ID < HUB.getAssetCount()); - MAX_ALLOWED_SPOKE_CAP = HUB.MAX_ALLOWED_SPOKE_CAP(); (ASSET, DECIMALS) = HUB.getAssetUnderlyingAndDecimals(ASSET_ID); + 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 diff --git a/src/spoke/instances/VaultSpokeInstance.sol b/src/spoke/instances/VaultSpokeInstance.sol index aad7d218b..81895f023 100644 --- a/src/spoke/instances/VaultSpokeInstance.sol +++ b/src/spoke/instances/VaultSpokeInstance.sol @@ -18,6 +18,8 @@ contract VaultSpokeInstance is VaultSpoke { } /// @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 diff --git a/src/spoke/interfaces/IVaultSpoke.sol b/src/spoke/interfaces/IVaultSpoke.sol index e7e14ea8e..d39d82a6e 100644 --- a/src/spoke/interfaces/IVaultSpoke.sol +++ b/src/spoke/interfaces/IVaultSpoke.sol @@ -26,17 +26,19 @@ interface IVaultSpoke is IERC4626, IERC2612, INoncesKeyed { 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 signature of 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 into the vault with a signature. + /// @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 signature of the mint. + /// @param signature The EIP712-typed signed bytes for the mint. /// @return The amount of assets deposited. function mintWithSig( EIP712Types.VaultMint calldata params, @@ -44,8 +46,9 @@ interface IVaultSpoke is IERC4626, IERC2612, INoncesKeyed { ) 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 signature of the withdraw. + /// @param signature The EIP712-typed signed bytes for the withdraw. /// @return The amount of shares withdrawn. function withdrawWithSig( EIP712Types.VaultWithdraw calldata params, @@ -53,15 +56,16 @@ interface IVaultSpoke is IERC4626, IERC2612, INoncesKeyed { ) 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 signature of the redeem. + /// @param signature The EIP712-typed signed bytes for the redeem. /// @return The amount of assets withdrawn. function redeemWithSig( EIP712Types.VaultRedeem calldata params, bytes calldata signature ) external returns (uint256); - /// @notice Deposits assets into the vault with an underlying asset permit. + /// @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. From 4b29c1c048b9d3792d5375b995ecfb1f4121af2d Mon Sep 17 00:00:00 2001 From: DhairyaSethi <55102840+DhairyaSethi@users.noreply.github.com> Date: Wed, 17 Dec 2025 02:04:11 +0530 Subject: [PATCH 17/17] fix: convertToShares, cache assetUnits, QoL --- src/spoke/VaultSpoke.sol | 32 ++++++++++------- src/spoke/interfaces/IVaultSpoke.sol | 4 +-- tests/Base.t.sol | 11 +++--- tests/gas/Spoke.Operations.gas.t.sol | 2 -- .../Spoke.SetUserPositionManagerWithSig.t.sol | 4 --- tests/unit/VaultSpoke/VaultSpoke.Base.t.sol | 3 +- .../VaultSpoke.DepositWithPermit.t.sol | 34 +------------------ .../VaultSpoke.ERC4626Compliance.t.sol | 6 ++-- .../VaultSpoke/VaultSpoke.MaxGetters.t.sol | 5 ++- ...tSpoke.Reverts.InsufficientAllowance.t.sol | 1 - ...oke.WithSig.Reverts.InvalidSignature.t.sol | 1 - .../unit/VaultSpoke/VaultSpoke.WithSig.t.sol | 1 - 12 files changed, 35 insertions(+), 69 deletions(-) diff --git a/src/spoke/VaultSpoke.sol b/src/spoke/VaultSpoke.sol index 5a6af98ab..4d9b6e05e 100644 --- a/src/spoke/VaultSpoke.sol +++ b/src/spoke/VaultSpoke.sol @@ -28,6 +28,7 @@ abstract contract VaultSpoke is IVaultSpoke, ERC20Upgradeable, NoncesKeyed, EIP7 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; @@ -40,6 +41,7 @@ abstract contract VaultSpoke is IVaultSpoke, ERC20Upgradeable, NoncesKeyed, EIP7 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(); } @@ -171,7 +173,7 @@ abstract contract VaultSpoke is IVaultSpoke, ERC20Upgradeable, NoncesKeyed, EIP7 ) external returns (uint256) { try IERC20Permit(ASSET).permit({ - owner: msg.sender, // deposit only mints for caller + owner: msg.sender, spender: address(this), value: assets, deadline: deadline, @@ -180,7 +182,7 @@ abstract contract VaultSpoke is IVaultSpoke, ERC20Upgradeable, NoncesKeyed, EIP7 s: s }) {} catch {} - return deposit(assets, receiver); + return _executeDeposit({depositor: msg.sender, receiver: receiver, assets: assets}); } /// @inheritdoc IERC20Permit @@ -245,7 +247,7 @@ abstract contract VaultSpoke is IVaultSpoke, ERC20Upgradeable, NoncesKeyed, EIP7 /// @inheritdoc IERC4626 function convertToAssets(uint256 shares) external view returns (uint256) { - return previewMint(shares); + return previewRedeem(shares); } /// @inheritdoc IERC4626 @@ -257,14 +259,14 @@ abstract contract VaultSpoke is IVaultSpoke, ERC20Upgradeable, NoncesKeyed, EIP7 if (config.addCap == MAX_ALLOWED_SPOKE_CAP) { return type(uint256).max; } - uint256 allowed = config.addCap * MathUtils.uncheckedExp(10, decimals()); + uint256 allowed = config.addCap * ASSET_UNITS; uint256 balance = totalAssets(); return allowed.zeroFloorSub(balance); } /// @inheritdoc IERC4626 - function maxMint(address owner) public view returns (uint256) { - uint256 maxAssets = maxDeposit(owner); + function maxMint(address receiver) public view returns (uint256) { + uint256 maxAssets = maxDeposit(receiver); return maxAssets == type(uint256).max ? type(uint256).max : previewDeposit(maxAssets); } @@ -350,7 +352,7 @@ abstract contract VaultSpoke is IVaultSpoke, ERC20Upgradeable, NoncesKeyed, EIP7 uint256 maxAssets = maxDeposit(receiver); require(assets <= maxAssets, MaxDepositExceeded(maxAssets, assets)); uint256 shares = previewDeposit(assets); - _deposit(depositor, receiver, assets, shares); + _deposit({caller: depositor, receiver: receiver, assets: assets, shares: shares}); return shares; } @@ -362,7 +364,7 @@ abstract contract VaultSpoke is IVaultSpoke, ERC20Upgradeable, NoncesKeyed, EIP7 uint256 maxShares = maxMint(receiver); require(shares <= maxShares, MaxMintExceeded(maxShares, shares)); uint256 assets = previewMint(shares); - _deposit(depositor, receiver, assets, shares); + _deposit({caller: depositor, receiver: receiver, assets: assets, shares: shares}); return assets; } @@ -375,7 +377,7 @@ abstract contract VaultSpoke is IVaultSpoke, ERC20Upgradeable, NoncesKeyed, EIP7 uint256 maxAssets = maxWithdraw(owner); require(assets <= maxAssets, MaxWithdrawExceeded(maxAssets, assets)); uint256 shares = previewWithdraw(assets); - _withdraw(caller, receiver, owner, assets, shares); + _withdraw({caller: caller, receiver: receiver, owner: owner, assets: assets, shares: shares}); return shares; } @@ -388,7 +390,7 @@ abstract contract VaultSpoke is IVaultSpoke, ERC20Upgradeable, NoncesKeyed, EIP7 uint256 maxShares = maxRedeem(owner); require(shares <= maxShares, MaxRedeemExceeded(maxShares, shares)); uint256 assets = previewRedeem(shares); - _withdraw(caller, receiver, owner, assets, shares); + _withdraw({caller: caller, receiver: receiver, owner: owner, assets: assets, shares: shares}); return assets; } @@ -402,7 +404,7 @@ abstract contract VaultSpoke is IVaultSpoke, ERC20Upgradeable, NoncesKeyed, EIP7 IERC20(ASSET).safeTransferFrom(caller, address(HUB), assets); HUB.add(ASSET_ID, assets); _mint(receiver, shares); - emit Deposit(caller, receiver, assets, shares); + emit Deposit({sender: caller, owner: receiver, assets: assets, shares: shares}); } function _withdraw( @@ -417,7 +419,13 @@ abstract contract VaultSpoke is IVaultSpoke, ERC20Upgradeable, NoncesKeyed, EIP7 } HUB.remove(ASSET_ID, assets, receiver); _burn(owner, shares); - emit Withdraw(caller, receiver, owner, assets, shares); + emit Withdraw({ + sender: caller, + receiver: receiver, + owner: owner, + assets: assets, + shares: shares + }); } function _maxRemovableAssets() internal view returns (uint256) { diff --git a/src/spoke/interfaces/IVaultSpoke.sol b/src/spoke/interfaces/IVaultSpoke.sol index d39d82a6e..2c79262b9 100644 --- a/src/spoke/interfaces/IVaultSpoke.sol +++ b/src/spoke/interfaces/IVaultSpoke.sol @@ -49,7 +49,7 @@ interface IVaultSpoke is IERC4626, IERC2612, INoncesKeyed { /// @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 withdrawn. + /// @return The amount of shares burnt. function withdrawWithSig( EIP712Types.VaultWithdraw calldata params, bytes calldata signature @@ -59,7 +59,7 @@ interface IVaultSpoke is IERC4626, IERC2612, INoncesKeyed { /// @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 withdrawn. + /// @return The amount of assets burnt. function redeemWithSig( EIP712Types.VaultRedeem calldata params, bytes calldata signature diff --git a/tests/Base.t.sol b/tests/Base.t.sol index e841de0b2..59233c2a6 100644 --- a/tests/Base.t.sol +++ b/tests/Base.t.sol @@ -18,6 +18,7 @@ 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'; @@ -2298,12 +2299,12 @@ abstract contract Base is Test { return vaultSpoke; } - function _configureVaultSpoke(IVaultSpoke vaultSpoke, IHub hub, uint256 assetId) internal { + function _registerVaultSpoke(IHub hub, uint256 assetId, IVaultSpoke vaultSpoke) internal { return - _configureVaultSpoke( - vaultSpoke, + _registerVaultSpoke( hub, assetId, + vaultSpoke, IHub.SpokeConfig({ addCap: type(uint40).max, drawCap: 0, @@ -2314,10 +2315,10 @@ abstract contract Base is Test { ); } - function _configureVaultSpoke( - IVaultSpoke vaultSpoke, + function _registerVaultSpoke( IHub hub, uint256 assetId, + IVaultSpoke vaultSpoke, IHub.SpokeConfig memory config ) internal pausePrank { vm.prank(ADMIN); 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/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/VaultSpoke/VaultSpoke.Base.t.sol b/tests/unit/VaultSpoke/VaultSpoke.Base.t.sol index 11b5758a2..33229f0f9 100644 --- a/tests/unit/VaultSpoke/VaultSpoke.Base.t.sol +++ b/tests/unit/VaultSpoke/VaultSpoke.Base.t.sol @@ -13,7 +13,7 @@ contract VaultSpokeBaseTest is Base { deployFixtures(); initEnvironment(); daiVault = _deployVaultSpoke(hub1, daiAssetId, SHARE_NAME, SHARE_SYMBOL, ADMIN); - _configureVaultSpoke(daiVault, hub1, daiAssetId); + _registerVaultSpoke(hub1, daiAssetId, daiVault); } function _depositData( @@ -166,7 +166,6 @@ contract VaultSpokeInitTest is VaultSpokeBaseTest { assertEq(daiVault.hub(), address(hub1)); assertEq(daiVault.PERMIT_NONCE_KEY(), 0); - assertEq(daiVault.MAX_ALLOWED_SPOKE_CAP(), hub1.MAX_ALLOWED_SPOKE_CAP()); assertEq(daiVault.totalAssets(), 0); assertEq(daiVault.totalSupply(), 0); diff --git a/tests/unit/VaultSpoke/VaultSpoke.DepositWithPermit.t.sol b/tests/unit/VaultSpoke/VaultSpoke.DepositWithPermit.t.sol index 2969f2429..ae19591f6 100644 --- a/tests/unit/VaultSpoke/VaultSpoke.DepositWithPermit.t.sol +++ b/tests/unit/VaultSpoke/VaultSpoke.DepositWithPermit.t.sol @@ -3,7 +3,6 @@ pragma solidity ^0.8.0; import 'tests/unit/VaultSpoke/VaultSpoke.Base.t.sol'; -import {IERC4626} from 'src/dependencies/openzeppelin/IERC4626.sol'; contract VaultSpokeDepositWithPermitTest is VaultSpokeBaseTest { IVaultSpoke public vault; @@ -103,38 +102,6 @@ contract VaultSpokeDepositWithPermitTest is VaultSpokeBaseTest { assertEq(vault.balanceOf(receiver), expectedShares); } - function test_depositWithPermit_deposit_executes_after_permit() 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 balanceBefore = vault.balanceOf(receiver); - uint256 assetBalanceBefore = asset.balanceOf(user); - - vm.prank(user); - vault.depositWithPermit(assets, receiver, params.deadline, v, r, s); - - assertGt(vault.balanceOf(receiver), balanceBefore); - assertEq(asset.balanceOf(user), assetBalanceBefore - assets); - assertEq(asset.allowance(user, address(vault)), 0); - } - function test_depositWithPermit_works_with_existing_allowance() public { address user = vm.randomAddress(); address receiver = vm.randomAddress(); @@ -161,5 +128,6 @@ contract VaultSpokeDepositWithPermitTest is VaultSpokeBaseTest { 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 index 2e120c683..314be5b6d 100644 --- a/tests/unit/VaultSpoke/VaultSpoke.ERC4626Compliance.t.sol +++ b/tests/unit/VaultSpoke/VaultSpoke.ERC4626Compliance.t.sol @@ -13,9 +13,9 @@ contract VaultSpokeERC4626ComplianceTest is VaultSpokeBaseTest, ERC4626Test { _underlying_ = daiVault.asset(); _vault_ = address(daiVault); - _delta_ = 0; - _vaultMayBeEmpty = true; // inflation protection through virtual shares on hub - _unlimitedAmount = false; + _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 { diff --git a/tests/unit/VaultSpoke/VaultSpoke.MaxGetters.t.sol b/tests/unit/VaultSpoke/VaultSpoke.MaxGetters.t.sol index dfc605ffc..73a87051d 100644 --- a/tests/unit/VaultSpoke/VaultSpoke.MaxGetters.t.sol +++ b/tests/unit/VaultSpoke/VaultSpoke.MaxGetters.t.sol @@ -12,7 +12,7 @@ abstract contract VaultSpokeMaxGettersReturnZeroTest is VaultSpokeBaseTest { updateAddCap(IHub(vault.hub()), vault.assetId(), address(vault), 0); } - function isVaultActiveOrNotPaused() public view returns (bool) { + function _isVaultActiveOrNotPaused() internal view returns (bool) { IHub.SpokeConfig memory config = IHub(vault.hub()).getSpokeConfig( vault.assetId(), address(vault) @@ -21,7 +21,7 @@ abstract contract VaultSpokeMaxGettersReturnZeroTest is VaultSpokeBaseTest { } modifier setUpPreconditions() { - if (isVaultActiveOrNotPaused()) { + if (_isVaultActiveOrNotPaused()) { vm.expectCall( vault.hub(), abi.encodeCall(IHub.getSpokeConfig, (vault.assetId(), address(vault))), @@ -145,7 +145,6 @@ contract VaultSpokeDepositMintGettersNonEmptyLiquidityVariableCapTest is super.setUp(); uint256 amount = vm.randomUint(1, maxSuppliableAssets().min(MAX_SUPPLY_AMOUNT)); deal(vault.asset(), address(this), amount); - // Utils.add(IHubBase(vault.hub()), vault.assetId(), address(vault), amount, address(this)); Utils.approve(vault, address(this), amount); vault.deposit(amount, address(this)); } diff --git a/tests/unit/VaultSpoke/VaultSpoke.Reverts.InsufficientAllowance.t.sol b/tests/unit/VaultSpoke/VaultSpoke.Reverts.InsufficientAllowance.t.sol index 6fcd14741..e01bd81a9 100644 --- a/tests/unit/VaultSpoke/VaultSpoke.Reverts.InsufficientAllowance.t.sol +++ b/tests/unit/VaultSpoke/VaultSpoke.Reverts.InsufficientAllowance.t.sol @@ -3,7 +3,6 @@ pragma solidity ^0.8.0; import 'tests/unit/VaultSpoke/VaultSpoke.Base.t.sol'; -import {IERC4626} from 'src/dependencies/openzeppelin/IERC4626.sol'; contract VaultSpokeWithSigInsufficientAllowanceTest is VaultSpokeBaseTest { IVaultSpoke public vault; diff --git a/tests/unit/VaultSpoke/VaultSpoke.WithSig.Reverts.InvalidSignature.t.sol b/tests/unit/VaultSpoke/VaultSpoke.WithSig.Reverts.InvalidSignature.t.sol index ac8b0b868..910ca76e6 100644 --- a/tests/unit/VaultSpoke/VaultSpoke.WithSig.Reverts.InvalidSignature.t.sol +++ b/tests/unit/VaultSpoke/VaultSpoke.WithSig.Reverts.InvalidSignature.t.sol @@ -3,7 +3,6 @@ pragma solidity ^0.8.0; import 'tests/unit/VaultSpoke/VaultSpoke.Base.t.sol'; -import {IERC4626} from 'src/dependencies/openzeppelin/IERC4626.sol'; contract VaultSpokeWithSigInvalidSignatureTest is VaultSpokeBaseTest { IVaultSpoke public vault; diff --git a/tests/unit/VaultSpoke/VaultSpoke.WithSig.t.sol b/tests/unit/VaultSpoke/VaultSpoke.WithSig.t.sol index fa0783daa..97e074414 100644 --- a/tests/unit/VaultSpoke/VaultSpoke.WithSig.t.sol +++ b/tests/unit/VaultSpoke/VaultSpoke.WithSig.t.sol @@ -3,7 +3,6 @@ pragma solidity ^0.8.0; import 'tests/unit/VaultSpoke/VaultSpoke.Base.t.sol'; -import {IERC4626} from 'src/dependencies/openzeppelin/IERC4626.sol'; contract VaultSpokeWithSigTest is VaultSpokeBaseTest { using SafeCast for *;