From 4fb0a4fe5dd033f79dbfe7edd29a535cf419d1e8 Mon Sep 17 00:00:00 2001 From: Idris Bowman <34751375+V00D00-child@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:04:37 -0400 Subject: [PATCH 01/14] feat: add atomic vault migration adapter --- .env.example | 3 + script/DeployVaultMigrationAdapter.s.sol | 40 ++++ src/helpers/VaultMigrationAdapter.sol | 283 +++++++++++++++++++++++ test/helpers/VaultMigrationAdapter.t.sol | 255 ++++++++++++++++++++ 4 files changed, 581 insertions(+) create mode 100644 script/DeployVaultMigrationAdapter.s.sol create mode 100644 src/helpers/VaultMigrationAdapter.sol create mode 100644 test/helpers/VaultMigrationAdapter.t.sol diff --git a/.env.example b/.env.example index d4dd0284..ae080a68 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,9 @@ VEDA_ADAPTER_OWNER_ADDRESS= VEDA_BORING_VAULT_ADDRESS= VEDA_TELLER_ADDRESS= VEDA_DEPOSIT_TOKEN_ADDRESS= +VAULT_MIGRATION_ADAPTER_OWNER_ADDRESS= +BASE_VEDA_ADAPTER_ADDRESS= +PREMIUM_VEDA_ADAPTER_ADDRESS= # Required for verifying contracts ETHERSCAN_API_KEY= diff --git a/script/DeployVaultMigrationAdapter.s.sol b/script/DeployVaultMigrationAdapter.s.sol new file mode 100644 index 00000000..136939fd --- /dev/null +++ b/script/DeployVaultMigrationAdapter.s.sol @@ -0,0 +1,40 @@ +// 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 { VaultMigrationAdapter } from "../src/helpers/VaultMigrationAdapter.sol"; + +/** + * @title DeployVaultMigrationAdapter + * @notice Deploys VaultMigrationAdapter deterministically with CREATE2. + */ +contract DeployVaultMigrationAdapter is Script { + bytes32 internal salt; + address internal owner; + address internal baseAdapter; + address internal premiumAdapter; + + function setUp() public { + salt = bytes32(abi.encodePacked(vm.envString("SALT"))); + owner = vm.envAddress("VAULT_MIGRATION_ADAPTER_OWNER_ADDRESS"); + baseAdapter = vm.envAddress("BASE_VEDA_ADAPTER_ADDRESS"); + premiumAdapter = vm.envAddress("PREMIUM_VEDA_ADAPTER_ADDRESS"); + + console2.log("~~~"); + console2.log("Owner: %s", owner); + console2.log("BaseAdapter: %s", baseAdapter); + console2.log("PremiumAdapter: %s", premiumAdapter); + console2.log("Salt:"); + console2.logBytes32(salt); + } + + function run() public { + vm.startBroadcast(); + address deployed = address(new VaultMigrationAdapter{ salt: salt }(owner, baseAdapter, premiumAdapter)); + vm.stopBroadcast(); + + console2.log("VaultMigrationAdapter: %s", deployed); + } +} diff --git a/src/helpers/VaultMigrationAdapter.sol b/src/helpers/VaultMigrationAdapter.sol new file mode 100644 index 00000000..7bdb7ec5 --- /dev/null +++ b/src/helpers/VaultMigrationAdapter.sol @@ -0,0 +1,283 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; +import { Ownable2Step } from "@openzeppelin/contracts/access/Ownable2Step.sol"; + +import { IComplianceVedaTeller } from "./interfaces/IComplianceVedaTeller.sol"; +import { Delegation } from "../utils/Types.sol"; + +/** + * @title IVedaAdapter + * @notice Minimal interface for the base Veda adapter methods used during migrations. + */ +interface IVedaAdapter { + /** + * @notice Deposits assets through a delegation chain. + * @param delegations Delegation chain sorted from leaf to root. + * @param minimumMint Minimum number of vault shares that must be minted. + */ + function depositByDelegation(Delegation[] calldata delegations, uint256 minimumMint) external; + + /** + * @notice Withdraws assets through a delegation chain. + * @param delegations Delegation chain sorted from leaf to root. + * @param minimumAssets Minimum number of underlying assets that must be returned. + */ + function withdrawByDelegation(Delegation[] calldata delegations, uint256 minimumAssets) external; +} + +/** + * @title IComplianceVedaAdapter + * @notice Minimal interface for the premium compliance-gated Veda adapter methods used during migrations. + */ +interface IComplianceVedaAdapter { + /** + * @notice Deposits assets through a delegation chain with compliance authorization. + * @param delegations Delegation chain sorted from leaf to root. + * @param minimumMint Minimum number of premium vault shares that must be minted. + * @param compliance Compliance authorization forwarded unchanged to the premium adapter. + */ + function depositByDelegation( + Delegation[] calldata delegations, + uint256 minimumMint, + IComplianceVedaTeller.ComplianceData calldata compliance + ) + external; + + /** + * @notice Withdraws assets through a delegation chain. + * @param delegations Delegation chain sorted from leaf to root. + * @param minimumAssets Minimum number of underlying assets that must be returned. + */ + function withdrawByDelegation(Delegation[] calldata delegations, uint256 minimumAssets) external; +} + +/** + * @title VaultMigrationAdapter + * @notice Atomically composes the existing base and premium Veda adapters. + * @dev Delegations remain targeted at the adapter that redeems them. This contract never redeems a delegation + * or holds migration assets: the source adapter sends assets to the root delegator and the destination + * adapter pulls those assets from the same root delegator. If either leg reverts, the entire migration + * transaction (and every preceding item in a batch) is rolled back. + */ +contract VaultMigrationAdapter is Ownable2Step { + /** + * @notice Parameters for one base-to-premium migration. + * @param withdrawalDelegations Delegation chain redeemed by the base adapter to withdraw base vault shares. + * @param minimumAssets Minimum underlying assets that the base withdrawal must return. + * @param depositDelegations Delegation chain redeemed by the premium adapter to deposit the withdrawn assets. + * @param minimumMint Minimum premium vault shares that the destination deposit must mint. + * @param compliance Compliance authorization forwarded unchanged to the premium adapter. + */ + struct ToPremiumParams { + Delegation[] withdrawalDelegations; + uint256 minimumAssets; + Delegation[] depositDelegations; + uint256 minimumMint; + IComplianceVedaTeller.ComplianceData compliance; + } + + /** + * @notice Parameters for one premium-to-base migration. + * @param withdrawalDelegations Delegation chain redeemed by the premium adapter to withdraw premium shares. + * @param minimumAssets Minimum underlying assets that the premium withdrawal must return. + * @param depositDelegations Delegation chain redeemed by the base adapter to deposit the withdrawn assets. + * @param minimumMint Minimum base vault shares that the destination deposit must mint. + */ + struct ToBaseParams { + Delegation[] withdrawalDelegations; + uint256 minimumAssets; + Delegation[] depositDelegations; + uint256 minimumMint; + } + + ////////////////////////////// Events ////////////////////////////// + + /** + * @notice Emitted after a base-to-premium migration completes. + * @param delegator Root delegator whose vault position was migrated. + * @param minimumAssets Minimum asset bound supplied to the withdrawal leg. + * @param minimumMint Minimum share bound supplied to the deposit leg. + */ + event MigrationToPremiumExecuted(address indexed delegator, uint256 minimumAssets, uint256 minimumMint); + + /** + * @notice Emitted after a premium-to-base migration completes. + * @param delegator Root delegator whose vault position was migrated. + * @param minimumAssets Minimum asset bound supplied to the withdrawal leg. + * @param minimumMint Minimum share bound supplied to the deposit leg. + */ + event MigrationToBaseExecuted(address indexed delegator, uint256 minimumAssets, uint256 minimumMint); + + /** + * @notice Emitted after a batch of base-to-premium migrations completes. + * @param caller Address that submitted the permissionless batch. + * @param count Number of migrations executed. + */ + event BatchMigrationToPremiumExecuted(address indexed caller, uint256 count); + + /** + * @notice Emitted after a batch of premium-to-base migrations completes. + * @param caller Address that submitted the permissionless batch. + * @param count Number of migrations executed. + */ + event BatchMigrationToBaseExecuted(address indexed caller, uint256 count); + + ////////////////////////////// Errors ////////////////////////////// + + /// @dev Thrown when a required constructor address is zero. + error InvalidZeroAddress(); + + /// @dev Thrown when either delegation chain contains fewer than two delegations. + error InvalidDelegationsLength(); + + /// @dev Thrown when a migration batch is empty. + error InvalidBatchLength(); + + /// @dev Thrown when the withdrawal and deposit chains have different root delegators. + error DelegatorMismatch(); + + ////////////////////////////// State ////////////////////////////// + + /** + * @notice Base vault adapter used for base deposits and withdrawals. + */ + IVedaAdapter public immutable baseAdapter; + + /** + * @notice Premium compliance-gated adapter used for premium deposits and withdrawals. + */ + IComplianceVedaAdapter public immutable premiumAdapter; + + ////////////////////////////// Constructor ////////////////////////////// + + /** + * @notice Initializes the atomic migration composer. + * @param _owner Address that owns this contract through Ownable2Step. + * @param _baseAdapter Address of the base Veda adapter. + * @param _premiumAdapter Address of the premium compliance-gated Veda adapter. + */ + constructor(address _owner, address _baseAdapter, address _premiumAdapter) Ownable(_owner) { + if (_owner == address(0) || _baseAdapter == address(0) || _premiumAdapter == address(0)) { + revert InvalidZeroAddress(); + } + baseAdapter = IVedaAdapter(_baseAdapter); + premiumAdapter = IComplianceVedaAdapter(_premiumAdapter); + } + + ////////////////////////////// External Methods ////////////////////////////// + + /** + * @notice Migrates one delegator from the base vault to the premium vault. + * @dev The withdrawal chain is redeemed by `baseAdapter`; the deposit chain and compliance data are + * forwarded to `premiumAdapter`. + * @param _params Withdrawal, deposit, slippage, and compliance parameters for the migration. + * @notice Security consideration: Callable by anyone. Security remains enforced by the delegation chains, + * their caveats, and the premium Teller's compliance validation. + */ + function migrateToPremiumByDelegation(ToPremiumParams calldata _params) external { + _migrateToPremiumByDelegation(_params); + } + + /** + * @notice Migrates multiple delegators from base to premium sequentially in one atomic transaction. + * @dev A revert from any withdrawal or deposit leg rolls back the entire batch. + * @param _params Migration parameters for each stream. + * @notice Security consideration: Callable by anyone. Every delegation chain remains independently enforced. + */ + function migrateToPremiumByDelegationBatch(ToPremiumParams[] calldata _params) external { + uint256 length_ = _params.length; + if (length_ == 0) revert InvalidBatchLength(); + + for (uint256 i = 0; i < length_;) { + _migrateToPremiumByDelegation(_params[i]); + unchecked { + ++i; + } + } + + emit BatchMigrationToPremiumExecuted(msg.sender, length_); + } + + /** + * @notice Migrates one delegator from the premium vault to the base vault. + * @dev The withdrawal chain is redeemed by `premiumAdapter`; the deposit chain is redeemed by `baseAdapter`. + * @param _params Withdrawal, deposit, and slippage parameters for the migration. + * @notice Security consideration: Callable by anyone. Security remains enforced by the delegation chains + * and their caveats. + */ + function migrateToBaseByDelegation(ToBaseParams calldata _params) external { + _migrateToBaseByDelegation(_params); + } + + /** + * @notice Migrates multiple delegators from premium to base sequentially in one atomic transaction. + * @dev A revert from any withdrawal or deposit leg rolls back the entire batch. + * @param _params Migration parameters for each stream. + * @notice Security consideration: Callable by anyone. Every delegation chain remains independently enforced. + */ + function migrateToBaseByDelegationBatch(ToBaseParams[] calldata _params) external { + uint256 length_ = _params.length; + if (length_ == 0) revert InvalidBatchLength(); + + for (uint256 i = 0; i < length_;) { + _migrateToBaseByDelegation(_params[i]); + unchecked { + ++i; + } + } + + emit BatchMigrationToBaseExecuted(msg.sender, length_); + } + + ////////////////////////////// Private/Internal Methods ////////////////////////////// + + /** + * @notice Executes one base-to-premium migration. + * @param _params Migration parameters forwarded to the base and premium adapters. + */ + function _migrateToPremiumByDelegation(ToPremiumParams calldata _params) internal { + address delegator_ = _validateDelegators(_params.withdrawalDelegations, _params.depositDelegations); + + baseAdapter.withdrawByDelegation(_params.withdrawalDelegations, _params.minimumAssets); + premiumAdapter.depositByDelegation(_params.depositDelegations, _params.minimumMint, _params.compliance); + + emit MigrationToPremiumExecuted(delegator_, _params.minimumAssets, _params.minimumMint); + } + + /** + * @notice Executes one premium-to-base migration. + * @param _params Migration parameters forwarded to the premium and base adapters. + */ + function _migrateToBaseByDelegation(ToBaseParams calldata _params) internal { + address delegator_ = _validateDelegators(_params.withdrawalDelegations, _params.depositDelegations); + + premiumAdapter.withdrawByDelegation(_params.withdrawalDelegations, _params.minimumAssets); + baseAdapter.depositByDelegation(_params.depositDelegations, _params.minimumMint); + + emit MigrationToBaseExecuted(delegator_, _params.minimumAssets, _params.minimumMint); + } + + /** + * @notice Validates both delegation chains and returns their shared root delegator. + * @param _withdrawalDelegations Source-vault withdrawal chain sorted from leaf to root. + * @param _depositDelegations Destination-vault deposit chain sorted from leaf to root. + * @return delegator_ Shared root delegator that owns both sides of the migration. + */ + function _validateDelegators( + Delegation[] calldata _withdrawalDelegations, + Delegation[] calldata _depositDelegations + ) + private + pure + returns (address delegator_) + { + uint256 withdrawalLength_ = _withdrawalDelegations.length; + uint256 depositLength_ = _depositDelegations.length; + if (withdrawalLength_ < 2 || depositLength_ < 2) revert InvalidDelegationsLength(); + + delegator_ = _withdrawalDelegations[withdrawalLength_ - 1].delegator; + if (delegator_ != _depositDelegations[depositLength_ - 1].delegator) revert DelegatorMismatch(); + } +} diff --git a/test/helpers/VaultMigrationAdapter.t.sol b/test/helpers/VaultMigrationAdapter.t.sol new file mode 100644 index 00000000..c7fdd6d1 --- /dev/null +++ b/test/helpers/VaultMigrationAdapter.t.sol @@ -0,0 +1,255 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { Test } from "forge-std/Test.sol"; + +import { IComplianceVedaAdapter, IVedaAdapter, VaultMigrationAdapter } from "../../src/helpers/VaultMigrationAdapter.sol"; +import { IComplianceVedaTeller } from "../../src/helpers/interfaces/IComplianceVedaTeller.sol"; +import { Caveat, Delegation } from "../../src/utils/Types.sol"; + +contract MockVedaAdapter is IVedaAdapter, IComplianceVedaAdapter { + uint256 public depositCalls; + uint256 public withdrawalCalls; + uint256 public lastMinimumMint; + uint256 public lastMinimumAssets; + uint256 public lastDepositDelegationsLength; + uint256 public lastWithdrawalDelegationsLength; + uint256 public lastComplianceDeadline; + bytes32 public lastComplianceSignatureHash; + bool public revertDeposit; + MockVedaAdapter public requiredWithdrawalAdapter; + + error DepositFailed(); + error WithdrawalDidNotRunFirst(); + + function setRevertDeposit(bool _revertDeposit) external { + revertDeposit = _revertDeposit; + } + + function setRequiredWithdrawalAdapter(MockVedaAdapter _adapter) external { + requiredWithdrawalAdapter = _adapter; + } + + function depositByDelegation(Delegation[] calldata _delegations, uint256 _minimumMint) external override { + _recordDeposit(_delegations, _minimumMint); + } + + function depositByDelegation( + Delegation[] calldata _delegations, + uint256 _minimumMint, + IComplianceVedaTeller.ComplianceData calldata _compliance + ) + external + override + { + lastComplianceDeadline = _compliance.deadline; + lastComplianceSignatureHash = keccak256(_compliance.signature); + _recordDeposit(_delegations, _minimumMint); + } + + function withdrawByDelegation( + Delegation[] calldata _delegations, + uint256 _minimumAssets + ) + external + override(IVedaAdapter, IComplianceVedaAdapter) + { + ++withdrawalCalls; + lastMinimumAssets = _minimumAssets; + lastWithdrawalDelegationsLength = _delegations.length; + } + + function _recordDeposit(Delegation[] calldata _delegations, uint256 _minimumMint) private { + if (revertDeposit) revert DepositFailed(); + if (address(requiredWithdrawalAdapter) != address(0) && requiredWithdrawalAdapter.withdrawalCalls() == 0) { + revert WithdrawalDidNotRunFirst(); + } + ++depositCalls; + lastMinimumMint = _minimumMint; + lastDepositDelegationsLength = _delegations.length; + } +} + +contract VaultMigrationAdapterTest is Test { + event MigrationToPremiumExecuted(address indexed delegator, uint256 minimumAssets, uint256 minimumMint); + event MigrationToBaseExecuted(address indexed delegator, uint256 minimumAssets, uint256 minimumMint); + event BatchMigrationToPremiumExecuted(address indexed caller, uint256 count); + event BatchMigrationToBaseExecuted(address indexed caller, uint256 count); + + address internal constant ALICE = address(0xA11CE); + address internal constant BOB = address(0xB0B); + + MockVedaAdapter internal baseAdapter; + MockVedaAdapter internal premiumAdapter; + VaultMigrationAdapter internal migrationAdapter; + + function setUp() public { + baseAdapter = new MockVedaAdapter(); + premiumAdapter = new MockVedaAdapter(); + migrationAdapter = new VaultMigrationAdapter(address(this), address(baseAdapter), address(premiumAdapter)); + } + + function test_migrateToPremium_forwardsParametersAndCompliance() public { + premiumAdapter.setRequiredWithdrawalAdapter(baseAdapter); + VaultMigrationAdapter.ToPremiumParams memory params_ = _toPremiumParams(ALICE, 91, 82, 1234, hex"abcd"); + + vm.expectEmit(true, false, false, true); + emit MigrationToPremiumExecuted(ALICE, 91, 82); + migrationAdapter.migrateToPremiumByDelegation(params_); + + assertEq(baseAdapter.withdrawalCalls(), 1); + assertEq(baseAdapter.lastMinimumAssets(), 91); + assertEq(baseAdapter.lastWithdrawalDelegationsLength(), 2); + assertEq(premiumAdapter.depositCalls(), 1); + assertEq(premiumAdapter.lastMinimumMint(), 82); + assertEq(premiumAdapter.lastDepositDelegationsLength(), 2); + assertEq(premiumAdapter.lastComplianceDeadline(), 1234); + assertEq(premiumAdapter.lastComplianceSignatureHash(), keccak256(hex"abcd")); + } + + function test_migrateToBase_withdrawsBeforeDeposit() public { + baseAdapter.setRequiredWithdrawalAdapter(premiumAdapter); + VaultMigrationAdapter.ToBaseParams memory params_ = _toBaseParams(ALICE, 71, 62); + + vm.expectEmit(true, false, false, true); + emit MigrationToBaseExecuted(ALICE, 71, 62); + migrationAdapter.migrateToBaseByDelegation(params_); + + assertEq(premiumAdapter.withdrawalCalls(), 1); + assertEq(premiumAdapter.lastMinimumAssets(), 71); + assertEq(baseAdapter.depositCalls(), 1); + assertEq(baseAdapter.lastMinimumMint(), 62); + } + + function test_migrateToPremium_isPermissionless() public { + vm.prank(BOB); + migrationAdapter.migrateToPremiumByDelegation(_toPremiumParams(ALICE, 1, 1, 1, hex"01")); + + assertEq(baseAdapter.withdrawalCalls(), 1); + assertEq(premiumAdapter.depositCalls(), 1); + } + + function test_migrateToPremium_revertsBothLegsWhenDepositFails() public { + premiumAdapter.setRevertDeposit(true); + + vm.expectRevert(MockVedaAdapter.DepositFailed.selector); + migrationAdapter.migrateToPremiumByDelegation(_toPremiumParams(ALICE, 1, 1, 1, hex"01")); + + assertEq(baseAdapter.withdrawalCalls(), 0); + assertEq(premiumAdapter.depositCalls(), 0); + } + + function test_migrateToBase_revertsOnDelegatorMismatch() public { + VaultMigrationAdapter.ToBaseParams memory params_ = VaultMigrationAdapter.ToBaseParams({ + withdrawalDelegations: _chain(ALICE, 1), minimumAssets: 1, depositDelegations: _chain(BOB, 2), minimumMint: 1 + }); + + vm.expectRevert(VaultMigrationAdapter.DelegatorMismatch.selector); + migrationAdapter.migrateToBaseByDelegation(params_); + } + + function test_migrateToPremium_revertsOnShortDelegationChain() public { + VaultMigrationAdapter.ToPremiumParams memory params_ = _toPremiumParams(ALICE, 1, 1, 1, hex"01"); + params_.withdrawalDelegations = new Delegation[](1); + + vm.expectRevert(VaultMigrationAdapter.InvalidDelegationsLength.selector); + migrationAdapter.migrateToPremiumByDelegation(params_); + } + + function test_migrateToPremiumBatch_executesAllItems() public { + VaultMigrationAdapter.ToPremiumParams[] memory params_ = new VaultMigrationAdapter.ToPremiumParams[](2); + params_[0] = _toPremiumParams(ALICE, 10, 11, 100, hex"01"); + params_[1] = _toPremiumParams(BOB, 20, 21, 200, hex"02"); + + vm.expectEmit(true, false, false, true); + emit BatchMigrationToPremiumExecuted(address(this), 2); + migrationAdapter.migrateToPremiumByDelegationBatch(params_); + + assertEq(baseAdapter.withdrawalCalls(), 2); + assertEq(premiumAdapter.depositCalls(), 2); + assertEq(premiumAdapter.lastMinimumMint(), 21); + assertEq(premiumAdapter.lastComplianceDeadline(), 200); + } + + function test_migrateToBaseBatch_executesAllItems() public { + VaultMigrationAdapter.ToBaseParams[] memory params_ = new VaultMigrationAdapter.ToBaseParams[](2); + params_[0] = _toBaseParams(ALICE, 10, 11); + params_[1] = _toBaseParams(BOB, 20, 21); + + vm.expectEmit(true, false, false, true); + emit BatchMigrationToBaseExecuted(address(this), 2); + migrationAdapter.migrateToBaseByDelegationBatch(params_); + + assertEq(premiumAdapter.withdrawalCalls(), 2); + assertEq(baseAdapter.depositCalls(), 2); + } + + function test_emptyBatchesRevert() public { + VaultMigrationAdapter.ToPremiumParams[] memory premiumParams_ = new VaultMigrationAdapter.ToPremiumParams[](0); + VaultMigrationAdapter.ToBaseParams[] memory baseParams_ = new VaultMigrationAdapter.ToBaseParams[](0); + + vm.expectRevert(VaultMigrationAdapter.InvalidBatchLength.selector); + migrationAdapter.migrateToPremiumByDelegationBatch(premiumParams_); + + vm.expectRevert(VaultMigrationAdapter.InvalidBatchLength.selector); + migrationAdapter.migrateToBaseByDelegationBatch(baseParams_); + } + + function _toPremiumParams( + address _delegator, + uint256 _minimumAssets, + uint256 _minimumMint, + uint256 _deadline, + bytes memory _signature + ) + private + pure + returns (VaultMigrationAdapter.ToPremiumParams memory) + { + return VaultMigrationAdapter.ToPremiumParams({ + withdrawalDelegations: _chain(_delegator, 1), + minimumAssets: _minimumAssets, + depositDelegations: _chain(_delegator, 2), + minimumMint: _minimumMint, + compliance: IComplianceVedaTeller.ComplianceData({ deadline: _deadline, signature: _signature }) + }); + } + + function _toBaseParams( + address _delegator, + uint256 _minimumAssets, + uint256 _minimumMint + ) + private + pure + returns (VaultMigrationAdapter.ToBaseParams memory) + { + return VaultMigrationAdapter.ToBaseParams({ + withdrawalDelegations: _chain(_delegator, 1), + minimumAssets: _minimumAssets, + depositDelegations: _chain(_delegator, 2), + minimumMint: _minimumMint + }); + } + + function _chain(address _delegator, uint256 _salt) private pure returns (Delegation[] memory delegations_) { + delegations_ = new Delegation[](2); + Caveat[] memory caveats_ = new Caveat[](0); + delegations_[0] = Delegation({ + delegate: address(0xD1), + delegator: address(0xD2), + authority: bytes32(0), + caveats: caveats_, + salt: _salt, + signature: hex"" + }); + delegations_[1] = Delegation({ + delegate: address(0xD2), + delegator: _delegator, + authority: bytes32(0), + caveats: caveats_, + salt: _salt + 1, + signature: hex"" + }); + } +} From a8ba1479cc87416edf64eac6127195f93d90710a Mon Sep 17 00:00:00 2001 From: Idris Bowman <34751375+V00D00-child@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:39:24 -0400 Subject: [PATCH 02/14] test: assert vault migration token and share amounts Replace mock call-count checks with signed delegation chains through the real adapters so migrations are verified by mUSD and vault-share balances. --- test/helpers/VaultMigrationAdapter.t.sol | 556 +++++++++++++++++------ 1 file changed, 426 insertions(+), 130 deletions(-) diff --git a/test/helpers/VaultMigrationAdapter.t.sol b/test/helpers/VaultMigrationAdapter.t.sol index c7fdd6d1..43dc6849 100644 --- a/test/helpers/VaultMigrationAdapter.t.sol +++ b/test/helpers/VaultMigrationAdapter.t.sol @@ -1,187 +1,366 @@ // SPDX-License-Identifier: MIT AND Apache-2.0 pragma solidity 0.8.23; -import { Test } from "forge-std/Test.sol"; +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; -import { IComplianceVedaAdapter, IVedaAdapter, VaultMigrationAdapter } from "../../src/helpers/VaultMigrationAdapter.sol"; +import { EncoderLib } from "../../src/libraries/EncoderLib.sol"; import { IComplianceVedaTeller } from "../../src/helpers/interfaces/IComplianceVedaTeller.sol"; -import { Caveat, Delegation } from "../../src/utils/Types.sol"; - -contract MockVedaAdapter is IVedaAdapter, IComplianceVedaAdapter { - uint256 public depositCalls; - uint256 public withdrawalCalls; - uint256 public lastMinimumMint; - uint256 public lastMinimumAssets; - uint256 public lastDepositDelegationsLength; - uint256 public lastWithdrawalDelegationsLength; +import { IVedaTeller } from "../../src/helpers/interfaces/IVedaTeller.sol"; +import { VedaAdapter } from "../../src/helpers/VedaAdapter.sol"; +import { ComplianceVedaAdapter } from "../../src/helpers/ComplianceVedaAdapter.sol"; +import { VaultMigrationAdapter } from "../../src/helpers/VaultMigrationAdapter.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 { BasicERC20 } from "../utils/BasicERC20.t.sol"; +import { Implementation, SignatureType, TestUser } from "../utils/Types.t.sol"; + +contract MockBoringVault is ERC20, Ownable { + using SafeERC20 for IERC20; + + constructor(string memory _name, string memory _symbol) ERC20(_name, _symbol) Ownable(msg.sender) { } + + function enter(address _from, IERC20 _asset, uint256 _assets, address _to, uint256 _shares) external onlyOwner { + _asset.safeTransferFrom(_from, address(this), _assets); + _mint(_to, _shares); + } + + function exit(address _from, IERC20 _asset, uint256 _assets, address _to, uint256 _shares) external onlyOwner { + _burn(_from, _shares); + _asset.safeTransfer(_to, _assets); + } +} + +contract MockVedaTeller is IVedaTeller, IComplianceVedaTeller { + MockBoringVault public immutable vault; + IERC20 public immutable asset; + + bool public revertDeposit; uint256 public lastComplianceDeadline; bytes32 public lastComplianceSignatureHash; - bool public revertDeposit; - MockVedaAdapter public requiredWithdrawalAdapter; error DepositFailed(); - error WithdrawalDidNotRunFirst(); + error InsufficientMint(); + error InsufficientAssets(); + + constructor(IERC20 _asset, string memory _vaultName, string memory _vaultSymbol) { + asset = _asset; + vault = new MockBoringVault(_vaultName, _vaultSymbol); + } function setRevertDeposit(bool _revertDeposit) external { revertDeposit = _revertDeposit; } - function setRequiredWithdrawalAdapter(MockVedaAdapter _adapter) external { - requiredWithdrawalAdapter = _adapter; + function deposit( + address _depositAsset, + uint256 _depositAmount, + uint256 _minimumMint, + address + ) + external + payable + override + returns (uint256) + { + return _deposit(_depositAsset, _depositAmount, _minimumMint, msg.sender); } - function depositByDelegation(Delegation[] calldata _delegations, uint256 _minimumMint) external override { - _recordDeposit(_delegations, _minimumMint); + function deposit( + address _depositAsset, + uint256 _depositAmount, + uint256 _minimumMint, + address _to, + address + ) + external + payable + override + returns (uint256) + { + return _deposit(_depositAsset, _depositAmount, _minimumMint, _to); } - function depositByDelegation( - Delegation[] calldata _delegations, - uint256 _minimumMint, - IComplianceVedaTeller.ComplianceData calldata _compliance + function deposit( + DepositParams calldata _params, + address _to, + address, + ComplianceData calldata _compliance ) external + payable override + returns (uint256) { lastComplianceDeadline = _compliance.deadline; lastComplianceSignatureHash = keccak256(_compliance.signature); - _recordDeposit(_delegations, _minimumMint); + return _deposit(_params.depositAsset, _params.depositAmount, _params.minimumMint, _to); } - function withdrawByDelegation( - Delegation[] calldata _delegations, - uint256 _minimumAssets + function withdraw( + address _withdrawAsset, + uint256 _shareAmount, + uint256 _minimumAssets, + address _to ) external - override(IVedaAdapter, IComplianceVedaAdapter) + override(IVedaTeller, IComplianceVedaTeller) + returns (uint256) { - ++withdrawalCalls; - lastMinimumAssets = _minimumAssets; - lastWithdrawalDelegationsLength = _delegations.length; + if (_shareAmount < _minimumAssets) revert InsufficientAssets(); + vault.exit(msg.sender, IERC20(_withdrawAsset), _shareAmount, _to, _shareAmount); + return _shareAmount; } - function _recordDeposit(Delegation[] calldata _delegations, uint256 _minimumMint) private { + function _deposit(address _depositAsset, uint256 _depositAmount, uint256 _minimumMint, address _to) private returns (uint256) { if (revertDeposit) revert DepositFailed(); - if (address(requiredWithdrawalAdapter) != address(0) && requiredWithdrawalAdapter.withdrawalCalls() == 0) { - revert WithdrawalDidNotRunFirst(); - } - ++depositCalls; - lastMinimumMint = _minimumMint; - lastDepositDelegationsLength = _delegations.length; + if (_depositAmount < _minimumMint) revert InsufficientMint(); + vault.enter(msg.sender, IERC20(_depositAsset), _depositAmount, _to, _depositAmount); + return _depositAmount; } } -contract VaultMigrationAdapterTest is Test { +/** + * @title VaultMigrationAdapter Test + * @notice Tests atomic base <-> premium vault migrations through signed delegation chains. + * @dev Uses in-process mock Tellers that actually pull tokens and mint/burn shares at 1:1 so + * assertions can follow token amounts the same way as VedaLending and ComplianceVedaLending. + */ +contract VaultMigrationAdapterTest is BaseTest { + 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; + + BasicERC20 internal musd; + MockVedaTeller internal baseTeller; + MockVedaTeller internal premiumTeller; + IERC20 internal baseVault; + IERC20 internal premiumVault; + VedaAdapter internal baseAdapter; + ComplianceVedaAdapter internal premiumAdapter; + VaultMigrationAdapter internal migrationAdapter; + address internal adapterOwner; + event MigrationToPremiumExecuted(address indexed delegator, uint256 minimumAssets, uint256 minimumMint); event MigrationToBaseExecuted(address indexed delegator, uint256 minimumAssets, uint256 minimumMint); event BatchMigrationToPremiumExecuted(address indexed caller, uint256 count); event BatchMigrationToBaseExecuted(address indexed caller, uint256 count); - address internal constant ALICE = address(0xA11CE); - address internal constant BOB = address(0xB0B); + function setUp() public override { + IMPLEMENTATION = Implementation.Hybrid; + SIGNATURE_TYPE = SignatureType.RawP256; + super.setUp(); + + adapterOwner = makeAddr("VaultMigrationAdapter Owner"); + erc20TransferAmountEnforcer = new ERC20TransferAmountEnforcer(); + redeemerEnforcer = new RedeemerEnforcer(); + + musd = new BasicERC20(address(this), "mUSD", "mUSD", 0); + baseTeller = new MockVedaTeller(musd, "Base mUSD Vault", "bmUSD"); + premiumTeller = new MockVedaTeller(musd, "Premium mUSD Vault", "pmUSD"); + baseVault = IERC20(address(baseTeller.vault())); + premiumVault = IERC20(address(premiumTeller.vault())); + + baseAdapter = + new VedaAdapter(adapterOwner, address(delegationManager), address(baseVault), address(baseTeller), address(musd)); + premiumAdapter = new ComplianceVedaAdapter( + adapterOwner, address(delegationManager), address(premiumVault), address(premiumTeller), address(musd) + ); + migrationAdapter = new VaultMigrationAdapter(adapterOwner, address(baseAdapter), address(premiumAdapter)); + + musd.mint(address(users.alice.deleGator), INITIAL_MUSD_BALANCE); + musd.mint(address(users.carol.deleGator), INITIAL_MUSD_BALANCE); + + vm.label(address(musd), "mUSD"); + vm.label(address(baseVault), "Base Vault"); + vm.label(address(premiumVault), "Premium Vault"); + vm.label(address(baseAdapter), "VedaAdapter"); + vm.label(address(premiumAdapter), "ComplianceVedaAdapter"); + vm.label(address(migrationAdapter), "VaultMigrationAdapter"); + } - MockVedaAdapter internal baseAdapter; - MockVedaAdapter internal premiumAdapter; - VaultMigrationAdapter internal migrationAdapter; + function test_constructor_revertsOnZeroAddresses() public { + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableInvalidOwner.selector, address(0))); + new VaultMigrationAdapter(address(0), address(baseAdapter), address(premiumAdapter)); - function setUp() public { - baseAdapter = new MockVedaAdapter(); - premiumAdapter = new MockVedaAdapter(); - migrationAdapter = new VaultMigrationAdapter(address(this), address(baseAdapter), address(premiumAdapter)); + vm.expectRevert(VaultMigrationAdapter.InvalidZeroAddress.selector); + new VaultMigrationAdapter(adapterOwner, address(0), address(premiumAdapter)); + + vm.expectRevert(VaultMigrationAdapter.InvalidZeroAddress.selector); + new VaultMigrationAdapter(adapterOwner, address(baseAdapter), address(0)); } - function test_migrateToPremium_forwardsParametersAndCompliance() public { - premiumAdapter.setRequiredWithdrawalAdapter(baseAdapter); - VaultMigrationAdapter.ToPremiumParams memory params_ = _toPremiumParams(ALICE, 91, 82, 1234, hex"abcd"); + function test_migrateToPremium_movesSharesAndLeavesNoDust() public { + _depositToBase(users.alice, DEPOSIT_AMOUNT, 0); + + uint256 aliceMusdBefore_ = musd.balanceOf(address(users.alice.deleGator)); + uint256 baseShares_ = baseVault.balanceOf(address(users.alice.deleGator)); + assertEq(baseShares_, DEPOSIT_AMOUNT); + assertEq(aliceMusdBefore_, INITIAL_MUSD_BALANCE - DEPOSIT_AMOUNT); - vm.expectEmit(true, false, false, true); - emit MigrationToPremiumExecuted(ALICE, 91, 82); + VaultMigrationAdapter.ToPremiumParams memory params_ = + _toPremiumParams(users.alice, baseShares_, DEPOSIT_AMOUNT, 1, DEPOSIT_AMOUNT, 2, block.timestamp + 30 minutes); + + vm.expectEmit(true, false, false, true, address(migrationAdapter)); + emit MigrationToPremiumExecuted(address(users.alice.deleGator), 0, 0); + vm.prank(address(users.bob.deleGator)); migrationAdapter.migrateToPremiumByDelegation(params_); - assertEq(baseAdapter.withdrawalCalls(), 1); - assertEq(baseAdapter.lastMinimumAssets(), 91); - assertEq(baseAdapter.lastWithdrawalDelegationsLength(), 2); - assertEq(premiumAdapter.depositCalls(), 1); - assertEq(premiumAdapter.lastMinimumMint(), 82); - assertEq(premiumAdapter.lastDepositDelegationsLength(), 2); - assertEq(premiumAdapter.lastComplianceDeadline(), 1234); - assertEq(premiumAdapter.lastComplianceSignatureHash(), keccak256(hex"abcd")); + assertEq(baseVault.balanceOf(address(users.alice.deleGator)), 0, "Base shares should be burned"); + assertEq(premiumVault.balanceOf(address(users.alice.deleGator)), DEPOSIT_AMOUNT, "Premium shares should be minted 1:1"); + assertEq(musd.balanceOf(address(users.alice.deleGator)), aliceMusdBefore_, "Underlying should settle back with Alice"); + _assertNoAdapterDust(); + assertEq(premiumTeller.lastComplianceDeadline(), block.timestamp + 30 minutes); + assertEq( + premiumTeller.lastComplianceSignatureHash(), keccak256(_signCompliance(DEPOSIT_AMOUNT, block.timestamp + 30 minutes)) + ); } - function test_migrateToBase_withdrawsBeforeDeposit() public { - baseAdapter.setRequiredWithdrawalAdapter(premiumAdapter); - VaultMigrationAdapter.ToBaseParams memory params_ = _toBaseParams(ALICE, 71, 62); + function test_migrateToBase_movesSharesAndLeavesNoDust() public { + _depositToPremium(users.alice, DEPOSIT_AMOUNT, 10, block.timestamp + 30 minutes); + + uint256 aliceMusdBefore_ = musd.balanceOf(address(users.alice.deleGator)); + uint256 premiumShares_ = premiumVault.balanceOf(address(users.alice.deleGator)); + assertEq(premiumShares_, DEPOSIT_AMOUNT); - vm.expectEmit(true, false, false, true); - emit MigrationToBaseExecuted(ALICE, 71, 62); + VaultMigrationAdapter.ToBaseParams memory params_ = _toBaseParams(users.alice, premiumShares_, 11, DEPOSIT_AMOUNT, 12); + + vm.expectEmit(true, false, false, true, address(migrationAdapter)); + emit MigrationToBaseExecuted(address(users.alice.deleGator), 0, 0); + vm.prank(address(users.bob.deleGator)); migrationAdapter.migrateToBaseByDelegation(params_); - assertEq(premiumAdapter.withdrawalCalls(), 1); - assertEq(premiumAdapter.lastMinimumAssets(), 71); - assertEq(baseAdapter.depositCalls(), 1); - assertEq(baseAdapter.lastMinimumMint(), 62); + assertEq(premiumVault.balanceOf(address(users.alice.deleGator)), 0, "Premium shares should be burned"); + assertEq(baseVault.balanceOf(address(users.alice.deleGator)), DEPOSIT_AMOUNT, "Base shares should be minted 1:1"); + assertEq(musd.balanceOf(address(users.alice.deleGator)), aliceMusdBefore_, "Underlying should settle back with Alice"); + _assertNoAdapterDust(); } function test_migrateToPremium_isPermissionless() public { - vm.prank(BOB); - migrationAdapter.migrateToPremiumByDelegation(_toPremiumParams(ALICE, 1, 1, 1, hex"01")); - - assertEq(baseAdapter.withdrawalCalls(), 1); - assertEq(premiumAdapter.depositCalls(), 1); + _depositToBase(users.alice, DEPOSIT_AMOUNT, 20); + uint256 baseShares_ = baseVault.balanceOf(address(users.alice.deleGator)); + + vm.prank(address(users.carol.deleGator)); + migrationAdapter.migrateToPremiumByDelegation( + _toPremiumParams(users.alice, baseShares_, DEPOSIT_AMOUNT, 21, DEPOSIT_AMOUNT, 22, block.timestamp + 30 minutes) + ); + + assertEq(baseVault.balanceOf(address(users.alice.deleGator)), 0); + assertEq(premiumVault.balanceOf(address(users.alice.deleGator)), DEPOSIT_AMOUNT); + assertEq(premiumVault.balanceOf(address(users.carol.deleGator)), 0, "Caller must not receive shares"); + _assertNoAdapterDust(); } function test_migrateToPremium_revertsBothLegsWhenDepositFails() public { - premiumAdapter.setRevertDeposit(true); - - vm.expectRevert(MockVedaAdapter.DepositFailed.selector); - migrationAdapter.migrateToPremiumByDelegation(_toPremiumParams(ALICE, 1, 1, 1, hex"01")); + _depositToBase(users.alice, DEPOSIT_AMOUNT, 30); + uint256 baseShares_ = baseVault.balanceOf(address(users.alice.deleGator)); + uint256 aliceMusdBefore_ = musd.balanceOf(address(users.alice.deleGator)); + premiumTeller.setRevertDeposit(true); + VaultMigrationAdapter.ToPremiumParams memory params_ = + _toPremiumParams(users.alice, baseShares_, DEPOSIT_AMOUNT, 31, DEPOSIT_AMOUNT, 32, block.timestamp + 30 minutes); + + vm.prank(address(users.bob.deleGator)); + vm.expectRevert(MockVedaTeller.DepositFailed.selector); + migrationAdapter.migrateToPremiumByDelegation(params_); - assertEq(baseAdapter.withdrawalCalls(), 0); - assertEq(premiumAdapter.depositCalls(), 0); + assertEq(baseVault.balanceOf(address(users.alice.deleGator)), baseShares_, "Base shares must roll back"); + assertEq(premiumVault.balanceOf(address(users.alice.deleGator)), 0, "No premium shares should be minted"); + assertEq(musd.balanceOf(address(users.alice.deleGator)), aliceMusdBefore_, "Underlying must roll back"); + _assertNoAdapterDust(); } function test_migrateToBase_revertsOnDelegatorMismatch() public { + _depositToPremium(users.alice, DEPOSIT_AMOUNT, 40, block.timestamp + 30 minutes); + uint256 aliceShares_ = premiumVault.balanceOf(address(users.alice.deleGator)); + VaultMigrationAdapter.ToBaseParams memory params_ = VaultMigrationAdapter.ToBaseParams({ - withdrawalDelegations: _chain(ALICE, 1), minimumAssets: 1, depositDelegations: _chain(BOB, 2), minimumMint: 1 + withdrawalDelegations: _createDelegationChain( + users.alice, address(premiumAdapter), address(premiumVault), aliceShares_, 41 + ), + minimumAssets: 0, + depositDelegations: _createDelegationChain(users.carol, address(baseAdapter), address(musd), DEPOSIT_AMOUNT, 42), + minimumMint: 0 }); vm.expectRevert(VaultMigrationAdapter.DelegatorMismatch.selector); migrationAdapter.migrateToBaseByDelegation(params_); + + assertEq(premiumVault.balanceOf(address(users.alice.deleGator)), aliceShares_); + assertEq(baseVault.balanceOf(address(users.alice.deleGator)), 0); + assertEq(baseVault.balanceOf(address(users.carol.deleGator)), 0); } function test_migrateToPremium_revertsOnShortDelegationChain() public { - VaultMigrationAdapter.ToPremiumParams memory params_ = _toPremiumParams(ALICE, 1, 1, 1, hex"01"); + _depositToBase(users.alice, DEPOSIT_AMOUNT, 50); + VaultMigrationAdapter.ToPremiumParams memory params_ = _toPremiumParams( + users.alice, + baseVault.balanceOf(address(users.alice.deleGator)), + DEPOSIT_AMOUNT, + 51, + DEPOSIT_AMOUNT, + 52, + block.timestamp + 30 minutes + ); params_.withdrawalDelegations = new Delegation[](1); vm.expectRevert(VaultMigrationAdapter.InvalidDelegationsLength.selector); migrationAdapter.migrateToPremiumByDelegation(params_); + + assertEq(baseVault.balanceOf(address(users.alice.deleGator)), DEPOSIT_AMOUNT); + assertEq(premiumVault.balanceOf(address(users.alice.deleGator)), 0); } - function test_migrateToPremiumBatch_executesAllItems() public { + function test_migrateToPremiumBatch_movesExactAmountsForEachDelegator() public { + uint256 aliceAmount_ = 300e6; + uint256 carolAmount_ = 400e6; + _depositToBase(users.alice, aliceAmount_, 60); + _depositToBase(users.carol, carolAmount_, 61); + VaultMigrationAdapter.ToPremiumParams[] memory params_ = new VaultMigrationAdapter.ToPremiumParams[](2); - params_[0] = _toPremiumParams(ALICE, 10, 11, 100, hex"01"); - params_[1] = _toPremiumParams(BOB, 20, 21, 200, hex"02"); + params_[0] = _toPremiumParams(users.alice, aliceAmount_, aliceAmount_, 62, aliceAmount_, 63, block.timestamp + 20 minutes); + params_[1] = _toPremiumParams(users.carol, carolAmount_, carolAmount_, 64, carolAmount_, 65, block.timestamp + 21 minutes); - vm.expectEmit(true, false, false, true); - emit BatchMigrationToPremiumExecuted(address(this), 2); + vm.expectEmit(true, false, false, true, address(migrationAdapter)); + emit BatchMigrationToPremiumExecuted(address(users.bob.deleGator), 2); + vm.prank(address(users.bob.deleGator)); migrationAdapter.migrateToPremiumByDelegationBatch(params_); - assertEq(baseAdapter.withdrawalCalls(), 2); - assertEq(premiumAdapter.depositCalls(), 2); - assertEq(premiumAdapter.lastMinimumMint(), 21); - assertEq(premiumAdapter.lastComplianceDeadline(), 200); + assertEq(baseVault.balanceOf(address(users.alice.deleGator)), 0); + assertEq(baseVault.balanceOf(address(users.carol.deleGator)), 0); + assertEq(premiumVault.balanceOf(address(users.alice.deleGator)), aliceAmount_); + assertEq(premiumVault.balanceOf(address(users.carol.deleGator)), carolAmount_); + assertEq(musd.balanceOf(address(users.alice.deleGator)), INITIAL_MUSD_BALANCE - aliceAmount_); + assertEq(musd.balanceOf(address(users.carol.deleGator)), INITIAL_MUSD_BALANCE - carolAmount_); + _assertNoAdapterDust(); } - function test_migrateToBaseBatch_executesAllItems() public { + function test_migrateToBaseBatch_movesExactAmountsForEachDelegator() public { + uint256 aliceAmount_ = 250e6; + uint256 carolAmount_ = 350e6; + _depositToPremium(users.alice, aliceAmount_, 70, block.timestamp + 20 minutes); + _depositToPremium(users.carol, carolAmount_, 71, block.timestamp + 21 minutes); + VaultMigrationAdapter.ToBaseParams[] memory params_ = new VaultMigrationAdapter.ToBaseParams[](2); - params_[0] = _toBaseParams(ALICE, 10, 11); - params_[1] = _toBaseParams(BOB, 20, 21); + params_[0] = _toBaseParams(users.alice, aliceAmount_, 72, aliceAmount_, 73); + params_[1] = _toBaseParams(users.carol, carolAmount_, 74, carolAmount_, 75); - vm.expectEmit(true, false, false, true); - emit BatchMigrationToBaseExecuted(address(this), 2); + vm.expectEmit(true, false, false, true, address(migrationAdapter)); + emit BatchMigrationToBaseExecuted(address(users.bob.deleGator), 2); + vm.prank(address(users.bob.deleGator)); migrationAdapter.migrateToBaseByDelegationBatch(params_); - assertEq(premiumAdapter.withdrawalCalls(), 2); - assertEq(baseAdapter.depositCalls(), 2); + assertEq(premiumVault.balanceOf(address(users.alice.deleGator)), 0); + assertEq(premiumVault.balanceOf(address(users.carol.deleGator)), 0); + assertEq(baseVault.balanceOf(address(users.alice.deleGator)), aliceAmount_); + assertEq(baseVault.balanceOf(address(users.carol.deleGator)), carolAmount_); + _assertNoAdapterDust(); } function test_emptyBatchesRevert() public { @@ -195,61 +374,178 @@ contract VaultMigrationAdapterTest is Test { migrationAdapter.migrateToBaseByDelegationBatch(baseParams_); } + function _depositToBase(TestUser memory _delegator, uint256 _amount, uint256 _salt) internal { + Delegation[] memory delegations_ = _createDelegationChain(_delegator, address(baseAdapter), address(musd), _amount, _salt); + + vm.prank(address(users.bob.deleGator)); + baseAdapter.depositByDelegation(delegations_, 0); + } + + function _depositToPremium(TestUser memory _delegator, uint256 _amount, uint256 _salt, uint256 _deadline) internal { + Delegation[] memory delegations_ = + _createDelegationChain(_delegator, address(premiumAdapter), address(musd), _amount, _salt); + IComplianceVedaTeller.ComplianceData memory compliance_ = _complianceData(_amount, _deadline); + + vm.prank(address(users.bob.deleGator)); + premiumAdapter.depositByDelegation(delegations_, 0, compliance_); + } + function _toPremiumParams( - address _delegator, - uint256 _minimumAssets, - uint256 _minimumMint, - uint256 _deadline, - bytes memory _signature + TestUser memory _delegator, + uint256 _shareAmount, + uint256 _assetAmount, + uint256 _withdrawalSalt, + uint256 _depositAmount, + uint256 _depositSalt, + uint256 _deadline ) - private - pure + internal + view returns (VaultMigrationAdapter.ToPremiumParams memory) { return VaultMigrationAdapter.ToPremiumParams({ - withdrawalDelegations: _chain(_delegator, 1), - minimumAssets: _minimumAssets, - depositDelegations: _chain(_delegator, 2), - minimumMint: _minimumMint, - compliance: IComplianceVedaTeller.ComplianceData({ deadline: _deadline, signature: _signature }) + withdrawalDelegations: _createDelegationChain( + _delegator, address(baseAdapter), address(baseVault), _shareAmount, _withdrawalSalt + ), + minimumAssets: 0, + depositDelegations: _createDelegationChain( + _delegator, address(premiumAdapter), address(musd), _depositAmount, _depositSalt + ), + minimumMint: 0, + compliance: _complianceData(_assetAmount, _deadline) }); } function _toBaseParams( - address _delegator, - uint256 _minimumAssets, - uint256 _minimumMint + TestUser memory _delegator, + uint256 _shareAmount, + uint256 _withdrawalSalt, + uint256 _depositAmount, + uint256 _depositSalt ) - private - pure + internal + view returns (VaultMigrationAdapter.ToBaseParams memory) { return VaultMigrationAdapter.ToBaseParams({ - withdrawalDelegations: _chain(_delegator, 1), - minimumAssets: _minimumAssets, - depositDelegations: _chain(_delegator, 2), - minimumMint: _minimumMint + withdrawalDelegations: _createDelegationChain( + _delegator, address(premiumAdapter), address(premiumVault), _shareAmount, _withdrawalSalt + ), + minimumAssets: 0, + depositDelegations: _createDelegationChain( + _delegator, address(baseAdapter), address(musd), _depositAmount, _depositSalt + ), + minimumMint: 0 }); } - function _chain(address _delegator, uint256 _salt) private pure returns (Delegation[] memory delegations_) { + function _createDelegationChain( + TestUser memory _delegator, + address _adapter, + address _token, + uint256 _amount, + uint256 _salt + ) + internal + view + returns (Delegation[] memory delegations_) + { + Delegation memory root_ = _createTransferDelegation(_delegator, _adapter, _token, type(uint256).max, _salt); + Delegation memory leaf_ = _createAdapterRedelegation(_adapter, EncoderLib._getDelegationHash(root_), _token, _amount, _salt); + delegations_ = new Delegation[](2); - Caveat[] memory caveats_ = new Caveat[](0); - delegations_[0] = Delegation({ - delegate: address(0xD1), - delegator: address(0xD2), - authority: bytes32(0), + delegations_[0] = leaf_; + delegations_[1] = root_; + } + + function _createTransferDelegation( + TestUser memory _delegator, + address _adapter, + 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(_adapter) }); + + Delegation memory delegation_ = Delegation({ + delegate: address(users.bob.deleGator), + delegator: address(_delegator.deleGator), + authority: ROOT_AUTHORITY, caveats: caveats_, salt: _salt, signature: hex"" }); - delegations_[1] = Delegation({ - delegate: address(0xD2), - delegator: _delegator, - authority: bytes32(0), + return signDelegation(_delegator, delegation_); + } + + function _createAdapterRedelegation( + address _adapter, + 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: _adapter, + delegator: address(users.bob.deleGator), + authority: _authority, caveats: caveats_, - salt: _salt + 1, + salt: _salt, signature: hex"" }); + return signDelegation(users.bob, delegation_); + } + + function _complianceData( + uint256 _amount, + uint256 _deadline + ) + internal + view + returns (IComplianceVedaTeller.ComplianceData memory) + { + return IComplianceVedaTeller.ComplianceData({ deadline: _deadline, signature: _signCompliance(_amount, _deadline) }); + } + + function _signCompliance(uint256 _amount, uint256 _deadline) internal view returns (bytes memory) { + bytes32 messageHash_ = keccak256( + abi.encode( + address(premiumTeller), + block.chainid, + address(premiumAdapter), + address(users.alice.deleGator), + address(musd), + _amount, + _deadline + ) + ); + bytes32 ethSignedMessageHash_ = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash_)); + (uint8 v_, bytes32 r_, bytes32 s_) = vm.sign(COMPLIANCE_SIGNER_KEY, ethSignedMessageHash_); + return abi.encodePacked(r_, s_, v_); + } + + function _assertNoAdapterDust() internal { + assertEq(musd.balanceOf(address(baseAdapter)), 0); + assertEq(musd.balanceOf(address(premiumAdapter)), 0); + assertEq(musd.balanceOf(address(migrationAdapter)), 0); + assertEq(baseVault.balanceOf(address(baseAdapter)), 0); + assertEq(premiumVault.balanceOf(address(premiumAdapter)), 0); + assertEq(baseVault.balanceOf(address(migrationAdapter)), 0); + assertEq(premiumVault.balanceOf(address(migrationAdapter)), 0); } } From fa63881357cf54f4d65d65054869530a17cfae60 Mon Sep 17 00:00:00 2001 From: MoMannn Date: Thu, 24 Sep 2026 14:55:13 +0200 Subject: [PATCH 03/14] Clean up code, add fork tests and add functionality to transfer premium balance --- .env.example | 2 +- ...s.sol => DeployVaultMigrationHelper.s.sol} | 14 +- src/helpers/VaultMigrationAdapter.sol | 283 ----- src/helpers/VaultMigrationHelper.sol | 424 +++++++ .../interfaces/IComplianceVedaTeller.sol | 16 + src/helpers/interfaces/IRolesAuthority.sol | 10 + test/helpers/VaultMigrationAdapter.t.sol | 551 --------- test/helpers/VaultMigrationHelper.t.sol | 1017 +++++++++++++++++ 8 files changed, 1475 insertions(+), 842 deletions(-) rename script/{DeployVaultMigrationAdapter.s.sol => DeployVaultMigrationHelper.s.sol} (64%) delete mode 100644 src/helpers/VaultMigrationAdapter.sol create mode 100644 src/helpers/VaultMigrationHelper.sol create mode 100644 src/helpers/interfaces/IRolesAuthority.sol delete mode 100644 test/helpers/VaultMigrationAdapter.t.sol create mode 100644 test/helpers/VaultMigrationHelper.t.sol diff --git a/.env.example b/.env.example index ae080a68..2aa46d72 100644 --- a/.env.example +++ b/.env.example @@ -12,7 +12,7 @@ VEDA_ADAPTER_OWNER_ADDRESS= VEDA_BORING_VAULT_ADDRESS= VEDA_TELLER_ADDRESS= VEDA_DEPOSIT_TOKEN_ADDRESS= -VAULT_MIGRATION_ADAPTER_OWNER_ADDRESS= +VAULT_MIGRATION_HELPER_OWNER_ADDRESS= BASE_VEDA_ADAPTER_ADDRESS= PREMIUM_VEDA_ADAPTER_ADDRESS= diff --git a/script/DeployVaultMigrationAdapter.s.sol b/script/DeployVaultMigrationHelper.s.sol similarity index 64% rename from script/DeployVaultMigrationAdapter.s.sol rename to script/DeployVaultMigrationHelper.s.sol index 136939fd..a296fb24 100644 --- a/script/DeployVaultMigrationAdapter.s.sol +++ b/script/DeployVaultMigrationHelper.s.sol @@ -4,13 +4,13 @@ pragma solidity 0.8.23; import "forge-std/Script.sol"; import { console2 } from "forge-std/console2.sol"; -import { VaultMigrationAdapter } from "../src/helpers/VaultMigrationAdapter.sol"; +import { VaultMigrationHelper } from "../src/helpers/VaultMigrationHelper.sol"; /** - * @title DeployVaultMigrationAdapter - * @notice Deploys VaultMigrationAdapter deterministically with CREATE2. + * @title DeployVaultMigrationHelper + * @notice Deploys VaultMigrationHelper deterministically with CREATE2. */ -contract DeployVaultMigrationAdapter is Script { +contract DeployVaultMigrationHelper is Script { bytes32 internal salt; address internal owner; address internal baseAdapter; @@ -18,7 +18,7 @@ contract DeployVaultMigrationAdapter is Script { function setUp() public { salt = bytes32(abi.encodePacked(vm.envString("SALT"))); - owner = vm.envAddress("VAULT_MIGRATION_ADAPTER_OWNER_ADDRESS"); + owner = vm.envAddress("VAULT_MIGRATION_HELPER_OWNER_ADDRESS"); baseAdapter = vm.envAddress("BASE_VEDA_ADAPTER_ADDRESS"); premiumAdapter = vm.envAddress("PREMIUM_VEDA_ADAPTER_ADDRESS"); @@ -32,9 +32,9 @@ contract DeployVaultMigrationAdapter is Script { function run() public { vm.startBroadcast(); - address deployed = address(new VaultMigrationAdapter{ salt: salt }(owner, baseAdapter, premiumAdapter)); + address deployed = address(new VaultMigrationHelper{ salt: salt }(owner, baseAdapter, premiumAdapter)); vm.stopBroadcast(); - console2.log("VaultMigrationAdapter: %s", deployed); + console2.log("VaultMigrationHelper: %s", deployed); } } diff --git a/src/helpers/VaultMigrationAdapter.sol b/src/helpers/VaultMigrationAdapter.sol deleted file mode 100644 index 7bdb7ec5..00000000 --- a/src/helpers/VaultMigrationAdapter.sol +++ /dev/null @@ -1,283 +0,0 @@ -// SPDX-License-Identifier: MIT AND Apache-2.0 -pragma solidity 0.8.23; - -import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; -import { Ownable2Step } from "@openzeppelin/contracts/access/Ownable2Step.sol"; - -import { IComplianceVedaTeller } from "./interfaces/IComplianceVedaTeller.sol"; -import { Delegation } from "../utils/Types.sol"; - -/** - * @title IVedaAdapter - * @notice Minimal interface for the base Veda adapter methods used during migrations. - */ -interface IVedaAdapter { - /** - * @notice Deposits assets through a delegation chain. - * @param delegations Delegation chain sorted from leaf to root. - * @param minimumMint Minimum number of vault shares that must be minted. - */ - function depositByDelegation(Delegation[] calldata delegations, uint256 minimumMint) external; - - /** - * @notice Withdraws assets through a delegation chain. - * @param delegations Delegation chain sorted from leaf to root. - * @param minimumAssets Minimum number of underlying assets that must be returned. - */ - function withdrawByDelegation(Delegation[] calldata delegations, uint256 minimumAssets) external; -} - -/** - * @title IComplianceVedaAdapter - * @notice Minimal interface for the premium compliance-gated Veda adapter methods used during migrations. - */ -interface IComplianceVedaAdapter { - /** - * @notice Deposits assets through a delegation chain with compliance authorization. - * @param delegations Delegation chain sorted from leaf to root. - * @param minimumMint Minimum number of premium vault shares that must be minted. - * @param compliance Compliance authorization forwarded unchanged to the premium adapter. - */ - function depositByDelegation( - Delegation[] calldata delegations, - uint256 minimumMint, - IComplianceVedaTeller.ComplianceData calldata compliance - ) - external; - - /** - * @notice Withdraws assets through a delegation chain. - * @param delegations Delegation chain sorted from leaf to root. - * @param minimumAssets Minimum number of underlying assets that must be returned. - */ - function withdrawByDelegation(Delegation[] calldata delegations, uint256 minimumAssets) external; -} - -/** - * @title VaultMigrationAdapter - * @notice Atomically composes the existing base and premium Veda adapters. - * @dev Delegations remain targeted at the adapter that redeems them. This contract never redeems a delegation - * or holds migration assets: the source adapter sends assets to the root delegator and the destination - * adapter pulls those assets from the same root delegator. If either leg reverts, the entire migration - * transaction (and every preceding item in a batch) is rolled back. - */ -contract VaultMigrationAdapter is Ownable2Step { - /** - * @notice Parameters for one base-to-premium migration. - * @param withdrawalDelegations Delegation chain redeemed by the base adapter to withdraw base vault shares. - * @param minimumAssets Minimum underlying assets that the base withdrawal must return. - * @param depositDelegations Delegation chain redeemed by the premium adapter to deposit the withdrawn assets. - * @param minimumMint Minimum premium vault shares that the destination deposit must mint. - * @param compliance Compliance authorization forwarded unchanged to the premium adapter. - */ - struct ToPremiumParams { - Delegation[] withdrawalDelegations; - uint256 minimumAssets; - Delegation[] depositDelegations; - uint256 minimumMint; - IComplianceVedaTeller.ComplianceData compliance; - } - - /** - * @notice Parameters for one premium-to-base migration. - * @param withdrawalDelegations Delegation chain redeemed by the premium adapter to withdraw premium shares. - * @param minimumAssets Minimum underlying assets that the premium withdrawal must return. - * @param depositDelegations Delegation chain redeemed by the base adapter to deposit the withdrawn assets. - * @param minimumMint Minimum base vault shares that the destination deposit must mint. - */ - struct ToBaseParams { - Delegation[] withdrawalDelegations; - uint256 minimumAssets; - Delegation[] depositDelegations; - uint256 minimumMint; - } - - ////////////////////////////// Events ////////////////////////////// - - /** - * @notice Emitted after a base-to-premium migration completes. - * @param delegator Root delegator whose vault position was migrated. - * @param minimumAssets Minimum asset bound supplied to the withdrawal leg. - * @param minimumMint Minimum share bound supplied to the deposit leg. - */ - event MigrationToPremiumExecuted(address indexed delegator, uint256 minimumAssets, uint256 minimumMint); - - /** - * @notice Emitted after a premium-to-base migration completes. - * @param delegator Root delegator whose vault position was migrated. - * @param minimumAssets Minimum asset bound supplied to the withdrawal leg. - * @param minimumMint Minimum share bound supplied to the deposit leg. - */ - event MigrationToBaseExecuted(address indexed delegator, uint256 minimumAssets, uint256 minimumMint); - - /** - * @notice Emitted after a batch of base-to-premium migrations completes. - * @param caller Address that submitted the permissionless batch. - * @param count Number of migrations executed. - */ - event BatchMigrationToPremiumExecuted(address indexed caller, uint256 count); - - /** - * @notice Emitted after a batch of premium-to-base migrations completes. - * @param caller Address that submitted the permissionless batch. - * @param count Number of migrations executed. - */ - event BatchMigrationToBaseExecuted(address indexed caller, uint256 count); - - ////////////////////////////// Errors ////////////////////////////// - - /// @dev Thrown when a required constructor address is zero. - error InvalidZeroAddress(); - - /// @dev Thrown when either delegation chain contains fewer than two delegations. - error InvalidDelegationsLength(); - - /// @dev Thrown when a migration batch is empty. - error InvalidBatchLength(); - - /// @dev Thrown when the withdrawal and deposit chains have different root delegators. - error DelegatorMismatch(); - - ////////////////////////////// State ////////////////////////////// - - /** - * @notice Base vault adapter used for base deposits and withdrawals. - */ - IVedaAdapter public immutable baseAdapter; - - /** - * @notice Premium compliance-gated adapter used for premium deposits and withdrawals. - */ - IComplianceVedaAdapter public immutable premiumAdapter; - - ////////////////////////////// Constructor ////////////////////////////// - - /** - * @notice Initializes the atomic migration composer. - * @param _owner Address that owns this contract through Ownable2Step. - * @param _baseAdapter Address of the base Veda adapter. - * @param _premiumAdapter Address of the premium compliance-gated Veda adapter. - */ - constructor(address _owner, address _baseAdapter, address _premiumAdapter) Ownable(_owner) { - if (_owner == address(0) || _baseAdapter == address(0) || _premiumAdapter == address(0)) { - revert InvalidZeroAddress(); - } - baseAdapter = IVedaAdapter(_baseAdapter); - premiumAdapter = IComplianceVedaAdapter(_premiumAdapter); - } - - ////////////////////////////// External Methods ////////////////////////////// - - /** - * @notice Migrates one delegator from the base vault to the premium vault. - * @dev The withdrawal chain is redeemed by `baseAdapter`; the deposit chain and compliance data are - * forwarded to `premiumAdapter`. - * @param _params Withdrawal, deposit, slippage, and compliance parameters for the migration. - * @notice Security consideration: Callable by anyone. Security remains enforced by the delegation chains, - * their caveats, and the premium Teller's compliance validation. - */ - function migrateToPremiumByDelegation(ToPremiumParams calldata _params) external { - _migrateToPremiumByDelegation(_params); - } - - /** - * @notice Migrates multiple delegators from base to premium sequentially in one atomic transaction. - * @dev A revert from any withdrawal or deposit leg rolls back the entire batch. - * @param _params Migration parameters for each stream. - * @notice Security consideration: Callable by anyone. Every delegation chain remains independently enforced. - */ - function migrateToPremiumByDelegationBatch(ToPremiumParams[] calldata _params) external { - uint256 length_ = _params.length; - if (length_ == 0) revert InvalidBatchLength(); - - for (uint256 i = 0; i < length_;) { - _migrateToPremiumByDelegation(_params[i]); - unchecked { - ++i; - } - } - - emit BatchMigrationToPremiumExecuted(msg.sender, length_); - } - - /** - * @notice Migrates one delegator from the premium vault to the base vault. - * @dev The withdrawal chain is redeemed by `premiumAdapter`; the deposit chain is redeemed by `baseAdapter`. - * @param _params Withdrawal, deposit, and slippage parameters for the migration. - * @notice Security consideration: Callable by anyone. Security remains enforced by the delegation chains - * and their caveats. - */ - function migrateToBaseByDelegation(ToBaseParams calldata _params) external { - _migrateToBaseByDelegation(_params); - } - - /** - * @notice Migrates multiple delegators from premium to base sequentially in one atomic transaction. - * @dev A revert from any withdrawal or deposit leg rolls back the entire batch. - * @param _params Migration parameters for each stream. - * @notice Security consideration: Callable by anyone. Every delegation chain remains independently enforced. - */ - function migrateToBaseByDelegationBatch(ToBaseParams[] calldata _params) external { - uint256 length_ = _params.length; - if (length_ == 0) revert InvalidBatchLength(); - - for (uint256 i = 0; i < length_;) { - _migrateToBaseByDelegation(_params[i]); - unchecked { - ++i; - } - } - - emit BatchMigrationToBaseExecuted(msg.sender, length_); - } - - ////////////////////////////// Private/Internal Methods ////////////////////////////// - - /** - * @notice Executes one base-to-premium migration. - * @param _params Migration parameters forwarded to the base and premium adapters. - */ - function _migrateToPremiumByDelegation(ToPremiumParams calldata _params) internal { - address delegator_ = _validateDelegators(_params.withdrawalDelegations, _params.depositDelegations); - - baseAdapter.withdrawByDelegation(_params.withdrawalDelegations, _params.minimumAssets); - premiumAdapter.depositByDelegation(_params.depositDelegations, _params.minimumMint, _params.compliance); - - emit MigrationToPremiumExecuted(delegator_, _params.minimumAssets, _params.minimumMint); - } - - /** - * @notice Executes one premium-to-base migration. - * @param _params Migration parameters forwarded to the premium and base adapters. - */ - function _migrateToBaseByDelegation(ToBaseParams calldata _params) internal { - address delegator_ = _validateDelegators(_params.withdrawalDelegations, _params.depositDelegations); - - premiumAdapter.withdrawByDelegation(_params.withdrawalDelegations, _params.minimumAssets); - baseAdapter.depositByDelegation(_params.depositDelegations, _params.minimumMint); - - emit MigrationToBaseExecuted(delegator_, _params.minimumAssets, _params.minimumMint); - } - - /** - * @notice Validates both delegation chains and returns their shared root delegator. - * @param _withdrawalDelegations Source-vault withdrawal chain sorted from leaf to root. - * @param _depositDelegations Destination-vault deposit chain sorted from leaf to root. - * @return delegator_ Shared root delegator that owns both sides of the migration. - */ - function _validateDelegators( - Delegation[] calldata _withdrawalDelegations, - Delegation[] calldata _depositDelegations - ) - private - pure - returns (address delegator_) - { - uint256 withdrawalLength_ = _withdrawalDelegations.length; - uint256 depositLength_ = _depositDelegations.length; - if (withdrawalLength_ < 2 || depositLength_ < 2) revert InvalidDelegationsLength(); - - delegator_ = _withdrawalDelegations[withdrawalLength_ - 1].delegator; - if (delegator_ != _depositDelegations[depositLength_ - 1].delegator) revert DelegatorMismatch(); - } -} diff --git a/src/helpers/VaultMigrationHelper.sol b/src/helpers/VaultMigrationHelper.sol new file mode 100644 index 00000000..de9fefe4 --- /dev/null +++ b/src/helpers/VaultMigrationHelper.sol @@ -0,0 +1,424 @@ +// 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 { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; +import { MessageHashUtils } from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; +import { ModeLib } from "@erc7579/lib/ModeLib.sol"; +import { ExecutionLib } from "@erc7579/lib/ExecutionLib.sol"; + +import { VedaAdapter } from "./VedaAdapter.sol"; +import { ComplianceVedaAdapter } from "./ComplianceVedaAdapter.sol"; +import { IComplianceVedaTeller } from "./interfaces/IComplianceVedaTeller.sol"; +import { IRolesAuthority } from "./interfaces/IRolesAuthority.sol"; +import { Delegation, ModeCode } from "../utils/Types.sol"; + +/** + * @title VaultMigrationHelper + * @notice Atomically composes the existing base and premium Veda adapters, and moves premium shares with + * a compliance signature. + * @dev Vault-to-vault migrations keep delegations targeted at the adapter that redeems them. Premium share + * transfers are redeemed by this helper: the chain must pin `transfer` `to` to this contract and name + * this contract as redeemer. This helper must hold `transferAllowedRole` on the premium Teller so Veda + * allows the first hop (helper is `to`) and the second hop (helper is `from`/`operator`). + * + * Leaf Caveat Format: + * - For `premiumTransfer`, 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). This helper 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 here. A delegation without this + * enforcer as the first caveat (or with an amount other than the owner's full premium balance) + * will revert. + */ +contract VaultMigrationHelper is Ownable2Step { + using SafeERC20 for IERC20; + + /** + * @notice Parameters for one base-to-premium migration. + * @param withdrawalDelegations Delegation chain redeemed by the base adapter to withdraw base vault shares. + * @param minimumAssets Minimum underlying assets that the base withdrawal must return. + * @param depositDelegations Delegation chain redeemed by the premium adapter to deposit the withdrawn assets. + * @param minimumMint Minimum premium vault shares that the destination deposit must mint. + * @param compliance Compliance authorization forwarded unchanged to the premium adapter. + */ + struct ToPremiumParams { + Delegation[] withdrawalDelegations; + uint256 minimumAssets; + Delegation[] depositDelegations; + uint256 minimumMint; + IComplianceVedaTeller.ComplianceData compliance; + } + + /** + * @notice Parameters for one premium-to-base migration. + * @param withdrawalDelegations Delegation chain redeemed by the premium adapter to withdraw premium shares. + * @param minimumAssets Minimum underlying assets that the premium withdrawal must return. + * @param depositDelegations Delegation chain redeemed by the base adapter to deposit the withdrawn assets. + * @param minimumMint Minimum base vault shares that the destination deposit must mint. + */ + struct ToBaseParams { + Delegation[] withdrawalDelegations; + uint256 minimumAssets; + Delegation[] depositDelegations; + uint256 minimumMint; + } + + ////////////////////////////// Events ////////////////////////////// + + /** + * @notice Emitted after a base-to-premium migration completes. + * @param delegator Root delegator whose vault position was migrated. + * @param sourceShares Base vault shares withdrawn. + * @param destShares Premium vault shares minted. + */ + event MigrationToPremiumExecuted(address indexed delegator, uint256 sourceShares, uint256 destShares); + + /** + * @notice Emitted after a premium-to-base migration completes. + * @param delegator Root delegator whose vault position was migrated. + * @param sourceShares Premium vault shares withdrawn. + * @param destShares Base vault shares minted. + */ + event MigrationToBaseExecuted(address indexed delegator, uint256 sourceShares, uint256 destShares); + + /** + * @notice Emitted after a batch of base-to-premium migrations completes. + * @param caller Address that submitted the permissionless batch. + * @param count Number of migrations executed. + */ + event BatchMigrationToPremiumExecuted(address indexed caller, uint256 count); + + /** + * @notice Emitted after a batch of premium-to-base migrations completes. + * @param caller Address that submitted the permissionless batch. + * @param count Number of migrations executed. + */ + event BatchMigrationToBaseExecuted(address indexed caller, uint256 count); + + /** + * @notice Emitted after a compliance-gated premium share transfer. + * @param from Share owner whose full premium balance was moved. + * @param to Compliance-approved recipient. + * @param amount Number of premium vault shares transferred. + */ + event PremiumTransferExecuted(address indexed from, address indexed to, uint256 amount); + + /** + * @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 required constructor address is zero. + error InvalidZeroAddress(); + + /// @dev Thrown when a zero address is provided for the recipient. + error InvalidRecipient(); + + /// @dev Thrown when either delegation chain contains fewer than two delegations. + error InvalidDelegationsLength(); + + /// @dev Thrown when a migration batch is empty. + error InvalidBatchLength(); + + /// @dev Thrown when the withdrawal and deposit chains have different root delegators. + error DelegatorMismatch(); + + /// @dev Thrown when the leaf caveat terms are shorter than 52 bytes. + error InvalidTermsLength(); + + /// @dev Thrown when the premium share balance is zero or does not match the leaf caveat amount. + error InvalidTransferAmount(); + + /// @dev Thrown when compliance signature verification fails. + error ComplianceCheckFailed(); + + /// @dev Thrown when the premium Teller has compliance signer checks disabled (`complianceSignerRole == 255`). + error ComplianceDisabled(); + + ////////////////////////////// State ////////////////////////////// + + /** + * @notice Base vault adapter used for base deposits and withdrawals. + */ + VedaAdapter public immutable baseAdapter; + + /** + * @notice Premium compliance-gated adapter used for premium deposits and withdrawals. + */ + ComplianceVedaAdapter public immutable premiumAdapter; + + /** + * @notice Compliance message hashes already consumed by `premiumTransfer`. + */ + mapping(bytes32 messageHash => bool used) public usedComplianceSignatures; + + ////////////////////////////// Constructor ////////////////////////////// + + /** + * @notice Initializes the helper with its owner and the base and premium Veda adapters. + * @param _owner Address of the contract owner. + * @param _baseAdapter Address of the base Veda adapter. + * @param _premiumAdapter Address of the premium compliance-gated Veda adapter. + */ + constructor(address _owner, address _baseAdapter, address _premiumAdapter) Ownable(_owner) { + if (_baseAdapter == address(0) || _premiumAdapter == address(0)) { + revert InvalidZeroAddress(); + } + baseAdapter = VedaAdapter(_baseAdapter); + premiumAdapter = ComplianceVedaAdapter(_premiumAdapter); + } + + ////////////////////////////// External Methods ////////////////////////////// + + /** + * @notice Migrates one delegator from the base vault to the premium vault. + * @dev The withdrawal chain is redeemed by `baseAdapter`; the deposit chain and compliance data are + * forwarded to `premiumAdapter`. + * @param _params Withdrawal, deposit, slippage, and compliance parameters for the migration. + * @notice Security consideration: Callable by anyone. Security remains enforced by the delegation chains, + * their caveats, and the premium Teller's compliance validation. + */ + function migrateToPremiumByDelegation(ToPremiumParams calldata _params) external { + _migrateToPremiumByDelegation(_params); + } + + /** + * @notice Migrates multiple delegators from base to premium sequentially in one atomic transaction. + * @dev A revert from any withdrawal or deposit leg rolls back the entire batch. + * @param _params Migration parameters for each stream. + * @notice Security consideration: Callable by anyone. Every delegation chain remains independently enforced. + */ + function migrateToPremiumByDelegationBatch(ToPremiumParams[] calldata _params) external { + uint256 length_ = _params.length; + if (length_ == 0) revert InvalidBatchLength(); + + for (uint256 i = 0; i < length_;) { + _migrateToPremiumByDelegation(_params[i]); + unchecked { + ++i; + } + } + + emit BatchMigrationToPremiumExecuted(msg.sender, length_); + } + + /** + * @notice Migrates one delegator from the premium vault to the base vault. + * @dev The withdrawal chain is redeemed by `premiumAdapter`; the deposit chain is redeemed by `baseAdapter`. + * @param _params Withdrawal, deposit, and slippage parameters for the migration. + * @notice Security consideration: Callable by anyone. Security remains enforced by the delegation chains + * and their caveats. + */ + function migrateToBaseByDelegation(ToBaseParams calldata _params) external { + _migrateToBaseByDelegation(_params); + } + + /** + * @notice Migrates multiple delegators from premium to base sequentially in one atomic transaction. + * @dev A revert from any withdrawal or deposit leg rolls back the entire batch. + * @param _params Migration parameters for each stream. + * @notice Security consideration: Callable by anyone. Every delegation chain remains independently enforced. + */ + function migrateToBaseByDelegationBatch(ToBaseParams[] calldata _params) external { + uint256 length_ = _params.length; + if (length_ == 0) revert InvalidBatchLength(); + + for (uint256 i = 0; i < length_;) { + _migrateToBaseByDelegation(_params[i]); + unchecked { + ++i; + } + } + + emit BatchMigrationToBaseExecuted(msg.sender, length_); + } + + /** + * @notice Moves `from`'s entire premium vault balance to `to` after a compliance signature check. + * @dev The delegation chain must be redeemable only by this helper and must pin `transfer` `to` to this + * helper. Production must grant this helper `transferAllowedRole` on the premium Teller. + * The transfer 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 _from Share owner and root delegator. + * @param _to Compliance-approved recipient of the premium shares. + * @param _delegations Chain sorted leaf to root. Leaf `delegate` must be this helper. + * @param _compliance Backend-issued EIP-191 approval (`deadline` + `signature`). + * @notice Security consideration: Callable by anyone. The compliance signature is bound to this helper, + * the premium Teller, `from`, `to`, the premium vault, the full share balance, and the deadline. + * A Teller deposit signature cannot be reused because it binds the Teller address, not this helper. + * The redelegation MUST include an `ERC20TransferAmountEnforcer` as its first caveat (`caveats[0]`), + * capped to exactly the owner's full premium balance. Reverts when Teller compliance is disabled. + */ + function premiumTransfer( + address _from, + address _to, + Delegation[] calldata _delegations, + IComplianceVedaTeller.ComplianceData calldata _compliance + ) + external + { + if (_from == address(0) || _to == address(0)) revert InvalidZeroAddress(); + + uint256 length_ = _delegations.length; + if (length_ < 2) revert InvalidDelegationsLength(); + if (_delegations[length_ - 1].delegator != _from) revert DelegatorMismatch(); + + IERC20 vault_ = IERC20(premiumAdapter.boringVault()); + uint256 amount_ = vault_.balanceOf(_from); + if (amount_ == 0 || amount_ != _parseERC20TransferTerms(_delegations[0].caveats[0].terms)) { + revert InvalidTransferAmount(); + } + + _verifyTransferCompliance(_from, _to, address(vault_), amount_, _compliance); + + 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(vault_), 0, abi.encodeCall(IERC20.transfer, (address(this), amount_))); + + premiumAdapter.delegationManager().redeemDelegations(permissionContexts_, encodedModes_, executionCallDatas_); + vault_.safeTransfer(_to, amount_); + + emit PremiumTransferExecuted(_from, _to, amount_); + } + + /** + * @notice Emergency function to recover tokens accidentally sent to this contract. + * @dev Migrations should not leave balances here. `premiumTransfer` only holds shares mid-transaction. + * 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 Executes one base-to-premium migration. + * @param _params Migration parameters forwarded to the base and premium adapters. + */ + function _migrateToPremiumByDelegation(ToPremiumParams calldata _params) internal { + address delegator_ = _validateDelegators(_params.withdrawalDelegations, _params.depositDelegations); + IERC20 sourceVault_ = IERC20(baseAdapter.boringVault()); + IERC20 destVault_ = IERC20(premiumAdapter.boringVault()); + uint256 sourceBefore_ = sourceVault_.balanceOf(delegator_); + uint256 destBefore_ = destVault_.balanceOf(delegator_); + + baseAdapter.withdrawByDelegation(_params.withdrawalDelegations, _params.minimumAssets); + premiumAdapter.depositByDelegation(_params.depositDelegations, _params.minimumMint, _params.compliance); + + emit MigrationToPremiumExecuted( + delegator_, sourceBefore_ - sourceVault_.balanceOf(delegator_), destVault_.balanceOf(delegator_) - destBefore_ + ); + } + + /** + * @notice Executes one premium-to-base migration. + * @param _params Migration parameters forwarded to the premium and base adapters. + */ + function _migrateToBaseByDelegation(ToBaseParams calldata _params) internal { + address delegator_ = _validateDelegators(_params.withdrawalDelegations, _params.depositDelegations); + IERC20 sourceVault_ = IERC20(premiumAdapter.boringVault()); + IERC20 destVault_ = IERC20(baseAdapter.boringVault()); + uint256 sourceBefore_ = sourceVault_.balanceOf(delegator_); + uint256 destBefore_ = destVault_.balanceOf(delegator_); + + premiumAdapter.withdrawByDelegation(_params.withdrawalDelegations, _params.minimumAssets); + baseAdapter.depositByDelegation(_params.depositDelegations, _params.minimumMint); + + emit MigrationToBaseExecuted( + delegator_, sourceBefore_ - sourceVault_.balanceOf(delegator_), destVault_.balanceOf(delegator_) - destBefore_ + ); + } + + /** + * @notice Validates both delegation chains and returns their shared root delegator. + * @param _withdrawalDelegations Source-vault withdrawal chain sorted from leaf to root. + * @param _depositDelegations Destination-vault deposit chain sorted from leaf to root. + * @return delegator_ Shared root delegator that owns both sides of the migration. + */ + function _validateDelegators( + Delegation[] calldata _withdrawalDelegations, + Delegation[] calldata _depositDelegations + ) + private + pure + returns (address delegator_) + { + uint256 withdrawalLength_ = _withdrawalDelegations.length; + uint256 depositLength_ = _depositDelegations.length; + if (withdrawalLength_ < 2 || depositLength_ < 2) revert InvalidDelegationsLength(); + + delegator_ = _withdrawalDelegations[withdrawalLength_ - 1].delegator; + if (delegator_ != _depositDelegations[depositLength_ - 1].delegator) revert DelegatorMismatch(); + } + + /** + * @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. + */ + function _parseERC20TransferTerms(bytes calldata _terms) private pure returns (uint256 amount_) { + if (_terms.length < 52) revert InvalidTermsLength(); + amount_ = uint256(bytes32(_terms[20:52])); + } + + /** + * @notice Verifies a transfer compliance signature using the Teller's signer role and window. + * @dev The digest starts with this helper's address so it cannot equal a Teller deposit hash. + * Reverts if the Teller has disabled compliance (`complianceSignerRole == 255`). + */ + function _verifyTransferCompliance( + address _from, + address _to, + address _vault, + uint256 _amount, + IComplianceVedaTeller.ComplianceData calldata _compliance + ) + private + { + IComplianceVedaTeller teller_ = premiumAdapter.teller(); + uint8 complianceSignerRole_ = teller_.complianceSignerRole(); + if (complianceSignerRole_ == type(uint8).max) revert ComplianceDisabled(); + + bytes32 messageHash_ = keccak256( + abi.encode(address(this), address(teller_), block.chainid, _from, _to, _vault, _amount, _compliance.deadline) + ); + if (usedComplianceSignatures[messageHash_]) revert ComplianceCheckFailed(); + if (block.timestamp > _compliance.deadline) revert ComplianceCheckFailed(); + + uint96 complianceWindow_ = teller_.complianceWindow(); + if (complianceWindow_ > 0 && _compliance.deadline > block.timestamp + complianceWindow_) { + revert ComplianceCheckFailed(); + } + + bytes32 ethSignedHash_ = MessageHashUtils.toEthSignedMessageHash(messageHash_); + address recovered_ = ECDSA.recover(ethSignedHash_, _compliance.signature); + if (!IRolesAuthority(teller_.authority()).doesUserHaveRole(recovered_, complianceSignerRole_)) { + revert ComplianceCheckFailed(); + } + + usedComplianceSignatures[messageHash_] = true; + } +} diff --git a/src/helpers/interfaces/IComplianceVedaTeller.sol b/src/helpers/interfaces/IComplianceVedaTeller.sol index b247f1e0..57df77a0 100644 --- a/src/helpers/interfaces/IComplianceVedaTeller.sol +++ b/src/helpers/interfaces/IComplianceVedaTeller.sol @@ -44,4 +44,20 @@ interface IComplianceVedaTeller { ) external returns (uint256 assetsOut); + + /** + * @notice RolesAuthority used to check compliance-signer and transfer-allowlist roles. + */ + function authority() external view returns (address); + + /** + * @notice Role ID that a recovered compliance signer must hold, or 255 to disable Teller checks. + * @dev `VaultMigrationHelper.premiumTransfer` reverts when this value is 255. + */ + function complianceSignerRole() external view returns (uint8); + + /** + * @notice Maximum seconds a compliance deadline may extend beyond `block.timestamp`, or 0 for no cap. + */ + function complianceWindow() external view returns (uint96); } diff --git a/src/helpers/interfaces/IRolesAuthority.sol b/src/helpers/interfaces/IRolesAuthority.sol new file mode 100644 index 00000000..d1511445 --- /dev/null +++ b/src/helpers/interfaces/IRolesAuthority.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +/** + * @title IRolesAuthority + * @notice Minimal interface for Solmate-style RolesAuthority role checks used by Veda tellers. + */ +interface IRolesAuthority { + function doesUserHaveRole(address user, uint8 role) external view returns (bool); +} diff --git a/test/helpers/VaultMigrationAdapter.t.sol b/test/helpers/VaultMigrationAdapter.t.sol deleted file mode 100644 index 43dc6849..00000000 --- a/test/helpers/VaultMigrationAdapter.t.sol +++ /dev/null @@ -1,551 +0,0 @@ -// SPDX-License-Identifier: MIT AND Apache-2.0 -pragma solidity 0.8.23; - -import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; -import { ERC20 } from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; -import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; -import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; - -import { EncoderLib } from "../../src/libraries/EncoderLib.sol"; -import { IComplianceVedaTeller } from "../../src/helpers/interfaces/IComplianceVedaTeller.sol"; -import { IVedaTeller } from "../../src/helpers/interfaces/IVedaTeller.sol"; -import { VedaAdapter } from "../../src/helpers/VedaAdapter.sol"; -import { ComplianceVedaAdapter } from "../../src/helpers/ComplianceVedaAdapter.sol"; -import { VaultMigrationAdapter } from "../../src/helpers/VaultMigrationAdapter.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 { BasicERC20 } from "../utils/BasicERC20.t.sol"; -import { Implementation, SignatureType, TestUser } from "../utils/Types.t.sol"; - -contract MockBoringVault is ERC20, Ownable { - using SafeERC20 for IERC20; - - constructor(string memory _name, string memory _symbol) ERC20(_name, _symbol) Ownable(msg.sender) { } - - function enter(address _from, IERC20 _asset, uint256 _assets, address _to, uint256 _shares) external onlyOwner { - _asset.safeTransferFrom(_from, address(this), _assets); - _mint(_to, _shares); - } - - function exit(address _from, IERC20 _asset, uint256 _assets, address _to, uint256 _shares) external onlyOwner { - _burn(_from, _shares); - _asset.safeTransfer(_to, _assets); - } -} - -contract MockVedaTeller is IVedaTeller, IComplianceVedaTeller { - MockBoringVault public immutable vault; - IERC20 public immutable asset; - - bool public revertDeposit; - uint256 public lastComplianceDeadline; - bytes32 public lastComplianceSignatureHash; - - error DepositFailed(); - error InsufficientMint(); - error InsufficientAssets(); - - constructor(IERC20 _asset, string memory _vaultName, string memory _vaultSymbol) { - asset = _asset; - vault = new MockBoringVault(_vaultName, _vaultSymbol); - } - - function setRevertDeposit(bool _revertDeposit) external { - revertDeposit = _revertDeposit; - } - - function deposit( - address _depositAsset, - uint256 _depositAmount, - uint256 _minimumMint, - address - ) - external - payable - override - returns (uint256) - { - return _deposit(_depositAsset, _depositAmount, _minimumMint, msg.sender); - } - - function deposit( - address _depositAsset, - uint256 _depositAmount, - uint256 _minimumMint, - address _to, - address - ) - external - payable - override - returns (uint256) - { - return _deposit(_depositAsset, _depositAmount, _minimumMint, _to); - } - - function deposit( - DepositParams calldata _params, - address _to, - address, - ComplianceData calldata _compliance - ) - external - payable - override - returns (uint256) - { - lastComplianceDeadline = _compliance.deadline; - lastComplianceSignatureHash = keccak256(_compliance.signature); - return _deposit(_params.depositAsset, _params.depositAmount, _params.minimumMint, _to); - } - - function withdraw( - address _withdrawAsset, - uint256 _shareAmount, - uint256 _minimumAssets, - address _to - ) - external - override(IVedaTeller, IComplianceVedaTeller) - returns (uint256) - { - if (_shareAmount < _minimumAssets) revert InsufficientAssets(); - vault.exit(msg.sender, IERC20(_withdrawAsset), _shareAmount, _to, _shareAmount); - return _shareAmount; - } - - function _deposit(address _depositAsset, uint256 _depositAmount, uint256 _minimumMint, address _to) private returns (uint256) { - if (revertDeposit) revert DepositFailed(); - if (_depositAmount < _minimumMint) revert InsufficientMint(); - vault.enter(msg.sender, IERC20(_depositAsset), _depositAmount, _to, _depositAmount); - return _depositAmount; - } -} - -/** - * @title VaultMigrationAdapter Test - * @notice Tests atomic base <-> premium vault migrations through signed delegation chains. - * @dev Uses in-process mock Tellers that actually pull tokens and mint/burn shares at 1:1 so - * assertions can follow token amounts the same way as VedaLending and ComplianceVedaLending. - */ -contract VaultMigrationAdapterTest is BaseTest { - 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; - - BasicERC20 internal musd; - MockVedaTeller internal baseTeller; - MockVedaTeller internal premiumTeller; - IERC20 internal baseVault; - IERC20 internal premiumVault; - VedaAdapter internal baseAdapter; - ComplianceVedaAdapter internal premiumAdapter; - VaultMigrationAdapter internal migrationAdapter; - address internal adapterOwner; - - event MigrationToPremiumExecuted(address indexed delegator, uint256 minimumAssets, uint256 minimumMint); - event MigrationToBaseExecuted(address indexed delegator, uint256 minimumAssets, uint256 minimumMint); - event BatchMigrationToPremiumExecuted(address indexed caller, uint256 count); - event BatchMigrationToBaseExecuted(address indexed caller, uint256 count); - - function setUp() public override { - IMPLEMENTATION = Implementation.Hybrid; - SIGNATURE_TYPE = SignatureType.RawP256; - super.setUp(); - - adapterOwner = makeAddr("VaultMigrationAdapter Owner"); - erc20TransferAmountEnforcer = new ERC20TransferAmountEnforcer(); - redeemerEnforcer = new RedeemerEnforcer(); - - musd = new BasicERC20(address(this), "mUSD", "mUSD", 0); - baseTeller = new MockVedaTeller(musd, "Base mUSD Vault", "bmUSD"); - premiumTeller = new MockVedaTeller(musd, "Premium mUSD Vault", "pmUSD"); - baseVault = IERC20(address(baseTeller.vault())); - premiumVault = IERC20(address(premiumTeller.vault())); - - baseAdapter = - new VedaAdapter(adapterOwner, address(delegationManager), address(baseVault), address(baseTeller), address(musd)); - premiumAdapter = new ComplianceVedaAdapter( - adapterOwner, address(delegationManager), address(premiumVault), address(premiumTeller), address(musd) - ); - migrationAdapter = new VaultMigrationAdapter(adapterOwner, address(baseAdapter), address(premiumAdapter)); - - musd.mint(address(users.alice.deleGator), INITIAL_MUSD_BALANCE); - musd.mint(address(users.carol.deleGator), INITIAL_MUSD_BALANCE); - - vm.label(address(musd), "mUSD"); - vm.label(address(baseVault), "Base Vault"); - vm.label(address(premiumVault), "Premium Vault"); - vm.label(address(baseAdapter), "VedaAdapter"); - vm.label(address(premiumAdapter), "ComplianceVedaAdapter"); - vm.label(address(migrationAdapter), "VaultMigrationAdapter"); - } - - function test_constructor_revertsOnZeroAddresses() public { - vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableInvalidOwner.selector, address(0))); - new VaultMigrationAdapter(address(0), address(baseAdapter), address(premiumAdapter)); - - vm.expectRevert(VaultMigrationAdapter.InvalidZeroAddress.selector); - new VaultMigrationAdapter(adapterOwner, address(0), address(premiumAdapter)); - - vm.expectRevert(VaultMigrationAdapter.InvalidZeroAddress.selector); - new VaultMigrationAdapter(adapterOwner, address(baseAdapter), address(0)); - } - - function test_migrateToPremium_movesSharesAndLeavesNoDust() public { - _depositToBase(users.alice, DEPOSIT_AMOUNT, 0); - - uint256 aliceMusdBefore_ = musd.balanceOf(address(users.alice.deleGator)); - uint256 baseShares_ = baseVault.balanceOf(address(users.alice.deleGator)); - assertEq(baseShares_, DEPOSIT_AMOUNT); - assertEq(aliceMusdBefore_, INITIAL_MUSD_BALANCE - DEPOSIT_AMOUNT); - - VaultMigrationAdapter.ToPremiumParams memory params_ = - _toPremiumParams(users.alice, baseShares_, DEPOSIT_AMOUNT, 1, DEPOSIT_AMOUNT, 2, block.timestamp + 30 minutes); - - vm.expectEmit(true, false, false, true, address(migrationAdapter)); - emit MigrationToPremiumExecuted(address(users.alice.deleGator), 0, 0); - vm.prank(address(users.bob.deleGator)); - migrationAdapter.migrateToPremiumByDelegation(params_); - - assertEq(baseVault.balanceOf(address(users.alice.deleGator)), 0, "Base shares should be burned"); - assertEq(premiumVault.balanceOf(address(users.alice.deleGator)), DEPOSIT_AMOUNT, "Premium shares should be minted 1:1"); - assertEq(musd.balanceOf(address(users.alice.deleGator)), aliceMusdBefore_, "Underlying should settle back with Alice"); - _assertNoAdapterDust(); - assertEq(premiumTeller.lastComplianceDeadline(), block.timestamp + 30 minutes); - assertEq( - premiumTeller.lastComplianceSignatureHash(), keccak256(_signCompliance(DEPOSIT_AMOUNT, block.timestamp + 30 minutes)) - ); - } - - function test_migrateToBase_movesSharesAndLeavesNoDust() public { - _depositToPremium(users.alice, DEPOSIT_AMOUNT, 10, block.timestamp + 30 minutes); - - uint256 aliceMusdBefore_ = musd.balanceOf(address(users.alice.deleGator)); - uint256 premiumShares_ = premiumVault.balanceOf(address(users.alice.deleGator)); - assertEq(premiumShares_, DEPOSIT_AMOUNT); - - VaultMigrationAdapter.ToBaseParams memory params_ = _toBaseParams(users.alice, premiumShares_, 11, DEPOSIT_AMOUNT, 12); - - vm.expectEmit(true, false, false, true, address(migrationAdapter)); - emit MigrationToBaseExecuted(address(users.alice.deleGator), 0, 0); - vm.prank(address(users.bob.deleGator)); - migrationAdapter.migrateToBaseByDelegation(params_); - - assertEq(premiumVault.balanceOf(address(users.alice.deleGator)), 0, "Premium shares should be burned"); - assertEq(baseVault.balanceOf(address(users.alice.deleGator)), DEPOSIT_AMOUNT, "Base shares should be minted 1:1"); - assertEq(musd.balanceOf(address(users.alice.deleGator)), aliceMusdBefore_, "Underlying should settle back with Alice"); - _assertNoAdapterDust(); - } - - function test_migrateToPremium_isPermissionless() public { - _depositToBase(users.alice, DEPOSIT_AMOUNT, 20); - uint256 baseShares_ = baseVault.balanceOf(address(users.alice.deleGator)); - - vm.prank(address(users.carol.deleGator)); - migrationAdapter.migrateToPremiumByDelegation( - _toPremiumParams(users.alice, baseShares_, DEPOSIT_AMOUNT, 21, DEPOSIT_AMOUNT, 22, block.timestamp + 30 minutes) - ); - - assertEq(baseVault.balanceOf(address(users.alice.deleGator)), 0); - assertEq(premiumVault.balanceOf(address(users.alice.deleGator)), DEPOSIT_AMOUNT); - assertEq(premiumVault.balanceOf(address(users.carol.deleGator)), 0, "Caller must not receive shares"); - _assertNoAdapterDust(); - } - - function test_migrateToPremium_revertsBothLegsWhenDepositFails() public { - _depositToBase(users.alice, DEPOSIT_AMOUNT, 30); - uint256 baseShares_ = baseVault.balanceOf(address(users.alice.deleGator)); - uint256 aliceMusdBefore_ = musd.balanceOf(address(users.alice.deleGator)); - premiumTeller.setRevertDeposit(true); - VaultMigrationAdapter.ToPremiumParams memory params_ = - _toPremiumParams(users.alice, baseShares_, DEPOSIT_AMOUNT, 31, DEPOSIT_AMOUNT, 32, block.timestamp + 30 minutes); - - vm.prank(address(users.bob.deleGator)); - vm.expectRevert(MockVedaTeller.DepositFailed.selector); - migrationAdapter.migrateToPremiumByDelegation(params_); - - assertEq(baseVault.balanceOf(address(users.alice.deleGator)), baseShares_, "Base shares must roll back"); - assertEq(premiumVault.balanceOf(address(users.alice.deleGator)), 0, "No premium shares should be minted"); - assertEq(musd.balanceOf(address(users.alice.deleGator)), aliceMusdBefore_, "Underlying must roll back"); - _assertNoAdapterDust(); - } - - function test_migrateToBase_revertsOnDelegatorMismatch() public { - _depositToPremium(users.alice, DEPOSIT_AMOUNT, 40, block.timestamp + 30 minutes); - uint256 aliceShares_ = premiumVault.balanceOf(address(users.alice.deleGator)); - - VaultMigrationAdapter.ToBaseParams memory params_ = VaultMigrationAdapter.ToBaseParams({ - withdrawalDelegations: _createDelegationChain( - users.alice, address(premiumAdapter), address(premiumVault), aliceShares_, 41 - ), - minimumAssets: 0, - depositDelegations: _createDelegationChain(users.carol, address(baseAdapter), address(musd), DEPOSIT_AMOUNT, 42), - minimumMint: 0 - }); - - vm.expectRevert(VaultMigrationAdapter.DelegatorMismatch.selector); - migrationAdapter.migrateToBaseByDelegation(params_); - - assertEq(premiumVault.balanceOf(address(users.alice.deleGator)), aliceShares_); - assertEq(baseVault.balanceOf(address(users.alice.deleGator)), 0); - assertEq(baseVault.balanceOf(address(users.carol.deleGator)), 0); - } - - function test_migrateToPremium_revertsOnShortDelegationChain() public { - _depositToBase(users.alice, DEPOSIT_AMOUNT, 50); - VaultMigrationAdapter.ToPremiumParams memory params_ = _toPremiumParams( - users.alice, - baseVault.balanceOf(address(users.alice.deleGator)), - DEPOSIT_AMOUNT, - 51, - DEPOSIT_AMOUNT, - 52, - block.timestamp + 30 minutes - ); - params_.withdrawalDelegations = new Delegation[](1); - - vm.expectRevert(VaultMigrationAdapter.InvalidDelegationsLength.selector); - migrationAdapter.migrateToPremiumByDelegation(params_); - - assertEq(baseVault.balanceOf(address(users.alice.deleGator)), DEPOSIT_AMOUNT); - assertEq(premiumVault.balanceOf(address(users.alice.deleGator)), 0); - } - - function test_migrateToPremiumBatch_movesExactAmountsForEachDelegator() public { - uint256 aliceAmount_ = 300e6; - uint256 carolAmount_ = 400e6; - _depositToBase(users.alice, aliceAmount_, 60); - _depositToBase(users.carol, carolAmount_, 61); - - VaultMigrationAdapter.ToPremiumParams[] memory params_ = new VaultMigrationAdapter.ToPremiumParams[](2); - params_[0] = _toPremiumParams(users.alice, aliceAmount_, aliceAmount_, 62, aliceAmount_, 63, block.timestamp + 20 minutes); - params_[1] = _toPremiumParams(users.carol, carolAmount_, carolAmount_, 64, carolAmount_, 65, block.timestamp + 21 minutes); - - vm.expectEmit(true, false, false, true, address(migrationAdapter)); - emit BatchMigrationToPremiumExecuted(address(users.bob.deleGator), 2); - vm.prank(address(users.bob.deleGator)); - migrationAdapter.migrateToPremiumByDelegationBatch(params_); - - assertEq(baseVault.balanceOf(address(users.alice.deleGator)), 0); - assertEq(baseVault.balanceOf(address(users.carol.deleGator)), 0); - assertEq(premiumVault.balanceOf(address(users.alice.deleGator)), aliceAmount_); - assertEq(premiumVault.balanceOf(address(users.carol.deleGator)), carolAmount_); - assertEq(musd.balanceOf(address(users.alice.deleGator)), INITIAL_MUSD_BALANCE - aliceAmount_); - assertEq(musd.balanceOf(address(users.carol.deleGator)), INITIAL_MUSD_BALANCE - carolAmount_); - _assertNoAdapterDust(); - } - - function test_migrateToBaseBatch_movesExactAmountsForEachDelegator() public { - uint256 aliceAmount_ = 250e6; - uint256 carolAmount_ = 350e6; - _depositToPremium(users.alice, aliceAmount_, 70, block.timestamp + 20 minutes); - _depositToPremium(users.carol, carolAmount_, 71, block.timestamp + 21 minutes); - - VaultMigrationAdapter.ToBaseParams[] memory params_ = new VaultMigrationAdapter.ToBaseParams[](2); - params_[0] = _toBaseParams(users.alice, aliceAmount_, 72, aliceAmount_, 73); - params_[1] = _toBaseParams(users.carol, carolAmount_, 74, carolAmount_, 75); - - vm.expectEmit(true, false, false, true, address(migrationAdapter)); - emit BatchMigrationToBaseExecuted(address(users.bob.deleGator), 2); - vm.prank(address(users.bob.deleGator)); - migrationAdapter.migrateToBaseByDelegationBatch(params_); - - assertEq(premiumVault.balanceOf(address(users.alice.deleGator)), 0); - assertEq(premiumVault.balanceOf(address(users.carol.deleGator)), 0); - assertEq(baseVault.balanceOf(address(users.alice.deleGator)), aliceAmount_); - assertEq(baseVault.balanceOf(address(users.carol.deleGator)), carolAmount_); - _assertNoAdapterDust(); - } - - function test_emptyBatchesRevert() public { - VaultMigrationAdapter.ToPremiumParams[] memory premiumParams_ = new VaultMigrationAdapter.ToPremiumParams[](0); - VaultMigrationAdapter.ToBaseParams[] memory baseParams_ = new VaultMigrationAdapter.ToBaseParams[](0); - - vm.expectRevert(VaultMigrationAdapter.InvalidBatchLength.selector); - migrationAdapter.migrateToPremiumByDelegationBatch(premiumParams_); - - vm.expectRevert(VaultMigrationAdapter.InvalidBatchLength.selector); - migrationAdapter.migrateToBaseByDelegationBatch(baseParams_); - } - - function _depositToBase(TestUser memory _delegator, uint256 _amount, uint256 _salt) internal { - Delegation[] memory delegations_ = _createDelegationChain(_delegator, address(baseAdapter), address(musd), _amount, _salt); - - vm.prank(address(users.bob.deleGator)); - baseAdapter.depositByDelegation(delegations_, 0); - } - - function _depositToPremium(TestUser memory _delegator, uint256 _amount, uint256 _salt, uint256 _deadline) internal { - Delegation[] memory delegations_ = - _createDelegationChain(_delegator, address(premiumAdapter), address(musd), _amount, _salt); - IComplianceVedaTeller.ComplianceData memory compliance_ = _complianceData(_amount, _deadline); - - vm.prank(address(users.bob.deleGator)); - premiumAdapter.depositByDelegation(delegations_, 0, compliance_); - } - - function _toPremiumParams( - TestUser memory _delegator, - uint256 _shareAmount, - uint256 _assetAmount, - uint256 _withdrawalSalt, - uint256 _depositAmount, - uint256 _depositSalt, - uint256 _deadline - ) - internal - view - returns (VaultMigrationAdapter.ToPremiumParams memory) - { - return VaultMigrationAdapter.ToPremiumParams({ - withdrawalDelegations: _createDelegationChain( - _delegator, address(baseAdapter), address(baseVault), _shareAmount, _withdrawalSalt - ), - minimumAssets: 0, - depositDelegations: _createDelegationChain( - _delegator, address(premiumAdapter), address(musd), _depositAmount, _depositSalt - ), - minimumMint: 0, - compliance: _complianceData(_assetAmount, _deadline) - }); - } - - function _toBaseParams( - TestUser memory _delegator, - uint256 _shareAmount, - uint256 _withdrawalSalt, - uint256 _depositAmount, - uint256 _depositSalt - ) - internal - view - returns (VaultMigrationAdapter.ToBaseParams memory) - { - return VaultMigrationAdapter.ToBaseParams({ - withdrawalDelegations: _createDelegationChain( - _delegator, address(premiumAdapter), address(premiumVault), _shareAmount, _withdrawalSalt - ), - minimumAssets: 0, - depositDelegations: _createDelegationChain( - _delegator, address(baseAdapter), address(musd), _depositAmount, _depositSalt - ), - minimumMint: 0 - }); - } - - function _createDelegationChain( - TestUser memory _delegator, - address _adapter, - address _token, - uint256 _amount, - uint256 _salt - ) - internal - view - returns (Delegation[] memory delegations_) - { - Delegation memory root_ = _createTransferDelegation(_delegator, _adapter, _token, type(uint256).max, _salt); - Delegation memory leaf_ = _createAdapterRedelegation(_adapter, EncoderLib._getDelegationHash(root_), _token, _amount, _salt); - - delegations_ = new Delegation[](2); - delegations_[0] = leaf_; - delegations_[1] = root_; - } - - function _createTransferDelegation( - TestUser memory _delegator, - address _adapter, - 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(_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( - address _adapter, - 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: _adapter, - delegator: address(users.bob.deleGator), - authority: _authority, - caveats: caveats_, - salt: _salt, - signature: hex"" - }); - return signDelegation(users.bob, delegation_); - } - - function _complianceData( - uint256 _amount, - uint256 _deadline - ) - internal - view - returns (IComplianceVedaTeller.ComplianceData memory) - { - return IComplianceVedaTeller.ComplianceData({ deadline: _deadline, signature: _signCompliance(_amount, _deadline) }); - } - - function _signCompliance(uint256 _amount, uint256 _deadline) internal view returns (bytes memory) { - bytes32 messageHash_ = keccak256( - abi.encode( - address(premiumTeller), - block.chainid, - address(premiumAdapter), - address(users.alice.deleGator), - address(musd), - _amount, - _deadline - ) - ); - bytes32 ethSignedMessageHash_ = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash_)); - (uint8 v_, bytes32 r_, bytes32 s_) = vm.sign(COMPLIANCE_SIGNER_KEY, ethSignedMessageHash_); - return abi.encodePacked(r_, s_, v_); - } - - function _assertNoAdapterDust() internal { - assertEq(musd.balanceOf(address(baseAdapter)), 0); - assertEq(musd.balanceOf(address(premiumAdapter)), 0); - assertEq(musd.balanceOf(address(migrationAdapter)), 0); - assertEq(baseVault.balanceOf(address(baseAdapter)), 0); - assertEq(premiumVault.balanceOf(address(premiumAdapter)), 0); - assertEq(baseVault.balanceOf(address(migrationAdapter)), 0); - assertEq(premiumVault.balanceOf(address(migrationAdapter)), 0); - } -} diff --git a/test/helpers/VaultMigrationHelper.t.sol b/test/helpers/VaultMigrationHelper.t.sol new file mode 100644 index 00000000..cf489194 --- /dev/null +++ b/test/helpers/VaultMigrationHelper.t.sol @@ -0,0 +1,1017 @@ +// SPDX-License-Identifier: MIT AND Apache-2.0 +pragma solidity 0.8.23; + +import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; + +import { EncoderLib } from "../../src/libraries/EncoderLib.sol"; +import { BasicERC20 } from "../utils/BasicERC20.t.sol"; +import { IComplianceVedaTeller } from "../../src/helpers/interfaces/IComplianceVedaTeller.sol"; +import { IVedaTeller } from "../../src/helpers/interfaces/IVedaTeller.sol"; +import { VedaAdapter } from "../../src/helpers/VedaAdapter.sol"; +import { ComplianceVedaAdapter } from "../../src/helpers/ComplianceVedaAdapter.sol"; +import { VaultMigrationHelper } from "../../src/helpers/VaultMigrationHelper.sol"; +import { ERC20TransferAmountEnforcer } from "../../src/enforcers/ERC20TransferAmountEnforcer.sol"; +import { RedeemerEnforcer } from "../../src/enforcers/RedeemerEnforcer.sol"; +import { AllowedCalldataEnforcer } from "../../src/enforcers/AllowedCalldataEnforcer.sol"; +import { AllowedTargetsEnforcer } from "../../src/enforcers/AllowedTargetsEnforcer.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; + function doesUserHaveRole(address user, uint8 role) external view returns (bool); +} + +interface ITellerConfiguration { + function owner() external view returns (address); + function setTransferRestrictions(uint8 transferAllowedRole, uint8 allowlistedRouterRole) external; +} + +interface IVedaAccountant { + function decimals() external view returns (uint8); + function getRateInQuoteSafe(address quote) external view returns (uint256); +} + +/// forge-config: default.evm_version = "cancun" +contract VaultMigrationHelperTest is BaseTest { + IERC20 internal constant MUSD = IERC20(0xacA92E438df0B2401fF60dA7E4337B687a2435DA); + IERC20 internal constant BASE_VAULT = IERC20(0xb4563bcD3B7764CCBf497f515585f70B6C3EA5Ae); + IVedaTeller internal constant BASE_TELLER = IVedaTeller(0x2D49EA58A4C70b62c8B56DE971310d9e999c8117); + IVedaAccountant internal constant ACCOUNTANT = IVedaAccountant(0x7382c5b8B51B8C4f127B3123C1039581BAA5A06B); + address internal constant BASE_LENS = 0xA816ECd922de94c6879AD23B9A884dB257F20947; + address internal constant BUFFER_LENS = 0xf484d54AF87199F421967E2231Dd6dC348C7Ee6A; + address internal constant DEPLOYED_BASE_ADAPTER = 0xaD4c09d065fDb6320FA8ADf69460CDd9d472C25A; + + IComplianceVedaTeller internal constant PREMIUM_TELLER = IComplianceVedaTeller(0xB0025a2eBc0474d4F28E975F0D3E70471246ebae); + IERC20 internal constant PREMIUM_VAULT = IERC20(0xBFeC8c2b1ccea3931a1363E4CaC27352c1C908B7); + IRolesAuthority internal constant PREMIUM_ROLES_AUTHORITY = IRolesAuthority(0x00f0CF4f540D1d47470f565Ecb11a75E073c2dd0); + IRolesAuthority internal constant BASE_ROLES_AUTHORITY = IRolesAuthority(0x1eC540C9a4656a50F2D6DaaBd647753F97f082D1); + + 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; + uint8 internal constant BASE_ADAPTER_ROLE = 90; + + uint256 internal constant INITIAL_MUSD_BALANCE = 100_000e6; + uint256 internal constant DEPOSIT_AMOUNT = 1_000e6; + uint256 internal constant SHARE_LOCK_SECONDS = 61; + uint256 internal constant COMPLIANCE_SIGNER_KEY = 0xC011A11CE; + + ERC20TransferAmountEnforcer internal erc20TransferAmountEnforcer; + RedeemerEnforcer internal redeemerEnforcer; + AllowedCalldataEnforcer internal allowedCalldataEnforcer; + AllowedTargetsEnforcer internal allowedTargetsEnforcer; + VedaAdapter internal baseAdapter; + ComplianceVedaAdapter internal premiumAdapter; + VaultMigrationHelper internal migrationHelper; + address internal adapterOwner; + address internal complianceSigner; + + event MigrationToPremiumExecuted(address indexed delegator, uint256 sourceShares, uint256 destShares); + event MigrationToBaseExecuted(address indexed delegator, uint256 sourceShares, uint256 destShares); + event BatchMigrationToPremiumExecuted(address indexed caller, uint256 count); + event BatchMigrationToBaseExecuted(address indexed caller, uint256 count); + event PremiumTransferExecuted(address indexed from, address indexed to, uint256 amount); + event StuckTokensWithdrawn(IERC20 indexed token, address indexed recipient, uint256 amount); + + function setUp() public override { + vm.createSelectFork(vm.envString("MONAD_RPC_URL")); + + IMPLEMENTATION = Implementation.Hybrid; + SIGNATURE_TYPE = SignatureType.RawP256; + super.setUp(); + + adapterOwner = makeAddr("VaultMigrationHelper Owner"); + complianceSigner = vm.addr(COMPLIANCE_SIGNER_KEY); + erc20TransferAmountEnforcer = new ERC20TransferAmountEnforcer(); + redeemerEnforcer = new RedeemerEnforcer(); + allowedCalldataEnforcer = new AllowedCalldataEnforcer(); + allowedTargetsEnforcer = new AllowedTargetsEnforcer(); + + baseAdapter = + new VedaAdapter(adapterOwner, address(delegationManager), address(BASE_VAULT), address(BASE_TELLER), address(MUSD)); + premiumAdapter = new ComplianceVedaAdapter( + adapterOwner, address(delegationManager), address(PREMIUM_VAULT), address(PREMIUM_TELLER), address(MUSD) + ); + migrationHelper = new VaultMigrationHelper(adapterOwner, address(baseAdapter), address(premiumAdapter)); + + _configureBaseAdapterRole(); + _configurePremiumForkRoles(); + _fundUser(users.alice); + _fundUser(users.carol); + + vm.label(address(MUSD), "mUSD"); + vm.label(address(BASE_VAULT), "Base mUSD Vault"); + vm.label(address(PREMIUM_VAULT), "Premium mUSD Vault"); + vm.label(address(BASE_TELLER), "Base Veda Teller"); + vm.label(address(PREMIUM_TELLER), "Premium Veda Teller"); + vm.label(address(ACCOUNTANT), "Base Accountant"); + vm.label(BASE_LENS, "Base Lens"); + vm.label(BUFFER_LENS, "Buffer Lens"); + vm.label(DEPLOYED_BASE_ADAPTER, "Deployed Base Adapter"); + vm.label(address(baseAdapter), "VedaAdapter"); + vm.label(address(premiumAdapter), "ComplianceVedaAdapter"); + vm.label(address(migrationHelper), "VaultMigrationHelper"); + vm.label(complianceSigner, "Compliance Signer"); + } + + function test_constructor_wiresAdaptersAndRevertsOnZeroAddresses() public { + assertEq(address(migrationHelper.baseAdapter()), address(baseAdapter)); + assertEq(address(migrationHelper.premiumAdapter()), address(premiumAdapter)); + assertEq(migrationHelper.owner(), adapterOwner); + + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableInvalidOwner.selector, address(0))); + new VaultMigrationHelper(address(0), address(baseAdapter), address(premiumAdapter)); + + vm.expectRevert(VaultMigrationHelper.InvalidZeroAddress.selector); + new VaultMigrationHelper(adapterOwner, address(0), address(premiumAdapter)); + + vm.expectRevert(VaultMigrationHelper.InvalidZeroAddress.selector); + new VaultMigrationHelper(adapterOwner, address(baseAdapter), address(0)); + } + + function test_lifecycle_depositAndWithdrawBaseThenPremium() public { + uint256 baseShares_ = _depositToBase(users.alice, DEPOSIT_AMOUNT, 0); + assertGt(baseShares_, 0); + assertEq(MUSD.balanceOf(address(users.alice.deleGator)), INITIAL_MUSD_BALANCE - DEPOSIT_AMOUNT); + _assertNoAdapterDust(); + + vm.warp(block.timestamp + SHARE_LOCK_SECONDS); + uint256 assetsOut_ = _withdrawFromBase(users.alice, baseShares_, 1); + assertEq(BASE_VAULT.balanceOf(address(users.alice.deleGator)), 0); + assertApproxEqAbs(assetsOut_, DEPOSIT_AMOUNT, DEPOSIT_AMOUNT / 100); + _assertNoAdapterDust(); + + uint256 premiumShares_ = _depositToPremium(users.alice, assetsOut_, 2, block.timestamp + 30 minutes); + assertGt(premiumShares_, 0); + assertEq(MUSD.balanceOf(address(users.alice.deleGator)), INITIAL_MUSD_BALANCE - DEPOSIT_AMOUNT); + _assertNoAdapterDust(); + + uint256 musdBeforePremiumWithdraw_ = MUSD.balanceOf(address(users.alice.deleGator)); + _withdrawFromPremium(users.alice, premiumShares_, 3); + assertEq(PREMIUM_VAULT.balanceOf(address(users.alice.deleGator)), 0); + assertGt(MUSD.balanceOf(address(users.alice.deleGator)), musdBeforePremiumWithdraw_); + _assertNoAdapterDust(); + } + + function test_migrateToPremium_movesSharesAndLeavesNoDust() public { + uint256 baseShares_ = _depositToBase(users.alice, DEPOSIT_AMOUNT, 10); + vm.warp(block.timestamp + SHARE_LOCK_SECONDS); + + uint256 quotedAssets_ = _quoteAssets(baseShares_); + uint256 musdBefore_ = MUSD.balanceOf(address(users.alice.deleGator)); + VaultMigrationHelper.ToPremiumParams memory params_ = + _toPremiumParams(users.alice, baseShares_, quotedAssets_, 11, quotedAssets_, 12, block.timestamp + 30 minutes); + + vm.expectEmit(true, false, false, false, address(migrationHelper)); + emit MigrationToPremiumExecuted(address(users.alice.deleGator), 0, 0); + vm.prank(address(users.bob.deleGator)); + migrationHelper.migrateToPremiumByDelegation(params_); + + assertEq(BASE_VAULT.balanceOf(address(users.alice.deleGator)), 0); + assertGt(PREMIUM_VAULT.balanceOf(address(users.alice.deleGator)), 0); + assertEq(MUSD.balanceOf(address(users.alice.deleGator)), musdBefore_); + _assertNoAdapterDust(); + } + + function test_migrateToBase_movesSharesAndLeavesNoDust() public { + uint256 premiumShares_ = _depositToPremium(users.alice, DEPOSIT_AMOUNT, 20, block.timestamp + 30 minutes); + uint256 quotedAssets_ = _quoteAssets(premiumShares_); + uint256 musdBefore_ = MUSD.balanceOf(address(users.alice.deleGator)); + VaultMigrationHelper.ToBaseParams memory params_ = _toBaseParams(users.alice, premiumShares_, 21, quotedAssets_, 22); + + vm.expectEmit(true, false, false, false, address(migrationHelper)); + emit MigrationToBaseExecuted(address(users.alice.deleGator), 0, 0); + vm.prank(address(users.bob.deleGator)); + migrationHelper.migrateToBaseByDelegation(params_); + + assertEq(PREMIUM_VAULT.balanceOf(address(users.alice.deleGator)), 0); + assertGt(BASE_VAULT.balanceOf(address(users.alice.deleGator)), 0); + assertEq(MUSD.balanceOf(address(users.alice.deleGator)), musdBefore_); + _assertNoAdapterDust(); + } + + function test_migrateToPremium_isPermissionless() public { + uint256 baseShares_ = _depositToBase(users.alice, DEPOSIT_AMOUNT, 30); + vm.warp(block.timestamp + SHARE_LOCK_SECONDS); + uint256 quotedAssets_ = _quoteAssets(baseShares_); + + vm.prank(address(users.carol.deleGator)); + migrationHelper.migrateToPremiumByDelegation( + _toPremiumParams(users.alice, baseShares_, quotedAssets_, 31, quotedAssets_, 32, block.timestamp + 30 minutes) + ); + + assertEq(BASE_VAULT.balanceOf(address(users.alice.deleGator)), 0); + assertGt(PREMIUM_VAULT.balanceOf(address(users.alice.deleGator)), 0); + assertEq(PREMIUM_VAULT.balanceOf(address(users.carol.deleGator)), 0); + _assertNoAdapterDust(); + } + + function test_migrateToPremium_revertsBothLegsWhenDepositFails() public { + uint256 baseShares_ = _depositToBase(users.alice, DEPOSIT_AMOUNT, 40); + vm.warp(block.timestamp + SHARE_LOCK_SECONDS); + uint256 quotedAssets_ = _quoteAssets(baseShares_); + uint256 musdBefore_ = MUSD.balanceOf(address(users.alice.deleGator)); + VaultMigrationHelper.ToPremiumParams memory params_ = + _toPremiumParams(users.alice, baseShares_, quotedAssets_, 41, quotedAssets_, 42, block.timestamp + 30 minutes); + params_.minimumMint = type(uint256).max; + + vm.prank(address(users.bob.deleGator)); + vm.expectRevert(); + migrationHelper.migrateToPremiumByDelegation(params_); + + assertEq(BASE_VAULT.balanceOf(address(users.alice.deleGator)), baseShares_); + assertEq(PREMIUM_VAULT.balanceOf(address(users.alice.deleGator)), 0); + assertEq(MUSD.balanceOf(address(users.alice.deleGator)), musdBefore_); + _assertNoAdapterDust(); + } + + function test_migrateToBase_revertsOnDelegatorMismatch() public { + uint256 aliceShares_ = _depositToPremium(users.alice, DEPOSIT_AMOUNT, 50, block.timestamp + 30 minutes); + uint256 quotedAssets_ = _quoteAssets(aliceShares_); + + VaultMigrationHelper.ToBaseParams memory params_ = VaultMigrationHelper.ToBaseParams({ + withdrawalDelegations: _createDelegationChain( + users.alice, address(premiumAdapter), address(PREMIUM_VAULT), aliceShares_, 51 + ), + minimumAssets: 0, + depositDelegations: _createDelegationChain(users.carol, address(baseAdapter), address(MUSD), quotedAssets_, 52), + minimumMint: 0 + }); + + vm.expectRevert(VaultMigrationHelper.DelegatorMismatch.selector); + migrationHelper.migrateToBaseByDelegation(params_); + + assertEq(PREMIUM_VAULT.balanceOf(address(users.alice.deleGator)), aliceShares_); + assertEq(BASE_VAULT.balanceOf(address(users.alice.deleGator)), 0); + assertEq(BASE_VAULT.balanceOf(address(users.carol.deleGator)), 0); + } + + function test_migrateToPremium_revertsOnShortDelegationChain() public { + uint256 baseShares_ = _depositToBase(users.alice, DEPOSIT_AMOUNT, 60); + vm.warp(block.timestamp + SHARE_LOCK_SECONDS); + uint256 quotedAssets_ = _quoteAssets(baseShares_); + VaultMigrationHelper.ToPremiumParams memory params_ = + _toPremiumParams(users.alice, baseShares_, quotedAssets_, 61, quotedAssets_, 62, block.timestamp + 30 minutes); + params_.withdrawalDelegations = new Delegation[](1); + + vm.expectRevert(VaultMigrationHelper.InvalidDelegationsLength.selector); + migrationHelper.migrateToPremiumByDelegation(params_); + + assertEq(BASE_VAULT.balanceOf(address(users.alice.deleGator)), baseShares_); + assertEq(PREMIUM_VAULT.balanceOf(address(users.alice.deleGator)), 0); + } + + function test_migrateToPremiumBatch_movesExactAmountsForEachDelegator() public { + uint256 aliceAmount_ = 300e6; + uint256 carolAmount_ = 400e6; + uint256 aliceShares_ = _depositToBase(users.alice, aliceAmount_, 70); + uint256 carolShares_ = _depositToBase(users.carol, carolAmount_, 71); + vm.warp(block.timestamp + SHARE_LOCK_SECONDS); + + uint256 aliceAssets_ = _quoteAssets(aliceShares_); + uint256 carolAssets_ = _quoteAssets(carolShares_); + VaultMigrationHelper.ToPremiumParams[] memory params_ = new VaultMigrationHelper.ToPremiumParams[](2); + params_[0] = _toPremiumParams(users.alice, aliceShares_, aliceAssets_, 72, aliceAssets_, 73, block.timestamp + 20 minutes); + params_[1] = _toPremiumParams(users.carol, carolShares_, carolAssets_, 74, carolAssets_, 75, block.timestamp + 21 minutes); + + vm.expectEmit(true, false, false, true, address(migrationHelper)); + emit BatchMigrationToPremiumExecuted(address(users.bob.deleGator), 2); + vm.prank(address(users.bob.deleGator)); + migrationHelper.migrateToPremiumByDelegationBatch(params_); + + assertEq(BASE_VAULT.balanceOf(address(users.alice.deleGator)), 0); + assertEq(BASE_VAULT.balanceOf(address(users.carol.deleGator)), 0); + assertGt(PREMIUM_VAULT.balanceOf(address(users.alice.deleGator)), 0); + assertGt(PREMIUM_VAULT.balanceOf(address(users.carol.deleGator)), 0); + _assertNoAdapterDust(); + } + + function test_migrateToBaseBatch_movesExactAmountsForEachDelegator() public { + uint256 aliceAmount_ = 250e6; + uint256 carolAmount_ = 350e6; + uint256 aliceShares_ = _depositToPremium(users.alice, aliceAmount_, 80, block.timestamp + 20 minutes); + uint256 carolShares_ = _depositToPremium(users.carol, carolAmount_, 81, block.timestamp + 21 minutes); + + uint256 aliceAssets_ = _quoteAssets(aliceShares_); + uint256 carolAssets_ = _quoteAssets(carolShares_); + VaultMigrationHelper.ToBaseParams[] memory params_ = new VaultMigrationHelper.ToBaseParams[](2); + params_[0] = _toBaseParams(users.alice, aliceShares_, 82, aliceAssets_, 83); + params_[1] = _toBaseParams(users.carol, carolShares_, 84, carolAssets_, 85); + + vm.expectEmit(true, false, false, true, address(migrationHelper)); + emit BatchMigrationToBaseExecuted(address(users.bob.deleGator), 2); + vm.prank(address(users.bob.deleGator)); + migrationHelper.migrateToBaseByDelegationBatch(params_); + + assertEq(PREMIUM_VAULT.balanceOf(address(users.alice.deleGator)), 0); + assertEq(PREMIUM_VAULT.balanceOf(address(users.carol.deleGator)), 0); + assertGt(BASE_VAULT.balanceOf(address(users.alice.deleGator)), 0); + assertGt(BASE_VAULT.balanceOf(address(users.carol.deleGator)), 0); + _assertNoAdapterDust(); + } + + function test_migrateToPremiumBatch_revertsAllWhenLaterStreamFails() public { + uint256 aliceShares_ = _depositToBase(users.alice, 300e6, 90); + uint256 carolShares_ = _depositToBase(users.carol, 400e6, 91); + vm.warp(block.timestamp + SHARE_LOCK_SECONDS); + + uint256 aliceAssets_ = _quoteAssets(aliceShares_); + uint256 carolAssets_ = _quoteAssets(carolShares_); + VaultMigrationHelper.ToPremiumParams[] memory params_ = new VaultMigrationHelper.ToPremiumParams[](2); + params_[0] = _toPremiumParams(users.alice, aliceShares_, aliceAssets_, 92, aliceAssets_, 93, block.timestamp + 20 minutes); + params_[1] = _toPremiumParams(users.carol, carolShares_, carolAssets_, 94, carolAssets_, 95, block.timestamp + 21 minutes); + params_[1].minimumMint = type(uint256).max; + + vm.prank(address(users.bob.deleGator)); + vm.expectRevert(); + migrationHelper.migrateToPremiumByDelegationBatch(params_); + + assertEq(BASE_VAULT.balanceOf(address(users.alice.deleGator)), aliceShares_); + assertEq(BASE_VAULT.balanceOf(address(users.carol.deleGator)), carolShares_); + assertEq(PREMIUM_VAULT.balanceOf(address(users.alice.deleGator)), 0); + assertEq(PREMIUM_VAULT.balanceOf(address(users.carol.deleGator)), 0); + } + + function test_emptyBatchesRevert() public { + VaultMigrationHelper.ToPremiumParams[] memory premiumParams_ = new VaultMigrationHelper.ToPremiumParams[](0); + VaultMigrationHelper.ToBaseParams[] memory baseParams_ = new VaultMigrationHelper.ToBaseParams[](0); + + vm.expectRevert(VaultMigrationHelper.InvalidBatchLength.selector); + migrationHelper.migrateToPremiumByDelegationBatch(premiumParams_); + + vm.expectRevert(VaultMigrationHelper.InvalidBatchLength.selector); + migrationHelper.migrateToBaseByDelegationBatch(baseParams_); + } + + function test_migrateToPremium_revertsOnExpiredCompliance() public { + uint256 baseShares_ = _depositToBase(users.alice, DEPOSIT_AMOUNT, 100); + vm.warp(block.timestamp + SHARE_LOCK_SECONDS); + uint256 quotedAssets_ = _quoteAssets(baseShares_); + VaultMigrationHelper.ToPremiumParams memory params_ = + _toPremiumParams(users.alice, baseShares_, quotedAssets_, 101, quotedAssets_, 102, block.timestamp - 1); + + vm.prank(address(users.bob.deleGator)); + vm.expectRevert(); + migrationHelper.migrateToPremiumByDelegation(params_); + + assertEq(BASE_VAULT.balanceOf(address(users.alice.deleGator)), baseShares_); + assertEq(PREMIUM_VAULT.balanceOf(address(users.alice.deleGator)), 0); + } + + function test_migrateToPremium_revertsOnWrongComplianceSigner() public { + uint256 baseShares_ = _depositToBase(users.alice, DEPOSIT_AMOUNT, 110); + vm.warp(block.timestamp + SHARE_LOCK_SECONDS); + uint256 quotedAssets_ = _quoteAssets(baseShares_); + VaultMigrationHelper.ToPremiumParams memory params_ = + _toPremiumParams(users.alice, baseShares_, quotedAssets_, 111, quotedAssets_, 112, block.timestamp + 30 minutes); + params_.compliance = IComplianceVedaTeller.ComplianceData({ + deadline: block.timestamp + 30 minutes, + signature: _signCompliance(users.alice, quotedAssets_, block.timestamp + 30 minutes, 0xBAD) + }); + + vm.prank(address(users.bob.deleGator)); + vm.expectRevert(); + migrationHelper.migrateToPremiumByDelegation(params_); + + assertEq(BASE_VAULT.balanceOf(address(users.alice.deleGator)), baseShares_); + } + + function test_migrateToPremium_revertsOnReplayedCompliance() public { + uint256 firstShares_ = _depositToBase(users.alice, DEPOSIT_AMOUNT, 120); + vm.warp(block.timestamp + SHARE_LOCK_SECONDS); + uint256 firstAssets_ = _quoteAssets(firstShares_); + uint256 deadline_ = block.timestamp + 30 minutes; + VaultMigrationHelper.ToPremiumParams memory firstParams_ = + _toPremiumParams(users.alice, firstShares_, firstAssets_, 121, firstAssets_, 122, deadline_); + + vm.prank(address(users.bob.deleGator)); + migrationHelper.migrateToPremiumByDelegation(firstParams_); + + uint256 secondShares_ = _depositToBase(users.alice, DEPOSIT_AMOUNT, 123); + vm.warp(block.timestamp + SHARE_LOCK_SECONDS); + uint256 secondAssets_ = _quoteAssets(secondShares_); + VaultMigrationHelper.ToPremiumParams memory replayParams_ = + _toPremiumParams(users.alice, secondShares_, secondAssets_, 124, secondAssets_, 125, deadline_ + 1 hours); + replayParams_.compliance = firstParams_.compliance; + + vm.prank(address(users.bob.deleGator)); + vm.expectRevert(); + migrationHelper.migrateToPremiumByDelegation(replayParams_); + } + + function test_migrateToPremium_revertsOnWithdrawSlippage() public { + uint256 baseShares_ = _depositToBase(users.alice, DEPOSIT_AMOUNT, 130); + vm.warp(block.timestamp + SHARE_LOCK_SECONDS); + uint256 quotedAssets_ = _quoteAssets(baseShares_); + VaultMigrationHelper.ToPremiumParams memory params_ = + _toPremiumParams(users.alice, baseShares_, quotedAssets_, 131, quotedAssets_, 132, block.timestamp + 30 minutes); + params_.minimumAssets = type(uint256).max; + + vm.prank(address(users.bob.deleGator)); + vm.expectRevert(); + migrationHelper.migrateToPremiumByDelegation(params_); + + assertEq(BASE_VAULT.balanceOf(address(users.alice.deleGator)), baseShares_); + } + + function test_migrateToPremium_revertsOnDelegationReplay() public { + uint256 baseShares_ = _depositToBase(users.alice, DEPOSIT_AMOUNT, 140); + vm.warp(block.timestamp + SHARE_LOCK_SECONDS); + uint256 quotedAssets_ = _quoteAssets(baseShares_); + VaultMigrationHelper.ToPremiumParams memory params_ = + _toPremiumParams(users.alice, baseShares_, quotedAssets_, 141, quotedAssets_, 142, block.timestamp + 30 minutes); + + vm.prank(address(users.bob.deleGator)); + migrationHelper.migrateToPremiumByDelegation(params_); + + vm.prank(address(users.bob.deleGator)); + vm.expectRevert(); + migrationHelper.migrateToPremiumByDelegation(params_); + } + + function test_premiumTransfer_movesFullBalanceToRecipient() public { + uint256 shares_ = _depositToPremium(users.alice, DEPOSIT_AMOUNT, 200, block.timestamp + 30 minutes); + address from_ = address(users.alice.deleGator); + address to_ = address(users.carol.deleGator); + Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, shares_, 201, address(migrationHelper)); + IComplianceVedaTeller.ComplianceData memory compliance_ = + _transferComplianceData(from_, to_, shares_, block.timestamp + 30 minutes, COMPLIANCE_SIGNER_KEY); + + vm.expectEmit(true, true, false, true, address(migrationHelper)); + emit PremiumTransferExecuted(from_, to_, shares_); + vm.prank(address(users.bob.deleGator)); + migrationHelper.premiumTransfer(from_, to_, delegations_, compliance_); + + assertEq(PREMIUM_VAULT.balanceOf(from_), 0); + assertEq(PREMIUM_VAULT.balanceOf(to_), shares_); + _assertNoAdapterDust(); + } + + function test_premiumTransfer_isPermissionless() public { + uint256 shares_ = _depositToPremium(users.alice, DEPOSIT_AMOUNT, 210, block.timestamp + 30 minutes); + address from_ = address(users.alice.deleGator); + address to_ = address(users.carol.deleGator); + Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, shares_, 211, address(migrationHelper)); + IComplianceVedaTeller.ComplianceData memory compliance_ = + _transferComplianceData(from_, to_, shares_, block.timestamp + 30 minutes, COMPLIANCE_SIGNER_KEY); + + vm.prank(address(users.carol.deleGator)); + migrationHelper.premiumTransfer(from_, to_, delegations_, compliance_); + + assertEq(PREMIUM_VAULT.balanceOf(from_), 0); + assertEq(PREMIUM_VAULT.balanceOf(to_), shares_); + _assertNoAdapterDust(); + } + + function test_premiumTransfer_revertsOnExpiredCompliance() public { + uint256 shares_ = _depositToPremium(users.alice, DEPOSIT_AMOUNT, 220, block.timestamp + 30 minutes); + address from_ = address(users.alice.deleGator); + address to_ = address(users.carol.deleGator); + Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, shares_, 221, address(migrationHelper)); + IComplianceVedaTeller.ComplianceData memory compliance_ = + _transferComplianceData(from_, to_, shares_, block.timestamp - 1, COMPLIANCE_SIGNER_KEY); + + vm.prank(address(users.bob.deleGator)); + vm.expectRevert(VaultMigrationHelper.ComplianceCheckFailed.selector); + migrationHelper.premiumTransfer(from_, to_, delegations_, compliance_); + + assertEq(PREMIUM_VAULT.balanceOf(from_), shares_); + } + + function test_premiumTransfer_revertsOnWrongComplianceSigner() public { + uint256 shares_ = _depositToPremium(users.alice, DEPOSIT_AMOUNT, 230, block.timestamp + 30 minutes); + address from_ = address(users.alice.deleGator); + address to_ = address(users.carol.deleGator); + Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, shares_, 231, address(migrationHelper)); + IComplianceVedaTeller.ComplianceData memory compliance_ = + _transferComplianceData(from_, to_, shares_, block.timestamp + 30 minutes, 0xBAD); + + vm.prank(address(users.bob.deleGator)); + vm.expectRevert(VaultMigrationHelper.ComplianceCheckFailed.selector); + migrationHelper.premiumTransfer(from_, to_, delegations_, compliance_); + } + + function test_premiumTransfer_revertsOnReplayedCompliance() public { + uint256 shares_ = _depositToPremium(users.alice, DEPOSIT_AMOUNT, 240, block.timestamp + 30 minutes); + address from_ = address(users.alice.deleGator); + address to_ = address(users.carol.deleGator); + uint256 deadline_ = block.timestamp + 30 minutes; + IComplianceVedaTeller.ComplianceData memory compliance_ = + _transferComplianceData(from_, to_, shares_, deadline_, COMPLIANCE_SIGNER_KEY); + Delegation[] memory firstDelegations_ = _createPremiumTransferChain(users.alice, shares_, 241, address(migrationHelper)); + + vm.prank(address(users.bob.deleGator)); + migrationHelper.premiumTransfer(from_, to_, firstDelegations_, compliance_); + + vm.startPrank(PREMIUM_ROLES_AUTHORITY.owner()); + PREMIUM_ROLES_AUTHORITY.setUserRole(to_, TRANSFER_ALLOWED_ROLE, true); + vm.stopPrank(); + vm.prank(to_); + PREMIUM_VAULT.transfer(from_, shares_); + Delegation[] memory secondDelegations_ = _createPremiumTransferChain(users.alice, shares_, 243, address(migrationHelper)); + + vm.prank(address(users.bob.deleGator)); + vm.expectRevert(VaultMigrationHelper.ComplianceCheckFailed.selector); + migrationHelper.premiumTransfer(from_, to_, secondDelegations_, compliance_); + } + + function test_premiumTransfer_revertsOnFromMismatch() public { + uint256 shares_ = _depositToPremium(users.alice, DEPOSIT_AMOUNT, 250, block.timestamp + 30 minutes); + address to_ = address(users.carol.deleGator); + Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, shares_, 251, address(migrationHelper)); + IComplianceVedaTeller.ComplianceData memory compliance_ = _transferComplianceData( + address(users.carol.deleGator), to_, shares_, block.timestamp + 30 minutes, COMPLIANCE_SIGNER_KEY + ); + + vm.expectRevert(VaultMigrationHelper.DelegatorMismatch.selector); + migrationHelper.premiumTransfer(address(users.carol.deleGator), to_, delegations_, compliance_); + } + + function test_premiumTransfer_revertsOnLeafAmountMismatch() public { + uint256 shares_ = _depositToPremium(users.alice, DEPOSIT_AMOUNT, 260, block.timestamp + 30 minutes); + address from_ = address(users.alice.deleGator); + address to_ = address(users.carol.deleGator); + Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, shares_ - 1, 261, address(migrationHelper)); + IComplianceVedaTeller.ComplianceData memory compliance_ = + _transferComplianceData(from_, to_, shares_, block.timestamp + 30 minutes, COMPLIANCE_SIGNER_KEY); + + vm.expectRevert(VaultMigrationHelper.InvalidTransferAmount.selector); + migrationHelper.premiumTransfer(from_, to_, delegations_, compliance_); + } + + function test_premiumTransfer_revertsOnShortDelegationChain() public { + uint256 shares_ = _depositToPremium(users.alice, DEPOSIT_AMOUNT, 270, block.timestamp + 30 minutes); + Delegation[] memory delegations_ = new Delegation[](1); + address from_ = address(users.alice.deleGator); + address to_ = address(users.carol.deleGator); + IComplianceVedaTeller.ComplianceData memory compliance_ = + _transferComplianceData(from_, to_, shares_, block.timestamp + 30 minutes, COMPLIANCE_SIGNER_KEY); + + vm.expectRevert(VaultMigrationHelper.InvalidDelegationsLength.selector); + migrationHelper.premiumTransfer(from_, to_, delegations_, compliance_); + } + + function test_premiumTransfer_revertsOnZeroTo() public { + uint256 shares_ = _depositToPremium(users.alice, DEPOSIT_AMOUNT, 280, block.timestamp + 30 minutes); + address from_ = address(users.alice.deleGator); + Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, shares_, 281, address(migrationHelper)); + IComplianceVedaTeller.ComplianceData memory compliance_ = + _transferComplianceData(from_, address(0), shares_, block.timestamp + 30 minutes, COMPLIANCE_SIGNER_KEY); + + vm.expectRevert(VaultMigrationHelper.InvalidZeroAddress.selector); + migrationHelper.premiumTransfer(from_, address(0), delegations_, compliance_); + } + + function test_premiumTransfer_revertsWhenHelperNotAllowlisted() public { + uint256 shares_ = _depositToPremium(users.alice, DEPOSIT_AMOUNT, 290, block.timestamp + 30 minutes); + address from_ = address(users.alice.deleGator); + address to_ = address(users.carol.deleGator); + Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, shares_, 291, address(migrationHelper)); + IComplianceVedaTeller.ComplianceData memory compliance_ = + _transferComplianceData(from_, to_, shares_, block.timestamp + 30 minutes, COMPLIANCE_SIGNER_KEY); + + vm.prank(PREMIUM_ROLES_AUTHORITY.owner()); + PREMIUM_ROLES_AUTHORITY.setUserRole(address(migrationHelper), TRANSFER_ALLOWED_ROLE, false); + + vm.prank(address(users.bob.deleGator)); + vm.expectRevert(); + migrationHelper.premiumTransfer(from_, to_, delegations_, compliance_); + + assertEq(PREMIUM_VAULT.balanceOf(from_), shares_); + } + + function test_premiumTransfer_revertsWhenCalldataToIsNotHelper() public { + uint256 shares_ = _depositToPremium(users.alice, DEPOSIT_AMOUNT, 300, block.timestamp + 30 minutes); + address from_ = address(users.alice.deleGator); + address to_ = address(users.carol.deleGator); + Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, shares_, 301, to_); + IComplianceVedaTeller.ComplianceData memory compliance_ = + _transferComplianceData(from_, to_, shares_, block.timestamp + 30 minutes, COMPLIANCE_SIGNER_KEY); + + vm.prank(address(users.bob.deleGator)); + vm.expectRevert("AllowedCalldataEnforcer:invalid-calldata"); + migrationHelper.premiumTransfer(from_, to_, delegations_, compliance_); + + assertEq(PREMIUM_VAULT.balanceOf(from_), shares_); + } + + function test_premiumTransfer_revertsOnDepositComplianceSignature() public { + uint256 shares_ = _depositToPremium(users.alice, DEPOSIT_AMOUNT, 310, block.timestamp + 30 minutes); + address from_ = address(users.alice.deleGator); + address to_ = address(users.carol.deleGator); + uint256 deadline_ = block.timestamp + 30 minutes; + Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, shares_, 311, address(migrationHelper)); + IComplianceVedaTeller.ComplianceData memory depositCompliance_ = IComplianceVedaTeller.ComplianceData({ + deadline: deadline_, signature: _signCompliance(users.alice, shares_, deadline_, COMPLIANCE_SIGNER_KEY) + }); + + vm.prank(address(users.bob.deleGator)); + vm.expectRevert(VaultMigrationHelper.ComplianceCheckFailed.selector); + migrationHelper.premiumTransfer(from_, to_, delegations_, depositCompliance_); + + assertEq(PREMIUM_VAULT.balanceOf(from_), shares_); + } + + function test_premiumTransfer_revertsWhenComplianceDisabled() public { + uint256 shares_ = _depositToPremium(users.alice, DEPOSIT_AMOUNT, 320, block.timestamp + 30 minutes); + address from_ = address(users.alice.deleGator); + address to_ = address(users.carol.deleGator); + Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, shares_, 321, address(migrationHelper)); + IComplianceVedaTeller.ComplianceData memory compliance_ = + _transferComplianceData(from_, to_, shares_, block.timestamp + 30 minutes, COMPLIANCE_SIGNER_KEY); + + vm.mockCall( + address(PREMIUM_TELLER), + abi.encodeWithSelector(IComplianceVedaTeller.complianceSignerRole.selector), + abi.encode(type(uint8).max) + ); + + vm.prank(address(users.bob.deleGator)); + vm.expectRevert(VaultMigrationHelper.ComplianceDisabled.selector); + migrationHelper.premiumTransfer(from_, to_, delegations_, compliance_); + + assertEq(PREMIUM_VAULT.balanceOf(from_), shares_); + vm.clearMockedCalls(); + } + + function test_withdrawEmergency_revertsOnNonOwner() public { + BasicERC20 testToken_ = new BasicERC20(adapterOwner, "TestToken", "TST", 0); + vm.prank(adapterOwner); + testToken_.mint(address(migrationHelper), 100 ether); + + vm.prank(address(users.alice.deleGator)); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, address(users.alice.deleGator))); + migrationHelper.withdrawEmergency(testToken_, 50 ether, address(users.alice.deleGator)); + + assertEq(testToken_.balanceOf(address(migrationHelper)), 100 ether); + } + + function test_withdrawEmergency_recoverTokens() public { + BasicERC20 testToken_ = new BasicERC20(adapterOwner, "TestToken", "TST", 0); + vm.prank(adapterOwner); + testToken_.mint(address(migrationHelper), 100 ether); + + vm.expectEmit(true, true, false, true, address(migrationHelper)); + emit StuckTokensWithdrawn(testToken_, address(users.alice.deleGator), 50 ether); + + vm.prank(adapterOwner); + migrationHelper.withdrawEmergency(testToken_, 50 ether, address(users.alice.deleGator)); + + assertEq(testToken_.balanceOf(address(migrationHelper)), 50 ether); + assertEq(testToken_.balanceOf(address(users.alice.deleGator)), 50 ether); + } + + function test_withdrawEmergency_revertsOnZeroRecipient() public { + BasicERC20 testToken_ = new BasicERC20(adapterOwner, "TestToken", "TST", 0); + vm.prank(adapterOwner); + testToken_.mint(address(migrationHelper), 100 ether); + + vm.expectRevert(VaultMigrationHelper.InvalidRecipient.selector); + vm.prank(adapterOwner); + migrationHelper.withdrawEmergency(testToken_, 50 ether, address(0)); + } + + function _configureBaseAdapterRole() internal { + vm.prank(BASE_ROLES_AUTHORITY.owner()); + BASE_ROLES_AUTHORITY.setUserRole(address(baseAdapter), BASE_ADAPTER_ROLE, true); + } + + function _configurePremiumForkRoles() internal { + address authorityOwner_ = PREMIUM_ROLES_AUTHORITY.owner(); + address tellerOwner_ = ITellerConfiguration(address(PREMIUM_TELLER)).owner(); + assertEq(authorityOwner_, tellerOwner_); + + vm.startPrank(authorityOwner_); + PREMIUM_ROLES_AUTHORITY.setUserRole(complianceSigner, COMPLIANCE_SIGNER_ROLE, true); + PREMIUM_ROLES_AUTHORITY.setUserRole(address(premiumAdapter), TRANSFER_ALLOWED_ROLE, true); + PREMIUM_ROLES_AUTHORITY.setUserRole(address(migrationHelper), TRANSFER_ALLOWED_ROLE, true); + PREMIUM_ROLES_AUTHORITY.setUserRole(address(premiumAdapter), TEST_ROUTER_ROLE, true); + PREMIUM_ROLES_AUTHORITY.setRoleCapability( + TEST_ROUTER_ROLE, address(PREMIUM_TELLER), IComplianceVedaTeller.withdraw.selector, true + ); + ITellerConfiguration(address(PREMIUM_TELLER)).setTransferRestrictions(TRANSFER_ALLOWED_ROLE, TEST_ROUTER_ROLE); + vm.stopPrank(); + } + + function _fundUser(TestUser memory _user) internal { + vm.prank(MUSD_WHALE); + require(MUSD.transfer(address(_user.deleGator), INITIAL_MUSD_BALANCE), "mUSD funding failed"); + } + + function _quoteAssets(uint256 _shares) internal view returns (uint256) { + return _shares * ACCOUNTANT.getRateInQuoteSafe(address(MUSD)) / (10 ** ACCOUNTANT.decimals()); + } + + function _depositToBase(TestUser memory _delegator, uint256 _amount, uint256 _salt) internal returns (uint256 shares_) { + uint256 sharesBefore_ = BASE_VAULT.balanceOf(address(_delegator.deleGator)); + Delegation[] memory delegations_ = _createDelegationChain(_delegator, address(baseAdapter), address(MUSD), _amount, _salt); + + vm.prank(address(users.bob.deleGator)); + baseAdapter.depositByDelegation(delegations_, 0); + shares_ = BASE_VAULT.balanceOf(address(_delegator.deleGator)) - sharesBefore_; + } + + function _withdrawFromBase( + TestUser memory _delegator, + uint256 _shareAmount, + uint256 _salt + ) + internal + returns (uint256 assetsOut_) + { + uint256 musdBefore_ = MUSD.balanceOf(address(_delegator.deleGator)); + Delegation[] memory delegations_ = + _createDelegationChain(_delegator, address(baseAdapter), address(BASE_VAULT), _shareAmount, _salt); + + vm.prank(address(users.bob.deleGator)); + baseAdapter.withdrawByDelegation(delegations_, 0); + assetsOut_ = MUSD.balanceOf(address(_delegator.deleGator)) - musdBefore_; + } + + function _depositToPremium( + TestUser memory _delegator, + uint256 _amount, + uint256 _salt, + uint256 _deadline + ) + internal + returns (uint256 shares_) + { + uint256 sharesBefore_ = PREMIUM_VAULT.balanceOf(address(_delegator.deleGator)); + Delegation[] memory delegations_ = + _createDelegationChain(_delegator, address(premiumAdapter), address(MUSD), _amount, _salt); + IComplianceVedaTeller.ComplianceData memory compliance_ = _complianceData(_delegator, _amount, _deadline); + + vm.prank(address(users.bob.deleGator)); + premiumAdapter.depositByDelegation(delegations_, 0, compliance_); + shares_ = PREMIUM_VAULT.balanceOf(address(_delegator.deleGator)) - sharesBefore_; + } + + function _withdrawFromPremium(TestUser memory _delegator, uint256 _shareAmount, uint256 _salt) internal { + Delegation[] memory delegations_ = + _createDelegationChain(_delegator, address(premiumAdapter), address(PREMIUM_VAULT), _shareAmount, _salt); + + vm.prank(address(users.bob.deleGator)); + premiumAdapter.withdrawByDelegation(delegations_, 0); + } + + function _toPremiumParams( + TestUser memory _delegator, + uint256 _shareAmount, + uint256 _assetAmount, + uint256 _withdrawalSalt, + uint256 _depositAmount, + uint256 _depositSalt, + uint256 _deadline + ) + internal + view + returns (VaultMigrationHelper.ToPremiumParams memory) + { + return VaultMigrationHelper.ToPremiumParams({ + withdrawalDelegations: _createDelegationChain( + _delegator, address(baseAdapter), address(BASE_VAULT), _shareAmount, _withdrawalSalt + ), + minimumAssets: 0, + depositDelegations: _createDelegationChain( + _delegator, address(premiumAdapter), address(MUSD), _depositAmount, _depositSalt + ), + minimumMint: 0, + compliance: _complianceData(_delegator, _assetAmount, _deadline) + }); + } + + function _toBaseParams( + TestUser memory _delegator, + uint256 _shareAmount, + uint256 _withdrawalSalt, + uint256 _depositAmount, + uint256 _depositSalt + ) + internal + view + returns (VaultMigrationHelper.ToBaseParams memory) + { + return VaultMigrationHelper.ToBaseParams({ + withdrawalDelegations: _createDelegationChain( + _delegator, address(premiumAdapter), address(PREMIUM_VAULT), _shareAmount, _withdrawalSalt + ), + minimumAssets: 0, + depositDelegations: _createDelegationChain( + _delegator, address(baseAdapter), address(MUSD), _depositAmount, _depositSalt + ), + minimumMint: 0 + }); + } + + function _createDelegationChain( + TestUser memory _delegator, + address _adapter, + address _token, + uint256 _amount, + uint256 _salt + ) + internal + view + returns (Delegation[] memory delegations_) + { + Delegation memory root_ = _createTransferDelegation(_delegator, _adapter, _token, type(uint256).max, _salt); + bytes32 authority_ = EncoderLib._getDelegationHash(root_); + Delegation memory leaf_ = _createAdapterRedelegation(_adapter, authority_, _token, _amount, _salt); + + delegations_ = new Delegation[](2); + delegations_[0] = leaf_; + delegations_[1] = root_; + } + + function _createTransferDelegation( + TestUser memory _delegator, + address _adapter, + 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(_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( + address _adapter, + 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: _adapter, + delegator: address(users.bob.deleGator), + authority: _authority, + caveats: caveats_, + salt: _salt, + signature: hex"" + }); + return signDelegation(users.bob, delegation_); + } + + function _createPremiumTransferChain( + TestUser memory _delegator, + uint256 _amount, + uint256 _salt, + address _calldataTo + ) + internal + view + returns (Delegation[] memory delegations_) + { + Caveat[] memory rootCaveats_ = new Caveat[](4); + rootCaveats_[0] = Caveat({ + args: hex"", + enforcer: address(erc20TransferAmountEnforcer), + terms: abi.encodePacked(address(PREMIUM_VAULT), type(uint256).max) + }); + rootCaveats_[1] = + Caveat({ args: hex"", enforcer: address(redeemerEnforcer), terms: abi.encodePacked(address(migrationHelper)) }); + rootCaveats_[2] = Caveat({ + args: hex"", enforcer: address(allowedCalldataEnforcer), terms: abi.encodePacked(uint256(4), abi.encode(_calldataTo)) + }); + rootCaveats_[3] = + Caveat({ args: hex"", enforcer: address(allowedTargetsEnforcer), terms: abi.encodePacked(address(PREMIUM_VAULT)) }); + + Delegation memory root_ = Delegation({ + delegate: address(users.bob.deleGator), + delegator: address(_delegator.deleGator), + authority: ROOT_AUTHORITY, + caveats: rootCaveats_, + salt: _salt, + signature: hex"" + }); + root_ = signDelegation(_delegator, root_); + + Caveat[] memory leafCaveats_ = new Caveat[](1); + leafCaveats_[0] = Caveat({ + args: hex"", enforcer: address(erc20TransferAmountEnforcer), terms: abi.encodePacked(address(PREMIUM_VAULT), _amount) + }); + + Delegation memory leaf_ = Delegation({ + delegate: address(migrationHelper), + delegator: address(users.bob.deleGator), + authority: EncoderLib._getDelegationHash(root_), + caveats: leafCaveats_, + salt: _salt, + signature: hex"" + }); + leaf_ = signDelegation(users.bob, leaf_); + + delegations_ = new Delegation[](2); + delegations_[0] = leaf_; + delegations_[1] = root_; + } + + function _transferComplianceData( + address _from, + address _to, + uint256 _amount, + uint256 _deadline, + uint256 _signerKey + ) + internal + view + returns (IComplianceVedaTeller.ComplianceData memory) + { + bytes32 messageHash_ = keccak256( + abi.encode( + address(migrationHelper), + address(PREMIUM_TELLER), + block.chainid, + _from, + _to, + address(PREMIUM_VAULT), + _amount, + _deadline + ) + ); + bytes32 ethSignedMessageHash_ = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash_)); + (uint8 v_, bytes32 r_, bytes32 s_) = vm.sign(_signerKey, ethSignedMessageHash_); + return IComplianceVedaTeller.ComplianceData({ deadline: _deadline, signature: abi.encodePacked(r_, s_, v_) }); + } + + function _complianceData( + TestUser memory _delegator, + uint256 _amount, + uint256 _deadline + ) + internal + view + returns (IComplianceVedaTeller.ComplianceData memory) + { + return IComplianceVedaTeller.ComplianceData({ + deadline: _deadline, signature: _signCompliance(_delegator, _amount, _deadline, COMPLIANCE_SIGNER_KEY) + }); + } + + function _signCompliance( + TestUser memory _delegator, + uint256 _amount, + uint256 _deadline, + uint256 _signerKey + ) + internal + view + returns (bytes memory) + { + bytes32 messageHash_ = keccak256( + abi.encode( + address(PREMIUM_TELLER), + block.chainid, + address(premiumAdapter), + address(_delegator.deleGator), + 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_); + } + + function _assertNoAdapterDust() internal { + assertEq(MUSD.balanceOf(address(baseAdapter)), 0); + assertEq(MUSD.balanceOf(address(premiumAdapter)), 0); + assertEq(MUSD.balanceOf(address(migrationHelper)), 0); + assertEq(BASE_VAULT.balanceOf(address(baseAdapter)), 0); + assertEq(PREMIUM_VAULT.balanceOf(address(premiumAdapter)), 0); + assertEq(BASE_VAULT.balanceOf(address(migrationHelper)), 0); + assertEq(PREMIUM_VAULT.balanceOf(address(migrationHelper)), 0); + } +} From 6ae5ae06d3236f3e5365568e5432f961214824a7 Mon Sep 17 00:00:00 2001 From: MoMannn Date: Thu, 24 Sep 2026 15:23:50 +0200 Subject: [PATCH 04/14] add batch premium transfer --- src/helpers/VaultMigrationHelper.sol | 96 ++++++++++++++++++++----- test/helpers/VaultMigrationHelper.t.sol | 68 ++++++++++++++++++ 2 files changed, 145 insertions(+), 19 deletions(-) diff --git a/src/helpers/VaultMigrationHelper.sol b/src/helpers/VaultMigrationHelper.sol index de9fefe4..e1ca9cb3 100644 --- a/src/helpers/VaultMigrationHelper.sol +++ b/src/helpers/VaultMigrationHelper.sol @@ -65,6 +65,20 @@ contract VaultMigrationHelper is Ownable2Step { uint256 minimumMint; } + /** + * @notice Parameters for one compliance-gated premium share transfer. + * @param from Share owner and root delegator. + * @param to Compliance-approved recipient of the premium shares. + * @param delegations Chain sorted leaf to root. Leaf `delegate` must be this helper. + * @param compliance Backend-issued EIP-191 approval (`deadline` + `signature`). + */ + struct PremiumTransferParams { + address from; + address to; + Delegation[] delegations; + IComplianceVedaTeller.ComplianceData compliance; + } + ////////////////////////////// Events ////////////////////////////// /** @@ -105,6 +119,13 @@ contract VaultMigrationHelper is Ownable2Step { */ event PremiumTransferExecuted(address indexed from, address indexed to, uint256 amount); + /** + * @notice Emitted after a batch of premium share transfers completes. + * @param caller Address that submitted the permissionless batch. + * @param count Number of transfers executed. + */ + event BatchPremiumTransferExecuted(address indexed caller, uint256 count); + /** * @notice Emitted when stuck tokens are withdrawn by owner. * @param token Address of the token withdrawn. @@ -124,7 +145,7 @@ contract VaultMigrationHelper is Ownable2Step { /// @dev Thrown when either delegation chain contains fewer than two delegations. error InvalidDelegationsLength(); - /// @dev Thrown when a migration batch is empty. + /// @dev Thrown when a batch is empty. error InvalidBatchLength(); /// @dev Thrown when the withdrawal and deposit chains have different root delegators. @@ -264,6 +285,61 @@ contract VaultMigrationHelper is Ownable2Step { IComplianceVedaTeller.ComplianceData calldata _compliance ) external + { + _premiumTransfer(_from, _to, _delegations, _compliance); + } + + /** + * @notice Moves multiple delegators' entire premium vault balances sequentially in one atomic transaction. + * @dev A revert from any transfer rolls back the entire batch. Each stream needs its own compliance + * signature because the digest includes `from`, `to`, amount, and deadline. + * @param _params Transfer parameters for each stream. + * @notice Security consideration: Callable by anyone. Every delegation chain and compliance signature + * remains independently enforced. + */ + function premiumTransferBatch(PremiumTransferParams[] calldata _params) external { + uint256 length_ = _params.length; + if (length_ == 0) revert InvalidBatchLength(); + + for (uint256 i = 0; i < length_;) { + PremiumTransferParams calldata params_ = _params[i]; + _premiumTransfer(params_.from, params_.to, params_.delegations, params_.compliance); + unchecked { + ++i; + } + } + + emit BatchPremiumTransferExecuted(msg.sender, length_); + } + + /** + * @notice Emergency function to recover tokens accidentally sent to this contract. + * @dev Migrations should not leave balances here. `premiumTransfer` only holds shares mid-transaction. + * 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 Executes one compliance-gated premium share transfer. + */ + function _premiumTransfer( + address _from, + address _to, + Delegation[] calldata _delegations, + IComplianceVedaTeller.ComplianceData calldata _compliance + ) + internal { if (_from == address(0) || _to == address(0)) revert InvalidZeroAddress(); @@ -295,24 +371,6 @@ contract VaultMigrationHelper is Ownable2Step { emit PremiumTransferExecuted(_from, _to, amount_); } - /** - * @notice Emergency function to recover tokens accidentally sent to this contract. - * @dev Migrations should not leave balances here. `premiumTransfer` only holds shares mid-transaction. - * 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 Executes one base-to-premium migration. * @param _params Migration parameters forwarded to the base and premium adapters. diff --git a/test/helpers/VaultMigrationHelper.t.sol b/test/helpers/VaultMigrationHelper.t.sol index cf489194..16761913 100644 --- a/test/helpers/VaultMigrationHelper.t.sol +++ b/test/helpers/VaultMigrationHelper.t.sol @@ -77,6 +77,7 @@ contract VaultMigrationHelperTest is BaseTest { event BatchMigrationToPremiumExecuted(address indexed caller, uint256 count); event BatchMigrationToBaseExecuted(address indexed caller, uint256 count); event PremiumTransferExecuted(address indexed from, address indexed to, uint256 amount); + event BatchPremiumTransferExecuted(address indexed caller, uint256 count); event StuckTokensWithdrawn(IERC20 indexed token, address indexed recipient, uint256 amount); function setUp() public override { @@ -347,6 +348,10 @@ contract VaultMigrationHelperTest is BaseTest { vm.expectRevert(VaultMigrationHelper.InvalidBatchLength.selector); migrationHelper.migrateToBaseByDelegationBatch(baseParams_); + + VaultMigrationHelper.PremiumTransferParams[] memory transferParams_ = new VaultMigrationHelper.PremiumTransferParams[](0); + vm.expectRevert(VaultMigrationHelper.InvalidBatchLength.selector); + migrationHelper.premiumTransferBatch(transferParams_); } function test_migrateToPremium_revertsOnExpiredCompliance() public { @@ -640,6 +645,49 @@ contract VaultMigrationHelperTest is BaseTest { vm.clearMockedCalls(); } + function test_premiumTransferBatch_movesEachDelegatorToRecipient() public { + uint256 aliceShares_ = _depositToPremium(users.alice, 300e6, 330, block.timestamp + 20 minutes); + uint256 carolShares_ = _depositToPremium(users.carol, 400e6, 331, block.timestamp + 21 minutes); + address recipient_ = makeAddr("premiumTransferRecipient"); + address alice_ = address(users.alice.deleGator); + address carol_ = address(users.carol.deleGator); + + VaultMigrationHelper.PremiumTransferParams[] memory params_ = new VaultMigrationHelper.PremiumTransferParams[](2); + params_[0] = _premiumTransferParams(users.alice, aliceShares_, 332, recipient_, block.timestamp + 20 minutes); + params_[1] = _premiumTransferParams(users.carol, carolShares_, 333, recipient_, block.timestamp + 21 minutes); + + vm.expectEmit(true, false, false, true, address(migrationHelper)); + emit BatchPremiumTransferExecuted(address(users.bob.deleGator), 2); + vm.prank(address(users.bob.deleGator)); + migrationHelper.premiumTransferBatch(params_); + + assertEq(PREMIUM_VAULT.balanceOf(alice_), 0); + assertEq(PREMIUM_VAULT.balanceOf(carol_), 0); + assertEq(PREMIUM_VAULT.balanceOf(recipient_), aliceShares_ + carolShares_); + _assertNoAdapterDust(); + } + + function test_premiumTransferBatch_revertsAllWhenLaterStreamFails() public { + uint256 aliceShares_ = _depositToPremium(users.alice, 300e6, 340, block.timestamp + 20 minutes); + uint256 carolShares_ = _depositToPremium(users.carol, 400e6, 341, block.timestamp + 21 minutes); + address recipient_ = makeAddr("premiumTransferRecipient"); + address alice_ = address(users.alice.deleGator); + address carol_ = address(users.carol.deleGator); + + VaultMigrationHelper.PremiumTransferParams[] memory params_ = new VaultMigrationHelper.PremiumTransferParams[](2); + params_[0] = _premiumTransferParams(users.alice, aliceShares_, 342, recipient_, block.timestamp + 20 minutes); + params_[1] = _premiumTransferParams(users.carol, carolShares_, 343, recipient_, block.timestamp - 1); + + vm.prank(address(users.bob.deleGator)); + vm.expectRevert(VaultMigrationHelper.ComplianceCheckFailed.selector); + migrationHelper.premiumTransferBatch(params_); + + assertEq(PREMIUM_VAULT.balanceOf(alice_), aliceShares_); + assertEq(PREMIUM_VAULT.balanceOf(carol_), carolShares_); + assertEq(PREMIUM_VAULT.balanceOf(recipient_), 0); + _assertNoAdapterDust(); + } + function test_withdrawEmergency_revertsOnNonOwner() public { BasicERC20 testToken_ = new BasicERC20(adapterOwner, "TestToken", "TST", 0); vm.prank(adapterOwner); @@ -810,6 +858,26 @@ contract VaultMigrationHelperTest is BaseTest { }); } + function _premiumTransferParams( + TestUser memory _delegator, + uint256 _amount, + uint256 _salt, + address _to, + uint256 _deadline + ) + internal + view + returns (VaultMigrationHelper.PremiumTransferParams memory) + { + address from_ = address(_delegator.deleGator); + return VaultMigrationHelper.PremiumTransferParams({ + from: from_, + to: _to, + delegations: _createPremiumTransferChain(_delegator, _amount, _salt, address(migrationHelper)), + compliance: _transferComplianceData(from_, _to, _amount, _deadline, COMPLIANCE_SIGNER_KEY) + }); + } + function _createDelegationChain( TestUser memory _delegator, address _adapter, From 914377c82fbc54a3a16618ffb4df35d347095122 Mon Sep 17 00:00:00 2001 From: MoMannn Date: Fri, 25 Sep 2026 15:11:17 +0200 Subject: [PATCH 05/14] add user signature for premium transfer --- src/helpers/VaultMigrationHelper.sol | 144 ++++++++++++++++-------- test/helpers/VaultMigrationHelper.t.sol | 125 +++++++++++++++++--- 2 files changed, 204 insertions(+), 65 deletions(-) diff --git a/src/helpers/VaultMigrationHelper.sol b/src/helpers/VaultMigrationHelper.sol index e1ca9cb3..226af202 100644 --- a/src/helpers/VaultMigrationHelper.sol +++ b/src/helpers/VaultMigrationHelper.sol @@ -6,6 +6,7 @@ import { SafeERC20 } from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.s import { Ownable2Step, Ownable } from "@openzeppelin/contracts/access/Ownable2Step.sol"; import { ECDSA } from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol"; import { MessageHashUtils } from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; +import { SignatureChecker } from "@openzeppelin/contracts/utils/cryptography/SignatureChecker.sol"; import { ModeLib } from "@erc7579/lib/ModeLib.sol"; import { ExecutionLib } from "@erc7579/lib/ExecutionLib.sol"; @@ -18,12 +19,35 @@ import { Delegation, ModeCode } from "../utils/Types.sol"; /** * @title VaultMigrationHelper * @notice Atomically composes the existing base and premium Veda adapters, and moves premium shares with - * a compliance signature. + * dual destination control (user + compliance backend). * @dev Vault-to-vault migrations keep delegations targeted at the adapter that redeems them. Premium share * transfers are redeemed by this helper: the chain must pin `transfer` `to` to this contract and name * this contract as redeemer. This helper must hold `transferAllowedRole` on the premium Teller so Veda * allows the first hop (helper is `to`) and the second hop (helper is `from`/`operator`). * + * Destination control for `premiumTransfer` is dual, not backend-only and not user-only: + * - The share owner (`from`) picks the recipient and ERC-1271-signs the destination digest below. + * That locks `to` to this exact leaf so a caller or backend cannot redirect the second hop. + * - The compliance backend still EIP-191-signs a digest that includes the same `to`, proving the + * recipient is premium-enabled / KYC'd. A user cannot send to an unapproved address even if they + * signed it. + * Neither party can complete a transfer to an address the other did not attest. Amount remains the + * full premium balance at execution (leaf terms). First hop stays helper-only via caveats. + * + * Signing schemes — wallets and CHOMP must implement both. They are not interchangeable: + * 1. Delegation chain (root + leaf): EIP-712 typed data through DelegationManager + * (`toTypedDataHash(domainHash, EncoderLib._getDelegationHash(delegation))`). The share owner + * signs the root; CHOMP (root `delegate`) signs the leaf. + * 2. Destination proof (`userSignature`): EIP-191 `personal_sign`, not EIP-712. Inner hash: + * `keccak256(abi.encode(address(this), block.chainid, from, to, delegations[0].signature))`. + * Signed payload: `keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", inner))`. + * Verified with ERC-1271 on `from` via SignatureChecker. `delegations[0].signature` is already + * in calldata; binding it ties `to` to this leaf without hashing the full delegation. + * 3. Compliance (`compliance.signature`): also EIP-191 `personal_sign`. Inner hash: + * `keccak256(abi.encode(address(this), teller, block.chainid, from, to, vault, amount, deadline))`. + * Same prefix as (2) and as Teller deposits, but the inner tuple starts with this helper so a + * Teller deposit signature cannot be reused. + * * Leaf Caveat Format: * - For `premiumTransfer`, the first caveat of the leaf delegation (`_delegations[0].caveats[0]`) must * follow the ERC20TransferAmountEnforcer terms format: abi.encodePacked(address token, uint256 amount) @@ -66,17 +90,19 @@ contract VaultMigrationHelper is Ownable2Step { } /** - * @notice Parameters for one compliance-gated premium share transfer. + * @notice Parameters for one dual-signed premium share transfer. * @param from Share owner and root delegator. - * @param to Compliance-approved recipient of the premium shares. + * @param to Recipient chosen by `from` and attested as premium-enabled by the compliance backend. * @param delegations Chain sorted leaf to root. Leaf `delegate` must be this helper. - * @param compliance Backend-issued EIP-191 approval (`deadline` + `signature`). + * @param compliance Backend-issued EIP-191 approval (`deadline` + `signature`) that includes `to`. + * @param userSignature EIP-191 `personal_sign` from `from` (not EIP-712). Exact inner hash is documented on the contract. */ struct PremiumTransferParams { address from; address to; Delegation[] delegations; IComplianceVedaTeller.ComplianceData compliance; + bytes userSignature; } ////////////////////////////// Events ////////////////////////////// @@ -112,9 +138,9 @@ contract VaultMigrationHelper is Ownable2Step { event BatchMigrationToBaseExecuted(address indexed caller, uint256 count); /** - * @notice Emitted after a compliance-gated premium share transfer. + * @notice Emitted after a dual-signed premium share transfer. * @param from Share owner whose full premium balance was moved. - * @param to Compliance-approved recipient. + * @param to Recipient chosen by `from` and attested as premium-enabled by the compliance backend. * @param amount Number of premium vault shares transferred. */ event PremiumTransferExecuted(address indexed from, address indexed to, uint256 amount); @@ -163,6 +189,9 @@ contract VaultMigrationHelper is Ownable2Step { /// @dev Thrown when the premium Teller has compliance signer checks disabled (`complianceSignerRole == 255`). error ComplianceDisabled(); + /// @dev Thrown when the share owner's destination signature is missing or does not match from, to, and leaf. + error InvalidUserSignature(); + ////////////////////////////// State ////////////////////////////// /** @@ -262,48 +291,39 @@ contract VaultMigrationHelper is Ownable2Step { } /** - * @notice Moves `from`'s entire premium vault balance to `to` after a compliance signature check. + * @notice Moves `from`'s entire premium vault balance to `to` after user and compliance checks. * @dev The delegation chain must be redeemable only by this helper and must pin `transfer` `to` to this * helper. Production must grant this helper `transferAllowedRole` on the premium Teller. * The transfer amount is parsed from the first caveat of the leaf delegation - * (`_delegations[0].caveats[0].terms`), which must follow the ERC20TransferAmountEnforcer + * (`delegations[0].caveats[0].terms`), which must follow the ERC20TransferAmountEnforcer * format: abi.encodePacked(address token, uint256 amount). - * @param _from Share owner and root delegator. - * @param _to Compliance-approved recipient of the premium shares. - * @param _delegations Chain sorted leaf to root. Leaf `delegate` must be this helper. - * @param _compliance Backend-issued EIP-191 approval (`deadline` + `signature`). - * @notice Security consideration: Callable by anyone. The compliance signature is bound to this helper, - * the premium Teller, `from`, `to`, the premium vault, the full share balance, and the deadline. + * @param _params From, to, chain, compliance, and user destination signature. + * @notice Security consideration: Callable by anyone. Destination is dual-controlled: the user signature + * binds `to` to this helper, chain, `from`, and this leaf; the compliance signature still includes `to` + * so only a premium-enabled recipient can receive. Delegations are EIP-712; `userSignature` and + * compliance are EIP-191 `personal_sign` over the exact inner hashes documented on the contract. * A Teller deposit signature cannot be reused because it binds the Teller address, not this helper. * The redelegation MUST include an `ERC20TransferAmountEnforcer` as its first caveat (`caveats[0]`), * capped to exactly the owner's full premium balance. Reverts when Teller compliance is disabled. */ - function premiumTransfer( - address _from, - address _to, - Delegation[] calldata _delegations, - IComplianceVedaTeller.ComplianceData calldata _compliance - ) - external - { - _premiumTransfer(_from, _to, _delegations, _compliance); + function premiumTransfer(PremiumTransferParams calldata _params) external { + _premiumTransfer(_params); } /** * @notice Moves multiple delegators' entire premium vault balances sequentially in one atomic transaction. - * @dev A revert from any transfer rolls back the entire batch. Each stream needs its own compliance - * signature because the digest includes `from`, `to`, amount, and deadline. + * @dev A revert from any transfer rolls back the entire batch. Each stream needs its own user signature + * (destination) and compliance signature (premium-enabled `to`) because both digests include `from`/`to`. * @param _params Transfer parameters for each stream. - * @notice Security consideration: Callable by anyone. Every delegation chain and compliance signature - * remains independently enforced. + * @notice Security consideration: Callable by anyone. Every delegation chain, user destination signature, + * and compliance signature remains independently enforced. */ function premiumTransferBatch(PremiumTransferParams[] calldata _params) external { uint256 length_ = _params.length; if (length_ == 0) revert InvalidBatchLength(); for (uint256 i = 0; i < length_;) { - PremiumTransferParams calldata params_ = _params[i]; - _premiumTransfer(params_.from, params_.to, params_.delegations, params_.compliance); + _premiumTransfer(_params[i]); unchecked { ++i; } @@ -331,32 +351,29 @@ contract VaultMigrationHelper is Ownable2Step { ////////////////////////////// Private/Internal Methods ////////////////////////////// /** - * @notice Executes one compliance-gated premium share transfer. + * @notice Executes one dual-signed premium share transfer. + * @dev User destination signature is checked before the backend compliance signature and before redeem. */ - function _premiumTransfer( - address _from, - address _to, - Delegation[] calldata _delegations, - IComplianceVedaTeller.ComplianceData calldata _compliance - ) - internal - { - if (_from == address(0) || _to == address(0)) revert InvalidZeroAddress(); + function _premiumTransfer(PremiumTransferParams calldata _params) internal { + address from_ = _params.from; + address to_ = _params.to; + if (from_ == address(0) || to_ == address(0)) revert InvalidZeroAddress(); - uint256 length_ = _delegations.length; + uint256 length_ = _params.delegations.length; if (length_ < 2) revert InvalidDelegationsLength(); - if (_delegations[length_ - 1].delegator != _from) revert DelegatorMismatch(); + if (_params.delegations[length_ - 1].delegator != from_) revert DelegatorMismatch(); IERC20 vault_ = IERC20(premiumAdapter.boringVault()); - uint256 amount_ = vault_.balanceOf(_from); - if (amount_ == 0 || amount_ != _parseERC20TransferTerms(_delegations[0].caveats[0].terms)) { + uint256 amount_ = vault_.balanceOf(from_); + if (amount_ == 0 || amount_ != _parseERC20TransferTerms(_params.delegations[0].caveats[0].terms)) { revert InvalidTransferAmount(); } - _verifyTransferCompliance(_from, _to, address(vault_), amount_, _compliance); + _verifyUserTransferSignature(from_, to_, _params.delegations[0].signature, _params.userSignature); + _verifyTransferCompliance(from_, to_, address(vault_), amount_, _params.compliance); bytes[] memory permissionContexts_ = new bytes[](1); - permissionContexts_[0] = abi.encode(_delegations); + permissionContexts_[0] = abi.encode(_params.delegations); ModeCode[] memory encodedModes_ = new ModeCode[](1); encodedModes_[0] = ModeLib.encodeSimpleSingle(); @@ -366,9 +383,9 @@ contract VaultMigrationHelper is Ownable2Step { ExecutionLib.encodeSingle(address(vault_), 0, abi.encodeCall(IERC20.transfer, (address(this), amount_))); premiumAdapter.delegationManager().redeemDelegations(permissionContexts_, encodedModes_, executionCallDatas_); - vault_.safeTransfer(_to, amount_); + vault_.safeTransfer(to_, amount_); - emit PremiumTransferExecuted(_from, _to, amount_); + emit PremiumTransferExecuted(from_, to_, amount_); } /** @@ -442,10 +459,39 @@ contract VaultMigrationHelper is Ownable2Step { amount_ = uint256(bytes32(_terms[20:52])); } + /** + * @notice Verifies that `from` authorized this exact destination for this leaf. + * @dev Destination proof is EIP-191 `personal_sign`, not the EIP-712 scheme used for delegations. + * Inner: `keccak256(abi.encode(address(this), block.chainid, from, to, leaf.signature))`. + * Signed: `toEthSignedMessageHash(inner)` == `"\x19Ethereum Signed Message:\n32" || inner`. + * Verified with ERC-1271 on `from` via SignatureChecker so a backend cannot swap `to` after + * the user signed. Binding `_delegations[0].signature` (already in calldata) ties `to` to this + * leaf without hashing the full delegation on-chain. + */ + function _verifyUserTransferSignature( + address _from, + address _to, + bytes calldata _leafSignature, + bytes calldata _userSignature + ) + private + view + { + bytes32 messageHash_ = keccak256(abi.encode(address(this), block.chainid, _from, _to, _leafSignature)); + bytes32 ethSignedHash_ = MessageHashUtils.toEthSignedMessageHash(messageHash_); + if (!SignatureChecker.isValidSignatureNow(_from, ethSignedHash_, _userSignature)) { + revert InvalidUserSignature(); + } + } + /** * @notice Verifies a transfer compliance signature using the Teller's signer role and window. - * @dev The digest starts with this helper's address so it cannot equal a Teller deposit hash. - * Reverts if the Teller has disabled compliance (`complianceSignerRole == 255`). + * @dev Destination is not user-only: this digest still includes `_to` so the backend attests that + * the recipient is premium-enabled / KYC'd. A user-signed `to` that the backend did not approve + * cannot receive. EIP-191 `personal_sign` (same prefix as `userSignature` and Teller deposits): + * inner `keccak256(abi.encode(address(this), teller, chainId, from, to, vault, amount, deadline))`. + * Starts with this helper so it cannot equal a Teller deposit hash. Reverts if the Teller has + * disabled compliance (`complianceSignerRole == 255`). */ function _verifyTransferCompliance( address _from, diff --git a/test/helpers/VaultMigrationHelper.t.sol b/test/helpers/VaultMigrationHelper.t.sol index 16761913..33e9ac29 100644 --- a/test/helpers/VaultMigrationHelper.t.sol +++ b/test/helpers/VaultMigrationHelper.t.sol @@ -447,11 +447,12 @@ contract VaultMigrationHelperTest is BaseTest { Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, shares_, 201, address(migrationHelper)); IComplianceVedaTeller.ComplianceData memory compliance_ = _transferComplianceData(from_, to_, shares_, block.timestamp + 30 minutes, COMPLIANCE_SIGNER_KEY); + bytes memory userSignature_ = _userTransferSignature(users.alice, to_, delegations_); vm.expectEmit(true, true, false, true, address(migrationHelper)); emit PremiumTransferExecuted(from_, to_, shares_); vm.prank(address(users.bob.deleGator)); - migrationHelper.premiumTransfer(from_, to_, delegations_, compliance_); + _callPremiumTransfer(from_, to_, delegations_, compliance_, userSignature_); assertEq(PREMIUM_VAULT.balanceOf(from_), 0); assertEq(PREMIUM_VAULT.balanceOf(to_), shares_); @@ -465,9 +466,10 @@ contract VaultMigrationHelperTest is BaseTest { Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, shares_, 211, address(migrationHelper)); IComplianceVedaTeller.ComplianceData memory compliance_ = _transferComplianceData(from_, to_, shares_, block.timestamp + 30 minutes, COMPLIANCE_SIGNER_KEY); + bytes memory userSignature_ = _userTransferSignature(users.alice, to_, delegations_); vm.prank(address(users.carol.deleGator)); - migrationHelper.premiumTransfer(from_, to_, delegations_, compliance_); + _callPremiumTransfer(from_, to_, delegations_, compliance_, userSignature_); assertEq(PREMIUM_VAULT.balanceOf(from_), 0); assertEq(PREMIUM_VAULT.balanceOf(to_), shares_); @@ -481,10 +483,11 @@ contract VaultMigrationHelperTest is BaseTest { Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, shares_, 221, address(migrationHelper)); IComplianceVedaTeller.ComplianceData memory compliance_ = _transferComplianceData(from_, to_, shares_, block.timestamp - 1, COMPLIANCE_SIGNER_KEY); + bytes memory userSignature_ = _userTransferSignature(users.alice, to_, delegations_); vm.prank(address(users.bob.deleGator)); vm.expectRevert(VaultMigrationHelper.ComplianceCheckFailed.selector); - migrationHelper.premiumTransfer(from_, to_, delegations_, compliance_); + _callPremiumTransfer(from_, to_, delegations_, compliance_, userSignature_); assertEq(PREMIUM_VAULT.balanceOf(from_), shares_); } @@ -496,10 +499,11 @@ contract VaultMigrationHelperTest is BaseTest { Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, shares_, 231, address(migrationHelper)); IComplianceVedaTeller.ComplianceData memory compliance_ = _transferComplianceData(from_, to_, shares_, block.timestamp + 30 minutes, 0xBAD); + bytes memory userSignature_ = _userTransferSignature(users.alice, to_, delegations_); vm.prank(address(users.bob.deleGator)); vm.expectRevert(VaultMigrationHelper.ComplianceCheckFailed.selector); - migrationHelper.premiumTransfer(from_, to_, delegations_, compliance_); + _callPremiumTransfer(from_, to_, delegations_, compliance_, userSignature_); } function test_premiumTransfer_revertsOnReplayedCompliance() public { @@ -510,9 +514,10 @@ contract VaultMigrationHelperTest is BaseTest { IComplianceVedaTeller.ComplianceData memory compliance_ = _transferComplianceData(from_, to_, shares_, deadline_, COMPLIANCE_SIGNER_KEY); Delegation[] memory firstDelegations_ = _createPremiumTransferChain(users.alice, shares_, 241, address(migrationHelper)); + bytes memory firstUserSignature_ = _userTransferSignature(users.alice, to_, firstDelegations_); vm.prank(address(users.bob.deleGator)); - migrationHelper.premiumTransfer(from_, to_, firstDelegations_, compliance_); + _callPremiumTransfer(from_, to_, firstDelegations_, compliance_, firstUserSignature_); vm.startPrank(PREMIUM_ROLES_AUTHORITY.owner()); PREMIUM_ROLES_AUTHORITY.setUserRole(to_, TRANSFER_ALLOWED_ROLE, true); @@ -520,10 +525,11 @@ contract VaultMigrationHelperTest is BaseTest { vm.prank(to_); PREMIUM_VAULT.transfer(from_, shares_); Delegation[] memory secondDelegations_ = _createPremiumTransferChain(users.alice, shares_, 243, address(migrationHelper)); + bytes memory secondUserSignature_ = _userTransferSignature(users.alice, to_, secondDelegations_); vm.prank(address(users.bob.deleGator)); vm.expectRevert(VaultMigrationHelper.ComplianceCheckFailed.selector); - migrationHelper.premiumTransfer(from_, to_, secondDelegations_, compliance_); + _callPremiumTransfer(from_, to_, secondDelegations_, compliance_, secondUserSignature_); } function test_premiumTransfer_revertsOnFromMismatch() public { @@ -535,7 +541,7 @@ contract VaultMigrationHelperTest is BaseTest { ); vm.expectRevert(VaultMigrationHelper.DelegatorMismatch.selector); - migrationHelper.premiumTransfer(address(users.carol.deleGator), to_, delegations_, compliance_); + _callPremiumTransfer(address(users.carol.deleGator), to_, delegations_, compliance_, hex""); } function test_premiumTransfer_revertsOnLeafAmountMismatch() public { @@ -547,7 +553,7 @@ contract VaultMigrationHelperTest is BaseTest { _transferComplianceData(from_, to_, shares_, block.timestamp + 30 minutes, COMPLIANCE_SIGNER_KEY); vm.expectRevert(VaultMigrationHelper.InvalidTransferAmount.selector); - migrationHelper.premiumTransfer(from_, to_, delegations_, compliance_); + _callPremiumTransfer(from_, to_, delegations_, compliance_, hex""); } function test_premiumTransfer_revertsOnShortDelegationChain() public { @@ -559,7 +565,7 @@ contract VaultMigrationHelperTest is BaseTest { _transferComplianceData(from_, to_, shares_, block.timestamp + 30 minutes, COMPLIANCE_SIGNER_KEY); vm.expectRevert(VaultMigrationHelper.InvalidDelegationsLength.selector); - migrationHelper.premiumTransfer(from_, to_, delegations_, compliance_); + _callPremiumTransfer(from_, to_, delegations_, compliance_, hex""); } function test_premiumTransfer_revertsOnZeroTo() public { @@ -570,7 +576,7 @@ contract VaultMigrationHelperTest is BaseTest { _transferComplianceData(from_, address(0), shares_, block.timestamp + 30 minutes, COMPLIANCE_SIGNER_KEY); vm.expectRevert(VaultMigrationHelper.InvalidZeroAddress.selector); - migrationHelper.premiumTransfer(from_, address(0), delegations_, compliance_); + _callPremiumTransfer(from_, address(0), delegations_, compliance_, hex""); } function test_premiumTransfer_revertsWhenHelperNotAllowlisted() public { @@ -580,13 +586,14 @@ contract VaultMigrationHelperTest is BaseTest { Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, shares_, 291, address(migrationHelper)); IComplianceVedaTeller.ComplianceData memory compliance_ = _transferComplianceData(from_, to_, shares_, block.timestamp + 30 minutes, COMPLIANCE_SIGNER_KEY); + bytes memory userSignature_ = _userTransferSignature(users.alice, to_, delegations_); vm.prank(PREMIUM_ROLES_AUTHORITY.owner()); PREMIUM_ROLES_AUTHORITY.setUserRole(address(migrationHelper), TRANSFER_ALLOWED_ROLE, false); vm.prank(address(users.bob.deleGator)); vm.expectRevert(); - migrationHelper.premiumTransfer(from_, to_, delegations_, compliance_); + _callPremiumTransfer(from_, to_, delegations_, compliance_, userSignature_); assertEq(PREMIUM_VAULT.balanceOf(from_), shares_); } @@ -598,10 +605,11 @@ contract VaultMigrationHelperTest is BaseTest { Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, shares_, 301, to_); IComplianceVedaTeller.ComplianceData memory compliance_ = _transferComplianceData(from_, to_, shares_, block.timestamp + 30 minutes, COMPLIANCE_SIGNER_KEY); + bytes memory userSignature_ = _userTransferSignature(users.alice, to_, delegations_); vm.prank(address(users.bob.deleGator)); vm.expectRevert("AllowedCalldataEnforcer:invalid-calldata"); - migrationHelper.premiumTransfer(from_, to_, delegations_, compliance_); + _callPremiumTransfer(from_, to_, delegations_, compliance_, userSignature_); assertEq(PREMIUM_VAULT.balanceOf(from_), shares_); } @@ -615,10 +623,11 @@ contract VaultMigrationHelperTest is BaseTest { IComplianceVedaTeller.ComplianceData memory depositCompliance_ = IComplianceVedaTeller.ComplianceData({ deadline: deadline_, signature: _signCompliance(users.alice, shares_, deadline_, COMPLIANCE_SIGNER_KEY) }); + bytes memory userSignature_ = _userTransferSignature(users.alice, to_, delegations_); vm.prank(address(users.bob.deleGator)); vm.expectRevert(VaultMigrationHelper.ComplianceCheckFailed.selector); - migrationHelper.premiumTransfer(from_, to_, delegations_, depositCompliance_); + _callPremiumTransfer(from_, to_, delegations_, depositCompliance_, userSignature_); assertEq(PREMIUM_VAULT.balanceOf(from_), shares_); } @@ -630,6 +639,7 @@ contract VaultMigrationHelperTest is BaseTest { Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, shares_, 321, address(migrationHelper)); IComplianceVedaTeller.ComplianceData memory compliance_ = _transferComplianceData(from_, to_, shares_, block.timestamp + 30 minutes, COMPLIANCE_SIGNER_KEY); + bytes memory userSignature_ = _userTransferSignature(users.alice, to_, delegations_); vm.mockCall( address(PREMIUM_TELLER), @@ -639,12 +649,60 @@ contract VaultMigrationHelperTest is BaseTest { vm.prank(address(users.bob.deleGator)); vm.expectRevert(VaultMigrationHelper.ComplianceDisabled.selector); - migrationHelper.premiumTransfer(from_, to_, delegations_, compliance_); + _callPremiumTransfer(from_, to_, delegations_, compliance_, userSignature_); assertEq(PREMIUM_VAULT.balanceOf(from_), shares_); vm.clearMockedCalls(); } + function test_premiumTransfer_revertsOnUserSignatureForDifferentTo() public { + uint256 shares_ = _depositToPremium(users.alice, DEPOSIT_AMOUNT, 350, block.timestamp + 30 minutes); + address from_ = address(users.alice.deleGator); + address to_ = address(users.carol.deleGator); + address otherTo_ = makeAddr("otherPremiumRecipient"); + Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, shares_, 351, address(migrationHelper)); + IComplianceVedaTeller.ComplianceData memory compliance_ = + _transferComplianceData(from_, to_, shares_, block.timestamp + 30 minutes, COMPLIANCE_SIGNER_KEY); + bytes memory userSignature_ = _userTransferSignature(users.alice, otherTo_, delegations_); + + vm.expectRevert(VaultMigrationHelper.InvalidUserSignature.selector); + _callPremiumTransfer(from_, to_, delegations_, compliance_, userSignature_); + + assertEq(PREMIUM_VAULT.balanceOf(from_), shares_); + } + + function test_premiumTransfer_revertsOnUserSignatureForDifferentLeaf() public { + uint256 shares_ = _depositToPremium(users.alice, DEPOSIT_AMOUNT, 360, block.timestamp + 30 minutes); + address from_ = address(users.alice.deleGator); + address to_ = address(users.carol.deleGator); + Delegation[] memory signedDelegations_ = _createPremiumTransferChain(users.alice, shares_, 361, address(migrationHelper)); + Delegation[] memory usedDelegations_ = _createPremiumTransferChain(users.alice, shares_, 362, address(migrationHelper)); + IComplianceVedaTeller.ComplianceData memory compliance_ = + _transferComplianceData(from_, to_, shares_, block.timestamp + 30 minutes, COMPLIANCE_SIGNER_KEY); + bytes memory userSignature_ = _userTransferSignature(users.alice, to_, signedDelegations_); + + vm.expectRevert(VaultMigrationHelper.InvalidUserSignature.selector); + _callPremiumTransfer(from_, to_, usedDelegations_, compliance_, userSignature_); + + assertEq(PREMIUM_VAULT.balanceOf(from_), shares_); + } + + function test_premiumTransfer_revertsOnComplianceSignatureForDifferentTo() public { + uint256 shares_ = _depositToPremium(users.alice, DEPOSIT_AMOUNT, 370, block.timestamp + 30 minutes); + address from_ = address(users.alice.deleGator); + address to_ = address(users.carol.deleGator); + address otherTo_ = makeAddr("unapprovedPremiumRecipient"); + Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, shares_, 371, address(migrationHelper)); + IComplianceVedaTeller.ComplianceData memory compliance_ = + _transferComplianceData(from_, otherTo_, shares_, block.timestamp + 30 minutes, COMPLIANCE_SIGNER_KEY); + bytes memory userSignature_ = _userTransferSignature(users.alice, to_, delegations_); + + vm.expectRevert(VaultMigrationHelper.ComplianceCheckFailed.selector); + _callPremiumTransfer(from_, to_, delegations_, compliance_, userSignature_); + + assertEq(PREMIUM_VAULT.balanceOf(from_), shares_); + } + function test_premiumTransferBatch_movesEachDelegatorToRecipient() public { uint256 aliceShares_ = _depositToPremium(users.alice, 300e6, 330, block.timestamp + 20 minutes); uint256 carolShares_ = _depositToPremium(users.carol, 400e6, 331, block.timestamp + 21 minutes); @@ -858,6 +916,22 @@ contract VaultMigrationHelperTest is BaseTest { }); } + function _callPremiumTransfer( + address _from, + address _to, + Delegation[] memory _delegations, + IComplianceVedaTeller.ComplianceData memory _compliance, + bytes memory _userSignature + ) + internal + { + migrationHelper.premiumTransfer( + VaultMigrationHelper.PremiumTransferParams({ + from: _from, to: _to, delegations: _delegations, compliance: _compliance, userSignature: _userSignature + }) + ); + } + function _premiumTransferParams( TestUser memory _delegator, uint256 _amount, @@ -870,11 +944,13 @@ contract VaultMigrationHelperTest is BaseTest { returns (VaultMigrationHelper.PremiumTransferParams memory) { address from_ = address(_delegator.deleGator); + Delegation[] memory delegations_ = _createPremiumTransferChain(_delegator, _amount, _salt, address(migrationHelper)); return VaultMigrationHelper.PremiumTransferParams({ from: from_, to: _to, - delegations: _createPremiumTransferChain(_delegator, _amount, _salt, address(migrationHelper)), - compliance: _transferComplianceData(from_, _to, _amount, _deadline, COMPLIANCE_SIGNER_KEY) + delegations: delegations_, + compliance: _transferComplianceData(from_, _to, _amount, _deadline, COMPLIANCE_SIGNER_KEY), + userSignature: _userTransferSignature(_delegator, _to, delegations_) }); } @@ -1005,6 +1081,23 @@ contract VaultMigrationHelperTest is BaseTest { delegations_[1] = root_; } + function _userTransferSignature( + TestUser memory _delegator, + address _to, + Delegation[] memory _delegations + ) + internal + view + returns (bytes memory) + { + bytes32 messageHash_ = keccak256( + abi.encode(address(migrationHelper), block.chainid, address(_delegator.deleGator), _to, _delegations[0].signature) + ); + bytes32 ethSignedMessageHash_ = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash_)); + // Destination proof is EIP-191 personal_sign (same prefix as compliance), not EIP-712 like the chain. + return signHash(_delegator, ethSignedMessageHash_); + } + function _transferComplianceData( address _from, address _to, From 0844476ecf376e7846c8a27ee6ed996e93f6adcf Mon Sep 17 00:00:00 2001 From: MoMannn Date: Fri, 25 Sep 2026 15:28:57 +0200 Subject: [PATCH 06/14] add deploy command --- script/DeployVaultMigrationHelper.s.sol | 3 +++ 1 file changed, 3 insertions(+) diff --git a/script/DeployVaultMigrationHelper.s.sol b/script/DeployVaultMigrationHelper.s.sol index a296fb24..17c5cc27 100644 --- a/script/DeployVaultMigrationHelper.s.sol +++ b/script/DeployVaultMigrationHelper.s.sol @@ -9,6 +9,9 @@ import { VaultMigrationHelper } from "../src/helpers/VaultMigrationHelper.sol"; /** * @title DeployVaultMigrationHelper * @notice Deploys VaultMigrationHelper deterministically with CREATE2. + * @dev Fill the required variables in the .env file + * @dev run the script with: + * forge script script/DeployVaultMigrationHelper.s.sol --rpc-url --private-key $PRIVATE_KEY --broadcast */ contract DeployVaultMigrationHelper is Script { bytes32 internal salt; From 9811b367e668a5961bab23c94654c37cdbc4fc06 Mon Sep 17 00:00:00 2001 From: MoMannn Date: Mon, 5 Oct 2026 11:37:43 +0200 Subject: [PATCH 07/14] verify user signature agains user delegation --- src/helpers/VaultMigrationHelper.sol | 33 ++++++++++++++----------- test/helpers/VaultMigrationHelper.t.sol | 10 ++++++-- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/src/helpers/VaultMigrationHelper.sol b/src/helpers/VaultMigrationHelper.sol index 226af202..533588ab 100644 --- a/src/helpers/VaultMigrationHelper.sol +++ b/src/helpers/VaultMigrationHelper.sol @@ -27,7 +27,7 @@ import { Delegation, ModeCode } from "../utils/Types.sol"; * * Destination control for `premiumTransfer` is dual, not backend-only and not user-only: * - The share owner (`from`) picks the recipient and ERC-1271-signs the destination digest below. - * That locks `to` to this exact leaf so a caller or backend cannot redirect the second hop. + * That locks `to` to the user's root delegation so a caller or backend cannot redirect the second hop. * - The compliance backend still EIP-191-signs a digest that includes the same `to`, proving the * recipient is premium-enabled / KYC'd. A user cannot send to an unapproved address even if they * signed it. @@ -39,10 +39,12 @@ import { Delegation, ModeCode } from "../utils/Types.sol"; * (`toTypedDataHash(domainHash, EncoderLib._getDelegationHash(delegation))`). The share owner * signs the root; CHOMP (root `delegate`) signs the leaf. * 2. Destination proof (`userSignature`): EIP-191 `personal_sign`, not EIP-712. Inner hash: - * `keccak256(abi.encode(address(this), block.chainid, from, to, delegations[0].signature))`. + * `keccak256(abi.encode(address(this), block.chainid, from, to, delegations[length - 1].signature))`. * Signed payload: `keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", inner))`. - * Verified with ERC-1271 on `from` via SignatureChecker. `delegations[0].signature` is already - * in calldata; binding it ties `to` to this leaf without hashing the full delegation. + * Verified with ERC-1271 on `from` via SignatureChecker. The chain is leaf-to-root, so + * `delegations[length - 1].signature` is the root the share owner signed (the initial transfer + * validation), not the leaf redelegation. Binding that signature ties `to` to this root without + * hashing the full delegation. * 3. Compliance (`compliance.signature`): also EIP-191 `personal_sign`. Inner hash: * `keccak256(abi.encode(address(this), teller, block.chainid, from, to, vault, amount, deadline))`. * Same prefix as (2) and as Teller deposits, but the inner tuple starts with this helper so a @@ -299,9 +301,10 @@ contract VaultMigrationHelper is Ownable2Step { * format: abi.encodePacked(address token, uint256 amount). * @param _params From, to, chain, compliance, and user destination signature. * @notice Security consideration: Callable by anyone. Destination is dual-controlled: the user signature - * binds `to` to this helper, chain, `from`, and this leaf; the compliance signature still includes `to` - * so only a premium-enabled recipient can receive. Delegations are EIP-712; `userSignature` and - * compliance are EIP-191 `personal_sign` over the exact inner hashes documented on the contract. + * binds `to` to this helper, chain, `from`, and the root delegation the user signed; the compliance + * signature still includes `to` so only a premium-enabled recipient can receive. Delegations are EIP-712; + * `userSignature` and compliance are EIP-191 `personal_sign` over the exact inner hashes documented + * on the contract. * A Teller deposit signature cannot be reused because it binds the Teller address, not this helper. * The redelegation MUST include an `ERC20TransferAmountEnforcer` as its first caveat (`caveats[0]`), * capped to exactly the owner's full premium balance. Reverts when Teller compliance is disabled. @@ -369,7 +372,7 @@ contract VaultMigrationHelper is Ownable2Step { revert InvalidTransferAmount(); } - _verifyUserTransferSignature(from_, to_, _params.delegations[0].signature, _params.userSignature); + _verifyUserTransferSignature(from_, to_, _params.delegations[length_ - 1].signature, _params.userSignature); _verifyTransferCompliance(from_, to_, address(vault_), amount_, _params.compliance); bytes[] memory permissionContexts_ = new bytes[](1); @@ -460,24 +463,26 @@ contract VaultMigrationHelper is Ownable2Step { } /** - * @notice Verifies that `from` authorized this exact destination for this leaf. + * @notice Verifies that `from` authorized this exact destination for their root delegation. * @dev Destination proof is EIP-191 `personal_sign`, not the EIP-712 scheme used for delegations. - * Inner: `keccak256(abi.encode(address(this), block.chainid, from, to, leaf.signature))`. + * Inner: `keccak256(abi.encode(address(this), block.chainid, from, to, root.signature))`. * Signed: `toEthSignedMessageHash(inner)` == `"\x19Ethereum Signed Message:\n32" || inner`. * Verified with ERC-1271 on `from` via SignatureChecker so a backend cannot swap `to` after - * the user signed. Binding `_delegations[0].signature` (already in calldata) ties `to` to this - * leaf without hashing the full delegation on-chain. + * the user signed. The chain is sorted leaf to root, so the bound signature is + * `delegations[length - 1].signature`: the user's initial transfer validation, not the + * redelegation at `delegations[0]`. Binding that signature ties `to` to this root without + * hashing the full delegation on-chain. */ function _verifyUserTransferSignature( address _from, address _to, - bytes calldata _leafSignature, + bytes calldata _rootSignature, bytes calldata _userSignature ) private view { - bytes32 messageHash_ = keccak256(abi.encode(address(this), block.chainid, _from, _to, _leafSignature)); + bytes32 messageHash_ = keccak256(abi.encode(address(this), block.chainid, _from, _to, _rootSignature)); bytes32 ethSignedHash_ = MessageHashUtils.toEthSignedMessageHash(messageHash_); if (!SignatureChecker.isValidSignatureNow(_from, ethSignedHash_, _userSignature)) { revert InvalidUserSignature(); diff --git a/test/helpers/VaultMigrationHelper.t.sol b/test/helpers/VaultMigrationHelper.t.sol index 33e9ac29..b5c70bab 100644 --- a/test/helpers/VaultMigrationHelper.t.sol +++ b/test/helpers/VaultMigrationHelper.t.sol @@ -671,7 +671,7 @@ contract VaultMigrationHelperTest is BaseTest { assertEq(PREMIUM_VAULT.balanceOf(from_), shares_); } - function test_premiumTransfer_revertsOnUserSignatureForDifferentLeaf() public { + function test_premiumTransfer_revertsOnUserSignatureForDifferentRoot() public { uint256 shares_ = _depositToPremium(users.alice, DEPOSIT_AMOUNT, 360, block.timestamp + 30 minutes); address from_ = address(users.alice.deleGator); address to_ = address(users.carol.deleGator); @@ -1091,7 +1091,13 @@ contract VaultMigrationHelperTest is BaseTest { returns (bytes memory) { bytes32 messageHash_ = keccak256( - abi.encode(address(migrationHelper), block.chainid, address(_delegator.deleGator), _to, _delegations[0].signature) + abi.encode( + address(migrationHelper), + block.chainid, + address(_delegator.deleGator), + _to, + _delegations[_delegations.length - 1].signature + ) ); bytes32 ethSignedMessageHash_ = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash_)); // Destination proof is EIP-191 personal_sign (same prefix as compliance), not EIP-712 like the chain. From 16962d4f1713e612fe504a9caceeda8a4cd76a24 Mon Sep 17 00:00:00 2001 From: MoMannn Date: Mon, 5 Oct 2026 11:50:42 +0200 Subject: [PATCH 08/14] gas optimization via caveats --- src/helpers/VaultMigrationHelper.sol | 63 +++++++------------- test/helpers/VaultMigrationHelper.t.sol | 77 +++++++++++-------------- 2 files changed, 54 insertions(+), 86 deletions(-) diff --git a/src/helpers/VaultMigrationHelper.sol b/src/helpers/VaultMigrationHelper.sol index 533588ab..46f6ac7c 100644 --- a/src/helpers/VaultMigrationHelper.sol +++ b/src/helpers/VaultMigrationHelper.sol @@ -21,9 +21,14 @@ import { Delegation, ModeCode } from "../utils/Types.sol"; * @notice Atomically composes the existing base and premium Veda adapters, and moves premium shares with * dual destination control (user + compliance backend). * @dev Vault-to-vault migrations keep delegations targeted at the adapter that redeems them. Premium share - * transfers are redeemed by this helper: the chain must pin `transfer` `to` to this contract and name - * this contract as redeemer. This helper must hold `transferAllowedRole` on the premium Teller so Veda - * allows the first hop (helper is `to`) and the second hop (helper is `from`/`operator`). + * transfers are redeemed by this helper. The user's root must: + * - pin the redeemed call with `AllowedCalldataEnforcer` terms + * `abi.encodePacked(uint256(0), IERC20.transfer.selector, abi.encode(address(this)))`, so the call is + * `transfer` to this helper and the amount stays unset on the root; + * - name the premium vault in `AllowedTargetsEnforcer`; + * - name this contract in `RedeemerEnforcer`. + * The root does not use `ERC20TransferAmountEnforcer`. This helper must hold `transferAllowedRole` on the + * premium Teller so Veda allows the first hop (helper is `to`) and the second hop (helper is `from`/`operator`). * * Destination control for `premiumTransfer` is dual, not backend-only and not user-only: * - The share owner (`from`) picks the recipient and ERC-1271-signs the destination digest below. @@ -31,8 +36,10 @@ import { Delegation, ModeCode } from "../utils/Types.sol"; * - The compliance backend still EIP-191-signs a digest that includes the same `to`, proving the * recipient is premium-enabled / KYC'd. A user cannot send to an unapproved address even if they * signed it. - * Neither party can complete a transfer to an address the other did not attest. Amount remains the - * full premium balance at execution (leaf terms). First hop stays helper-only via caveats. + * Neither party can complete a transfer to an address the other did not attest. The amount is the + * owner's full premium balance at execution (`balanceOf`). It is not read from a leaf caveat. + * The compliance digest includes that amount, so the backend still attests the quantity moved. + * First hop stays helper-only via the root caveats above. * * Signing schemes — wallets and CHOMP must implement both. They are not interchangeable: * 1. Delegation chain (root + leaf): EIP-712 typed data through DelegationManager @@ -49,14 +56,6 @@ import { Delegation, ModeCode } from "../utils/Types.sol"; * `keccak256(abi.encode(address(this), teller, block.chainid, from, to, vault, amount, deadline))`. * Same prefix as (2) and as Teller deposits, but the inner tuple starts with this helper so a * Teller deposit signature cannot be reused. - * - * Leaf Caveat Format: - * - For `premiumTransfer`, 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). This helper 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 here. A delegation without this - * enforcer as the first caveat (or with an amount other than the owner's full premium balance) - * will revert. */ contract VaultMigrationHelper is Ownable2Step { using SafeERC20 for IERC20; @@ -179,12 +178,6 @@ contract VaultMigrationHelper is Ownable2Step { /// @dev Thrown when the withdrawal and deposit chains have different root delegators. error DelegatorMismatch(); - /// @dev Thrown when the leaf caveat terms are shorter than 52 bytes. - error InvalidTermsLength(); - - /// @dev Thrown when the premium share balance is zero or does not match the leaf caveat amount. - error InvalidTransferAmount(); - /// @dev Thrown when compliance signature verification fails. error ComplianceCheckFailed(); @@ -294,20 +287,18 @@ contract VaultMigrationHelper is Ownable2Step { /** * @notice Moves `from`'s entire premium vault balance to `to` after user and compliance checks. - * @dev The delegation chain must be redeemable only by this helper and must pin `transfer` `to` to this - * helper. Production must grant this helper `transferAllowedRole` on the premium Teller. - * The transfer 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). + * @dev The delegation chain must be redeemable only by this helper. The root pins `transfer` to this helper + * via `AllowedCalldataEnforcer` (selector plus recipient) and the premium vault via `AllowedTargetsEnforcer`. + * Production must grant this helper `transferAllowedRole` on the premium Teller. + * The amount is `balanceOf(from)` at execution, not a value encoded in the leaf. * @param _params From, to, chain, compliance, and user destination signature. * @notice Security consideration: Callable by anyone. Destination is dual-controlled: the user signature * binds `to` to this helper, chain, `from`, and the root delegation the user signed; the compliance - * signature still includes `to` so only a premium-enabled recipient can receive. Delegations are EIP-712; - * `userSignature` and compliance are EIP-191 `personal_sign` over the exact inner hashes documented - * on the contract. + * signature still includes `to` and the full balance so only a premium-enabled recipient can receive + * that exact amount. Delegations are EIP-712; `userSignature` and compliance are EIP-191 `personal_sign` + * over the exact inner hashes documented on the contract. * A Teller deposit signature cannot be reused because it binds the Teller address, not this helper. - * The redelegation MUST include an `ERC20TransferAmountEnforcer` as its first caveat (`caveats[0]`), - * capped to exactly the owner's full premium balance. Reverts when Teller compliance is disabled. + * Reverts when Teller compliance is disabled. */ function premiumTransfer(PremiumTransferParams calldata _params) external { _premiumTransfer(_params); @@ -368,9 +359,6 @@ contract VaultMigrationHelper is Ownable2Step { IERC20 vault_ = IERC20(premiumAdapter.boringVault()); uint256 amount_ = vault_.balanceOf(from_); - if (amount_ == 0 || amount_ != _parseERC20TransferTerms(_params.delegations[0].caveats[0].terms)) { - revert InvalidTransferAmount(); - } _verifyUserTransferSignature(from_, to_, _params.delegations[length_ - 1].signature, _params.userSignature); _verifyTransferCompliance(from_, to_, address(vault_), amount_, _params.compliance); @@ -451,17 +439,6 @@ contract VaultMigrationHelper is Ownable2Step { if (delegator_ != _depositDelegations[depositLength_ - 1].delegator) revert DelegatorMismatch(); } - /** - * @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. - */ - function _parseERC20TransferTerms(bytes calldata _terms) private pure returns (uint256 amount_) { - if (_terms.length < 52) revert InvalidTermsLength(); - amount_ = uint256(bytes32(_terms[20:52])); - } - /** * @notice Verifies that `from` authorized this exact destination for their root delegation. * @dev Destination proof is EIP-191 `personal_sign`, not the EIP-712 scheme used for delegations. diff --git a/test/helpers/VaultMigrationHelper.t.sol b/test/helpers/VaultMigrationHelper.t.sol index b5c70bab..997b5d0d 100644 --- a/test/helpers/VaultMigrationHelper.t.sol +++ b/test/helpers/VaultMigrationHelper.t.sol @@ -444,7 +444,7 @@ contract VaultMigrationHelperTest is BaseTest { uint256 shares_ = _depositToPremium(users.alice, DEPOSIT_AMOUNT, 200, block.timestamp + 30 minutes); address from_ = address(users.alice.deleGator); address to_ = address(users.carol.deleGator); - Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, shares_, 201, address(migrationHelper)); + Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, 201, address(migrationHelper)); IComplianceVedaTeller.ComplianceData memory compliance_ = _transferComplianceData(from_, to_, shares_, block.timestamp + 30 minutes, COMPLIANCE_SIGNER_KEY); bytes memory userSignature_ = _userTransferSignature(users.alice, to_, delegations_); @@ -463,7 +463,7 @@ contract VaultMigrationHelperTest is BaseTest { uint256 shares_ = _depositToPremium(users.alice, DEPOSIT_AMOUNT, 210, block.timestamp + 30 minutes); address from_ = address(users.alice.deleGator); address to_ = address(users.carol.deleGator); - Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, shares_, 211, address(migrationHelper)); + Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, 211, address(migrationHelper)); IComplianceVedaTeller.ComplianceData memory compliance_ = _transferComplianceData(from_, to_, shares_, block.timestamp + 30 minutes, COMPLIANCE_SIGNER_KEY); bytes memory userSignature_ = _userTransferSignature(users.alice, to_, delegations_); @@ -480,7 +480,7 @@ contract VaultMigrationHelperTest is BaseTest { uint256 shares_ = _depositToPremium(users.alice, DEPOSIT_AMOUNT, 220, block.timestamp + 30 minutes); address from_ = address(users.alice.deleGator); address to_ = address(users.carol.deleGator); - Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, shares_, 221, address(migrationHelper)); + Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, 221, address(migrationHelper)); IComplianceVedaTeller.ComplianceData memory compliance_ = _transferComplianceData(from_, to_, shares_, block.timestamp - 1, COMPLIANCE_SIGNER_KEY); bytes memory userSignature_ = _userTransferSignature(users.alice, to_, delegations_); @@ -496,7 +496,7 @@ contract VaultMigrationHelperTest is BaseTest { uint256 shares_ = _depositToPremium(users.alice, DEPOSIT_AMOUNT, 230, block.timestamp + 30 minutes); address from_ = address(users.alice.deleGator); address to_ = address(users.carol.deleGator); - Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, shares_, 231, address(migrationHelper)); + Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, 231, address(migrationHelper)); IComplianceVedaTeller.ComplianceData memory compliance_ = _transferComplianceData(from_, to_, shares_, block.timestamp + 30 minutes, 0xBAD); bytes memory userSignature_ = _userTransferSignature(users.alice, to_, delegations_); @@ -513,7 +513,7 @@ contract VaultMigrationHelperTest is BaseTest { uint256 deadline_ = block.timestamp + 30 minutes; IComplianceVedaTeller.ComplianceData memory compliance_ = _transferComplianceData(from_, to_, shares_, deadline_, COMPLIANCE_SIGNER_KEY); - Delegation[] memory firstDelegations_ = _createPremiumTransferChain(users.alice, shares_, 241, address(migrationHelper)); + Delegation[] memory firstDelegations_ = _createPremiumTransferChain(users.alice, 241, address(migrationHelper)); bytes memory firstUserSignature_ = _userTransferSignature(users.alice, to_, firstDelegations_); vm.prank(address(users.bob.deleGator)); @@ -524,7 +524,7 @@ contract VaultMigrationHelperTest is BaseTest { vm.stopPrank(); vm.prank(to_); PREMIUM_VAULT.transfer(from_, shares_); - Delegation[] memory secondDelegations_ = _createPremiumTransferChain(users.alice, shares_, 243, address(migrationHelper)); + Delegation[] memory secondDelegations_ = _createPremiumTransferChain(users.alice, 243, address(migrationHelper)); bytes memory secondUserSignature_ = _userTransferSignature(users.alice, to_, secondDelegations_); vm.prank(address(users.bob.deleGator)); @@ -535,7 +535,7 @@ contract VaultMigrationHelperTest is BaseTest { function test_premiumTransfer_revertsOnFromMismatch() public { uint256 shares_ = _depositToPremium(users.alice, DEPOSIT_AMOUNT, 250, block.timestamp + 30 minutes); address to_ = address(users.carol.deleGator); - Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, shares_, 251, address(migrationHelper)); + Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, 251, address(migrationHelper)); IComplianceVedaTeller.ComplianceData memory compliance_ = _transferComplianceData( address(users.carol.deleGator), to_, shares_, block.timestamp + 30 minutes, COMPLIANCE_SIGNER_KEY ); @@ -544,16 +544,19 @@ contract VaultMigrationHelperTest is BaseTest { _callPremiumTransfer(address(users.carol.deleGator), to_, delegations_, compliance_, hex""); } - function test_premiumTransfer_revertsOnLeafAmountMismatch() public { + function test_premiumTransfer_revertsOnComplianceAmountMismatch() public { uint256 shares_ = _depositToPremium(users.alice, DEPOSIT_AMOUNT, 260, block.timestamp + 30 minutes); address from_ = address(users.alice.deleGator); address to_ = address(users.carol.deleGator); - Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, shares_ - 1, 261, address(migrationHelper)); + Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, 261, address(migrationHelper)); IComplianceVedaTeller.ComplianceData memory compliance_ = - _transferComplianceData(from_, to_, shares_, block.timestamp + 30 minutes, COMPLIANCE_SIGNER_KEY); + _transferComplianceData(from_, to_, shares_ - 1, block.timestamp + 30 minutes, COMPLIANCE_SIGNER_KEY); + bytes memory userSignature_ = _userTransferSignature(users.alice, to_, delegations_); - vm.expectRevert(VaultMigrationHelper.InvalidTransferAmount.selector); - _callPremiumTransfer(from_, to_, delegations_, compliance_, hex""); + vm.expectRevert(VaultMigrationHelper.ComplianceCheckFailed.selector); + _callPremiumTransfer(from_, to_, delegations_, compliance_, userSignature_); + + assertEq(PREMIUM_VAULT.balanceOf(from_), shares_); } function test_premiumTransfer_revertsOnShortDelegationChain() public { @@ -571,7 +574,7 @@ contract VaultMigrationHelperTest is BaseTest { function test_premiumTransfer_revertsOnZeroTo() public { uint256 shares_ = _depositToPremium(users.alice, DEPOSIT_AMOUNT, 280, block.timestamp + 30 minutes); address from_ = address(users.alice.deleGator); - Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, shares_, 281, address(migrationHelper)); + Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, 281, address(migrationHelper)); IComplianceVedaTeller.ComplianceData memory compliance_ = _transferComplianceData(from_, address(0), shares_, block.timestamp + 30 minutes, COMPLIANCE_SIGNER_KEY); @@ -583,7 +586,7 @@ contract VaultMigrationHelperTest is BaseTest { uint256 shares_ = _depositToPremium(users.alice, DEPOSIT_AMOUNT, 290, block.timestamp + 30 minutes); address from_ = address(users.alice.deleGator); address to_ = address(users.carol.deleGator); - Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, shares_, 291, address(migrationHelper)); + Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, 291, address(migrationHelper)); IComplianceVedaTeller.ComplianceData memory compliance_ = _transferComplianceData(from_, to_, shares_, block.timestamp + 30 minutes, COMPLIANCE_SIGNER_KEY); bytes memory userSignature_ = _userTransferSignature(users.alice, to_, delegations_); @@ -602,7 +605,7 @@ contract VaultMigrationHelperTest is BaseTest { uint256 shares_ = _depositToPremium(users.alice, DEPOSIT_AMOUNT, 300, block.timestamp + 30 minutes); address from_ = address(users.alice.deleGator); address to_ = address(users.carol.deleGator); - Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, shares_, 301, to_); + Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, 301, to_); IComplianceVedaTeller.ComplianceData memory compliance_ = _transferComplianceData(from_, to_, shares_, block.timestamp + 30 minutes, COMPLIANCE_SIGNER_KEY); bytes memory userSignature_ = _userTransferSignature(users.alice, to_, delegations_); @@ -619,7 +622,7 @@ contract VaultMigrationHelperTest is BaseTest { address from_ = address(users.alice.deleGator); address to_ = address(users.carol.deleGator); uint256 deadline_ = block.timestamp + 30 minutes; - Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, shares_, 311, address(migrationHelper)); + Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, 311, address(migrationHelper)); IComplianceVedaTeller.ComplianceData memory depositCompliance_ = IComplianceVedaTeller.ComplianceData({ deadline: deadline_, signature: _signCompliance(users.alice, shares_, deadline_, COMPLIANCE_SIGNER_KEY) }); @@ -636,7 +639,7 @@ contract VaultMigrationHelperTest is BaseTest { uint256 shares_ = _depositToPremium(users.alice, DEPOSIT_AMOUNT, 320, block.timestamp + 30 minutes); address from_ = address(users.alice.deleGator); address to_ = address(users.carol.deleGator); - Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, shares_, 321, address(migrationHelper)); + Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, 321, address(migrationHelper)); IComplianceVedaTeller.ComplianceData memory compliance_ = _transferComplianceData(from_, to_, shares_, block.timestamp + 30 minutes, COMPLIANCE_SIGNER_KEY); bytes memory userSignature_ = _userTransferSignature(users.alice, to_, delegations_); @@ -660,7 +663,7 @@ contract VaultMigrationHelperTest is BaseTest { address from_ = address(users.alice.deleGator); address to_ = address(users.carol.deleGator); address otherTo_ = makeAddr("otherPremiumRecipient"); - Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, shares_, 351, address(migrationHelper)); + Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, 351, address(migrationHelper)); IComplianceVedaTeller.ComplianceData memory compliance_ = _transferComplianceData(from_, to_, shares_, block.timestamp + 30 minutes, COMPLIANCE_SIGNER_KEY); bytes memory userSignature_ = _userTransferSignature(users.alice, otherTo_, delegations_); @@ -675,8 +678,8 @@ contract VaultMigrationHelperTest is BaseTest { uint256 shares_ = _depositToPremium(users.alice, DEPOSIT_AMOUNT, 360, block.timestamp + 30 minutes); address from_ = address(users.alice.deleGator); address to_ = address(users.carol.deleGator); - Delegation[] memory signedDelegations_ = _createPremiumTransferChain(users.alice, shares_, 361, address(migrationHelper)); - Delegation[] memory usedDelegations_ = _createPremiumTransferChain(users.alice, shares_, 362, address(migrationHelper)); + Delegation[] memory signedDelegations_ = _createPremiumTransferChain(users.alice, 361, address(migrationHelper)); + Delegation[] memory usedDelegations_ = _createPremiumTransferChain(users.alice, 362, address(migrationHelper)); IComplianceVedaTeller.ComplianceData memory compliance_ = _transferComplianceData(from_, to_, shares_, block.timestamp + 30 minutes, COMPLIANCE_SIGNER_KEY); bytes memory userSignature_ = _userTransferSignature(users.alice, to_, signedDelegations_); @@ -692,7 +695,7 @@ contract VaultMigrationHelperTest is BaseTest { address from_ = address(users.alice.deleGator); address to_ = address(users.carol.deleGator); address otherTo_ = makeAddr("unapprovedPremiumRecipient"); - Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, shares_, 371, address(migrationHelper)); + Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, 371, address(migrationHelper)); IComplianceVedaTeller.ComplianceData memory compliance_ = _transferComplianceData(from_, otherTo_, shares_, block.timestamp + 30 minutes, COMPLIANCE_SIGNER_KEY); bytes memory userSignature_ = _userTransferSignature(users.alice, to_, delegations_); @@ -944,7 +947,7 @@ contract VaultMigrationHelperTest is BaseTest { returns (VaultMigrationHelper.PremiumTransferParams memory) { address from_ = address(_delegator.deleGator); - Delegation[] memory delegations_ = _createPremiumTransferChain(_delegator, _amount, _salt, address(migrationHelper)); + Delegation[] memory delegations_ = _createPremiumTransferChain(_delegator, _salt, address(migrationHelper)); return VaultMigrationHelper.PremiumTransferParams({ from: from_, to: _to, @@ -1027,28 +1030,20 @@ contract VaultMigrationHelperTest is BaseTest { return signDelegation(users.bob, delegation_); } - function _createPremiumTransferChain( - TestUser memory _delegator, - uint256 _amount, - uint256 _salt, - address _calldataTo - ) + function _createPremiumTransferChain(TestUser memory _delegator, uint256 _salt, address _calldataTo) internal view returns (Delegation[] memory delegations_) { - Caveat[] memory rootCaveats_ = new Caveat[](4); - rootCaveats_[0] = Caveat({ - args: hex"", - enforcer: address(erc20TransferAmountEnforcer), - terms: abi.encodePacked(address(PREMIUM_VAULT), type(uint256).max) - }); - rootCaveats_[1] = + Caveat[] memory rootCaveats_ = new Caveat[](3); + rootCaveats_[0] = Caveat({ args: hex"", enforcer: address(redeemerEnforcer), terms: abi.encodePacked(address(migrationHelper)) }); - rootCaveats_[2] = Caveat({ - args: hex"", enforcer: address(allowedCalldataEnforcer), terms: abi.encodePacked(uint256(4), abi.encode(_calldataTo)) + rootCaveats_[1] = Caveat({ + args: hex"", + enforcer: address(allowedCalldataEnforcer), + terms: abi.encodePacked(uint256(0), IERC20.transfer.selector, abi.encode(_calldataTo)) }); - rootCaveats_[3] = + rootCaveats_[2] = Caveat({ args: hex"", enforcer: address(allowedTargetsEnforcer), terms: abi.encodePacked(address(PREMIUM_VAULT)) }); Delegation memory root_ = Delegation({ @@ -1061,11 +1056,7 @@ contract VaultMigrationHelperTest is BaseTest { }); root_ = signDelegation(_delegator, root_); - Caveat[] memory leafCaveats_ = new Caveat[](1); - leafCaveats_[0] = Caveat({ - args: hex"", enforcer: address(erc20TransferAmountEnforcer), terms: abi.encodePacked(address(PREMIUM_VAULT), _amount) - }); - + Caveat[] memory leafCaveats_ = new Caveat[](0); Delegation memory leaf_ = Delegation({ delegate: address(migrationHelper), delegator: address(users.bob.deleGator), From 6c24a2b97444af8964b6857be3d0c38a14245760 Mon Sep 17 00:00:00 2001 From: MoMannn Date: Mon, 5 Oct 2026 12:42:28 +0200 Subject: [PATCH 09/14] fix tests based on base and premium accountant --- test/helpers/VaultMigrationHelper.t.sol | 54 +++++++++++++++---------- 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/test/helpers/VaultMigrationHelper.t.sol b/test/helpers/VaultMigrationHelper.t.sol index 997b5d0d..d963f96b 100644 --- a/test/helpers/VaultMigrationHelper.t.sol +++ b/test/helpers/VaultMigrationHelper.t.sol @@ -41,13 +41,14 @@ contract VaultMigrationHelperTest is BaseTest { IERC20 internal constant MUSD = IERC20(0xacA92E438df0B2401fF60dA7E4337B687a2435DA); IERC20 internal constant BASE_VAULT = IERC20(0xb4563bcD3B7764CCBf497f515585f70B6C3EA5Ae); IVedaTeller internal constant BASE_TELLER = IVedaTeller(0x2D49EA58A4C70b62c8B56DE971310d9e999c8117); - IVedaAccountant internal constant ACCOUNTANT = IVedaAccountant(0x7382c5b8B51B8C4f127B3123C1039581BAA5A06B); + IVedaAccountant internal constant BASE_ACCOUNTANT = IVedaAccountant(0x7382c5b8B51B8C4f127B3123C1039581BAA5A06B); address internal constant BASE_LENS = 0xA816ECd922de94c6879AD23B9A884dB257F20947; address internal constant BUFFER_LENS = 0xf484d54AF87199F421967E2231Dd6dC348C7Ee6A; address internal constant DEPLOYED_BASE_ADAPTER = 0xaD4c09d065fDb6320FA8ADf69460CDd9d472C25A; IComplianceVedaTeller internal constant PREMIUM_TELLER = IComplianceVedaTeller(0xB0025a2eBc0474d4F28E975F0D3E70471246ebae); IERC20 internal constant PREMIUM_VAULT = IERC20(0xBFeC8c2b1ccea3931a1363E4CaC27352c1C908B7); + IVedaAccountant internal constant PREMIUM_ACCOUNTANT = IVedaAccountant(0xb4c9e25D3D6a4E42A7d52C21822D6016d29Ee0A2); IRolesAuthority internal constant PREMIUM_ROLES_AUTHORITY = IRolesAuthority(0x00f0CF4f540D1d47470f565Ecb11a75E073c2dd0); IRolesAuthority internal constant BASE_ROLES_AUTHORITY = IRolesAuthority(0x1eC540C9a4656a50F2D6DaaBd647753F97f082D1); @@ -111,7 +112,8 @@ contract VaultMigrationHelperTest is BaseTest { vm.label(address(PREMIUM_VAULT), "Premium mUSD Vault"); vm.label(address(BASE_TELLER), "Base Veda Teller"); vm.label(address(PREMIUM_TELLER), "Premium Veda Teller"); - vm.label(address(ACCOUNTANT), "Base Accountant"); + vm.label(address(BASE_ACCOUNTANT), "Base Accountant"); + vm.label(address(PREMIUM_ACCOUNTANT), "Premium Accountant"); vm.label(BASE_LENS, "Base Lens"); vm.label(BUFFER_LENS, "Buffer Lens"); vm.label(DEPLOYED_BASE_ADAPTER, "Deployed Base Adapter"); @@ -164,7 +166,7 @@ contract VaultMigrationHelperTest is BaseTest { uint256 baseShares_ = _depositToBase(users.alice, DEPOSIT_AMOUNT, 10); vm.warp(block.timestamp + SHARE_LOCK_SECONDS); - uint256 quotedAssets_ = _quoteAssets(baseShares_); + uint256 quotedAssets_ = _quoteBaseAssets(baseShares_); uint256 musdBefore_ = MUSD.balanceOf(address(users.alice.deleGator)); VaultMigrationHelper.ToPremiumParams memory params_ = _toPremiumParams(users.alice, baseShares_, quotedAssets_, 11, quotedAssets_, 12, block.timestamp + 30 minutes); @@ -182,7 +184,7 @@ contract VaultMigrationHelperTest is BaseTest { function test_migrateToBase_movesSharesAndLeavesNoDust() public { uint256 premiumShares_ = _depositToPremium(users.alice, DEPOSIT_AMOUNT, 20, block.timestamp + 30 minutes); - uint256 quotedAssets_ = _quoteAssets(premiumShares_); + uint256 quotedAssets_ = _quotePremiumAssets(premiumShares_); uint256 musdBefore_ = MUSD.balanceOf(address(users.alice.deleGator)); VaultMigrationHelper.ToBaseParams memory params_ = _toBaseParams(users.alice, premiumShares_, 21, quotedAssets_, 22); @@ -200,7 +202,7 @@ contract VaultMigrationHelperTest is BaseTest { function test_migrateToPremium_isPermissionless() public { uint256 baseShares_ = _depositToBase(users.alice, DEPOSIT_AMOUNT, 30); vm.warp(block.timestamp + SHARE_LOCK_SECONDS); - uint256 quotedAssets_ = _quoteAssets(baseShares_); + uint256 quotedAssets_ = _quoteBaseAssets(baseShares_); vm.prank(address(users.carol.deleGator)); migrationHelper.migrateToPremiumByDelegation( @@ -216,7 +218,7 @@ contract VaultMigrationHelperTest is BaseTest { function test_migrateToPremium_revertsBothLegsWhenDepositFails() public { uint256 baseShares_ = _depositToBase(users.alice, DEPOSIT_AMOUNT, 40); vm.warp(block.timestamp + SHARE_LOCK_SECONDS); - uint256 quotedAssets_ = _quoteAssets(baseShares_); + uint256 quotedAssets_ = _quoteBaseAssets(baseShares_); uint256 musdBefore_ = MUSD.balanceOf(address(users.alice.deleGator)); VaultMigrationHelper.ToPremiumParams memory params_ = _toPremiumParams(users.alice, baseShares_, quotedAssets_, 41, quotedAssets_, 42, block.timestamp + 30 minutes); @@ -234,7 +236,7 @@ contract VaultMigrationHelperTest is BaseTest { function test_migrateToBase_revertsOnDelegatorMismatch() public { uint256 aliceShares_ = _depositToPremium(users.alice, DEPOSIT_AMOUNT, 50, block.timestamp + 30 minutes); - uint256 quotedAssets_ = _quoteAssets(aliceShares_); + uint256 quotedAssets_ = _quotePremiumAssets(aliceShares_); VaultMigrationHelper.ToBaseParams memory params_ = VaultMigrationHelper.ToBaseParams({ withdrawalDelegations: _createDelegationChain( @@ -256,7 +258,7 @@ contract VaultMigrationHelperTest is BaseTest { function test_migrateToPremium_revertsOnShortDelegationChain() public { uint256 baseShares_ = _depositToBase(users.alice, DEPOSIT_AMOUNT, 60); vm.warp(block.timestamp + SHARE_LOCK_SECONDS); - uint256 quotedAssets_ = _quoteAssets(baseShares_); + uint256 quotedAssets_ = _quoteBaseAssets(baseShares_); VaultMigrationHelper.ToPremiumParams memory params_ = _toPremiumParams(users.alice, baseShares_, quotedAssets_, 61, quotedAssets_, 62, block.timestamp + 30 minutes); params_.withdrawalDelegations = new Delegation[](1); @@ -275,8 +277,8 @@ contract VaultMigrationHelperTest is BaseTest { uint256 carolShares_ = _depositToBase(users.carol, carolAmount_, 71); vm.warp(block.timestamp + SHARE_LOCK_SECONDS); - uint256 aliceAssets_ = _quoteAssets(aliceShares_); - uint256 carolAssets_ = _quoteAssets(carolShares_); + uint256 aliceAssets_ = _quoteBaseAssets(aliceShares_); + uint256 carolAssets_ = _quoteBaseAssets(carolShares_); VaultMigrationHelper.ToPremiumParams[] memory params_ = new VaultMigrationHelper.ToPremiumParams[](2); params_[0] = _toPremiumParams(users.alice, aliceShares_, aliceAssets_, 72, aliceAssets_, 73, block.timestamp + 20 minutes); params_[1] = _toPremiumParams(users.carol, carolShares_, carolAssets_, 74, carolAssets_, 75, block.timestamp + 21 minutes); @@ -299,8 +301,8 @@ contract VaultMigrationHelperTest is BaseTest { uint256 aliceShares_ = _depositToPremium(users.alice, aliceAmount_, 80, block.timestamp + 20 minutes); uint256 carolShares_ = _depositToPremium(users.carol, carolAmount_, 81, block.timestamp + 21 minutes); - uint256 aliceAssets_ = _quoteAssets(aliceShares_); - uint256 carolAssets_ = _quoteAssets(carolShares_); + uint256 aliceAssets_ = _quotePremiumAssets(aliceShares_); + uint256 carolAssets_ = _quotePremiumAssets(carolShares_); VaultMigrationHelper.ToBaseParams[] memory params_ = new VaultMigrationHelper.ToBaseParams[](2); params_[0] = _toBaseParams(users.alice, aliceShares_, 82, aliceAssets_, 83); params_[1] = _toBaseParams(users.carol, carolShares_, 84, carolAssets_, 85); @@ -322,8 +324,8 @@ contract VaultMigrationHelperTest is BaseTest { uint256 carolShares_ = _depositToBase(users.carol, 400e6, 91); vm.warp(block.timestamp + SHARE_LOCK_SECONDS); - uint256 aliceAssets_ = _quoteAssets(aliceShares_); - uint256 carolAssets_ = _quoteAssets(carolShares_); + uint256 aliceAssets_ = _quoteBaseAssets(aliceShares_); + uint256 carolAssets_ = _quoteBaseAssets(carolShares_); VaultMigrationHelper.ToPremiumParams[] memory params_ = new VaultMigrationHelper.ToPremiumParams[](2); params_[0] = _toPremiumParams(users.alice, aliceShares_, aliceAssets_, 92, aliceAssets_, 93, block.timestamp + 20 minutes); params_[1] = _toPremiumParams(users.carol, carolShares_, carolAssets_, 94, carolAssets_, 95, block.timestamp + 21 minutes); @@ -357,7 +359,7 @@ contract VaultMigrationHelperTest is BaseTest { function test_migrateToPremium_revertsOnExpiredCompliance() public { uint256 baseShares_ = _depositToBase(users.alice, DEPOSIT_AMOUNT, 100); vm.warp(block.timestamp + SHARE_LOCK_SECONDS); - uint256 quotedAssets_ = _quoteAssets(baseShares_); + uint256 quotedAssets_ = _quoteBaseAssets(baseShares_); VaultMigrationHelper.ToPremiumParams memory params_ = _toPremiumParams(users.alice, baseShares_, quotedAssets_, 101, quotedAssets_, 102, block.timestamp - 1); @@ -372,7 +374,7 @@ contract VaultMigrationHelperTest is BaseTest { function test_migrateToPremium_revertsOnWrongComplianceSigner() public { uint256 baseShares_ = _depositToBase(users.alice, DEPOSIT_AMOUNT, 110); vm.warp(block.timestamp + SHARE_LOCK_SECONDS); - uint256 quotedAssets_ = _quoteAssets(baseShares_); + uint256 quotedAssets_ = _quoteBaseAssets(baseShares_); VaultMigrationHelper.ToPremiumParams memory params_ = _toPremiumParams(users.alice, baseShares_, quotedAssets_, 111, quotedAssets_, 112, block.timestamp + 30 minutes); params_.compliance = IComplianceVedaTeller.ComplianceData({ @@ -390,7 +392,7 @@ contract VaultMigrationHelperTest is BaseTest { function test_migrateToPremium_revertsOnReplayedCompliance() public { uint256 firstShares_ = _depositToBase(users.alice, DEPOSIT_AMOUNT, 120); vm.warp(block.timestamp + SHARE_LOCK_SECONDS); - uint256 firstAssets_ = _quoteAssets(firstShares_); + uint256 firstAssets_ = _quoteBaseAssets(firstShares_); uint256 deadline_ = block.timestamp + 30 minutes; VaultMigrationHelper.ToPremiumParams memory firstParams_ = _toPremiumParams(users.alice, firstShares_, firstAssets_, 121, firstAssets_, 122, deadline_); @@ -400,7 +402,7 @@ contract VaultMigrationHelperTest is BaseTest { uint256 secondShares_ = _depositToBase(users.alice, DEPOSIT_AMOUNT, 123); vm.warp(block.timestamp + SHARE_LOCK_SECONDS); - uint256 secondAssets_ = _quoteAssets(secondShares_); + uint256 secondAssets_ = _quoteBaseAssets(secondShares_); VaultMigrationHelper.ToPremiumParams memory replayParams_ = _toPremiumParams(users.alice, secondShares_, secondAssets_, 124, secondAssets_, 125, deadline_ + 1 hours); replayParams_.compliance = firstParams_.compliance; @@ -413,7 +415,7 @@ contract VaultMigrationHelperTest is BaseTest { function test_migrateToPremium_revertsOnWithdrawSlippage() public { uint256 baseShares_ = _depositToBase(users.alice, DEPOSIT_AMOUNT, 130); vm.warp(block.timestamp + SHARE_LOCK_SECONDS); - uint256 quotedAssets_ = _quoteAssets(baseShares_); + uint256 quotedAssets_ = _quoteBaseAssets(baseShares_); VaultMigrationHelper.ToPremiumParams memory params_ = _toPremiumParams(users.alice, baseShares_, quotedAssets_, 131, quotedAssets_, 132, block.timestamp + 30 minutes); params_.minimumAssets = type(uint256).max; @@ -428,7 +430,7 @@ contract VaultMigrationHelperTest is BaseTest { function test_migrateToPremium_revertsOnDelegationReplay() public { uint256 baseShares_ = _depositToBase(users.alice, DEPOSIT_AMOUNT, 140); vm.warp(block.timestamp + SHARE_LOCK_SECONDS); - uint256 quotedAssets_ = _quoteAssets(baseShares_); + uint256 quotedAssets_ = _quoteBaseAssets(baseShares_); VaultMigrationHelper.ToPremiumParams memory params_ = _toPremiumParams(users.alice, baseShares_, quotedAssets_, 141, quotedAssets_, 142, block.timestamp + 30 minutes); @@ -813,8 +815,16 @@ contract VaultMigrationHelperTest is BaseTest { require(MUSD.transfer(address(_user.deleGator), INITIAL_MUSD_BALANCE), "mUSD funding failed"); } - function _quoteAssets(uint256 _shares) internal view returns (uint256) { - return _shares * ACCOUNTANT.getRateInQuoteSafe(address(MUSD)) / (10 ** ACCOUNTANT.decimals()); + function _quoteBaseAssets(uint256 _shares) internal view returns (uint256) { + return _quoteAssets(BASE_ACCOUNTANT, _shares); + } + + function _quotePremiumAssets(uint256 _shares) internal view returns (uint256) { + return _quoteAssets(PREMIUM_ACCOUNTANT, _shares); + } + + function _quoteAssets(IVedaAccountant _accountant, uint256 _shares) internal view returns (uint256) { + return _shares * _accountant.getRateInQuoteSafe(address(MUSD)) / (10 ** _accountant.decimals()); } function _depositToBase(TestUser memory _delegator, uint256 _amount, uint256 _salt) internal returns (uint256 shares_) { From 61c8a428465505ed7d6652474637000c82a45e51 Mon Sep 17 00:00:00 2001 From: MoMannn Date: Tue, 13 Oct 2026 16:18:46 +0200 Subject: [PATCH 10/14] make sure shares are actually transfered --- src/helpers/VaultMigrationHelper.sol | 8 +++ test/helpers/VaultMigrationHelper.t.sol | 92 ++++++++++++++++++++++++- 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/src/helpers/VaultMigrationHelper.sol b/src/helpers/VaultMigrationHelper.sol index 46f6ac7c..6ac7313e 100644 --- a/src/helpers/VaultMigrationHelper.sol +++ b/src/helpers/VaultMigrationHelper.sol @@ -187,6 +187,9 @@ contract VaultMigrationHelper is Ownable2Step { /// @dev Thrown when the share owner's destination signature is missing or does not match from, to, and leaf. error InvalidUserSignature(); + /// @dev Thrown when the redeemed transfer did not deliver the approved shares to this helper. + error PremiumTransferNotDelivered(); + ////////////////////////////// State ////////////////////////////// /** @@ -347,6 +350,9 @@ contract VaultMigrationHelper is Ownable2Step { /** * @notice Executes one dual-signed premium share transfer. * @dev User destination signature is checked before the backend compliance signature and before redeem. + * The second hop may spend only shares received in this call. `redeemDelegations` does not prove the + * root moved shares, so this records the helper's vault balance before redemption and reverts unless + * that balance grew by at least `amount_`. Any surplus stays here for `withdrawEmergency`. */ function _premiumTransfer(PremiumTransferParams calldata _params) internal { address from_ = _params.from; @@ -373,7 +379,9 @@ contract VaultMigrationHelper is Ownable2Step { executionCallDatas_[0] = ExecutionLib.encodeSingle(address(vault_), 0, abi.encodeCall(IERC20.transfer, (address(this), amount_))); + uint256 helperBefore_ = vault_.balanceOf(address(this)); premiumAdapter.delegationManager().redeemDelegations(permissionContexts_, encodedModes_, executionCallDatas_); + if (vault_.balanceOf(address(this)) < helperBefore_ + amount_) revert PremiumTransferNotDelivered(); vault_.safeTransfer(to_, amount_); emit PremiumTransferExecuted(from_, to_, amount_); diff --git a/test/helpers/VaultMigrationHelper.t.sol b/test/helpers/VaultMigrationHelper.t.sol index d963f96b..d4c837fc 100644 --- a/test/helpers/VaultMigrationHelper.t.sol +++ b/test/helpers/VaultMigrationHelper.t.sol @@ -15,7 +15,7 @@ import { ERC20TransferAmountEnforcer } from "../../src/enforcers/ERC20TransferAm import { RedeemerEnforcer } from "../../src/enforcers/RedeemerEnforcer.sol"; import { AllowedCalldataEnforcer } from "../../src/enforcers/AllowedCalldataEnforcer.sol"; import { AllowedTargetsEnforcer } from "../../src/enforcers/AllowedTargetsEnforcer.sol"; -import { Delegation, Caveat } from "../../src/utils/Types.sol"; +import { Delegation, Caveat, ModeCode } from "../../src/utils/Types.sol"; import { BaseTest } from "../utils/BaseTest.t.sol"; import { Implementation, SignatureType, TestUser } from "../utils/Types.t.sol"; @@ -36,6 +36,17 @@ interface IVedaAccountant { function getRateInQuoteSafe(address quote) external view returns (uint256); } +/// @dev Accepts any ERC-1271 signature and returns from `executeFromExecutor` without moving shares. +contract NoOpAccount { + function isValidSignature(bytes32, bytes calldata) external pure returns (bytes4) { + return 0x1626ba7e; + } + + function executeFromExecutor(ModeCode, bytes calldata) external payable returns (bytes[] memory) { + return new bytes[](0); + } +} + /// forge-config: default.evm_version = "cancun" contract VaultMigrationHelperTest is BaseTest { IERC20 internal constant MUSD = IERC20(0xacA92E438df0B2401fF60dA7E4337B687a2435DA); @@ -708,6 +719,37 @@ contract VaultMigrationHelperTest is BaseTest { assertEq(PREMIUM_VAULT.balanceOf(from_), shares_); } + function test_premiumTransfer_revertsWhenRedemptionDoesNotDeliverShares() public { + uint256 strayShares_ = _depositToPremium(users.alice, DEPOSIT_AMOUNT, 800, block.timestamp + 30 minutes); + address victim_ = makeAddr("victimEoa"); + vm.prank(address(users.bob.deleGator)); + migrationHelper.premiumTransfer( + _premiumTransferParams(users.alice, strayShares_, 801, victim_, block.timestamp + 30 minutes) + ); + + vm.prank(victim_); + require(PREMIUM_VAULT.transfer(address(migrationHelper), strayShares_), "stray transfer failed"); + + NoOpAccount attacker_ = new NoOpAccount(); + uint256 attackerShares_ = _depositToPremium(users.carol, 100e6, 810, block.timestamp + 30 minutes); + address payee_ = makeAddr("attackerPayee"); + vm.prank(address(users.bob.deleGator)); + migrationHelper.premiumTransfer( + _premiumTransferParams(users.carol, attackerShares_, 811, address(attacker_), block.timestamp + 30 minutes) + ); + + VaultMigrationHelper.PremiumTransferParams memory params_ = + _customRootTransferParams(address(attacker_), payee_, attackerShares_, 820); + + vm.expectRevert(VaultMigrationHelper.PremiumTransferNotDelivered.selector); + vm.prank(makeAddr("anyone")); + migrationHelper.premiumTransfer(params_); + + assertEq(PREMIUM_VAULT.balanceOf(address(attacker_)), attackerShares_); + assertEq(PREMIUM_VAULT.balanceOf(payee_), 0); + assertEq(PREMIUM_VAULT.balanceOf(address(migrationHelper)), strayShares_); + } + function test_premiumTransferBatch_movesEachDelegatorToRecipient() public { uint256 aliceShares_ = _depositToPremium(users.alice, 300e6, 330, block.timestamp + 20 minutes); uint256 carolShares_ = _depositToPremium(users.carol, 400e6, 331, block.timestamp + 21 minutes); @@ -967,6 +1009,48 @@ contract VaultMigrationHelperTest is BaseTest { }); } + function _customRootTransferParams( + address _from, + address _to, + uint256 _amount, + uint256 _salt + ) + internal + view + returns (VaultMigrationHelper.PremiumTransferParams memory) + { + Caveat[] memory noCaveats_ = new Caveat[](0); + Delegation memory root_ = Delegation({ + delegate: address(users.bob.deleGator), + delegator: _from, + authority: ROOT_AUTHORITY, + caveats: noCaveats_, + salt: _salt, + signature: hex"c0ffee" + }); + Delegation memory leaf_ = Delegation({ + delegate: address(migrationHelper), + delegator: address(users.bob.deleGator), + authority: EncoderLib._getDelegationHash(root_), + caveats: noCaveats_, + salt: _salt, + signature: hex"" + }); + leaf_ = signDelegation(users.bob, leaf_); + + Delegation[] memory delegations_ = new Delegation[](2); + delegations_[0] = leaf_; + delegations_[1] = root_; + + return VaultMigrationHelper.PremiumTransferParams({ + from: _from, + to: _to, + delegations: delegations_, + compliance: _transferComplianceData(_from, _to, _amount, block.timestamp + 30 minutes, COMPLIANCE_SIGNER_KEY), + userSignature: hex"01" + }); + } + function _createDelegationChain( TestUser memory _delegator, address _adapter, @@ -1040,7 +1124,11 @@ contract VaultMigrationHelperTest is BaseTest { return signDelegation(users.bob, delegation_); } - function _createPremiumTransferChain(TestUser memory _delegator, uint256 _salt, address _calldataTo) + function _createPremiumTransferChain( + TestUser memory _delegator, + uint256 _salt, + address _calldataTo + ) internal view returns (Delegation[] memory delegations_) From b3012f50a02e00ffc5895bd9c109063ac22488a1 Mon Sep 17 00:00:00 2001 From: MoMannn Date: Wed, 14 Oct 2026 10:58:40 +0200 Subject: [PATCH 11/14] document getPrincipalHistoryPaginated --- src/helpers/VaultMigrationHelper.sol | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/helpers/VaultMigrationHelper.sol b/src/helpers/VaultMigrationHelper.sol index 6ac7313e..095422d1 100644 --- a/src/helpers/VaultMigrationHelper.sol +++ b/src/helpers/VaultMigrationHelper.sol @@ -40,6 +40,11 @@ import { Delegation, ModeCode } from "../utils/Types.sol"; * owner's full premium balance at execution (`balanceOf`). It is not read from a leaf caveat. * The compliance digest includes that amount, so the backend still attests the quantity moved. * First hop stays helper-only via the root caveats above. + * `premiumTransfer` and `premiumTransferBatch` move premium vault shares and do not update the Teller's + * per-user principal history. `getPrincipalHistoryPaginated` must not be used alone as a per-holder + * principal record. A complete record requires reconciling Teller deposits and withdrawals with vault + * `Transfer` events under an explicit rule for allocating principal when shares move. Transfer events + * alone do not establish that basis. * * Signing schemes — wallets and CHOMP must implement both. They are not interchangeable: * 1. Delegation chain (root + leaf): EIP-712 typed data through DelegationManager @@ -294,6 +299,11 @@ contract VaultMigrationHelper is Ownable2Step { * via `AllowedCalldataEnforcer` (selector plus recipient) and the premium vault via `AllowedTargetsEnforcer`. * Production must grant this helper `transferAllowedRole` on the premium Teller. * The amount is `balanceOf(from)` at execution, not a value encoded in the leaf. + * This moves premium vault shares and does not update the Teller's per-user principal history. + * `getPrincipalHistoryPaginated` must not be used alone as a per-holder principal record. A complete + * record requires reconciling Teller deposits and withdrawals with vault `Transfer` events under an + * explicit rule for allocating principal when shares move. Transfer events alone do not establish + * that basis. * @param _params From, to, chain, compliance, and user destination signature. * @notice Security consideration: Callable by anyone. Destination is dual-controlled: the user signature * binds `to` to this helper, chain, `from`, and the root delegation the user signed; the compliance @@ -311,6 +321,11 @@ contract VaultMigrationHelper is Ownable2Step { * @notice Moves multiple delegators' entire premium vault balances sequentially in one atomic transaction. * @dev A revert from any transfer rolls back the entire batch. Each stream needs its own user signature * (destination) and compliance signature (premium-enabled `to`) because both digests include `from`/`to`. + * Every stream moves premium vault shares and does not update the Teller's per-user principal history. + * `getPrincipalHistoryPaginated` must not be used alone as a per-holder principal record. A complete + * record requires reconciling Teller deposits and withdrawals with vault `Transfer` events under an + * explicit rule for allocating principal when shares move. Transfer events alone do not establish + * that basis. * @param _params Transfer parameters for each stream. * @notice Security consideration: Callable by anyone. Every delegation chain, user destination signature, * and compliance signature remains independently enforced. @@ -353,6 +368,11 @@ contract VaultMigrationHelper is Ownable2Step { * The second hop may spend only shares received in this call. `redeemDelegations` does not prove the * root moved shares, so this records the helper's vault balance before redemption and reverts unless * that balance grew by at least `amount_`. Any surplus stays here for `withdrawEmergency`. + * The share move does not update the Teller's per-user principal history. + * `getPrincipalHistoryPaginated` must not be used alone as a per-holder principal record. A complete + * record requires reconciling Teller deposits and withdrawals with vault `Transfer` events under an + * explicit rule for allocating principal when shares move. Transfer events alone do not establish + * that basis. */ function _premiumTransfer(PremiumTransferParams calldata _params) internal { address from_ = _params.from; From 8b18421f00e62b3c4d35580ad168da1eaec84d43 Mon Sep 17 00:00:00 2001 From: MoMannn Date: Wed, 14 Oct 2026 11:06:37 +0200 Subject: [PATCH 12/14] use delegationHash instead of signature --- src/helpers/VaultMigrationHelper.sol | 30 ++++--- test/helpers/VaultMigrationHelper.t.sol | 101 +++++++++++++++++++++++- 2 files changed, 117 insertions(+), 14 deletions(-) diff --git a/src/helpers/VaultMigrationHelper.sol b/src/helpers/VaultMigrationHelper.sol index 095422d1..7ed54cf5 100644 --- a/src/helpers/VaultMigrationHelper.sol +++ b/src/helpers/VaultMigrationHelper.sol @@ -51,12 +51,13 @@ import { Delegation, ModeCode } from "../utils/Types.sol"; * (`toTypedDataHash(domainHash, EncoderLib._getDelegationHash(delegation))`). The share owner * signs the root; CHOMP (root `delegate`) signs the leaf. * 2. Destination proof (`userSignature`): EIP-191 `personal_sign`, not EIP-712. Inner hash: - * `keccak256(abi.encode(address(this), block.chainid, from, to, delegations[length - 1].signature))`. + * `keccak256(abi.encode(address(this), block.chainid, from, to, delegations[length - 2].authority))`. * Signed payload: `keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", inner))`. * Verified with ERC-1271 on `from` via SignatureChecker. The chain is leaf-to-root, so - * `delegations[length - 1].signature` is the root the share owner signed (the initial transfer - * validation), not the leaf redelegation. Binding that signature ties `to` to this root without - * hashing the full delegation. + * `delegations[length - 2].authority` is the hash of the root the share owner signed + * (`EncoderLib._getDelegationHash`, the same identifier `disableDelegation` uses), not the leaf + * redelegation. The DelegationManager reverts unless that authority equals the root hash, so the + * proof is bound to this root without hashing the full delegation on-chain. * 3. Compliance (`compliance.signature`): also EIP-191 `personal_sign`. Inner hash: * `keccak256(abi.encode(address(this), teller, block.chainid, from, to, vault, amount, deadline))`. * Same prefix as (2) and as Teller deposits, but the inner tuple starts with this helper so a @@ -306,7 +307,8 @@ contract VaultMigrationHelper is Ownable2Step { * that basis. * @param _params From, to, chain, compliance, and user destination signature. * @notice Security consideration: Callable by anyone. Destination is dual-controlled: the user signature - * binds `to` to this helper, chain, `from`, and the root delegation the user signed; the compliance + * binds `to` to this helper, chain, `from`, and the root delegation hash + * (`delegations[length - 2].authority`, the identifier `disableDelegation` uses); the compliance * signature still includes `to` and the full balance so only a premium-enabled recipient can receive * that exact amount. Delegations are EIP-712; `userSignature` and compliance are EIP-191 `personal_sign` * over the exact inner hashes documented on the contract. @@ -386,7 +388,8 @@ contract VaultMigrationHelper is Ownable2Step { IERC20 vault_ = IERC20(premiumAdapter.boringVault()); uint256 amount_ = vault_.balanceOf(from_); - _verifyUserTransferSignature(from_, to_, _params.delegations[length_ - 1].signature, _params.userSignature); + // DelegationManager reverts unless this authority equals the root delegation hash. + _verifyUserTransferSignature(from_, to_, _params.delegations[length_ - 2].authority, _params.userSignature); _verifyTransferCompliance(from_, to_, address(vault_), amount_, _params.compliance); bytes[] memory permissionContexts_ = new bytes[](1); @@ -470,24 +473,25 @@ contract VaultMigrationHelper is Ownable2Step { /** * @notice Verifies that `from` authorized this exact destination for their root delegation. * @dev Destination proof is EIP-191 `personal_sign`, not the EIP-712 scheme used for delegations. - * Inner: `keccak256(abi.encode(address(this), block.chainid, from, to, root.signature))`. + * Inner: `keccak256(abi.encode(address(this), block.chainid, from, to, delegations[length - 2].authority))`. * Signed: `toEthSignedMessageHash(inner)` == `"\x19Ethereum Signed Message:\n32" || inner`. * Verified with ERC-1271 on `from` via SignatureChecker so a backend cannot swap `to` after - * the user signed. The chain is sorted leaf to root, so the bound signature is - * `delegations[length - 1].signature`: the user's initial transfer validation, not the - * redelegation at `delegations[0]`. Binding that signature ties `to` to this root without - * hashing the full delegation on-chain. + * the user signed. The chain is sorted leaf to root, so the bound value is + * `delegations[length - 2].authority`: the root delegation hash (`EncoderLib._getDelegationHash`, + * the identifier `disableDelegation` uses), not the root signature and not the redelegation at + * `delegations[0]`. The DelegationManager reverts `InvalidAuthority` unless that authority equals + * the root hash, so this ties `to` to this root without hashing the full delegation on-chain. */ function _verifyUserTransferSignature( address _from, address _to, - bytes calldata _rootSignature, + bytes32 _rootDelegationHash, bytes calldata _userSignature ) private view { - bytes32 messageHash_ = keccak256(abi.encode(address(this), block.chainid, _from, _to, _rootSignature)); + bytes32 messageHash_ = keccak256(abi.encode(address(this), block.chainid, _from, _to, _rootDelegationHash)); bytes32 ethSignedHash_ = MessageHashUtils.toEthSignedMessageHash(messageHash_); if (!SignatureChecker.isValidSignatureNow(_from, ethSignedHash_, _userSignature)) { revert InvalidUserSignature(); diff --git a/test/helpers/VaultMigrationHelper.t.sol b/test/helpers/VaultMigrationHelper.t.sol index d4c837fc..72f15813 100644 --- a/test/helpers/VaultMigrationHelper.t.sol +++ b/test/helpers/VaultMigrationHelper.t.sol @@ -3,6 +3,7 @@ pragma solidity 0.8.23; import { IERC20 } from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import { Ownable } from "@openzeppelin/contracts/access/Ownable.sol"; +import { MessageHashUtils } from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol"; import { EncoderLib } from "../../src/libraries/EncoderLib.sol"; import { BasicERC20 } from "../utils/BasicERC20.t.sol"; @@ -47,6 +48,24 @@ contract NoOpAccount { } } +/// @dev Accepts an empty signature for every hash it has approved, and does not move shares. +contract ApprovedHashAccount { + mapping(bytes32 hash => bool approved) public approvedHashes; + + function approveHash(bytes32 _hash) external { + approvedHashes[_hash] = true; + } + + function isValidSignature(bytes32 _hash, bytes calldata _signature) external view returns (bytes4) { + if (_signature.length == 0 && approvedHashes[_hash]) return 0x1626ba7e; + return 0xffffffff; + } + + function executeFromExecutor(ModeCode, bytes calldata) external payable returns (bytes[] memory) { + return new bytes[](0); + } +} + /// forge-config: default.evm_version = "cancun" contract VaultMigrationHelperTest is BaseTest { IERC20 internal constant MUSD = IERC20(0xacA92E438df0B2401fF60dA7E4337B687a2435DA); @@ -703,6 +722,37 @@ contract VaultMigrationHelperTest is BaseTest { assertEq(PREMIUM_VAULT.balanceOf(from_), shares_); } + function test_premiumTransfer_revertsWhenEmptySignatureIsReusedOnAnotherRoot() public { + ApprovedHashAccount account_ = new ApprovedHashAccount(); + uint256 shares_ = _depositToPremium(users.carol, 100e6, 900, block.timestamp + 30 minutes); + address payee_ = makeAddr("approvedHashPayee"); + + vm.prank(address(users.bob.deleGator)); + migrationHelper.premiumTransfer( + _premiumTransferParams(users.carol, shares_, 901, address(account_), block.timestamp + 30 minutes) + ); + assertEq(PREMIUM_VAULT.balanceOf(address(account_)), shares_); + + Delegation[] memory rootA_ = _createEmptySignatureTransferChain(address(account_), 902); + Delegation[] memory rootB_ = _createEmptySignatureTransferChain(address(account_), 903); + bytes32 domainHash_ = delegationManager.getDomainHash(); + account_.approveHash(MessageHashUtils.toTypedDataHash(domainHash_, EncoderLib._getDelegationHash(rootA_[1]))); + account_.approveHash(MessageHashUtils.toTypedDataHash(domainHash_, EncoderLib._getDelegationHash(rootB_[1]))); + account_.approveHash(_destinationEthSignedHash(address(account_), payee_, rootA_[0].authority)); + + IComplianceVedaTeller.ComplianceData memory compliance_ = + _transferComplianceData(address(account_), payee_, shares_, block.timestamp + 30 minutes, COMPLIANCE_SIGNER_KEY); + + vm.expectRevert(VaultMigrationHelper.InvalidUserSignature.selector); + _callPremiumTransfer(address(account_), payee_, rootB_, compliance_, hex""); + + vm.expectRevert(VaultMigrationHelper.PremiumTransferNotDelivered.selector); + _callPremiumTransfer(address(account_), payee_, rootA_, compliance_, hex""); + + assertEq(PREMIUM_VAULT.balanceOf(address(account_)), shares_); + assertEq(PREMIUM_VAULT.balanceOf(payee_), 0); + } + function test_premiumTransfer_revertsOnComplianceSignatureForDifferentTo() public { uint256 shares_ = _depositToPremium(users.alice, DEPOSIT_AMOUNT, 370, block.timestamp + 30 minutes); address from_ = address(users.alice.deleGator); @@ -1170,6 +1220,55 @@ contract VaultMigrationHelperTest is BaseTest { delegations_[1] = root_; } + function _createEmptySignatureTransferChain( + address _delegator, + uint256 _salt + ) + internal + view + returns (Delegation[] memory delegations_) + { + Caveat[] memory rootCaveats_ = new Caveat[](3); + rootCaveats_[0] = + Caveat({ args: hex"", enforcer: address(redeemerEnforcer), terms: abi.encodePacked(address(migrationHelper)) }); + rootCaveats_[1] = Caveat({ + args: hex"", + enforcer: address(allowedCalldataEnforcer), + terms: abi.encodePacked(uint256(0), IERC20.transfer.selector, abi.encode(address(migrationHelper))) + }); + rootCaveats_[2] = + Caveat({ args: hex"", enforcer: address(allowedTargetsEnforcer), terms: abi.encodePacked(address(PREMIUM_VAULT)) }); + + Delegation memory root_ = Delegation({ + delegate: address(users.bob.deleGator), + delegator: _delegator, + authority: ROOT_AUTHORITY, + caveats: rootCaveats_, + salt: _salt, + signature: hex"" + }); + + Caveat[] memory leafCaveats_ = new Caveat[](0); + Delegation memory leaf_ = Delegation({ + delegate: address(migrationHelper), + delegator: address(users.bob.deleGator), + authority: EncoderLib._getDelegationHash(root_), + caveats: leafCaveats_, + salt: _salt, + signature: hex"" + }); + leaf_ = signDelegation(users.bob, leaf_); + + delegations_ = new Delegation[](2); + delegations_[0] = leaf_; + delegations_[1] = root_; + } + + function _destinationEthSignedHash(address _from, address _to, bytes32 _rootDelegationHash) internal view returns (bytes32) { + bytes32 messageHash_ = keccak256(abi.encode(address(migrationHelper), block.chainid, _from, _to, _rootDelegationHash)); + return MessageHashUtils.toEthSignedMessageHash(messageHash_); + } + function _userTransferSignature( TestUser memory _delegator, address _to, @@ -1185,7 +1284,7 @@ contract VaultMigrationHelperTest is BaseTest { block.chainid, address(_delegator.deleGator), _to, - _delegations[_delegations.length - 1].signature + _delegations[_delegations.length - 2].authority ) ); bytes32 ethSignedMessageHash_ = keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash_)); From 96e4012d1e18420fd7ed1f0976f529e5421619f7 Mon Sep 17 00:00:00 2001 From: MoMannn Date: Wed, 14 Oct 2026 11:08:44 +0200 Subject: [PATCH 13/14] use distinct errors --- src/helpers/VaultMigrationHelper.sol | 21 ++++++++++---- test/helpers/VaultMigrationHelper.t.sol | 37 ++++++++++++++++++++----- 2 files changed, 45 insertions(+), 13 deletions(-) diff --git a/src/helpers/VaultMigrationHelper.sol b/src/helpers/VaultMigrationHelper.sol index 7ed54cf5..fed85a93 100644 --- a/src/helpers/VaultMigrationHelper.sol +++ b/src/helpers/VaultMigrationHelper.sol @@ -184,8 +184,17 @@ contract VaultMigrationHelper is Ownable2Step { /// @dev Thrown when the withdrawal and deposit chains have different root delegators. error DelegatorMismatch(); - /// @dev Thrown when compliance signature verification fails. - error ComplianceCheckFailed(); + /// @dev Thrown when a transfer compliance approval has already been consumed. + error ComplianceSignatureAlreadyUsed(); + + /// @dev Thrown when a transfer compliance approval has expired. + error ComplianceSignatureExpired(); + + /// @dev Thrown when a transfer compliance deadline exceeds the Teller's configured compliance window. + error ComplianceDeadlineExceedsWindow(); + + /// @dev Thrown when the recovered transfer compliance signer does not hold the Teller's compliance signer role. + error InvalidComplianceSigner(); /// @dev Thrown when the premium Teller has compliance signer checks disabled (`complianceSignerRole == 255`). error ComplianceDisabled(); @@ -523,18 +532,18 @@ contract VaultMigrationHelper is Ownable2Step { bytes32 messageHash_ = keccak256( abi.encode(address(this), address(teller_), block.chainid, _from, _to, _vault, _amount, _compliance.deadline) ); - if (usedComplianceSignatures[messageHash_]) revert ComplianceCheckFailed(); - if (block.timestamp > _compliance.deadline) revert ComplianceCheckFailed(); + if (usedComplianceSignatures[messageHash_]) revert ComplianceSignatureAlreadyUsed(); + if (block.timestamp > _compliance.deadline) revert ComplianceSignatureExpired(); uint96 complianceWindow_ = teller_.complianceWindow(); if (complianceWindow_ > 0 && _compliance.deadline > block.timestamp + complianceWindow_) { - revert ComplianceCheckFailed(); + revert ComplianceDeadlineExceedsWindow(); } bytes32 ethSignedHash_ = MessageHashUtils.toEthSignedMessageHash(messageHash_); address recovered_ = ECDSA.recover(ethSignedHash_, _compliance.signature); if (!IRolesAuthority(teller_.authority()).doesUserHaveRole(recovered_, complianceSignerRole_)) { - revert ComplianceCheckFailed(); + revert InvalidComplianceSigner(); } usedComplianceSignatures[messageHash_] = true; diff --git a/test/helpers/VaultMigrationHelper.t.sol b/test/helpers/VaultMigrationHelper.t.sol index 72f15813..3f43e38a 100644 --- a/test/helpers/VaultMigrationHelper.t.sol +++ b/test/helpers/VaultMigrationHelper.t.sol @@ -518,12 +518,35 @@ contract VaultMigrationHelperTest is BaseTest { bytes memory userSignature_ = _userTransferSignature(users.alice, to_, delegations_); vm.prank(address(users.bob.deleGator)); - vm.expectRevert(VaultMigrationHelper.ComplianceCheckFailed.selector); + vm.expectRevert(VaultMigrationHelper.ComplianceSignatureExpired.selector); _callPremiumTransfer(from_, to_, delegations_, compliance_, userSignature_); assertEq(PREMIUM_VAULT.balanceOf(from_), shares_); } + function test_premiumTransfer_revertsWhenComplianceDeadlineExceedsWindow() public { + uint256 shares_ = _depositToPremium(users.alice, DEPOSIT_AMOUNT, 225, block.timestamp + 30 minutes); + address from_ = address(users.alice.deleGator); + address to_ = address(users.carol.deleGator); + Delegation[] memory delegations_ = _createPremiumTransferChain(users.alice, 226, address(migrationHelper)); + IComplianceVedaTeller.ComplianceData memory compliance_ = + _transferComplianceData(from_, to_, shares_, block.timestamp + 30 minutes, COMPLIANCE_SIGNER_KEY); + bytes memory userSignature_ = _userTransferSignature(users.alice, to_, delegations_); + + vm.mockCall( + address(PREMIUM_TELLER), + abi.encodeWithSelector(IComplianceVedaTeller.complianceWindow.selector), + abi.encode(uint96(10 minutes)) + ); + + vm.prank(address(users.bob.deleGator)); + vm.expectRevert(VaultMigrationHelper.ComplianceDeadlineExceedsWindow.selector); + _callPremiumTransfer(from_, to_, delegations_, compliance_, userSignature_); + + assertEq(PREMIUM_VAULT.balanceOf(from_), shares_); + vm.clearMockedCalls(); + } + function test_premiumTransfer_revertsOnWrongComplianceSigner() public { uint256 shares_ = _depositToPremium(users.alice, DEPOSIT_AMOUNT, 230, block.timestamp + 30 minutes); address from_ = address(users.alice.deleGator); @@ -534,7 +557,7 @@ contract VaultMigrationHelperTest is BaseTest { bytes memory userSignature_ = _userTransferSignature(users.alice, to_, delegations_); vm.prank(address(users.bob.deleGator)); - vm.expectRevert(VaultMigrationHelper.ComplianceCheckFailed.selector); + vm.expectRevert(VaultMigrationHelper.InvalidComplianceSigner.selector); _callPremiumTransfer(from_, to_, delegations_, compliance_, userSignature_); } @@ -560,7 +583,7 @@ contract VaultMigrationHelperTest is BaseTest { bytes memory secondUserSignature_ = _userTransferSignature(users.alice, to_, secondDelegations_); vm.prank(address(users.bob.deleGator)); - vm.expectRevert(VaultMigrationHelper.ComplianceCheckFailed.selector); + vm.expectRevert(VaultMigrationHelper.ComplianceSignatureAlreadyUsed.selector); _callPremiumTransfer(from_, to_, secondDelegations_, compliance_, secondUserSignature_); } @@ -585,7 +608,7 @@ contract VaultMigrationHelperTest is BaseTest { _transferComplianceData(from_, to_, shares_ - 1, block.timestamp + 30 minutes, COMPLIANCE_SIGNER_KEY); bytes memory userSignature_ = _userTransferSignature(users.alice, to_, delegations_); - vm.expectRevert(VaultMigrationHelper.ComplianceCheckFailed.selector); + vm.expectRevert(VaultMigrationHelper.InvalidComplianceSigner.selector); _callPremiumTransfer(from_, to_, delegations_, compliance_, userSignature_); assertEq(PREMIUM_VAULT.balanceOf(from_), shares_); @@ -661,7 +684,7 @@ contract VaultMigrationHelperTest is BaseTest { bytes memory userSignature_ = _userTransferSignature(users.alice, to_, delegations_); vm.prank(address(users.bob.deleGator)); - vm.expectRevert(VaultMigrationHelper.ComplianceCheckFailed.selector); + vm.expectRevert(VaultMigrationHelper.InvalidComplianceSigner.selector); _callPremiumTransfer(from_, to_, delegations_, depositCompliance_, userSignature_); assertEq(PREMIUM_VAULT.balanceOf(from_), shares_); @@ -763,7 +786,7 @@ contract VaultMigrationHelperTest is BaseTest { _transferComplianceData(from_, otherTo_, shares_, block.timestamp + 30 minutes, COMPLIANCE_SIGNER_KEY); bytes memory userSignature_ = _userTransferSignature(users.alice, to_, delegations_); - vm.expectRevert(VaultMigrationHelper.ComplianceCheckFailed.selector); + vm.expectRevert(VaultMigrationHelper.InvalidComplianceSigner.selector); _callPremiumTransfer(from_, to_, delegations_, compliance_, userSignature_); assertEq(PREMIUM_VAULT.balanceOf(from_), shares_); @@ -834,7 +857,7 @@ contract VaultMigrationHelperTest is BaseTest { params_[1] = _premiumTransferParams(users.carol, carolShares_, 343, recipient_, block.timestamp - 1); vm.prank(address(users.bob.deleGator)); - vm.expectRevert(VaultMigrationHelper.ComplianceCheckFailed.selector); + vm.expectRevert(VaultMigrationHelper.ComplianceSignatureExpired.selector); migrationHelper.premiumTransferBatch(params_); assertEq(PREMIUM_VAULT.balanceOf(alice_), aliceShares_); From 43c721bb15130836a89d2ea492f4dc25b326c98c Mon Sep 17 00:00:00 2001 From: MoMannn Date: Wed, 14 Oct 2026 11:13:32 +0200 Subject: [PATCH 14/14] update natspec --- src/helpers/VaultMigrationHelper.sol | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/helpers/VaultMigrationHelper.sol b/src/helpers/VaultMigrationHelper.sol index fed85a93..98fffdff 100644 --- a/src/helpers/VaultMigrationHelper.sol +++ b/src/helpers/VaultMigrationHelper.sol @@ -27,7 +27,9 @@ import { Delegation, ModeCode } from "../utils/Types.sol"; * `transfer` to this helper and the amount stays unset on the root; * - name the premium vault in `AllowedTargetsEnforcer`; * - name this contract in `RedeemerEnforcer`. - * The root does not use `ERC20TransferAmountEnforcer`. This helper must hold `transferAllowedRole` on the + * The premium-transfer root does not use `ERC20TransferAmountEnforcer`. Migration leaves do; each adapter + * reads its amount from that leaf's `caveats[0].terms[20:52]` (see `ToPremiumParams` and `ToBaseParams`). + * This helper must hold `transferAllowedRole` on the * premium Teller so Veda allows the first hop (helper is `to`) and the second hop (helper is `from`/`operator`). * * Destination control for `premiumTransfer` is dual, not backend-only and not user-only: @@ -69,8 +71,12 @@ contract VaultMigrationHelper is Ownable2Step { /** * @notice Parameters for one base-to-premium migration. * @param withdrawalDelegations Delegation chain redeemed by the base adapter to withdraw base vault shares. + * The leaf's first caveat MUST be an `ERC20TransferAmountEnforcer` encoding the base vault and the + * exact share amount. The base adapter reads that amount from `caveats[0].terms[20:52]`. * @param minimumAssets Minimum underlying assets that the base withdrawal must return. * @param depositDelegations Delegation chain redeemed by the premium adapter to deposit the withdrawn assets. + * The same first-caveat requirement applies, encoding mUSD and the exact deposit amount. That amount + * is also bound in `compliance`. * @param minimumMint Minimum premium vault shares that the destination deposit must mint. * @param compliance Compliance authorization forwarded unchanged to the premium adapter. */ @@ -85,8 +91,11 @@ contract VaultMigrationHelper is Ownable2Step { /** * @notice Parameters for one premium-to-base migration. * @param withdrawalDelegations Delegation chain redeemed by the premium adapter to withdraw premium shares. + * The leaf's first caveat MUST be an `ERC20TransferAmountEnforcer` encoding the premium vault and + * the exact share amount. The premium adapter reads that amount from `caveats[0].terms[20:52]`. * @param minimumAssets Minimum underlying assets that the premium withdrawal must return. * @param depositDelegations Delegation chain redeemed by the base adapter to deposit the withdrawn assets. + * The same first-caveat requirement applies, encoding mUSD and the exact deposit amount. * @param minimumMint Minimum base vault shares that the destination deposit must mint. */ struct ToBaseParams {