diff --git a/.env.example b/.env.example index 4083dfd8..d4dd0284 100644 --- a/.env.example +++ b/.env.example @@ -43,7 +43,7 @@ ARBITRUM_NOVA_RPC_URL=https://nova.arbitrum.io/rpc SEI_RPC_URL=https://evm-rpc.sei-apis.com INK_RPC_URL=https://rpc-gel.inkonchain.com SONIC_RPC_URL=https://rpc.soniclabs.com -MONAD_RPC_URL=https://rpc.monad.xyz +MONAD_RPC_URL=https://monad-mainnet.infura.io/v3/${RPC_API_KEY} MEGA_ETH_RPC_URL= RONIN_RPC_URL=https://api.roninchain.com/rpc TEMPO_RPC_URL=https://rpc.tempo.xyz diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 09fc09bc..9aeadf14 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -52,4 +52,5 @@ jobs: env: LINEA_RPC_URL: ${{ secrets.LINEA_RPC_URL }} ARBITRUM_RPC_URL: ${{ secrets.ARBITRUM_RPC_URL }} + MONAD_RPC_URL: ${{ secrets.MONAD_RPC_URL }} RPC_API_KEY: ${{ secrets.RPC_API_KEY }} diff --git a/script/DeployComplianceVedaAdapter.s.sol b/script/DeployComplianceVedaAdapter.s.sol new file mode 100644 index 00000000..65f55745 --- /dev/null +++ b/script/DeployComplianceVedaAdapter.s.sol @@ -0,0 +1,59 @@ +// 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 { ComplianceVedaAdapter } from "../src/helpers/ComplianceVedaAdapter.sol"; + +/** + * @title DeployComplianceVedaAdapter + * @notice Deploys ComplianceVedaAdapter deterministically with CREATE2. + * @dev Run with: + * forge script script/DeployComplianceVedaAdapter.s.sol --rpc-url --private-key $PRIVATE_KEY --broadcast + * Monad simulations may require --skip-simulation because its mUSD contract uses post-London opcodes. + */ +contract DeployComplianceVedaAdapter is Script { + bytes32 internal salt; + address internal vedaAdapterOwner; + address internal delegationManager; + address internal boringVault; + address internal vedaTeller; + address internal 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"); + + 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("Salt:"); + console2.logBytes32(salt); + } + + function run() public { + vm.startBroadcast(); + + // Foundry's fork mode cannot interact with mUSD on Monad (NotActivated in revm). + // Mock the approve call so simulation passes; the real broadcast executes the + // actual constructor on-chain where the token works correctly. + // vm.mockCall(depositToken, abi.encodeWithSelector(bytes4(keccak256("approve(address,uint256)"))), abi.encode(true)); + + address deployed = address( + new ComplianceVedaAdapter{ salt: salt }(vedaAdapterOwner, delegationManager, boringVault, vedaTeller, depositToken) + ); + + vm.stopBroadcast(); + console2.log("ComplianceVedaAdapter: %s", deployed); + + // vm.clearMockedCalls(); + } +} diff --git a/src/helpers/ComplianceVedaAdapter.sol b/src/helpers/ComplianceVedaAdapter.sol new file mode 100644 index 00000000..2a5d27c9 --- /dev/null +++ b/src/helpers/ComplianceVedaAdapter.sol @@ -0,0 +1,449 @@ +// 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 { IComplianceVedaTeller } from "./interfaces/IComplianceVedaTeller.sol"; + +/** + * @title ComplianceVedaAdapter + * @notice Adapter contract that enables compliance-gated Veda BoringVault deposit and withdrawal operations + * through MetaMask's delegation framework + * @dev This contract acts as an intermediary between users and a compliance-enabled Veda Teller + * (`TellerWithMultiAssetSupport` Base V0.3 / `TellerWithYieldStreaming`), enabling delegation-based + * token operations without requiring direct token approvals from the user to the vault. + * + * 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. Deposits use the struct-based + * `deposit(DepositParams, to, referral, ComplianceData)` selector. Withdrawals use + * `teller.withdraw()` on TellerWithYieldStreaming. + * - 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. + * + * Compliance: + * - An off-chain backend verifies that the share recipient (`to`) is an approved account and produces + * an EIP-191 (`personal_sign`) signature. The Teller recovers the signer and checks that the signer + * holds the configured `complianceSignerRole` on the Teller's `RolesAuthority`. + * - The digest is `keccak256(abi.encode(teller, chainId, msg.sender, to, asset, amount, deadline))`, + * then prefixed with `"\x19Ethereum Signed Message:\n32"`. There is no separate nonce: replay + * protection is the Teller's `usedComplianceSignatures` map of that digest. + * - Because this adapter is `msg.sender` on the Teller call, the signature MUST bind this adapter as + * the caller and the root delegator as `to` (share recipient). A signature issued for a direct user + * deposit cannot be reused through this adapter. + * - When `allowlistedRouterRole` is enabled on the Teller, this adapter must hold that role so it can + * mint shares to `to != msg.sender`. Withdrawals are not compliance-gated. + * + * 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 ComplianceVedaAdapter contract + * + * 2. The operator then redelegates to this ComplianceVedaAdapter 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, + * and calls `teller.deposit(...)` with the caller-supplied `ComplianceData` 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: + * - ComplianceVedaAdapter must approve the BoringVault to spend deposit tokens. The constructor sets + * this allowance to `type(uint256).max`, and the owner-only `ensureAllowance()` function + * can be used as a fail-safe to restore it to max if it were ever reduced. + * + * 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 and, for deposits, the + * Teller's compliance check. 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. A valid compliance signature is additionally bound to + * this adapter, the recipient, the asset, the amount, and a deadline; it cannot be replayed after the Teller + * marks the digest as used. + */ +contract ComplianceVedaAdapter is Ownable2Step { + using SafeERC20 for IERC20; + using ExecutionLib for bytes; + using ModeLib for ModeCode; + + /** + * @notice Parameters for a single deposit operation in a batch + * @dev `compliance` is forwarded to the Teller unchanged. Each stream needs its own signature because + * the digest includes amount and deadline, and used signatures cannot be replayed. + */ + struct DepositParams { + Delegation[] delegations; + uint256 minimumMint; + IComplianceVedaTeller.ComplianceData compliance; + } + + /** + * @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 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 token, uint256 amount, uint256 shares); + + /** + * @notice Emitted when a withdrawal operation is executed via delegation + * @param delegator Address of the share owner (delegator) + * @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 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 compliance-gated Teller contract for deposit and withdrawal operations + */ + IComplianceVedaTeller 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 compliance-gated 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 = IComplianceVedaTeller(_teller); + depositToken = IERC20(_depositToken); + + // Approve BoringVault to pull tokens + depositToken.forceApprove(boringVault, type(uint256).max); + } + + ////////////////////////////// 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). + * `_compliance` is forwarded as-is. The Teller verifies deadline, EIP-191 signature, signer role, + * and unused digest. The signed `msg.sender` field must be this adapter and `to` must be the + * root delegator. + * @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. + * @param _compliance Backend-issued compliance approval (`deadline` + `signature`) for this deposit + * @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, + IComplianceVedaTeller.ComplianceData calldata _compliance + ) + external + { + _executeDepositByDelegation(_delegations, _minimumMint, _compliance); + } + + /** + * @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. Each stream must carry its own + * `ComplianceData`; a signature cannot be reused across streams. + * @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, params_.compliance); + 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). + * Withdrawals are not compliance-gated by the Teller. + * @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. + * Recommended delegation: restrict this owner action by token, recipient, amount, and call count. + * @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); + } + + /** + * @notice Resets the deposit token allowance for the BoringVault to `type(uint256).max` + * @dev Fail-safe function. The constructor already sets the allowance to `type(uint256).max` + * the allowance should realistically never be exhausted. This function exists only as a + * defensive measure. The owner can restore it to max. + * Recommended delegation: allow only this selector and limit it to one call. + */ + function ensureAllowance() external onlyOwner { + depositToken.forceApprove(boringVault, type(uint256).max); + } + + ////////////////////////////// Private/Internal Methods ////////////////////////////// + + /** + * @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. + * Forwards `_compliance` to the Teller; this adapter is `msg.sender` and `to` is the root delegator. + * @param _delegations Delegation chain, sorted leaf to root + * @param _minimumMint Minimum vault shares expected (sanity-check bound) + * @param _compliance Backend-issued compliance approval for this deposit + */ + function _executeDepositByDelegation( + Delegation[] calldata _delegations, + uint256 _minimumMint, + IComplianceVedaTeller.ComplianceData calldata _compliance + ) + 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_); + + // Deposit into Teller: pulls depositToken from this adapter, mints shares to root delegator + uint256 shares_ = teller.deposit( + IComplianceVedaTeller.DepositParams({ + depositAsset: address(depositToken), depositAmount: amount_, minimumMint: _minimumMint + }), + rootDelegator_, + address(0), + _compliance + ); + + emit DepositExecuted(rootDelegator_, 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_, address(depositToken), shareAmount_, assetsOut_); + } +} diff --git a/src/helpers/interfaces/IComplianceVedaTeller.sol b/src/helpers/interfaces/IComplianceVedaTeller.sol new file mode 100644 index 00000000..b247f1e0 --- /dev/null +++ b/src/helpers/interfaces/IComplianceVedaTeller.sol @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +/** + * @title IComplianceVedaTeller + * @notice Interface for compliance-gated Veda tellers using the Base V0.3 deposit API. + * @dev Asset fields use address instead of Solmate's ERC20 type because both have the same ABI encoding. + */ +interface IComplianceVedaTeller { + struct DepositParams { + address depositAsset; + uint256 depositAmount; + uint256 minimumMint; + } + + struct ComplianceData { + uint256 deadline; + bytes signature; + } + + /** + * @notice Deposits an asset and mints vault shares to `to`. + * @dev The compliance signature is bound to this teller, chain ID, caller, recipient, asset, amount, and deadline. + */ + function deposit( + DepositParams calldata params, + address to, + address referralAddress, + ComplianceData calldata compliance + ) + external + payable + returns (uint256 shares); + + /** + * @notice Burns shares from the caller and sends the selected asset to `to`. + * @dev Available on TellerWithYieldStreaming. + */ + function withdraw( + address withdrawAsset, + uint256 shareAmount, + uint256 minimumAssets, + address to + ) + external + returns (uint256 assetsOut); +} diff --git a/test/helpers/ComplianceVedaLending.t.sol b/test/helpers/ComplianceVedaLending.t.sol new file mode 100644 index 00000000..797ccb2e --- /dev/null +++ b/test/helpers/ComplianceVedaLending.t.sol @@ -0,0 +1,359 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +import { EncoderLib } from "../../src/libraries/EncoderLib.sol"; +import { IComplianceVedaTeller } from "../../src/helpers/interfaces/IComplianceVedaTeller.sol"; +import { ComplianceVedaAdapter } from "../../src/helpers/ComplianceVedaAdapter.sol"; +import { ERC20TransferAmountEnforcer } from "../../src/enforcers/ERC20TransferAmountEnforcer.sol"; +import { RedeemerEnforcer } from "../../src/enforcers/RedeemerEnforcer.sol"; +import { Delegation, Caveat } from "../../src/utils/Types.sol"; +import { BaseTest } from "../utils/BaseTest.t.sol"; +import { Implementation, SignatureType, TestUser } from "../utils/Types.t.sol"; + +interface IRolesAuthority { + function owner() external view returns (address); + function setUserRole(address user, uint8 role, bool enabled) external; + function setRoleCapability(uint8 role, address target, bytes4 functionSig, bool enabled) external; +} + +interface ITellerConfiguration { + function owner() external view returns (address); + function setTransferRestrictions(uint8 transferAllowedRole, uint8 allowlistedRouterRole) external; +} + +/// forge-config: default.evm_version = "cancun" +contract ComplianceVedaLendingTest is BaseTest { + IComplianceVedaTeller internal constant TELLER = IComplianceVedaTeller(0xB0025a2eBc0474d4F28E975F0D3E70471246ebae); + IERC20 internal constant BORING_VAULT = IERC20(0xBFeC8c2b1ccea3931a1363E4CaC27352c1C908B7); + IERC20 internal constant MUSD = IERC20(0xacA92E438df0B2401fF60dA7E4337B687a2435DA); + IRolesAuthority internal constant ROLES_AUTHORITY = IRolesAuthority(0x00f0CF4f540D1d47470f565Ecb11a75E073c2dd0); + + address internal constant MUSD_WHALE = 0x6cb5094cfe45Ee97938702637478fe7146A8FA0f; + uint8 internal constant COMPLIANCE_SIGNER_ROLE = 36; + uint8 internal constant TRANSFER_ALLOWED_ROLE = 37; + uint8 internal constant TEST_ROUTER_ROLE = 254; + + uint256 internal constant INITIAL_MUSD_BALANCE = 100_000e6; + uint256 internal constant DEPOSIT_AMOUNT = 1_000e6; + uint256 internal constant COMPLIANCE_SIGNER_KEY = 0xC011A11CE; + + ERC20TransferAmountEnforcer internal erc20TransferAmountEnforcer; + RedeemerEnforcer internal redeemerEnforcer; + ComplianceVedaAdapter internal adapter; + address internal adapterOwner; + address internal complianceSigner; + + function setUp() public override { + vm.createSelectFork(vm.envString("MONAD_RPC_URL")); + + IMPLEMENTATION = Implementation.Hybrid; + SIGNATURE_TYPE = SignatureType.RawP256; + super.setUp(); + + adapterOwner = makeAddr("ComplianceVedaAdapter Owner"); + complianceSigner = vm.addr(COMPLIANCE_SIGNER_KEY); + erc20TransferAmountEnforcer = new ERC20TransferAmountEnforcer(); + redeemerEnforcer = new RedeemerEnforcer(); + adapter = new ComplianceVedaAdapter( + adapterOwner, address(delegationManager), address(BORING_VAULT), address(TELLER), address(MUSD) + ); + + _configureForkRoles(); + + vm.prank(MUSD_WHALE); + require(MUSD.transfer(address(users.alice.deleGator), INITIAL_MUSD_BALANCE), "mUSD funding failed"); + + vm.label(address(adapter), "ComplianceVedaAdapter"); + vm.label(address(TELLER), "Veda Compliance Teller"); + vm.label(address(BORING_VAULT), "Premium mUSD Vault"); + vm.label(address(MUSD), "mUSD"); + vm.label(complianceSigner, "Compliance Signer"); + } + + function test_deposit_direct_withCompliance() public { + uint256 deadline_ = block.timestamp + 30 minutes; + bytes memory signature_ = _signCompliance( + address(users.alice.deleGator), address(users.alice.deleGator), DEPOSIT_AMOUNT, deadline_, COMPLIANCE_SIGNER_KEY + ); + + vm.startPrank(address(users.alice.deleGator)); + MUSD.approve(address(BORING_VAULT), DEPOSIT_AMOUNT); + uint256 shares_ = TELLER.deposit( + IComplianceVedaTeller.DepositParams(address(MUSD), DEPOSIT_AMOUNT, 0), + address(users.alice.deleGator), + address(0), + IComplianceVedaTeller.ComplianceData(deadline_, signature_) + ); + vm.stopPrank(); + + assertGt(shares_, 0); + assertEq(BORING_VAULT.balanceOf(address(users.alice.deleGator)), shares_); + } + + function test_deposit_viaAdapter_withCompliance() public { + uint256 shares_ = _depositViaAdapter(DEPOSIT_AMOUNT, 0, block.timestamp + 30 minutes); + + assertGt(shares_, 0); + assertEq(MUSD.balanceOf(address(adapter)), 0); + assertEq(BORING_VAULT.balanceOf(address(adapter)), 0); + } + + function test_withdraw_viaAdapter_afterComplianceDeposit() public { + _depositViaAdapter(DEPOSIT_AMOUNT, 0, block.timestamp + 30 minutes); + uint256 shares_ = BORING_VAULT.balanceOf(address(users.alice.deleGator)); + uint256 assetsBefore_ = MUSD.balanceOf(address(users.alice.deleGator)); + Delegation[] memory delegations_ = _createDelegationChain(address(BORING_VAULT), shares_, 1); + + vm.prank(address(users.bob.deleGator)); + adapter.withdrawByDelegation(delegations_, 0); + + assertEq(BORING_VAULT.balanceOf(address(users.alice.deleGator)), 0); + assertGt(MUSD.balanceOf(address(users.alice.deleGator)), assetsBefore_); + assertEq(BORING_VAULT.balanceOf(address(adapter)), 0); + } + + function test_deposit_batch_withUniqueComplianceApprovals() public { + uint256 firstAmount_ = DEPOSIT_AMOUNT; + uint256 secondAmount_ = DEPOSIT_AMOUNT / 2; + ComplianceVedaAdapter.DepositParams[] memory streams_ = new ComplianceVedaAdapter.DepositParams[](2); + streams_[0] = _buildDepositParams(firstAmount_, 10, block.timestamp + 20 minutes); + streams_[1] = _buildDepositParams(secondAmount_, 11, block.timestamp + 21 minutes); + + vm.prank(address(users.bob.deleGator)); + adapter.depositByDelegationBatch(streams_); + + assertGt(BORING_VAULT.balanceOf(address(users.alice.deleGator)), 0); + assertEq(MUSD.balanceOf(address(adapter)), 0); + } + + function test_withdraw_batch_afterComplianceDeposits() public { + _depositViaAdapter(DEPOSIT_AMOUNT, 12, block.timestamp + 20 minutes); + uint256 shares_ = BORING_VAULT.balanceOf(address(users.alice.deleGator)); + uint256 firstShareAmount_ = shares_ / 2; + + ComplianceVedaAdapter.WithdrawParams[] memory streams_ = new ComplianceVedaAdapter.WithdrawParams[](2); + streams_[0] = ComplianceVedaAdapter.WithdrawParams({ + delegations: _createDelegationChain(address(BORING_VAULT), firstShareAmount_, 13), minimumAssets: 0 + }); + streams_[1] = ComplianceVedaAdapter.WithdrawParams({ + delegations: _createDelegationChain(address(BORING_VAULT), shares_ - firstShareAmount_, 14), minimumAssets: 0 + }); + + vm.prank(address(users.bob.deleGator)); + adapter.withdrawByDelegationBatch(streams_); + + assertEq(BORING_VAULT.balanceOf(address(users.alice.deleGator)), 0); + assertEq(BORING_VAULT.balanceOf(address(adapter)), 0); + } + + function test_reverts_replayedComplianceSignatureWithFreshDelegation() public { + uint256 deadline_ = block.timestamp + 30 minutes; + _depositViaAdapter(DEPOSIT_AMOUNT, 20, deadline_); + + Delegation[] memory freshDelegations_ = _createDelegationChain(address(MUSD), DEPOSIT_AMOUNT, 21); + IComplianceVedaTeller.ComplianceData memory compliance_ = + _complianceData(address(users.alice.deleGator), DEPOSIT_AMOUNT, deadline_, COMPLIANCE_SIGNER_KEY); + + vm.prank(address(users.bob.deleGator)); + vm.expectRevert(); + adapter.depositByDelegation(freshDelegations_, 0, compliance_); + } + + function test_reverts_expiredComplianceSignature() public { + uint256 deadline_ = block.timestamp - 1; + Delegation[] memory delegations_ = _createDelegationChain(address(MUSD), DEPOSIT_AMOUNT, 30); + IComplianceVedaTeller.ComplianceData memory compliance_ = + _complianceData(address(users.alice.deleGator), DEPOSIT_AMOUNT, deadline_, COMPLIANCE_SIGNER_KEY); + + vm.prank(address(users.bob.deleGator)); + vm.expectRevert(); + adapter.depositByDelegation(delegations_, 0, compliance_); + } + + function test_reverts_wrongComplianceSigner() public { + uint256 deadline_ = block.timestamp + 30 minutes; + Delegation[] memory delegations_ = _createDelegationChain(address(MUSD), DEPOSIT_AMOUNT, 31); + IComplianceVedaTeller.ComplianceData memory compliance_ = + _complianceData(address(users.alice.deleGator), DEPOSIT_AMOUNT, deadline_, 0xBAD); + + vm.prank(address(users.bob.deleGator)); + vm.expectRevert(); + adapter.depositByDelegation(delegations_, 0, compliance_); + } + + function test_reverts_signatureForWrongRecipient() public { + uint256 deadline_ = block.timestamp + 30 minutes; + Delegation[] memory delegations_ = _createDelegationChain(address(MUSD), DEPOSIT_AMOUNT, 32); + IComplianceVedaTeller.ComplianceData memory compliance_ = + _complianceData(address(users.bob.deleGator), DEPOSIT_AMOUNT, deadline_, COMPLIANCE_SIGNER_KEY); + + vm.prank(address(users.bob.deleGator)); + vm.expectRevert(); + adapter.depositByDelegation(delegations_, 0, compliance_); + } + + function test_reverts_minimumMintTooHigh() public { + uint256 deadline_ = block.timestamp + 30 minutes; + Delegation[] memory delegations_ = _createDelegationChain(address(MUSD), DEPOSIT_AMOUNT, 33); + IComplianceVedaTeller.ComplianceData memory compliance_ = + _complianceData(address(users.alice.deleGator), DEPOSIT_AMOUNT, deadline_, COMPLIANCE_SIGNER_KEY); + + vm.prank(address(users.bob.deleGator)); + vm.expectRevert(); + adapter.depositByDelegation(delegations_, type(uint256).max, compliance_); + } + + function test_reverts_emptyBatches() public { + ComplianceVedaAdapter.DepositParams[] memory deposits_ = new ComplianceVedaAdapter.DepositParams[](0); + ComplianceVedaAdapter.WithdrawParams[] memory withdrawals_ = new ComplianceVedaAdapter.WithdrawParams[](0); + + vm.expectRevert(ComplianceVedaAdapter.InvalidBatchLength.selector); + adapter.depositByDelegationBatch(deposits_); + vm.expectRevert(ComplianceVedaAdapter.InvalidBatchLength.selector); + adapter.withdrawByDelegationBatch(withdrawals_); + } + + function _configureForkRoles() internal { + address authorityOwner_ = ROLES_AUTHORITY.owner(); + address tellerOwner_ = ITellerConfiguration(address(TELLER)).owner(); + assertEq(authorityOwner_, tellerOwner_); + + vm.startPrank(authorityOwner_); + ROLES_AUTHORITY.setUserRole(complianceSigner, COMPLIANCE_SIGNER_ROLE, true); + ROLES_AUTHORITY.setUserRole(address(adapter), TRANSFER_ALLOWED_ROLE, true); + ROLES_AUTHORITY.setUserRole(address(adapter), TEST_ROUTER_ROLE, true); + ROLES_AUTHORITY.setRoleCapability(TEST_ROUTER_ROLE, address(TELLER), IComplianceVedaTeller.withdraw.selector, true); + ITellerConfiguration(address(TELLER)).setTransferRestrictions(TRANSFER_ALLOWED_ROLE, TEST_ROUTER_ROLE); + vm.stopPrank(); + } + + function _depositViaAdapter(uint256 _amount, uint256 _salt, uint256 _deadline) internal returns (uint256 shares_) { + uint256 sharesBefore_ = BORING_VAULT.balanceOf(address(users.alice.deleGator)); + Delegation[] memory delegations_ = _createDelegationChain(address(MUSD), _amount, _salt); + IComplianceVedaTeller.ComplianceData memory compliance_ = + _complianceData(address(users.alice.deleGator), _amount, _deadline, COMPLIANCE_SIGNER_KEY); + + vm.prank(address(users.bob.deleGator)); + adapter.depositByDelegation(delegations_, 0, compliance_); + shares_ = BORING_VAULT.balanceOf(address(users.alice.deleGator)) - sharesBefore_; + } + + function _buildDepositParams( + uint256 _amount, + uint256 _salt, + uint256 _deadline + ) + internal + view + returns (ComplianceVedaAdapter.DepositParams memory) + { + return ComplianceVedaAdapter.DepositParams({ + delegations: _createDelegationChain(address(MUSD), _amount, _salt), + minimumMint: 0, + compliance: _complianceData(address(users.alice.deleGator), _amount, _deadline, COMPLIANCE_SIGNER_KEY) + }); + } + + function _createDelegationChain( + address _token, + uint256 _amount, + uint256 _salt + ) + internal + view + returns (Delegation[] memory delegations_) + { + Delegation memory root_ = _createTransferDelegation(users.alice, _token, type(uint256).max, _salt); + Delegation memory leaf_ = _createAdapterRedelegation(EncoderLib._getDelegationHash(root_), _token, _amount, _salt); + + delegations_ = new Delegation[](2); + delegations_[0] = leaf_; + delegations_[1] = root_; + } + + function _createTransferDelegation( + TestUser memory _delegator, + 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(address(adapter)) }); + + Delegation memory delegation_ = Delegation({ + delegate: address(users.bob.deleGator), + delegator: address(_delegator.deleGator), + authority: ROOT_AUTHORITY, + caveats: caveats_, + salt: _salt, + signature: hex"" + }); + return signDelegation(_delegator, delegation_); + } + + function _createAdapterRedelegation( + 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(adapter), + delegator: address(users.bob.deleGator), + authority: _authority, + caveats: caveats_, + salt: _salt, + signature: hex"" + }); + return signDelegation(users.bob, delegation_); + } + + function _complianceData( + address _recipient, + uint256 _amount, + uint256 _deadline, + uint256 _signerKey + ) + internal + view + returns (IComplianceVedaTeller.ComplianceData memory) + { + return IComplianceVedaTeller.ComplianceData({ + deadline: _deadline, signature: _signCompliance(address(adapter), _recipient, _amount, _deadline, _signerKey) + }); + } + + function _signCompliance( + address _caller, + address _recipient, + uint256 _amount, + uint256 _deadline, + uint256 _signerKey + ) + internal + view + returns (bytes memory) + { + bytes32 messageHash_ = + keccak256(abi.encode(address(TELLER), block.chainid, _caller, _recipient, address(MUSD), _amount, _deadline)); + bytes32 ethSignedMessageHash_ = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash_)); + (uint8 v_, bytes32 r_, bytes32 s_) = vm.sign(_signerKey, ethSignedMessageHash_); + return abi.encodePacked(r_, s_, v_); + } +}