diff --git a/.env.example b/.env.example index 758bc8c7..bd97ff91 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,10 @@ META_SWAP_ADAPTER_OWNER_ADDRESS= METASWAP_ADDRESS= SWAPS_API_SIGNER_ADDRESS= ARGS_EQUALITY_CHECK_ENFORCER_ADDRESS= +VEDA_ADAPTER_OWNER_ADDRESS= +VEDA_BORING_VAULT_ADDRESS= +VEDA_TELLER_ADDRESS= +VEDA_DEPOSIT_TOKEN_ADDRESS= # Required for verifying contracts ETHERSCAN_API_KEY= diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7ba85d68..09fc09bc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -51,3 +51,5 @@ jobs: run: forge test -vvv env: LINEA_RPC_URL: ${{ secrets.LINEA_RPC_URL }} + ARBITRUM_RPC_URL: ${{ secrets.ARBITRUM_RPC_URL }} + RPC_API_KEY: ${{ secrets.RPC_API_KEY }} diff --git a/script/DeployVedaAdapter.s.sol b/script/DeployVedaAdapter.s.sol new file mode 100644 index 00000000..1fa72062 --- /dev/null +++ b/script/DeployVedaAdapter.s.sol @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import "forge-std/Script.sol"; +import { console2 } from "forge-std/console2.sol"; + +import { VedaAdapter } from "../src/helpers/VedaAdapter.sol"; + +/** + * @title DeployVedaAdapter + * @notice Deploys the VedaAdapter contract. + * @dev Fill the required variables in the .env file + * @dev run the script with: + * forge script script/DeployVedaAdapter.s.sol --rpc-url --private-key $PRIVATE_KEY --broadcast + */ +contract DeployVedaAdapter is Script { + bytes32 salt; + address deployer; + address vedaAdapterOwner; + address delegationManager; + address boringVault; + address vedaTeller; + address depositToken; + + function setUp() public { + salt = bytes32(abi.encodePacked(vm.envString("SALT"))); + vedaAdapterOwner = vm.envAddress("VEDA_ADAPTER_OWNER_ADDRESS"); + delegationManager = vm.envAddress("DELEGATION_MANAGER_ADDRESS"); + boringVault = vm.envAddress("VEDA_BORING_VAULT_ADDRESS"); + vedaTeller = vm.envAddress("VEDA_TELLER_ADDRESS"); + depositToken = vm.envAddress("VEDA_DEPOSIT_TOKEN_ADDRESS"); + deployer = msg.sender; + console2.log("~~~"); + console2.log("Owner: %s", vedaAdapterOwner); + console2.log("DelegationManager: %s", delegationManager); + console2.log("BoringVault: %s", boringVault); + console2.log("VedaTeller: %s", vedaTeller); + console2.log("DepositToken: %s", depositToken); + console2.log("Deployer: %s", deployer); + console2.log("Salt:"); + console2.logBytes32(salt); + } + + function run() public { + console2.log("~~~"); + vm.startBroadcast(); + + address vedaAdapter = + address(new VedaAdapter{ salt: salt }(vedaAdapterOwner, delegationManager, boringVault, vedaTeller, depositToken)); + console2.log("VedaAdapter: %s", vedaAdapter); + + vm.stopBroadcast(); + } +} diff --git a/src/helpers/VedaAdapter.sol b/src/helpers/VedaAdapter.sol new file mode 100644 index 00000000..2f9c8f9f --- /dev/null +++ b/src/helpers/VedaAdapter.sol @@ -0,0 +1,405 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { Ownable2Step, Ownable } from "@openzeppelin/contracts/access/Ownable2Step.sol"; +import { ModeLib } from "@erc7579/lib/ModeLib.sol"; +import { ExecutionLib } from "@erc7579/lib/ExecutionLib.sol"; +import { Delegation, ModeCode } from "../utils/Types.sol"; +import { IDelegationManager } from "../interfaces/IDelegationManager.sol"; +import { IVedaTeller } from "./interfaces/IVedaTeller.sol"; + +/** + * @title VedaAdapter + * @notice Adapter contract that enables Veda BoringVault deposit and withdrawal operations through MetaMask's + * delegation framework + * @dev This contract acts as an intermediary between users and Veda's BoringVault, enabling delegation-based + * token operations without requiring direct token approvals. + * + * Architecture: + * - BoringVault: The ERC20 vault share token that also custodies assets. On deposit, the vault pulls + * tokens from the caller via `safeTransferFrom`, so this adapter must approve the BoringVault. + * - Teller: The contract that orchestrates deposits/withdrawals. The adapter calls `teller.deposit()` + * for deposits and `teller.withdraw()` for withdrawals (user-facing, no special + * role needed). + * - depositToken: The single ERC20 token used for both deposits and withdrawals. Fixed at construction; + * deposits transfer this token into the vault, and withdrawals redeem vault shares back to this token. + * + * Delegation Flow: + * 1. The user creates an initial delegation to an "operator" address (a DeleGator-upgraded account). + * This delegation includes: + * - A transfer enforcer to control which tokens/shares and amounts can be transferred + * - A redeemer enforcer that restricts redemption to only the VedaAdapter contract + * + * 2. The operator then redelegates to this VedaAdapter contract with additional constraints: + * - Allowed methods enforcer limiting which functions can be called + * - Limited calls enforcer restricting the delegation to a single execution + * + * 3. For deposits: the adapter redeems the delegation chain, transfers tokens from the user to itself, + * approves the BoringVault, and calls `teller.deposit()` to mint shares to the user. + * For withdrawals: the adapter redeems the delegation chain, transfers vault shares from the user + * to itself, and calls `teller.withdraw()` to burn shares and send `depositToken` assets to the user. + * + * Requirements: + * - VedaAdapter must approve the BoringVault to spend deposit tokens + * + * Leaf Caveat Format: + * - The first caveat of the leaf delegation (`_delegations[0].caveats[0]`) must follow the + * ERC20TransferAmountEnforcer terms format: abi.encodePacked(address token, uint256 amount) (52 bytes). + * The adapter parses only the amount from these terms; the token address encoded in bytes 0–19 is + * consumed by the enforcer itself and is not read by this adapter. + * + * @notice Security consideration: Anyone can call `depositByDelegation` and `withdrawByDelegation` — there is no + * caller restriction. Security is enforced entirely through the delegation chain. The redelegation from the + * operator to this adapter MUST include an `ERC20TransferAmountEnforcer` caveat capped to exactly the intended + * deposit or withdrawal amount, and it MUST be the first caveat (`caveats[0]`) of that redelegation — the + * adapter reads the amount directly from `_delegations[0].caveats[0].terms`. Once that amount is + * transferred the enforcer's running total is exhausted and any replay attempt will revert, making the + * delegation effectively single-use. A delegation without this enforcer as the first caveat (or with an amount + * larger than intended) could be exploited by any caller to transfer more tokens than authorised. + */ +contract VedaAdapter is Ownable2Step { + using SafeERC20 for IERC20; + using ExecutionLib for bytes; + using ModeLib for ModeCode; + + /** + * @notice Parameters for a single deposit operation in a batch + */ + struct DepositParams { + Delegation[] delegations; + uint256 minimumMint; + } + + /** + * @notice Parameters for a single withdrawal operation in a batch + */ + struct WithdrawParams { + Delegation[] delegations; + uint256 minimumAssets; + } + + ////////////////////////////// Events ////////////////////////////// + + /** + * @notice Emitted when a deposit operation is executed via delegation + * @param delegator Address of the token owner (delegator) + * @param delegate Address of the executor (delegate) + * @param token Address of the deposited token + * @param amount Amount of tokens deposited + * @param shares Amount of vault shares minted to the delegator + */ + event DepositExecuted( + address indexed delegator, address indexed delegate, address indexed token, uint256 amount, uint256 shares + ); + + /** + * @notice Emitted when a withdrawal operation is executed via delegation + * @param delegator Address of the share owner (delegator) + * @param delegate Address of the executor (delegate) + * @param token Address of the underlying token withdrawn (always `depositToken`) + * @param shareAmount Amount of vault shares burned + * @param assetsOut Amount of underlying tokens sent to the delegator + */ + event WithdrawExecuted( + address indexed delegator, address indexed delegate, address indexed token, uint256 shareAmount, uint256 assetsOut + ); + + /** + * @notice Emitted when a batch deposit is completed + * @param caller Address of the batch executor + * @param count Number of deposit streams executed + */ + event BatchDepositExecuted(address indexed caller, uint256 count); + + /** + * @notice Emitted when a batch withdrawal is completed + * @param caller Address of the batch executor + * @param count Number of withdrawal streams executed + */ + event BatchWithdrawExecuted(address indexed caller, uint256 count); + + /** + * @notice Emitted when stuck tokens are withdrawn by owner + * @param token Address of the token withdrawn + * @param recipient Address of the recipient + * @param amount Amount of tokens withdrawn + */ + event StuckTokensWithdrawn(IERC20 indexed token, address indexed recipient, uint256 amount); + + ////////////////////////////// Errors ////////////////////////////// + + /// @dev Thrown when a zero address is provided for required parameters + error InvalidZeroAddress(); + + /// @dev Thrown when a zero address is provided for the recipient + error InvalidRecipient(); + + /// @dev Thrown when the delegation chain has fewer than 2 delegations + error InvalidDelegationsLength(); + + /// @dev Thrown when the batch array is empty + error InvalidBatchLength(); + + /// @dev Thrown when the leaf caveat terms are shorter than 52 bytes (ERC20TransferAmountEnforcer format) + error InvalidTermsLength(); + + ////////////////////////////// State ////////////////////////////// + + /** + * @notice The DelegationManager contract used to redeem delegations + */ + IDelegationManager public immutable delegationManager; + + /** + * @notice The BoringVault contract (approval target for token transfers) + */ + address public immutable boringVault; + + /** + * @notice The Teller contract for deposit and withdrawal operations + */ + IVedaTeller public immutable teller; + + /** + * @notice The ERC20 token used for all deposits and withdrawals + * @dev Fixed at construction. Deposits transfer this token into the vault; withdrawals redeem vault + * shares back to this token. + */ + IERC20 public immutable depositToken; + + ////////////////////////////// Constructor ////////////////////////////// + + /** + * @notice Initializes the adapter with delegation manager, BoringVault, Teller, and deposit token addresses + * @param _owner Address of the contract owner + * @param _delegationManager Address of the delegation manager contract + * @param _boringVault Address of the BoringVault (token approval target) + * @param _teller Address of the Teller contract (deposit entry point) + * @param _depositToken Address of the ERC20 token used for all deposits and withdrawals + */ + constructor( + address _owner, + address _delegationManager, + address _boringVault, + address _teller, + address _depositToken + ) + Ownable(_owner) + { + if (_delegationManager == address(0) || _boringVault == address(0) || _teller == address(0) || _depositToken == address(0)) { + revert InvalidZeroAddress(); + } + + delegationManager = IDelegationManager(_delegationManager); + boringVault = _boringVault; + teller = IVedaTeller(_teller); + depositToken = IERC20(_depositToken); + } + + ////////////////////////////// External Methods ////////////////////////////// + + /** + * @notice Deposits tokens into a Veda BoringVault using delegation-based token transfer + * @dev Redeems the delegation to transfer `depositToken` from the user to this adapter, then calls deposit + * on the Teller which mints vault shares directly to the original token owner. + * Requires at least 2 delegations forming a chain from user to operator to this adapter. + * The deposit amount is parsed from the first caveat of the leaf delegation + * (`_delegations[0].caveats[0].terms`), which must follow the ERC20TransferAmountEnforcer + * format: abi.encodePacked(address token, uint256 amount). + * @param _delegations Array of Delegation objects, sorted leaf to root + * @param _minimumMint Minimum vault shares the caller expects to receive, used as a sanity-check + * bound. The Veda vault conversion is always at fair value; rate drift from yield streaming + * is negligible. A tolerance of 0.1-0.5% is recommended. If this check causes a revert, + * no funds are lost — retry with a fresh quote. + * @notice Security consideration: Callable by anyone. The redelegation passed in MUST include an + * `ERC20TransferAmountEnforcer` as its first caveat (`caveats[0]`), capped to exactly the intended + * deposit amount, to prevent over-spending or replay. + */ + function depositByDelegation(Delegation[] calldata _delegations, uint256 _minimumMint) external { + _executeDepositByDelegation(_delegations, _minimumMint); + } + + /** + * @notice Deposits tokens using multiple delegation streams, executed sequentially + * @dev Each element is executed one after the other. The amount for each stream is parsed + * from the first caveat of each stream's leaf delegation. + * @param _depositStreams Array of deposit parameters + * @notice Security consideration: Callable by anyone. Each redelegation in the batch MUST include an + * `ERC20TransferAmountEnforcer` as its first caveat (`caveats[0]`), capped to exactly the intended + * deposit amount, to prevent over-spending or replay. + */ + function depositByDelegationBatch(DepositParams[] calldata _depositStreams) external { + uint256 streamsLength_ = _depositStreams.length; + if (streamsLength_ == 0) revert InvalidBatchLength(); + + for (uint256 i = 0; i < streamsLength_;) { + DepositParams calldata params_ = _depositStreams[i]; + _executeDepositByDelegation(params_.delegations, params_.minimumMint); + unchecked { + ++i; + } + } + + emit BatchDepositExecuted(msg.sender, streamsLength_); + } + + /** + * @notice Withdraws `depositToken` from a Veda BoringVault using delegation-based share transfer + * @dev Redeems the delegation to transfer vault shares to this adapter, then calls withdraw + * on the Teller which burns shares and sends `depositToken` assets directly to the original share owner. + * Requires at least 2 delegations forming a chain from user to operator to this adapter. + * The share amount is parsed from the first caveat of the leaf delegation + * (`_delegations[0].caveats[0].terms`), which must follow the ERC20TransferAmountEnforcer + * format: abi.encodePacked(address boringVault, uint256 shareAmount). + * @param _delegations Array of Delegation objects, sorted leaf to root + * @param _minimumAssets Minimum underlying assets the caller expects to receive, used as a + * sanity-check bound. The Veda vault conversion is always at fair value; rate drift from + * yield streaming is negligible. A tolerance of 0.1-0.5% is recommended. If this check + * causes a revert, no funds are lost — retry with a fresh quote. + * @notice Security consideration: Callable by anyone. The redelegation passed in MUST include an + * `ERC20TransferAmountEnforcer` as its first caveat (`caveats[0]`), capped to exactly the intended + * share amount, to prevent over-spending or replay. + */ + function withdrawByDelegation(Delegation[] calldata _delegations, uint256 _minimumAssets) external { + _executeWithdrawByDelegation(_delegations, _minimumAssets); + } + + /** + * @notice Withdraws `depositToken` using multiple delegation streams, executed sequentially + * @dev Each element is executed one after the other. The share amount for each stream is parsed + * from the first caveat of each stream's leaf delegation. + * @param _withdrawStreams Array of withdraw parameters + * @notice Security consideration: Callable by anyone. Each redelegation in the batch MUST include an + * `ERC20TransferAmountEnforcer` as its first caveat (`caveats[0]`), capped to exactly the intended + * share amount, to prevent over-spending or replay. + */ + function withdrawByDelegationBatch(WithdrawParams[] calldata _withdrawStreams) external { + uint256 streamsLength_ = _withdrawStreams.length; + if (streamsLength_ == 0) revert InvalidBatchLength(); + + for (uint256 i = 0; i < streamsLength_;) { + WithdrawParams calldata params_ = _withdrawStreams[i]; + _executeWithdrawByDelegation(params_.delegations, params_.minimumAssets); + unchecked { + ++i; + } + } + + emit BatchWithdrawExecuted(msg.sender, streamsLength_); + } + + /** + * @notice Emergency function to recover tokens accidentally sent to this contract + * @dev This contract should never hold ERC20 tokens as all token operations are handled + * through delegation-based transfers that move tokens directly between users and the BoringVault. + * This function is only for recovering tokens sent to this contract by mistake. + * @param _token The token to be recovered + * @param _amount The amount of tokens to recover + * @param _recipient The address to receive the recovered tokens + */ + function withdrawEmergency(IERC20 _token, uint256 _amount, address _recipient) external onlyOwner { + if (_recipient == address(0)) revert InvalidRecipient(); + + _token.safeTransfer(_recipient, _amount); + + emit StuckTokensWithdrawn(_token, _recipient, _amount); + } + + ////////////////////////////// Private/Internal Methods ////////////////////////////// + + /** + * @notice Ensures sufficient token allowance for a spender to pull tokens + * @dev Checks current allowance and sets it to max if insufficient. + * @param _token Token to manage allowance for + * @param _spender Address that needs to spend the tokens + * @param _amount Amount needed for the operation + */ + function _ensureAllowance(IERC20 _token, address _spender, uint256 _amount) private { + uint256 allowance_ = _token.allowance(address(this), _spender); + if (allowance_ < _amount) { + _token.forceApprove(_spender, type(uint256).max); + } + } + + /** + * @notice Parses the transfer amount from ERC20TransferAmountEnforcer terms + * @dev Terms format: abi.encodePacked(address token, uint256 amount) = 52 bytes. + * The token address (bytes 0–19) is validated by the enforcer itself and is not read here. + * Only the amount (bytes 20–51) is returned. + * @param _terms The raw terms bytes from a caveat + * @return amount_ The uint256 amount encoded in bytes 20-51 + */ + function _parseERC20TransferTerms(bytes calldata _terms) private pure returns (uint256 amount_) { + if (_terms.length < 52) revert InvalidTermsLength(); + amount_ = uint256(bytes32(_terms[20:52])); + } + + /** + * @notice Internal implementation of deposit by delegation + * @dev Parses the deposit amount from the first caveat of the leaf delegation + * via `_parseERC20TransferTerms`. Uses `depositToken` as the transfer token. + * @param _delegations Delegation chain, sorted leaf to root + * @param _minimumMint Minimum vault shares expected (sanity-check bound) + */ + function _executeDepositByDelegation(Delegation[] calldata _delegations, uint256 _minimumMint) internal { + uint256 length_ = _delegations.length; + if (length_ < 2) revert InvalidDelegationsLength(); + + uint256 amount_ = _parseERC20TransferTerms(_delegations[0].caveats[0].terms); + address rootDelegator_ = _delegations[length_ - 1].delegator; + + // Redeem delegation: transfer tokens from user to this adapter + bytes[] memory permissionContexts_ = new bytes[](1); + permissionContexts_[0] = abi.encode(_delegations); + + ModeCode[] memory encodedModes_ = new ModeCode[](1); + encodedModes_[0] = ModeLib.encodeSimpleSingle(); + + bytes[] memory executionCallDatas_ = new bytes[](1); + executionCallDatas_[0] = + ExecutionLib.encodeSingle(address(depositToken), 0, abi.encodeCall(IERC20.transfer, (address(this), amount_))); + + delegationManager.redeemDelegations(permissionContexts_, encodedModes_, executionCallDatas_); + + // Approve BoringVault to pull tokens, then deposit via Teller + _ensureAllowance(depositToken, boringVault, amount_); + uint256 shares_ = teller.deposit(address(depositToken), amount_, _minimumMint, rootDelegator_, address(0)); + + emit DepositExecuted(rootDelegator_, msg.sender, address(depositToken), amount_, shares_); + } + + /** + * @notice Internal implementation of withdraw by delegation + * @dev Parses the share amount from the first caveat of the leaf delegation + * via `_parseERC20TransferTerms`. Redeems vault shares and sends `depositToken` to the root delegator. + * @param _delegations Delegation chain, sorted leaf to root + * @param _minimumAssets Minimum underlying assets expected (sanity-check bound) + */ + function _executeWithdrawByDelegation(Delegation[] calldata _delegations, uint256 _minimumAssets) internal { + uint256 length_ = _delegations.length; + if (length_ < 2) revert InvalidDelegationsLength(); + + uint256 shareAmount_ = _parseERC20TransferTerms(_delegations[0].caveats[0].terms); + address rootDelegator_ = _delegations[length_ - 1].delegator; + + // Redeem delegation: transfer vault shares from user to this adapter + bytes[] memory permissionContexts_ = new bytes[](1); + permissionContexts_[0] = abi.encode(_delegations); + + ModeCode[] memory encodedModes_ = new ModeCode[](1); + encodedModes_[0] = ModeLib.encodeSimpleSingle(); + + bytes[] memory executionCallDatas_ = new bytes[](1); + executionCallDatas_[0] = + ExecutionLib.encodeSingle(boringVault, 0, abi.encodeCall(IERC20.transfer, (address(this), shareAmount_))); + + delegationManager.redeemDelegations(permissionContexts_, encodedModes_, executionCallDatas_); + + // Withdraw from Teller: burns shares from this adapter, sends depositToken to root delegator + uint256 assetsOut_ = teller.withdraw(address(depositToken), shareAmount_, _minimumAssets, rootDelegator_); + + emit WithdrawExecuted(rootDelegator_, msg.sender, address(depositToken), shareAmount_, assetsOut_); + } +} diff --git a/src/helpers/interfaces/IVedaTeller.sol b/src/helpers/interfaces/IVedaTeller.sol new file mode 100644 index 00000000..ab9a1d8d --- /dev/null +++ b/src/helpers/interfaces/IVedaTeller.sol @@ -0,0 +1,74 @@ +// Based on: +// https://github.com/Se7en-Seas/boring-vault/blob/main/src/base/Roles/TellerWithMultiAssetSupport.sol +// https://github.com/Veda-Labs/boring-vault/blob/dev/oct-2025/src/base/Roles/TellerWithYieldStreaming.sol + +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +/** + * @title IVedaTeller + * @notice Interface for the user-facing functions of Veda's TellerWithMultiAssetSupport. + * @dev Uses `address` for asset parameters to avoid importing Solmate's ERC20. + * The Teller is the entry/exit point for the BoringVault. All functions use `requiresAuth`, + * so callers must be authorized on the Teller's Authority. + */ +interface IVedaTeller { + /** + * @notice Allows users to deposit into the BoringVault, if the contract is not paused. + * @dev Shares are minted to `msg.sender`. A share lock period may apply. + * @param depositAsset The ERC20 token to deposit + * @param depositAmount The amount to deposit + * @param minimumMint The minimum shares the user expects to receive + * @param referralAddress Address used for referral tracking + * @return shares The number of vault shares minted + */ + function deposit( + address depositAsset, + uint256 depositAmount, + uint256 minimumMint, + address referralAddress + ) + external + payable + returns (uint256 shares); + + /** + * @notice Allows an authorized caller to deposit into the BoringVault for another address, if this contract is not paused. + * @dev Intended for router-like integrations; this selector should remain role-gated. + * @param depositAsset The ERC20 token to deposit + * @param depositAmount The amount to deposit + * @param minimumMint The minimum shares the user expects to receive + * @param to The address that will receive the minted vault shares + * @param referralAddress Address used for referral tracking + * @return shares The number of vault shares minted + */ + function deposit( + address depositAsset, + uint256 depositAmount, + uint256 minimumMint, + address to, + address referralAddress + ) + external + payable + returns (uint256 shares); + + /** + * @notice Allows users to withdraw from the BoringVault. + * @dev Available on TellerWithYieldStreaming. Burns shares from `msg.sender` and sends + * underlying assets to `to`. Updates vested yield before withdrawal. + * @param withdrawAsset The ERC20 token to receive + * @param shareAmount The amount of vault shares to burn + * @param minimumAssets The minimum underlying assets expected + * @param to The address that will receive the underlying assets + * @return assetsOut The amount of underlying assets sent + */ + function withdraw( + address withdrawAsset, + uint256 shareAmount, + uint256 minimumAssets, + address to + ) + external + returns (uint256 assetsOut); +} diff --git a/test/helpers/VedaLending.t.sol b/test/helpers/VedaLending.t.sol new file mode 100644 index 00000000..7930e744 --- /dev/null +++ b/test/helpers/VedaLending.t.sol @@ -0,0 +1,1007 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { Test } from "forge-std/Test.sol"; +import { ModeLib } from "@erc7579/lib/ModeLib.sol"; +import { ExecutionLib } from "@erc7579/lib/ExecutionLib.sol"; +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { EncoderLib } from "../../src/libraries/EncoderLib.sol"; +import { IVedaTeller } from "../../src/helpers/interfaces/IVedaTeller.sol"; +import { BaseTest } from "../utils/BaseTest.t.sol"; +import { Implementation, SignatureType, TestUser } from "../utils/Types.t.sol"; +import { Execution, Delegation, Caveat, ModeCode, CallType, ExecType } from "../../src/utils/Types.sol"; +import { CALLTYPE_BATCH, EXECTYPE_TRY, MODE_DEFAULT } from "../../src/utils/Constants.sol"; +import { ModePayload } from "@erc7579/lib/ModeLib.sol"; +import { AllowedTargetsEnforcer } from "../../src/enforcers/AllowedTargetsEnforcer.sol"; +import { AllowedMethodsEnforcer } from "../../src/enforcers/AllowedMethodsEnforcer.sol"; +import { AllowedCalldataEnforcer } from "../../src/enforcers/AllowedCalldataEnforcer.sol"; +import { RedeemerEnforcer } from "../../src/enforcers/RedeemerEnforcer.sol"; +import { ValueLteEnforcer } from "../../src/enforcers/ValueLteEnforcer.sol"; +import { LimitedCallsEnforcer } from "../../src/enforcers/LimitedCallsEnforcer.sol"; +import { LogicalOrWrapperEnforcer } from "../../src/enforcers/LogicalOrWrapperEnforcer.sol"; +import { ERC20TransferAmountEnforcer } from "../../src/enforcers/ERC20TransferAmountEnforcer.sol"; +import { IDelegationManager } from "../../src/interfaces/IDelegationManager.sol"; +import { VedaAdapter } from "../../src/helpers/VedaAdapter.sol"; +import { BasicERC20 } from "../utils/BasicERC20.t.sol"; + +// @dev Do not remove this comment below +/// forge-config: default.evm_version = "shanghai" + +/** + * @title VedaLending Test + * @notice Tests delegation-based lending on Veda BoringVault. + * @dev Uses a forked Arbitrum mainnet environment to test real contract interactions. + * + * Veda BoringVault implements the ERC-4626 standard for tokenized vaults: + * - Users deposit assets (e.g., USDC) and receive vault shares representing proportional ownership + * - Shares are NOT 1:1 with assets - the conversion rate depends on vault's total assets and total supply + * - The vault contract itself is the ERC-20 share token (no separate token contract) + * - Veda uses multiple contracts to manage the flow of funds: + * - We implement Teller for deposits and withdrawals + * - We implement BoringVault for the approval and custody of assets + * - More docs here: https://docs.veda.tech/architecture-and-flow-of-funds + * + * - Security considerations: + * - We need a redelegation with specific amount to the adapter to prevent over withdrawal or deposit. This would not effect the + * user, but could drain the transaction creator wallet. + */ +contract VedaLendingTest is BaseTest { + using ModeLib for ModeCode; + + // Restricted vault - cannot set on behalfOf + IVedaTeller public constant VEDA_TELLER = IVedaTeller(0x86821F179eaD9F0b3C79b2f8deF0227eEBFDc9f9); + IERC20 public constant BORING_VAULT = IERC20(0xB5F07d769dD60fE54c97dd53101181073DDf21b2); + + IERC20 public constant USDC = IERC20(0xaf88d065e77c8cC2239327C5EDb3A432268e5831); + address public constant USDC_WHALE = 0xC6962004f452bE9203591991D15f6b388e09E8D0; + address public owner; + + // Enforcers for delegation restrictions + AllowedTargetsEnforcer public allowedTargetsEnforcer; + AllowedMethodsEnforcer public allowedMethodsEnforcer; + AllowedCalldataEnforcer public allowedCalldataEnforcer; + ValueLteEnforcer public valueLteEnforcer; + LogicalOrWrapperEnforcer public logicalOrWrapperEnforcer; + ERC20TransferAmountEnforcer public erc20TransferAmountEnforcer; + RedeemerEnforcer public redeemerEnforcer; + LimitedCallsEnforcer public limitedCallsEnforcer; + VedaAdapter public vedaAdapter; + + uint256 public constant INITIAL_USD_BALANCE = 10000000000; // 10k USDC + uint256 public constant DEPOSIT_AMOUNT = 1000000000; // 1k USDC + uint256 public constant SHARE_LOCK_SECONDS = 61; // Warp past the 60s share lock period applied by deposit() + + ////////////////////// Setup ////////////////////// + + function setUp() public override { + // Create fork from mainnet at specific block + vm.createSelectFork(vm.envString("ARBITRUM_RPC_URL")); + + // Set implementation type + IMPLEMENTATION = Implementation.Hybrid; + SIGNATURE_TYPE = SignatureType.RawP256; + + // Call parent setup to initialize delegation framework + super.setUp(); + + owner = makeAddr("VedaAdapter Owner"); + + // Deploy enforcers + allowedTargetsEnforcer = new AllowedTargetsEnforcer(); + allowedMethodsEnforcer = new AllowedMethodsEnforcer(); + allowedCalldataEnforcer = new AllowedCalldataEnforcer(); + valueLteEnforcer = new ValueLteEnforcer(); + erc20TransferAmountEnforcer = new ERC20TransferAmountEnforcer(); + redeemerEnforcer = new RedeemerEnforcer(); + limitedCallsEnforcer = new LimitedCallsEnforcer(); + logicalOrWrapperEnforcer = new LogicalOrWrapperEnforcer(delegationManager); + vedaAdapter = new VedaAdapter(owner, address(delegationManager), address(BORING_VAULT), address(VEDA_TELLER), address(USDC)); + + vm.label(address(allowedTargetsEnforcer), "AllowedTargetsEnforcer"); + vm.label(address(allowedMethodsEnforcer), "AllowedMethodsEnforcer"); + vm.label(address(allowedCalldataEnforcer), "AllowedCalldataEnforcer"); + vm.label(address(valueLteEnforcer), "ValueLteEnforcer"); + vm.label(address(logicalOrWrapperEnforcer), "LogicalOrWrapperEnforcer"); + vm.label(address(erc20TransferAmountEnforcer), "ERC20TransferAmountEnforcer"); + vm.label(address(vedaAdapter), "VedaAdapter"); + vm.label(address(BORING_VAULT), "Veda BoringVault"); + vm.label(address(VEDA_TELLER), "Veda Teller"); + vm.label(address(USDC), "USDC"); + vm.label(USDC_WHALE, "USDC Whale"); + + vm.deal(address(users.alice.deleGator), 1 ether); + vm.deal(address(users.bob.deleGator), 1 ether); + + vm.prank(USDC_WHALE); + USDC.transfer(address(users.alice.deleGator), INITIAL_USD_BALANCE); // 10k USDC + } + + // ================================================================================== + // Section 1: Direct Protocol Tests (Fork Sanity) + // Validates the forked mainnet environment works before testing adapter logic. + // ================================================================================== + + function test_deposit_direct_usdc() public { + uint256 aliceUSDCInitialBalance_ = USDC.balanceOf(address(users.alice.deleGator)); + assertEq(aliceUSDCInitialBalance_, INITIAL_USD_BALANCE); + + uint256 aliceSharesBefore_ = BORING_VAULT.balanceOf(address(users.alice.deleGator)); + + vm.prank(address(users.alice.deleGator)); + USDC.approve(address(BORING_VAULT), DEPOSIT_AMOUNT); + vm.prank(address(users.alice.deleGator)); + uint256 sharesMinted_ = VEDA_TELLER.deposit(address(USDC), DEPOSIT_AMOUNT, 0, address(0)); + + uint256 aliceUSDCBalance_ = USDC.balanceOf(address(users.alice.deleGator)); + assertEq(aliceUSDCBalance_, INITIAL_USD_BALANCE - DEPOSIT_AMOUNT); + + uint256 aliceSharesAfter_ = BORING_VAULT.balanceOf(address(users.alice.deleGator)); + assertEq(aliceSharesAfter_ - aliceSharesBefore_, sharesMinted_); + } + + function test_withdraw_direct_usdc() public { + _setupLendingState(); + vm.warp(block.timestamp + SHARE_LOCK_SECONDS); + + uint256 aliceUSDCAfterDeposit_ = USDC.balanceOf(address(users.alice.deleGator)); + assertEq(aliceUSDCAfterDeposit_, INITIAL_USD_BALANCE - DEPOSIT_AMOUNT); + + uint256 aliceShares_ = BORING_VAULT.balanceOf(address(users.alice.deleGator)); + assertGt(aliceShares_, 0, "Alice should have vault shares after deposit"); + + // Withdraw all shares back to USDC + vm.prank(address(users.alice.deleGator)); + uint256 assetsOut_ = VEDA_TELLER.withdraw(address(USDC), aliceShares_, 0, address(users.alice.deleGator)); + + assertGt(assetsOut_, 0, "Should receive assets back"); + + uint256 aliceSharesAfter_ = BORING_VAULT.balanceOf(address(users.alice.deleGator)); + assertEq(aliceSharesAfter_, 0, "All shares should be burned"); + + uint256 aliceUSDCFinal_ = USDC.balanceOf(address(users.alice.deleGator)); + assertApproxEqAbs(aliceUSDCFinal_, INITIAL_USD_BALANCE, DEPOSIT_AMOUNT / 100, "USDC balance should be close to initial"); + } + + // ================================================================================== + // Section 2: Adapter Happy-Path Tests (Core Functionality) + // Validates the standard deposit/withdraw flow via the adapter using delegations. + // ================================================================================== + + function test_deposit_viaAdapterDelegation_usdc() public { + uint256 aliceUSDCInitialBalance_ = USDC.balanceOf(address(users.alice.deleGator)); + assertEq(aliceUSDCInitialBalance_, INITIAL_USD_BALANCE); + uint256 aliceSharesInitial_ = BORING_VAULT.balanceOf(address(users.alice.deleGator)); + assertEq(aliceSharesInitial_, 0); + + // Alice delegates USDC transfer rights to Bob, redeemable only by the adapter + Delegation memory delegation_ = + _createTransferDelegation(address(users.bob.deleGator), address(vedaAdapter), address(USDC), type(uint256).max); + + // Bob redelegates to the VedaAdapter with a transfer amount cap + Delegation memory redelegation_ = + _createAdapterRedelegation(EncoderLib._getDelegationHash(delegation_), address(USDC), DEPOSIT_AMOUNT); + + Delegation[] memory delegations_ = new Delegation[](2); + delegations_[0] = redelegation_; + delegations_[1] = delegation_; + + vm.prank(address(users.bob.deleGator)); + vedaAdapter.depositByDelegation(delegations_, 0); + + uint256 aliceUSDCFinal_ = USDC.balanceOf(address(users.alice.deleGator)); + assertEq(aliceUSDCFinal_, INITIAL_USD_BALANCE - DEPOSIT_AMOUNT, "USDC balance should decrease"); + + uint256 aliceSharesFinal_ = BORING_VAULT.balanceOf(address(users.alice.deleGator)); + assertGt(aliceSharesFinal_, 0, "Shares should be minted to Alice"); + } + + function test_withdraw_viaAdapterDelegation_usdc() public { + _setupLendingState(); + vm.warp(block.timestamp + SHARE_LOCK_SECONDS); + + uint256 aliceShares_ = BORING_VAULT.balanceOf(address(users.alice.deleGator)); + assertGt(aliceShares_, 0, "Alice should have vault shares"); + uint256 aliceUSDCBefore_ = USDC.balanceOf(address(users.alice.deleGator)); + + // Alice delegates BoringVault share transfer rights to Bob, redeemable only by the adapter + Delegation memory delegation_ = _createTransferDelegation( + address(users.bob.deleGator), address(vedaAdapter), address(BORING_VAULT), type(uint256).max + ); + + // Bob redelegates to the VedaAdapter with a share transfer amount cap + Delegation memory redelegation_ = + _createAdapterRedelegation(EncoderLib._getDelegationHash(delegation_), address(BORING_VAULT), aliceShares_); + + Delegation[] memory delegations_ = new Delegation[](2); + delegations_[0] = redelegation_; + delegations_[1] = delegation_; + + vm.prank(address(users.bob.deleGator)); + vedaAdapter.withdrawByDelegation(delegations_, 0); + + uint256 aliceSharesAfter_ = BORING_VAULT.balanceOf(address(users.alice.deleGator)); + assertEq(aliceSharesAfter_, 0, "All shares should be burned"); + + uint256 aliceUSDCAfter_ = USDC.balanceOf(address(users.alice.deleGator)); + assertGt(aliceUSDCAfter_, aliceUSDCBefore_, "Alice should receive USDC back"); + assertApproxEqAbs(aliceUSDCAfter_, INITIAL_USD_BALANCE, DEPOSIT_AMOUNT / 100, "USDC balance should be close to initial"); + } + + // ================================================================================== + // Section 3: Constructor Validation Tests + // Ensures the adapter rejects invalid constructor parameters. + // ================================================================================== + + /// @notice Constructor must revert when delegationManager is zero address + function test_constructor_revertsOnZeroDelegationManager() public { + vm.expectRevert(VedaAdapter.InvalidZeroAddress.selector); + new VedaAdapter(owner, address(0), address(BORING_VAULT), address(VEDA_TELLER), address(USDC)); + } + + /// @notice Constructor must revert when boringVault is zero address + function test_constructor_revertsOnZeroBoringVault() public { + vm.expectRevert(VedaAdapter.InvalidZeroAddress.selector); + new VedaAdapter(owner, address(delegationManager), address(0), address(VEDA_TELLER), address(USDC)); + } + + /// @notice Constructor must revert when teller is zero address + function test_constructor_revertsOnZeroTeller() public { + vm.expectRevert(VedaAdapter.InvalidZeroAddress.selector); + new VedaAdapter(owner, address(delegationManager), address(BORING_VAULT), address(0), address(USDC)); + } + + /// @notice Constructor must revert when depositToken is zero address + function test_constructor_revertsOnZeroDepositToken() public { + vm.expectRevert(VedaAdapter.InvalidZeroAddress.selector); + new VedaAdapter(owner, address(delegationManager), address(BORING_VAULT), address(VEDA_TELLER), address(0)); + } + + /// @notice Constructor must revert when owner is zero address (OZ Ownable) + function test_constructor_revertsOnZeroOwner() public { + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableInvalidOwner.selector, address(0))); + new VedaAdapter(address(0), address(delegationManager), address(BORING_VAULT), address(VEDA_TELLER), address(USDC)); + } + + /// @notice Constructor must store immutable state correctly with valid inputs + function test_constructor_successWithValidAddresses() public { + VedaAdapter newAdapter_ = + new VedaAdapter(owner, address(delegationManager), address(BORING_VAULT), address(VEDA_TELLER), address(USDC)); + + assertEq(address(newAdapter_.delegationManager()), address(delegationManager)); + assertEq(newAdapter_.boringVault(), address(BORING_VAULT)); + assertEq(address(newAdapter_.teller()), address(VEDA_TELLER)); + assertEq(address(newAdapter_.depositToken()), address(USDC)); + assertEq(newAdapter_.owner(), owner); + } + + // ================================================================================== + // Section 4: Deposit Input Validation / Revert Tests + // Ensures depositByDelegation rejects invalid inputs before any state changes. + // ================================================================================== + + /// @notice depositByDelegation must revert with 0 delegations + function test_depositByDelegation_revertsOnEmptyDelegations() public { + Delegation[] memory delegations_ = new Delegation[](0); + + vm.expectRevert(VedaAdapter.InvalidDelegationsLength.selector); + vm.prank(address(users.bob.deleGator)); + vedaAdapter.depositByDelegation(delegations_, 0); + } + + /// @notice depositByDelegation must revert with only 1 delegation (requires >= 2 for redelegation pattern) + function test_depositByDelegation_revertsOnSingleDelegation() public { + Delegation memory delegation_ = + _createTransferDelegation(address(users.bob.deleGator), address(vedaAdapter), address(USDC), DEPOSIT_AMOUNT); + Delegation[] memory delegations_ = new Delegation[](1); + delegations_[0] = delegation_; + + vm.expectRevert(VedaAdapter.InvalidDelegationsLength.selector); + vm.prank(address(users.bob.deleGator)); + vedaAdapter.depositByDelegation(delegations_, 0); + } + + // ================================================================================== + // Section 5: Withdraw Input Validation / Revert Tests + // Ensures withdrawByDelegation rejects invalid inputs before any state changes. + // ================================================================================== + + /// @notice withdrawByDelegation must revert with 0 delegations + function test_withdrawByDelegation_revertsOnEmptyDelegations() public { + _setupLendingState(); + + Delegation[] memory delegations_ = new Delegation[](0); + + vm.expectRevert(VedaAdapter.InvalidDelegationsLength.selector); + vm.prank(address(users.bob.deleGator)); + vedaAdapter.withdrawByDelegation(delegations_, 0); + } + + /// @notice withdrawByDelegation must revert with only 1 delegation (requires >= 2 for redelegation pattern) + function test_withdrawByDelegation_revertsOnSingleDelegation() public { + _setupLendingState(); + + Delegation memory delegation_ = + _createTransferDelegation(address(users.bob.deleGator), address(vedaAdapter), address(BORING_VAULT), DEPOSIT_AMOUNT); + Delegation[] memory delegations_ = new Delegation[](1); + delegations_[0] = delegation_; + + vm.expectRevert(VedaAdapter.InvalidDelegationsLength.selector); + vm.prank(address(users.bob.deleGator)); + vedaAdapter.withdrawByDelegation(delegations_, 0); + } + + // ================================================================================== + // Section 6: Event Emission Tests + // Validates that adapter emits correct events with expected indexed parameters. + // ================================================================================== + + /// @notice depositByDelegation must emit DepositExecuted with correct parameters + function test_depositByDelegation_emitsDepositExecutedEvent() public { + Delegation memory delegation_ = + _createTransferDelegation(address(users.bob.deleGator), address(vedaAdapter), address(USDC), type(uint256).max); + Delegation memory redelegation_ = + _createAdapterRedelegation(EncoderLib._getDelegationHash(delegation_), address(USDC), DEPOSIT_AMOUNT); + + Delegation[] memory delegations_ = new Delegation[](2); + delegations_[0] = redelegation_; + delegations_[1] = delegation_; + + // Expect event: check indexed delegator, delegate, and token. Amount and shares are checked via topic4. + vm.expectEmit(true, true, true, false, address(vedaAdapter)); + emit VedaAdapter.DepositExecuted( + address(users.alice.deleGator), address(users.bob.deleGator), address(USDC), DEPOSIT_AMOUNT, 0 + ); + + vm.prank(address(users.bob.deleGator)); + vedaAdapter.depositByDelegation(delegations_, 0); + } + + /// @notice withdrawByDelegation must emit WithdrawExecuted with correct parameters + function test_withdrawByDelegation_emitsWithdrawExecutedEvent() public { + _setupLendingState(); + vm.warp(block.timestamp + SHARE_LOCK_SECONDS); + + uint256 aliceShares_ = BORING_VAULT.balanceOf(address(users.alice.deleGator)); + + Delegation memory delegation_ = _createTransferDelegation( + address(users.bob.deleGator), address(vedaAdapter), address(BORING_VAULT), type(uint256).max + ); + Delegation memory redelegation_ = + _createAdapterRedelegation(EncoderLib._getDelegationHash(delegation_), address(BORING_VAULT), aliceShares_); + + Delegation[] memory delegations_ = new Delegation[](2); + delegations_[0] = redelegation_; + delegations_[1] = delegation_; + + // Expect event: check indexed delegator, delegate, and token. shareAmount and assetsOut are checked via topic4. + vm.expectEmit(true, true, true, false, address(vedaAdapter)); + emit VedaAdapter.WithdrawExecuted( + address(users.alice.deleGator), address(users.bob.deleGator), address(USDC), aliceShares_, 0 + ); + + vm.prank(address(users.bob.deleGator)); + vedaAdapter.withdrawByDelegation(delegations_, 0); + } + + // ================================================================================== + // Section 7: Batch Operation Tests + // Validates depositByDelegationBatch and withdrawByDelegationBatch. + // ================================================================================== + + /// @notice depositByDelegationBatch must revert on empty array + function test_depositByDelegationBatch_revertsOnEmptyArray() public { + VedaAdapter.DepositParams[] memory streams_ = new VedaAdapter.DepositParams[](0); + + vm.expectRevert(VedaAdapter.InvalidBatchLength.selector); + vm.prank(address(users.bob.deleGator)); + vedaAdapter.depositByDelegationBatch(streams_); + } + + /// @notice withdrawByDelegationBatch must revert on empty array + function test_withdrawByDelegationBatch_revertsOnEmptyArray() public { + VedaAdapter.WithdrawParams[] memory streams_ = new VedaAdapter.WithdrawParams[](0); + + vm.expectRevert(VedaAdapter.InvalidBatchLength.selector); + vm.prank(address(users.bob.deleGator)); + vedaAdapter.withdrawByDelegationBatch(streams_); + } + + /// @notice Batch deposit with 2 independent delegation chains in a single transaction + function test_depositByDelegationBatch_twoDelegationChains() public { + uint256 amount1_ = 300 * 1e6; // 300 USDC + uint256 amount2_ = 400 * 1e6; // 400 USDC + + // Chain 1: Alice -> Bob -> VedaAdapter (salt 0) + Delegation memory delegation1_ = _createTransferDelegationWithSalt( + address(users.bob.deleGator), address(vedaAdapter), address(USDC), type(uint256).max, 0 + ); + Delegation memory redelegation1_ = + _createAdapterRedelegationWithSalt(EncoderLib._getDelegationHash(delegation1_), address(USDC), amount1_, 0); + Delegation[] memory delegations1_ = new Delegation[](2); + delegations1_[0] = redelegation1_; + delegations1_[1] = delegation1_; + + // Chain 2: Alice -> Bob -> VedaAdapter (salt 1) + Delegation memory delegation2_ = _createTransferDelegationWithSalt( + address(users.bob.deleGator), address(vedaAdapter), address(USDC), type(uint256).max, 1 + ); + Delegation memory redelegation2_ = + _createAdapterRedelegationWithSalt(EncoderLib._getDelegationHash(delegation2_), address(USDC), amount2_, 1); + Delegation[] memory delegations2_ = new Delegation[](2); + delegations2_[0] = redelegation2_; + delegations2_[1] = delegation2_; + + VedaAdapter.DepositParams[] memory streams_ = new VedaAdapter.DepositParams[](2); + streams_[0] = VedaAdapter.DepositParams({ delegations: delegations1_, minimumMint: 0 }); + streams_[1] = VedaAdapter.DepositParams({ delegations: delegations2_, minimumMint: 0 }); + + vm.expectEmit(true, true, true, true, address(vedaAdapter)); + emit VedaAdapter.BatchDepositExecuted(address(users.bob.deleGator), 2); + + vm.prank(address(users.bob.deleGator)); + vedaAdapter.depositByDelegationBatch(streams_); + + uint256 aliceUSDCFinal_ = USDC.balanceOf(address(users.alice.deleGator)); + assertEq(aliceUSDCFinal_, INITIAL_USD_BALANCE - amount1_ - amount2_, "USDC should decrease by total batch amount"); + + uint256 aliceShares_ = BORING_VAULT.balanceOf(address(users.alice.deleGator)); + assertGt(aliceShares_, 0, "Alice should receive vault shares from batch deposit"); + } + + /// @notice Batch withdraw with 2 independent delegation chains in a single transaction + function test_withdrawByDelegationBatch_twoDelegationChains() public { + // Setup: Deposit via adapter to create shares, then warp past the 60s share lock period + _depositViaAdapter(DEPOSIT_AMOUNT, 10); + vm.warp(block.timestamp + SHARE_LOCK_SECONDS); + + uint256 totalShares_ = BORING_VAULT.balanceOf(address(users.alice.deleGator)); + assertGt(totalShares_, 0, "Alice should have shares after deposit"); + + uint256 sharesPart1_ = totalShares_ / 2; + uint256 sharesPart2_ = totalShares_ - sharesPart1_; + + VedaAdapter.WithdrawParams[] memory wdStreams_ = new VedaAdapter.WithdrawParams[](2); + wdStreams_[0] = _buildWithdrawParams(sharesPart1_, 20); + wdStreams_[1] = _buildWithdrawParams(sharesPart2_, 21); + + vm.expectEmit(true, true, true, true, address(vedaAdapter)); + emit VedaAdapter.BatchWithdrawExecuted(address(users.bob.deleGator), 2); + + vm.prank(address(users.bob.deleGator)); + vedaAdapter.withdrawByDelegationBatch(wdStreams_); + + assertEq(BORING_VAULT.balanceOf(address(users.alice.deleGator)), 0, "All shares should be redeemed after batch withdraw"); + assertApproxEqAbs( + USDC.balanceOf(address(users.alice.deleGator)), + INITIAL_USD_BALANCE, + DEPOSIT_AMOUNT / 100, + "USDC should be approximately restored" + ); + } + + // ================================================================================== + // Section 8: Emergency Withdraw Tests + // Validates the owner-only withdrawEmergency function for recovering stuck tokens. + // ================================================================================== + + /// @notice Only the contract owner can call withdrawEmergency + function test_withdrawEmergency_revertsOnNonOwner() public { + BasicERC20 testToken_ = new BasicERC20(owner, "TestToken", "TST", 0); + vm.prank(owner); + testToken_.mint(address(vedaAdapter), 100 ether); + + vm.prank(address(users.alice.deleGator)); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(users.alice.deleGator))); + vedaAdapter.withdrawEmergency(testToken_, 50 ether, address(users.alice.deleGator)); + + assertEq(testToken_.balanceOf(address(vedaAdapter)), 100 ether, "Balance should be unchanged"); + } + + /// @notice Owner can recover stuck tokens; emits StuckTokensWithdrawn event + function test_withdrawEmergency_recoverTokens() public { + BasicERC20 testToken_ = new BasicERC20(owner, "TestToken", "TST", 0); + vm.prank(owner); + testToken_.mint(address(vedaAdapter), 100 ether); + + vm.expectEmit(true, true, true, true, address(vedaAdapter)); + emit VedaAdapter.StuckTokensWithdrawn(testToken_, address(users.alice.deleGator), 50 ether); + + vm.prank(owner); + vedaAdapter.withdrawEmergency(testToken_, 50 ether, address(users.alice.deleGator)); + + assertEq(testToken_.balanceOf(address(vedaAdapter)), 50 ether, "Adapter should retain remaining tokens"); + assertEq(testToken_.balanceOf(address(users.alice.deleGator)), 50 ether, "Recipient should receive tokens"); + } + + /// @notice withdrawEmergency must revert when recipient is zero address + function test_withdrawEmergency_revertsOnZeroRecipient() public { + BasicERC20 testToken_ = new BasicERC20(owner, "TestToken", "TST", 0); + vm.prank(owner); + testToken_.mint(address(vedaAdapter), 100 ether); + + vm.expectRevert(VedaAdapter.InvalidRecipient.selector); + vm.prank(owner); + vedaAdapter.withdrawEmergency(testToken_, 50 ether, address(0)); + } + + // ================================================================================== + // Section 9: Edge Cases and Security Validation + // Tests for subtle behaviors, allowance management, chain integrity, and token mismatch. + // ================================================================================== + + /// @notice After a deposit, the adapter must not retain any deposited tokens + function test_adapterDoesNotRetainTokensAfterDeposit() public { + Delegation memory delegation_ = + _createTransferDelegation(address(users.bob.deleGator), address(vedaAdapter), address(USDC), type(uint256).max); + Delegation memory redelegation_ = + _createAdapterRedelegation(EncoderLib._getDelegationHash(delegation_), address(USDC), DEPOSIT_AMOUNT); + + Delegation[] memory delegations_ = new Delegation[](2); + delegations_[0] = redelegation_; + delegations_[1] = delegation_; + + vm.prank(address(users.bob.deleGator)); + vedaAdapter.depositByDelegation(delegations_, 0); + + assertEq(USDC.balanceOf(address(vedaAdapter)), 0, "Adapter must not retain any USDC after deposit"); + } + + /// @notice After a withdraw, the adapter must not retain any vault shares + function test_adapterDoesNotRetainSharesAfterWithdraw() public { + _setupLendingState(); + vm.warp(block.timestamp + SHARE_LOCK_SECONDS); + + uint256 aliceShares_ = BORING_VAULT.balanceOf(address(users.alice.deleGator)); + + Delegation memory delegation_ = _createTransferDelegation( + address(users.bob.deleGator), address(vedaAdapter), address(BORING_VAULT), type(uint256).max + ); + Delegation memory redelegation_ = + _createAdapterRedelegation(EncoderLib._getDelegationHash(delegation_), address(BORING_VAULT), aliceShares_); + + Delegation[] memory delegations_ = new Delegation[](2); + delegations_[0] = redelegation_; + delegations_[1] = delegation_; + + vm.prank(address(users.bob.deleGator)); + vedaAdapter.withdrawByDelegation(delegations_, 0); + + assertEq(BORING_VAULT.balanceOf(address(vedaAdapter)), 0, "Adapter must not retain any vault shares after withdraw"); + } + + /// @notice After the first deposit, the adapter grants unlimited allowance to BoringVault. + /// Subsequent deposits reuse the existing allowance without issuing a new approval. + function test_allowanceFullyConsumedAfterDeposit() public { + assertEq(USDC.allowance(address(vedaAdapter), address(BORING_VAULT)), 0, "Initial allowance should be 0"); + + Delegation memory delegation_ = + _createTransferDelegation(address(users.bob.deleGator), address(vedaAdapter), address(USDC), type(uint256).max); + Delegation memory redelegation_ = + _createAdapterRedelegation(EncoderLib._getDelegationHash(delegation_), address(USDC), DEPOSIT_AMOUNT); + + Delegation[] memory delegations_ = new Delegation[](2); + delegations_[0] = redelegation_; + delegations_[1] = delegation_; + + vm.prank(address(users.bob.deleGator)); + vedaAdapter.depositByDelegation(delegations_, 0); + + assertEq( + USDC.allowance(address(vedaAdapter), address(BORING_VAULT)), + type(uint256).max - DEPOSIT_AMOUNT, + "Allowance should be unlimited minus the deposited amount" + ); + } + + /// @notice A 3-level delegation chain (Alice -> Carol -> Bob -> Adapter) must correctly resolve + /// rootDelegator as Alice, ensuring shares are minted to the actual token owner. + function test_depositByDelegation_withThreeLevelDelegationChain() public { + vm.deal(address(users.carol.deleGator), 1 ether); + + // Root delegation: Alice -> Carol (with transfer enforcer + redeemer enforcer) + Delegation memory rootDelegation_ = + _createTransferDelegation(address(users.carol.deleGator), address(vedaAdapter), address(USDC), type(uint256).max); + + // Middle delegation: Carol -> Bob (no additional caveats, just extends the chain) + Delegation memory middleDelegation_ = Delegation({ + delegate: address(users.bob.deleGator), + delegator: address(users.carol.deleGator), + authority: EncoderLib._getDelegationHash(rootDelegation_), + caveats: new Caveat[](0), + salt: 0, + signature: hex"" + }); + middleDelegation_ = signDelegation(users.carol, middleDelegation_); + + // Leaf delegation: Bob -> VedaAdapter (with transfer amount cap) + Delegation memory adapterDelegation_ = + _createAdapterRedelegation(EncoderLib._getDelegationHash(middleDelegation_), address(USDC), DEPOSIT_AMOUNT); + + // Chain order: [leaf, middle, root] + Delegation[] memory delegations_ = new Delegation[](3); + delegations_[0] = adapterDelegation_; + delegations_[1] = middleDelegation_; + delegations_[2] = rootDelegation_; + + vm.prank(address(users.bob.deleGator)); + vedaAdapter.depositByDelegation(delegations_, 0); + + // rootDelegator_ = delegations[2].delegator = Alice + uint256 aliceShares_ = BORING_VAULT.balanceOf(address(users.alice.deleGator)); + assertGt(aliceShares_, 0, "Shares must be minted to Alice (root delegator), not Carol or Bob"); + + assertEq(BORING_VAULT.balanceOf(address(users.carol.deleGator)), 0, "Carol must not receive shares"); + assertEq(BORING_VAULT.balanceOf(address(users.bob.deleGator)), 0, "Bob must not receive shares"); + } + + // ================================================================================== + // Section 10: Terms Validation Tests + // Ensures the adapter rejects malformed caveat terms before executing. + // ================================================================================== + + /// @notice depositByDelegation must revert when leaf caveat terms are shorter than 52 bytes + function test_depositByDelegation_revertsOnShortTerms() public { + Delegation memory delegation_ = + _createTransferDelegation(address(users.bob.deleGator), address(vedaAdapter), address(USDC), type(uint256).max); + + Caveat[] memory shortCaveats_ = new Caveat[](1); + shortCaveats_[0] = Caveat({ + args: hex"", + enforcer: address(erc20TransferAmountEnforcer), + terms: abi.encodePacked(address(USDC)) // 20 bytes, too short + }); + + Delegation memory redelegation_ = Delegation({ + delegate: address(vedaAdapter), + delegator: address(users.bob.deleGator), + authority: EncoderLib._getDelegationHash(delegation_), + caveats: shortCaveats_, + salt: 0, + signature: hex"" + }); + redelegation_ = signDelegation(users.bob, redelegation_); + + Delegation[] memory delegations_ = new Delegation[](2); + delegations_[0] = redelegation_; + delegations_[1] = delegation_; + + vm.expectRevert(VedaAdapter.InvalidTermsLength.selector); + vm.prank(address(users.bob.deleGator)); + vedaAdapter.depositByDelegation(delegations_, 0); + } + + /// @notice withdrawByDelegation must revert when leaf caveat terms are shorter than 52 bytes + function test_withdrawByDelegation_revertsOnShortTerms() public { + _setupLendingState(); + + Delegation memory delegation_ = _createTransferDelegation( + address(users.bob.deleGator), address(vedaAdapter), address(BORING_VAULT), type(uint256).max + ); + + Caveat[] memory shortCaveats_ = new Caveat[](1); + shortCaveats_[0] = Caveat({ + args: hex"", + enforcer: address(erc20TransferAmountEnforcer), + terms: hex"aabbccdd" // 4 bytes, too short + }); + + Delegation memory redelegation_ = Delegation({ + delegate: address(vedaAdapter), + delegator: address(users.bob.deleGator), + authority: EncoderLib._getDelegationHash(delegation_), + caveats: shortCaveats_, + salt: 0, + signature: hex"" + }); + redelegation_ = signDelegation(users.bob, redelegation_); + + Delegation[] memory delegations_ = new Delegation[](2); + delegations_[0] = redelegation_; + delegations_[1] = delegation_; + + vm.expectRevert(VedaAdapter.InvalidTermsLength.selector); + vm.prank(address(users.bob.deleGator)); + vedaAdapter.withdrawByDelegation(delegations_, 0); + } + + // ================================================================================== + // Section 11: Replay / Double-Spend Prevention Tests + // Validates that the ERC20TransferAmountEnforcer prevents reuse of the same delegation. + // ================================================================================== + + /// @notice Calling depositByDelegation twice with the same delegation chain must revert on the second call + function test_depositByDelegation_revertsOnReplay() public { + Delegation memory delegation_ = + _createTransferDelegation(address(users.bob.deleGator), address(vedaAdapter), address(USDC), type(uint256).max); + Delegation memory redelegation_ = + _createAdapterRedelegation(EncoderLib._getDelegationHash(delegation_), address(USDC), DEPOSIT_AMOUNT); + + Delegation[] memory delegations_ = new Delegation[](2); + delegations_[0] = redelegation_; + delegations_[1] = delegation_; + + vm.prank(address(users.bob.deleGator)); + vedaAdapter.depositByDelegation(delegations_, 0); + + vm.prank(address(users.bob.deleGator)); + vm.expectRevert(); + vedaAdapter.depositByDelegation(delegations_, 0); + } + + // ================================================================================== + // Section 12: Slippage Protection Tests + // Validates that minimumMint / minimumAssets bounds cause reverts when not met. + // ================================================================================== + + /// @notice depositByDelegation must revert when minimumMint exceeds the actual shares minted + function test_depositByDelegation_revertsOnSlippage() public { + Delegation memory delegation_ = + _createTransferDelegation(address(users.bob.deleGator), address(vedaAdapter), address(USDC), type(uint256).max); + Delegation memory redelegation_ = + _createAdapterRedelegation(EncoderLib._getDelegationHash(delegation_), address(USDC), DEPOSIT_AMOUNT); + + Delegation[] memory delegations_ = new Delegation[](2); + delegations_[0] = redelegation_; + delegations_[1] = delegation_; + + vm.prank(address(users.bob.deleGator)); + vm.expectRevert(); + vedaAdapter.depositByDelegation(delegations_, type(uint256).max); + } + + /// @notice withdrawByDelegation must revert when minimumAssets exceeds the actual assets received + function test_withdrawByDelegation_revertsOnSlippage() public { + _setupLendingState(); + vm.warp(block.timestamp + SHARE_LOCK_SECONDS); + + uint256 aliceShares_ = BORING_VAULT.balanceOf(address(users.alice.deleGator)); + + Delegation memory delegation_ = _createTransferDelegation( + address(users.bob.deleGator), address(vedaAdapter), address(BORING_VAULT), type(uint256).max + ); + Delegation memory redelegation_ = + _createAdapterRedelegation(EncoderLib._getDelegationHash(delegation_), address(BORING_VAULT), aliceShares_); + + Delegation[] memory delegations_ = new Delegation[](2); + delegations_[0] = redelegation_; + delegations_[1] = delegation_; + + vm.prank(address(users.bob.deleGator)); + vm.expectRevert(); + vedaAdapter.withdrawByDelegation(delegations_, type(uint256).max); + } + + // ================================================================================== + // Section 13: Alternative Delegator Tests + // Validates the adapter works correctly when Carol (not Alice) is the root delegator. + // ================================================================================== + + /// @notice Deposit via adapter where Carol is the root delegator instead of Alice + function test_depositByDelegation_carolAsRootDelegator() public { + vm.deal(address(users.carol.deleGator), 1 ether); + vm.prank(USDC_WHALE); + USDC.transfer(address(users.carol.deleGator), INITIAL_USD_BALANCE); + + uint256 carolUSDCBefore_ = USDC.balanceOf(address(users.carol.deleGator)); + + // Carol delegates USDC transfer rights to Bob, redeemable only by the adapter + Delegation memory delegation_ = _createTransferDelegationFull( + users.carol, address(users.bob.deleGator), address(vedaAdapter), address(USDC), type(uint256).max, 0 + ); + + // Bob redelegates to the VedaAdapter with a transfer amount cap + Delegation memory redelegation_ = + _createAdapterRedelegation(EncoderLib._getDelegationHash(delegation_), address(USDC), DEPOSIT_AMOUNT); + + Delegation[] memory delegations_ = new Delegation[](2); + delegations_[0] = redelegation_; + delegations_[1] = delegation_; + + vm.prank(address(users.bob.deleGator)); + vedaAdapter.depositByDelegation(delegations_, 0); + + uint256 carolUSDCAfter_ = USDC.balanceOf(address(users.carol.deleGator)); + assertEq(carolUSDCAfter_, carolUSDCBefore_ - DEPOSIT_AMOUNT, "Carol's USDC should decrease"); + + uint256 carolShares_ = BORING_VAULT.balanceOf(address(users.carol.deleGator)); + assertGt(carolShares_, 0, "Shares should be minted to Carol (root delegator)"); + + assertEq(BORING_VAULT.balanceOf(address(users.bob.deleGator)), 0, "Bob must not receive shares"); + assertEq(BORING_VAULT.balanceOf(address(users.alice.deleGator)), 0, "Alice must not receive shares"); + } + + /// @notice Withdraw via adapter where Carol is the root delegator instead of Alice + function test_withdrawByDelegation_carolAsRootDelegator() public { + vm.deal(address(users.carol.deleGator), 1 ether); + vm.prank(USDC_WHALE); + USDC.transfer(address(users.carol.deleGator), INITIAL_USD_BALANCE); + + // Carol deposits directly to get shares + vm.prank(address(users.carol.deleGator)); + USDC.approve(address(BORING_VAULT), DEPOSIT_AMOUNT); + vm.prank(address(users.carol.deleGator)); + VEDA_TELLER.deposit(address(USDC), DEPOSIT_AMOUNT, 0, address(0)); + vm.warp(block.timestamp + SHARE_LOCK_SECONDS); + + uint256 carolShares_ = BORING_VAULT.balanceOf(address(users.carol.deleGator)); + assertGt(carolShares_, 0, "Carol should have vault shares"); + uint256 carolUSDCBefore_ = USDC.balanceOf(address(users.carol.deleGator)); + + // Carol delegates share transfer rights to Bob, redeemable only by the adapter + Delegation memory delegation_ = _createTransferDelegationFull( + users.carol, address(users.bob.deleGator), address(vedaAdapter), address(BORING_VAULT), type(uint256).max, 0 + ); + + // Bob redelegates to the VedaAdapter with a share transfer amount cap + Delegation memory redelegation_ = + _createAdapterRedelegation(EncoderLib._getDelegationHash(delegation_), address(BORING_VAULT), carolShares_); + + Delegation[] memory delegations_ = new Delegation[](2); + delegations_[0] = redelegation_; + delegations_[1] = delegation_; + + vm.prank(address(users.bob.deleGator)); + vedaAdapter.withdrawByDelegation(delegations_, 0); + + assertEq(BORING_VAULT.balanceOf(address(users.carol.deleGator)), 0, "All shares should be burned"); + uint256 carolUSDCAfter_ = USDC.balanceOf(address(users.carol.deleGator)); + assertGt(carolUSDCAfter_, carolUSDCBefore_, "Carol should receive USDC back"); + } + + // ================================================================================== + // Helper Functions + // ================================================================================== + + /// @notice Sets up initial lending state (Alice deposits USDC to get vault shares) + function _setupLendingState() internal { + vm.prank(address(users.alice.deleGator)); + USDC.approve(address(BORING_VAULT), DEPOSIT_AMOUNT); + vm.prank(address(users.alice.deleGator)); + VEDA_TELLER.deposit(address(USDC), DEPOSIT_AMOUNT, 0, address(0)); + } + + /// @notice Deposits USDC via adapter delegation (helper to reduce stack depth in batch tests) + function _depositViaAdapter(uint256 _amount, uint256 _salt) internal { + Delegation memory delegation_ = _createTransferDelegationWithSalt( + address(users.bob.deleGator), address(vedaAdapter), address(USDC), type(uint256).max, _salt + ); + Delegation memory redelegation_ = + _createAdapterRedelegationWithSalt(EncoderLib._getDelegationHash(delegation_), address(USDC), _amount, _salt); + Delegation[] memory delegations_ = new Delegation[](2); + delegations_[0] = redelegation_; + delegations_[1] = delegation_; + + vm.prank(address(users.bob.deleGator)); + vedaAdapter.depositByDelegation(delegations_, 0); + } + + /// @notice Builds a WithdrawParams struct for batch withdraw (helper to reduce stack depth) + function _buildWithdrawParams(uint256 _shareAmount, uint256 _salt) internal view returns (VedaAdapter.WithdrawParams memory) { + Delegation memory wd_ = _createTransferDelegationWithSalt( + address(users.bob.deleGator), address(vedaAdapter), address(BORING_VAULT), type(uint256).max, _salt + ); + Delegation memory rewd_ = + _createAdapterRedelegationWithSalt(EncoderLib._getDelegationHash(wd_), address(BORING_VAULT), _shareAmount, _salt); + Delegation[] memory wdDelegations_ = new Delegation[](2); + wdDelegations_[0] = rewd_; + wdDelegations_[1] = wd_; + + return VedaAdapter.WithdrawParams({ delegations: wdDelegations_, minimumAssets: 0 }); + } + + /// @notice Creates a transfer delegation with ERC20TransferAmountEnforcer and RedeemerEnforcer + function _createTransferDelegation( + address _delegate, + address _redeemer, + address _token, + uint256 _amount + ) + internal + view + returns (Delegation memory) + { + return _createTransferDelegationFull(users.alice, _delegate, _redeemer, _token, _amount, 0); + } + + /// @notice Creates a transfer delegation with a custom salt for unique delegation hashes in batch operations + function _createTransferDelegationWithSalt( + address _delegate, + address _redeemer, + address _token, + uint256 _amount, + uint256 _salt + ) + internal + view + returns (Delegation memory) + { + return _createTransferDelegationFull(users.alice, _delegate, _redeemer, _token, _amount, _salt); + } + + /// @notice Creates a transfer delegation signed by an arbitrary delegator + function _createTransferDelegationFull( + TestUser memory _delegator, + address _delegate, + address _redeemer, + address _token, + uint256 _amount, + uint256 _salt + ) + internal + view + returns (Delegation memory) + { + Caveat[] memory caveats_ = new Caveat[](2); + caveats_[0] = + Caveat({ args: hex"", enforcer: address(erc20TransferAmountEnforcer), terms: abi.encodePacked(_token, _amount) }); + + caveats_[1] = Caveat({ args: hex"", enforcer: address(redeemerEnforcer), terms: abi.encodePacked(_redeemer) }); + + Delegation memory delegation_ = Delegation({ + delegate: _delegate, + delegator: address(_delegator.deleGator), + authority: ROOT_AUTHORITY, + caveats: caveats_, + salt: _salt, + signature: hex"" + }); + + return signDelegation(_delegator, delegation_); + } + + /// @notice Creates an adapter redelegation with ERC20TransferAmountEnforcer + function _createAdapterRedelegation( + bytes32 _authority, + address _token, + uint256 _amount + ) + internal + view + returns (Delegation memory) + { + return _createAdapterRedelegationFull(users.bob, _authority, _token, _amount, 0); + } + + /// @notice Creates an adapter redelegation with a custom salt for unique delegation hashes in batch operations + function _createAdapterRedelegationWithSalt( + bytes32 _authority, + address _token, + uint256 _amount, + uint256 _salt + ) + internal + view + returns (Delegation memory) + { + return _createAdapterRedelegationFull(users.bob, _authority, _token, _amount, _salt); + } + + /// @notice Creates an adapter redelegation signed by an arbitrary operator + function _createAdapterRedelegationFull( + TestUser memory _operator, + bytes32 _authority, + address _token, + uint256 _amount, + uint256 _salt + ) + internal + view + returns (Delegation memory) + { + Caveat[] memory caveats_ = new Caveat[](1); + caveats_[0] = + Caveat({ args: hex"", enforcer: address(erc20TransferAmountEnforcer), terms: abi.encodePacked(_token, _amount) }); + + Delegation memory delegation_ = Delegation({ + delegate: address(vedaAdapter), + delegator: address(_operator.deleGator), + authority: _authority, + caveats: caveats_, + salt: _salt, + signature: hex"" + }); + + return signDelegation(_operator, delegation_); + } +}