diff --git a/.gitmodules b/.gitmodules index 690924b..20d19a9 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,6 @@ [submodule "lib/openzeppelin-contracts"] path = lib/openzeppelin-contracts url = https://github.com/OpenZeppelin/openzeppelin-contracts +[submodule "lib/council"] + path = lib/council + url = https://github.com/delvtech/council diff --git a/foundry.toml b/foundry.toml index 4c3f2a9..7d2fa25 100644 --- a/foundry.toml +++ b/foundry.toml @@ -4,5 +4,9 @@ out = "out" libs = ["lib"] remappings = [ + 'council/=lib/council/contracts', 'openzeppelin/=lib/openzeppelin-contracts/contracts', ] + +[rpc_endpoints] +mainnet = "${MAINNET_RPC_URL}" diff --git a/lib/council b/lib/council new file mode 160000 index 0000000..5f7be33 --- /dev/null +++ b/lib/council @@ -0,0 +1 @@ +Subproject commit 5f7be330b05f1c3bebd0176882cc5c3429f0764f diff --git a/src/MigrationVestingVault.sol b/src/MigrationVestingVault.sol new file mode 100644 index 0000000..2cca363 --- /dev/null +++ b/src/MigrationVestingVault.sol @@ -0,0 +1,115 @@ +// 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"; + +/// @title MigrationVestingVault +/// @notice A migration vault that converts ELFI tokens to HD tokens. Migrated +/// tokens are granted with a linear vesting schedule. The grant is +/// created at a destination address provided by the migrator. This +/// contract inherits full voting power tracking from +/// `AbstractVestingVault`. +contract MigrationVestingVault is AbstractVestingVault { + using History for History.HistoricalBalances; + + /// @dev Thrown when an existing grant is found. + error ExistingGrantFound(); + + /// @dev Thrown when ELFI transfers fail. + error ElfiTransferFailed(); + + /// @dev Thrown when there are insufficient HD tokens. + error InsufficientHDTokens(); + + /// @dev The HD treasury that is funding this migration contract. + address public immutable hdTreasury; + + /// @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; + + /// @dev The global expiration block at which all grants fully vest. + uint256 public immutable expiration; + + /// @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 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 { + // Ensure the destination does not already have an active grant. + 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 HD token amount to be granted. + uint256 hdAmount = amount * conversionMultiplier; + + // Pull the HD tokens from the source. + if (!token.transferFrom(hdTreasury, address(this), hdAmount)) { + revert InsufficientHDTokens(); + } + + // Calculate the initial voting power using the current unvested multiplier. + Storage.Uint256 memory unvestedMultiplier = _unvestedMultiplier(); + uint128 initialVotingPower = uint128((hdAmount * uint128(unvestedMultiplier.data)) / 100); + + // Create the grant at the destination address. + _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, + 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))); + } +} diff --git a/test/MigrationVestingVaultTest.t.sol b/test/MigrationVestingVaultTest.t.sol new file mode 100644 index 0000000..e77c07a --- /dev/null +++ b/test/MigrationVestingVaultTest.t.sol @@ -0,0 +1,411 @@ +// SPDX-License-Identifier: Apache-2.0 +pragma solidity 0.8.24; + +import { Test } from "forge-std/Test.sol"; +import { HDToken } from "../src/HDToken.sol"; +import { IERC20 } from "council/interfaces/IERC20.sol"; +import { CoreVoting } from "council/CoreVoting.sol"; +import { MigrationVestingVault } from "../src/MigrationVestingVault.sol"; +import { VestingVaultStorage } from "council/libraries/VestingVaultStorage.sol"; + +/// @dev This test suite provides coverage for the MigrationVestingVault contract's +/// functionality, including migration, voting power tracking, and delegation. +contract MigrationVestingVaultTest 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; + uint256 internal constant CONVERSION_MULTIPLIER = 10; + uint256 internal constant VESTING_DURATION = 90 days; + address internal constant ELFI_WHALE = 0x6De73946eab234F1EE61256F10067D713aF0e37A; + + /// @dev Contract instances + CoreVoting internal constant CORE_VOTING = CoreVoting(0xEaCD577C3F6c44C3ffA398baaD97aE12CDCFed4a); + IERC20 internal constant ELFI = IERC20(0x5c6D51ecBA4D8E4F20373e3ce96a62342B125D6d); + MigrationVestingVault 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 with the following: + /// 1. Fork mainnet at specified block + /// 2. Set up test accounts + /// 3. Deploy HDToken and MigrationVestingVault + /// 4. Configure CoreVoting with the new vault + 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 + 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 + ); + vault.initialize(deployer, deployer); + + // Add vault to CoreVoting + vm.startPrank(CORE_VOTING.owner()); + CORE_VOTING.changeVaultStatus(address(vault), true); + + // Set an approval on the vault to spend 1_000_000 HD tokens. + vm.startPrank(deployer); + hdToken.approve(address(vault), 1_000_000e18); + + // Fund the addresses with ELFI. + 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); + } + } + + // ============================== + // Migration Tests + // ============================== + + /// @dev Ensures migration fails when destination already has a grant. + function test_migrate_failure_existingGrant() external { + // First migration + uint256 amount = 100e18; + vm.startPrank(alice); + ELFI.approve(address(vault), amount); + vault.migrate(amount, alice); + + // Attempt second migration to same destination + vm.expectRevert(MigrationVestingVault.ExistingGrantFound.selector); + vault.migrate(amount, alice); + vm.stopPrank(); + } + + /// @dev Ensures migration fails when ELFI transfer fails. + function test_migrate_failure_transferFailed() external { + // Try transferring ELFI without setting an approval. This should + // fail. + uint256 amount = 100e18; + vm.startPrank(alice); + vm.expectRevert(); + vault.migrate(amount, alice); + vm.stopPrank(); + } + + /// @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; + + vm.startPrank(alice); + ELFI.approve(address(vault), excessiveAmount); + + vm.expectRevert(); + vault.migrate(excessiveAmount, alice); + vm.stopPrank(); + } + + /// @dev Ensures successful migration with correct grant creation. + function test_migrate_and_claim() external { + uint256 amount = 100e18; + + // Record initial states + uint256 vaultElfiBalanceBefore = ELFI.balanceOf(address(vault)); + uint256 aliceElfiBalanceBefore = ELFI.balanceOf(alice); + + + // Alice migrates some of her ELFI tokens. She sets Bob as the destination + // address. + 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(); + + // Ensure that the grant was properly configured. + VestingVaultStorage.Grant memory grant = vault.getGrant(bob); + assertEq(grant.allocation, amount * 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"); + 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" + ); + + // Half of the three months passes, and half of the HD tokens can be + // claimed by Bob. + uint256 bobHDBalanceBefore = hdToken.balanceOf(address(bob)); + uint256 vaultHDBalanceBefore = hdToken.balanceOf(address(vault)); + uint256 votingPowerBefore = vault.queryVotePower(bob, block.number, ""); + vm.startPrank(bob); + uint256 halfwayBlock = (block.number + vault.expiration()) / 2; + vm.roll(halfwayBlock); + vault.claim(); + + // Ensure that Bob received half of the HD grant and that his voting + // power was reduced by half. + 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(votingPowerAfter, votingPowerBefore / 2, "Voting power not updated"); + + // The other half of the three months passes. + vm.startPrank(bob); + vm.roll(vault.expiration()); + vault.claim(); + + // Ensure that Bob received the other half of the HD grant and that his + // voting power was reduced to zero. + 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(vaultHDBalanceAfter, 0, "Bob HD balance not updated"); + assertEq(votingPowerAfter, 0, "Voting power not updated"); + } + + /// @dev Ensures that a user can immediately claim half of their HD if they + /// migrate one and half months after the vault's time starts. + function test_migrate_and_claim_halfway_through() external { + uint256 amount = 100e18; + + // Record initial states + uint256 vaultElfiBalanceBefore = ELFI.balanceOf(address(vault)); + uint256 aliceElfiBalanceBefore = ELFI.balanceOf(alice); + + // Half of the three months passes and Alice migrates her ELFI. + uint256 halfwayBlock = (block.number + vault.expiration()) / 2; + vm.roll(halfwayBlock); + + // Alice migrates some of her ELFI tokens. She sets Bob as the destination + // address. + 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(); + VestingVaultStorage.Grant memory grant = vault.getGrant(bob); + assertEq(grant.allocation, amount * 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"); + 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" + ); + + // Bob can claim half of the tokens immediately. + uint256 bobHDBalanceBefore = hdToken.balanceOf(address(bob)); + uint256 vaultHDBalanceBefore = hdToken.balanceOf(address(vault)); + uint256 votingPowerBefore = vault.queryVotePower(bob, block.number, ""); + vm.startPrank(bob); + vault.claim(); + + // Ensure that Bob received half of the HD grant and that his voting + // power was reduced by half. + 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(votingPowerAfter, votingPowerBefore / 2, "Voting power not updated"); + + // The other half of the three months passes. + vm.startPrank(bob); + vm.roll(vault.expiration()); + vault.claim(); + + // Ensure that Bob received the other half of the HD grant and that his + // voting power was reduced to zero. + 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(vaultHDBalanceAfter, 0, "Bob HD balance not updated"); + assertEq(votingPowerAfter, 0, "Voting power not updated"); + } + + /// @dev Ensures that a user can immediately claim all of their HD if they + /// migrate four months after the vault's time starts. + function test_migrate_and_claim_after_expiration() external { + uint256 amount = 100e18; + + // Record initial states + uint256 vaultElfiBalanceBefore = ELFI.balanceOf(address(vault)); + uint256 aliceElfiBalanceBefore = ELFI.balanceOf(alice); + + // Four months passes and Alice migrates her ELFI. + vm.roll(vault.expiration()); + + // Alice migrates some of her ELFI tokens. She sets Bob as the destination + // address. + 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(); + VestingVaultStorage.Grant memory grant = vault.getGrant(bob); + assertEq(grant.allocation, amount * 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"); + 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" + ); + + // Bob can claim all of the tokens immediately. + uint256 bobHDBalanceBefore = hdToken.balanceOf(address(bob)); + uint256 vaultHDBalanceBefore = hdToken.balanceOf(address(vault)); + vm.startPrank(bob); + vault.claim(); + + // Ensure that Bob received all of the HD grant and that his voting + // power was reduced to zero. + 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(votingPowerAfter, 0, "Voting power not updated"); + } + + // ============================== + // Voting Power Tests + // ============================== + + /// @dev Ensures correct voting power calculation after migration + function test_votingPower_afterMigration() external { + uint256 amount = 100e18; + + vm.startPrank(alice); + ELFI.approve(address(vault), amount); + vault.migrate(amount, alice); + vm.stopPrank(); + + // Wait for power to be queryable (past stale blocks) + vm.roll(block.number + STALE_BLOCKS + 1); + + // Get voting power of alice at the last block number + uint256 votingPower = vault.queryVotePower(alice, block.number - 1, ""); + assertEq( + votingPower, + amount * CONVERSION_MULTIPLIER, + "Incorrect voting power" + ); + } + + /// @dev Tests voting power transfer through delegation + function test_votingPower_afterDelegation() external { + uint256 amount = 100e18; + + // Set up initial grant + vm.startPrank(alice); + ELFI.approve(address(vault), amount); + vault.migrate(amount, alice); + + // Delegate to bob + vm.expectEmit(true, true, true, true); + emit VoteChange(alice, alice, -int256(uint256(amount * CONVERSION_MULTIPLIER))); + vm.expectEmit(true, true, true, true); + emit VoteChange(bob, alice, int256(uint256(amount * CONVERSION_MULTIPLIER))); + vault.delegate(bob); + vm.stopPrank(); + + // Wait for power to be queryable + 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 * CONVERSION_MULTIPLIER, + "Bob should have Alice's voting power" + ); + } + + /// @dev Tests voting power changes through vesting progression + function test_votingPower_throughVesting() external { + uint256 amount = 100e18; + + vm.startPrank(alice); + ELFI.approve(address(vault), amount); + vault.migrate(amount, alice); + vm.stopPrank(); + + // Move to middle of vesting period + uint256 halfwayBlock = (block.number + vault.expiration()) / 2; + vm.roll(halfwayBlock); + + // Check voting power is maintained through vesting + uint256 votingPower = vault.queryVotePower(alice, block.number - 1, ""); + assertEq( + votingPower, + amount * CONVERSION_MULTIPLIER, + "Voting power should remain constant" + ); + } +}