From 2abd39e82ef50978159d3c388496c45ea40e8599 Mon Sep 17 00:00:00 2001 From: Alex Towle Date: Thu, 20 Feb 2025 13:53:18 -1000 Subject: [PATCH 1/9] Added a migration rewards vault --- lib/council | 2 +- src/MigrationRewardsVault.sol | 282 ++++++++++++++++++++++++++++++++++ 2 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 src/MigrationRewardsVault.sol diff --git a/lib/council b/lib/council index 5f7be33..3003e56 160000 --- a/lib/council +++ b/lib/council @@ -1 +1 @@ -Subproject commit 5f7be330b05f1c3bebd0176882cc5c3429f0764f +Subproject commit 3003e568a8d35e9fa7b59fcb5e961b2c577f1d0c diff --git a/src/MigrationRewardsVault.sol b/src/MigrationRewardsVault.sol new file mode 100644 index 0000000..1c1bcd3 --- /dev/null +++ b/src/MigrationRewardsVault.sol @@ -0,0 +1,282 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.24; + +import { IERC20 } from "council/interfaces/IERC20.sol"; +import { IVotingVault } from "council/interfaces/IVotingVault.sol"; +import { History } from "council/libraries/History.sol"; +import { VestingVaultStorage } from "council/libraries/VestingVaultStorage.sol"; +import { Storage } from "council/libraries/Storage.sol"; +import { AbstractVestingVault } from "council/vaults/VestingVault.sol"; + +// FIXME: Change this. +// +// FIXME: Implement the functionality that we want. We need to update the claim +// function in addition to changing some of the parameters of `migrate`. +// +/// @title MigrationRewardsVault +/// @notice A migration vault that converts ELFI tokens to HD tokens. Migrated +/// tokens have a cliff. After this cliff, they will accrue a bonus +/// linearly as time passes. The grant is created at a destination +/// address provided by the migrator. This contract inherits full voting +/// power tracking from `AbstractVestingVault`. +contract MigrationRewardsVault is AbstractVestingVault { + using History for History.HistoricalBalances; + + /// @notice Thrown when an existing grant is found. + error ExistingGrantFound(); + + /// @notice Thrown when ELFI transfers fail. + error ElfiTransferFailed(); + + /// @notice Thrown when there are insufficient HD tokens. + error InsufficientHDTokens(); + + /// @notice One in basis points. + uint256 public constant ONE_BPS = 10_000; + + /// @notice The HD treasury that is funding this migration contract. + address public immutable hdTreasury; + + /// @notice The ELFI token to migrate from. + IERC20 public immutable elfiToken; + + /// @notice The conversion rate from ELFI to HD. + uint256 public immutable conversionMultiplier; + + /// @notice The global start block at which all grants start vesting. + uint256 public immutable startBlock; + + // FIXME: Rename this to "cliff" + // + /// @notice The global cliff block at which all grants have vested their cliff + /// amount. + uint256 public immutable cliff; + + // FIXME + // + /// @notice The global expiration block at which all grants fully vest. + uint256 public immutable expiration; + + // FIXME: The bonus multiplier in basis points. + uint256 public immutable bonusMultiplier; + + /// @notice Constructs the migration vault. + /// @param _hdTreasury The HD treasury that is funding this migration + /// contract. + /// @param _hdToken The ERC20 token to be vested (HD token). + /// @param _elfiToken The ERC20 token to migrate from (ELFI token). + /// @param _stale The stale block lag used in voting power calculations. + /// @param _conversionMultiplier The conversion multiplier from ELFI to HD. + /// @param _startBlock The global start block for all grants. + /// @param _expiration The global expiration block for all grants. + constructor( + address _hdTreasury, + IERC20 _hdToken, + IERC20 _elfiToken, + uint256 _stale, + uint256 _conversionMultiplier, + uint256 _startBlock, + uint256 _expiration + ) AbstractVestingVault(_hdToken, _stale) { + hdTreasury = _hdTreasury; + elfiToken = _elfiToken; + conversionMultiplier = _conversionMultiplier; + startBlock = _startBlock; + expiration = _expiration; + } + + /// @notice Migrates a specified amount of ELFI tokens into a vesting grant + /// of HD tokens. + /// @dev Converts ELFI to HD at the conversion rate. Pre-cliff migrations + /// receive a 5% bonus vesting post-cliff over 2 months. Post-cliff, + /// pre-expiration migrations receive a reduced bonus proportional to + /// remaining time, vesting from creation. Post-expiration migrations + /// receive no bonus, just the base amount. Caller must approve this + /// contract for ELFI tokens, and the treasury must approve HD tokens. + /// @param _amount The amount of ELFI tokens to migrate. + /// @param _destination The address to receive the HD token grant. + function migrate(uint256 _amount, address _destination) external { + // Prevent duplicate grants at the destination. + VestingVaultStorage.Grant storage existingGrant = _grants()[_destination]; + if (existingGrant.allocation != 0) { + revert ExistingGrantFound(); + } + + // Transfer ELFI tokens from the caller to this contract. + if (!elfiToken.transferFrom(msg.sender, address(this), _amount)) { + revert ElfiTransferFailed(); + } + + // Calculate the base HD amount from ELFI conversion. + uint256 baseHdAmount = (_amount * conversionMultiplier) / ONE_BPS; + uint256 totalHdAmount; + + // Determine the total HD amount based on migration timing. + if (block.number < cliff) { + // Full 5% bonus for pre-cliff migrations. + totalHdAmount = (baseHdAmount * bonusMultiplier) / ONE_BPS; + } else if (block.number < expiration) { + // Reduced bonus for post-cliff, pre-expiration migrations. + uint256 blocksRemaining = expiration - block.number; + uint256 bonusPeriod = expiration - cliff; + uint256 bonusFactor = ONE_BPS + ((bonusMultiplier - ONE_BPS) * blocksRemaining) / bonusPeriod; + totalHdAmount = (baseHdAmount * bonusFactor) / ONE_BPS; + } else { + // No bonus for post-expiration migrations, just base amount. + totalHdAmount = baseHdAmount; + } + + // Transfer HD tokens from the treasury to this contract. + if (!token.transferFrom(hdTreasury, address(this), totalHdAmount)) { + revert InsufficientHDTokens(); + } + + // Create the grant with current block as creation time and base as initial voting power. + _grants()[_destination] = VestingVaultStorage.Grant({ + allocation: uint128(totalHdAmount), + withdrawn: 0, + created: uint128(block.number), + expiration: uint128(expiration), + cliff: uint128(cliff), + latestVotingPower: uint128(baseHdAmount), + delegatee: _destination, + range: [uint256(0), uint256(0)] + }); + + // Update voting power history with the base amount. + History.HistoricalBalances memory votingPower = _votingPower(); + votingPower.push(_destination, baseHdAmount); + emit VoteChange(_destination, _destination, int256(baseHdAmount)); + } + + // FIXME: Use custom reverts. + // + /// @notice Claims all withdrawable HD tokens from the caller's grant and + /// terminates it. + /// @dev Withdraws the currently withdrawable amount (base plus vested + /// bonus), returns any unvested bonus to the treasury, resets voting + /// power to 0, and deletes the grant. Fails if no tokens are + /// withdrawable (e.g., before cliff for early migrators or before + /// creation). + function claim() public override { + // Load the caller’s grant and calculate the withdrawable amount. + VestingVaultStorage.Grant storage grant = _grants()[msg.sender]; + uint256 withdrawable = _getWithdrawableAmount(grant); + if (withdrawable == 0) { + revert("NothingToClaim"); + } + + // Calculate the unvested amount to return to the treasury. + uint256 unvested = grant.allocation > withdrawable ? grant.allocation - withdrawable : 0; + + // Transfer withdrawable amount to the claimant. + if (!token.transfer(msg.sender, withdrawable)) { + revert("TransferFailed"); + } + + // Return any unvested bonus to the treasury. + if (unvested > 0) { + if (!token.transfer(hdTreasury, unvested)) { + revert("TreasuryTransferFailed"); + } + } + + // Reset voting power to 0 and update delegatee’s history. + if (grant.latestVotingPower > 0) { + History.HistoricalBalances memory votingPower = _votingPower(); + uint256 delegateeVotes = votingPower.loadTop(grant.delegatee); + votingPower.push(grant.delegatee, delegateeVotes - grant.latestVotingPower); + emit VoteChange(grant.delegatee, msg.sender, -int256(uint256(grant.latestVotingPower))); + } + + // Delete the grant to prevent further vesting or claims. + delete _grants()[msg.sender]; + } + + /// @notice Calculates the current voting power of a grant. + /// @dev Returns 0 before creation. For early migrators (pre-cliff), returns + /// the base amount until the cliff, then tracks the withdrawable + /// amount. For late migrators (post-cliff), returns 0 until creation, + /// then tracks the withdrawable amount (base immediately, plus vested + /// bonus). + /// @param _grant The grant to check. + /// @return The current voting power of the grant. + function _currentVotingPower(VestingVaultStorage.Grant memory _grant) + internal + view + override + returns (uint256) + { + // No voting power before the grant is created. + if (block.number < _grant.created) { + return 0; + } + + // Before the cliff (for early migrators), use the base amount set at + // creation. + if (block.number < _grant.cliff) { + return _grant.latestVotingPower; + } + + // After the cliff (or creation for late migrators), use the + // withdrawable amount. + return _getWithdrawableAmount(_grant); + } + + /// @notice Calculates the amount of HD tokens withdrawable from a grant. + /// @dev Returns 0 before the vesting start (cliff for early migrators, + /// creation for late migrators). For early migrators (pre-cliff), the + /// base unlocks at the cliff, and the 5% bonus vests linearly over 2 + /// months. For late migrators (post-cliff), the base is immediately + /// withdrawable, and a reduced bonus vests linearly over the remaining + /// time to expiration. + /// @param _grant The grant to check. + /// @return The total withdrawable amount (base plus vested bonus, less any + /// prior withdrawals). + function _getWithdrawableAmount(VestingVaultStorage.Grant memory _grant) + internal + view + override + returns (uint256) + { + // Nothing withdrawable before creation or before cliff for early migrators. + if (block.number < _grant.created || (_grant.created < cliff && block.number < cliff)) { + return 0; + } + + // Calculate the effective bonus factor based on creation time. + uint256 effectiveBonusFactor; + if (_grant.created < cliff) { + effectiveBonusFactor = bonusMultiplier; // Full 5% bonus (10,500) + } else { + // For late migrators, scale bonus based on remaining blocks to expiration. + uint256 blocksRemaining = _grant.expiration > _grant.created ? _grant.expiration - _grant.created : 0; + uint256 bonusPeriod = _grant.expiration - cliff; + effectiveBonusFactor = ONE_BPS + ((bonusMultiplier - ONE_BPS) * blocksRemaining) / bonusPeriod; + } + + // Derive the base amount using the effective bonus factor. + uint256 baseAmount = (_grant.allocation * ONE_BPS) / effectiveBonusFactor; + uint256 maxBonusAmount = _grant.allocation - baseAmount; + + // Return full allocation if past expiration. + if (block.number >= _grant.expiration) { + return _grant.allocation; + } + + // Vesting starts at cliff for early migrators, creation for late migrators. + uint256 vestingStart = _grant.created < cliff ? cliff : _grant.created; + if (block.number < vestingStart) { + return 0; + } + + // Calculate vested bonus linearly from vesting start to expiration. + uint256 blocksSinceVestingStart = block.number - vestingStart; + uint256 vestingPeriod = _grant.expiration - vestingStart; + uint256 vestedBonus = (maxBonusAmount * blocksSinceVestingStart) / vestingPeriod; + + // Clamp the result to allocation to handle rounding errors. + uint256 withdrawable = baseAmount + vestedBonus; + return withdrawable > _grant.allocation ? _grant.allocation : withdrawable; + } +} From 30d0a249f9f5b4303535a36915986c0e4831fd79 Mon Sep 17 00:00:00 2001 From: Alex Towle Date: Thu, 20 Feb 2025 14:15:30 -1000 Subject: [PATCH 2/9] Made some precision improvements --- src/MigrationRewardsVault.sol | 88 +++++++++++++++++++---------------- src/MigrationVestingVault.sol | 4 ++ 2 files changed, 51 insertions(+), 41 deletions(-) diff --git a/src/MigrationRewardsVault.sol b/src/MigrationRewardsVault.sol index 1c1bcd3..45686ba 100644 --- a/src/MigrationRewardsVault.sol +++ b/src/MigrationRewardsVault.sol @@ -8,11 +8,6 @@ import { VestingVaultStorage } from "council/libraries/VestingVaultStorage.sol"; import { Storage } from "council/libraries/Storage.sol"; import { AbstractVestingVault } from "council/vaults/VestingVault.sol"; -// FIXME: Change this. -// -// FIXME: Implement the functionality that we want. We need to update the claim -// function in addition to changing some of the parameters of `migrate`. -// /// @title MigrationRewardsVault /// @notice A migration vault that converts ELFI tokens to HD tokens. Migrated /// tokens have a cliff. After this cliff, they will accrue a bonus @@ -31,8 +26,33 @@ contract MigrationRewardsVault is AbstractVestingVault { /// @notice Thrown when there are insufficient HD tokens. error InsufficientHDTokens(); + /// @notice Thrown when no tokens are withdrawable during a claim attempt. + error NothingToClaim(); + + /// @notice Thrown when the HD token transfer to the claimant fails. + error TransferFailed(); + + /// @notice Thrown when the HD token transfer to the treasury fails. + error TreasuryTransferFailed(); + /// @notice One in basis points. - uint256 public constant ONE_BPS = 10_000; + uint256 public constant ONE = 1e18; + + /// @notice The conversion rate from ELFI to HD. + uint256 public constant CONVERSION_MULTIPLIER = 10e18; + + /// @notice The bonus multiplier, representing a 5% APR over + /// a three-month cliff period. For a 5% APR over 2 months (0.16 + /// years), bonus = 5% * 0.16 which is approximately 0.83%. + uint256 public constant BONUS_MULTIPLIER = 1.008333333333333333e18; + + /// @notice The number of blocks between deploying the contract and the + /// vesting cliff. + uint256 public constant CLIFF_DURATION = 91 days / 12; // ~3 months + + /// @notice The number of blocks between deploying the contract and the + /// expiration. + uint256 public constant EXPIRATION_DURATION = 152 days / 12; // ~5 months /// @notice The HD treasury that is funding this migration contract. address public immutable hdTreasury; @@ -40,49 +60,37 @@ contract MigrationRewardsVault is AbstractVestingVault { /// @notice The ELFI token to migrate from. IERC20 public immutable elfiToken; - /// @notice The conversion rate from ELFI to HD. - uint256 public immutable conversionMultiplier; - /// @notice The global start block at which all grants start vesting. uint256 public immutable startBlock; - // FIXME: Rename this to "cliff" - // /// @notice The global cliff block at which all grants have vested their cliff /// amount. uint256 public immutable cliff; - // FIXME - // /// @notice The global expiration block at which all grants fully vest. uint256 public immutable expiration; - // FIXME: The bonus multiplier in basis points. - uint256 public immutable bonusMultiplier; - /// @notice Constructs the migration vault. - /// @param _hdTreasury The HD treasury that is funding this migration - /// contract. + /// @param _hdTreasury The HD treasury funding this migration contract. /// @param _hdToken The ERC20 token to be vested (HD token). /// @param _elfiToken The ERC20 token to migrate from (ELFI token). - /// @param _stale The stale block lag used in voting power calculations. - /// @param _conversionMultiplier The conversion multiplier from ELFI to HD. - /// @param _startBlock The global start block for all grants. - /// @param _expiration The global expiration block for all grants. + /// @param _stale The stale block lag for voting power calculations. constructor( address _hdTreasury, IERC20 _hdToken, IERC20 _elfiToken, - uint256 _stale, - uint256 _conversionMultiplier, - uint256 _startBlock, - uint256 _expiration + uint256 _stale ) AbstractVestingVault(_hdToken, _stale) { + // Set immutable variables hdTreasury = _hdTreasury; elfiToken = _elfiToken; - conversionMultiplier = _conversionMultiplier; - startBlock = _startBlock; - expiration = _expiration; + + // Use deployment block as startBlock. + startBlock = block.number; + + // Calculate cliff and expiration based on durations. + cliff = block.number + CLIFF_DURATION; + expiration = block.number + EXPIRATION_DURATION; } /// @notice Migrates a specified amount of ELFI tokens into a vesting grant @@ -108,19 +116,19 @@ contract MigrationRewardsVault is AbstractVestingVault { } // Calculate the base HD amount from ELFI conversion. - uint256 baseHdAmount = (_amount * conversionMultiplier) / ONE_BPS; + uint256 baseHdAmount = (_amount * CONVERSION_MULTIPLIER) / ONE; uint256 totalHdAmount; // Determine the total HD amount based on migration timing. if (block.number < cliff) { // Full 5% bonus for pre-cliff migrations. - totalHdAmount = (baseHdAmount * bonusMultiplier) / ONE_BPS; + totalHdAmount = (baseHdAmount * BONUS_MULTIPLIER) / ONE; } else if (block.number < expiration) { // Reduced bonus for post-cliff, pre-expiration migrations. uint256 blocksRemaining = expiration - block.number; uint256 bonusPeriod = expiration - cliff; - uint256 bonusFactor = ONE_BPS + ((bonusMultiplier - ONE_BPS) * blocksRemaining) / bonusPeriod; - totalHdAmount = (baseHdAmount * bonusFactor) / ONE_BPS; + uint256 bonusFactor = ONE + ((BONUS_MULTIPLIER - ONE) * blocksRemaining) / bonusPeriod; + totalHdAmount = (baseHdAmount * bonusFactor) / ONE; } else { // No bonus for post-expiration migrations, just base amount. totalHdAmount = baseHdAmount; @@ -149,8 +157,6 @@ contract MigrationRewardsVault is AbstractVestingVault { emit VoteChange(_destination, _destination, int256(baseHdAmount)); } - // FIXME: Use custom reverts. - // /// @notice Claims all withdrawable HD tokens from the caller's grant and /// terminates it. /// @dev Withdraws the currently withdrawable amount (base plus vested @@ -163,7 +169,7 @@ contract MigrationRewardsVault is AbstractVestingVault { VestingVaultStorage.Grant storage grant = _grants()[msg.sender]; uint256 withdrawable = _getWithdrawableAmount(grant); if (withdrawable == 0) { - revert("NothingToClaim"); + revert NothingToClaim(); } // Calculate the unvested amount to return to the treasury. @@ -171,13 +177,13 @@ contract MigrationRewardsVault is AbstractVestingVault { // Transfer withdrawable amount to the claimant. if (!token.transfer(msg.sender, withdrawable)) { - revert("TransferFailed"); + revert TransferFailed(); } // Return any unvested bonus to the treasury. if (unvested > 0) { if (!token.transfer(hdTreasury, unvested)) { - revert("TreasuryTransferFailed"); + revert TreasuryTransferFailed(); } } @@ -247,16 +253,16 @@ contract MigrationRewardsVault is AbstractVestingVault { // Calculate the effective bonus factor based on creation time. uint256 effectiveBonusFactor; if (_grant.created < cliff) { - effectiveBonusFactor = bonusMultiplier; // Full 5% bonus (10,500) + effectiveBonusFactor = BONUS_MULTIPLIER; // Full 5% bonus (10,500) } else { // For late migrators, scale bonus based on remaining blocks to expiration. uint256 blocksRemaining = _grant.expiration > _grant.created ? _grant.expiration - _grant.created : 0; uint256 bonusPeriod = _grant.expiration - cliff; - effectiveBonusFactor = ONE_BPS + ((bonusMultiplier - ONE_BPS) * blocksRemaining) / bonusPeriod; + effectiveBonusFactor = ONE + ((BONUS_MULTIPLIER - ONE) * blocksRemaining) / bonusPeriod; } // Derive the base amount using the effective bonus factor. - uint256 baseAmount = (_grant.allocation * ONE_BPS) / effectiveBonusFactor; + uint256 baseAmount = (_grant.allocation * ONE) / effectiveBonusFactor; uint256 maxBonusAmount = _grant.allocation - baseAmount; // Return full allocation if past expiration. diff --git a/src/MigrationVestingVault.sol b/src/MigrationVestingVault.sol index 2cca363..1d53bcd 100644 --- a/src/MigrationVestingVault.sol +++ b/src/MigrationVestingVault.sol @@ -56,6 +56,10 @@ contract MigrationVestingVault is AbstractVestingVault { IERC20 _elfiToken, uint256 _stale, uint256 _conversionMultiplier, + // FIXME: What if this is in the future? What if this is greater than + // the expiration? Adjust this so that these things aren't possible. The + // simplest solution is to switch to using constants for the time between + // the start block and the expiration. uint256 _startBlock, uint256 _expiration ) AbstractVestingVault(_hdToken, _stale) { From c24d76c9b6053262f3af6d55fae880653ebd0343 Mon Sep 17 00:00:00 2001 From: Alex Towle Date: Mon, 24 Feb 2025 13:08:37 -1000 Subject: [PATCH 3/9] Added tests for the migration rewards vault --- test/MigrationRewardsVaultTest.t.sol | 330 +++++++++++++++++++++++++++ 1 file changed, 330 insertions(+) create mode 100644 test/MigrationRewardsVaultTest.t.sol diff --git a/test/MigrationRewardsVaultTest.t.sol b/test/MigrationRewardsVaultTest.t.sol new file mode 100644 index 0000000..06740fc --- /dev/null +++ b/test/MigrationRewardsVaultTest.t.sol @@ -0,0 +1,330 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.24; + +import { Test } from "forge-std/Test.sol"; +import { HDToken } from "../src/HDToken.sol"; // Assuming HDToken.sol exists +import { IERC20 } from "council/interfaces/IERC20.sol"; +import { CoreVoting } from "council/CoreVoting.sol"; +import { MigrationRewardsVault } from "../src/MigrationRewardsVault.sol"; +import { VestingVaultStorage } from "council/libraries/VestingVaultStorage.sol"; + +/// @title MigrationRewardsVaultTest +/// @notice Test suite for the MigrationRewardsVault contract, covering migration, +/// vesting, bonus application, voting power tracking, and claiming. +contract MigrationRewardsVaultTest is Test { + /// @dev Events to test + event VoteChange(address indexed from, address indexed to, int256 amount); + event Transfer(address indexed from, address indexed to, uint256 value); + + /// @dev Constants for mainnet contracts and configuration + uint256 internal constant FORK_BLOCK = 19_000_000; + uint256 internal constant STALE_BLOCKS = 100; + address internal constant ELFI_WHALE = 0x6De73946eab234F1EE61256F10067D713aF0e37A; + + /// @dev Contract instances + CoreVoting internal constant CORE_VOTING = CoreVoting(0xEaCD577C3F6c44C3ffA398baaD97aE12CDCFed4a); + IERC20 internal constant ELFI = IERC20(0x5c6D51ecBA4D8E4F20373e3ce96a62342B125D6d); + MigrationRewardsVault internal vault; + HDToken internal hdToken; + + /// @dev Test accounts + address internal deployer; + address internal alice; + address internal bob; + address internal charlie; + + /// @notice Sets up the test environment by forking mainnet, deploying contracts, + /// and configuring accounts and CoreVoting. + function setUp() public { + // Create test accounts + deployer = address(this); + alice = makeAddr("alice"); + bob = makeAddr("bob"); + charlie = makeAddr("charlie"); + + // Fork mainnet + vm.createSelectFork(vm.rpcUrl("mainnet"), FORK_BLOCK); + + // Deploy HD token + vm.startPrank(deployer); + hdToken = new HDToken( + "HD Token", + "HD", + block.timestamp + 1 days + ); + + // Deploy migration vault + vault = new MigrationRewardsVault( + deployer, // Treasury is deployer for simplicity + IERC20(address(hdToken)), + ELFI, + STALE_BLOCKS + ); + + // Add vault to CoreVoting + vm.startPrank(CORE_VOTING.owner()); + CORE_VOTING.changeVaultStatus(address(vault), true); + + // Approve vault to spend 1,000,000 HD tokens from deployer (treasury) + vm.startPrank(deployer); + hdToken.approve(address(vault), 1_000_000e18); + + // Fund test accounts with ELFI from whale + vm.startPrank(ELFI_WHALE); + uint256 whaleBalance = ELFI.balanceOf(ELFI_WHALE); + address[] memory accounts = new address[](3); + accounts[0] = alice; + accounts[1] = bob; + accounts[2] = charlie; + for (uint256 i = 0; i < accounts.length; i++) { + ELFI.transfer(accounts[i], whaleBalance / accounts.length); + } + vm.stopPrank(); + } + + // ============================== + // Migration Tests + // ============================== + + /// @notice Tests that migration fails if the destination already has a grant. + function test_migrate_failure_existingGrant() external { + uint256 amount = 100e18; + vm.startPrank(alice); + ELFI.approve(address(vault), amount); + vault.migrate(amount, alice); + + // Attempt second migration to same destination + vm.expectRevert(MigrationRewardsVault.ExistingGrantFound.selector); + vault.migrate(amount, alice); + vm.stopPrank(); + } + + /// @notice Tests that migration fails if ELFI transfer fails (no approval). + function test_migrate_failure_transferFailed() external { + uint256 amount = 100e18; + vm.startPrank(alice); + vm.expectRevert(); // ERC20: insufficient allowance + vault.migrate(amount, alice); + vm.stopPrank(); + } + + /// @notice Tests that migration fails if insufficient HD tokens are available. + function test_migrate_failure_insufficientHDTokens() external { + uint256 excessiveAmount = (hdToken.allowance(deployer, address(vault)) / 10) + 1e18; + vm.startPrank(alice); + ELFI.approve(address(vault), excessiveAmount); + vm.expectRevert(MigrationRewardsVault.InsufficientHDTokens.selector); + vault.migrate(excessiveAmount, alice); + vm.stopPrank(); + } + + /// @notice Tests successful migration and claiming for a pre-cliff migrator. + function test_migrate_and_claim_preCliff() external { + uint256 amount = 100e18; + + // Record initial states + uint256 vaultElfiBalanceBefore = ELFI.balanceOf(address(vault)); + uint256 aliceElfiBalanceBefore = ELFI.balanceOf(alice); + uint256 vaultHdBalanceBefore = hdToken.balanceOf(address(vault)); + uint256 bobHdBalanceBefore = hdToken.balanceOf(bob); + + // Alice migrates ELFI to Bob (pre-cliff) + vm.startPrank(alice); + ELFI.approve(address(vault), amount); + vm.expectEmit(true, true, true, true); + emit Transfer(alice, address(vault), amount); + vault.migrate(amount, bob); + vm.stopPrank(); + + // Verify grant configuration + VestingVaultStorage.Grant memory grant = vault.getGrant(bob); + uint256 expectedAllocation = (amount * 10 * vault.BONUS_MULTIPLIER()) / vault.ONE(); + assertEq(grant.allocation, expectedAllocation, "Wrong allocation"); + assertEq(grant.withdrawn, 0, "Should not have withdrawals"); + assertEq(grant.created, block.number, "Wrong creation block"); + assertEq(grant.cliff, vault.cliff(), "Wrong cliff"); + assertEq(grant.expiration, vault.expiration(), "Wrong expiration"); + assertEq(grant.delegatee, bob, "Wrong delegatee"); + + // Verify token transfers + assertEq(ELFI.balanceOf(address(vault)), vaultElfiBalanceBefore + amount, "Vault ELFI balance not updated"); + assertEq(ELFI.balanceOf(alice), aliceElfiBalanceBefore - amount, "Alice ELFI balance not updated"); + assertEq(hdToken.balanceOf(address(vault)), vaultHdBalanceBefore + expectedAllocation, "Vault HD balance not updated"); + + // Move to halfway between cliff and expiration + uint256 halfwayBlock = vault.cliff() + (vault.expiration() - vault.cliff()) / 2; + vm.roll(halfwayBlock); + + // Bob claims + vm.startPrank(bob); + vault.claim(); + + // Verify claim outcomes + uint256 bobHdBalanceAfter = hdToken.balanceOf(bob); + uint256 vaultHdBalanceAfter = hdToken.balanceOf(address(vault)); + uint256 votingPowerAfter = vault.queryVotePower(bob, block.number, ""); + uint256 expectedBase = amount * 10; // CONVERSION_MULTIPLIER = 10e18 + uint256 expectedBonusHalf = ((expectedAllocation - expectedBase) / 2); + assertEq(bobHdBalanceAfter, bobHdBalanceBefore + expectedBase + expectedBonusHalf, "Bob HD balance incorrect"); + assertEq(vaultHdBalanceAfter, vaultHdBalanceBefore + expectedAllocation - (expectedBase + expectedBonusHalf), "Vault HD balance incorrect"); + assertEq(votingPowerAfter, 0, "Voting power should be zero after claim"); + assertEq(hdToken.balanceOf(vault.hdTreasury()), expectedBonusHalf, "Treasury should receive unvested bonus"); + + // Verify grant is deleted + grant = vault.getGrant(bob); + assertEq(grant.allocation, 0, "Grant should be deleted"); + } + + /// @notice Tests migration and immediate partial claim for a post-cliff migrator. + function test_migrate_and_claim_postCliff_halfway() external { + uint256 amount = 100e18; + + // Move to halfway between cliff and expiration + uint256 halfwayBlock = vault.cliff() + (vault.expiration() - vault.cliff()) / 2; + vm.roll(halfwayBlock); + + // Record initial states + uint256 bobHdBalanceBefore = hdToken.balanceOf(bob); + uint256 treasuryHdBalanceBefore = hdToken.balanceOf(vault.hdTreasury()); + + // Alice migrates ELFI to Bob (post-cliff) + vm.startPrank(alice); + ELFI.approve(address(vault), amount); + vault.migrate(amount, bob); + vm.stopPrank(); + + // Verify grant configuration + VestingVaultStorage.Grant memory grant = vault.getGrant(bob); + uint256 blocksRemaining = vault.expiration() - halfwayBlock; + uint256 bonusPeriod = vault.expiration() - vault.cliff(); + uint256 bonusFactor = vault.ONE() + ((vault.BONUS_MULTIPLIER() - vault.ONE()) * blocksRemaining) / bonusPeriod; + uint256 expectedBase = amount * 10; // CONVERSION_MULTIPLIER = 10e18 + uint256 expectedAllocation = (expectedBase * bonusFactor) / vault.ONE(); + assertEq(grant.allocation, expectedAllocation, "Wrong allocation"); + assertEq(grant.created, halfwayBlock, "Wrong creation block"); + + // Bob claims immediately + vm.startPrank(bob); + vault.claim(); + + // Verify claim outcomes + uint256 bobHdBalanceAfter = hdToken.balanceOf(bob); + uint256 vaultHdBalanceAfter = hdToken.balanceOf(address(vault)); + uint256 votingPowerAfter = vault.queryVotePower(bob, block.number, ""); + assertEq(bobHdBalanceAfter, bobHdBalanceBefore + expectedBase, "Bob HD balance incorrect"); + assertEq(vaultHdBalanceAfter, 0, "Vault HD balance incorrect"); + assertEq(votingPowerAfter, 0, "Voting power should be zero after claim"); + assertEq(hdToken.balanceOf(vault.hdTreasury()), treasuryHdBalanceBefore - expectedBase, "Treasury balance incorrect"); + } + + /// @notice Tests migration and full claim after expiration. + function test_migrate_and_claim_postExpiration() external { + uint256 amount = 100e18; + + // Move past expiration + vm.roll(vault.expiration() + 1); + + // Record initial states + uint256 vaultHdBalanceBefore = hdToken.balanceOf(address(vault)); + uint256 bobHdBalanceBefore = hdToken.balanceOf(bob); + + // Alice migrates ELFI to Bob (post-expiration) + vm.startPrank(alice); + ELFI.approve(address(vault), amount); + vault.migrate(amount, bob); + vm.stopPrank(); + + // Verify grant configuration (no bonus post-expiration) + VestingVaultStorage.Grant memory grant = vault.getGrant(bob); + uint256 expectedBase = amount * 10; // CONVERSION_MULTIPLIER = 10e18 + assertEq(grant.allocation, expectedBase, "Wrong allocation"); + + // Bob claims immediately + vm.startPrank(bob); + vault.claim(); + + // Verify claim outcomes + uint256 bobHdBalanceAfter = hdToken.balanceOf(bob); + uint256 vaultHdBalanceAfter = hdToken.balanceOf(address(vault)); + uint256 votingPowerAfter = vault.queryVotePower(bob, block.number, ""); + assertEq(bobHdBalanceAfter, bobHdBalanceBefore + expectedBase, "Bob HD balance incorrect"); + assertEq(vaultHdBalanceAfter, vaultHdBalanceBefore, "Vault HD balance should not decrease beyond base"); + assertEq(votingPowerAfter, 0, "Voting power should be zero after claim"); + assertEq(hdToken.balanceOf(vault.hdTreasury()), 0, "Treasury should receive no bonus post-expiration"); + } + + // ============================== + // Voting Power Tests + // ============================== + + /// @notice Tests voting power after pre-cliff migration. + function test_votingPower_afterPreCliffMigration() external { + uint256 amount = 100e18; + + vm.startPrank(alice); + ELFI.approve(address(vault), amount); + vault.migrate(amount, alice); + vm.stopPrank(); + + // Wait past stale blocks + vm.roll(block.number + STALE_BLOCKS + 1); + + // Verify voting power + uint256 votingPower = vault.queryVotePower(alice, block.number - 1, ""); + assertEq(votingPower, amount * 10, "Incorrect voting power pre-cliff"); // Base amount + } + + /// @notice Tests voting power delegation. + function test_votingPower_afterDelegation() external { + uint256 amount = 100e18; + + // Alice migrates and delegates to Bob + vm.startPrank(alice); + ELFI.approve(address(vault), amount); + vault.migrate(amount, alice); + vm.expectEmit(true, true, true, true); + emit VoteChange(alice, alice, -int256(amount * 10)); + vm.expectEmit(true, true, true, true); + emit VoteChange(bob, alice, int256(amount * 10)); + vault.delegate(bob); + vm.stopPrank(); + + // Wait past stale blocks + vm.roll(block.number + STALE_BLOCKS + 1); + + // Verify voting powers + uint256 aliceVotingPower = vault.queryVotePower(alice, block.number - 1, ""); + uint256 bobVotingPower = vault.queryVotePower(bob, block.number - 1, ""); + assertEq(aliceVotingPower, 0, "Alice should have no voting power"); + assertEq(bobVotingPower, amount * 10, "Bob should have Alice's voting power"); + } + + /// @notice Tests voting power progression through vesting. + function test_votingPower_throughVesting() external { + uint256 amount = 100e18; + + // Alice migrates pre-cliff + vm.startPrank(alice); + ELFI.approve(address(vault), amount); + vault.migrate(amount, alice); + vm.stopPrank(); + + // Move to cliff + vm.roll(vault.cliff()); + uint256 votingPowerAtCliff = vault.queryVotePower(alice, block.number - 1, ""); + assertEq(votingPowerAtCliff, amount * 10, "Voting power incorrect at cliff"); + + // Move halfway between cliff and expiration + uint256 halfwayBlock = vault.cliff() + (vault.expiration() - vault.cliff()) / 2; + vm.roll(halfwayBlock); + uint256 votingPowerHalfway = vault.queryVotePower(alice, block.number - 1, ""); + uint256 expectedAllocation = (amount * 10 * vault.BONUS_MULTIPLIER()) / vault.ONE(); + uint256 expectedBonusHalf = ((expectedAllocation - (amount * 10)) / 2); + assertEq(votingPowerHalfway, amount * 10 + expectedBonusHalf, "Voting power incorrect halfway"); + + // Move to expiration + vm.roll(vault.expiration()); + uint256 votingPowerAtExpiration = vault.queryVotePower(alice, block.number - 1, ""); + assertEq(votingPowerAtExpiration, expectedAllocation, "Voting power incorrect at expiration"); + } +} From c1ac3943d9efd65aee9c5b611aa6e9511706ad73 Mon Sep 17 00:00:00 2001 From: Alex Towle Date: Mon, 24 Feb 2025 13:28:58 -1000 Subject: [PATCH 4/9] Fixed the tests --- test/MigrationRewardsVaultTest.t.sol | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/test/MigrationRewardsVaultTest.t.sol b/test/MigrationRewardsVaultTest.t.sol index 06740fc..21f418b 100644 --- a/test/MigrationRewardsVaultTest.t.sol +++ b/test/MigrationRewardsVaultTest.t.sol @@ -113,7 +113,7 @@ contract MigrationRewardsVaultTest is Test { uint256 excessiveAmount = (hdToken.allowance(deployer, address(vault)) / 10) + 1e18; vm.startPrank(alice); ELFI.approve(address(vault), excessiveAmount); - vm.expectRevert(MigrationRewardsVault.InsufficientHDTokens.selector); + vm.expectRevert(); vault.migrate(excessiveAmount, alice); vm.stopPrank(); } @@ -137,6 +137,7 @@ contract MigrationRewardsVaultTest is Test { vm.stopPrank(); // Verify grant configuration + uint256 treasuryHdBalanceBefore = hdToken.balanceOf(vault.hdTreasury()); VestingVaultStorage.Grant memory grant = vault.getGrant(bob); uint256 expectedAllocation = (amount * 10 * vault.BONUS_MULTIPLIER()) / vault.ONE(); assertEq(grant.allocation, expectedAllocation, "Wrong allocation"); @@ -166,9 +167,9 @@ contract MigrationRewardsVaultTest is Test { uint256 expectedBase = amount * 10; // CONVERSION_MULTIPLIER = 10e18 uint256 expectedBonusHalf = ((expectedAllocation - expectedBase) / 2); assertEq(bobHdBalanceAfter, bobHdBalanceBefore + expectedBase + expectedBonusHalf, "Bob HD balance incorrect"); - assertEq(vaultHdBalanceAfter, vaultHdBalanceBefore + expectedAllocation - (expectedBase + expectedBonusHalf), "Vault HD balance incorrect"); + assertEq(vaultHdBalanceAfter, 0, "Vault HD balance incorrect"); assertEq(votingPowerAfter, 0, "Voting power should be zero after claim"); - assertEq(hdToken.balanceOf(vault.hdTreasury()), expectedBonusHalf, "Treasury should receive unvested bonus"); + assertEq(hdToken.balanceOf(vault.hdTreasury()), treasuryHdBalanceBefore + expectedBonusHalf, "Treasury should receive unvested bonus"); // Verify grant is deleted grant = vault.getGrant(bob); @@ -235,6 +236,7 @@ contract MigrationRewardsVaultTest is Test { vm.stopPrank(); // Verify grant configuration (no bonus post-expiration) + uint256 treasuryHdBalanceBefore = hdToken.balanceOf(vault.hdTreasury()); VestingVaultStorage.Grant memory grant = vault.getGrant(bob); uint256 expectedBase = amount * 10; // CONVERSION_MULTIPLIER = 10e18 assertEq(grant.allocation, expectedBase, "Wrong allocation"); @@ -250,7 +252,7 @@ contract MigrationRewardsVaultTest is Test { assertEq(bobHdBalanceAfter, bobHdBalanceBefore + expectedBase, "Bob HD balance incorrect"); assertEq(vaultHdBalanceAfter, vaultHdBalanceBefore, "Vault HD balance should not decrease beyond base"); assertEq(votingPowerAfter, 0, "Voting power should be zero after claim"); - assertEq(hdToken.balanceOf(vault.hdTreasury()), 0, "Treasury should receive no bonus post-expiration"); + assertEq(hdToken.balanceOf(vault.hdTreasury()), treasuryHdBalanceBefore, "Treasury should receive no bonus post-expiration"); } // ============================== @@ -317,14 +319,16 @@ contract MigrationRewardsVaultTest is Test { // Move halfway between cliff and expiration uint256 halfwayBlock = vault.cliff() + (vault.expiration() - vault.cliff()) / 2; vm.roll(halfwayBlock); - uint256 votingPowerHalfway = vault.queryVotePower(alice, block.number - 1, ""); + vault.updateVotingPower(alice); + uint256 votingPowerHalfway = vault.queryVotePower(alice, block.number, ""); uint256 expectedAllocation = (amount * 10 * vault.BONUS_MULTIPLIER()) / vault.ONE(); uint256 expectedBonusHalf = ((expectedAllocation - (amount * 10)) / 2); assertEq(votingPowerHalfway, amount * 10 + expectedBonusHalf, "Voting power incorrect halfway"); // Move to expiration vm.roll(vault.expiration()); - uint256 votingPowerAtExpiration = vault.queryVotePower(alice, block.number - 1, ""); + vault.updateVotingPower(alice); + uint256 votingPowerAtExpiration = vault.queryVotePower(alice, block.number, ""); assertEq(votingPowerAtExpiration, expectedAllocation, "Voting power incorrect at expiration"); } } From b4b6a63347aa394e641deadff648174408060630 Mon Sep 17 00:00:00 2001 From: Alex Towle Date: Mon, 24 Feb 2025 13:38:36 -1000 Subject: [PATCH 5/9] Simplified logic --- src/MigrationRewardsVault.sol | 4 ++-- src/MigrationVestingVault.sol | 18 +++++++----------- test/MigrationVestingVaultTest.t.sol | 7 +------ 3 files changed, 10 insertions(+), 19 deletions(-) diff --git a/src/MigrationRewardsVault.sol b/src/MigrationRewardsVault.sol index 45686ba..2bdc11a 100644 --- a/src/MigrationRewardsVault.sol +++ b/src/MigrationRewardsVault.sol @@ -39,7 +39,7 @@ contract MigrationRewardsVault is AbstractVestingVault { uint256 public constant ONE = 1e18; /// @notice The conversion rate from ELFI to HD. - uint256 public constant CONVERSION_MULTIPLIER = 10e18; + uint256 public constant CONVERSION_MULTIPLIER = 10; /// @notice The bonus multiplier, representing a 5% APR over /// a three-month cliff period. For a 5% APR over 2 months (0.16 @@ -116,7 +116,7 @@ contract MigrationRewardsVault is AbstractVestingVault { } // Calculate the base HD amount from ELFI conversion. - uint256 baseHdAmount = (_amount * CONVERSION_MULTIPLIER) / ONE; + uint256 baseHdAmount = _amount * CONVERSION_MULTIPLIER; uint256 totalHdAmount; // Determine the total HD amount based on migration timing. diff --git a/src/MigrationVestingVault.sol b/src/MigrationVestingVault.sol index 1d53bcd..248f148 100644 --- a/src/MigrationVestingVault.sol +++ b/src/MigrationVestingVault.sol @@ -26,6 +26,10 @@ contract MigrationVestingVault is AbstractVestingVault { /// @dev Thrown when there are insufficient HD tokens. error InsufficientHDTokens(); + /// @notice The number of blocks between deploying the contract and the + /// expiration. + uint256 public constant EXPIRATION_DURATION = 91 days / 12; // ~3 months + /// @dev The HD treasury that is funding this migration contract. address public immutable hdTreasury; @@ -48,26 +52,18 @@ contract MigrationVestingVault is AbstractVestingVault { /// @param _elfiToken The ERC20 token to migrate from (ELFI token). /// @param _stale The stale block lag used in voting power calculations. /// @param _conversionMultiplier The conversion multiplier from ELFI to HD. - /// @param _startBlock The global start block for all grants. - /// @param _expiration The global expiration block for all grants. constructor( address _hdTreasury, IERC20 _hdToken, IERC20 _elfiToken, uint256 _stale, - uint256 _conversionMultiplier, - // FIXME: What if this is in the future? What if this is greater than - // the expiration? Adjust this so that these things aren't possible. The - // simplest solution is to switch to using constants for the time between - // the start block and the expiration. - uint256 _startBlock, - uint256 _expiration + uint256 _conversionMultiplier ) AbstractVestingVault(_hdToken, _stale) { hdTreasury = _hdTreasury; elfiToken = _elfiToken; conversionMultiplier = _conversionMultiplier; - startBlock = _startBlock; - expiration = _expiration; + startBlock = block.number; + expiration = startBlock + EXPIRATION_DURATION; } /// @notice Migrates a specified amount of ELFI tokens into a vesting grant of HD tokens. diff --git a/test/MigrationVestingVaultTest.t.sol b/test/MigrationVestingVaultTest.t.sol index e77c07a..1205bfd 100644 --- a/test/MigrationVestingVaultTest.t.sol +++ b/test/MigrationVestingVaultTest.t.sol @@ -19,7 +19,6 @@ contract MigrationVestingVaultTest is Test { uint256 internal constant FORK_BLOCK = 19_000_000; uint256 internal constant STALE_BLOCKS = 100; uint256 internal constant CONVERSION_MULTIPLIER = 10; - uint256 internal constant VESTING_DURATION = 90 days; address internal constant ELFI_WHALE = 0x6De73946eab234F1EE61256F10067D713aF0e37A; /// @dev Contract instances @@ -58,16 +57,12 @@ contract MigrationVestingVaultTest is Test { ); // Deploy migration vault - uint256 startBlock = block.number; - uint256 expiration = block.number + (VESTING_DURATION / 12); vault = new MigrationVestingVault( deployer, IERC20(address(hdToken)), // Cast HDToken to IERC20 ELFI, STALE_BLOCKS, - CONVERSION_MULTIPLIER, - startBlock, - expiration + CONVERSION_MULTIPLIER ); vault.initialize(deployer, deployer); From 352dea5c35f1023777bda2e62b00d093f56f8191 Mon Sep 17 00:00:00 2001 From: Alex Towle Date: Mon, 24 Feb 2025 13:41:08 -1000 Subject: [PATCH 6/9] Made the conversion multiplier a constant --- src/MigrationVestingVault.sol | 13 ++++------ test/MigrationVestingVaultTest.t.sol | 38 +++++++++++++--------------- 2 files changed, 23 insertions(+), 28 deletions(-) diff --git a/src/MigrationVestingVault.sol b/src/MigrationVestingVault.sol index 248f148..3aa51e1 100644 --- a/src/MigrationVestingVault.sol +++ b/src/MigrationVestingVault.sol @@ -26,6 +26,9 @@ contract MigrationVestingVault is AbstractVestingVault { /// @dev Thrown when there are insufficient HD tokens. error InsufficientHDTokens(); + /// @notice The conversion rate from ELFI to HD. + uint256 public constant CONVERSION_MULTIPLIER = 10; + /// @notice The number of blocks between deploying the contract and the /// expiration. uint256 public constant EXPIRATION_DURATION = 91 days / 12; // ~3 months @@ -36,9 +39,6 @@ contract MigrationVestingVault is AbstractVestingVault { /// @dev The ELFI token to migrate from. IERC20 public immutable elfiToken; - /// @dev The conversion rate from ELFI to HD. - uint256 public immutable conversionMultiplier; - /// @dev The global start block at which all grants start vesting. uint256 public immutable startBlock; @@ -51,17 +51,14 @@ contract MigrationVestingVault is AbstractVestingVault { /// @param _hdToken The ERC20 token to be vested (HD token). /// @param _elfiToken The ERC20 token to migrate from (ELFI token). /// @param _stale The stale block lag used in voting power calculations. - /// @param _conversionMultiplier The conversion multiplier from ELFI to HD. constructor( address _hdTreasury, IERC20 _hdToken, IERC20 _elfiToken, - uint256 _stale, - uint256 _conversionMultiplier + uint256 _stale ) AbstractVestingVault(_hdToken, _stale) { hdTreasury = _hdTreasury; elfiToken = _elfiToken; - conversionMultiplier = _conversionMultiplier; startBlock = block.number; expiration = startBlock + EXPIRATION_DURATION; } @@ -84,7 +81,7 @@ contract MigrationVestingVault is AbstractVestingVault { } // Calculate the HD token amount to be granted. - uint256 hdAmount = amount * conversionMultiplier; + uint256 hdAmount = amount * CONVERSION_MULTIPLIER; // Pull the HD tokens from the source. if (!token.transferFrom(hdTreasury, address(this), hdAmount)) { diff --git a/test/MigrationVestingVaultTest.t.sol b/test/MigrationVestingVaultTest.t.sol index 1205bfd..d064a0d 100644 --- a/test/MigrationVestingVaultTest.t.sol +++ b/test/MigrationVestingVaultTest.t.sol @@ -18,7 +18,6 @@ contract MigrationVestingVaultTest is Test { /// @dev Constants for mainnet contracts and configuration uint256 internal constant FORK_BLOCK = 19_000_000; uint256 internal constant STALE_BLOCKS = 100; - uint256 internal constant CONVERSION_MULTIPLIER = 10; address internal constant ELFI_WHALE = 0x6De73946eab234F1EE61256F10067D713aF0e37A; /// @dev Contract instances @@ -61,8 +60,7 @@ contract MigrationVestingVaultTest is Test { deployer, IERC20(address(hdToken)), // Cast HDToken to IERC20 ELFI, - STALE_BLOCKS, - CONVERSION_MULTIPLIER + STALE_BLOCKS ); vault.initialize(deployer, deployer); @@ -117,7 +115,7 @@ contract MigrationVestingVaultTest is Test { /// @dev Ensures migration fails when insufficient HD tokens are available function test_migrate_failure_insufficientHDTokens() external { - uint256 excessiveAmount = hdToken.allowance(deployer, address(vault)) / vault.conversionMultiplier() + 1; + uint256 excessiveAmount = hdToken.allowance(deployer, address(vault)) / vault.CONVERSION_MULTIPLIER() + 1; vm.startPrank(alice); ELFI.approve(address(vault), excessiveAmount); @@ -147,7 +145,7 @@ contract MigrationVestingVaultTest is Test { // Ensure that the grant was properly configured. VestingVaultStorage.Grant memory grant = vault.getGrant(bob); - assertEq(grant.allocation, amount * CONVERSION_MULTIPLIER, "Wrong allocation"); + assertEq(grant.allocation, amount * vault.CONVERSION_MULTIPLIER(), "Wrong allocation"); assertEq(grant.withdrawn, 0, "Should not have withdrawals"); assertEq(grant.cliff, grant.created, "Cliff should equal creation block"); assertEq(grant.expiration, vault.expiration(), "Wrong expiration"); @@ -180,8 +178,8 @@ contract MigrationVestingVaultTest is Test { uint256 bobHDBalanceAfter = hdToken.balanceOf(address(bob)); uint256 vaultHDBalanceAfter = hdToken.balanceOf(address(vault)); uint256 votingPowerAfter = vault.queryVotePower(bob, block.number, ""); - assertEq(bobHDBalanceAfter, bobHDBalanceBefore + amount * CONVERSION_MULTIPLIER / 2, "Bob HD balance not updated"); - assertEq(vaultHDBalanceAfter, vaultHDBalanceBefore - amount * CONVERSION_MULTIPLIER / 2, "Bob HD balance not updated"); + assertEq(bobHDBalanceAfter, bobHDBalanceBefore + amount * vault.CONVERSION_MULTIPLIER() / 2, "Bob HD balance not updated"); + assertEq(vaultHDBalanceAfter, vaultHDBalanceBefore - amount * vault.CONVERSION_MULTIPLIER() / 2, "Bob HD balance not updated"); assertEq(votingPowerAfter, votingPowerBefore / 2, "Voting power not updated"); // The other half of the three months passes. @@ -194,7 +192,7 @@ contract MigrationVestingVaultTest is Test { bobHDBalanceAfter = hdToken.balanceOf(address(bob)); vaultHDBalanceAfter = hdToken.balanceOf(address(vault)); votingPowerAfter = vault.queryVotePower(bob, block.number, ""); - assertEq(bobHDBalanceAfter, amount * CONVERSION_MULTIPLIER, "Bob HD balance not updated"); + assertEq(bobHDBalanceAfter, amount * vault.CONVERSION_MULTIPLIER(), "Bob HD balance not updated"); assertEq(vaultHDBalanceAfter, 0, "Bob HD balance not updated"); assertEq(votingPowerAfter, 0, "Voting power not updated"); } @@ -221,7 +219,7 @@ contract MigrationVestingVaultTest is Test { vault.migrate(amount, bob); vm.stopPrank(); VestingVaultStorage.Grant memory grant = vault.getGrant(bob); - assertEq(grant.allocation, amount * CONVERSION_MULTIPLIER, "Wrong allocation"); + assertEq(grant.allocation, amount * vault.CONVERSION_MULTIPLIER(), "Wrong allocation"); assertEq(grant.withdrawn, 0, "Should not have withdrawals"); assertEq(grant.cliff, grant.created, "Cliff should equal creation block"); assertEq(grant.expiration, vault.expiration(), "Wrong expiration"); @@ -251,8 +249,8 @@ contract MigrationVestingVaultTest is Test { uint256 bobHDBalanceAfter = hdToken.balanceOf(address(bob)); uint256 vaultHDBalanceAfter = hdToken.balanceOf(address(vault)); uint256 votingPowerAfter = vault.queryVotePower(bob, block.number, ""); - assertEq(bobHDBalanceAfter, bobHDBalanceBefore + amount * CONVERSION_MULTIPLIER / 2, "Bob HD balance not updated"); - assertEq(vaultHDBalanceAfter, vaultHDBalanceBefore - amount * CONVERSION_MULTIPLIER / 2, "Bob HD balance not updated"); + assertEq(bobHDBalanceAfter, bobHDBalanceBefore + amount * vault.CONVERSION_MULTIPLIER() / 2, "Bob HD balance not updated"); + assertEq(vaultHDBalanceAfter, vaultHDBalanceBefore - amount * vault.CONVERSION_MULTIPLIER() / 2, "Bob HD balance not updated"); assertEq(votingPowerAfter, votingPowerBefore / 2, "Voting power not updated"); // The other half of the three months passes. @@ -265,7 +263,7 @@ contract MigrationVestingVaultTest is Test { bobHDBalanceAfter = hdToken.balanceOf(address(bob)); vaultHDBalanceAfter = hdToken.balanceOf(address(vault)); votingPowerAfter = vault.queryVotePower(bob, block.number, ""); - assertEq(bobHDBalanceAfter, amount * CONVERSION_MULTIPLIER, "Bob HD balance not updated"); + assertEq(bobHDBalanceAfter, amount * vault.CONVERSION_MULTIPLIER(), "Bob HD balance not updated"); assertEq(vaultHDBalanceAfter, 0, "Bob HD balance not updated"); assertEq(votingPowerAfter, 0, "Voting power not updated"); } @@ -291,7 +289,7 @@ contract MigrationVestingVaultTest is Test { vault.migrate(amount, bob); vm.stopPrank(); VestingVaultStorage.Grant memory grant = vault.getGrant(bob); - assertEq(grant.allocation, amount * CONVERSION_MULTIPLIER, "Wrong allocation"); + assertEq(grant.allocation, amount * vault.CONVERSION_MULTIPLIER(), "Wrong allocation"); assertEq(grant.withdrawn, 0, "Should not have withdrawals"); assertEq(grant.cliff, grant.created, "Cliff should equal creation block"); assertEq(grant.expiration, vault.expiration(), "Wrong expiration"); @@ -320,8 +318,8 @@ contract MigrationVestingVaultTest is Test { uint256 bobHDBalanceAfter = hdToken.balanceOf(address(bob)); uint256 vaultHDBalanceAfter = hdToken.balanceOf(address(vault)); uint256 votingPowerAfter = vault.queryVotePower(bob, block.number, ""); - assertEq(bobHDBalanceAfter, bobHDBalanceBefore + amount * CONVERSION_MULTIPLIER, "Bob HD balance not updated"); - assertEq(vaultHDBalanceAfter, vaultHDBalanceBefore - amount * CONVERSION_MULTIPLIER, "Bob HD balance not updated"); + assertEq(bobHDBalanceAfter, bobHDBalanceBefore + amount * vault.CONVERSION_MULTIPLIER(), "Bob HD balance not updated"); + assertEq(vaultHDBalanceAfter, vaultHDBalanceBefore - amount * vault.CONVERSION_MULTIPLIER(), "Bob HD balance not updated"); assertEq(votingPowerAfter, 0, "Voting power not updated"); } @@ -345,7 +343,7 @@ contract MigrationVestingVaultTest is Test { uint256 votingPower = vault.queryVotePower(alice, block.number - 1, ""); assertEq( votingPower, - amount * CONVERSION_MULTIPLIER, + amount * vault.CONVERSION_MULTIPLIER(), "Incorrect voting power" ); } @@ -361,9 +359,9 @@ contract MigrationVestingVaultTest is Test { // Delegate to bob vm.expectEmit(true, true, true, true); - emit VoteChange(alice, alice, -int256(uint256(amount * CONVERSION_MULTIPLIER))); + emit VoteChange(alice, alice, -int256(uint256(amount * vault.CONVERSION_MULTIPLIER()))); vm.expectEmit(true, true, true, true); - emit VoteChange(bob, alice, int256(uint256(amount * CONVERSION_MULTIPLIER))); + emit VoteChange(bob, alice, int256(uint256(amount * vault.CONVERSION_MULTIPLIER()))); vault.delegate(bob); vm.stopPrank(); @@ -377,7 +375,7 @@ contract MigrationVestingVaultTest is Test { assertEq(aliceVotingPower, 0, "Alice should have no voting power"); assertEq( bobVotingPower, - amount * CONVERSION_MULTIPLIER, + amount * vault.CONVERSION_MULTIPLIER(), "Bob should have Alice's voting power" ); } @@ -399,7 +397,7 @@ contract MigrationVestingVaultTest is Test { uint256 votingPower = vault.queryVotePower(alice, block.number - 1, ""); assertEq( votingPower, - amount * CONVERSION_MULTIPLIER, + amount * vault.CONVERSION_MULTIPLIER(), "Voting power should remain constant" ); } From b3b07da90b29a33368558e3cbce5fe189f5803db Mon Sep 17 00:00:00 2001 From: Alex Towle Date: Mon, 24 Feb 2025 14:02:04 -1000 Subject: [PATCH 7/9] Added validation for the amount and destination --- src/MigrationRewardsVault.sol | 16 +++++++++++++ src/MigrationVestingVault.sol | 36 ++++++++++++++++++++-------- test/MigrationRewardsVaultTest.t.sol | 29 ++++++++++++++++++++++ test/MigrationVestingVaultTest.t.sol | 29 ++++++++++++++++++++++ 4 files changed, 100 insertions(+), 10 deletions(-) diff --git a/src/MigrationRewardsVault.sol b/src/MigrationRewardsVault.sol index 2bdc11a..e7f5c3c 100644 --- a/src/MigrationRewardsVault.sol +++ b/src/MigrationRewardsVault.sol @@ -26,6 +26,12 @@ contract MigrationRewardsVault is AbstractVestingVault { /// @notice Thrown when there are insufficient HD tokens. error InsufficientHDTokens(); + /// @notice Thrown when the destination is zero. + error InvalidDestination(); + + /// @notice Thrown when the migration amount is zero. + error InvalidMigrationAmount(); + /// @notice Thrown when no tokens are withdrawable during a claim attempt. error NothingToClaim(); @@ -104,6 +110,16 @@ contract MigrationRewardsVault is AbstractVestingVault { /// @param _amount The amount of ELFI tokens to migrate. /// @param _destination The address to receive the HD token grant. function migrate(uint256 _amount, address _destination) external { + // If the amount is zero, we shouldn't proceed with the migration. + if (_amount == 0) { + revert InvalidMigrationAmount(); + } + + // If the destination is zero, we shouldn't proceed with the migration. + if (_destination == address(0)) { + revert InvalidDestination(); + } + // Prevent duplicate grants at the destination. VestingVaultStorage.Grant storage existingGrant = _grants()[_destination]; if (existingGrant.allocation != 0) { diff --git a/src/MigrationVestingVault.sol b/src/MigrationVestingVault.sol index 3aa51e1..916d27a 100644 --- a/src/MigrationVestingVault.sol +++ b/src/MigrationVestingVault.sol @@ -26,6 +26,12 @@ contract MigrationVestingVault is AbstractVestingVault { /// @dev Thrown when there are insufficient HD tokens. error InsufficientHDTokens(); + /// @notice Thrown when the destination is zero. + error InvalidDestination(); + + /// @notice Thrown when the migration amount is zero. + error InvalidMigrationAmount(); + /// @notice The conversion rate from ELFI to HD. uint256 public constant CONVERSION_MULTIPLIER = 10; @@ -66,22 +72,32 @@ contract MigrationVestingVault is AbstractVestingVault { /// @notice Migrates a specified amount of ELFI tokens into a vesting grant of HD tokens. /// @dev The caller must have approved this contract for the ELFI token amount. /// The destination address must not have an existing grant. - /// @param amount The number of tokens to migrate (in ELFI units). - /// @param destination The address at which the vesting grant will be created. - function migrate(uint256 amount, address destination) external { + /// @param _amount The number of tokens to migrate (in ELFI units). + /// @param _destination The address at which the vesting grant will be created. + function migrate(uint256 _amount, address _destination) external { + // If the amount is zero, we shouldn't proceed with the migration. + if (_amount == 0) { + revert InvalidMigrationAmount(); + } + + // If the destination is zero, we shouldn't proceed with the migration. + if (_destination == address(0)) { + revert InvalidDestination(); + } + // Ensure the destination does not already have an active grant. - VestingVaultStorage.Grant storage existingGrant = _grants()[destination]; + VestingVaultStorage.Grant storage existingGrant = _grants()[_destination]; if (existingGrant.allocation != 0) { revert ExistingGrantFound(); } // Transfer ELFI tokens from the caller to this contract. - if (!elfiToken.transferFrom(msg.sender, address(this), amount)) { + if (!elfiToken.transferFrom(msg.sender, address(this), _amount)) { revert ElfiTransferFailed(); } // Calculate the HD token amount to be granted. - uint256 hdAmount = amount * CONVERSION_MULTIPLIER; + uint256 hdAmount = _amount * CONVERSION_MULTIPLIER; // Pull the HD tokens from the source. if (!token.transferFrom(hdTreasury, address(this), hdAmount)) { @@ -93,20 +109,20 @@ contract MigrationVestingVault is AbstractVestingVault { uint128 initialVotingPower = uint128((hdAmount * uint128(unvestedMultiplier.data)) / 100); // Create the grant at the destination address. - _grants()[destination] = VestingVaultStorage.Grant({ + _grants()[_destination] = VestingVaultStorage.Grant({ allocation: uint128(hdAmount), withdrawn: 0, created: uint128(startBlock), expiration: uint128(expiration), cliff: uint128(startBlock), // vesting starts immediately latestVotingPower: initialVotingPower, - delegatee: destination, + delegatee: _destination, range: [uint256(0), uint256(0)] }); // Update the destination's voting power. History.HistoricalBalances memory votingPower = History.load("votingPower"); - votingPower.push(destination, initialVotingPower); - emit VoteChange(destination, destination, int256(uint256(initialVotingPower))); + votingPower.push(_destination, initialVotingPower); + emit VoteChange(_destination, _destination, int256(uint256(initialVotingPower))); } } diff --git a/test/MigrationRewardsVaultTest.t.sol b/test/MigrationRewardsVaultTest.t.sol index 21f418b..3403f3e 100644 --- a/test/MigrationRewardsVaultTest.t.sol +++ b/test/MigrationRewardsVaultTest.t.sol @@ -86,6 +86,35 @@ contract MigrationRewardsVaultTest is Test { // Migration Tests // ============================== + /// @notice Tests that migration fails with zero migration amount. + function test_migrate_failure_zeroAmount() external { + // Set up Alice to attempt a migration with zero amount + vm.startPrank(alice); + ELFI.approve(address(vault), 1e18); // Approve some amount even though we'll send 0 + + // Expect the specific error for zero amount + vm.expectRevert(MigrationRewardsVault.InvalidMigrationAmount.selector); + + // Attempt migration with zero amount + vault.migrate(0, bob); + vm.stopPrank(); + } + + /// @notice Tests that migration fails with zero address destination. + function test_migrate_failure_zeroAddressDestination() external { + // Set up Alice to attempt a migration to the zero address + uint256 amount = 100e18; + vm.startPrank(alice); + ELFI.approve(address(vault), amount); + + // Expect the specific error for zero address destination + vm.expectRevert(MigrationRewardsVault.InvalidDestination.selector); + + // Attempt migration to address(0) + vault.migrate(amount, address(0)); + vm.stopPrank(); + } + /// @notice Tests that migration fails if the destination already has a grant. function test_migrate_failure_existingGrant() external { uint256 amount = 100e18; diff --git a/test/MigrationVestingVaultTest.t.sol b/test/MigrationVestingVaultTest.t.sol index d064a0d..b536574 100644 --- a/test/MigrationVestingVaultTest.t.sol +++ b/test/MigrationVestingVaultTest.t.sol @@ -88,6 +88,35 @@ contract MigrationVestingVaultTest is Test { // Migration Tests // ============================== + /// @notice Tests that migration fails with zero migration amount. + function test_migrate_failure_zeroAmount() external { + // Set up Alice to attempt a migration with zero amount + vm.startPrank(alice); + ELFI.approve(address(vault), 1e18); // Approve some amount even though we'll send 0 + + // Expect the specific error for zero amount + vm.expectRevert(MigrationVestingVault.InvalidMigrationAmount.selector); + + // Attempt migration with zero amount + vault.migrate(0, bob); + vm.stopPrank(); + } + + /// @notice Tests that migration fails with zero address destination. + function test_migrate_failure_zeroAddressDestination() external { + // Set up Alice to attempt a migration to the zero address + uint256 amount = 100e18; + vm.startPrank(alice); + ELFI.approve(address(vault), amount); + + // Expect the specific error for zero address destination + vm.expectRevert(MigrationVestingVault.InvalidDestination.selector); + + // Attempt migration to address(0) + vault.migrate(amount, address(0)); + vm.stopPrank(); + } + /// @dev Ensures migration fails when destination already has a grant. function test_migrate_failure_existingGrant() external { // First migration From bf406bfd42dad77ab8450b7b8f18421970dee7ab Mon Sep 17 00:00:00 2001 From: Alex Towle Date: Tue, 25 Feb 2025 10:17:08 -1000 Subject: [PATCH 8/9] Addressed @jrhea's review feedback --- test/MigrationRewardsVaultTest.t.sol | 160 ++++++++++++++++++++++++++- 1 file changed, 155 insertions(+), 5 deletions(-) diff --git a/test/MigrationRewardsVaultTest.t.sol b/test/MigrationRewardsVaultTest.t.sol index 3403f3e..44736e2 100644 --- a/test/MigrationRewardsVaultTest.t.sol +++ b/test/MigrationRewardsVaultTest.t.sol @@ -147,8 +147,52 @@ contract MigrationRewardsVaultTest is Test { vm.stopPrank(); } + /// @notice Tests claiming fails before the cliff. + function test_migrate_preCliff_and_claim_preCliff_failure() external { + uint256 amount = 100e18; + + // Record initial states + uint256 vaultElfiBalanceBefore = ELFI.balanceOf(address(vault)); + uint256 aliceElfiBalanceBefore = ELFI.balanceOf(alice); + uint256 vaultHdBalanceBefore = hdToken.balanceOf(address(vault)); + uint256 bobHdBalanceBefore = hdToken.balanceOf(bob); + + // Alice migrates ELFI to Bob (pre-cliff) + vm.startPrank(alice); + ELFI.approve(address(vault), amount); + vm.expectEmit(true, true, true, true); + emit Transfer(alice, address(vault), amount); + vault.migrate(amount, bob); + vm.stopPrank(); + + // Verify grant configuration + uint256 treasuryHdBalanceBefore = hdToken.balanceOf(vault.hdTreasury()); + VestingVaultStorage.Grant memory grant = vault.getGrant(bob); + uint256 expectedAllocation = (amount * 10 * vault.BONUS_MULTIPLIER()) / vault.ONE(); + assertEq(grant.allocation, expectedAllocation, "Wrong allocation"); + assertEq(grant.withdrawn, 0, "Should not have withdrawals"); + assertEq(grant.created, block.number, "Wrong creation block"); + assertEq(grant.cliff, vault.cliff(), "Wrong cliff"); + assertEq(grant.expiration, vault.expiration(), "Wrong expiration"); + assertEq(grant.delegatee, bob, "Wrong delegatee"); + + // Verify token transfers + assertEq(ELFI.balanceOf(address(vault)), vaultElfiBalanceBefore + amount, "Vault ELFI balance not updated"); + assertEq(ELFI.balanceOf(alice), aliceElfiBalanceBefore - amount, "Alice ELFI balance not updated"); + assertEq(hdToken.balanceOf(address(vault)), vaultHdBalanceBefore + expectedAllocation, "Vault HD balance not updated"); + + // Move to halfway between the start block and the cliff. + uint256 halfwayBlock = (vault.startBlock() + vault.cliff()) / 2; + vm.roll(halfwayBlock); + + // Bob attempts to claim. This should fail + vm.startPrank(bob); + vm.expectRevert(MigrationRewardsVault.NothingToClaim.selector); + vault.claim(); + } + /// @notice Tests successful migration and claiming for a pre-cliff migrator. - function test_migrate_and_claim_preCliff() external { + function test_migrate_preCliff_and_claim_preExpiration() external { uint256 amount = 100e18; // Record initial states @@ -205,8 +249,63 @@ contract MigrationRewardsVaultTest is Test { assertEq(grant.allocation, 0, "Grant should be deleted"); } + /// @notice Tests successful migration pre cliff and claiming post expiration. + function test_migrate_preCliff_and_claim_postExpiration() external { + uint256 amount = 100e18; + + // Record initial states + uint256 vaultElfiBalanceBefore = ELFI.balanceOf(address(vault)); + uint256 aliceElfiBalanceBefore = ELFI.balanceOf(alice); + uint256 vaultHdBalanceBefore = hdToken.balanceOf(address(vault)); + uint256 bobHdBalanceBefore = hdToken.balanceOf(bob); + + // Alice migrates ELFI to Bob (pre-cliff) + vm.startPrank(alice); + ELFI.approve(address(vault), amount); + vm.expectEmit(true, true, true, true); + emit Transfer(alice, address(vault), amount); + vault.migrate(amount, bob); + vm.stopPrank(); + + // Verify grant configuration + uint256 treasuryHdBalanceBefore = hdToken.balanceOf(vault.hdTreasury()); + VestingVaultStorage.Grant memory grant = vault.getGrant(bob); + uint256 expectedAllocation = (amount * 10 * vault.BONUS_MULTIPLIER()) / vault.ONE(); + assertEq(grant.allocation, expectedAllocation, "Wrong allocation"); + assertEq(grant.withdrawn, 0, "Should not have withdrawals"); + assertEq(grant.created, block.number, "Wrong creation block"); + assertEq(grant.cliff, vault.cliff(), "Wrong cliff"); + assertEq(grant.expiration, vault.expiration(), "Wrong expiration"); + assertEq(grant.delegatee, bob, "Wrong delegatee"); + + // Verify token transfers + assertEq(ELFI.balanceOf(address(vault)), vaultElfiBalanceBefore + amount, "Vault ELFI balance not updated"); + assertEq(ELFI.balanceOf(alice), aliceElfiBalanceBefore - amount, "Alice ELFI balance not updated"); + assertEq(hdToken.balanceOf(address(vault)), vaultHdBalanceBefore + expectedAllocation, "Vault HD balance not updated"); + + // Move to expiration + vm.roll(vault.expiration()); + + // Bob claims + vm.startPrank(bob); + vault.claim(); + + // Verify claim outcomes + uint256 bobHdBalanceAfter = hdToken.balanceOf(bob); + uint256 vaultHdBalanceAfter = hdToken.balanceOf(address(vault)); + uint256 votingPowerAfter = vault.queryVotePower(bob, block.number, ""); + assertEq(bobHdBalanceAfter, bobHdBalanceBefore + expectedAllocation, "Bob HD balance incorrect"); + assertEq(vaultHdBalanceAfter, 0, "Vault HD balance incorrect"); + assertEq(votingPowerAfter, 0, "Voting power should be zero after claim"); + assertEq(hdToken.balanceOf(vault.hdTreasury()), treasuryHdBalanceBefore, "Treasury should receive unvested bonus"); + + // Verify grant is deleted + grant = vault.getGrant(bob); + assertEq(grant.allocation, 0, "Grant should be deleted"); + } + /// @notice Tests migration and immediate partial claim for a post-cliff migrator. - function test_migrate_and_claim_postCliff_halfway() external { + function test_migrate_postCliff_and_claim_preExpiration() external { uint256 amount = 100e18; // Move to halfway between cliff and expiration @@ -215,7 +314,6 @@ contract MigrationRewardsVaultTest is Test { // Record initial states uint256 bobHdBalanceBefore = hdToken.balanceOf(bob); - uint256 treasuryHdBalanceBefore = hdToken.balanceOf(vault.hdTreasury()); // Alice migrates ELFI to Bob (post-cliff) vm.startPrank(alice); @@ -224,6 +322,7 @@ contract MigrationRewardsVaultTest is Test { vm.stopPrank(); // Verify grant configuration + uint256 treasuryHdBalanceBefore = hdToken.balanceOf(vault.hdTreasury()); VestingVaultStorage.Grant memory grant = vault.getGrant(bob); uint256 blocksRemaining = vault.expiration() - halfwayBlock; uint256 bonusPeriod = vault.expiration() - vault.cliff(); @@ -244,11 +343,54 @@ contract MigrationRewardsVaultTest is Test { assertEq(bobHdBalanceAfter, bobHdBalanceBefore + expectedBase, "Bob HD balance incorrect"); assertEq(vaultHdBalanceAfter, 0, "Vault HD balance incorrect"); assertEq(votingPowerAfter, 0, "Voting power should be zero after claim"); - assertEq(hdToken.balanceOf(vault.hdTreasury()), treasuryHdBalanceBefore - expectedBase, "Treasury balance incorrect"); + assertEq(hdToken.balanceOf(vault.hdTreasury()), treasuryHdBalanceBefore + (expectedAllocation - expectedBase), "Treasury balance incorrect"); + } + + /// @notice Tests migration post cliff and claiming post expiration. + function test_migrate_postCliff_and_claim_postExpiration() external { + uint256 amount = 100e18; + + // Move to halfway between cliff and expiration + uint256 halfwayBlock = vault.cliff() + (vault.expiration() - vault.cliff()) / 2; + vm.roll(halfwayBlock); + + // Record initial states + uint256 bobHdBalanceBefore = hdToken.balanceOf(bob); + + // Alice migrates ELFI to Bob (post-cliff) + vm.startPrank(alice); + ELFI.approve(address(vault), amount); + vault.migrate(amount, bob); + vm.stopPrank(); + + // Verify grant configuration + uint256 treasuryHdBalanceBefore = hdToken.balanceOf(vault.hdTreasury()); + VestingVaultStorage.Grant memory grant = vault.getGrant(bob); + uint256 blocksRemaining = vault.expiration() - halfwayBlock; + uint256 bonusPeriod = vault.expiration() - vault.cliff(); + uint256 bonusFactor = vault.ONE() + ((vault.BONUS_MULTIPLIER() - vault.ONE()) * blocksRemaining) / bonusPeriod; + uint256 expectedBase = amount * 10; // CONVERSION_MULTIPLIER = 10e18 + uint256 expectedAllocation = (expectedBase * bonusFactor) / vault.ONE(); + assertEq(grant.allocation, expectedAllocation, "Wrong allocation"); + assertEq(grant.created, halfwayBlock, "Wrong creation block"); + + // Bob claims after expiration + vm.roll(vault.expiration() + 1_000e18); + vm.startPrank(bob); + vault.claim(); + + // Verify claim outcomes + uint256 bobHdBalanceAfter = hdToken.balanceOf(bob); + uint256 vaultHdBalanceAfter = hdToken.balanceOf(address(vault)); + uint256 votingPowerAfter = vault.queryVotePower(bob, block.number, ""); + assertEq(bobHdBalanceAfter, bobHdBalanceBefore + expectedAllocation, "Bob HD balance incorrect"); + assertEq(vaultHdBalanceAfter, 0, "Vault HD balance incorrect"); + assertEq(votingPowerAfter, 0, "Voting power should be zero after claim"); + assertEq(hdToken.balanceOf(vault.hdTreasury()), treasuryHdBalanceBefore, "Treasury balance incorrect"); } /// @notice Tests migration and full claim after expiration. - function test_migrate_and_claim_postExpiration() external { + function test_migrate_postExpiration_and_claim_postExpiration() external { uint256 amount = 100e18; // Move past expiration @@ -284,6 +426,14 @@ contract MigrationRewardsVaultTest is Test { assertEq(hdToken.balanceOf(vault.hdTreasury()), treasuryHdBalanceBefore, "Treasury should receive no bonus post-expiration"); } + /// @notice Tests claiming without migrating. This should fail. + function test_claim_without_migrating_failure() external { + // Bob attempts to claim tokens, but there is nothing to claim. + vm.startPrank(bob); + vm.expectRevert(MigrationRewardsVault.NothingToClaim.selector); + vault.claim(); + } + // ============================== // Voting Power Tests // ============================== From c5588727456f49a422d79a5a52a7951b2bd73353 Mon Sep 17 00:00:00 2001 From: Alex Towle Date: Tue, 25 Feb 2025 11:31:35 -1000 Subject: [PATCH 9/9] Addressed @jrhea's final review feedback --- test/MigrationRewardsVaultTest.t.sol | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/test/MigrationRewardsVaultTest.t.sol b/test/MigrationRewardsVaultTest.t.sol index 44736e2..9e8c2f8 100644 --- a/test/MigrationRewardsVaultTest.t.sol +++ b/test/MigrationRewardsVaultTest.t.sol @@ -284,7 +284,7 @@ contract MigrationRewardsVaultTest is Test { assertEq(hdToken.balanceOf(address(vault)), vaultHdBalanceBefore + expectedAllocation, "Vault HD balance not updated"); // Move to expiration - vm.roll(vault.expiration()); + vm.roll(vault.expiration() + 1_000); // Bob claims vm.startPrank(bob); @@ -344,6 +344,10 @@ contract MigrationRewardsVaultTest is Test { assertEq(vaultHdBalanceAfter, 0, "Vault HD balance incorrect"); assertEq(votingPowerAfter, 0, "Voting power should be zero after claim"); assertEq(hdToken.balanceOf(vault.hdTreasury()), treasuryHdBalanceBefore + (expectedAllocation - expectedBase), "Treasury balance incorrect"); + + // Verify grant is deleted + grant = vault.getGrant(bob); + assertEq(grant.allocation, 0, "Grant should be deleted"); } /// @notice Tests migration post cliff and claiming post expiration. @@ -375,7 +379,7 @@ contract MigrationRewardsVaultTest is Test { assertEq(grant.created, halfwayBlock, "Wrong creation block"); // Bob claims after expiration - vm.roll(vault.expiration() + 1_000e18); + vm.roll(vault.expiration() + 1_000); vm.startPrank(bob); vault.claim(); @@ -387,6 +391,10 @@ contract MigrationRewardsVaultTest is Test { assertEq(vaultHdBalanceAfter, 0, "Vault HD balance incorrect"); assertEq(votingPowerAfter, 0, "Voting power should be zero after claim"); assertEq(hdToken.balanceOf(vault.hdTreasury()), treasuryHdBalanceBefore, "Treasury balance incorrect"); + + // Verify grant is deleted + grant = vault.getGrant(bob); + assertEq(grant.allocation, 0, "Grant should be deleted"); } /// @notice Tests migration and full claim after expiration. @@ -424,6 +432,10 @@ contract MigrationRewardsVaultTest is Test { assertEq(vaultHdBalanceAfter, vaultHdBalanceBefore, "Vault HD balance should not decrease beyond base"); assertEq(votingPowerAfter, 0, "Voting power should be zero after claim"); assertEq(hdToken.balanceOf(vault.hdTreasury()), treasuryHdBalanceBefore, "Treasury should receive no bonus post-expiration"); + + // Verify grant is deleted + grant = vault.getGrant(bob); + assertEq(grant.allocation, 0, "Grant should be deleted"); } /// @notice Tests claiming without migrating. This should fail.